@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
@@ -0,0 +1,355 @@
1
+ "use strict";
2
+ /** Per-session isolated server state: registries, prekeys, app-state, IQ routing, capture. */
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.FakeServerSession = void 0;
5
+ exports.matchesIq = matchesIq;
6
+ exports.matchesStanza = matchesStanza;
7
+ const router_1 = require("../protocol/iq/router");
8
+ const fake_companion_host_1 = require("../state/fake-companion-host");
9
+ const AppStateSyncManager_1 = require("./AppStateSyncManager");
10
+ const iq_handlers_1 = require("./iq-handlers");
11
+ const PreKeyDispenser_1 = require("./PreKeyDispenser");
12
+ const ServerRegistries_1 = require("./ServerRegistries");
13
+ const HOST_DOMAIN = 's.whatsapp.net';
14
+ /**
15
+ * One isolated logical device's worth of server state. Every `WaClient`
16
+ * connected to a `FakeWaServer` is bound to exactly one session; peers,
17
+ * groups, privacy, prekeys, app-state, and captured stanzas never leak across
18
+ * sessions. A single-client server uses one default session, which is why the
19
+ * legacy `FakeWaServer` surface delegates straight through to it.
20
+ */
21
+ class FakeServerSession {
22
+ constructor(id, host, options) {
23
+ this.registries = new ServerRegistries_1.ServerRegistries();
24
+ this.preKeyDispenser = new PreKeyDispenser_1.PreKeyDispenser();
25
+ this.appStateSync = new AppStateSyncManager_1.AppStateSyncManager();
26
+ this.companionHost = new fake_companion_host_1.FakeCompanionHostState();
27
+ this.iqRouter = new router_1.WaFakeIqRouter();
28
+ this.capturedStanzas = [];
29
+ this.pendingIqExpectations = new Set();
30
+ this.pendingStanzaExpectations = new Set();
31
+ this.capturedStanzaListeners = new Set();
32
+ this.inboundStanzaListeners = new Set();
33
+ this.id = id;
34
+ if (options.defaultIqHandlers) {
35
+ (0, iq_handlers_1.registerDefaultIqHandlers)(this.iqRouter, this.buildIqHandlerDeps(host));
36
+ }
37
+ }
38
+ registerIqHandler(matcher, respond, label) {
39
+ const handler = { matcher, respond, label };
40
+ return this.iqRouter.register(handler, { priority: 'high' });
41
+ }
42
+ routeIq(iq, context) {
43
+ return this.iqRouter.route(iq, context);
44
+ }
45
+ handleCapturedStanza(node) {
46
+ this.capturedStanzas.push(node);
47
+ for (const listener of this.capturedStanzaListeners) {
48
+ try {
49
+ listener(node);
50
+ }
51
+ catch (error) {
52
+ void error;
53
+ }
54
+ }
55
+ for (const listener of this.inboundStanzaListeners) {
56
+ try {
57
+ listener(node);
58
+ }
59
+ catch (error) {
60
+ void error;
61
+ }
62
+ }
63
+ for (const expectation of this.pendingStanzaExpectations) {
64
+ if (matchesStanza(node, expectation.matcher)) {
65
+ expectation.resolve(node);
66
+ break;
67
+ }
68
+ }
69
+ if (node.tag !== 'iq') {
70
+ return;
71
+ }
72
+ for (const expectation of this.pendingIqExpectations) {
73
+ if (matchesIq(node, expectation.matcher)) {
74
+ expectation.resolve(node);
75
+ return;
76
+ }
77
+ }
78
+ }
79
+ subscribeInboundMessages(listener) {
80
+ const wrapped = (node) => {
81
+ if (node.tag !== 'message')
82
+ return;
83
+ listener(node);
84
+ };
85
+ this.inboundStanzaListeners.add(wrapped);
86
+ return () => {
87
+ this.inboundStanzaListeners.delete(wrapped);
88
+ };
89
+ }
90
+ onCapturedStanza(listener) {
91
+ this.capturedStanzaListeners.add(listener);
92
+ return () => {
93
+ this.capturedStanzaListeners.delete(listener);
94
+ };
95
+ }
96
+ capturedStanzaSnapshot() {
97
+ return this.capturedStanzas.slice();
98
+ }
99
+ expectIq(matcher, options = {}) {
100
+ const timeoutMs = options.timeoutMs ?? 2000;
101
+ for (const captured of this.capturedStanzas) {
102
+ if (matchesIq(captured, matcher)) {
103
+ return Promise.resolve(captured);
104
+ }
105
+ }
106
+ return new Promise((resolve, reject) => {
107
+ const expectation = {
108
+ matcher,
109
+ resolve: (iq) => {
110
+ this.pendingIqExpectations.delete(expectation);
111
+ clearTimeout(expectation.timer);
112
+ resolve(iq);
113
+ },
114
+ reject: (error) => {
115
+ this.pendingIqExpectations.delete(expectation);
116
+ clearTimeout(expectation.timer);
117
+ reject(error);
118
+ },
119
+ timer: setTimeout(() => {
120
+ this.pendingIqExpectations.delete(expectation);
121
+ reject(new Error(`expectIq timed out after ${timeoutMs}ms (${describeMatcher(matcher)})`));
122
+ }, timeoutMs)
123
+ };
124
+ this.pendingIqExpectations.add(expectation);
125
+ });
126
+ }
127
+ expectStanza(matcher, options = {}) {
128
+ const timeoutMs = options.timeoutMs ?? 2000;
129
+ for (const captured of this.capturedStanzas) {
130
+ if (matchesStanza(captured, matcher)) {
131
+ return Promise.resolve(captured);
132
+ }
133
+ }
134
+ return new Promise((resolve, reject) => {
135
+ const expectation = {
136
+ matcher,
137
+ resolve: (node) => {
138
+ this.pendingStanzaExpectations.delete(expectation);
139
+ clearTimeout(expectation.timer);
140
+ resolve(node);
141
+ },
142
+ reject: (error) => {
143
+ this.pendingStanzaExpectations.delete(expectation);
144
+ clearTimeout(expectation.timer);
145
+ reject(error);
146
+ },
147
+ timer: setTimeout(() => {
148
+ this.pendingStanzaExpectations.delete(expectation);
149
+ reject(new Error(`expectStanza timed out after ${timeoutMs}ms (${describeStanzaMatcher(matcher)})`));
150
+ }, timeoutMs)
151
+ };
152
+ this.pendingStanzaExpectations.add(expectation);
153
+ });
154
+ }
155
+ buildIqHandlerDeps(host) {
156
+ const reg = this.registries;
157
+ const preKey = this.preKeyDispenser;
158
+ const appState = this.appStateSync;
159
+ const companionHost = this.companionHost;
160
+ return {
161
+ get peerRegistry() {
162
+ return reg.peerRegistry;
163
+ },
164
+ get groupRegistry() {
165
+ return reg.groupRegistry;
166
+ },
167
+ get privacySettings() {
168
+ return reg.privacySettings;
169
+ },
170
+ get blocklistJids() {
171
+ return reg.blocklistJids;
172
+ },
173
+ get profilePicturesByJid() {
174
+ return reg.profilePicturesByJid;
175
+ },
176
+ get businessProfilesByJid() {
177
+ return reg.businessProfilesByJid;
178
+ },
179
+ get abPropsInput() {
180
+ return reg.abPropsInput;
181
+ },
182
+ get issuedPrivacyTokens() {
183
+ return reg.issuedPrivacyTokens;
184
+ },
185
+ get latestStatusText() {
186
+ return reg.latestStatusText;
187
+ },
188
+ setLatestStatusText: (text) => {
189
+ reg.latestStatusText = text;
190
+ },
191
+ lookupDeviceIdsForUser: (userJid) => reg.lookupDeviceIdsForUser(userJid),
192
+ notifyGroupOp: (op) => reg.notifyGroupOp(op),
193
+ mutatePrivacySettings: (category, value) => reg.mutatePrivacySettings(category, value),
194
+ mutateBlocklist: (action, jid) => reg.mutateBlocklist(action, jid),
195
+ notifyProfilePictureSet: (op) => reg.notifyProfilePictureSet(op),
196
+ handleProfilePictureSet: (targetJid, newId) => reg.handleProfilePictureSet(targetJid, newId),
197
+ notifyStatusSet: (text) => reg.notifyStatusSet(text),
198
+ notifyLogout: () => reg.notifyLogout(),
199
+ notifyPrivacyTokenIssue: (token) => reg.notifyPrivacyTokenIssue(token),
200
+ notifyDirtyBitsClear: (op) => reg.notifyDirtyBitsClear(op),
201
+ notifyPrivacySet: (change) => {
202
+ for (const listener of reg.privacySetListeners) {
203
+ try {
204
+ listener(change);
205
+ }
206
+ catch (error) {
207
+ void error;
208
+ }
209
+ }
210
+ },
211
+ notifyBlocklistChange: (change) => {
212
+ for (const listener of reg.blocklistChangeListeners) {
213
+ try {
214
+ listener(change);
215
+ }
216
+ catch (error) {
217
+ void error;
218
+ }
219
+ }
220
+ },
221
+ capturePreKeyBundle: (bundle) => preKey.captureBundle(bundle),
222
+ countServerPreKeys: () => preKey.preKeysAvailable(),
223
+ consumeOutboundAppStatePatches: (iq) => appState.consumeOutboundAppStatePatches(iq),
224
+ get appStateCollectionProviders() {
225
+ return appState.appStateCollectionProviders;
226
+ },
227
+ requireMediaHttpsInfo: () => host.requireMediaHttpsInfo(),
228
+ get mobilePrimary() {
229
+ return companionHost.primary;
230
+ },
231
+ relayLinkCodeStage: (iq, context) => host.relayLinkCodeStage(iq, context),
232
+ linkCompanionDevice: (upload) => this.linkCompanionDevice(host, upload),
233
+ revokeCompanionDevices: (deviceJids) => this.revokeCompanionDevices(deviceJids),
234
+ recordKeyIndexList: (bytes, timestampSeconds) => companionHost.recordKeyIndexList(bytes, timestampSeconds)
235
+ };
236
+ }
237
+ /**
238
+ * Server side of the primary's `pair-device` upload: allocates the device
239
+ * slot, hands the signed identity to the companion connection, and records
240
+ * the link. Resolves `null` when this session has no primary or the ref
241
+ * belongs to no live connection, which the handler turns into an IQ error.
242
+ */
243
+ async linkCompanionDevice(host, upload) {
244
+ const primary = this.companionHost.primary;
245
+ if (!primary) {
246
+ return null;
247
+ }
248
+ const deviceId = this.companionHost.allocateDeviceId();
249
+ const deviceJid = `${primary.username}:${deviceId}@${HOST_DOMAIN}`;
250
+ const completed = await host
251
+ .completeCompanionPairing({
252
+ ref: upload.ref,
253
+ deviceJid,
254
+ deviceIdentityBytes: upload.deviceIdentityBytes
255
+ })
256
+ .catch((error) => {
257
+ this.companionHost.releaseDeviceId(deviceId);
258
+ throw error;
259
+ });
260
+ if (!completed) {
261
+ this.companionHost.releaseDeviceId(deviceId);
262
+ return null;
263
+ }
264
+ this.companionHost.recordCompanion({
265
+ deviceJid,
266
+ deviceId,
267
+ ref: upload.ref,
268
+ keyIndex: (0, fake_companion_host_1.readCompanionKeyIndex)(upload.deviceIdentityBytes),
269
+ deviceIdentityBytes: upload.deviceIdentityBytes,
270
+ linkedAtSeconds: Math.floor(Date.now() / 1000)
271
+ });
272
+ this.companionHost.recordKeyIndexList(upload.keyIndexListBytes, upload.keyIndexListTimestampSeconds);
273
+ this.registries.registerDeviceId(primary.jid, deviceId);
274
+ return { deviceJid, companionPropsBytes: completed.companionPropsBytes };
275
+ }
276
+ revokeCompanionDevices(deviceJids) {
277
+ const removed = deviceJids
278
+ ? this.companionHost.removeCompanions(deviceJids)
279
+ : this.companionHost.removeAllCompanions();
280
+ const primary = this.companionHost.primary;
281
+ if (primary) {
282
+ for (const deviceJid of removed) {
283
+ this.registries.unregisterDeviceId(primary.jid, (0, ServerRegistries_1.toDeviceIdPart)(deviceJid));
284
+ }
285
+ }
286
+ return removed;
287
+ }
288
+ }
289
+ exports.FakeServerSession = FakeServerSession;
290
+ function matchesIq(iq, matcher) {
291
+ if (iq.tag !== 'iq')
292
+ return false;
293
+ if (matcher.id !== undefined && iq.attrs.id !== matcher.id)
294
+ return false;
295
+ if (matcher.type !== undefined && iq.attrs.type !== matcher.type)
296
+ return false;
297
+ if (matcher.xmlns !== undefined && iq.attrs.xmlns !== matcher.xmlns)
298
+ return false;
299
+ if (matcher.childTag !== undefined) {
300
+ const children = Array.isArray(iq.content) ? iq.content : null;
301
+ if (!children || children.length === 0)
302
+ return false;
303
+ if (children[0].tag !== matcher.childTag)
304
+ return false;
305
+ }
306
+ return true;
307
+ }
308
+ function describeMatcher(matcher) {
309
+ const parts = [];
310
+ if (matcher.id !== undefined)
311
+ parts.push(`id=${matcher.id}`);
312
+ if (matcher.type !== undefined)
313
+ parts.push(`type=${matcher.type}`);
314
+ if (matcher.xmlns !== undefined)
315
+ parts.push(`xmlns=${matcher.xmlns}`);
316
+ if (matcher.childTag !== undefined)
317
+ parts.push(`childTag=${matcher.childTag}`);
318
+ return parts.length > 0 ? parts.join(', ') : 'any iq';
319
+ }
320
+ function matchesStanza(node, matcher) {
321
+ if (matcher.tag !== undefined && node.tag !== matcher.tag)
322
+ return false;
323
+ if (matcher.type !== undefined && node.attrs.type !== matcher.type)
324
+ return false;
325
+ if (matcher.xmlns !== undefined && node.attrs.xmlns !== matcher.xmlns)
326
+ return false;
327
+ if (matcher.from !== undefined && node.attrs.from !== matcher.from)
328
+ return false;
329
+ if (matcher.to !== undefined && node.attrs.to !== matcher.to)
330
+ return false;
331
+ if (matcher.childTag !== undefined) {
332
+ const children = Array.isArray(node.content) ? node.content : null;
333
+ if (!children || children.length === 0)
334
+ return false;
335
+ if (children[0].tag !== matcher.childTag)
336
+ return false;
337
+ }
338
+ return true;
339
+ }
340
+ function describeStanzaMatcher(matcher) {
341
+ const parts = [];
342
+ if (matcher.tag !== undefined)
343
+ parts.push(`tag=${matcher.tag}`);
344
+ if (matcher.type !== undefined)
345
+ parts.push(`type=${matcher.type}`);
346
+ if (matcher.xmlns !== undefined)
347
+ parts.push(`xmlns=${matcher.xmlns}`);
348
+ if (matcher.from !== undefined)
349
+ parts.push(`from=${matcher.from}`);
350
+ if (matcher.to !== undefined)
351
+ parts.push(`to=${matcher.to}`);
352
+ if (matcher.childTag !== undefined)
353
+ parts.push(`childTag=${matcher.childTag}`);
354
+ return parts.length > 0 ? parts.join(', ') : 'any stanza';
355
+ }
@@ -1,5 +1,7 @@
1
1
  import { Agent as HttpsAgent } from 'node:https';
2
2
  import { type WaFakeAuthenticatedInfo, WaFakeConnectionPipeline } from '../infra/WaFakeConnectionPipeline';
3
+ import type { ParsedClientPayload } from '../protocol/auth/client-payload-validate';
4
+ import type { BuildSuccessNodeInput } from '../protocol/auth/success-node';
3
5
  import type { BuildAbPropsResultInput } from '../protocol/iq/abprops';
4
6
  import { type BuildServerSyncNotificationInput, type FakeAppStateCollectionPayload } from '../protocol/iq/appstate-sync';
5
7
  import type { FakeBusinessProfile } from '../protocol/iq/business';
@@ -7,41 +9,72 @@ import type { FakePrivacyCategoryName, FakePrivacySettingsState } from '../proto
7
9
  import type { FakePrivacyTokenIssue } from '../protocol/iq/privacy-token';
8
10
  import type { FakeProfilePictureResult } from '../protocol/iq/profile';
9
11
  import { type WaFakeIqMatcher, type WaFakeIqResponder } from '../protocol/iq/router';
12
+ import { type FakeAccountDevice } from '../protocol/push/mobile-notification';
10
13
  import type { ClientPreKeyBundle } from '../protocol/signal/prekey-upload';
14
+ import type { FakeCompanionHostState } from '../state/fake-companion-host';
11
15
  import { type PublishedMediaBlob, type PublishMediaInput } from '../state/fake-media-store';
12
16
  import { type BinaryNode } from '../transport/codec';
13
- import { AppStateSyncManager, type CapturedAppStateMutation } from './AppStateSyncManager';
17
+ import { type AppStateSyncManager, type CapturedAppStateMutation } from './AppStateSyncManager';
14
18
  import { type FakePairingDriverOptions } from './FakePairingDriver';
15
19
  import { type CreateFakePeerOptions, FakePeer } from './FakePeer';
16
- import { PreKeyDispenser } from './PreKeyDispenser';
20
+ import { type ExpectIqOptions, type ExpectStanzaOptions, FakeServerSession, type StanzaMatcher } from './FakeServerSession';
21
+ import type { PreKeyDispenser } from './PreKeyDispenser';
17
22
  import { type AuthenticatedPipelineListener, Scenario } from './Scenario';
18
- import { type CapturedBlocklistChange, type CapturedDirtyBitsClear, type CapturedGroupOp, type CapturedPrivacySet, type CapturedProfilePictureSet, type CapturedStatusSet, type FakeGroupMetadata, ServerRegistries } from './ServerRegistries';
23
+ import type { CapturedBlocklistChange, CapturedDirtyBitsClear, CapturedGroupOp, CapturedPrivacySet, CapturedProfilePictureSet, CapturedStatusSet, FakeGroupMetadata, ServerRegistries } from './ServerRegistries';
19
24
  export type { FakeGroupMetadata, CapturedGroupOp, CapturedPrivacySet, CapturedBlocklistChange, CapturedProfilePictureSet, CapturedStatusSet, CapturedDirtyBitsClear } from './ServerRegistries';
20
25
  export type { CapturedAppStateMutation } from './AppStateSyncManager';
21
26
  export interface FakeWaServerOptions {
22
27
  readonly host?: string;
23
28
  readonly port?: number;
24
29
  readonly path?: string;
30
+ /**
31
+ * Attributes stamped on the post-handshake `<success/>` node of every
32
+ * authenticated connection (lid, display name, props versions, ...).
33
+ */
34
+ readonly successNodeAttributes?: BuildSuccessNodeInput;
35
+ /**
36
+ * Register the built-in IQ auto-handlers that answer everything the lib
37
+ * emits during normal operation. Default `true`; pass `false` to start
38
+ * with an empty router and wire every response via `registerIqHandler`.
39
+ */
40
+ readonly defaultIqHandlers?: boolean;
41
+ /**
42
+ * Assigns each authenticated connection to an isolated session, keyed by
43
+ * the returned id. Two connections that resolve to different ids share no
44
+ * peers, groups, prekeys, app-state, or captured stanzas. When omitted,
45
+ * every connection uses one shared default session (single-client mode).
46
+ *
47
+ * The resolver runs once per connection, right after authentication, so
48
+ * `info.clientPayload` is available for keying by login identity, e.g.
49
+ * `sessionKey: ({ clientPayload }) => clientPayload.kind === 'login' ?
50
+ * clientPayload.username : 'pending'`.
51
+ */
52
+ readonly sessionKey?: (info: FakeSessionKeyInfo) => string;
53
+ /**
54
+ * Also serve the WhatsApp Mobile transport on a raw TCP listener. Mobile
55
+ * clients dial `tcp://host:port` instead of upgrading to a WebSocket, but
56
+ * speak the same framing above the carrier, so both listeners share one
57
+ * server identity, session model, and IQ router. Pass `true` for an
58
+ * ephemeral port on the WebSocket host, or an object to pin host/port.
59
+ * Read the address back from {@link FakeWaServer.tcpUrl} and feed it to the
60
+ * client's `mobileTransport.tcpUrl`.
61
+ */
62
+ readonly tcp?: boolean | {
63
+ readonly host?: string;
64
+ readonly port?: number;
65
+ };
66
+ }
67
+ export interface FakeSessionKeyInfo {
68
+ readonly clientPayload: ParsedClientPayload;
69
+ readonly pipeline: WaFakeConnectionPipeline;
25
70
  }
26
71
  export interface FakeWaServerNoiseRootCa {
27
72
  readonly publicKey: Uint8Array;
28
73
  readonly serial: number;
29
74
  }
30
75
  export type FakeWaServerPipelineListener = (pipeline: WaFakeConnectionPipeline) => void;
31
- export interface ExpectIqOptions {
32
- readonly timeoutMs?: number;
33
- }
34
- export interface ExpectStanzaOptions {
35
- readonly timeoutMs?: number;
36
- }
37
- export interface StanzaMatcher {
38
- readonly tag?: string;
39
- readonly type?: string;
40
- readonly xmlns?: string;
41
- readonly from?: string;
42
- readonly to?: string;
43
- readonly childTag?: string;
44
- }
76
+ export { FakeServerSession } from './FakeServerSession';
77
+ export type { ExpectIqOptions, ExpectStanzaOptions, StanzaMatcher } from './FakeServerSession';
45
78
  export interface CapturedMediaUpload {
46
79
  readonly path: string;
47
80
  readonly mediaType: string;
@@ -51,22 +84,23 @@ export interface CapturedMediaUpload {
51
84
  readonly receivedAtMs: number;
52
85
  }
53
86
  export declare class FakeWaServer {
54
- readonly registries: ServerRegistries;
55
- readonly preKeyDispenser: PreKeyDispenser;
56
- readonly appStateSync: AppStateSyncManager;
57
87
  private readonly wsServer;
58
88
  private readonly pipelines;
59
- private readonly iqRouter;
60
- private readonly capturedStanzas;
61
- private readonly pendingIqExpectations;
62
- private readonly pendingStanzaExpectations;
89
+ private readonly defaultSession;
90
+ private readonly pipelineSessions;
63
91
  private readonly authenticatedListeners;
64
- private readonly inboundStanzaListeners;
65
- private readonly capturedStanzaListeners;
92
+ private readonly sessions;
93
+ private readonly globalIqHandlers;
94
+ private readonly options;
66
95
  private rootCa;
67
96
  private serverStaticKeyPair;
68
97
  private listenInfo;
69
- private pipelineListener;
98
+ private readonly tcpServer;
99
+ private tcpListenInfo;
100
+ private readonly pendingCompanionOffers;
101
+ private readonly mobilePrimaryPipelines;
102
+ private readonly linkCodeSessions;
103
+ private readonly pipelineListeners;
70
104
  private rejectMode;
71
105
  private readonly mediaStore;
72
106
  private readonly mediaHttpsServer;
@@ -74,7 +108,22 @@ export declare class FakeWaServer {
74
108
  private nextUploadCounter;
75
109
  private cachedMediaProxyAgent;
76
110
  constructor(options?: FakeWaServerOptions);
77
- private buildIqHandlerDeps;
111
+ /** State of the single default session (the only session for one client). */
112
+ get registries(): ServerRegistries;
113
+ /** Companion-host account state of the default session (linked devices, key-index list). */
114
+ get companionHost(): FakeCompanionHostState;
115
+ get preKeyDispenser(): PreKeyDispenser;
116
+ get appStateSync(): AppStateSyncManager;
117
+ /**
118
+ * Returns the isolated session for `id`, creating it on first use. Every
119
+ * session gets its own registries, prekey dispenser, app-state, IQ router,
120
+ * and stanza capture. Use with the `sessionKey` option to run several
121
+ * `WaClient`s against one server without them sharing any state.
122
+ */
123
+ session(id: string): FakeServerSession;
124
+ /** The session a connection is bound to (default before authentication). */
125
+ sessionFor(pipeline: WaFakeConnectionPipeline): FakeServerSession;
126
+ private createSession;
78
127
  registerIqHandler(matcher: WaFakeIqMatcher, respond: WaFakeIqResponder, label?: string): () => void;
79
128
  routeIqForTest(iq: BinaryNode): Promise<BinaryNode | null>;
80
129
  onOutboundGroupOp(listener: (op: CapturedGroupOp) => void): () => void;
@@ -119,18 +168,26 @@ export declare class FakeWaServer {
119
168
  expectAppStateMutation(predicate: (mutation: CapturedAppStateMutation) => boolean, timeoutMs?: number): Promise<CapturedAppStateMutation>;
120
169
  provideAppStateCollection(name: string, provider: () => Promise<FakeAppStateCollectionPayload | null> | FakeAppStateCollectionPayload | null): () => void;
121
170
  pushServerSyncNotification(pipeline: WaFakeConnectionPipeline, input: BuildServerSyncNotificationInput): Promise<void>;
171
+ /**
172
+ * Pushes the account's device set to a mobile primary as `account_sync`.
173
+ * Defaults to what this session tracks - the primary itself plus every
174
+ * companion it linked - so passing a shorter list is how a test tells the
175
+ * primary that a device disappeared while it was offline.
176
+ */
177
+ pushAccountSyncDevices(pipeline: WaFakeConnectionPipeline, options?: {
178
+ readonly devices?: readonly FakeAccountDevice[];
179
+ }): Promise<void>;
122
180
  onAuthenticatedPipeline(listener: AuthenticatedPipelineListener): () => void;
123
181
  scenario(configure: (s: Scenario) => void): void;
124
182
  expectIq(matcher: WaFakeIqMatcher, options?: ExpectIqOptions): Promise<BinaryNode>;
125
183
  capturedStanzaSnapshot(): readonly BinaryNode[];
126
184
  /**
127
- * Subscribes to every stanza captured from the client side (the lib).
128
- * The listener is called synchronously for each new stanza as it
129
- * arrives, in addition to `capturedStanzas` being appended. Returns
130
- * an unsubscribe function. Useful for benches that need to count or
131
- * react to a stream of receipts/messages without paying the O(N²)
132
- * cost of polling `capturedStanzaSnapshot()` or queuing one
133
- * `expectStanza(...)` per iteration.
185
+ * Subscribes to every stanza captured from the client side (the lib) of
186
+ * the default session. The listener is called synchronously for each new
187
+ * stanza as it arrives. Returns an unsubscribe function. Useful for
188
+ * benches that need to count or react to a stream of receipts/messages
189
+ * without paying the O(N²) cost of polling `capturedStanzaSnapshot()` or
190
+ * queuing one `expectStanza(...)` per iteration.
134
191
  */
135
192
  onCapturedStanza(listener: (node: BinaryNode) => void): () => void;
136
193
  expectStanza(matcher: StanzaMatcher, options?: ExpectStanzaOptions): Promise<BinaryNode>;
@@ -162,12 +219,63 @@ export declare class FakeWaServer {
162
219
  get url(): string;
163
220
  get host(): string;
164
221
  get port(): number;
222
+ /**
223
+ * `tcp://host:port` of the mobile listener, for the client's
224
+ * `mobileTransport.tcpUrl`.
225
+ *
226
+ * @throws when the server was started without the `tcp` option.
227
+ */
228
+ get tcpUrl(): string;
165
229
  get noiseRootCa(): FakeWaServerNoiseRootCa;
166
- onPipeline(listener: FakeWaServerPipelineListener): void;
230
+ /**
231
+ * Subscribes to every new connection pipeline (pre-auth). Listeners
232
+ * fan out; returns an unsubscribe function.
233
+ */
234
+ onPipeline(listener: FakeWaServerPipelineListener): () => void;
167
235
  listen(): Promise<void>;
236
+ private rollbackFailedListen;
168
237
  stop(): Promise<void>;
169
238
  private handleConnection;
170
- private handleCapturedStanza;
239
+ /**
240
+ * Rebinds a connection to its keyed session once authenticated. Runs
241
+ * before user `onAuthenticated` listeners and before any client stanza, so
242
+ * captures and IQ routing land in the right session from the first frame.
243
+ * No-op without a `sessionKey` resolver (single default session).
244
+ */
245
+ private bindPipelineSession;
246
+ /** Drops every reference to a closed connection so a relay never targets it. */
247
+ private forgetPipeline;
248
+ /**
249
+ * Records a phone login as the session's account owner. Everything on the
250
+ * companion-host path keys off it: device jids are minted under its number,
251
+ * and `remove-companion-device` is read as a revoke rather than a logout.
252
+ */
253
+ private bindMobilePrimary;
254
+ /**
255
+ * Offers a companion connection the refs for a primary-driven link: pushes
256
+ * the `pair-device` IQ it turns into a QR, and remembers each ref so the
257
+ * primary's upload can be routed back to this connection. Returns the refs.
258
+ *
259
+ * This is the counterpart of {@link runPairing}, where the server itself
260
+ * plays the primary. Here a real mobile-primary client signs the identity
261
+ * and the server only relays.
262
+ */
263
+ offerCompanionPairing(pipeline: WaFakeConnectionPipeline, options?: {
264
+ readonly refCount?: number;
265
+ }): Promise<readonly string[]>;
266
+ /**
267
+ * Relays one link-code stage between the two clients running the pairing
268
+ * handshake. `companion_hello` mints the ref that ties the stages together
269
+ * and registers it as a pairing offer, so the primary's later `pair-device`
270
+ * upload lands on the same companion connection as the QR flow.
271
+ */
272
+ private handleLinkCodeStage;
273
+ /**
274
+ * Host side of a companion link: hands the primary-signed identity to the
275
+ * connection that owns `ref` and reports what that companion advertised at
276
+ * registration. Resolves `null` for an unknown or already-consumed ref.
277
+ */
278
+ private completeCompanionPairing;
171
279
  private buildMediaRequestHandler;
172
280
  private handleMediaUpload;
173
281
  private requireListening;