zapo-js 1.1.1 → 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 (48) hide show
  1. package/dist/appstate/sync/WaAppStateSyncClient.d.ts +11 -1
  2. package/dist/appstate/sync/WaAppStateSyncClient.js +36 -15
  3. package/dist/auth/credentials-flow.js +3 -1
  4. package/dist/client/WaClientFactory.js +28 -6
  5. package/dist/client/connection/WaConnectionManager.js +3 -0
  6. package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
  7. package/dist/client/coordinators/WaAppStateMutationCoordinator.js +29 -5
  8. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +6 -1
  9. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +5 -5
  10. package/dist/client/coordinators/WaPassiveTasksCoordinator.d.ts +6 -1
  11. package/dist/client/coordinators/WaPassiveTasksCoordinator.js +3 -3
  12. package/dist/client/coordinators/WaProfileCoordinator.d.ts +11 -6
  13. package/dist/client/coordinators/WaProfileCoordinator.js +4 -1
  14. package/dist/client/coordinators/WaRetryCoordinator.d.ts +18 -0
  15. package/dist/client/coordinators/WaRetryCoordinator.js +87 -14
  16. package/dist/esm/appstate/sync/WaAppStateSyncClient.js +36 -15
  17. package/dist/esm/auth/credentials-flow.js +3 -1
  18. package/dist/esm/client/WaClientFactory.js +28 -6
  19. package/dist/esm/client/connection/WaConnectionManager.js +3 -0
  20. package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +29 -5
  21. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +6 -6
  22. package/dist/esm/client/coordinators/WaPassiveTasksCoordinator.js +3 -3
  23. package/dist/esm/client/coordinators/WaProfileCoordinator.js +4 -1
  24. package/dist/esm/client/coordinators/WaRetryCoordinator.js +88 -15
  25. package/dist/esm/message/WaMessageClient.js +3 -0
  26. package/dist/esm/message/primitives/incoming.js +14 -3
  27. package/dist/esm/protocol/constants.js +1 -1
  28. package/dist/esm/protocol/message.js +22 -0
  29. package/dist/esm/retry/replay.js +36 -2
  30. package/dist/esm/signal/session/SignalRatchet.js +2 -2
  31. package/dist/esm/transport/node/WaNodeOrchestrator.js +2 -2
  32. package/dist/esm/transport/node/builders/global.js +3 -0
  33. package/dist/message/WaMessageClient.js +3 -0
  34. package/dist/message/primitives/incoming.d.ts +7 -1
  35. package/dist/message/primitives/incoming.js +14 -3
  36. package/dist/message/types.d.ts +5 -0
  37. package/dist/protocol/constants.d.ts +1 -1
  38. package/dist/protocol/constants.js +3 -2
  39. package/dist/protocol/message.d.ts +22 -0
  40. package/dist/protocol/message.js +23 -1
  41. package/dist/retry/replay.d.ts +12 -0
  42. package/dist/retry/replay.js +36 -2
  43. package/dist/signal/session/SignalRatchet.js +2 -2
  44. package/dist/transport/node/WaNodeOrchestrator.d.ts +6 -1
  45. package/dist/transport/node/WaNodeOrchestrator.js +2 -2
  46. package/dist/transport/node/builders/global.d.ts +1 -0
  47. package/dist/transport/node/builders/global.js +3 -0
  48. package/package.json +1 -1
@@ -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) {
@@ -194,6 +234,35 @@ class WaRetryCoordinator {
194
234
  withKeys: prepared.retryKeys !== undefined
195
235
  });
196
236
  }
237
+ async sendDecryptFailureAck(context) {
238
+ if (!context.stanzaId || !context.from) {
239
+ return;
240
+ }
241
+ try {
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
+ });
256
+ }
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
+ });
264
+ }
265
+ }
197
266
  async handleParsedRetryRequest(receiptNode, request) {
198
267
  if (request.type === constants_1.WA_MESSAGE_TYPES.RECEIPT_TYPE_ENC_REKEY_RETRY) {
199
268
  this.deps.logger.debug('received enc_rekey_retry request (voip path deferred)', {
@@ -367,14 +436,14 @@ class WaRetryCoordinator {
367
436
  await this.deps.preKeyStore.markKeyAsUploaded(preKey.keyId);
368
437
  const signedIdentity = this.deps.getCurrentCredentials()?.signedIdentity;
369
438
  return {
370
- identity,
439
+ identity: (0, keys_1.toRawPubKey)(identity),
371
440
  key: {
372
441
  id: preKey.keyId,
373
- publicKey: preKey.keyPair.pubKey
442
+ publicKey: (0, keys_1.toRawPubKey)(preKey.keyPair.pubKey)
374
443
  },
375
444
  skey: {
376
445
  id: signedPreKey.keyId,
377
- publicKey: signedPreKey.keyPair.pubKey,
446
+ publicKey: (0, keys_1.toRawPubKey)(signedPreKey.keyPair.pubKey),
378
447
  signature: signedPreKey.signature
379
448
  },
380
449
  deviceIdentity: signedIdentity
@@ -702,6 +771,9 @@ class WaRetryCoordinator {
702
771
  if (!this.deps.peerDataOperation || !this.deps.emitIncomingMessage) {
703
772
  return false;
704
773
  }
774
+ if (this.deps.isMobilePrimary?.()) {
775
+ return false;
776
+ }
705
777
  const subtype = context.messageNode.attrs.subtype;
706
778
  if (typeof subtype === 'string' && PLACEHOLDER_RESEND_SKIP_SUBTYPES.has(subtype)) {
707
779
  return false;
@@ -759,6 +831,7 @@ class WaRetryCoordinator {
759
831
  }
760
832
  }))
761
833
  });
834
+ const meJid = this.deps.getCurrentCredentials()?.meJid;
762
835
  for (const result of results) {
763
836
  const bytes = result.placeholderMessageResendResponse?.webMessageInfoBytes;
764
837
  if (!bytes) {
@@ -766,7 +839,7 @@ class WaRetryCoordinator {
766
839
  }
767
840
  try {
768
841
  const recovered = _proto_1.proto.WebMessageInfo.decode(bytes);
769
- emitIncomingMessage((0, incoming_1.buildRecoveredIncomingEvent)(recovered));
842
+ emitIncomingMessage((0, incoming_1.buildRecoveredIncomingEvent)(recovered, meJid));
770
843
  }
771
844
  catch (error) {
772
845
  this.deps.logger.warn('placeholder resend: failed to decode WebMessageInfo', {
@@ -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
@@ -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');
@@ -180,6 +180,7 @@ export class WaAppStateMutationCoordinator {
180
180
  this.emitSnapshotMutations = options.emitSnapshotMutations === true;
181
181
  this.nctSaltSink = options.nctSaltSink;
182
182
  this.contactSink = options.contactSink;
183
+ this.pushNameSink = options.pushNameSink;
183
184
  this.pendingMutations = new Map();
184
185
  this.flushPromise = null;
185
186
  }
@@ -227,11 +228,10 @@ export class WaAppStateMutationCoordinator {
227
228
  emitEventsFromSyncResult(syncResult) {
228
229
  for (const collectionResult of syncResult.collections) {
229
230
  const mutations = collectionResult.mutations ?? [];
230
- // Persistence sinks (contact store, ...): run on the last-wins
231
- // mutation per key INCLUDING snapshot sources, so pair-time
232
- // bootstrap of the address book always lands in the store even
233
- // when public events are suppressed for snapshot mutations.
234
- if (this.contactSink) {
231
+ // Persistence sinks (contact store, own pushName): run on the
232
+ // last-wins mutation per key INCLUDING snapshot sources, so
233
+ // pair-time bootstrap lands even when snapshot events are suppressed.
234
+ if (this.contactSink || this.pushNameSink) {
235
235
  const sinkLastIndex = new Map();
236
236
  for (let i = 0; i < mutations.length; i += 1) {
237
237
  const m = mutations[i];
@@ -252,6 +252,16 @@ export class WaAppStateMutationCoordinator {
252
252
  message: toError(error).message
253
253
  });
254
254
  }
255
+ try {
256
+ this.handlePushNameMutation(m);
257
+ }
258
+ catch (error) {
259
+ this.logger.debug('pushName sink failed', {
260
+ collection: m.collection,
261
+ index: m.index,
262
+ message: toError(error).message
263
+ });
264
+ }
255
265
  }
256
266
  }
257
267
  const lastMutationIndexByKey = new Map();
@@ -356,6 +366,20 @@ export class WaAppStateMutationCoordinator {
356
366
  lastUpdatedMs
357
367
  });
358
368
  }
369
+ handlePushNameMutation(mutation) {
370
+ if (!this.pushNameSink)
371
+ return;
372
+ // A `set` under the literal index ["setting_pushName"]; cheap reject
373
+ // before reading the value.
374
+ if (mutation.operation !== 'set')
375
+ return;
376
+ if (!mutation.index.includes('setting_pushName'))
377
+ return;
378
+ const name = mutation.value?.pushNameSetting?.name;
379
+ if (typeof name !== 'string')
380
+ return;
381
+ this.pushNameSink(name);
382
+ }
359
383
  /**
360
384
  * Mutes or unmutes a chat. `muteEndTimestampMs` is required when
361
385
  * `muted` is `true` and must be a non-negative safe-integer epoch.
@@ -11,7 +11,7 @@ import { wrapDeviceSentMessage } from '../../message/encode/device-sent.js';
11
11
  import { writeRandomPadMax16 } from '../../message/encode/padding.js';
12
12
  import { buildBotInvokeProtoCopy, extractInvokedBotJid, genBotMsgSecret } from '../../message/kinds/bot.js';
13
13
  import { proto } from '../../proto.js';
14
- import { WA_DEFAULTS } from '../../protocol/constants.js';
14
+ import { WA_DEFAULTS, WA_NACK_REASONS } from '../../protocol/constants.js';
15
15
  import { isBotJid, isGroupJid, isHostedDeviceJid, isLidJid, isNewsletterJid, normalizeDeviceJid, normalizeRecipientJid, parseJidFull, parseSignalAddressFromJid, signalAddressKey, toUserJid } from '../../protocol/jid.js';
16
16
  import { encodeBinaryNode } from '../../transport/binary/index.js';
17
17
  import { buildButtonAddonNode, buildDirectMessageFanoutNode, buildGroupSenderKeyMessageNode, buildMetaNode } from '../../transport/node/builders/message.js';
@@ -23,7 +23,7 @@ export class WaMessageDispatchCoordinator {
23
23
  this.privacyTokenDedup = new PromiseDedup();
24
24
  this.distributionDedup = new PromiseDedup();
25
25
  this.deps = options;
26
- this.mobileMessageIdFormat = options.mobileMessageIdFormat ?? false;
26
+ this.mobileMessageIdFormat = options.mobileMessageIdFormat ?? (() => false);
27
27
  this.serverClock = options.serverClock;
28
28
  }
29
29
  async publishMessageNode(node, options = {}) {
@@ -654,7 +654,7 @@ export class WaMessageDispatchCoordinator {
654
654
  const serverAddressingMode = result.ack.addressingMode;
655
655
  const hasPhashMismatch = !!serverPhash && serverPhash !== localPhash;
656
656
  const hasAddressingMismatch = !!serverAddressingMode && serverAddressingMode !== addressingMode;
657
- const hasAddressingError = ackError === 421;
657
+ const hasAddressingError = ackError === WA_NACK_REASONS.STALE_GROUP_ADDRESSING_MODE;
658
658
  if (!retryContext.retried &&
659
659
  (hasPhashMismatch || hasAddressingMismatch || hasAddressingError)) {
660
660
  this.deps.logger.warn('group direct publish acknowledged with mismatch metadata', {
@@ -829,7 +829,7 @@ export class WaMessageDispatchCoordinator {
829
829
  const serverAddressingMode = result.ack.addressingMode;
830
830
  const hasPhashMismatch = !!serverPhash && serverPhash !== localPhash;
831
831
  const hasAddressingMismatch = !!serverAddressingMode && serverAddressingMode !== addressingMode;
832
- const hasAddressingError = ackError === 421;
832
+ const hasAddressingError = ackError === WA_NACK_REASONS.STALE_GROUP_ADDRESSING_MODE;
833
833
  if (!retryContext.retried &&
834
834
  (hasPhashMismatch || hasAddressingMismatch || hasAddressingError)) {
835
835
  this.deps.logger.warn('group message publish acknowledged with mismatch metadata', {
@@ -1191,7 +1191,7 @@ export class WaMessageDispatchCoordinator {
1191
1191
  const meUserJid = toUserJid(this.requireCurrentMeJid('sendMessage'));
1192
1192
  const timestampBytes = new Uint8Array(8);
1193
1193
  const dv = new DataView(timestampBytes.buffer, timestampBytes.byteOffset, timestampBytes.byteLength);
1194
- if (this.mobileMessageIdFormat) {
1194
+ if (this.mobileMessageIdFormat()) {
1195
1195
  dv.setBigUint64(0, BigInt(Date.now()), false);
1196
1196
  const digest = md5Bytes([
1197
1197
  timestampBytes,
@@ -1213,7 +1213,7 @@ export class WaMessageDispatchCoordinator {
1213
1213
  this.deps.logger.warn('failed to generate message id, falling back to random', {
1214
1214
  message: toError(error).message
1215
1215
  });
1216
- if (this.mobileMessageIdFormat) {
1216
+ if (this.mobileMessageIdFormat()) {
1217
1217
  const bytes = await randomBytesAsync(16);
1218
1218
  bytes[0] = 0xac;
1219
1219
  return bytesToHex(bytes).toUpperCase();
@@ -16,7 +16,7 @@ export class WaPassiveTasksCoordinator {
16
16
  this.signedPreKeyServerErrorBackoffMs =
17
17
  options.signedPreKeyServerErrorBackoffMs ?? SIGNAL_SIGNED_PREKEY_SERVER_ERROR_BACKOFF_MS;
18
18
  this.runtime = options.runtime;
19
- this.mobilePrimary = options.mobilePrimary ?? false;
19
+ this.mobilePrimary = options.mobilePrimary ?? (() => false);
20
20
  this.appStateSync = options.appStateSync;
21
21
  this.passiveTasksPromise = null;
22
22
  }
@@ -59,7 +59,7 @@ export class WaPassiveTasksCoordinator {
59
59
  return;
60
60
  }
61
61
  this.runtime.syncAbProps();
62
- if (this.mobilePrimary && this.appStateSync) {
62
+ if (this.mobilePrimary() && this.appStateSync) {
63
63
  await this.appStateSync.ensureInitialSyncKey().catch((error) => {
64
64
  this.logger.warn('app-state initial key generation failed', {
65
65
  message: toError(error).message
@@ -146,7 +146,7 @@ export class WaPassiveTasksCoordinator {
146
146
  const response = await this.runtime.queryWithContext('prekeys.upload', uploadNode, WA_DEFAULTS.IQ_TIMEOUT_MS, {
147
147
  count: preKeys.length,
148
148
  lastPreKeyId
149
- }, this.mobilePrimary ? { useSystemId: true } : undefined);
149
+ }, this.mobilePrimary() ? { useSystemId: true } : undefined);
150
150
  if (response.attrs.type === WA_IQ_TYPES.RESULT) {
151
151
  // Mark uploaded key first so the serverHasPreKeys flag never commits ahead of local key progress.
152
152
  await this.preKeyStore.markKeyAsUploaded(lastPreKeyId);
@@ -192,7 +192,7 @@ function buildTextStatusMutationInput(input) {
192
192
  }
193
193
  /** Builds a {@link WaProfileCoordinator} from its IQ/MEX/SID dependencies. */
194
194
  export function createProfileCoordinator(options) {
195
- const { queryWithContext, generateSid, mexSocket, queryLidsByPhoneJids, mutations, logger } = options;
195
+ const { queryWithContext, generateSid, mexSocket, queryLidsByPhoneJids, mutations, applyOwnPushName, logger } = options;
196
196
  return {
197
197
  getProfilePicture: async (jid, type, existingId) => {
198
198
  const node = buildGetProfilePictureIq(jid, type, existingId);
@@ -242,6 +242,9 @@ export function createProfileCoordinator(options) {
242
242
  assertIqResult(result, 'profile.setStatus');
243
243
  },
244
244
  setPushName: async (name) => {
245
+ // Local apply first: the app-state echo of this same write then
246
+ // collapses into a no-op via applyOwnPushName's idempotency guard.
247
+ await applyOwnPushName(name);
245
248
  await mutations.set({ schema: 'SettingPushName', name });
246
249
  },
247
250
  getProfiles: async (jids) => {