@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
|
@@ -1,124 +1,129 @@
|
|
|
1
1
|
import { Agent as HttpsAgent } from 'node:https';
|
|
2
2
|
import { WaFakeConnectionPipeline } from '../infra/WaFakeConnectionPipeline.js';
|
|
3
3
|
import { WaFakeMediaHttpsServer } from '../infra/WaFakeMediaHttpsServer.js';
|
|
4
|
+
import { WaFakeTcpServer } from '../infra/WaFakeTcpServer.js';
|
|
4
5
|
import { WaFakeWsServer } from '../infra/WaFakeWsServer.js';
|
|
5
6
|
import { generateFakeNoiseRootCa } from '../protocol/auth/cert-chain.js';
|
|
6
|
-
import {
|
|
7
|
+
import { buildPairDeviceIq, buildPairSuccessIq, mintPairingRefs } from '../protocol/auth/pair-device.js';
|
|
8
|
+
import { buildCompanionHelloResultContent, buildLinkCodeNotification, parseLinkCodeStanza } from '../protocol/iq/link-code.js';
|
|
9
|
+
import { buildIqError, buildIqResult } from '../protocol/iq/router.js';
|
|
10
|
+
import { buildAccountSyncDevicesNotification } from '../protocol/push/mobile-notification.js';
|
|
7
11
|
import { FakeMediaStore } from '../state/fake-media-store.js';
|
|
8
12
|
import { X25519 } from '../transport/crypto.js';
|
|
9
|
-
import { AppStateSyncManager } from './AppStateSyncManager.js';
|
|
10
13
|
import { FakePairingDriver } from './FakePairingDriver.js';
|
|
11
14
|
import { FakePeer } from './FakePeer.js';
|
|
12
|
-
import {
|
|
13
|
-
import { PreKeyDispenser } from './PreKeyDispenser.js';
|
|
15
|
+
import { FakeServerSession } from './FakeServerSession.js';
|
|
14
16
|
import { Scenario } from './Scenario.js';
|
|
15
|
-
|
|
17
|
+
const HOST_DOMAIN = 's.whatsapp.net';
|
|
18
|
+
const MOBILE_PRIMARY_PLATFORM = 'android';
|
|
19
|
+
/**
|
|
20
|
+
* Builds the mobile TCP listener when the option asks for one. Defaults its
|
|
21
|
+
* host to the WebSocket host so both listeners answer on the same interface.
|
|
22
|
+
*/
|
|
23
|
+
function createTcpServer(options) {
|
|
24
|
+
if (!options.tcp) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const config = options.tcp === true ? {} : options.tcp;
|
|
28
|
+
return new WaFakeTcpServer({
|
|
29
|
+
host: config.host ?? options.host,
|
|
30
|
+
port: config.port
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
export { FakeServerSession } from './FakeServerSession.js';
|
|
16
34
|
export class FakeWaServer {
|
|
17
35
|
constructor(options = {}) {
|
|
18
|
-
this.registries = new ServerRegistries();
|
|
19
|
-
this.preKeyDispenser = new PreKeyDispenser();
|
|
20
|
-
this.appStateSync = new AppStateSyncManager();
|
|
21
36
|
this.pipelines = new Set();
|
|
22
|
-
this.
|
|
23
|
-
this.capturedStanzas = [];
|
|
24
|
-
this.pendingIqExpectations = new Set();
|
|
25
|
-
this.pendingStanzaExpectations = new Set();
|
|
37
|
+
this.pipelineSessions = new WeakMap();
|
|
26
38
|
this.authenticatedListeners = new Set();
|
|
27
|
-
this.
|
|
28
|
-
this.
|
|
39
|
+
this.sessions = new Map();
|
|
40
|
+
this.globalIqHandlers = new Set();
|
|
29
41
|
this.rootCa = null;
|
|
30
42
|
this.serverStaticKeyPair = null;
|
|
31
43
|
this.listenInfo = null;
|
|
32
|
-
this.
|
|
44
|
+
this.tcpListenInfo = null;
|
|
45
|
+
this.pendingCompanionOffers = new Map();
|
|
46
|
+
this.mobilePrimaryPipelines = new Map();
|
|
47
|
+
this.linkCodeSessions = new Map();
|
|
48
|
+
this.pipelineListeners = new Set();
|
|
33
49
|
this.rejectMode = null;
|
|
34
50
|
this.mediaStore = new FakeMediaStore();
|
|
35
51
|
this.mediaHttpsServer = new WaFakeMediaHttpsServer();
|
|
36
52
|
this.capturedMediaUploads = [];
|
|
37
53
|
this.nextUploadCounter = 0;
|
|
38
54
|
this.cachedMediaProxyAgent = null;
|
|
55
|
+
this.options = options;
|
|
39
56
|
this.wsServer = new WaFakeWsServer(options);
|
|
40
57
|
this.wsServer.onConnection((connection) => this.handleConnection(connection));
|
|
41
|
-
|
|
58
|
+
this.tcpServer = createTcpServer(options);
|
|
59
|
+
this.tcpServer?.onConnection((connection) => this.handleConnection(connection));
|
|
60
|
+
this.defaultSession = this.createSession('__default__');
|
|
42
61
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
notifyPrivacyTokenIssue: (token) => reg.notifyPrivacyTokenIssue(token),
|
|
87
|
-
notifyDirtyBitsClear: (op) => reg.notifyDirtyBitsClear(op),
|
|
88
|
-
notifyPrivacySet: (change) => {
|
|
89
|
-
for (const listener of reg.privacySetListeners) {
|
|
90
|
-
try {
|
|
91
|
-
listener(change);
|
|
92
|
-
}
|
|
93
|
-
catch (error) {
|
|
94
|
-
void error;
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
},
|
|
98
|
-
notifyBlocklistChange: (change) => {
|
|
99
|
-
for (const listener of reg.blocklistChangeListeners) {
|
|
100
|
-
try {
|
|
101
|
-
listener(change);
|
|
102
|
-
}
|
|
103
|
-
catch (error) {
|
|
104
|
-
void error;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
},
|
|
108
|
-
capturePreKeyBundle: (bundle) => preKey.captureBundle(bundle),
|
|
109
|
-
consumeOutboundAppStatePatches: (iq) => appState.consumeOutboundAppStatePatches(iq),
|
|
110
|
-
get appStateCollectionProviders() {
|
|
111
|
-
return appState.appStateCollectionProviders;
|
|
112
|
-
},
|
|
113
|
-
requireMediaHttpsInfo: () => this.requireMediaHttpsInfo()
|
|
114
|
-
};
|
|
62
|
+
/** State of the single default session (the only session for one client). */
|
|
63
|
+
get registries() {
|
|
64
|
+
return this.defaultSession.registries;
|
|
65
|
+
}
|
|
66
|
+
/** Companion-host account state of the default session (linked devices, key-index list). */
|
|
67
|
+
get companionHost() {
|
|
68
|
+
return this.defaultSession.companionHost;
|
|
69
|
+
}
|
|
70
|
+
get preKeyDispenser() {
|
|
71
|
+
return this.defaultSession.preKeyDispenser;
|
|
72
|
+
}
|
|
73
|
+
get appStateSync() {
|
|
74
|
+
return this.defaultSession.appStateSync;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Returns the isolated session for `id`, creating it on first use. Every
|
|
78
|
+
* session gets its own registries, prekey dispenser, app-state, IQ router,
|
|
79
|
+
* and stanza capture. Use with the `sessionKey` option to run several
|
|
80
|
+
* `WaClient`s against one server without them sharing any state.
|
|
81
|
+
*/
|
|
82
|
+
session(id) {
|
|
83
|
+
const existing = this.sessions.get(id);
|
|
84
|
+
if (existing) {
|
|
85
|
+
return existing;
|
|
86
|
+
}
|
|
87
|
+
return this.createSession(id);
|
|
88
|
+
}
|
|
89
|
+
/** The session a connection is bound to (default before authentication). */
|
|
90
|
+
sessionFor(pipeline) {
|
|
91
|
+
return this.pipelineSessions.get(pipeline) ?? this.defaultSession;
|
|
92
|
+
}
|
|
93
|
+
createSession(id) {
|
|
94
|
+
const session = new FakeServerSession(id, {
|
|
95
|
+
requireMediaHttpsInfo: () => this.requireMediaHttpsInfo(),
|
|
96
|
+
completeCompanionPairing: (input) => this.completeCompanionPairing(input),
|
|
97
|
+
relayLinkCodeStage: (iq, context) => this.handleLinkCodeStage(iq, context)
|
|
98
|
+
}, { defaultIqHandlers: this.options.defaultIqHandlers !== false });
|
|
99
|
+
this.sessions.set(id, session);
|
|
100
|
+
// Server-level custom handlers apply to every session, present and future.
|
|
101
|
+
for (const handler of this.globalIqHandlers) {
|
|
102
|
+
handler.unregisterBySession.set(session, session.registerIqHandler(handler.matcher, handler.respond, handler.label));
|
|
103
|
+
}
|
|
104
|
+
return session;
|
|
115
105
|
}
|
|
116
106
|
registerIqHandler(matcher, respond, label) {
|
|
117
|
-
const
|
|
118
|
-
|
|
107
|
+
const entry = {
|
|
108
|
+
matcher,
|
|
109
|
+
respond,
|
|
110
|
+
label,
|
|
111
|
+
unregisterBySession: new Map()
|
|
112
|
+
};
|
|
113
|
+
for (const session of this.sessions.values()) {
|
|
114
|
+
entry.unregisterBySession.set(session, session.registerIqHandler(matcher, respond, label));
|
|
115
|
+
}
|
|
116
|
+
this.globalIqHandlers.add(entry);
|
|
117
|
+
return () => {
|
|
118
|
+
for (const unregister of entry.unregisterBySession.values()) {
|
|
119
|
+
unregister();
|
|
120
|
+
}
|
|
121
|
+
entry.unregisterBySession.clear();
|
|
122
|
+
this.globalIqHandlers.delete(entry);
|
|
123
|
+
};
|
|
119
124
|
}
|
|
120
125
|
async routeIqForTest(iq) {
|
|
121
|
-
return this.
|
|
126
|
+
return this.defaultSession.routeIq(iq);
|
|
122
127
|
}
|
|
123
128
|
onOutboundGroupOp(listener) {
|
|
124
129
|
return this.registries.onOutboundGroupOp(listener);
|
|
@@ -175,7 +180,7 @@ export class FakeWaServer {
|
|
|
175
180
|
return this.registries.createFakeGroup(input);
|
|
176
181
|
}
|
|
177
182
|
async triggerPreKeyUpload(pipeline, options = {}) {
|
|
178
|
-
return this.preKeyDispenser.triggerPreKeyUpload(pipeline, options);
|
|
183
|
+
return this.sessionFor(pipeline).preKeyDispenser.triggerPreKeyUpload(pipeline, options);
|
|
179
184
|
}
|
|
180
185
|
awaitPreKeyBundle(timeoutMs = 15000) {
|
|
181
186
|
return this.preKeyDispenser.awaitPreKeyBundle(timeoutMs);
|
|
@@ -205,7 +210,28 @@ export class FakeWaServer {
|
|
|
205
210
|
return this.appStateSync.provideAppStateCollection(name, provider);
|
|
206
211
|
}
|
|
207
212
|
async pushServerSyncNotification(pipeline, input) {
|
|
208
|
-
return this.appStateSync.pushServerSyncNotification(pipeline, input);
|
|
213
|
+
return this.sessionFor(pipeline).appStateSync.pushServerSyncNotification(pipeline, input);
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Pushes the account's device set to a mobile primary as `account_sync`.
|
|
217
|
+
* Defaults to what this session tracks - the primary itself plus every
|
|
218
|
+
* companion it linked - so passing a shorter list is how a test tells the
|
|
219
|
+
* primary that a device disappeared while it was offline.
|
|
220
|
+
*/
|
|
221
|
+
async pushAccountSyncDevices(pipeline, options = {}) {
|
|
222
|
+
const session = this.sessionFor(pipeline);
|
|
223
|
+
const primary = session.companionHost.primary;
|
|
224
|
+
if (!primary && !options.devices) {
|
|
225
|
+
throw new Error('cannot push account_sync: this session has no mobile primary');
|
|
226
|
+
}
|
|
227
|
+
const devices = options.devices ?? [
|
|
228
|
+
{ deviceJid: primary.jid, keyIndex: 0 },
|
|
229
|
+
...session.companionHost.linkedCompanions().map((companion) => ({
|
|
230
|
+
deviceJid: companion.deviceJid,
|
|
231
|
+
keyIndex: companion.keyIndex
|
|
232
|
+
}))
|
|
233
|
+
];
|
|
234
|
+
await pipeline.sendStanza(buildAccountSyncDevicesNotification({ devices }));
|
|
209
235
|
}
|
|
210
236
|
onAuthenticatedPipeline(listener) {
|
|
211
237
|
this.authenticatedListeners.add(listener);
|
|
@@ -215,78 +241,24 @@ export class FakeWaServer {
|
|
|
215
241
|
configure(new Scenario(this));
|
|
216
242
|
}
|
|
217
243
|
expectIq(matcher, options = {}) {
|
|
218
|
-
|
|
219
|
-
for (const captured of this.capturedStanzas) {
|
|
220
|
-
if (matchesIq(captured, matcher)) {
|
|
221
|
-
return Promise.resolve(captured);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
return new Promise((resolve, reject) => {
|
|
225
|
-
const expectation = {
|
|
226
|
-
matcher,
|
|
227
|
-
resolve: (iq) => {
|
|
228
|
-
this.pendingIqExpectations.delete(expectation);
|
|
229
|
-
clearTimeout(expectation.timer);
|
|
230
|
-
resolve(iq);
|
|
231
|
-
},
|
|
232
|
-
reject: (error) => {
|
|
233
|
-
this.pendingIqExpectations.delete(expectation);
|
|
234
|
-
clearTimeout(expectation.timer);
|
|
235
|
-
reject(error);
|
|
236
|
-
},
|
|
237
|
-
timer: setTimeout(() => {
|
|
238
|
-
this.pendingIqExpectations.delete(expectation);
|
|
239
|
-
reject(new Error(`expectIq timed out after ${timeoutMs}ms (${describeMatcher(matcher)})`));
|
|
240
|
-
}, timeoutMs)
|
|
241
|
-
};
|
|
242
|
-
this.pendingIqExpectations.add(expectation);
|
|
243
|
-
});
|
|
244
|
+
return this.defaultSession.expectIq(matcher, options);
|
|
244
245
|
}
|
|
245
246
|
capturedStanzaSnapshot() {
|
|
246
|
-
return this.
|
|
247
|
+
return this.defaultSession.capturedStanzaSnapshot();
|
|
247
248
|
}
|
|
248
249
|
/**
|
|
249
|
-
* Subscribes to every stanza captured from the client side (the lib)
|
|
250
|
-
* The listener is called synchronously for each new
|
|
251
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
* `expectStanza(...)` per iteration.
|
|
250
|
+
* Subscribes to every stanza captured from the client side (the lib) of
|
|
251
|
+
* the default session. The listener is called synchronously for each new
|
|
252
|
+
* stanza as it arrives. Returns an unsubscribe function. Useful for
|
|
253
|
+
* benches that need to count or react to a stream of receipts/messages
|
|
254
|
+
* without paying the O(N²) cost of polling `capturedStanzaSnapshot()` or
|
|
255
|
+
* queuing one `expectStanza(...)` per iteration.
|
|
256
256
|
*/
|
|
257
257
|
onCapturedStanza(listener) {
|
|
258
|
-
this.
|
|
259
|
-
return () => {
|
|
260
|
-
this.capturedStanzaListeners.delete(listener);
|
|
261
|
-
};
|
|
258
|
+
return this.defaultSession.onCapturedStanza(listener);
|
|
262
259
|
}
|
|
263
260
|
expectStanza(matcher, options = {}) {
|
|
264
|
-
|
|
265
|
-
for (const captured of this.capturedStanzas) {
|
|
266
|
-
if (matchesStanza(captured, matcher)) {
|
|
267
|
-
return Promise.resolve(captured);
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
return new Promise((resolve, reject) => {
|
|
271
|
-
const expectation = {
|
|
272
|
-
matcher,
|
|
273
|
-
resolve: (node) => {
|
|
274
|
-
this.pendingStanzaExpectations.delete(expectation);
|
|
275
|
-
clearTimeout(expectation.timer);
|
|
276
|
-
resolve(node);
|
|
277
|
-
},
|
|
278
|
-
reject: (error) => {
|
|
279
|
-
this.pendingStanzaExpectations.delete(expectation);
|
|
280
|
-
clearTimeout(expectation.timer);
|
|
281
|
-
reject(error);
|
|
282
|
-
},
|
|
283
|
-
timer: setTimeout(() => {
|
|
284
|
-
this.pendingStanzaExpectations.delete(expectation);
|
|
285
|
-
reject(new Error(`expectStanza timed out after ${timeoutMs}ms (${describeStanzaMatcher(matcher)})`));
|
|
286
|
-
}, timeoutMs)
|
|
287
|
-
};
|
|
288
|
-
this.pendingStanzaExpectations.add(expectation);
|
|
289
|
-
});
|
|
261
|
+
return this.defaultSession.expectStanza(matcher, options);
|
|
290
262
|
}
|
|
291
263
|
async broadcastStanza(node) {
|
|
292
264
|
const tasks = [];
|
|
@@ -348,8 +320,9 @@ export class FakeWaServer {
|
|
|
348
320
|
return this.capturedMediaUploads.slice();
|
|
349
321
|
}
|
|
350
322
|
async createFakePeer(options, pipeline) {
|
|
351
|
-
const
|
|
352
|
-
this.
|
|
323
|
+
const session = this.sessionFor(pipeline);
|
|
324
|
+
const peer = await FakePeer.create(options, this.buildFakePeerDeps(pipeline, session));
|
|
325
|
+
session.registries.registerPeer(peer);
|
|
353
326
|
return peer;
|
|
354
327
|
}
|
|
355
328
|
async createFakePeerWithDevices(input, pipeline) {
|
|
@@ -362,6 +335,7 @@ export class FakeWaServer {
|
|
|
362
335
|
}
|
|
363
336
|
const userPart = input.userJid.slice(0, atIdx);
|
|
364
337
|
const server = input.userJid.slice(atIdx + 1);
|
|
338
|
+
const session = this.sessionFor(pipeline);
|
|
365
339
|
const peers = [];
|
|
366
340
|
for (const deviceId of input.deviceIds) {
|
|
367
341
|
const deviceJid = deviceId === 0 ? input.userJid : `${userPart}:${deviceId}@${server}`;
|
|
@@ -369,36 +343,27 @@ export class FakeWaServer {
|
|
|
369
343
|
jid: deviceJid,
|
|
370
344
|
displayName: input.displayName,
|
|
371
345
|
skipOneTimePreKey: input.skipOneTimePreKey
|
|
372
|
-
}, this.buildFakePeerDeps(pipeline));
|
|
373
|
-
|
|
346
|
+
}, this.buildFakePeerDeps(pipeline, session));
|
|
347
|
+
session.registries.registerPeer(peer);
|
|
374
348
|
peers.push(peer);
|
|
375
349
|
}
|
|
376
350
|
return peers;
|
|
377
351
|
}
|
|
378
|
-
buildFakePeerDeps(pipeline) {
|
|
352
|
+
buildFakePeerDeps(pipeline, session) {
|
|
379
353
|
return {
|
|
380
|
-
bundleResolver: () =>
|
|
381
|
-
reserveOneTimePreKey: () =>
|
|
354
|
+
bundleResolver: () => session.preKeyDispenser.awaitPreKeyBundle(),
|
|
355
|
+
reserveOneTimePreKey: () => session.preKeyDispenser.dispenseOneTimePreKey(),
|
|
382
356
|
pushStanza: (stanza) => pipeline.sendStanza(stanza),
|
|
383
|
-
subscribeInboundMessages: (listener) =>
|
|
384
|
-
const wrapped = (node) => {
|
|
385
|
-
if (node.tag !== 'message')
|
|
386
|
-
return;
|
|
387
|
-
listener(node);
|
|
388
|
-
};
|
|
389
|
-
this.inboundStanzaListeners.add(wrapped);
|
|
390
|
-
return () => {
|
|
391
|
-
this.inboundStanzaListeners.delete(wrapped);
|
|
392
|
-
};
|
|
393
|
-
}
|
|
357
|
+
subscribeInboundMessages: (listener) => session.subscribeInboundMessages(listener)
|
|
394
358
|
};
|
|
395
359
|
}
|
|
396
360
|
async runPairing(pipeline, options, companionMaterialResolver) {
|
|
361
|
+
const session = this.sessionFor(pipeline);
|
|
397
362
|
const driver = new FakePairingDriver(options, {
|
|
398
363
|
pipeline,
|
|
399
364
|
companionMaterialResolver,
|
|
400
365
|
waitForPairDeviceAck: async (pairDeviceIqId) => {
|
|
401
|
-
await
|
|
366
|
+
await session.expectIq({
|
|
402
367
|
id: pairDeviceIqId
|
|
403
368
|
}, {
|
|
404
369
|
timeoutMs: 15000
|
|
@@ -431,12 +396,31 @@ export class FakeWaServer {
|
|
|
431
396
|
get port() {
|
|
432
397
|
return this.requireListening().port;
|
|
433
398
|
}
|
|
399
|
+
/**
|
|
400
|
+
* `tcp://host:port` of the mobile listener, for the client's
|
|
401
|
+
* `mobileTransport.tcpUrl`.
|
|
402
|
+
*
|
|
403
|
+
* @throws when the server was started without the `tcp` option.
|
|
404
|
+
*/
|
|
405
|
+
get tcpUrl() {
|
|
406
|
+
if (!this.tcpListenInfo) {
|
|
407
|
+
throw new Error('fake server has no mobile listener; start it with { tcp: true } to serve the mobile transport');
|
|
408
|
+
}
|
|
409
|
+
return this.tcpListenInfo.url;
|
|
410
|
+
}
|
|
434
411
|
get noiseRootCa() {
|
|
435
412
|
const root = this.requireRootCa();
|
|
436
413
|
return { publicKey: root.publicKey, serial: root.serial };
|
|
437
414
|
}
|
|
415
|
+
/**
|
|
416
|
+
* Subscribes to every new connection pipeline (pre-auth). Listeners
|
|
417
|
+
* fan out; returns an unsubscribe function.
|
|
418
|
+
*/
|
|
438
419
|
onPipeline(listener) {
|
|
439
|
-
this.
|
|
420
|
+
this.pipelineListeners.add(listener);
|
|
421
|
+
return () => {
|
|
422
|
+
this.pipelineListeners.delete(listener);
|
|
423
|
+
};
|
|
440
424
|
}
|
|
441
425
|
async listen() {
|
|
442
426
|
if (this.listenInfo) {
|
|
@@ -451,11 +435,35 @@ export class FakeWaServer {
|
|
|
451
435
|
this.wsServer.setHttpRequestHandler(mediaHandler);
|
|
452
436
|
this.mediaHttpsServer.setRequestHandler(mediaHandler);
|
|
453
437
|
this.listenInfo = await this.wsServer.listen();
|
|
454
|
-
|
|
438
|
+
try {
|
|
439
|
+
if (this.tcpServer) {
|
|
440
|
+
this.tcpListenInfo = await this.tcpServer.listen();
|
|
441
|
+
}
|
|
442
|
+
await this.mediaHttpsServer.listen('127.0.0.1');
|
|
443
|
+
}
|
|
444
|
+
catch (error) {
|
|
445
|
+
// A half-open server is worse than none: the listeners that did
|
|
446
|
+
// come up would stay bound and `listenInfo` would make a retry a
|
|
447
|
+
// no-op. Roll everything back so the caller can start over.
|
|
448
|
+
await this.rollbackFailedListen();
|
|
449
|
+
throw error;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
async rollbackFailedListen() {
|
|
453
|
+
const ignore = () => undefined;
|
|
454
|
+
await this.wsServer.close().catch(ignore);
|
|
455
|
+
await this.tcpServer?.close().catch(ignore);
|
|
456
|
+
await this.mediaHttpsServer.close().catch(ignore);
|
|
457
|
+
this.listenInfo = null;
|
|
458
|
+
this.tcpListenInfo = null;
|
|
459
|
+
this.rootCa = null;
|
|
460
|
+
this.serverStaticKeyPair = null;
|
|
455
461
|
}
|
|
456
462
|
async stop() {
|
|
457
463
|
this.pipelines.clear();
|
|
458
464
|
await this.wsServer.close();
|
|
465
|
+
await this.tcpServer?.close();
|
|
466
|
+
this.tcpListenInfo = null;
|
|
459
467
|
await this.mediaHttpsServer.close();
|
|
460
468
|
if (this.cachedMediaProxyAgent) {
|
|
461
469
|
this.cachedMediaProxyAgent.destroy();
|
|
@@ -478,53 +486,185 @@ export class FakeWaServer {
|
|
|
478
486
|
connection,
|
|
479
487
|
rootCa: this.rootCa,
|
|
480
488
|
serverStaticKeyPair: this.serverStaticKeyPair,
|
|
481
|
-
|
|
489
|
+
routeIq: (node) => this.sessionFor(pipeline).routeIq(node, { connection: pipeline }),
|
|
490
|
+
...(this.options.successNodeAttributes !== undefined
|
|
491
|
+
? { successNodeAttributes: this.options.successNodeAttributes }
|
|
492
|
+
: {})
|
|
482
493
|
});
|
|
483
494
|
this.pipelines.add(pipeline);
|
|
495
|
+
this.pipelineSessions.set(pipeline, this.defaultSession);
|
|
484
496
|
pipeline.setEvents({
|
|
485
497
|
onAuthenticated: () => {
|
|
498
|
+
this.bindPipelineSession(pipeline);
|
|
499
|
+
this.bindMobilePrimary(pipeline);
|
|
486
500
|
for (const listener of this.authenticatedListeners) {
|
|
487
|
-
|
|
501
|
+
try {
|
|
502
|
+
void Promise.resolve(listener(pipeline)).catch(() => undefined);
|
|
503
|
+
}
|
|
504
|
+
catch {
|
|
505
|
+
/* ignore */
|
|
506
|
+
}
|
|
488
507
|
}
|
|
489
508
|
},
|
|
490
|
-
onStanza: (node) => this.handleCapturedStanza(node),
|
|
491
|
-
onClose: () => this.
|
|
509
|
+
onStanza: (node) => this.sessionFor(pipeline).handleCapturedStanza(node),
|
|
510
|
+
onClose: () => this.forgetPipeline(pipeline)
|
|
492
511
|
});
|
|
493
|
-
this.
|
|
494
|
-
}
|
|
495
|
-
handleCapturedStanza(node) {
|
|
496
|
-
this.capturedStanzas.push(node);
|
|
497
|
-
for (const listener of this.capturedStanzaListeners) {
|
|
512
|
+
for (const listener of this.pipelineListeners) {
|
|
498
513
|
try {
|
|
499
|
-
listener(
|
|
514
|
+
void Promise.resolve(listener(pipeline)).catch(() => undefined);
|
|
500
515
|
}
|
|
501
|
-
catch
|
|
502
|
-
|
|
516
|
+
catch {
|
|
517
|
+
/* ignore */
|
|
503
518
|
}
|
|
504
519
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Rebinds a connection to its keyed session once authenticated. Runs
|
|
523
|
+
* before user `onAuthenticated` listeners and before any client stanza, so
|
|
524
|
+
* captures and IQ routing land in the right session from the first frame.
|
|
525
|
+
* No-op without a `sessionKey` resolver (single default session).
|
|
526
|
+
*/
|
|
527
|
+
bindPipelineSession(pipeline) {
|
|
528
|
+
const resolveKey = this.options.sessionKey;
|
|
529
|
+
const clientPayload = pipeline.clientPayload;
|
|
530
|
+
if (!resolveKey || !clientPayload) {
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const id = resolveKey({ clientPayload, pipeline });
|
|
534
|
+
this.pipelineSessions.set(pipeline, this.session(id));
|
|
535
|
+
}
|
|
536
|
+
/** Drops every reference to a closed connection so a relay never targets it. */
|
|
537
|
+
forgetPipeline(pipeline) {
|
|
538
|
+
this.pipelines.delete(pipeline);
|
|
539
|
+
for (const [ref, offered] of this.pendingCompanionOffers) {
|
|
540
|
+
if (offered === pipeline) {
|
|
541
|
+
this.pendingCompanionOffers.delete(ref);
|
|
508
542
|
}
|
|
509
|
-
|
|
510
|
-
|
|
543
|
+
}
|
|
544
|
+
for (const [jid, primary] of this.mobilePrimaryPipelines) {
|
|
545
|
+
if (primary === pipeline) {
|
|
546
|
+
this.mobilePrimaryPipelines.delete(jid);
|
|
511
547
|
}
|
|
512
548
|
}
|
|
513
|
-
for (const
|
|
514
|
-
if (
|
|
515
|
-
|
|
516
|
-
break;
|
|
549
|
+
for (const [ref, session] of this.linkCodeSessions) {
|
|
550
|
+
if (session.companion === pipeline || session.primary === pipeline) {
|
|
551
|
+
this.linkCodeSessions.delete(ref);
|
|
517
552
|
}
|
|
518
553
|
}
|
|
519
|
-
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Records a phone login as the session's account owner. Everything on the
|
|
557
|
+
* companion-host path keys off it: device jids are minted under its number,
|
|
558
|
+
* and `remove-companion-device` is read as a revoke rather than a logout.
|
|
559
|
+
*/
|
|
560
|
+
bindMobilePrimary(pipeline) {
|
|
561
|
+
const clientPayload = pipeline.clientPayload;
|
|
562
|
+
if (clientPayload?.kind !== 'login' || clientPayload.flavor !== 'mobile') {
|
|
520
563
|
return;
|
|
521
564
|
}
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
565
|
+
const session = this.sessionFor(pipeline);
|
|
566
|
+
const jid = `${clientPayload.username}@${HOST_DOMAIN}`;
|
|
567
|
+
session.companionHost.bindPrimary({ username: clientPayload.username, jid });
|
|
568
|
+
if (session.companionHost.primary?.jid !== jid) {
|
|
569
|
+
// The session already belongs to another number. Wiring this login
|
|
570
|
+
// in anyway would let a link be relayed to this connection and then
|
|
571
|
+
// minted under the account that owns the session.
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
session.registries.registerDeviceId(jid, 0);
|
|
575
|
+
// A reconnect of the same account replaces its stale connection here.
|
|
576
|
+
this.mobilePrimaryPipelines.set(jid, pipeline);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Offers a companion connection the refs for a primary-driven link: pushes
|
|
580
|
+
* the `pair-device` IQ it turns into a QR, and remembers each ref so the
|
|
581
|
+
* primary's upload can be routed back to this connection. Returns the refs.
|
|
582
|
+
*
|
|
583
|
+
* This is the counterpart of {@link runPairing}, where the server itself
|
|
584
|
+
* plays the primary. Here a real mobile-primary client signs the identity
|
|
585
|
+
* and the server only relays.
|
|
586
|
+
*/
|
|
587
|
+
async offerCompanionPairing(pipeline, options = {}) {
|
|
588
|
+
const refs = await mintPairingRefs(options.refCount);
|
|
589
|
+
// Send before registering: the builder rejects a bad ref count and the
|
|
590
|
+
// send can fail, and either would leave refs in the map that no
|
|
591
|
+
// connection will ever consume.
|
|
592
|
+
await pipeline.sendStanza(buildPairDeviceIq({ refs }));
|
|
593
|
+
for (const ref of refs) {
|
|
594
|
+
this.pendingCompanionOffers.set(ref, pipeline);
|
|
595
|
+
}
|
|
596
|
+
return refs;
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* Relays one link-code stage between the two clients running the pairing
|
|
600
|
+
* handshake. `companion_hello` mints the ref that ties the stages together
|
|
601
|
+
* and registers it as a pairing offer, so the primary's later `pair-device`
|
|
602
|
+
* upload lands on the same companion connection as the QR flow.
|
|
603
|
+
*/
|
|
604
|
+
async handleLinkCodeStage(iq, context) {
|
|
605
|
+
const parsed = parseLinkCodeStanza(iq);
|
|
606
|
+
if (!parsed || !context) {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
const sender = context.connection;
|
|
610
|
+
if (parsed.stage === 'companion_hello') {
|
|
611
|
+
const primary = parsed.phoneJid
|
|
612
|
+
? this.mobilePrimaryPipelines.get(parsed.phoneJid)
|
|
613
|
+
: undefined;
|
|
614
|
+
if (!primary) {
|
|
615
|
+
return buildIqError(iq, { code: 404, text: 'primary-not-connected' });
|
|
526
616
|
}
|
|
617
|
+
const [ref] = await mintPairingRefs(1);
|
|
618
|
+
this.linkCodeSessions.set(ref, { companion: sender, primary });
|
|
619
|
+
this.pendingCompanionOffers.set(ref, sender);
|
|
620
|
+
await primary.sendStanza(buildLinkCodeNotification({
|
|
621
|
+
stage: 'companion_hello',
|
|
622
|
+
ref,
|
|
623
|
+
children: parsed.children
|
|
624
|
+
}));
|
|
625
|
+
return buildIqResult(iq, { content: buildCompanionHelloResultContent(ref) });
|
|
527
626
|
}
|
|
627
|
+
const session = parsed.ref ? this.linkCodeSessions.get(parsed.ref) : undefined;
|
|
628
|
+
if (!session || !parsed.ref) {
|
|
629
|
+
return buildIqError(iq, { code: 404, text: 'unknown-pairing-ref' });
|
|
630
|
+
}
|
|
631
|
+
const target = parsed.stage === 'primary_hello' ? session.companion : session.primary;
|
|
632
|
+
await target.sendStanza(buildLinkCodeNotification({
|
|
633
|
+
stage: parsed.stage,
|
|
634
|
+
ref: parsed.ref,
|
|
635
|
+
children: parsed.children
|
|
636
|
+
}));
|
|
637
|
+
return buildIqResult(iq);
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Host side of a companion link: hands the primary-signed identity to the
|
|
641
|
+
* connection that owns `ref` and reports what that companion advertised at
|
|
642
|
+
* registration. Resolves `null` for an unknown or already-consumed ref.
|
|
643
|
+
*/
|
|
644
|
+
async completeCompanionPairing(input) {
|
|
645
|
+
const pipeline = this.pendingCompanionOffers.get(input.ref);
|
|
646
|
+
if (!pipeline) {
|
|
647
|
+
return null;
|
|
648
|
+
}
|
|
649
|
+
const registration = pipeline.clientPayload;
|
|
650
|
+
const companionPropsBytes = registration?.kind === 'registration'
|
|
651
|
+
? (registration.devicePairingData.deviceProps ?? null)
|
|
652
|
+
: null;
|
|
653
|
+
// `<platform>` describes the primary that signed the link, not the
|
|
654
|
+
// companion. A zapo mobile session always logs in as ANDROID, so this
|
|
655
|
+
// is the only value a primary-driven link can carry here.
|
|
656
|
+
const platform = MOBILE_PRIMARY_PLATFORM;
|
|
657
|
+
await pipeline.sendStanza(buildPairSuccessIq({
|
|
658
|
+
deviceJid: input.deviceJid,
|
|
659
|
+
platform,
|
|
660
|
+
deviceIdentityBytes: input.deviceIdentityBytes
|
|
661
|
+
}));
|
|
662
|
+
for (const [ref, offered] of this.pendingCompanionOffers) {
|
|
663
|
+
if (offered === pipeline) {
|
|
664
|
+
this.pendingCompanionOffers.delete(ref);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return { companionPropsBytes, platform };
|
|
528
668
|
}
|
|
529
669
|
buildMediaRequestHandler() {
|
|
530
670
|
return (req, res) => {
|
|
@@ -612,69 +752,3 @@ function parseQueryParam(query, name) {
|
|
|
612
752
|
}
|
|
613
753
|
return undefined;
|
|
614
754
|
}
|
|
615
|
-
function matchesIq(iq, matcher) {
|
|
616
|
-
if (iq.tag !== 'iq')
|
|
617
|
-
return false;
|
|
618
|
-
if (matcher.id !== undefined && iq.attrs.id !== matcher.id)
|
|
619
|
-
return false;
|
|
620
|
-
if (matcher.type !== undefined && iq.attrs.type !== matcher.type)
|
|
621
|
-
return false;
|
|
622
|
-
if (matcher.xmlns !== undefined && iq.attrs.xmlns !== matcher.xmlns)
|
|
623
|
-
return false;
|
|
624
|
-
if (matcher.childTag !== undefined) {
|
|
625
|
-
const children = Array.isArray(iq.content) ? iq.content : null;
|
|
626
|
-
if (!children || children.length === 0)
|
|
627
|
-
return false;
|
|
628
|
-
if (children[0].tag !== matcher.childTag)
|
|
629
|
-
return false;
|
|
630
|
-
}
|
|
631
|
-
return true;
|
|
632
|
-
}
|
|
633
|
-
function describeMatcher(matcher) {
|
|
634
|
-
const parts = [];
|
|
635
|
-
if (matcher.id !== undefined)
|
|
636
|
-
parts.push(`id=${matcher.id}`);
|
|
637
|
-
if (matcher.type !== undefined)
|
|
638
|
-
parts.push(`type=${matcher.type}`);
|
|
639
|
-
if (matcher.xmlns !== undefined)
|
|
640
|
-
parts.push(`xmlns=${matcher.xmlns}`);
|
|
641
|
-
if (matcher.childTag !== undefined)
|
|
642
|
-
parts.push(`childTag=${matcher.childTag}`);
|
|
643
|
-
return parts.length > 0 ? parts.join(', ') : 'any iq';
|
|
644
|
-
}
|
|
645
|
-
function matchesStanza(node, matcher) {
|
|
646
|
-
if (matcher.tag !== undefined && node.tag !== matcher.tag)
|
|
647
|
-
return false;
|
|
648
|
-
if (matcher.type !== undefined && node.attrs.type !== matcher.type)
|
|
649
|
-
return false;
|
|
650
|
-
if (matcher.xmlns !== undefined && node.attrs.xmlns !== matcher.xmlns)
|
|
651
|
-
return false;
|
|
652
|
-
if (matcher.from !== undefined && node.attrs.from !== matcher.from)
|
|
653
|
-
return false;
|
|
654
|
-
if (matcher.to !== undefined && node.attrs.to !== matcher.to)
|
|
655
|
-
return false;
|
|
656
|
-
if (matcher.childTag !== undefined) {
|
|
657
|
-
const children = Array.isArray(node.content) ? node.content : null;
|
|
658
|
-
if (!children || children.length === 0)
|
|
659
|
-
return false;
|
|
660
|
-
if (children[0].tag !== matcher.childTag)
|
|
661
|
-
return false;
|
|
662
|
-
}
|
|
663
|
-
return true;
|
|
664
|
-
}
|
|
665
|
-
function describeStanzaMatcher(matcher) {
|
|
666
|
-
const parts = [];
|
|
667
|
-
if (matcher.tag !== undefined)
|
|
668
|
-
parts.push(`tag=${matcher.tag}`);
|
|
669
|
-
if (matcher.type !== undefined)
|
|
670
|
-
parts.push(`type=${matcher.type}`);
|
|
671
|
-
if (matcher.xmlns !== undefined)
|
|
672
|
-
parts.push(`xmlns=${matcher.xmlns}`);
|
|
673
|
-
if (matcher.from !== undefined)
|
|
674
|
-
parts.push(`from=${matcher.from}`);
|
|
675
|
-
if (matcher.to !== undefined)
|
|
676
|
-
parts.push(`to=${matcher.to}`);
|
|
677
|
-
if (matcher.childTag !== undefined)
|
|
678
|
-
parts.push(`childTag=${matcher.childTag}`);
|
|
679
|
-
return parts.length > 0 ? parts.join(', ') : 'any stanza';
|
|
680
|
-
}
|