@zapo-js/fake-server 1.1.0 → 1.3.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 +90 -1
  8. package/dist/api/FakeWaServer.js +231 -5
  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 +231 -5
  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 +23 -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,5 +1,6 @@
1
1
  import { Agent as HttpsAgent } from 'node:https';
2
2
  import { type WaFakeAuthenticatedInfo, WaFakeConnectionPipeline } from '../infra/WaFakeConnectionPipeline';
3
+ import { type FakeNoiseRootCa } from '../protocol/auth/cert-chain';
3
4
  import type { ParsedClientPayload } from '../protocol/auth/client-payload-validate';
4
5
  import type { BuildSuccessNodeInput } from '../protocol/auth/success-node';
5
6
  import type { BuildAbPropsResultInput } from '../protocol/iq/abprops';
@@ -8,8 +9,10 @@ import type { FakeBusinessProfile } from '../protocol/iq/business';
8
9
  import type { FakePrivacyCategoryName, FakePrivacySettingsState } from '../protocol/iq/privacy';
9
10
  import type { FakePrivacyTokenIssue } from '../protocol/iq/privacy-token';
10
11
  import type { FakeProfilePictureResult } from '../protocol/iq/profile';
11
- import type { WaFakeIqMatcher, WaFakeIqResponder } from '../protocol/iq/router';
12
+ import { type WaFakeIqMatcher, type WaFakeIqResponder } from '../protocol/iq/router';
13
+ import { type FakeAccountDevice } from '../protocol/push/mobile-notification';
12
14
  import type { ClientPreKeyBundle } from '../protocol/signal/prekey-upload';
15
+ import type { FakeCompanionHostState } from '../state/fake-companion-host';
13
16
  import { type PublishedMediaBlob, type PublishMediaInput } from '../state/fake-media-store';
14
17
  import { type BinaryNode } from '../transport/codec';
15
18
  import { type AppStateSyncManager, type CapturedAppStateMutation } from './AppStateSyncManager';
@@ -48,6 +51,35 @@ export interface FakeWaServerOptions {
48
51
  * clientPayload.username : 'pending'`.
49
52
  */
50
53
  readonly sessionKey?: (info: FakeSessionKeyInfo) => string;
54
+ /**
55
+ * Also serve the WhatsApp Mobile transport on a raw TCP listener. Mobile
56
+ * clients dial `tcp://host:port` instead of upgrading to a WebSocket, but
57
+ * speak the same framing above the carrier, so both listeners share one
58
+ * server identity, session model, and IQ router. Pass `true` for an
59
+ * ephemeral port on the WebSocket host, or an object to pin host/port.
60
+ * Read the address back from {@link FakeWaServer.tcpUrl} and feed it to the
61
+ * client's `mobileTransport.tcpUrl`.
62
+ */
63
+ readonly tcp?: boolean | {
64
+ readonly host?: string;
65
+ readonly port?: number;
66
+ };
67
+ /**
68
+ * Root of the Noise certificate chain the server presents. Defaults to a
69
+ * fresh random CA per {@link FakeWaServer.listen}, read back from
70
+ * {@link FakeWaServer.noiseRootCa}.
71
+ *
72
+ * A client in a separate process cannot read it back: it has to pin the
73
+ * anchor before it dials, while the server is not listening yet. Pass a CA
74
+ * both sides derive from a shared seed to make it knowable ahead of time.
75
+ *
76
+ * Carries the signing half, unlike the public-only
77
+ * {@link FakeWaServerNoiseRootCa} this server exposes. It signs a chain no
78
+ * real client trusts, so it is test material rather than a secret — but a
79
+ * seed committed beside the tests is the intended source, not one shared
80
+ * with anything that matters.
81
+ */
82
+ readonly noiseRootCa?: FakeNoiseRootCa;
51
83
  }
52
84
  export interface FakeSessionKeyInfo {
53
85
  readonly clientPayload: ParsedClientPayload;
@@ -80,6 +112,11 @@ export declare class FakeWaServer {
80
112
  private rootCa;
81
113
  private serverStaticKeyPair;
82
114
  private listenInfo;
115
+ private readonly tcpServer;
116
+ private tcpListenInfo;
117
+ private readonly pendingCompanionOffers;
118
+ private readonly mobilePrimaryPipelines;
119
+ private readonly linkCodeSessions;
83
120
  private readonly pipelineListeners;
84
121
  private rejectMode;
85
122
  private readonly mediaStore;
@@ -90,6 +127,8 @@ export declare class FakeWaServer {
90
127
  constructor(options?: FakeWaServerOptions);
91
128
  /** State of the single default session (the only session for one client). */
92
129
  get registries(): ServerRegistries;
130
+ /** Companion-host account state of the default session (linked devices, key-index list). */
131
+ get companionHost(): FakeCompanionHostState;
93
132
  get preKeyDispenser(): PreKeyDispenser;
94
133
  get appStateSync(): AppStateSyncManager;
95
134
  /**
@@ -146,6 +185,15 @@ export declare class FakeWaServer {
146
185
  expectAppStateMutation(predicate: (mutation: CapturedAppStateMutation) => boolean, timeoutMs?: number): Promise<CapturedAppStateMutation>;
147
186
  provideAppStateCollection(name: string, provider: () => Promise<FakeAppStateCollectionPayload | null> | FakeAppStateCollectionPayload | null): () => void;
148
187
  pushServerSyncNotification(pipeline: WaFakeConnectionPipeline, input: BuildServerSyncNotificationInput): Promise<void>;
188
+ /**
189
+ * Pushes the account's device set to a mobile primary as `account_sync`.
190
+ * Defaults to what this session tracks - the primary itself plus every
191
+ * companion it linked - so passing a shorter list is how a test tells the
192
+ * primary that a device disappeared while it was offline.
193
+ */
194
+ pushAccountSyncDevices(pipeline: WaFakeConnectionPipeline, options?: {
195
+ readonly devices?: readonly FakeAccountDevice[];
196
+ }): Promise<void>;
149
197
  onAuthenticatedPipeline(listener: AuthenticatedPipelineListener): () => void;
150
198
  scenario(configure: (s: Scenario) => void): void;
151
199
  expectIq(matcher: WaFakeIqMatcher, options?: ExpectIqOptions): Promise<BinaryNode>;
@@ -188,6 +236,13 @@ export declare class FakeWaServer {
188
236
  get url(): string;
189
237
  get host(): string;
190
238
  get port(): number;
239
+ /**
240
+ * `tcp://host:port` of the mobile listener, for the client's
241
+ * `mobileTransport.tcpUrl`.
242
+ *
243
+ * @throws when the server was started without the `tcp` option.
244
+ */
245
+ get tcpUrl(): string;
191
246
  get noiseRootCa(): FakeWaServerNoiseRootCa;
192
247
  /**
193
248
  * Subscribes to every new connection pipeline (pre-auth). Listeners
@@ -195,6 +250,7 @@ export declare class FakeWaServer {
195
250
  */
196
251
  onPipeline(listener: FakeWaServerPipelineListener): () => void;
197
252
  listen(): Promise<void>;
253
+ private rollbackFailedListen;
198
254
  stop(): Promise<void>;
199
255
  private handleConnection;
200
256
  /**
@@ -204,6 +260,39 @@ export declare class FakeWaServer {
204
260
  * No-op without a `sessionKey` resolver (single default session).
205
261
  */
206
262
  private bindPipelineSession;
263
+ /** Drops every reference to a closed connection so a relay never targets it. */
264
+ private forgetPipeline;
265
+ /**
266
+ * Records a phone login as the session's account owner. Everything on the
267
+ * companion-host path keys off it: device jids are minted under its number,
268
+ * and `remove-companion-device` is read as a revoke rather than a logout.
269
+ */
270
+ private bindMobilePrimary;
271
+ /**
272
+ * Offers a companion connection the refs for a primary-driven link: pushes
273
+ * the `pair-device` IQ it turns into a QR, and remembers each ref so the
274
+ * primary's upload can be routed back to this connection. Returns the refs.
275
+ *
276
+ * This is the counterpart of {@link runPairing}, where the server itself
277
+ * plays the primary. Here a real mobile-primary client signs the identity
278
+ * and the server only relays.
279
+ */
280
+ offerCompanionPairing(pipeline: WaFakeConnectionPipeline, options?: {
281
+ readonly refCount?: number;
282
+ }): Promise<readonly string[]>;
283
+ /**
284
+ * Relays one link-code stage between the two clients running the pairing
285
+ * handshake. `companion_hello` mints the ref that ties the stages together
286
+ * and registers it as a pairing offer, so the primary's later `pair-device`
287
+ * upload lands on the same companion connection as the QR flow.
288
+ */
289
+ private handleLinkCodeStage;
290
+ /**
291
+ * Host side of a companion link: hands the primary-signed identity to the
292
+ * connection that owns `ref` and reports what that companion advertised at
293
+ * registration. Resolves `null` for an unknown or already-consumed ref.
294
+ */
295
+ private completeCompanionPairing;
207
296
  private buildMediaRequestHandler;
208
297
  private handleMediaUpload;
209
298
  private requireListening;
@@ -4,14 +4,35 @@ 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");
12
+ const router_1 = require("../protocol/iq/router");
13
+ const mobile_notification_1 = require("../protocol/push/mobile-notification");
9
14
  const fake_media_store_1 = require("../state/fake-media-store");
10
15
  const crypto_1 = require("../transport/crypto");
11
16
  const FakePairingDriver_1 = require("./FakePairingDriver");
12
17
  const FakePeer_1 = require("./FakePeer");
13
18
  const FakeServerSession_1 = require("./FakeServerSession");
14
19
  const Scenario_1 = require("./Scenario");
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
+ }
15
36
  var FakeServerSession_2 = require("./FakeServerSession");
16
37
  Object.defineProperty(exports, "FakeServerSession", { enumerable: true, get: function () { return FakeServerSession_2.FakeServerSession; } });
17
38
  class FakeWaServer {
@@ -24,6 +45,10 @@ class FakeWaServer {
24
45
  this.rootCa = null;
25
46
  this.serverStaticKeyPair = null;
26
47
  this.listenInfo = null;
48
+ this.tcpListenInfo = null;
49
+ this.pendingCompanionOffers = new Map();
50
+ this.mobilePrimaryPipelines = new Map();
51
+ this.linkCodeSessions = new Map();
27
52
  this.pipelineListeners = new Set();
28
53
  this.rejectMode = null;
29
54
  this.mediaStore = new fake_media_store_1.FakeMediaStore();
@@ -34,12 +59,18 @@ class FakeWaServer {
34
59
  this.options = options;
35
60
  this.wsServer = new WaFakeWsServer_1.WaFakeWsServer(options);
36
61
  this.wsServer.onConnection((connection) => this.handleConnection(connection));
62
+ this.tcpServer = createTcpServer(options);
63
+ this.tcpServer?.onConnection((connection) => this.handleConnection(connection));
37
64
  this.defaultSession = this.createSession('__default__');
38
65
  }
39
66
  /** State of the single default session (the only session for one client). */
40
67
  get registries() {
41
68
  return this.defaultSession.registries;
42
69
  }
70
+ /** Companion-host account state of the default session (linked devices, key-index list). */
71
+ get companionHost() {
72
+ return this.defaultSession.companionHost;
73
+ }
43
74
  get preKeyDispenser() {
44
75
  return this.defaultSession.preKeyDispenser;
45
76
  }
@@ -64,7 +95,11 @@ class FakeWaServer {
64
95
  return this.pipelineSessions.get(pipeline) ?? this.defaultSession;
65
96
  }
66
97
  createSession(id) {
67
- const session = new FakeServerSession_1.FakeServerSession(id, { requireMediaHttpsInfo: () => this.requireMediaHttpsInfo() }, { defaultIqHandlers: this.options.defaultIqHandlers !== false });
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 });
68
103
  this.sessions.set(id, session);
69
104
  // Server-level custom handlers apply to every session, present and future.
70
105
  for (const handler of this.globalIqHandlers) {
@@ -181,6 +216,27 @@ class FakeWaServer {
181
216
  async pushServerSyncNotification(pipeline, input) {
182
217
  return this.sessionFor(pipeline).appStateSync.pushServerSyncNotification(pipeline, input);
183
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 }));
239
+ }
184
240
  onAuthenticatedPipeline(listener) {
185
241
  this.authenticatedListeners.add(listener);
186
242
  return () => this.authenticatedListeners.delete(listener);
@@ -344,6 +400,18 @@ class FakeWaServer {
344
400
  get port() {
345
401
  return this.requireListening().port;
346
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
+ }
347
415
  get noiseRootCa() {
348
416
  const root = this.requireRootCa();
349
417
  return { publicKey: root.publicKey, serial: root.serial };
@@ -364,18 +432,42 @@ class FakeWaServer {
364
432
  }
365
433
  ;
366
434
  [this.rootCa, this.serverStaticKeyPair] = await Promise.all([
367
- (0, cert_chain_1.generateFakeNoiseRootCa)(),
435
+ this.options.noiseRootCa ?? (0, cert_chain_1.generateFakeNoiseRootCa)(),
368
436
  crypto_1.X25519.generateKeyPair()
369
437
  ]);
370
438
  const mediaHandler = this.buildMediaRequestHandler();
371
439
  this.wsServer.setHttpRequestHandler(mediaHandler);
372
440
  this.mediaHttpsServer.setRequestHandler(mediaHandler);
373
441
  this.listenInfo = await this.wsServer.listen();
374
- 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;
375
465
  }
376
466
  async stop() {
377
467
  this.pipelines.clear();
378
468
  await this.wsServer.close();
469
+ await this.tcpServer?.close();
470
+ this.tcpListenInfo = null;
379
471
  await this.mediaHttpsServer.close();
380
472
  if (this.cachedMediaProxyAgent) {
381
473
  this.cachedMediaProxyAgent.destroy();
@@ -398,7 +490,7 @@ class FakeWaServer {
398
490
  connection,
399
491
  rootCa: this.rootCa,
400
492
  serverStaticKeyPair: this.serverStaticKeyPair,
401
- routeIq: (node) => this.sessionFor(pipeline).routeIq(node),
493
+ routeIq: (node) => this.sessionFor(pipeline).routeIq(node, { connection: pipeline }),
402
494
  ...(this.options.successNodeAttributes !== undefined
403
495
  ? { successNodeAttributes: this.options.successNodeAttributes }
404
496
  : {})
@@ -408,6 +500,7 @@ class FakeWaServer {
408
500
  pipeline.setEvents({
409
501
  onAuthenticated: () => {
410
502
  this.bindPipelineSession(pipeline);
503
+ this.bindMobilePrimary(pipeline);
411
504
  for (const listener of this.authenticatedListeners) {
412
505
  try {
413
506
  void Promise.resolve(listener(pipeline)).catch(() => undefined);
@@ -418,7 +511,7 @@ class FakeWaServer {
418
511
  }
419
512
  },
420
513
  onStanza: (node) => this.sessionFor(pipeline).handleCapturedStanza(node),
421
- onClose: () => this.pipelines.delete(pipeline)
514
+ onClose: () => this.forgetPipeline(pipeline)
422
515
  });
423
516
  for (const listener of this.pipelineListeners) {
424
517
  try {
@@ -444,6 +537,139 @@ class FakeWaServer {
444
537
  const id = resolveKey({ clientPayload, pipeline });
445
538
  this.pipelineSessions.set(pipeline, this.session(id));
446
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);
546
+ }
547
+ }
548
+ for (const [jid, primary] of this.mobilePrimaryPipelines) {
549
+ if (primary === pipeline) {
550
+ this.mobilePrimaryPipelines.delete(jid);
551
+ }
552
+ }
553
+ for (const [ref, session] of this.linkCodeSessions) {
554
+ if (session.companion === pipeline || session.primary === pipeline) {
555
+ this.linkCodeSessions.delete(ref);
556
+ }
557
+ }
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') {
567
+ return;
568
+ }
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' });
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) });
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 };
672
+ }
447
673
  buildMediaRequestHandler() {
448
674
  return (req, res) => {
449
675
  const rawUrl = req.url ?? '';
@@ -99,6 +99,13 @@ export declare class ServerRegistries {
99
99
  mutateBlocklist(action: 'block' | 'unblock', jid: string): void;
100
100
  notifyGroupOp(op: CapturedGroupOp): void;
101
101
  registerPeer(peer: FakePeer): void;
102
+ /**
103
+ * Makes a device visible to `usync` without minting a full fake peer – used
104
+ * for the primary's own device set, whose companions are real clients with
105
+ * their own keys rather than server-side fakes.
106
+ */
107
+ registerDeviceId(userJid: string, deviceId: number): void;
108
+ unregisterDeviceId(userJid: string, deviceId: number): void;
102
109
  lookupDeviceIdsForUser(userJid: string): readonly number[];
103
110
  notifyProfilePictureSet(op: CapturedProfilePictureSet): void;
104
111
  notifyStatusSet(text: string): void;
@@ -191,16 +191,31 @@ class ServerRegistries {
191
191
  }
192
192
  registerPeer(peer) {
193
193
  this.peerRegistry.set(peer.jid, peer);
194
- const userJid = toUserJidPart(peer.jid);
195
- const deviceId = toDeviceIdPart(peer.jid);
194
+ this.registerDeviceId(toUserJidPart(peer.jid), toDeviceIdPart(peer.jid));
195
+ }
196
+ /**
197
+ * Makes a device visible to `usync` without minting a full fake peer – used
198
+ * for the primary's own device set, whose companions are real clients with
199
+ * their own keys rather than server-side fakes.
200
+ */
201
+ registerDeviceId(userJid, deviceId) {
196
202
  const ids = this.deviceIdsByUser.get(userJid);
197
- if (ids) {
203
+ if (!ids) {
204
+ this.deviceIdsByUser.set(userJid, [deviceId]);
205
+ return;
206
+ }
207
+ if (!ids.includes(deviceId)) {
198
208
  ids.push(deviceId);
199
209
  ids.sort((a, b) => a - b);
200
210
  }
201
- else {
202
- this.deviceIdsByUser.set(userJid, [deviceId]);
211
+ }
212
+ unregisterDeviceId(userJid, deviceId) {
213
+ const ids = this.deviceIdsByUser.get(userJid);
214
+ if (!ids) {
215
+ return;
203
216
  }
217
+ const next = ids.filter((id) => id !== deviceId);
218
+ this.deviceIdsByUser.set(userJid, next);
204
219
  }
205
220
  lookupDeviceIdsForUser(userJid) {
206
221
  return this.deviceIdsByUser.get(userJid) ?? [];
@@ -2,11 +2,13 @@
2
2
  import { type BuildAbPropsResultInput } from '../protocol/iq/abprops';
3
3
  import { type FakeAppStateCollectionPayload } from '../protocol/iq/appstate-sync';
4
4
  import { type FakeBusinessProfile } from '../protocol/iq/business';
5
+ import { type ParsedPairDeviceUpload } from '../protocol/iq/companion-host';
5
6
  import { type FakePrivacyCategoryName, type FakePrivacySettingsState } from '../protocol/iq/privacy';
6
7
  import { type FakePrivacyTokenIssue } from '../protocol/iq/privacy-token';
7
8
  import { type FakeProfilePictureResult } from '../protocol/iq/profile';
8
- import { type WaFakeIqRouter } from '../protocol/iq/router';
9
+ import { type WaFakeIqContext, type WaFakeIqRouter } from '../protocol/iq/router';
9
10
  import { type ClientPreKeyBundle } from '../protocol/signal/prekey-upload';
11
+ import type { FakeMobilePrimaryIdentity } from '../state/fake-companion-host';
10
12
  import { type BinaryNode } from '../transport/codec';
11
13
  import type { FakePeer } from './FakePeer';
12
14
  import { type CapturedBlocklistChange, type CapturedDirtyBitsClear, type CapturedGroupOp, type CapturedPrivacySet, type CapturedProfilePictureSet, type MutableFakeGroup } from './ServerRegistries';
@@ -41,6 +43,22 @@ export interface IqHandlerDeps {
41
43
  readonly host: string;
42
44
  readonly port: number;
43
45
  };
46
+ /** Mobile primary bound to this session, or `null` for a web session. */
47
+ readonly mobilePrimary: FakeMobilePrimaryIdentity | null;
48
+ /**
49
+ * Completes the primary's `pair-device` upload. Resolves `null` when the
50
+ * session has no primary or the ref belongs to no live connection.
51
+ */
52
+ linkCompanionDevice(upload: ParsedPairDeviceUpload): Promise<LinkedCompanionResult | null>;
53
+ /** Unlinks the given companions, or every one of them when passed `null`. */
54
+ revokeCompanionDevices(deviceJids: readonly string[] | null): readonly string[];
55
+ recordKeyIndexList(bytes: Uint8Array, timestampSeconds: number): void;
56
+ /** Routes a link-code stage to the other client, or `null` to fall through. */
57
+ relayLinkCodeStage(iq: BinaryNode, context: WaFakeIqContext | undefined): Promise<BinaryNode | null>;
58
+ }
59
+ export interface LinkedCompanionResult {
60
+ readonly deviceJid: string;
61
+ readonly companionPropsBytes: Uint8Array | null;
44
62
  }
45
63
  export declare function parseUsyncRequestedUserJids(iq: BinaryNode): readonly string[];
46
64
  export declare function parseRequestedKeyJids(iq: BinaryNode): readonly string[];