@zapo-js/fake-server 1.1.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 +89 -30
- 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/FakeServerSession.d.ts +35 -2
- package/dist/api/FakeServerSession.js +65 -3
- package/dist/api/FakeWaServer.d.ts +73 -1
- package/dist/api/FakeWaServer.js +230 -4
- package/dist/api/ServerRegistries.d.ts +7 -0
- package/dist/api/ServerRegistries.js +20 -5
- package/dist/api/iq-handlers.d.ts +19 -1
- package/dist/api/iq-handlers.js +96 -1
- package/dist/esm/api/FakeMobilePrimary.js +102 -0
- package/dist/esm/api/FakePairingDriver.js +2 -4
- package/dist/esm/api/FakeServerSession.js +66 -4
- package/dist/esm/api/FakeWaServer.js +230 -4
- package/dist/esm/api/ServerRegistries.js +20 -5
- package/dist/esm/api/iq-handlers.js +96 -1
- package/dist/esm/index.js +8 -0
- package/dist/esm/infra/WaFakeConnection.js +8 -22
- package/dist/esm/infra/WaFakeConnectionPipeline.js +17 -5
- 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/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 +3 -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 +1 -0
- package/dist/index.d.ts +17 -2
- package/dist/index.js +28 -1
- package/dist/infra/WaFakeConnection.d.ts +21 -3
- package/dist/infra/WaFakeConnection.js +8 -22
- package/dist/infra/WaFakeConnectionPipeline.d.ts +1 -0
- package/dist/infra/WaFakeConnectionPipeline.js +17 -5
- 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/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 +15 -2
- package/dist/protocol/iq/router.js +3 -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 +1 -0
- package/dist/transport/util.js +2 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# @zapo-js/fake-server
|
|
2
2
|
|
|
3
|
-
In-process fake WhatsApp
|
|
3
|
+
In-process fake WhatsApp server that drives the real `zapo-js` `WaClient` end-to-end - full Noise XX/IK handshake, QR pairing, Signal Protocol (X3DH + Double Ratchet), SenderKey for groups, media upload/download over self-signed HTTPS, app-state sync - all without touching WhatsApp servers.
|
|
4
|
+
|
|
5
|
+
It serves both transports: companions over WebSocket, and phones over the
|
|
6
|
+
mobile TCP transport, including the companion-hosting side of a primary
|
|
7
|
+
(pair-device, key-index list, pairing-code handshake).
|
|
4
8
|
|
|
5
9
|
## Install
|
|
6
10
|
|
|
@@ -37,9 +41,10 @@ await server.stop()
|
|
|
37
41
|
```text
|
|
38
42
|
src/
|
|
39
43
|
├── api/ # Public-facing API
|
|
40
|
-
│ ├── FakeWaServer.ts # Main facade – WS
|
|
44
|
+
│ ├── FakeWaServer.ts # Main facade – WS/TCP servers, IQ router, registries, lifecycle
|
|
41
45
|
│ ├── FakePeer.ts # Simulated WhatsApp peer – Signal crypto, send/recv, groups
|
|
42
|
-
│ ├── FakePairingDriver.ts # QR pairing flow orchestrator
|
|
46
|
+
│ ├── FakePairingDriver.ts # QR pairing flow orchestrator (server plays the primary)
|
|
47
|
+
│ ├── FakeMobilePrimary.ts # Registered mobile-primary credentials for a phone client
|
|
43
48
|
│ └── Scenario.ts # Declarative test scenario DSL
|
|
44
49
|
├── protocol/ # Protocol-layer builders, parsers, crypto
|
|
45
50
|
│ ├── auth/ # Pairing, ADV identity, cert chain, client payload
|
|
@@ -48,8 +53,10 @@ src/
|
|
|
48
53
|
│ ├── signal/ # Signal Protocol impl (Double Ratchet, SenderKey, prekeys)
|
|
49
54
|
│ └── stream/ # Stream error builders
|
|
50
55
|
├── infra/ # Transport infrastructure
|
|
51
|
-
│ ├── WaFakeWsServer.ts # Raw WebSocket server (ws)
|
|
52
|
-
│ ├──
|
|
56
|
+
│ ├── WaFakeWsServer.ts # Raw WebSocket server (ws) – companion transport
|
|
57
|
+
│ ├── WaFakeTcpServer.ts # Raw TCP server – mobile transport
|
|
58
|
+
│ ├── socket-adapters.ts # Per-carrier socket adapters (WebSocket, TCP)
|
|
59
|
+
│ ├── WaFakeConnection.ts # Per-connection, carrier-agnostic wrapper
|
|
53
60
|
│ ├── WaFakeConnectionPipeline.ts # Noise handshake + authenticated frame transport
|
|
54
61
|
│ ├── WaFakeFrameSocket.ts # Length-prefixed framing layer
|
|
55
62
|
│ ├── WaFakeTransport.ts # AES-GCM noise transport (post-handshake encrypt/decrypt)
|
|
@@ -148,35 +155,87 @@ Simulated WhatsApp peer with real Signal Protocol crypto:
|
|
|
148
155
|
|
|
149
156
|
After pairing, the lib reconnects with IK handshake. Use `waitForNextAuthenticatedPipeline()` to capture the post-pair pipeline.
|
|
150
157
|
|
|
158
|
+
### Mobile transport (phone clients)
|
|
159
|
+
|
|
160
|
+
Start the server with `{ tcp: true }` to also listen for the WhatsApp Mobile
|
|
161
|
+
transport, which dials `tcp://host:port` instead of upgrading to a WebSocket.
|
|
162
|
+
Both listeners share one server identity, session model, and IQ router.
|
|
163
|
+
|
|
164
|
+
Registration happens out of band (against WhatsApp's HTTP endpoints), so a
|
|
165
|
+
phone session starts from credentials that already exist – seed them with
|
|
166
|
+
`seedFakeMobilePrimary`:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const server = await FakeWaServer.start({ tcp: true })
|
|
170
|
+
const store = createStore({})
|
|
171
|
+
const primary = await seedFakeMobilePrimary(store, 'phone-session', {
|
|
172
|
+
phoneNumber: '5511999999999'
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
const client = new WaClient({
|
|
176
|
+
store,
|
|
177
|
+
sessionId: 'phone-session',
|
|
178
|
+
mobileTransport: { deviceInfo: primary.deviceInfo, tcpUrl: server.tcpUrl },
|
|
179
|
+
testHooks: { noiseRootCa: server.noiseRootCa }
|
|
180
|
+
})
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Companion hosting (a phone links a companion)
|
|
184
|
+
|
|
185
|
+
The inverse of `runPairing`: the identity is signed by a real mobile-primary
|
|
186
|
+
client rather than by the server, which relays that signed exchange and does the
|
|
187
|
+
account bookkeeping around it. `offerCompanionPairing(pipeline)` pushes the refs
|
|
188
|
+
a companion turns into its QR; when the primary uploads `pair-device` for one of
|
|
189
|
+
them, the server mints the device jid, hands the signed identity to the
|
|
190
|
+
companion as `pair-success`, and tracks the link.
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
await server.offerCompanionPairing(companionPipeline)
|
|
194
|
+
const linked = await primary.mobile.linkCompanion(qrFromCompanion)
|
|
195
|
+
server.companionHost.linkedCompanions() // [{ deviceJid, keyIndex, ... }]
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
The pairing-code flow works the same way end to end: the server mints the ref on
|
|
199
|
+
`companion_hello` and relays `primary_hello` / `companion_finish` between the two
|
|
200
|
+
clients, so `client.auth.requestPairingCode()` on one side and
|
|
201
|
+
`client.mobile.linkCompanionByCode()` on the other complete a real handshake.
|
|
202
|
+
|
|
203
|
+
`pushAccountSyncDevices(pipeline)` pushes the account's device set as an
|
|
204
|
+
`account_sync` notification – pass a shorter list to tell a primary that a
|
|
205
|
+
device disappeared while it was offline.
|
|
206
|
+
|
|
151
207
|
## IQ coverage
|
|
152
208
|
|
|
153
209
|
Every outbound IQ the lib sends during normal operation is handled:
|
|
154
210
|
|
|
155
|
-
| IQ | Handler | State mutation
|
|
156
|
-
| -------------------------------------------------------------------------------- | ----------------------------- |
|
|
157
|
-
| `abt` get | `abprops` | Seedable via `setAbProps()`
|
|
158
|
-
| `w:p` / `urn:xmpp:ping` get | `whatsapp-ping` / `xmpp-ping` | Ack
|
|
159
|
-
| `encrypt` set | `prekey-upload` | Captures bundle, resets dispenser
|
|
160
|
-
| `encrypt` get `<digest>` | `signal-digest` | Returns 404 → forces upload
|
|
161
|
-
| `encrypt` get `<count>` | `prekey-count` | Serves remaining dispenser prekey count
|
|
162
|
-
| `encrypt` set `<rotate>` | `signed-prekey-rotate` | Ack
|
|
163
|
-
| `encrypt` get `<key>` | `prekey-fetch` | Serves peer bundles from registry
|
|
164
|
-
| `passive` set | `passive-mode` | Ack
|
|
165
|
-
| `usync` get | `usync` | Resolves device IDs from registry
|
|
166
|
-
| `w:m` set `<media_conn>` | `media-conn` | Points lib at fake HTTPS server
|
|
167
|
-
| `w:sync:app:state` set | `app-state-sync` | Serves patches/snapshots from providers
|
|
168
|
-
| `w:g2` get `<query>` | `group-metadata` | Serves from group registry
|
|
169
|
-
| `w:g2` set `<create\|add\|remove\|promote\|demote\|subject\|description\|leave>` | `group-*` | Mutates group registry
|
|
170
|
-
| `privacy` get | `privacy-get` | Serves settings + disallowed lists
|
|
171
|
-
| `privacy` set `<privacy>` | `privacy-set` | Mutates privacy state
|
|
172
|
-
| `privacy` set `<tokens>` | `privacy-token-issue` | Captures issued tokens
|
|
173
|
-
| `blocklist` get/set | `blocklist-*` | Mutates blocklist
|
|
174
|
-
| `w:profile:picture` get/set | `profile-picture-*` | Mutates profile picture registry
|
|
175
|
-
| `status` set | `status-set` | Captures latest status
|
|
176
|
-
| `w:biz` get/set | `business-profile-*` | Serves/captures business profiles
|
|
177
|
-
| `md` set `<remove-companion-device>` | `remove-companion-device` |
|
|
178
|
-
| `
|
|
179
|
-
| `
|
|
211
|
+
| IQ | Handler | State mutation |
|
|
212
|
+
| -------------------------------------------------------------------------------- | ----------------------------- | ----------------------------------------- |
|
|
213
|
+
| `abt` get | `abprops` | Seedable via `setAbProps()` |
|
|
214
|
+
| `w:p` / `urn:xmpp:ping` get | `whatsapp-ping` / `xmpp-ping` | Ack |
|
|
215
|
+
| `encrypt` set | `prekey-upload` | Captures bundle, resets dispenser |
|
|
216
|
+
| `encrypt` get `<digest>` | `signal-digest` | Returns 404 → forces upload |
|
|
217
|
+
| `encrypt` get `<count>` | `prekey-count` | Serves remaining dispenser prekey count |
|
|
218
|
+
| `encrypt` set `<rotate>` | `signed-prekey-rotate` | Ack |
|
|
219
|
+
| `encrypt` get `<key>` | `prekey-fetch` | Serves peer bundles from registry |
|
|
220
|
+
| `passive` set | `passive-mode` | Ack |
|
|
221
|
+
| `usync` get | `usync` | Resolves device IDs from registry |
|
|
222
|
+
| `w:m` set `<media_conn>` | `media-conn` | Points lib at fake HTTPS server |
|
|
223
|
+
| `w:sync:app:state` set | `app-state-sync` | Serves patches/snapshots from providers |
|
|
224
|
+
| `w:g2` get `<query>` | `group-metadata` | Serves from group registry |
|
|
225
|
+
| `w:g2` set `<create\|add\|remove\|promote\|demote\|subject\|description\|leave>` | `group-*` | Mutates group registry |
|
|
226
|
+
| `privacy` get | `privacy-get` | Serves settings + disallowed lists |
|
|
227
|
+
| `privacy` set `<privacy>` | `privacy-set` | Mutates privacy state |
|
|
228
|
+
| `privacy` set `<tokens>` | `privacy-token-issue` | Captures issued tokens |
|
|
229
|
+
| `blocklist` get/set | `blocklist-*` | Mutates blocklist |
|
|
230
|
+
| `w:profile:picture` get/set | `profile-picture-*` | Mutates profile picture registry |
|
|
231
|
+
| `status` set | `status-set` | Captures latest status |
|
|
232
|
+
| `w:biz` get/set | `business-profile-*` | Serves/captures business profiles |
|
|
233
|
+
| `md` set `<remove-companion-device>` | `remove-companion-device` | Unlinks a hosted device, else logs out |
|
|
234
|
+
| `md` set `<pair-device>` | `companion-pair-device` | Mints the device jid, relays pair-success |
|
|
235
|
+
| `md` set `<key-index-list>` | `companion-key-index-list` | Records the published list |
|
|
236
|
+
| `md` set `<link_code_companion_reg>` | `link-code-companion-reg` | Relays the pairing-code handshake |
|
|
237
|
+
| `newsletter` get `<my_addons>` | `newsletter-my-addons` | Ack |
|
|
238
|
+
| `urn:xmpp:whatsapp:dirty` set | `dirty-bits-clear` | Captures cleared bits |
|
|
180
239
|
|
|
181
240
|
## Using with non-zapo-js clients
|
|
182
241
|
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/** Registered mobile-primary credentials for driving a phone-side client. */
|
|
2
|
+
import type { WaAuthCredentials, WaMobileTransportDeviceInfo, WaStore } from '../transport/auth';
|
|
3
|
+
export interface SeedFakeMobilePrimaryOptions {
|
|
4
|
+
/** Account phone number, digits only (for example `5511999999999`). */
|
|
5
|
+
readonly phoneNumber: string;
|
|
6
|
+
/** Overrides merged over the default phone identity. */
|
|
7
|
+
readonly deviceInfo?: Partial<WaMobileTransportDeviceInfo>;
|
|
8
|
+
readonly pushName?: string;
|
|
9
|
+
/**
|
|
10
|
+
* LID user part. Set it to make the primary LID-native, which is what
|
|
11
|
+
* drives `<client-props isChatDbLidMigrated>` on a pair-device upload.
|
|
12
|
+
*/
|
|
13
|
+
readonly lidUser?: string;
|
|
14
|
+
}
|
|
15
|
+
export interface FakeMobilePrimary {
|
|
16
|
+
/** `<phone>@s.whatsapp.net` – the primary is always device 0. */
|
|
17
|
+
readonly meJid: string;
|
|
18
|
+
readonly meLid: string | null;
|
|
19
|
+
readonly deviceInfo: WaMobileTransportDeviceInfo;
|
|
20
|
+
readonly credentials: WaAuthCredentials;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Seeds a store session with the credentials of an already-registered mobile
|
|
24
|
+
* primary, so a client can connect in mobile mode without a registration flow
|
|
25
|
+
* (which happens out of band, against WhatsApp's HTTP registration endpoints,
|
|
26
|
+
* and is therefore out of this server's reach).
|
|
27
|
+
*
|
|
28
|
+
* The returned `deviceInfo` goes straight into the client's `mobileTransport`;
|
|
29
|
+
* point its `tcpUrl` at {@link FakeWaServer.tcpUrl}:
|
|
30
|
+
*
|
|
31
|
+
* @example
|
|
32
|
+
* ```ts
|
|
33
|
+
* const server = await FakeWaServer.start({ tcp: true })
|
|
34
|
+
* const store = createStore({})
|
|
35
|
+
* const primary = await seedFakeMobilePrimary(store, 'mobile-session', {
|
|
36
|
+
* phoneNumber: '5511999999999'
|
|
37
|
+
* })
|
|
38
|
+
* const client = new WaClient({
|
|
39
|
+
* store,
|
|
40
|
+
* sessionId: 'mobile-session',
|
|
41
|
+
* mobileTransport: { deviceInfo: primary.deviceInfo, tcpUrl: server.tcpUrl },
|
|
42
|
+
* testHooks: { noiseRootCa: server.noiseRootCa }
|
|
43
|
+
* })
|
|
44
|
+
* ```
|
|
45
|
+
*
|
|
46
|
+
* @throws when `phoneNumber` is not a positive digit-only string.
|
|
47
|
+
*/
|
|
48
|
+
export declare function seedFakeMobilePrimary(store: WaStore, sessionId: string, options: SeedFakeMobilePrimaryOptions): Promise<FakeMobilePrimary>;
|
|
49
|
+
/**
|
|
50
|
+
* Builds registered mobile-primary credentials without persisting them. Use
|
|
51
|
+
* {@link seedFakeMobilePrimary} unless you own the persistence yourself.
|
|
52
|
+
*
|
|
53
|
+
* @throws when `phoneNumber` is not a positive digit-only string.
|
|
54
|
+
*/
|
|
55
|
+
export declare function createFakeMobilePrimary(options: SeedFakeMobilePrimaryOptions): Promise<FakeMobilePrimary>;
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Registered mobile-primary credentials for driving a phone-side client. */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.seedFakeMobilePrimary = seedFakeMobilePrimary;
|
|
5
|
+
exports.createFakeMobilePrimary = createFakeMobilePrimary;
|
|
6
|
+
const crypto_1 = require("../transport/crypto");
|
|
7
|
+
const HOST_DOMAIN = 's.whatsapp.net';
|
|
8
|
+
const LID_DOMAIN = 'lid';
|
|
9
|
+
const ADV_SECRET_BYTES = 32;
|
|
10
|
+
const SIGNED_PRE_KEY_ID = 1;
|
|
11
|
+
/**
|
|
12
|
+
* Device the seeded primary advertises. A phone always sends these in its login
|
|
13
|
+
* `UserAgent`, so they double as the assertion target for a cross-check test.
|
|
14
|
+
*/
|
|
15
|
+
const DEFAULT_DEVICE_INFO = {
|
|
16
|
+
manufacturer: 'Google',
|
|
17
|
+
device: 'Pixel 7',
|
|
18
|
+
osVersion: '14',
|
|
19
|
+
osBuildNumber: 'UQ1A.240205.004',
|
|
20
|
+
appVersion: '2.26.27.70',
|
|
21
|
+
mcc: '724',
|
|
22
|
+
mnc: '10',
|
|
23
|
+
localeLanguageIso6391: 'pt',
|
|
24
|
+
localeCountryIso31661Alpha2: 'BR'
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Seeds a store session with the credentials of an already-registered mobile
|
|
28
|
+
* primary, so a client can connect in mobile mode without a registration flow
|
|
29
|
+
* (which happens out of band, against WhatsApp's HTTP registration endpoints,
|
|
30
|
+
* and is therefore out of this server's reach).
|
|
31
|
+
*
|
|
32
|
+
* The returned `deviceInfo` goes straight into the client's `mobileTransport`;
|
|
33
|
+
* point its `tcpUrl` at {@link FakeWaServer.tcpUrl}:
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* ```ts
|
|
37
|
+
* const server = await FakeWaServer.start({ tcp: true })
|
|
38
|
+
* const store = createStore({})
|
|
39
|
+
* const primary = await seedFakeMobilePrimary(store, 'mobile-session', {
|
|
40
|
+
* phoneNumber: '5511999999999'
|
|
41
|
+
* })
|
|
42
|
+
* const client = new WaClient({
|
|
43
|
+
* store,
|
|
44
|
+
* sessionId: 'mobile-session',
|
|
45
|
+
* mobileTransport: { deviceInfo: primary.deviceInfo, tcpUrl: server.tcpUrl },
|
|
46
|
+
* testHooks: { noiseRootCa: server.noiseRootCa }
|
|
47
|
+
* })
|
|
48
|
+
* ```
|
|
49
|
+
*
|
|
50
|
+
* @throws when `phoneNumber` is not a positive digit-only string.
|
|
51
|
+
*/
|
|
52
|
+
async function seedFakeMobilePrimary(store, sessionId, options) {
|
|
53
|
+
const primary = await createFakeMobilePrimary(options);
|
|
54
|
+
await store.session(sessionId).auth.save(primary.credentials);
|
|
55
|
+
return primary;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Builds registered mobile-primary credentials without persisting them. Use
|
|
59
|
+
* {@link seedFakeMobilePrimary} unless you own the persistence yourself.
|
|
60
|
+
*
|
|
61
|
+
* @throws when `phoneNumber` is not a positive digit-only string.
|
|
62
|
+
*/
|
|
63
|
+
async function createFakeMobilePrimary(options) {
|
|
64
|
+
if (!/^[1-9]\d*$/.test(options.phoneNumber)) {
|
|
65
|
+
throw new Error(`phoneNumber must be digits only without a leading zero, got: ${options.phoneNumber}`);
|
|
66
|
+
}
|
|
67
|
+
// The login payload carries the number as a numeric username, so anything
|
|
68
|
+
// past the safe-integer range would seed credentials that only fail later,
|
|
69
|
+
// on connect.
|
|
70
|
+
if (!Number.isSafeInteger(Number(options.phoneNumber))) {
|
|
71
|
+
throw new Error(`phoneNumber is too long to be sent as a numeric username: ${options.phoneNumber}`);
|
|
72
|
+
}
|
|
73
|
+
const [noiseKeyPair, identityKeyPair, signedPreKeyPair, advSecretKey, registrationId] = await Promise.all([
|
|
74
|
+
crypto_1.X25519.generateKeyPair(),
|
|
75
|
+
crypto_1.X25519.generateKeyPair(),
|
|
76
|
+
crypto_1.X25519.generateKeyPair(),
|
|
77
|
+
(0, crypto_1.randomBytesAsync)(ADV_SECRET_BYTES),
|
|
78
|
+
(0, crypto_1.randomIntAsync)(1, 16381)
|
|
79
|
+
]);
|
|
80
|
+
const signature = await (0, crypto_1.xeddsaSign)(identityKeyPair.privKey, (0, crypto_1.toSerializedPubKey)(signedPreKeyPair.pubKey));
|
|
81
|
+
const deviceInfo = {
|
|
82
|
+
...DEFAULT_DEVICE_INFO,
|
|
83
|
+
...options.deviceInfo
|
|
84
|
+
};
|
|
85
|
+
const meJid = `${options.phoneNumber}@${HOST_DOMAIN}`;
|
|
86
|
+
const meLid = options.lidUser ? `${options.lidUser}@${LID_DOMAIN}` : null;
|
|
87
|
+
const credentials = {
|
|
88
|
+
noiseKeyPair,
|
|
89
|
+
registrationInfo: { registrationId, identityKeyPair },
|
|
90
|
+
signedPreKey: {
|
|
91
|
+
keyId: SIGNED_PRE_KEY_ID,
|
|
92
|
+
keyPair: signedPreKeyPair,
|
|
93
|
+
signature,
|
|
94
|
+
uploaded: false
|
|
95
|
+
},
|
|
96
|
+
advSecretKey,
|
|
97
|
+
meJid,
|
|
98
|
+
deviceInfo,
|
|
99
|
+
// A phone has no companion device-identity and no key-index list of its
|
|
100
|
+
// own: both are minted at pairing time, by this primary, for others.
|
|
101
|
+
serverHasPreKeys: false,
|
|
102
|
+
...(meLid ? { meLid } : {}),
|
|
103
|
+
...(options.pushName ? { pushName: options.pushName } : {})
|
|
104
|
+
};
|
|
105
|
+
return { meJid, meLid, deviceInfo, credentials };
|
|
106
|
+
}
|
|
@@ -4,7 +4,6 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
exports.FakePairingDriver = void 0;
|
|
5
5
|
const fake_primary_device_1 = require("../protocol/auth/fake-primary-device");
|
|
6
6
|
const pair_device_1 = require("../protocol/auth/pair-device");
|
|
7
|
-
const crypto_1 = require("../transport/crypto");
|
|
8
7
|
class FakePairingDriver {
|
|
9
8
|
constructor(options, deps) {
|
|
10
9
|
this.primary = null;
|
|
@@ -13,8 +12,7 @@ class FakePairingDriver {
|
|
|
13
12
|
}
|
|
14
13
|
async run() {
|
|
15
14
|
this.primary = this.options.primary ?? (await (0, fake_primary_device_1.generateFakePrimaryDevice)());
|
|
16
|
-
const
|
|
17
|
-
const pairDeviceIq = (0, pair_device_1.buildPairDeviceIq)({ refs });
|
|
15
|
+
const pairDeviceIq = (0, pair_device_1.buildPairDeviceIq)({ refs: await (0, pair_device_1.mintPairingRefs)() });
|
|
18
16
|
await this.deps.pipeline.sendStanza(pairDeviceIq);
|
|
19
17
|
const waitForAck = this.deps.waitForPairDeviceAck?.(pairDeviceIq.attrs.id) ?? Promise.resolve();
|
|
20
18
|
const [material] = await Promise.all([this.deps.companionMaterialResolver(), waitForAck]);
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/** Per-session isolated server state: registries, prekeys, app-state, IQ routing, capture. */
|
|
2
2
|
import type { BuildAbPropsResultInput } from '../protocol/iq/abprops';
|
|
3
|
-
import { type WaFakeIqMatcher, type WaFakeIqResponder, WaFakeIqRouter } from '../protocol/iq/router';
|
|
3
|
+
import { type WaFakeIqContext, type WaFakeIqMatcher, type WaFakeIqResponder, WaFakeIqRouter } from '../protocol/iq/router';
|
|
4
4
|
import type { ClientPreKeyBundle } from '../protocol/signal/prekey-upload';
|
|
5
|
+
import { FakeCompanionHostState } from '../state/fake-companion-host';
|
|
5
6
|
import { type BinaryNode } from '../transport/codec';
|
|
6
7
|
import { AppStateSyncManager } from './AppStateSyncManager';
|
|
7
8
|
import { PreKeyDispenser } from './PreKeyDispenser';
|
|
@@ -30,6 +31,29 @@ export interface FakeServerSessionHost {
|
|
|
30
31
|
readonly host: string;
|
|
31
32
|
readonly port: number;
|
|
32
33
|
};
|
|
34
|
+
/**
|
|
35
|
+
* Completes a pairing offer against the companion connection that owns
|
|
36
|
+
* `ref`: relays the primary-signed identity as `pair-success` and reports
|
|
37
|
+
* back what the companion advertised. Resolves `null` when no live
|
|
38
|
+
* connection holds that ref. Lives on the host because pairing spans two
|
|
39
|
+
* connections while a session owns neither.
|
|
40
|
+
*/
|
|
41
|
+
completeCompanionPairing(input: CompleteCompanionPairingInput): Promise<CompletedCompanionPairing | null>;
|
|
42
|
+
/**
|
|
43
|
+
* Routes one link-code stage to the other client in the handshake, or
|
|
44
|
+
* resolves `null` when the stanza is not a stage this server relays.
|
|
45
|
+
*/
|
|
46
|
+
relayLinkCodeStage(iq: BinaryNode, context: WaFakeIqContext | undefined): Promise<BinaryNode | null>;
|
|
47
|
+
}
|
|
48
|
+
export interface CompleteCompanionPairingInput {
|
|
49
|
+
readonly ref: string;
|
|
50
|
+
readonly deviceJid: string;
|
|
51
|
+
readonly deviceIdentityBytes: Uint8Array;
|
|
52
|
+
}
|
|
53
|
+
export interface CompletedCompanionPairing {
|
|
54
|
+
/** Companion `DeviceProps` from its registration payload, echoed to the primary. */
|
|
55
|
+
readonly companionPropsBytes: Uint8Array | null;
|
|
56
|
+
readonly platform: string;
|
|
33
57
|
}
|
|
34
58
|
/**
|
|
35
59
|
* One isolated logical device's worth of server state. Every `WaClient`
|
|
@@ -43,6 +67,7 @@ export declare class FakeServerSession {
|
|
|
43
67
|
readonly registries: ServerRegistries;
|
|
44
68
|
readonly preKeyDispenser: PreKeyDispenser;
|
|
45
69
|
readonly appStateSync: AppStateSyncManager;
|
|
70
|
+
readonly companionHost: FakeCompanionHostState;
|
|
46
71
|
readonly iqRouter: WaFakeIqRouter;
|
|
47
72
|
private readonly capturedStanzas;
|
|
48
73
|
private readonly pendingIqExpectations;
|
|
@@ -53,7 +78,7 @@ export declare class FakeServerSession {
|
|
|
53
78
|
readonly defaultIqHandlers: boolean;
|
|
54
79
|
});
|
|
55
80
|
registerIqHandler(matcher: WaFakeIqMatcher, respond: WaFakeIqResponder, label?: string): () => void;
|
|
56
|
-
routeIq(iq: BinaryNode): Promise<BinaryNode | null>;
|
|
81
|
+
routeIq(iq: BinaryNode, context?: WaFakeIqContext): Promise<BinaryNode | null>;
|
|
57
82
|
handleCapturedStanza(node: BinaryNode): void;
|
|
58
83
|
subscribeInboundMessages(listener: (node: BinaryNode) => void): () => void;
|
|
59
84
|
onCapturedStanza(listener: (node: BinaryNode) => void): () => void;
|
|
@@ -61,6 +86,14 @@ export declare class FakeServerSession {
|
|
|
61
86
|
expectIq(matcher: WaFakeIqMatcher, options?: ExpectIqOptions): Promise<BinaryNode>;
|
|
62
87
|
expectStanza(matcher: StanzaMatcher, options?: ExpectStanzaOptions): Promise<BinaryNode>;
|
|
63
88
|
private buildIqHandlerDeps;
|
|
89
|
+
/**
|
|
90
|
+
* Server side of the primary's `pair-device` upload: allocates the device
|
|
91
|
+
* slot, hands the signed identity to the companion connection, and records
|
|
92
|
+
* the link. Resolves `null` when this session has no primary or the ref
|
|
93
|
+
* belongs to no live connection, which the handler turns into an IQ error.
|
|
94
|
+
*/
|
|
95
|
+
private linkCompanionDevice;
|
|
96
|
+
private revokeCompanionDevices;
|
|
64
97
|
}
|
|
65
98
|
export declare function matchesIq(iq: BinaryNode, matcher: WaFakeIqMatcher): boolean;
|
|
66
99
|
export declare function matchesStanza(node: BinaryNode, matcher: StanzaMatcher): boolean;
|
|
@@ -5,10 +5,12 @@ exports.FakeServerSession = void 0;
|
|
|
5
5
|
exports.matchesIq = matchesIq;
|
|
6
6
|
exports.matchesStanza = matchesStanza;
|
|
7
7
|
const router_1 = require("../protocol/iq/router");
|
|
8
|
+
const fake_companion_host_1 = require("../state/fake-companion-host");
|
|
8
9
|
const AppStateSyncManager_1 = require("./AppStateSyncManager");
|
|
9
10
|
const iq_handlers_1 = require("./iq-handlers");
|
|
10
11
|
const PreKeyDispenser_1 = require("./PreKeyDispenser");
|
|
11
12
|
const ServerRegistries_1 = require("./ServerRegistries");
|
|
13
|
+
const HOST_DOMAIN = 's.whatsapp.net';
|
|
12
14
|
/**
|
|
13
15
|
* One isolated logical device's worth of server state. Every `WaClient`
|
|
14
16
|
* connected to a `FakeWaServer` is bound to exactly one session; peers,
|
|
@@ -21,6 +23,7 @@ class FakeServerSession {
|
|
|
21
23
|
this.registries = new ServerRegistries_1.ServerRegistries();
|
|
22
24
|
this.preKeyDispenser = new PreKeyDispenser_1.PreKeyDispenser();
|
|
23
25
|
this.appStateSync = new AppStateSyncManager_1.AppStateSyncManager();
|
|
26
|
+
this.companionHost = new fake_companion_host_1.FakeCompanionHostState();
|
|
24
27
|
this.iqRouter = new router_1.WaFakeIqRouter();
|
|
25
28
|
this.capturedStanzas = [];
|
|
26
29
|
this.pendingIqExpectations = new Set();
|
|
@@ -36,8 +39,8 @@ class FakeServerSession {
|
|
|
36
39
|
const handler = { matcher, respond, label };
|
|
37
40
|
return this.iqRouter.register(handler, { priority: 'high' });
|
|
38
41
|
}
|
|
39
|
-
routeIq(iq) {
|
|
40
|
-
return this.iqRouter.route(iq);
|
|
42
|
+
routeIq(iq, context) {
|
|
43
|
+
return this.iqRouter.route(iq, context);
|
|
41
44
|
}
|
|
42
45
|
handleCapturedStanza(node) {
|
|
43
46
|
this.capturedStanzas.push(node);
|
|
@@ -153,6 +156,7 @@ class FakeServerSession {
|
|
|
153
156
|
const reg = this.registries;
|
|
154
157
|
const preKey = this.preKeyDispenser;
|
|
155
158
|
const appState = this.appStateSync;
|
|
159
|
+
const companionHost = this.companionHost;
|
|
156
160
|
return {
|
|
157
161
|
get peerRegistry() {
|
|
158
162
|
return reg.peerRegistry;
|
|
@@ -220,9 +224,67 @@ class FakeServerSession {
|
|
|
220
224
|
get appStateCollectionProviders() {
|
|
221
225
|
return appState.appStateCollectionProviders;
|
|
222
226
|
},
|
|
223
|
-
requireMediaHttpsInfo: () => host.requireMediaHttpsInfo()
|
|
227
|
+
requireMediaHttpsInfo: () => host.requireMediaHttpsInfo(),
|
|
228
|
+
get mobilePrimary() {
|
|
229
|
+
return companionHost.primary;
|
|
230
|
+
},
|
|
231
|
+
relayLinkCodeStage: (iq, context) => host.relayLinkCodeStage(iq, context),
|
|
232
|
+
linkCompanionDevice: (upload) => this.linkCompanionDevice(host, upload),
|
|
233
|
+
revokeCompanionDevices: (deviceJids) => this.revokeCompanionDevices(deviceJids),
|
|
234
|
+
recordKeyIndexList: (bytes, timestampSeconds) => companionHost.recordKeyIndexList(bytes, timestampSeconds)
|
|
224
235
|
};
|
|
225
236
|
}
|
|
237
|
+
/**
|
|
238
|
+
* Server side of the primary's `pair-device` upload: allocates the device
|
|
239
|
+
* slot, hands the signed identity to the companion connection, and records
|
|
240
|
+
* the link. Resolves `null` when this session has no primary or the ref
|
|
241
|
+
* belongs to no live connection, which the handler turns into an IQ error.
|
|
242
|
+
*/
|
|
243
|
+
async linkCompanionDevice(host, upload) {
|
|
244
|
+
const primary = this.companionHost.primary;
|
|
245
|
+
if (!primary) {
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
const deviceId = this.companionHost.allocateDeviceId();
|
|
249
|
+
const deviceJid = `${primary.username}:${deviceId}@${HOST_DOMAIN}`;
|
|
250
|
+
const completed = await host
|
|
251
|
+
.completeCompanionPairing({
|
|
252
|
+
ref: upload.ref,
|
|
253
|
+
deviceJid,
|
|
254
|
+
deviceIdentityBytes: upload.deviceIdentityBytes
|
|
255
|
+
})
|
|
256
|
+
.catch((error) => {
|
|
257
|
+
this.companionHost.releaseDeviceId(deviceId);
|
|
258
|
+
throw error;
|
|
259
|
+
});
|
|
260
|
+
if (!completed) {
|
|
261
|
+
this.companionHost.releaseDeviceId(deviceId);
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
this.companionHost.recordCompanion({
|
|
265
|
+
deviceJid,
|
|
266
|
+
deviceId,
|
|
267
|
+
ref: upload.ref,
|
|
268
|
+
keyIndex: (0, fake_companion_host_1.readCompanionKeyIndex)(upload.deviceIdentityBytes),
|
|
269
|
+
deviceIdentityBytes: upload.deviceIdentityBytes,
|
|
270
|
+
linkedAtSeconds: Math.floor(Date.now() / 1000)
|
|
271
|
+
});
|
|
272
|
+
this.companionHost.recordKeyIndexList(upload.keyIndexListBytes, upload.keyIndexListTimestampSeconds);
|
|
273
|
+
this.registries.registerDeviceId(primary.jid, deviceId);
|
|
274
|
+
return { deviceJid, companionPropsBytes: completed.companionPropsBytes };
|
|
275
|
+
}
|
|
276
|
+
revokeCompanionDevices(deviceJids) {
|
|
277
|
+
const removed = deviceJids
|
|
278
|
+
? this.companionHost.removeCompanions(deviceJids)
|
|
279
|
+
: this.companionHost.removeAllCompanions();
|
|
280
|
+
const primary = this.companionHost.primary;
|
|
281
|
+
if (primary) {
|
|
282
|
+
for (const deviceJid of removed) {
|
|
283
|
+
this.registries.unregisterDeviceId(primary.jid, (0, ServerRegistries_1.toDeviceIdPart)(deviceJid));
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return removed;
|
|
287
|
+
}
|
|
226
288
|
}
|
|
227
289
|
exports.FakeServerSession = FakeServerSession;
|
|
228
290
|
function matchesIq(iq, matcher) {
|
|
@@ -8,8 +8,10 @@ import type { FakeBusinessProfile } from '../protocol/iq/business';
|
|
|
8
8
|
import type { FakePrivacyCategoryName, FakePrivacySettingsState } from '../protocol/iq/privacy';
|
|
9
9
|
import type { FakePrivacyTokenIssue } from '../protocol/iq/privacy-token';
|
|
10
10
|
import type { FakeProfilePictureResult } from '../protocol/iq/profile';
|
|
11
|
-
import type
|
|
11
|
+
import { type WaFakeIqMatcher, type WaFakeIqResponder } from '../protocol/iq/router';
|
|
12
|
+
import { type FakeAccountDevice } from '../protocol/push/mobile-notification';
|
|
12
13
|
import type { ClientPreKeyBundle } from '../protocol/signal/prekey-upload';
|
|
14
|
+
import type { FakeCompanionHostState } from '../state/fake-companion-host';
|
|
13
15
|
import { type PublishedMediaBlob, type PublishMediaInput } from '../state/fake-media-store';
|
|
14
16
|
import { type BinaryNode } from '../transport/codec';
|
|
15
17
|
import { type AppStateSyncManager, type CapturedAppStateMutation } from './AppStateSyncManager';
|
|
@@ -48,6 +50,19 @@ export interface FakeWaServerOptions {
|
|
|
48
50
|
* clientPayload.username : 'pending'`.
|
|
49
51
|
*/
|
|
50
52
|
readonly sessionKey?: (info: FakeSessionKeyInfo) => string;
|
|
53
|
+
/**
|
|
54
|
+
* Also serve the WhatsApp Mobile transport on a raw TCP listener. Mobile
|
|
55
|
+
* clients dial `tcp://host:port` instead of upgrading to a WebSocket, but
|
|
56
|
+
* speak the same framing above the carrier, so both listeners share one
|
|
57
|
+
* server identity, session model, and IQ router. Pass `true` for an
|
|
58
|
+
* ephemeral port on the WebSocket host, or an object to pin host/port.
|
|
59
|
+
* Read the address back from {@link FakeWaServer.tcpUrl} and feed it to the
|
|
60
|
+
* client's `mobileTransport.tcpUrl`.
|
|
61
|
+
*/
|
|
62
|
+
readonly tcp?: boolean | {
|
|
63
|
+
readonly host?: string;
|
|
64
|
+
readonly port?: number;
|
|
65
|
+
};
|
|
51
66
|
}
|
|
52
67
|
export interface FakeSessionKeyInfo {
|
|
53
68
|
readonly clientPayload: ParsedClientPayload;
|
|
@@ -80,6 +95,11 @@ export declare class FakeWaServer {
|
|
|
80
95
|
private rootCa;
|
|
81
96
|
private serverStaticKeyPair;
|
|
82
97
|
private listenInfo;
|
|
98
|
+
private readonly tcpServer;
|
|
99
|
+
private tcpListenInfo;
|
|
100
|
+
private readonly pendingCompanionOffers;
|
|
101
|
+
private readonly mobilePrimaryPipelines;
|
|
102
|
+
private readonly linkCodeSessions;
|
|
83
103
|
private readonly pipelineListeners;
|
|
84
104
|
private rejectMode;
|
|
85
105
|
private readonly mediaStore;
|
|
@@ -90,6 +110,8 @@ export declare class FakeWaServer {
|
|
|
90
110
|
constructor(options?: FakeWaServerOptions);
|
|
91
111
|
/** State of the single default session (the only session for one client). */
|
|
92
112
|
get registries(): ServerRegistries;
|
|
113
|
+
/** Companion-host account state of the default session (linked devices, key-index list). */
|
|
114
|
+
get companionHost(): FakeCompanionHostState;
|
|
93
115
|
get preKeyDispenser(): PreKeyDispenser;
|
|
94
116
|
get appStateSync(): AppStateSyncManager;
|
|
95
117
|
/**
|
|
@@ -146,6 +168,15 @@ export declare class FakeWaServer {
|
|
|
146
168
|
expectAppStateMutation(predicate: (mutation: CapturedAppStateMutation) => boolean, timeoutMs?: number): Promise<CapturedAppStateMutation>;
|
|
147
169
|
provideAppStateCollection(name: string, provider: () => Promise<FakeAppStateCollectionPayload | null> | FakeAppStateCollectionPayload | null): () => void;
|
|
148
170
|
pushServerSyncNotification(pipeline: WaFakeConnectionPipeline, input: BuildServerSyncNotificationInput): Promise<void>;
|
|
171
|
+
/**
|
|
172
|
+
* Pushes the account's device set to a mobile primary as `account_sync`.
|
|
173
|
+
* Defaults to what this session tracks - the primary itself plus every
|
|
174
|
+
* companion it linked - so passing a shorter list is how a test tells the
|
|
175
|
+
* primary that a device disappeared while it was offline.
|
|
176
|
+
*/
|
|
177
|
+
pushAccountSyncDevices(pipeline: WaFakeConnectionPipeline, options?: {
|
|
178
|
+
readonly devices?: readonly FakeAccountDevice[];
|
|
179
|
+
}): Promise<void>;
|
|
149
180
|
onAuthenticatedPipeline(listener: AuthenticatedPipelineListener): () => void;
|
|
150
181
|
scenario(configure: (s: Scenario) => void): void;
|
|
151
182
|
expectIq(matcher: WaFakeIqMatcher, options?: ExpectIqOptions): Promise<BinaryNode>;
|
|
@@ -188,6 +219,13 @@ export declare class FakeWaServer {
|
|
|
188
219
|
get url(): string;
|
|
189
220
|
get host(): string;
|
|
190
221
|
get port(): number;
|
|
222
|
+
/**
|
|
223
|
+
* `tcp://host:port` of the mobile listener, for the client's
|
|
224
|
+
* `mobileTransport.tcpUrl`.
|
|
225
|
+
*
|
|
226
|
+
* @throws when the server was started without the `tcp` option.
|
|
227
|
+
*/
|
|
228
|
+
get tcpUrl(): string;
|
|
191
229
|
get noiseRootCa(): FakeWaServerNoiseRootCa;
|
|
192
230
|
/**
|
|
193
231
|
* Subscribes to every new connection pipeline (pre-auth). Listeners
|
|
@@ -195,6 +233,7 @@ export declare class FakeWaServer {
|
|
|
195
233
|
*/
|
|
196
234
|
onPipeline(listener: FakeWaServerPipelineListener): () => void;
|
|
197
235
|
listen(): Promise<void>;
|
|
236
|
+
private rollbackFailedListen;
|
|
198
237
|
stop(): Promise<void>;
|
|
199
238
|
private handleConnection;
|
|
200
239
|
/**
|
|
@@ -204,6 +243,39 @@ export declare class FakeWaServer {
|
|
|
204
243
|
* No-op without a `sessionKey` resolver (single default session).
|
|
205
244
|
*/
|
|
206
245
|
private bindPipelineSession;
|
|
246
|
+
/** Drops every reference to a closed connection so a relay never targets it. */
|
|
247
|
+
private forgetPipeline;
|
|
248
|
+
/**
|
|
249
|
+
* Records a phone login as the session's account owner. Everything on the
|
|
250
|
+
* companion-host path keys off it: device jids are minted under its number,
|
|
251
|
+
* and `remove-companion-device` is read as a revoke rather than a logout.
|
|
252
|
+
*/
|
|
253
|
+
private bindMobilePrimary;
|
|
254
|
+
/**
|
|
255
|
+
* Offers a companion connection the refs for a primary-driven link: pushes
|
|
256
|
+
* the `pair-device` IQ it turns into a QR, and remembers each ref so the
|
|
257
|
+
* primary's upload can be routed back to this connection. Returns the refs.
|
|
258
|
+
*
|
|
259
|
+
* This is the counterpart of {@link runPairing}, where the server itself
|
|
260
|
+
* plays the primary. Here a real mobile-primary client signs the identity
|
|
261
|
+
* and the server only relays.
|
|
262
|
+
*/
|
|
263
|
+
offerCompanionPairing(pipeline: WaFakeConnectionPipeline, options?: {
|
|
264
|
+
readonly refCount?: number;
|
|
265
|
+
}): Promise<readonly string[]>;
|
|
266
|
+
/**
|
|
267
|
+
* Relays one link-code stage between the two clients running the pairing
|
|
268
|
+
* handshake. `companion_hello` mints the ref that ties the stages together
|
|
269
|
+
* and registers it as a pairing offer, so the primary's later `pair-device`
|
|
270
|
+
* upload lands on the same companion connection as the QR flow.
|
|
271
|
+
*/
|
|
272
|
+
private handleLinkCodeStage;
|
|
273
|
+
/**
|
|
274
|
+
* Host side of a companion link: hands the primary-signed identity to the
|
|
275
|
+
* connection that owns `ref` and reports what that companion advertised at
|
|
276
|
+
* registration. Resolves `null` for an unknown or already-consumed ref.
|
|
277
|
+
*/
|
|
278
|
+
private completeCompanionPairing;
|
|
207
279
|
private buildMediaRequestHandler;
|
|
208
280
|
private handleMediaUpload;
|
|
209
281
|
private requireListening;
|