@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.
- package/README.md +180 -33
- package/dist/api/FakeMobilePrimary.d.ts +55 -0
- package/dist/api/FakeMobilePrimary.js +106 -0
- package/dist/api/FakePairingDriver.js +1 -3
- package/dist/api/FakePeer.js +7 -1
- package/dist/api/FakeServerSession.d.ts +100 -0
- package/dist/api/FakeServerSession.js +355 -0
- package/dist/api/FakeWaServer.d.ts +145 -37
- package/dist/api/FakeWaServer.js +345 -270
- package/dist/api/ServerRegistries.d.ts +7 -0
- package/dist/api/ServerRegistries.js +20 -5
- package/dist/api/iq-handlers.d.ts +20 -1
- package/dist/api/iq-handlers.js +119 -1
- package/dist/bin.js +69 -21
- package/dist/esm/api/FakeMobilePrimary.js +102 -0
- package/dist/esm/api/FakePairingDriver.js +2 -4
- package/dist/esm/api/FakePeer.js +7 -1
- package/dist/esm/api/FakeServerSession.js +349 -0
- package/dist/esm/api/FakeWaServer.js +344 -270
- package/dist/esm/api/ServerRegistries.js +20 -5
- package/dist/esm/api/iq-handlers.js +119 -1
- package/dist/esm/bin.js +67 -19
- package/dist/esm/index.js +9 -1
- package/dist/esm/infra/WaFakeConnection.js +8 -22
- package/dist/esm/infra/WaFakeConnectionPipeline.js +38 -14
- package/dist/esm/infra/WaFakeTcpServer.js +69 -0
- package/dist/esm/infra/WaFakeWsServer.js +2 -1
- package/dist/esm/infra/socket-adapters.js +79 -0
- package/dist/esm/protocol/auth/cert-chain.js +13 -2
- package/dist/esm/protocol/auth/client-payload-validate.js +77 -1
- package/dist/esm/protocol/auth/pair-device.js +16 -1
- package/dist/esm/protocol/iq/companion-host.js +104 -0
- package/dist/esm/protocol/iq/link-code.js +90 -0
- package/dist/esm/protocol/iq/router.js +9 -3
- package/dist/esm/protocol/push/mobile-notification.js +97 -0
- package/dist/esm/state/fake-companion-host.js +158 -0
- package/dist/esm/transport/auth.js +7 -0
- package/dist/esm/transport/crypto.js +1 -1
- package/dist/esm/transport/util.js +2 -1
- package/dist/index.d.ts +19 -4
- package/dist/index.js +29 -1
- package/dist/infra/WaFakeConnection.d.ts +21 -3
- package/dist/infra/WaFakeConnection.js +8 -22
- package/dist/infra/WaFakeConnectionPipeline.d.ts +11 -2
- package/dist/infra/WaFakeConnectionPipeline.js +38 -14
- package/dist/infra/WaFakeTcpServer.d.ts +29 -0
- package/dist/infra/WaFakeTcpServer.js +73 -0
- package/dist/infra/WaFakeWsServer.js +2 -1
- package/dist/infra/socket-adapters.d.ts +18 -0
- package/dist/infra/socket-adapters.js +83 -0
- package/dist/protocol/auth/cert-chain.d.ts +4 -0
- package/dist/protocol/auth/cert-chain.js +13 -2
- package/dist/protocol/auth/client-payload-validate.d.ts +24 -0
- package/dist/protocol/auth/client-payload-validate.js +77 -1
- package/dist/protocol/auth/pair-device.d.ts +15 -1
- package/dist/protocol/auth/pair-device.js +16 -0
- package/dist/protocol/iq/companion-host.d.ts +52 -0
- package/dist/protocol/iq/companion-host.js +109 -0
- package/dist/protocol/iq/link-code.d.ts +46 -0
- package/dist/protocol/iq/link-code.js +95 -0
- package/dist/protocol/iq/router.d.ts +21 -2
- package/dist/protocol/iq/router.js +9 -3
- package/dist/protocol/push/mobile-notification.d.ts +58 -0
- package/dist/protocol/push/mobile-notification.js +102 -0
- package/dist/state/fake-companion-host.d.ts +73 -0
- package/dist/state/fake-companion-host.js +163 -0
- package/dist/transport/auth.d.ts +8 -0
- package/dist/transport/auth.js +8 -0
- package/dist/transport/crypto.d.ts +1 -1
- package/dist/transport/crypto.js +2 -1
- package/dist/transport/util.d.ts +2 -1
- package/dist/transport/util.js +3 -1
- 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
|
+
}
|