@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,349 @@
1
+ /** Per-session isolated server state: registries, prekeys, app-state, IQ routing, capture. */
2
+ import { WaFakeIqRouter } from '../protocol/iq/router.js';
3
+ import { FakeCompanionHostState, readCompanionKeyIndex } from '../state/fake-companion-host.js';
4
+ import { AppStateSyncManager } from './AppStateSyncManager.js';
5
+ import { registerDefaultIqHandlers } from './iq-handlers.js';
6
+ import { PreKeyDispenser } from './PreKeyDispenser.js';
7
+ import { ServerRegistries, toDeviceIdPart } from './ServerRegistries.js';
8
+ const HOST_DOMAIN = 's.whatsapp.net';
9
+ /**
10
+ * One isolated logical device's worth of server state. Every `WaClient`
11
+ * connected to a `FakeWaServer` is bound to exactly one session; peers,
12
+ * groups, privacy, prekeys, app-state, and captured stanzas never leak across
13
+ * sessions. A single-client server uses one default session, which is why the
14
+ * legacy `FakeWaServer` surface delegates straight through to it.
15
+ */
16
+ export class FakeServerSession {
17
+ constructor(id, host, options) {
18
+ this.registries = new ServerRegistries();
19
+ this.preKeyDispenser = new PreKeyDispenser();
20
+ this.appStateSync = new AppStateSyncManager();
21
+ this.companionHost = new FakeCompanionHostState();
22
+ this.iqRouter = new WaFakeIqRouter();
23
+ this.capturedStanzas = [];
24
+ this.pendingIqExpectations = new Set();
25
+ this.pendingStanzaExpectations = new Set();
26
+ this.capturedStanzaListeners = new Set();
27
+ this.inboundStanzaListeners = new Set();
28
+ this.id = id;
29
+ if (options.defaultIqHandlers) {
30
+ registerDefaultIqHandlers(this.iqRouter, this.buildIqHandlerDeps(host));
31
+ }
32
+ }
33
+ registerIqHandler(matcher, respond, label) {
34
+ const handler = { matcher, respond, label };
35
+ return this.iqRouter.register(handler, { priority: 'high' });
36
+ }
37
+ routeIq(iq, context) {
38
+ return this.iqRouter.route(iq, context);
39
+ }
40
+ handleCapturedStanza(node) {
41
+ this.capturedStanzas.push(node);
42
+ for (const listener of this.capturedStanzaListeners) {
43
+ try {
44
+ listener(node);
45
+ }
46
+ catch (error) {
47
+ void error;
48
+ }
49
+ }
50
+ for (const listener of this.inboundStanzaListeners) {
51
+ try {
52
+ listener(node);
53
+ }
54
+ catch (error) {
55
+ void error;
56
+ }
57
+ }
58
+ for (const expectation of this.pendingStanzaExpectations) {
59
+ if (matchesStanza(node, expectation.matcher)) {
60
+ expectation.resolve(node);
61
+ break;
62
+ }
63
+ }
64
+ if (node.tag !== 'iq') {
65
+ return;
66
+ }
67
+ for (const expectation of this.pendingIqExpectations) {
68
+ if (matchesIq(node, expectation.matcher)) {
69
+ expectation.resolve(node);
70
+ return;
71
+ }
72
+ }
73
+ }
74
+ subscribeInboundMessages(listener) {
75
+ const wrapped = (node) => {
76
+ if (node.tag !== 'message')
77
+ return;
78
+ listener(node);
79
+ };
80
+ this.inboundStanzaListeners.add(wrapped);
81
+ return () => {
82
+ this.inboundStanzaListeners.delete(wrapped);
83
+ };
84
+ }
85
+ onCapturedStanza(listener) {
86
+ this.capturedStanzaListeners.add(listener);
87
+ return () => {
88
+ this.capturedStanzaListeners.delete(listener);
89
+ };
90
+ }
91
+ capturedStanzaSnapshot() {
92
+ return this.capturedStanzas.slice();
93
+ }
94
+ expectIq(matcher, options = {}) {
95
+ const timeoutMs = options.timeoutMs ?? 2000;
96
+ for (const captured of this.capturedStanzas) {
97
+ if (matchesIq(captured, matcher)) {
98
+ return Promise.resolve(captured);
99
+ }
100
+ }
101
+ return new Promise((resolve, reject) => {
102
+ const expectation = {
103
+ matcher,
104
+ resolve: (iq) => {
105
+ this.pendingIqExpectations.delete(expectation);
106
+ clearTimeout(expectation.timer);
107
+ resolve(iq);
108
+ },
109
+ reject: (error) => {
110
+ this.pendingIqExpectations.delete(expectation);
111
+ clearTimeout(expectation.timer);
112
+ reject(error);
113
+ },
114
+ timer: setTimeout(() => {
115
+ this.pendingIqExpectations.delete(expectation);
116
+ reject(new Error(`expectIq timed out after ${timeoutMs}ms (${describeMatcher(matcher)})`));
117
+ }, timeoutMs)
118
+ };
119
+ this.pendingIqExpectations.add(expectation);
120
+ });
121
+ }
122
+ expectStanza(matcher, options = {}) {
123
+ const timeoutMs = options.timeoutMs ?? 2000;
124
+ for (const captured of this.capturedStanzas) {
125
+ if (matchesStanza(captured, matcher)) {
126
+ return Promise.resolve(captured);
127
+ }
128
+ }
129
+ return new Promise((resolve, reject) => {
130
+ const expectation = {
131
+ matcher,
132
+ resolve: (node) => {
133
+ this.pendingStanzaExpectations.delete(expectation);
134
+ clearTimeout(expectation.timer);
135
+ resolve(node);
136
+ },
137
+ reject: (error) => {
138
+ this.pendingStanzaExpectations.delete(expectation);
139
+ clearTimeout(expectation.timer);
140
+ reject(error);
141
+ },
142
+ timer: setTimeout(() => {
143
+ this.pendingStanzaExpectations.delete(expectation);
144
+ reject(new Error(`expectStanza timed out after ${timeoutMs}ms (${describeStanzaMatcher(matcher)})`));
145
+ }, timeoutMs)
146
+ };
147
+ this.pendingStanzaExpectations.add(expectation);
148
+ });
149
+ }
150
+ buildIqHandlerDeps(host) {
151
+ const reg = this.registries;
152
+ const preKey = this.preKeyDispenser;
153
+ const appState = this.appStateSync;
154
+ const companionHost = this.companionHost;
155
+ return {
156
+ get peerRegistry() {
157
+ return reg.peerRegistry;
158
+ },
159
+ get groupRegistry() {
160
+ return reg.groupRegistry;
161
+ },
162
+ get privacySettings() {
163
+ return reg.privacySettings;
164
+ },
165
+ get blocklistJids() {
166
+ return reg.blocklistJids;
167
+ },
168
+ get profilePicturesByJid() {
169
+ return reg.profilePicturesByJid;
170
+ },
171
+ get businessProfilesByJid() {
172
+ return reg.businessProfilesByJid;
173
+ },
174
+ get abPropsInput() {
175
+ return reg.abPropsInput;
176
+ },
177
+ get issuedPrivacyTokens() {
178
+ return reg.issuedPrivacyTokens;
179
+ },
180
+ get latestStatusText() {
181
+ return reg.latestStatusText;
182
+ },
183
+ setLatestStatusText: (text) => {
184
+ reg.latestStatusText = text;
185
+ },
186
+ lookupDeviceIdsForUser: (userJid) => reg.lookupDeviceIdsForUser(userJid),
187
+ notifyGroupOp: (op) => reg.notifyGroupOp(op),
188
+ mutatePrivacySettings: (category, value) => reg.mutatePrivacySettings(category, value),
189
+ mutateBlocklist: (action, jid) => reg.mutateBlocklist(action, jid),
190
+ notifyProfilePictureSet: (op) => reg.notifyProfilePictureSet(op),
191
+ handleProfilePictureSet: (targetJid, newId) => reg.handleProfilePictureSet(targetJid, newId),
192
+ notifyStatusSet: (text) => reg.notifyStatusSet(text),
193
+ notifyLogout: () => reg.notifyLogout(),
194
+ notifyPrivacyTokenIssue: (token) => reg.notifyPrivacyTokenIssue(token),
195
+ notifyDirtyBitsClear: (op) => reg.notifyDirtyBitsClear(op),
196
+ notifyPrivacySet: (change) => {
197
+ for (const listener of reg.privacySetListeners) {
198
+ try {
199
+ listener(change);
200
+ }
201
+ catch (error) {
202
+ void error;
203
+ }
204
+ }
205
+ },
206
+ notifyBlocklistChange: (change) => {
207
+ for (const listener of reg.blocklistChangeListeners) {
208
+ try {
209
+ listener(change);
210
+ }
211
+ catch (error) {
212
+ void error;
213
+ }
214
+ }
215
+ },
216
+ capturePreKeyBundle: (bundle) => preKey.captureBundle(bundle),
217
+ countServerPreKeys: () => preKey.preKeysAvailable(),
218
+ consumeOutboundAppStatePatches: (iq) => appState.consumeOutboundAppStatePatches(iq),
219
+ get appStateCollectionProviders() {
220
+ return appState.appStateCollectionProviders;
221
+ },
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)
230
+ };
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
+ }
283
+ }
284
+ export function matchesIq(iq, matcher) {
285
+ if (iq.tag !== 'iq')
286
+ return false;
287
+ if (matcher.id !== undefined && iq.attrs.id !== matcher.id)
288
+ return false;
289
+ if (matcher.type !== undefined && iq.attrs.type !== matcher.type)
290
+ return false;
291
+ if (matcher.xmlns !== undefined && iq.attrs.xmlns !== matcher.xmlns)
292
+ return false;
293
+ if (matcher.childTag !== undefined) {
294
+ const children = Array.isArray(iq.content) ? iq.content : null;
295
+ if (!children || children.length === 0)
296
+ return false;
297
+ if (children[0].tag !== matcher.childTag)
298
+ return false;
299
+ }
300
+ return true;
301
+ }
302
+ function describeMatcher(matcher) {
303
+ const parts = [];
304
+ if (matcher.id !== undefined)
305
+ parts.push(`id=${matcher.id}`);
306
+ if (matcher.type !== undefined)
307
+ parts.push(`type=${matcher.type}`);
308
+ if (matcher.xmlns !== undefined)
309
+ parts.push(`xmlns=${matcher.xmlns}`);
310
+ if (matcher.childTag !== undefined)
311
+ parts.push(`childTag=${matcher.childTag}`);
312
+ return parts.length > 0 ? parts.join(', ') : 'any iq';
313
+ }
314
+ export function matchesStanza(node, matcher) {
315
+ if (matcher.tag !== undefined && node.tag !== matcher.tag)
316
+ return false;
317
+ if (matcher.type !== undefined && node.attrs.type !== matcher.type)
318
+ return false;
319
+ if (matcher.xmlns !== undefined && node.attrs.xmlns !== matcher.xmlns)
320
+ return false;
321
+ if (matcher.from !== undefined && node.attrs.from !== matcher.from)
322
+ return false;
323
+ if (matcher.to !== undefined && node.attrs.to !== matcher.to)
324
+ return false;
325
+ if (matcher.childTag !== undefined) {
326
+ const children = Array.isArray(node.content) ? node.content : null;
327
+ if (!children || children.length === 0)
328
+ return false;
329
+ if (children[0].tag !== matcher.childTag)
330
+ return false;
331
+ }
332
+ return true;
333
+ }
334
+ function describeStanzaMatcher(matcher) {
335
+ const parts = [];
336
+ if (matcher.tag !== undefined)
337
+ parts.push(`tag=${matcher.tag}`);
338
+ if (matcher.type !== undefined)
339
+ parts.push(`type=${matcher.type}`);
340
+ if (matcher.xmlns !== undefined)
341
+ parts.push(`xmlns=${matcher.xmlns}`);
342
+ if (matcher.from !== undefined)
343
+ parts.push(`from=${matcher.from}`);
344
+ if (matcher.to !== undefined)
345
+ parts.push(`to=${matcher.to}`);
346
+ if (matcher.childTag !== undefined)
347
+ parts.push(`childTag=${matcher.childTag}`);
348
+ return parts.length > 0 ? parts.join(', ') : 'any stanza';
349
+ }