@zapo-js/fake-server 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +180 -33
  2. package/dist/api/FakeMobilePrimary.d.ts +55 -0
  3. package/dist/api/FakeMobilePrimary.js +106 -0
  4. package/dist/api/FakePairingDriver.js +1 -3
  5. package/dist/api/FakePeer.js +7 -1
  6. package/dist/api/FakeServerSession.d.ts +100 -0
  7. package/dist/api/FakeServerSession.js +355 -0
  8. package/dist/api/FakeWaServer.d.ts +145 -37
  9. package/dist/api/FakeWaServer.js +345 -270
  10. package/dist/api/ServerRegistries.d.ts +7 -0
  11. package/dist/api/ServerRegistries.js +20 -5
  12. package/dist/api/iq-handlers.d.ts +20 -1
  13. package/dist/api/iq-handlers.js +119 -1
  14. package/dist/bin.js +69 -21
  15. package/dist/esm/api/FakeMobilePrimary.js +102 -0
  16. package/dist/esm/api/FakePairingDriver.js +2 -4
  17. package/dist/esm/api/FakePeer.js +7 -1
  18. package/dist/esm/api/FakeServerSession.js +349 -0
  19. package/dist/esm/api/FakeWaServer.js +344 -270
  20. package/dist/esm/api/ServerRegistries.js +20 -5
  21. package/dist/esm/api/iq-handlers.js +119 -1
  22. package/dist/esm/bin.js +67 -19
  23. package/dist/esm/index.js +9 -1
  24. package/dist/esm/infra/WaFakeConnection.js +8 -22
  25. package/dist/esm/infra/WaFakeConnectionPipeline.js +38 -14
  26. package/dist/esm/infra/WaFakeTcpServer.js +69 -0
  27. package/dist/esm/infra/WaFakeWsServer.js +2 -1
  28. package/dist/esm/infra/socket-adapters.js +79 -0
  29. package/dist/esm/protocol/auth/cert-chain.js +13 -2
  30. package/dist/esm/protocol/auth/client-payload-validate.js +77 -1
  31. package/dist/esm/protocol/auth/pair-device.js +16 -1
  32. package/dist/esm/protocol/iq/companion-host.js +104 -0
  33. package/dist/esm/protocol/iq/link-code.js +90 -0
  34. package/dist/esm/protocol/iq/router.js +9 -3
  35. package/dist/esm/protocol/push/mobile-notification.js +97 -0
  36. package/dist/esm/state/fake-companion-host.js +158 -0
  37. package/dist/esm/transport/auth.js +7 -0
  38. package/dist/esm/transport/crypto.js +1 -1
  39. package/dist/esm/transport/util.js +2 -1
  40. package/dist/index.d.ts +19 -4
  41. package/dist/index.js +29 -1
  42. package/dist/infra/WaFakeConnection.d.ts +21 -3
  43. package/dist/infra/WaFakeConnection.js +8 -22
  44. package/dist/infra/WaFakeConnectionPipeline.d.ts +11 -2
  45. package/dist/infra/WaFakeConnectionPipeline.js +38 -14
  46. package/dist/infra/WaFakeTcpServer.d.ts +29 -0
  47. package/dist/infra/WaFakeTcpServer.js +73 -0
  48. package/dist/infra/WaFakeWsServer.js +2 -1
  49. package/dist/infra/socket-adapters.d.ts +18 -0
  50. package/dist/infra/socket-adapters.js +83 -0
  51. package/dist/protocol/auth/cert-chain.d.ts +4 -0
  52. package/dist/protocol/auth/cert-chain.js +13 -2
  53. package/dist/protocol/auth/client-payload-validate.d.ts +24 -0
  54. package/dist/protocol/auth/client-payload-validate.js +77 -1
  55. package/dist/protocol/auth/pair-device.d.ts +15 -1
  56. package/dist/protocol/auth/pair-device.js +16 -0
  57. package/dist/protocol/iq/companion-host.d.ts +52 -0
  58. package/dist/protocol/iq/companion-host.js +109 -0
  59. package/dist/protocol/iq/link-code.d.ts +46 -0
  60. package/dist/protocol/iq/link-code.js +95 -0
  61. package/dist/protocol/iq/router.d.ts +21 -2
  62. package/dist/protocol/iq/router.js +9 -3
  63. package/dist/protocol/push/mobile-notification.d.ts +58 -0
  64. package/dist/protocol/push/mobile-notification.js +102 -0
  65. package/dist/state/fake-companion-host.d.ts +73 -0
  66. package/dist/state/fake-companion-host.js +163 -0
  67. package/dist/transport/auth.d.ts +8 -0
  68. package/dist/transport/auth.js +8 -0
  69. package/dist/transport/crypto.d.ts +1 -1
  70. package/dist/transport/crypto.js +2 -1
  71. package/dist/transport/util.d.ts +2 -1
  72. package/dist/transport/util.js +3 -1
  73. package/package.json +6 -1
package/README.md CHANGED
@@ -1,6 +1,18 @@
1
1
  # @zapo-js/fake-server
2
2
 
3
- In-process fake WhatsApp Web 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.
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).
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm i -D @zapo-js/fake-server zapo-js
13
+ ```
14
+
15
+ `zapo-js` is a peer dependency - the fake server reuses the core's binary codec, crypto, and protos. Everything below works from any project; nothing requires this monorepo.
4
16
 
5
17
  ## Quick start
6
18
 
@@ -29,9 +41,10 @@ await server.stop()
29
41
  ```text
30
42
  src/
31
43
  ├── api/ # Public-facing API
32
- │ ├── FakeWaServer.ts # Main facade – WS server, IQ router, registries, lifecycle
44
+ │ ├── FakeWaServer.ts # Main facade – WS/TCP servers, IQ router, registries, lifecycle
33
45
  │ ├── FakePeer.ts # Simulated WhatsApp peer – Signal crypto, send/recv, groups
34
- │ ├── 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
35
48
  │ └── Scenario.ts # Declarative test scenario DSL
36
49
  ├── protocol/ # Protocol-layer builders, parsers, crypto
37
50
  │ ├── auth/ # Pairing, ADV identity, cert chain, client payload
@@ -40,8 +53,10 @@ src/
40
53
  │ ├── signal/ # Signal Protocol impl (Double Ratchet, SenderKey, prekeys)
41
54
  │ └── stream/ # Stream error builders
42
55
  ├── infra/ # Transport infrastructure
43
- │ ├── WaFakeWsServer.ts # Raw WebSocket server (ws)
44
- │ ├── WaFakeConnection.ts # Per-connection WS wrapper
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
45
60
  │ ├── WaFakeConnectionPipeline.ts # Noise handshake + authenticated frame transport
46
61
  │ ├── WaFakeFrameSocket.ts # Length-prefixed framing layer
47
62
  │ ├── WaFakeTransport.ts # AES-GCM noise transport (post-handshake encrypt/decrypt)
@@ -50,7 +65,7 @@ src/
50
65
  │ ├── fake-media-store.ts # In-memory media blob store
51
66
  │ └── fake-app-state-collection.ts # App-state patch/snapshot provider
52
67
  ├── transport/ # Re-exports from zapo-js (codec, crypto, protos)
53
- └── __tests__/ # Cross-check test suite (147 tests)
68
+ └── __tests__/ # Cross-check test suite
54
69
  └── helpers/ # Shared test utilities (zapo-client factory)
55
70
 
56
71
  bench/
@@ -63,14 +78,63 @@ bench/
63
78
 
64
79
  ### FakeWaServer
65
80
 
66
- Central facade. Manages the WS listener, noise handshake, IQ router, and all state registries:
81
+ Central facade. Manages the WS listener, noise handshake, IQ router, and all state registries.
82
+
83
+ Constructor options (`FakeWaServer.start({...})` / `new FakeWaServer({...})`):
84
+
85
+ ```ts
86
+ const server = await FakeWaServer.start({
87
+ host: '127.0.0.1', // default
88
+ port: 5222, // default: random free port
89
+ path: '/ws/chat', // default
90
+ successNodeAttributes: {
91
+ // stamped on every post-handshake <success/>
92
+ lid: '5511999@lid',
93
+ displayName: 'Fake Display'
94
+ },
95
+ defaultIqHandlers: false // start with an empty IQ router (default: true)
96
+ })
97
+ ```
67
98
 
68
99
  - **Peer registry** (`peerRegistry`): maps device JIDs → `FakePeer` instances. The global `usync` + `prekey-fetch` IQ handlers consult this.
100
+ - **Sessions**: state is grouped per session (see below). A single-client server uses one default session, and the `server.registries` / `server.expectIq(...)` / `server.preKeyDispenser` surface delegates straight to it.
69
101
  - **Group registry** (`groupRegistry`): maps group JIDs → group metadata + participants. The `w:g2` handler serves it.
70
- - **IQ router** (`WaFakeIqRouter`): first-match-wins stanza dispatcher with `{ xmlns, type, childTag }` matchers. ~20 global handlers registered in the constructor cover every IQ the lib emits during normal operation.
102
+ - **IQ router** (`WaFakeIqRouter`): first-match-wins stanza dispatcher with `{ xmlns, type, childTag }` matchers. ~27 global handlers registered in the constructor cover every IQ the lib emits during normal operation (disable them with `defaultIqHandlers: false`). `registerIqHandler(matcher, respond)` registers at high priority and shadows the defaults; a responder that returns `null` falls through to the next matching handler, so you can observe an IQ (capture, assert) and still let the default answer it.
71
103
  - **Prekey dispenser**: hands out unique one-time prekeys from the lib's upload to FakePeers. Resets on each forced refill (`triggerPreKeyUpload({ force: true })`).
72
104
  - **Listener fan-outs**: `onOutboundGroupOp`, `onOutboundPrivacySet`, `onOutboundBlocklistChange`, `onOutboundProfilePictureSet`, `onOutboundStatusSet`, `onLogout`, `onOutboundPrivacyTokenIssue`, `onOutboundDirtyBitsClear`.
73
105
 
106
+ ### Sessions (multiple clients, one server)
107
+
108
+ By default every connection shares one session, so a single `WaClient` sees the whole `FakeWaServer` surface. To run **several clients against one server without them sharing any state**, pass a `sessionKey` resolver: each authenticated connection is bound to an isolated session keyed by the returned id. Sessions never share peers, groups, prekeys, app-state, or captured stanzas.
109
+
110
+ ```ts
111
+ const server = await FakeWaServer.start({
112
+ // Key each connection by its login username; pre-login (registration)
113
+ // connections fall under one transient bucket.
114
+ sessionKey: ({ clientPayload }) =>
115
+ clientPayload.kind === 'login' ? String(clientPayload.username) : 'pending'
116
+ })
117
+
118
+ // After a client authenticates, reach its isolated state through the pipeline:
119
+ const session = server.sessionFor(pipeline) // FakeServerSession
120
+ await session.expectIq({ xmlns: 'passive', type: 'set' })
121
+ session.registries.peerRegistry // this client's peers only
122
+ session.preKeyDispenser.capturedPreKeyBundleSnapshot() // this client's prekeys
123
+
124
+ // Or address a session by id directly:
125
+ const alice = server.session('5511111111111')
126
+
127
+ // Peer creation routes to the connection's session automatically:
128
+ const peer = await server.createFakePeer({ jid: peerJid }, pipeline)
129
+ ```
130
+
131
+ Notes:
132
+
133
+ - The resolver runs once per connection, right after authentication (so `clientPayload` is available). Connections that resolve to the same id reuse one session.
134
+ - `server.registerIqHandler(...)` applies to every session (present and future); `session.registerIqHandler(...)` scopes to one session.
135
+ - The media HTTPS store is process-global and shared across sessions; isolation covers peers/groups/prekeys/app-state/captures, not uploaded media blobs.
136
+ - QR pairing writes to the connection's session too, so pair clients **sequentially** when keying registration connections into a shared bucket.
137
+
74
138
  ### FakePeer
75
139
 
76
140
  Simulated WhatsApp peer with real Signal Protocol crypto:
@@ -91,33 +155,97 @@ Simulated WhatsApp peer with real Signal Protocol crypto:
91
155
 
92
156
  After pairing, the lib reconnects with IK handshake. Use `waitForNextAuthenticatedPipeline()` to capture the post-pair pipeline.
93
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
+
94
207
  ## IQ coverage
95
208
 
96
209
  Every outbound IQ the lib sends during normal operation is handled:
97
210
 
98
- | IQ | Handler | State mutation |
99
- | -------------------------------------------------------------------------------- | ----------------------------- | --------------------------------------- |
100
- | `abt` get | `abprops` | Seedable via `setAbProps()` |
101
- | `w:p` / `urn:xmpp:ping` get | `whatsapp-ping` / `xmpp-ping` | Ack |
102
- | `encrypt` set | `prekey-upload` | Captures bundle, resets dispenser |
103
- | `encrypt` get `<digest>` | `signal-digest` | Returns 404 → forces upload |
104
- | `encrypt` set `<rotate>` | `signed-prekey-rotate` | Ack |
105
- | `encrypt` get `<key>` | `prekey-fetch` | Serves peer bundles from registry |
106
- | `usync` get | `usync` | Resolves device IDs from registry |
107
- | `w:m` set `<media_conn>` | `media-conn` | Points lib at fake HTTPS server |
108
- | `w:sync:app:state` set | `app-state-sync` | Serves patches/snapshots from providers |
109
- | `w:g2` get `<query>` | `group-metadata` | Serves from group registry |
110
- | `w:g2` set `<create\|add\|remove\|promote\|demote\|subject\|description\|leave>` | `group-*` | Mutates group registry |
111
- | `privacy` get | `privacy-get` | Serves settings + disallowed lists |
112
- | `privacy` set `<privacy>` | `privacy-set` | Mutates privacy state |
113
- | `privacy` set `<tokens>` | `privacy-token-issue` | Captures issued tokens |
114
- | `blocklist` get/set | `blocklist-*` | Mutates blocklist |
115
- | `w:profile:picture` get/set | `profile-picture-*` | Mutates profile picture registry |
116
- | `status` set | `status-set` | Captures latest status |
117
- | `w:biz` get/set | `business-profile-*` | Serves/captures business profiles |
118
- | `md` set `<remove-companion-device>` | `remove-companion-device` | Fires logout listeners |
119
- | `newsletter` get `<my_addons>` | `newsletter-my-addons` | Ack |
120
- | `urn:xmpp:whatsapp:dirty` set | `dirty-bits-clear` | Captures cleared bits |
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 |
239
+
240
+ ## Using with non-zapo-js clients
241
+
242
+ The fake server speaks the real wire protocol, so other WhatsApp Web libraries (Baileys forks, whatsmeow, ...) can connect to it too. The recipe:
243
+
244
+ 1. **Point the client's WebSocket URL at `server.url`** (the upgrade path is `/ws/chat`, same as production).
245
+ 2. **Trust the fake root CA.** The server signs its Noise cert chain with a per-instance random root; clients that verify the cert signature against WhatsApp's pinned production key must be told to trust `server.noiseRootCa.publicKey` instead (exposed via the CLI's `--json` as `noiseRootCa.publicKeyHex`). The chain carries a valid `notBefore`/`notAfter` window, so clients that enforce validity work out of the box.
246
+ 3. **Post-connect IQs are answered by default**: the `passive` set IQ and the `encrypt` get `<count>` prekey-count query, which Baileys-family and whatsmeow clients block on before reporting the connection as open.
247
+ 4. **Offline drain bulletin**: after every login the server sends `<ib><offline count="0"/></ib>`, which Baileys-family clients require before flushing their buffered events.
248
+ 5. **QR pairing**: in-process, use `server.runPairing(...)` with the client's `advSecretKey` + identity public key; from the standalone CLI, use `--pair <device-jid>` and paste the QR payload the client displays when prompted.
121
249
 
122
250
  ## Benchmarking
123
251
 
@@ -161,16 +289,35 @@ node --expose-gc --import tsx packages/fake-server/bench/messaging.bench.ts --cp
161
289
 
162
290
  ## CLI
163
291
 
292
+ The package ships a `fake-wa-server` bin, so the standalone server works from any project (or no project at all):
293
+
294
+ ```bash
295
+ # One-off via npx
296
+ npx @zapo-js/fake-server --port 5222 --peer 5511888@s.whatsapp.net --log
297
+
298
+ # Installed as a dev dependency
299
+ npx fake-wa-server --port 5222 --peer 5511888@s.whatsapp.net --log
300
+
301
+ # First-time QR pairing: prompts on stdin for the QR payload the client displays
302
+ npx fake-wa-server --port 5222 --pair 5511999:1@s.whatsapp.net
303
+
304
+ # Print connection info (url + noise root CA hex) as JSON, for scripting
305
+ npx fake-wa-server --json
306
+ ```
307
+
308
+ From inside this monorepo (runs the source, no build needed):
309
+
164
310
  ```bash
165
- # Standalone server for manual testing
166
311
  npm --workspace=@zapo-js/fake-server run cli -- --port 5222 --peer 5511888@s.whatsapp.net --log
167
312
  ```
168
313
 
314
+ Run with `--help` for the full flag list.
315
+
169
316
  ## Test suite
170
317
 
171
318
  ```bash
172
319
  npm --workspace=@zapo-js/fake-server test
173
- # 147 tests, --test-concurrency=1
320
+ # 164 tests, --test-concurrency=1
174
321
  ```
175
322
 
176
323
  Cross-check tests drive a real `WaClient` against the fake server and assert on both sides (lib emits correct events, peer decrypts correctly). Unit tests validate protocol builders/parsers in isolation.
@@ -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 refs = await Promise.all(Array.from({ length: 6 }, () => (0, crypto_1.randomBytesAsync)(16)));
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]);
@@ -550,7 +550,13 @@ class FakePeer {
550
550
  }
551
551
  nextId() {
552
552
  this.nextMessageCounter += 1;
553
- return `${this.jid}-${Date.now()}-${this.nextMessageCounter}`;
553
+ // WA-style uppercase hex, no '@': strict binary decoders tokenize an
554
+ // id containing '@' as a JID and reject the stanza. The registration
555
+ // id keeps ids from colliding across peers created in the same ms.
556
+ const peerPart = this.keyBundle.registrationId.toString(16).padStart(4, '0');
557
+ const timePart = Date.now().toString(16);
558
+ const counterPart = this.nextMessageCounter.toString(16).padStart(4, '0');
559
+ return `3EB0${peerPart}${timePart}${counterPart}`.toUpperCase();
554
560
  }
555
561
  }
556
562
  exports.FakePeer = FakePeer;
@@ -0,0 +1,100 @@
1
+ /** Per-session isolated server state: registries, prekeys, app-state, IQ routing, capture. */
2
+ import type { BuildAbPropsResultInput } from '../protocol/iq/abprops';
3
+ import { type WaFakeIqContext, type WaFakeIqMatcher, type WaFakeIqResponder, WaFakeIqRouter } from '../protocol/iq/router';
4
+ import type { ClientPreKeyBundle } from '../protocol/signal/prekey-upload';
5
+ import { FakeCompanionHostState } from '../state/fake-companion-host';
6
+ import { type BinaryNode } from '../transport/codec';
7
+ import { AppStateSyncManager } from './AppStateSyncManager';
8
+ import { PreKeyDispenser } from './PreKeyDispenser';
9
+ import { ServerRegistries } from './ServerRegistries';
10
+ export interface ExpectIqOptions {
11
+ readonly timeoutMs?: number;
12
+ }
13
+ export interface ExpectStanzaOptions {
14
+ readonly timeoutMs?: number;
15
+ }
16
+ export interface StanzaMatcher {
17
+ readonly tag?: string;
18
+ readonly type?: string;
19
+ readonly xmlns?: string;
20
+ readonly from?: string;
21
+ readonly to?: string;
22
+ readonly childTag?: string;
23
+ }
24
+ /**
25
+ * Shared server-side dependencies a session needs but does not own. The media
26
+ * HTTPS server is process-global (one listener), so its address is injected
27
+ * rather than duplicated per session.
28
+ */
29
+ export interface FakeServerSessionHost {
30
+ requireMediaHttpsInfo(): {
31
+ readonly host: string;
32
+ readonly port: number;
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;
57
+ }
58
+ /**
59
+ * One isolated logical device's worth of server state. Every `WaClient`
60
+ * connected to a `FakeWaServer` is bound to exactly one session; peers,
61
+ * groups, privacy, prekeys, app-state, and captured stanzas never leak across
62
+ * sessions. A single-client server uses one default session, which is why the
63
+ * legacy `FakeWaServer` surface delegates straight through to it.
64
+ */
65
+ export declare class FakeServerSession {
66
+ readonly id: string;
67
+ readonly registries: ServerRegistries;
68
+ readonly preKeyDispenser: PreKeyDispenser;
69
+ readonly appStateSync: AppStateSyncManager;
70
+ readonly companionHost: FakeCompanionHostState;
71
+ readonly iqRouter: WaFakeIqRouter;
72
+ private readonly capturedStanzas;
73
+ private readonly pendingIqExpectations;
74
+ private readonly pendingStanzaExpectations;
75
+ private readonly capturedStanzaListeners;
76
+ private readonly inboundStanzaListeners;
77
+ constructor(id: string, host: FakeServerSessionHost, options: {
78
+ readonly defaultIqHandlers: boolean;
79
+ });
80
+ registerIqHandler(matcher: WaFakeIqMatcher, respond: WaFakeIqResponder, label?: string): () => void;
81
+ routeIq(iq: BinaryNode, context?: WaFakeIqContext): Promise<BinaryNode | null>;
82
+ handleCapturedStanza(node: BinaryNode): void;
83
+ subscribeInboundMessages(listener: (node: BinaryNode) => void): () => void;
84
+ onCapturedStanza(listener: (node: BinaryNode) => void): () => void;
85
+ capturedStanzaSnapshot(): readonly BinaryNode[];
86
+ expectIq(matcher: WaFakeIqMatcher, options?: ExpectIqOptions): Promise<BinaryNode>;
87
+ expectStanza(matcher: StanzaMatcher, options?: ExpectStanzaOptions): Promise<BinaryNode>;
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;
97
+ }
98
+ export declare function matchesIq(iq: BinaryNode, matcher: WaFakeIqMatcher): boolean;
99
+ export declare function matchesStanza(node: BinaryNode, matcher: StanzaMatcher): boolean;
100
+ export type { ClientPreKeyBundle, BuildAbPropsResultInput };