zapo-js 1.1.0 → 1.1.2

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 (62) hide show
  1. package/README.md +5 -1
  2. package/dist/appstate/sync/WaAppStateSyncClient.d.ts +11 -1
  3. package/dist/appstate/sync/WaAppStateSyncClient.js +36 -15
  4. package/dist/auth/credentials-flow.js +3 -1
  5. package/dist/auth/pairing/WaPairingFlow.js +2 -0
  6. package/dist/client/WaClient.js +26 -20
  7. package/dist/client/WaClientFactory.js +28 -6
  8. package/dist/client/connection/WaConnectionManager.js +3 -0
  9. package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
  10. package/dist/client/coordinators/WaAppStateMutationCoordinator.js +29 -5
  11. package/dist/client/coordinators/WaIncomingNodeCoordinator.js +1 -1
  12. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +6 -1
  13. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +5 -5
  14. package/dist/client/coordinators/WaPassiveTasksCoordinator.d.ts +6 -1
  15. package/dist/client/coordinators/WaPassiveTasksCoordinator.js +3 -3
  16. package/dist/client/coordinators/WaProfileCoordinator.d.ts +11 -6
  17. package/dist/client/coordinators/WaProfileCoordinator.js +4 -1
  18. package/dist/client/coordinators/WaRetryCoordinator.d.ts +18 -1
  19. package/dist/client/coordinators/WaRetryCoordinator.js +83 -30
  20. package/dist/client/messaging/ignore-key.js +4 -2
  21. package/dist/client/types.d.ts +13 -10
  22. package/dist/esm/appstate/sync/WaAppStateSyncClient.js +36 -15
  23. package/dist/esm/auth/credentials-flow.js +3 -1
  24. package/dist/esm/auth/pairing/WaPairingFlow.js +2 -0
  25. package/dist/esm/client/WaClient.js +26 -20
  26. package/dist/esm/client/WaClientFactory.js +28 -6
  27. package/dist/esm/client/connection/WaConnectionManager.js +3 -0
  28. package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +29 -5
  29. package/dist/esm/client/coordinators/WaIncomingNodeCoordinator.js +1 -1
  30. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +6 -6
  31. package/dist/esm/client/coordinators/WaPassiveTasksCoordinator.js +3 -3
  32. package/dist/esm/client/coordinators/WaProfileCoordinator.js +4 -1
  33. package/dist/esm/client/coordinators/WaRetryCoordinator.js +84 -31
  34. package/dist/esm/client/messaging/ignore-key.js +4 -2
  35. package/dist/esm/message/WaMessageClient.js +3 -0
  36. package/dist/esm/message/primitives/incoming.js +20 -5
  37. package/dist/esm/protocol/constants.js +1 -1
  38. package/dist/esm/protocol/message.js +22 -0
  39. package/dist/esm/retry/replay.js +36 -2
  40. package/dist/esm/signal/session/SignalRatchet.js +2 -2
  41. package/dist/esm/transport/WaComms.js +4 -0
  42. package/dist/esm/transport/keepalive/WaKeepAlive.js +4 -0
  43. package/dist/esm/transport/node/WaNodeOrchestrator.js +2 -2
  44. package/dist/esm/transport/node/builders/global.js +3 -0
  45. package/dist/message/WaMessageClient.js +3 -0
  46. package/dist/message/primitives/incoming.d.ts +7 -1
  47. package/dist/message/primitives/incoming.js +20 -5
  48. package/dist/message/types.d.ts +5 -0
  49. package/dist/protocol/constants.d.ts +1 -1
  50. package/dist/protocol/constants.js +3 -2
  51. package/dist/protocol/message.d.ts +22 -0
  52. package/dist/protocol/message.js +23 -1
  53. package/dist/retry/replay.d.ts +12 -0
  54. package/dist/retry/replay.js +36 -2
  55. package/dist/signal/session/SignalRatchet.js +2 -2
  56. package/dist/transport/WaComms.js +4 -0
  57. package/dist/transport/keepalive/WaKeepAlive.js +4 -0
  58. package/dist/transport/node/WaNodeOrchestrator.d.ts +6 -1
  59. package/dist/transport/node/WaNodeOrchestrator.js +2 -2
  60. package/dist/transport/node/builders/global.d.ts +1 -0
  61. package/dist/transport/node/builders/global.js +3 -0
  62. package/package.json +1 -1
@@ -33,6 +33,21 @@ interface WaRetryCoordinatorOptions {
33
33
  readonly resolveUserIcdc?: (userJid: string) => Promise<IcdcMeta | null>;
34
34
  readonly peerDataOperation?: PeerDataOperationRequester;
35
35
  readonly emitIncomingMessage?: (event: WaIncomingMessageEvent) => void;
36
+ /**
37
+ * Placeholder resend asks the primary phone (a peer) for the plaintext. A
38
+ * mobile primary is itself the phone and has no peer to ask, so when this
39
+ * resolves true the coordinator skips the resend and falls back to plain
40
+ * retry receipts. Resolved per failure (post-connect, after credentials
41
+ * load) so a registered mobile session reconnecting without an explicit
42
+ * `mobileTransport` option still takes the fallback path.
43
+ */
44
+ readonly isMobilePrimary?: () => boolean;
45
+ /**
46
+ * Resolves the trusted-contact (privacy) token node for a recipient user
47
+ * jid: retry resends must carry the same `<tctoken>` the original send did,
48
+ * or privacy-gated recipients nack them with error 463.
49
+ */
50
+ readonly resolvePrivacyTokenNode?: (recipientJid: string) => Promise<BinaryNode | null>;
36
51
  }
37
52
  export declare class WaRetryCoordinator {
38
53
  private readonly deps;
@@ -44,13 +59,15 @@ export declare class WaRetryCoordinator {
44
59
  private readonly placeholderInFlight;
45
60
  private placeholderQueue;
46
61
  private placeholderTimer;
62
+ private readonly decryptFailureQueue;
47
63
  constructor(options: WaRetryCoordinatorOptions);
48
64
  onDecryptFailure(context: WaRetryDecryptFailureContext, error: unknown): Promise<boolean>;
65
+ private handleDecryptFailure;
49
66
  handleIncomingRetryReceipt(receiptNode: BinaryNode): Promise<void>;
50
67
  private isRetryReceiptNode;
51
68
  private prepareDecryptFailureRetry;
52
69
  private sendDecryptFailureRetryReceipt;
53
- private resolvePeerRetryRecipient;
70
+ private sendDecryptFailureAck;
54
71
  private handleParsedRetryRequest;
55
72
  private processRetryRequest;
56
73
  private prepareRetryResend;
@@ -1,6 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WaRetryCoordinator = void 0;
4
+ const keys_1 = require("../../crypto/core/keys");
5
+ const BoundedTaskQueue_1 = require("../../infra/perf/BoundedTaskQueue");
4
6
  const incoming_1 = require("../../message/primitives/incoming");
5
7
  const _proto_1 = require("../../proto");
6
8
  const constants_1 = require("../../protocol/constants");
@@ -17,6 +19,10 @@ const collections_1 = require("../../util/collections");
17
19
  const primitives_1 = require("../../util/primitives");
18
20
  const RETRY_CLEANUP_INTERVAL_MS = 30000;
19
21
  const RETRY_SESSION_BASE_KEY_CACHE_MAX_ENTRIES = 8192;
22
+ // Decrypt-failure handling runs off the inbound pipeline (keys-section builds
23
+ // serialize on the prekey lock and hit the store); excess under flood is dropped.
24
+ const DECRYPT_FAILURE_QUEUE_MAX_SIZE = 1024;
25
+ const DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY = 8;
20
26
  const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 3;
21
27
  const PLACEHOLDER_RESEND_BATCH_SIZE = 32;
22
28
  const PLACEHOLDER_RESEND_DEBOUNCE_MS = 200;
@@ -58,22 +64,46 @@ class WaRetryCoordinator {
58
64
  signalProtocol: options.signalProtocol,
59
65
  sessionResolver: options.sessionResolver,
60
66
  getCurrentCredentials: options.getCurrentCredentials,
61
- resolveUserIcdc: options.resolveUserIcdc
67
+ resolveUserIcdc: options.resolveUserIcdc,
68
+ resolvePrivacyTokenNode: options.resolvePrivacyTokenNode
62
69
  });
63
70
  this.retryProcessingByMessageId = new Map();
64
71
  this.retrySessionBaseKeys = new Map();
72
+ this.decryptFailureQueue = new BoundedTaskQueue_1.BoundedTaskQueue(DECRYPT_FAILURE_QUEUE_MAX_SIZE, DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY);
65
73
  }
66
- async onDecryptFailure(context, error) {
74
+ onDecryptFailure(context, error) {
75
+ // Deferred to the bounded queue: building the receipt inline would stall
76
+ // the inbound node pipeline.
77
+ void this.decryptFailureQueue
78
+ .enqueue(() => this.handleDecryptFailure(context, error))
79
+ .catch((queueError) => {
80
+ if (queueError instanceof BoundedTaskQueue_1.BoundedTaskQueueFullError) {
81
+ this.deps.logger.warn('decrypt-failure retry dropped: queue saturated', {
82
+ id: context.stanzaId,
83
+ from: context.from,
84
+ participant: context.participant
85
+ });
86
+ return;
87
+ }
88
+ this.deps.logger.warn('failed to schedule decrypt-failure retry', {
89
+ id: context.stanzaId,
90
+ from: context.from,
91
+ participant: context.participant,
92
+ message: (0, primitives_1.toError)(queueError).message
93
+ });
94
+ });
95
+ return Promise.resolve(true);
96
+ }
97
+ async handleDecryptFailure(context, error) {
67
98
  try {
68
99
  const prepared = await this.prepareDecryptFailureRetry(context, error);
69
- if (!prepared) {
70
- return false;
100
+ if (prepared && !prepared.delegatedToPlaceholderResend) {
101
+ await this.sendDecryptFailureRetryReceipt(context, prepared);
71
102
  }
72
- if (prepared.delegatedToPlaceholderResend) {
73
- return true;
74
- }
75
- await this.sendDecryptFailureRetryReceipt(context, prepared);
76
- return true;
103
+ // Ack the failed stanza even on the give-up path: the retry receipt
104
+ // asks for a resend but does not consume the message, so without the
105
+ // ack it is redelivered on every offline resume.
106
+ await this.sendDecryptFailureAck(context);
77
107
  }
78
108
  catch (sendError) {
79
109
  this.deps.logger.warn('failed to send retry receipt for decrypt failure', {
@@ -82,7 +112,6 @@ class WaRetryCoordinator {
82
112
  participant: context.participant,
83
113
  message: (0, primitives_1.toError)(sendError).message
84
114
  });
85
- return false;
86
115
  }
87
116
  }
88
117
  async handleIncomingRetryReceipt(receiptNode) {
@@ -141,6 +170,17 @@ class WaRetryCoordinator {
141
170
  const requester = context.participant ?? context.from;
142
171
  const expiresAtMs = nowMs + this.retryTtlMs;
143
172
  const retryCount = await this.deps.retryStore.incrementInboundCounter(context.stanzaId, requester, nowMs, expiresAtMs);
173
+ if (retryCount > constants_2.MAX_RETRY_ATTEMPTS) {
174
+ // Give up past the ceiling: each attempt rebuilds an expensive keys
175
+ // section, so an uncapped retry on redelivered backlog hammers the store.
176
+ this.deps.logger.debug('retry receipt skipped: inbound retry limit exceeded', {
177
+ id: context.stanzaId,
178
+ from: context.from,
179
+ participant: context.participant,
180
+ retryCount
181
+ });
182
+ return null;
183
+ }
144
184
  const delegatedToPlaceholderResend = retryCount >= PLACEHOLDER_RESEND_RETRY_THRESHOLD &&
145
185
  this.enqueuePlaceholderResend(context);
146
186
  if (delegatedToPlaceholderResend) {
@@ -169,7 +209,7 @@ class WaRetryCoordinator {
169
209
  };
170
210
  }
171
211
  async sendDecryptFailureRetryReceipt(context, prepared) {
172
- const recipient = context.recipient ?? this.resolvePeerRetryRecipient(context);
212
+ const { recipient } = context;
173
213
  const retryReceiptNode = (0, retry_1.buildRetryReceiptNode)({
174
214
  stanzaId: context.stanzaId,
175
215
  to: context.from,
@@ -194,24 +234,33 @@ class WaRetryCoordinator {
194
234
  withKeys: prepared.retryKeys !== undefined
195
235
  });
196
236
  }
197
- resolvePeerRetryRecipient(context) {
198
- if (!context.participant) {
199
- return undefined;
200
- }
201
- const meLid = this.deps.getCurrentCredentials()?.meLid;
202
- if (!meLid) {
203
- return undefined;
237
+ async sendDecryptFailureAck(context) {
238
+ if (!context.stanzaId || !context.from) {
239
+ return;
204
240
  }
205
241
  try {
206
- const participantUser = (0, jid_1.toUserJid)(context.participant);
207
- const meUserLid = (0, jid_1.toUserJid)(meLid);
208
- if (participantUser !== meUserLid) {
209
- return undefined;
210
- }
211
- return meUserLid;
242
+ await this.deps.sendNode((0, global_1.buildAckNode)({
243
+ kind: 'message',
244
+ node: context.messageNode,
245
+ id: context.stanzaId,
246
+ to: context.from,
247
+ participant: context.participant,
248
+ from: this.deps.getCurrentCredentials()?.meJid ?? undefined,
249
+ error: constants_1.WA_NACK_REASONS.UNHANDLED_ERROR
250
+ }));
251
+ this.deps.logger.trace('acked undecryptable stanza', {
252
+ id: context.stanzaId,
253
+ from: context.from,
254
+ participant: context.participant
255
+ });
212
256
  }
213
- catch {
214
- return undefined;
257
+ catch (error) {
258
+ this.deps.logger.warn('failed to ack undecryptable stanza', {
259
+ id: context.stanzaId,
260
+ from: context.from,
261
+ participant: context.participant,
262
+ message: (0, primitives_1.toError)(error).message
263
+ });
215
264
  }
216
265
  }
217
266
  async handleParsedRetryRequest(receiptNode, request) {
@@ -387,14 +436,14 @@ class WaRetryCoordinator {
387
436
  await this.deps.preKeyStore.markKeyAsUploaded(preKey.keyId);
388
437
  const signedIdentity = this.deps.getCurrentCredentials()?.signedIdentity;
389
438
  return {
390
- identity,
439
+ identity: (0, keys_1.toRawPubKey)(identity),
391
440
  key: {
392
441
  id: preKey.keyId,
393
- publicKey: preKey.keyPair.pubKey
442
+ publicKey: (0, keys_1.toRawPubKey)(preKey.keyPair.pubKey)
394
443
  },
395
444
  skey: {
396
445
  id: signedPreKey.keyId,
397
- publicKey: signedPreKey.keyPair.pubKey,
446
+ publicKey: (0, keys_1.toRawPubKey)(signedPreKey.keyPair.pubKey),
398
447
  signature: signedPreKey.signature
399
448
  },
400
449
  deviceIdentity: signedIdentity
@@ -722,6 +771,9 @@ class WaRetryCoordinator {
722
771
  if (!this.deps.peerDataOperation || !this.deps.emitIncomingMessage) {
723
772
  return false;
724
773
  }
774
+ if (this.deps.isMobilePrimary?.()) {
775
+ return false;
776
+ }
725
777
  const subtype = context.messageNode.attrs.subtype;
726
778
  if (typeof subtype === 'string' && PLACEHOLDER_RESEND_SKIP_SUBTYPES.has(subtype)) {
727
779
  return false;
@@ -779,6 +831,7 @@ class WaRetryCoordinator {
779
831
  }
780
832
  }))
781
833
  });
834
+ const meJid = this.deps.getCurrentCredentials()?.meJid;
782
835
  for (const result of results) {
783
836
  const bytes = result.placeholderMessageResendResponse?.webMessageInfoBytes;
784
837
  if (!bytes) {
@@ -786,7 +839,7 @@ class WaRetryCoordinator {
786
839
  }
787
840
  try {
788
841
  const recovered = _proto_1.proto.WebMessageInfo.decode(bytes);
789
- emitIncomingMessage((0, incoming_1.buildRecoveredIncomingEvent)(recovered));
842
+ emitIncomingMessage((0, incoming_1.buildRecoveredIncomingEvent)(recovered, meJid));
790
843
  }
791
844
  catch (error) {
792
845
  this.deps.logger.warn('placeholder resend: failed to decode WebMessageInfo', {
@@ -88,12 +88,14 @@ function extractIgnoreKeyContext(node, meJid) {
88
88
  const me = tryParseJid(meJid);
89
89
  const fromCandidates = collectFromCandidates(kind, a);
90
90
  const fromMe = me !== null && fromCandidates.some((f) => tryParseJid(f)?.address.user === me.address.user);
91
+ // Device-stripped to match the JID form used by events/keys; a userless
92
+ // server `from` like `s.whatsapp.net` is unparseable, so fall back to raw.
91
93
  return {
92
94
  kind,
93
- remoteJid: a.from ?? null,
95
+ remoteJid: tryParseJid(a.from)?.userJid ?? a.from ?? null,
94
96
  fromMe,
95
97
  id: a.id,
96
- participant: a.participant ?? null
98
+ participant: tryParseJid(a.participant)?.userJid ?? a.participant ?? null
97
99
  };
98
100
  }
99
101
  /** Pure matcher. Exported for direct testing without a coordinator. */
@@ -423,27 +423,30 @@ export interface WaIgnoreKey {
423
423
  * Lib derives `kind` from the stanza tag and resolves `fromMe` by comparing
424
424
  * every from-candidate (`from`, `sender_pn`, `sender_lid`) against `meJid`.
425
425
  *
426
- * `remoteJid` and `participant` expose the **raw** `from` / `participant`
427
- * attrs verbatim and do NOT include the descriptor-style alt-attr lookups
428
- * (`sender_pn` / `sender_lid` / `participant_pn` / `participant_lid`) or
429
- * PN↔LID normalization. If the predicate needs to match by user identity
430
- * regardless of addressing mode, run the raw JID through `parseJidFull` and
431
- * compare on `userJid`, or use the descriptor form which handles it.
426
+ * `remoteJid` and `participant` are the `from` / `participant` attrs with the
427
+ * `:device` segment stripped (bare `user@server`), matching the JID form used
428
+ * by message events and keys. A value that does not parse as a JID (e.g. a
429
+ * userless server `from` like `s.whatsapp.net`) is passed through unchanged.
430
+ * They do NOT include the descriptor-style
431
+ * alt-attr lookups (`sender_pn` / `sender_lid` / `participant_pn` /
432
+ * `participant_lid`) or PN↔LID normalization, so they stay in whichever
433
+ * addressing mode the stanza arrived in. To match by user identity regardless
434
+ * of addressing mode, use the descriptor form, which handles it.
432
435
  */
433
436
  export interface WaIgnoreKeyContext {
434
437
  readonly kind: WaIgnoreStanzaKind;
435
- /** Raw `from` attr (group JID for groups, PN or LID device JID for 1:1). */
438
+ /** `from` attr without `:device` (group JID for groups, PN or LID user JID for 1:1). */
436
439
  readonly remoteJid: string | null;
437
440
  readonly fromMe: boolean;
438
441
  readonly id: string | undefined;
439
- /** Raw `participant` attr; `null` for non-group stanzas. */
442
+ /** `participant` attr without `:device`; `null` for non-group stanzas. */
440
443
  readonly participant: string | null;
441
444
  }
442
445
  /**
443
446
  * Predicate form of {@link WaClient.ignoreKey}. Return `true` to drop the
444
447
  * stanza, `false` to let it through. Receives a {@link WaIgnoreKeyContext}
445
- * with the raw `from`/`participant` attrs (see the context's JSDoc for the
446
- * PN↔LID caveat) plus lib-resolved `kind` and `fromMe`.
448
+ * with the device-stripped `from`/`participant` (see the context's JSDoc for
449
+ * the addressing-mode caveat) plus lib-resolved `kind` and `fromMe`.
447
450
  */
448
451
  export type WaIgnoreKeyPredicate = (ctx: WaIgnoreKeyContext) => boolean;
449
452
  export interface WaIncomingBaseEvent {
@@ -35,20 +35,33 @@ export class WaAppStateSyncClient {
35
35
  this.sendKeyShare = options.sendKeyShare;
36
36
  this.triggerSync = options.triggerSync;
37
37
  this.crypto = new WaAppStateCrypto(undefined, options.skipMacVerification === true);
38
- this.mobilePrimary = options.mobilePrimary ?? false;
38
+ this.mobilePrimary = options.mobilePrimary ?? (() => false);
39
39
  this.syncContext = null;
40
40
  this.syncPromise = null;
41
41
  }
42
42
  /**
43
43
  * Returns the active app-state sync key, generating and persisting a new
44
44
  * one when the store is empty (used during initial setup).
45
+ *
46
+ * The key id mirrors the primary device layout: 2 big-endian bytes of
47
+ * device id followed by a 4 big-endian byte epoch. `keyEpoch` and
48
+ * `pickActiveSyncKey` read that structure, so a shorter id would make the
49
+ * generated key invisible to active-key selection.
45
50
  */
46
51
  async ensureInitialSyncKey() {
47
52
  const existing = await this.store.getActiveSyncKey();
48
53
  if (existing) {
49
54
  return existing;
50
55
  }
51
- const keyIdBytes = await randomBytesAsync(2);
56
+ const deviceId = this.resolveDeviceIndex() ?? 0;
57
+ const epoch = await randomIntAsync(1, 65537);
58
+ const keyIdBytes = new Uint8Array(6);
59
+ keyIdBytes[0] = (deviceId >>> 8) & 0xff;
60
+ keyIdBytes[1] = deviceId & 0xff;
61
+ keyIdBytes[2] = (epoch >>> 24) & 0xff;
62
+ keyIdBytes[3] = (epoch >>> 16) & 0xff;
63
+ keyIdBytes[4] = (epoch >>> 8) & 0xff;
64
+ keyIdBytes[5] = epoch & 0xff;
52
65
  const keyData = await randomBytesAsync(32);
53
66
  const rawId = await randomIntAsync(0, 4294967295);
54
67
  const key = {
@@ -61,6 +74,7 @@ export class WaAppStateSyncClient {
61
74
  this.crypto.clearCache();
62
75
  this.logger.info('app-state initial sync key generated (mobile primary)', {
63
76
  keyId: bytesToHex(keyIdBytes),
77
+ epoch,
64
78
  rawId
65
79
  });
66
80
  return key;
@@ -386,17 +400,18 @@ export class WaAppStateSyncClient {
386
400
  async buildCollectionSyncRequest(collection, pendingByCollection, activeSyncKey) {
387
401
  const collectionState = await this.getCollectionState(collection);
388
402
  const hasPersistedState = collectionState.initialized;
403
+ const requestSnapshot = !this.mobilePrimary() && !hasPersistedState;
389
404
  const attrs = {
390
405
  name: collection,
391
406
  version: String(hasPersistedState ? collectionState.version : APP_STATE_DEFAULT_COLLECTION_VERSION),
392
- return_snapshot: hasPersistedState ? 'false' : 'true'
407
+ return_snapshot: requestSnapshot ? 'true' : 'false'
393
408
  };
394
409
  const children = [];
395
410
  const pendingMutations = pendingByCollection.get(collection) ?? [];
396
411
  let outgoingContext;
397
412
  let skippedUpload = false;
398
413
  if (pendingMutations.length > 0) {
399
- if (!hasPersistedState) {
414
+ if (!hasPersistedState && !this.mobilePrimary()) {
400
415
  skippedUpload = true;
401
416
  this.logger.debug('app-state skipped outgoing patch upload until snapshot bootstrap', {
402
417
  collection,
@@ -435,7 +450,7 @@ export class WaAppStateSyncClient {
435
450
  content: [
436
451
  {
437
452
  tag: WA_NODE_TAGS.SYNC,
438
- attrs: this.mobilePrimary ? { data_namespace: '3' } : {},
453
+ attrs: this.mobilePrimary() ? { data_namespace: '3' } : {},
439
454
  content: collectionNodes
440
455
  }
441
456
  ]
@@ -470,20 +485,19 @@ export class WaAppStateSyncClient {
470
485
  return this.createCollectionOutcome(collection, payload.state, payload.version);
471
486
  }
472
487
  const pendingMutationsCount = pendingByCollection.get(collection)?.length ?? 0;
473
- if (payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT ||
474
- payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT_HAS_MORE) {
488
+ const isConflict = payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT ||
489
+ payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT_HAS_MORE;
490
+ if (isConflict) {
475
491
  shouldRefetch =
476
492
  payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT_HAS_MORE ||
477
493
  pendingMutationsCount > 0;
478
- return this.createCollectionOutcome(collection, payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT
479
- ? pendingMutationsCount > 0
480
- ? WA_APP_STATE_COLLECTION_STATES.CONFLICT
481
- : WA_APP_STATE_COLLECTION_STATES.SUCCESS
482
- : payload.state, payload.version, shouldRefetch);
483
494
  }
484
495
  try {
485
496
  let appliedMutations = [];
486
- if (payload.snapshotReference) {
497
+ if (payload.snapshotReference && this.mobilePrimary()) {
498
+ collectionLogger.debug('app-state ignoring server snapshot on primary device');
499
+ }
500
+ else if (payload.snapshotReference) {
487
501
  const downloader = options.downloadExternalBlob;
488
502
  if (!downloader) {
489
503
  throw new Error(`snapshot for ${payload.collection} requires external blob downloader`);
@@ -522,12 +536,19 @@ export class WaAppStateSyncClient {
522
536
  payload.state === WA_APP_STATE_COLLECTION_STATES.SUCCESS_HAS_MORE ||
523
537
  (payload.state === WA_APP_STATE_COLLECTION_STATES.SUCCESS &&
524
538
  skippedUploadCollections.has(collection));
539
+ const resolvedState = !isConflict
540
+ ? payload.state
541
+ : payload.state === WA_APP_STATE_COLLECTION_STATES.CONFLICT
542
+ ? pendingMutationsCount > 0
543
+ ? WA_APP_STATE_COLLECTION_STATES.CONFLICT
544
+ : WA_APP_STATE_COLLECTION_STATES.SUCCESS
545
+ : payload.state;
525
546
  collectionLogger.debug('app-state collection processed', {
526
- state: payload.state,
547
+ state: resolvedState,
527
548
  version: payload.version,
528
549
  appliedMutations: appliedMutations.length
529
550
  });
530
- return this.createCollectionOutcome(collection, payload.state, payload.version, shouldRefetch, collectionStateChanged, appliedMutations);
551
+ return this.createCollectionOutcome(collection, resolvedState, payload.version, shouldRefetch, collectionStateChanged, appliedMutations);
531
552
  }
532
553
  catch (error) {
533
554
  if (error instanceof WaAppStateMissingKeyError) {
@@ -30,6 +30,8 @@ export async function loadOrCreateCredentials(args) {
30
30
  }
31
31
  await restoreSignalStore(args.signalStore, args.preKeyStore, existing);
32
32
  args.logger.trace('auth credentials restored into signal store');
33
+ // A mobile primary has no self-signed device-identity and no key-index-list:
34
+ // both are companion-only (set at pairing). Do not re-add them here.
33
35
  return existing;
34
36
  }
35
37
  export async function persistCredentials(args, credentials) {
@@ -129,7 +131,7 @@ export async function buildCommsConfig(logger, credentials, socketOptions, clien
129
131
  noise: {
130
132
  clientStaticKeyPair: credentials.noiseKeyPair,
131
133
  isRegistered: registered,
132
- serverStaticKey: credentials.serverStaticKey,
134
+ serverStaticKey: registered ? credentials.serverStaticKey : undefined,
133
135
  routingInfo: credentials.routingInfo,
134
136
  trustedRootCa: clientOptions.noiseTrustedRootCa,
135
137
  verifyCertificateChain: clientOptions.disableNoiseCertificateChainVerification
@@ -8,6 +8,7 @@ import { ADV_PREFIX_HOSTED_ACCOUNT_SIGNATURE, computeAdvIdentityHmac, generateDe
8
8
  import { buildAckNode, buildIqResultNode } from '../../transport/node/builders/global.js';
9
9
  import { buildCompanionFinishRequestNode, buildCompanionHelloRequestNode, buildGetCountryCodeRequestNode } from '../../transport/node/builders/pairing.js';
10
10
  import { decodeNodeContentUtf8OrBytes, findNodeChild, findNodeChildrenByTags, getFirstNodeChild, getNodeChildrenNonEmptyUtf8ByTag, hasNodeChild } from '../../transport/node/helpers.js';
11
+ import { assertIqResult } from '../../transport/node/query.js';
11
12
  import { concatBytes, decodeProtoBytes, uint8Equal, uint8TimingSafeEqual } from '../../util/bytes.js';
12
13
  export class WaPairingFlow {
13
14
  constructor(options) {
@@ -46,6 +47,7 @@ export class WaPairingFlow {
46
47
  responseTag: response.tag,
47
48
  responseType: response.attrs.type
48
49
  });
50
+ assertIqResult(response, 'companion hello');
49
51
  const linkCodeNode = findNodeChild(response, WA_NODE_TAGS.LINK_CODE_COMPANION_REG);
50
52
  if (!linkCodeNode) {
51
53
  throw new Error('companion hello response missing link_code_companion_reg');
@@ -255,10 +255,28 @@ export class WaClient extends EventEmitter {
255
255
  return;
256
256
  }
257
257
  if (protocolType === proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION) {
258
- if (this.options.history?.enabled !== false &&
259
- protocolMessage.historySyncNotification) {
260
- const peerRemoteJid = event.key.remoteJid;
261
- const peerStanzaId = event.key.id;
258
+ if (!protocolMessage.historySyncNotification) {
259
+ return;
260
+ }
261
+ const peerRemoteJid = event.key.remoteJid;
262
+ const peerStanzaId = event.key.id;
263
+ const sendHistSyncReceipt = peerRemoteJid && peerStanzaId
264
+ ? async () => {
265
+ try {
266
+ await this.message.sendReceipt(peerRemoteJid, peerStanzaId, {
267
+ type: WA_MESSAGE_TYPES.RECEIPT_TYPE_HISTORY_SYNC
268
+ });
269
+ }
270
+ catch (err) {
271
+ this.logger.warn('failed to send hist_sync receipt', {
272
+ id: peerStanzaId,
273
+ to: peerRemoteJid,
274
+ message: toError(err).message
275
+ });
276
+ }
277
+ }
278
+ : undefined;
279
+ if (this.options.history?.enabled !== false) {
262
280
  await runHistorySyncNotification({
263
281
  logger: this.logger,
264
282
  mediaTransfer: this.mediaTransfer,
@@ -266,24 +284,12 @@ export class WaClient extends EventEmitter {
266
284
  emitEvent: this.emit.bind(this),
267
285
  onPrivacyTokens: (conversations) => this.deps.trustedContactToken.hydrateFromHistorySync(conversations),
268
286
  onNctSalt: (salt) => this.deps.trustedContactToken.hydrateNctSaltFromHistorySync(salt),
269
- onProcessed: peerRemoteJid && peerStanzaId
270
- ? async () => {
271
- try {
272
- await this.message.sendReceipt(peerRemoteJid, peerStanzaId, {
273
- type: WA_MESSAGE_TYPES.RECEIPT_TYPE_HISTORY_SYNC
274
- });
275
- }
276
- catch (err) {
277
- this.logger.warn('failed to send hist_sync receipt', {
278
- id: peerStanzaId,
279
- to: peerRemoteJid,
280
- message: toError(err).message
281
- });
282
- }
283
- }
284
- : undefined
287
+ onProcessed: sendHistSyncReceipt
285
288
  }, protocolMessage.historySyncNotification);
286
289
  }
290
+ else if (sendHistSyncReceipt) {
291
+ await sendHistSyncReceipt();
292
+ }
287
293
  return;
288
294
  }
289
295
  if (SYNC_RELATED_PROTOCOL_TYPES.has(protocolType)) {
@@ -210,7 +210,7 @@ export function buildWaClientDependencies(input) {
210
210
  logger,
211
211
  defaultTimeoutMs: options.nodeQueryTimeoutMs,
212
212
  hostDomain: WA_DEFAULTS.HOST_DOMAIN,
213
- mobileIqIdFormat: options.mobileTransport !== undefined
213
+ mobileIqIdFormat: () => isMobilePrimary()
214
214
  });
215
215
  const keepAlive = new WaKeepAlive({
216
216
  logger,
@@ -352,6 +352,7 @@ export function buildWaClientDependencies(input) {
352
352
  }
353
353
  });
354
354
  const getCurrentCredentials = authClient.getCurrentCredentials.bind(authClient);
355
+ const isMobilePrimary = () => options.mobileTransport !== undefined || Boolean(getCurrentCredentials()?.deviceInfo);
355
356
  const groupCoordinator = createGroupCoordinator({
356
357
  queryWithContext: runtime.queryWithContext,
357
358
  mexSocket: { query: runtime.query }
@@ -477,7 +478,7 @@ export function buildWaClientDependencies(input) {
477
478
  additionalAttributes: sendOptions.additionalAttributes
478
479
  }),
479
480
  getIcdcHashLength: () => abPropsCoordinator.getConfigValue('md_icdc_hash_length'),
480
- mobileMessageIdFormat: options.mobileTransport !== undefined,
481
+ mobileMessageIdFormat: isMobilePrimary,
481
482
  serverClock
482
483
  });
483
484
  const presenceCoordinator = createPresenceCoordinator({
@@ -517,12 +518,15 @@ export function buildWaClientDependencies(input) {
517
518
  sendNode: runtime.sendNode,
518
519
  getCurrentCredentials,
519
520
  resolveUserIcdc: (userJid) => messageDispatch.resolveUserIcdc(userJid),
521
+ resolvePrivacyTokenNode: (recipientJid) => trustedContactToken.resolveTokenForMessage(recipientJid),
522
+ // Placeholder resend asks the primary phone (a peer) for the plaintext.
520
523
  peerDataOperation,
521
524
  emitIncomingMessage: (event) => {
522
525
  void runtime
523
526
  .handleIncomingMessageEvent(event)
524
527
  .catch((err) => runtime.handleError(toError(err)));
525
- }
528
+ },
529
+ isMobilePrimary
526
530
  });
527
531
  const botCoordinator = createBotCoordinator({
528
532
  logger,
@@ -546,7 +550,7 @@ export function buildWaClientDependencies(input) {
546
550
  await messageDispatch.requestAppStateSyncKeys(keyIds);
547
551
  },
548
552
  skipMacVerification: options.dangerous?.disableAppStateMacVerification,
549
- mobilePrimary: options.mobileTransport !== undefined,
553
+ mobilePrimary: isMobilePrimary,
550
554
  isOwnAccountDevice: (deviceJid) => {
551
555
  const credentials = getCurrentCredentials();
552
556
  if (!credentials)
@@ -560,6 +564,18 @@ export function buildWaClientDependencies(input) {
560
564
  await runtime.syncAppState();
561
565
  }
562
566
  });
567
+ // Persists a pushName change and re-broadcasts presence carrying it (how the
568
+ // name reaches peers on primary connections). No-op when unchanged, which
569
+ // also collapses the app-state echo of our own SettingPushName write.
570
+ const applyOwnPushName = async (name) => {
571
+ if (getCurrentCredentials()?.meDisplayName === name) {
572
+ return;
573
+ }
574
+ await authClient.persistSuccessAttributes({ meDisplayName: name });
575
+ if (connectionManager?.isConnected()) {
576
+ await presenceCoordinator.send();
577
+ }
578
+ };
563
579
  const appStateMutations = new WaAppStateMutationCoordinator({
564
580
  logger,
565
581
  messageStore: sessionStore.messages,
@@ -570,7 +586,12 @@ export function buildWaClientDependencies(input) {
570
586
  emitSnapshotMutations: options.chatEvents?.emitSnapshotMutations === true,
571
587
  emitMutation: (event) => runtime.emitEvent('mutation', event),
572
588
  nctSaltSink: (salt) => trustedContactToken.handleNctSaltSync(salt),
573
- contactSink: runtime.persistContact
589
+ contactSink: runtime.persistContact,
590
+ pushNameSink: (name) => {
591
+ void applyOwnPushName(name).catch((error) => logger.debug('apply own pushName from app-state sync failed', {
592
+ message: toError(error).message
593
+ }));
594
+ }
574
595
  });
575
596
  const profileCoordinator = createProfileCoordinator({
576
597
  queryWithContext: runtime.queryWithContext,
@@ -578,6 +599,7 @@ export function buildWaClientDependencies(input) {
578
599
  mexSocket: { query: runtime.query },
579
600
  queryLidsByPhoneJids: (phoneJids) => signalDeviceSync.queryLidsByPhoneJids(phoneJids),
580
601
  mutations: appStateMutations,
602
+ applyOwnPushName,
581
603
  logger
582
604
  });
583
605
  const statusCoordinator = createStatusCoordinator({
@@ -975,7 +997,7 @@ export function buildWaClientDependencies(input) {
975
997
  abPropsCoordinator,
976
998
  markOnlineOnConnect: options.markOnlineOnConnect ?? false
977
999
  }),
978
- mobilePrimary: options.mobileTransport !== undefined,
1000
+ mobilePrimary: isMobilePrimary,
979
1001
  appStateSync
980
1002
  });
981
1003
  const lowLevelCoordinator = createLowLevelCoordinator({
@@ -246,6 +246,9 @@ export class WaConnectionManager {
246
246
  if (!serverStaticKey) {
247
247
  this.logger.trace('no server static key available to persist');
248
248
  }
249
+ else if (!credentials.meJid) {
250
+ this.logger.trace('skipping server static key persist while unregistered');
251
+ }
249
252
  else {
250
253
  await this.authClient.persistServerStaticKey(serverStaticKey);
251
254
  this.assertLifecycleCurrent(lifecycleGeneration, 'start comms');