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