@zapo-js/fake-server 1.1.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 (66) hide show
  1. package/README.md +89 -30
  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/FakeServerSession.d.ts +35 -2
  6. package/dist/api/FakeServerSession.js +65 -3
  7. package/dist/api/FakeWaServer.d.ts +73 -1
  8. package/dist/api/FakeWaServer.js +230 -4
  9. package/dist/api/ServerRegistries.d.ts +7 -0
  10. package/dist/api/ServerRegistries.js +20 -5
  11. package/dist/api/iq-handlers.d.ts +19 -1
  12. package/dist/api/iq-handlers.js +96 -1
  13. package/dist/esm/api/FakeMobilePrimary.js +102 -0
  14. package/dist/esm/api/FakePairingDriver.js +2 -4
  15. package/dist/esm/api/FakeServerSession.js +66 -4
  16. package/dist/esm/api/FakeWaServer.js +230 -4
  17. package/dist/esm/api/ServerRegistries.js +20 -5
  18. package/dist/esm/api/iq-handlers.js +96 -1
  19. package/dist/esm/index.js +8 -0
  20. package/dist/esm/infra/WaFakeConnection.js +8 -22
  21. package/dist/esm/infra/WaFakeConnectionPipeline.js +17 -5
  22. package/dist/esm/infra/WaFakeTcpServer.js +69 -0
  23. package/dist/esm/infra/WaFakeWsServer.js +2 -1
  24. package/dist/esm/infra/socket-adapters.js +79 -0
  25. package/dist/esm/protocol/auth/client-payload-validate.js +77 -1
  26. package/dist/esm/protocol/auth/pair-device.js +16 -1
  27. package/dist/esm/protocol/iq/companion-host.js +104 -0
  28. package/dist/esm/protocol/iq/link-code.js +90 -0
  29. package/dist/esm/protocol/iq/router.js +3 -3
  30. package/dist/esm/protocol/push/mobile-notification.js +97 -0
  31. package/dist/esm/state/fake-companion-host.js +158 -0
  32. package/dist/esm/transport/auth.js +7 -0
  33. package/dist/esm/transport/crypto.js +1 -1
  34. package/dist/esm/transport/util.js +1 -0
  35. package/dist/index.d.ts +17 -2
  36. package/dist/index.js +28 -1
  37. package/dist/infra/WaFakeConnection.d.ts +21 -3
  38. package/dist/infra/WaFakeConnection.js +8 -22
  39. package/dist/infra/WaFakeConnectionPipeline.d.ts +1 -0
  40. package/dist/infra/WaFakeConnectionPipeline.js +17 -5
  41. package/dist/infra/WaFakeTcpServer.d.ts +29 -0
  42. package/dist/infra/WaFakeTcpServer.js +73 -0
  43. package/dist/infra/WaFakeWsServer.js +2 -1
  44. package/dist/infra/socket-adapters.d.ts +18 -0
  45. package/dist/infra/socket-adapters.js +83 -0
  46. package/dist/protocol/auth/client-payload-validate.d.ts +24 -0
  47. package/dist/protocol/auth/client-payload-validate.js +77 -1
  48. package/dist/protocol/auth/pair-device.d.ts +15 -1
  49. package/dist/protocol/auth/pair-device.js +16 -0
  50. package/dist/protocol/iq/companion-host.d.ts +52 -0
  51. package/dist/protocol/iq/companion-host.js +109 -0
  52. package/dist/protocol/iq/link-code.d.ts +46 -0
  53. package/dist/protocol/iq/link-code.js +95 -0
  54. package/dist/protocol/iq/router.d.ts +15 -2
  55. package/dist/protocol/iq/router.js +3 -3
  56. package/dist/protocol/push/mobile-notification.d.ts +58 -0
  57. package/dist/protocol/push/mobile-notification.js +102 -0
  58. package/dist/state/fake-companion-host.d.ts +73 -0
  59. package/dist/state/fake-companion-host.js +163 -0
  60. package/dist/transport/auth.d.ts +8 -0
  61. package/dist/transport/auth.js +8 -0
  62. package/dist/transport/crypto.d.ts +1 -1
  63. package/dist/transport/crypto.js +2 -1
  64. package/dist/transport/util.d.ts +1 -0
  65. package/dist/transport/util.js +2 -1
  66. package/package.json +1 -1
@@ -1,9 +1,11 @@
1
1
  /** Per-session isolated server state: registries, prekeys, app-state, IQ routing, capture. */
2
2
  import { WaFakeIqRouter } from '../protocol/iq/router.js';
3
+ import { FakeCompanionHostState, readCompanionKeyIndex } from '../state/fake-companion-host.js';
3
4
  import { AppStateSyncManager } from './AppStateSyncManager.js';
4
5
  import { registerDefaultIqHandlers } from './iq-handlers.js';
5
6
  import { PreKeyDispenser } from './PreKeyDispenser.js';
6
- import { ServerRegistries } from './ServerRegistries.js';
7
+ import { ServerRegistries, toDeviceIdPart } from './ServerRegistries.js';
8
+ const HOST_DOMAIN = 's.whatsapp.net';
7
9
  /**
8
10
  * One isolated logical device's worth of server state. Every `WaClient`
9
11
  * connected to a `FakeWaServer` is bound to exactly one session; peers,
@@ -16,6 +18,7 @@ export class FakeServerSession {
16
18
  this.registries = new ServerRegistries();
17
19
  this.preKeyDispenser = new PreKeyDispenser();
18
20
  this.appStateSync = new AppStateSyncManager();
21
+ this.companionHost = new FakeCompanionHostState();
19
22
  this.iqRouter = new WaFakeIqRouter();
20
23
  this.capturedStanzas = [];
21
24
  this.pendingIqExpectations = new Set();
@@ -31,8 +34,8 @@ export class FakeServerSession {
31
34
  const handler = { matcher, respond, label };
32
35
  return this.iqRouter.register(handler, { priority: 'high' });
33
36
  }
34
- routeIq(iq) {
35
- return this.iqRouter.route(iq);
37
+ routeIq(iq, context) {
38
+ return this.iqRouter.route(iq, context);
36
39
  }
37
40
  handleCapturedStanza(node) {
38
41
  this.capturedStanzas.push(node);
@@ -148,6 +151,7 @@ export class FakeServerSession {
148
151
  const reg = this.registries;
149
152
  const preKey = this.preKeyDispenser;
150
153
  const appState = this.appStateSync;
154
+ const companionHost = this.companionHost;
151
155
  return {
152
156
  get peerRegistry() {
153
157
  return reg.peerRegistry;
@@ -215,9 +219,67 @@ export class FakeServerSession {
215
219
  get appStateCollectionProviders() {
216
220
  return appState.appStateCollectionProviders;
217
221
  },
218
- requireMediaHttpsInfo: () => host.requireMediaHttpsInfo()
222
+ requireMediaHttpsInfo: () => host.requireMediaHttpsInfo(),
223
+ get mobilePrimary() {
224
+ return companionHost.primary;
225
+ },
226
+ relayLinkCodeStage: (iq, context) => host.relayLinkCodeStage(iq, context),
227
+ linkCompanionDevice: (upload) => this.linkCompanionDevice(host, upload),
228
+ revokeCompanionDevices: (deviceJids) => this.revokeCompanionDevices(deviceJids),
229
+ recordKeyIndexList: (bytes, timestampSeconds) => companionHost.recordKeyIndexList(bytes, timestampSeconds)
219
230
  };
220
231
  }
232
+ /**
233
+ * Server side of the primary's `pair-device` upload: allocates the device
234
+ * slot, hands the signed identity to the companion connection, and records
235
+ * the link. Resolves `null` when this session has no primary or the ref
236
+ * belongs to no live connection, which the handler turns into an IQ error.
237
+ */
238
+ async linkCompanionDevice(host, upload) {
239
+ const primary = this.companionHost.primary;
240
+ if (!primary) {
241
+ return null;
242
+ }
243
+ const deviceId = this.companionHost.allocateDeviceId();
244
+ const deviceJid = `${primary.username}:${deviceId}@${HOST_DOMAIN}`;
245
+ const completed = await host
246
+ .completeCompanionPairing({
247
+ ref: upload.ref,
248
+ deviceJid,
249
+ deviceIdentityBytes: upload.deviceIdentityBytes
250
+ })
251
+ .catch((error) => {
252
+ this.companionHost.releaseDeviceId(deviceId);
253
+ throw error;
254
+ });
255
+ if (!completed) {
256
+ this.companionHost.releaseDeviceId(deviceId);
257
+ return null;
258
+ }
259
+ this.companionHost.recordCompanion({
260
+ deviceJid,
261
+ deviceId,
262
+ ref: upload.ref,
263
+ keyIndex: readCompanionKeyIndex(upload.deviceIdentityBytes),
264
+ deviceIdentityBytes: upload.deviceIdentityBytes,
265
+ linkedAtSeconds: Math.floor(Date.now() / 1000)
266
+ });
267
+ this.companionHost.recordKeyIndexList(upload.keyIndexListBytes, upload.keyIndexListTimestampSeconds);
268
+ this.registries.registerDeviceId(primary.jid, deviceId);
269
+ return { deviceJid, companionPropsBytes: completed.companionPropsBytes };
270
+ }
271
+ revokeCompanionDevices(deviceJids) {
272
+ const removed = deviceJids
273
+ ? this.companionHost.removeCompanions(deviceJids)
274
+ : this.companionHost.removeAllCompanions();
275
+ const primary = this.companionHost.primary;
276
+ if (primary) {
277
+ for (const deviceJid of removed) {
278
+ this.registries.unregisterDeviceId(primary.jid, toDeviceIdPart(deviceJid));
279
+ }
280
+ }
281
+ return removed;
282
+ }
221
283
  }
222
284
  export function matchesIq(iq, matcher) {
223
285
  if (iq.tag !== 'iq')
@@ -1,14 +1,35 @@
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';
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';
6
11
  import { FakeMediaStore } from '../state/fake-media-store.js';
7
12
  import { X25519 } from '../transport/crypto.js';
8
13
  import { FakePairingDriver } from './FakePairingDriver.js';
9
14
  import { FakePeer } from './FakePeer.js';
10
15
  import { FakeServerSession } from './FakeServerSession.js';
11
16
  import { Scenario } from './Scenario.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
+ }
12
33
  export { FakeServerSession } from './FakeServerSession.js';
13
34
  export class FakeWaServer {
14
35
  constructor(options = {}) {
@@ -20,6 +41,10 @@ export class FakeWaServer {
20
41
  this.rootCa = null;
21
42
  this.serverStaticKeyPair = null;
22
43
  this.listenInfo = null;
44
+ this.tcpListenInfo = null;
45
+ this.pendingCompanionOffers = new Map();
46
+ this.mobilePrimaryPipelines = new Map();
47
+ this.linkCodeSessions = new Map();
23
48
  this.pipelineListeners = new Set();
24
49
  this.rejectMode = null;
25
50
  this.mediaStore = new FakeMediaStore();
@@ -30,12 +55,18 @@ export class FakeWaServer {
30
55
  this.options = options;
31
56
  this.wsServer = new WaFakeWsServer(options);
32
57
  this.wsServer.onConnection((connection) => this.handleConnection(connection));
58
+ this.tcpServer = createTcpServer(options);
59
+ this.tcpServer?.onConnection((connection) => this.handleConnection(connection));
33
60
  this.defaultSession = this.createSession('__default__');
34
61
  }
35
62
  /** State of the single default session (the only session for one client). */
36
63
  get registries() {
37
64
  return this.defaultSession.registries;
38
65
  }
66
+ /** Companion-host account state of the default session (linked devices, key-index list). */
67
+ get companionHost() {
68
+ return this.defaultSession.companionHost;
69
+ }
39
70
  get preKeyDispenser() {
40
71
  return this.defaultSession.preKeyDispenser;
41
72
  }
@@ -60,7 +91,11 @@ export class FakeWaServer {
60
91
  return this.pipelineSessions.get(pipeline) ?? this.defaultSession;
61
92
  }
62
93
  createSession(id) {
63
- const session = new FakeServerSession(id, { requireMediaHttpsInfo: () => this.requireMediaHttpsInfo() }, { defaultIqHandlers: this.options.defaultIqHandlers !== false });
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 });
64
99
  this.sessions.set(id, session);
65
100
  // Server-level custom handlers apply to every session, present and future.
66
101
  for (const handler of this.globalIqHandlers) {
@@ -177,6 +212,27 @@ export class FakeWaServer {
177
212
  async pushServerSyncNotification(pipeline, input) {
178
213
  return this.sessionFor(pipeline).appStateSync.pushServerSyncNotification(pipeline, input);
179
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 }));
235
+ }
180
236
  onAuthenticatedPipeline(listener) {
181
237
  this.authenticatedListeners.add(listener);
182
238
  return () => this.authenticatedListeners.delete(listener);
@@ -340,6 +396,18 @@ export class FakeWaServer {
340
396
  get port() {
341
397
  return this.requireListening().port;
342
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
+ }
343
411
  get noiseRootCa() {
344
412
  const root = this.requireRootCa();
345
413
  return { publicKey: root.publicKey, serial: root.serial };
@@ -367,11 +435,35 @@ export class FakeWaServer {
367
435
  this.wsServer.setHttpRequestHandler(mediaHandler);
368
436
  this.mediaHttpsServer.setRequestHandler(mediaHandler);
369
437
  this.listenInfo = await this.wsServer.listen();
370
- 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;
371
461
  }
372
462
  async stop() {
373
463
  this.pipelines.clear();
374
464
  await this.wsServer.close();
465
+ await this.tcpServer?.close();
466
+ this.tcpListenInfo = null;
375
467
  await this.mediaHttpsServer.close();
376
468
  if (this.cachedMediaProxyAgent) {
377
469
  this.cachedMediaProxyAgent.destroy();
@@ -394,7 +486,7 @@ export class FakeWaServer {
394
486
  connection,
395
487
  rootCa: this.rootCa,
396
488
  serverStaticKeyPair: this.serverStaticKeyPair,
397
- routeIq: (node) => this.sessionFor(pipeline).routeIq(node),
489
+ routeIq: (node) => this.sessionFor(pipeline).routeIq(node, { connection: pipeline }),
398
490
  ...(this.options.successNodeAttributes !== undefined
399
491
  ? { successNodeAttributes: this.options.successNodeAttributes }
400
492
  : {})
@@ -404,6 +496,7 @@ export class FakeWaServer {
404
496
  pipeline.setEvents({
405
497
  onAuthenticated: () => {
406
498
  this.bindPipelineSession(pipeline);
499
+ this.bindMobilePrimary(pipeline);
407
500
  for (const listener of this.authenticatedListeners) {
408
501
  try {
409
502
  void Promise.resolve(listener(pipeline)).catch(() => undefined);
@@ -414,7 +507,7 @@ export class FakeWaServer {
414
507
  }
415
508
  },
416
509
  onStanza: (node) => this.sessionFor(pipeline).handleCapturedStanza(node),
417
- onClose: () => this.pipelines.delete(pipeline)
510
+ onClose: () => this.forgetPipeline(pipeline)
418
511
  });
419
512
  for (const listener of this.pipelineListeners) {
420
513
  try {
@@ -440,6 +533,139 @@ export class FakeWaServer {
440
533
  const id = resolveKey({ clientPayload, pipeline });
441
534
  this.pipelineSessions.set(pipeline, this.session(id));
442
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);
542
+ }
543
+ }
544
+ for (const [jid, primary] of this.mobilePrimaryPipelines) {
545
+ if (primary === pipeline) {
546
+ this.mobilePrimaryPipelines.delete(jid);
547
+ }
548
+ }
549
+ for (const [ref, session] of this.linkCodeSessions) {
550
+ if (session.companion === pipeline || session.primary === pipeline) {
551
+ this.linkCodeSessions.delete(ref);
552
+ }
553
+ }
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') {
563
+ return;
564
+ }
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' });
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) });
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 };
668
+ }
443
669
  buildMediaRequestHandler() {
444
670
  return (req, res) => {
445
671
  const rawUrl = req.url ?? '';
@@ -186,16 +186,31 @@ export class ServerRegistries {
186
186
  }
187
187
  registerPeer(peer) {
188
188
  this.peerRegistry.set(peer.jid, peer);
189
- const userJid = toUserJidPart(peer.jid);
190
- const deviceId = toDeviceIdPart(peer.jid);
189
+ this.registerDeviceId(toUserJidPart(peer.jid), toDeviceIdPart(peer.jid));
190
+ }
191
+ /**
192
+ * Makes a device visible to `usync` without minting a full fake peer – used
193
+ * for the primary's own device set, whose companions are real clients with
194
+ * their own keys rather than server-side fakes.
195
+ */
196
+ registerDeviceId(userJid, deviceId) {
191
197
  const ids = this.deviceIdsByUser.get(userJid);
192
- if (ids) {
198
+ if (!ids) {
199
+ this.deviceIdsByUser.set(userJid, [deviceId]);
200
+ return;
201
+ }
202
+ if (!ids.includes(deviceId)) {
193
203
  ids.push(deviceId);
194
204
  ids.sort((a, b) => a - b);
195
205
  }
196
- else {
197
- this.deviceIdsByUser.set(userJid, [deviceId]);
206
+ }
207
+ unregisterDeviceId(userJid, deviceId) {
208
+ const ids = this.deviceIdsByUser.get(userJid);
209
+ if (!ids) {
210
+ return;
198
211
  }
212
+ const next = ids.filter((id) => id !== deviceId);
213
+ this.deviceIdsByUser.set(userJid, next);
199
214
  }
200
215
  lookupDeviceIdsForUser(userJid) {
201
216
  return this.deviceIdsByUser.get(userJid) ?? [];
@@ -2,6 +2,7 @@
2
2
  import { buildAbPropsResult } from '../protocol/iq/abprops.js';
3
3
  import { buildAppStateSyncFullResult, buildAppStateSyncResult, parseAppStateSyncRequest } from '../protocol/iq/appstate-sync.js';
4
4
  import { buildBusinessProfileResult, parseGetBusinessProfileIq } from '../protocol/iq/business.js';
5
+ import { parseKeyIndexListPublish, parsePairDeviceUpload, parseRemoveCompanionDevice } from '../protocol/iq/companion-host.js';
5
6
  import { parseClearDirtyBitsIq } from '../protocol/iq/dirty-bits.js';
6
7
  import { buildGroupMetadataNode, buildGroupParticipantChangeResult, parseCreateGroupIq, parseGroupParticipantChangeIq, parseLeaveGroupIq, parseSetDescriptionIq, parseSetSubjectIq } from '../protocol/iq/group-ops.js';
7
8
  import { buildNewsletterMyAddonsResult } from '../protocol/iq/newsletter.js';
@@ -73,6 +74,28 @@ function buildGroupMetadataReply(iq, metadata) {
73
74
  ]
74
75
  };
75
76
  }
77
+ /**
78
+ * True when the stanza arrived on the phone that owns this session's account.
79
+ * Being a phone login is not enough: a second number connecting to the same
80
+ * session must not drive the companion set of the account that owns it.
81
+ */
82
+ function isSessionOwnerConnection(context, deps) {
83
+ const payload = context?.connection.clientPayload;
84
+ if (payload?.kind !== 'login' || payload.flavor !== 'mobile') {
85
+ return false;
86
+ }
87
+ return deps.mobilePrimary?.username === payload.username;
88
+ }
89
+ /** The device jid a connection speaks for, or `null` before it logs in. */
90
+ function ownDeviceJid(context) {
91
+ const payload = context?.connection.clientPayload;
92
+ if (payload?.kind !== 'login') {
93
+ return null;
94
+ }
95
+ return payload.device > 0
96
+ ? `${payload.username}:${payload.device}@s.whatsapp.net`
97
+ : `${payload.username}@s.whatsapp.net`;
98
+ }
76
99
  export function registerDefaultIqHandlers(router, deps) {
77
100
  router.register({
78
101
  label: 'prekey-upload',
@@ -244,10 +267,82 @@ export function registerDefaultIqHandlers(router, deps) {
244
267
  matcher: { xmlns: 'encrypt', type: 'set', childTag: 'rotate' },
245
268
  respond: (iq) => buildIqResult(iq)
246
269
  });
270
+ router.register({
271
+ label: 'link-code-companion-reg',
272
+ matcher: { xmlns: 'md', type: 'set', childTag: 'link_code_companion_reg' },
273
+ respond: (iq, context) => deps.relayLinkCodeStage(iq, context)
274
+ });
275
+ router.register({
276
+ label: 'companion-pair-device',
277
+ matcher: { xmlns: 'md', type: 'set', childTag: 'pair-device' },
278
+ respond: async (iq, context) => {
279
+ const upload = parsePairDeviceUpload(iq);
280
+ if (!upload) {
281
+ return buildIqError(iq, { code: 400, text: 'invalid-pair-device' });
282
+ }
283
+ // The link is minted under the account that owns the session, so
284
+ // only that account's phone may ask for one.
285
+ if (!isSessionOwnerConnection(context, deps)) {
286
+ return buildIqError(iq, { code: 403, text: 'not-authorized' });
287
+ }
288
+ const linked = await deps.linkCompanionDevice(upload);
289
+ if (!linked) {
290
+ return buildIqError(iq, { code: 404, text: 'unknown-pairing-ref' });
291
+ }
292
+ // Shape mirrors the phone's parser: <device jid> is a direct child
293
+ // of the result, with the companion's own props beside it.
294
+ const content = [{ tag: 'device', attrs: { jid: linked.deviceJid } }];
295
+ if (linked.companionPropsBytes) {
296
+ content.push({
297
+ tag: 'companion-props',
298
+ attrs: {},
299
+ content: linked.companionPropsBytes
300
+ });
301
+ }
302
+ return buildIqResult(iq, { content });
303
+ }
304
+ });
305
+ router.register({
306
+ label: 'companion-key-index-list',
307
+ matcher: { xmlns: 'md', type: 'set', childTag: 'key-index-list' },
308
+ respond: (iq, context) => {
309
+ const published = parseKeyIndexListPublish(iq);
310
+ if (!published) {
311
+ return buildIqError(iq, { code: 400, text: 'invalid-key-index-list' });
312
+ }
313
+ if (!isSessionOwnerConnection(context, deps)) {
314
+ return buildIqError(iq, { code: 403, text: 'not-authorized' });
315
+ }
316
+ deps.recordKeyIndexList(published.keyIndexListBytes, published.timestampSeconds);
317
+ return buildIqResult(iq);
318
+ }
319
+ });
247
320
  router.register({
248
321
  label: 'remove-companion-device',
249
322
  matcher: { xmlns: 'md', type: 'set', childTag: 'remove-companion-device' },
250
- respond: (iq) => {
323
+ respond: (iq, context) => {
324
+ const removal = parseRemoveCompanionDevice(iq);
325
+ // The same stanza means "log me out" from a companion and "unlink
326
+ // that device" from a primary. The wire does not say which, but the
327
+ // connection does: the account's own phone is always revoking
328
+ // someone else's device, and never ends its own session this way.
329
+ if (removal && isSessionOwnerConnection(context, deps)) {
330
+ if (!removal.all) {
331
+ deps.revokeCompanionDevices(removal.deviceJid ? [removal.deviceJid] : []);
332
+ }
333
+ else if (!removal.excludeHostedCompanion) {
334
+ // The hosted set is exactly what this session tracks, so
335
+ // sparing it leaves the registry untouched.
336
+ deps.revokeCompanionDevices(null);
337
+ }
338
+ return buildIqResult(iq);
339
+ }
340
+ // Anyone else may only unlink itself: a connection that is not the
341
+ // account's phone must never drop someone else's device. Its own
342
+ // session ends either way.
343
+ if (removal?.deviceJid && removal.deviceJid === ownDeviceJid(context)) {
344
+ deps.revokeCompanionDevices([removal.deviceJid]);
345
+ }
251
346
  deps.notifyLogout();
252
347
  return buildIqResult(iq);
253
348
  }
package/dist/esm/index.js CHANGED
@@ -1,8 +1,16 @@
1
+ export { createFakeMobilePrimary, seedFakeMobilePrimary } from './api/FakeMobilePrimary.js';
1
2
  export { FakePairingDriver } from './api/FakePairingDriver.js';
2
3
  export { FakePeer } from './api/FakePeer.js';
3
4
  export { FakeServerSession, FakeWaServer } from './api/FakeWaServer.js';
4
5
  export { IqExpectation, Scenario } from './api/Scenario.js';
5
6
  export { WaFakeConnection } from './infra/WaFakeConnection.js';
7
+ export { WaFakeTcpServer } from './infra/WaFakeTcpServer.js';
8
+ export { createTcpSocketAdapter, createWebSocketAdapter } from './infra/socket-adapters.js';
9
+ export { ClientPayloadValidationError, parseClientPayload } from './protocol/auth/client-payload-validate.js';
10
+ export { parseKeyIndexListPublish, parsePairDeviceUpload, parseRemoveCompanionDevice } from './protocol/iq/companion-host.js';
11
+ export { buildCompanionHelloResultContent, buildLinkCodeNotification, parseLinkCodeStanza } from './protocol/iq/link-code.js';
12
+ export { buildAccountSyncDevicesNotification, buildAccountTakeoverNotice, buildRegistrationCodeNotification } from './protocol/push/mobile-notification.js';
13
+ export { FakeCompanionHostState, readCompanionKeyIndex } from './state/fake-companion-host.js';
6
14
  export { buildAppStateSyncFullResult, buildAppStateSyncResult, buildServerSyncNotification, parseAppStateSyncRequest } from './protocol/iq/appstate-sync.js';
7
15
  export { buildIqError, buildIqResult } from './protocol/iq/router.js';
8
16
  export { buildCall, buildFailure } from './protocol/push/call-failure.js';
@@ -4,7 +4,14 @@ export class WaFakeConnection {
4
4
  this.currentState = 'open';
5
5
  this.id = id;
6
6
  this.socket = socket;
7
- this.bindSocketEvents();
7
+ this.socket.listen({
8
+ onFrame: (frame) => this.handlers.onFrame?.(frame),
9
+ onClose: (info) => {
10
+ this.currentState = 'closed';
11
+ this.handlers.onClose?.(info);
12
+ },
13
+ onError: (error) => this.handlers.onError?.(error)
14
+ });
8
15
  }
9
16
  get state() {
10
17
  return this.currentState;
@@ -25,25 +32,4 @@ export class WaFakeConnection {
25
32
  this.currentState = 'closing';
26
33
  this.socket.close(code, reason);
27
34
  }
28
- bindSocketEvents() {
29
- this.socket.on('message', (data, isBinary) => {
30
- if (!isBinary) {
31
- this.handlers.onError?.(new Error('received unexpected text frame'));
32
- return;
33
- }
34
- const frame = data instanceof Uint8Array
35
- ? data
36
- : Array.isArray(data)
37
- ? new Uint8Array(Buffer.concat(data))
38
- : new Uint8Array(data);
39
- this.handlers.onFrame?.(frame);
40
- });
41
- this.socket.on('close', (code, reasonBuf) => {
42
- this.currentState = 'closed';
43
- this.handlers.onClose?.({ code, reason: reasonBuf.toString('utf8') });
44
- });
45
- this.socket.on('error', (error) => {
46
- this.handlers.onError?.(error);
47
- });
48
- }
49
35
  }