zapo-js 1.3.0 → 1.5.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 (113) hide show
  1. package/dist/appstate/types.d.ts +1 -2
  2. package/dist/auth/WaAuthClient.d.ts +25 -0
  3. package/dist/auth/WaAuthClient.js +63 -7
  4. package/dist/auth/credentials-flow.d.ts +8 -0
  5. package/dist/auth/credentials-flow.js +35 -2
  6. package/dist/auth/pairing/WaShortcakeFlow.d.ts +93 -0
  7. package/dist/auth/pairing/WaShortcakeFlow.js +210 -0
  8. package/dist/auth/pairing/companion-host.d.ts +105 -0
  9. package/dist/auth/pairing/companion-host.js +148 -0
  10. package/dist/auth/pairing/pairing-code-crypto.d.ts +1 -0
  11. package/dist/auth/pairing/pairing-code-crypto.js +3 -2
  12. package/dist/auth/pairing/shortcake-crypto.d.ts +59 -0
  13. package/dist/auth/pairing/shortcake-crypto.js +106 -0
  14. package/dist/auth/types.d.ts +20 -9
  15. package/dist/client/WaClient.d.ts +15 -5
  16. package/dist/client/WaClient.js +46 -12
  17. package/dist/client/WaClientFactory.d.ts +3 -0
  18. package/dist/client/WaClientFactory.js +27 -7
  19. package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
  20. package/dist/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  21. package/dist/client/coordinators/WaGroupCoordinator.js +6 -1
  22. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +3 -1
  23. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +13 -3
  24. package/dist/client/coordinators/WaMobileCoordinator.d.ts +192 -0
  25. package/dist/client/coordinators/WaMobileCoordinator.js +645 -0
  26. package/dist/client/coordinators/WaRetryCoordinator.js +1 -0
  27. package/dist/client/messaging/companion-host.d.ts +43 -0
  28. package/dist/client/messaging/companion-host.js +61 -0
  29. package/dist/client/messaging/key-protocol.js +25 -1
  30. package/dist/client/persistence/companion-host.d.ts +36 -0
  31. package/dist/client/persistence/companion-host.js +57 -0
  32. package/dist/client/plugins/install.d.ts +2 -1
  33. package/dist/client/plugins/install.js +44 -11
  34. package/dist/client/types.d.ts +90 -7
  35. package/dist/esm/auth/WaAuthClient.js +64 -8
  36. package/dist/esm/auth/credentials-flow.js +36 -3
  37. package/dist/esm/auth/pairing/WaShortcakeFlow.js +206 -0
  38. package/dist/esm/auth/pairing/companion-host.js +140 -0
  39. package/dist/esm/auth/pairing/pairing-code-crypto.js +2 -2
  40. package/dist/esm/auth/pairing/shortcake-crypto.js +99 -0
  41. package/dist/esm/client/WaClient.js +46 -12
  42. package/dist/esm/client/WaClientFactory.js +28 -8
  43. package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +37 -1
  44. package/dist/esm/client/coordinators/WaGroupCoordinator.js +6 -1
  45. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +14 -4
  46. package/dist/esm/client/coordinators/WaMobileCoordinator.js +641 -0
  47. package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -0
  48. package/dist/esm/client/messaging/companion-host.js +56 -0
  49. package/dist/esm/client/messaging/key-protocol.js +25 -1
  50. package/dist/esm/client/persistence/companion-host.js +54 -0
  51. package/dist/esm/client/plugins/install.js +44 -11
  52. package/dist/esm/index.js +5 -2
  53. package/dist/esm/message/WaMessageClient.js +3 -2
  54. package/dist/esm/message/context-info.js +4 -0
  55. package/dist/esm/protocol/constants.js +4 -3
  56. package/dist/esm/protocol/device-identity.js +18 -0
  57. package/dist/esm/protocol/group.js +6 -0
  58. package/dist/esm/protocol/message.js +12 -0
  59. package/dist/esm/protocol/nodes.js +17 -1
  60. package/dist/esm/protocol/notification.js +3 -1
  61. package/dist/esm/retry/replay.js +7 -15
  62. package/dist/esm/signal/attestation/WaAdvSignature.js +67 -2
  63. package/dist/esm/signal/attestation/constants.js +2 -0
  64. package/dist/esm/signal/index.js +1 -0
  65. package/dist/esm/transport/index.js +1 -1
  66. package/dist/esm/transport/node/builders/group.js +4 -3
  67. package/dist/esm/transport/node/builders/mobile.js +113 -0
  68. package/dist/esm/transport/node/builders/shortcake.js +50 -0
  69. package/dist/esm/transport/noise/WaClientPayload.js +17 -8
  70. package/dist/esm/transport/wa-version-fetcher.js +152 -0
  71. package/dist/esm/util/index.js +1 -0
  72. package/dist/index.d.ts +11 -4
  73. package/dist/index.js +13 -4
  74. package/dist/message/WaMessageClient.js +2 -1
  75. package/dist/message/context-info.d.ts +2 -0
  76. package/dist/message/context-info.js +5 -0
  77. package/dist/protocol/constants.d.ts +6 -4
  78. package/dist/protocol/constants.js +8 -2
  79. package/dist/protocol/device-identity.d.ts +25 -0
  80. package/dist/protocol/device-identity.js +21 -0
  81. package/dist/protocol/group.d.ts +6 -0
  82. package/dist/protocol/group.js +7 -1
  83. package/dist/protocol/message.d.ts +14 -0
  84. package/dist/protocol/message.js +13 -1
  85. package/dist/protocol/nodes.d.ts +16 -0
  86. package/dist/protocol/nodes.js +17 -1
  87. package/dist/protocol/notification.d.ts +2 -0
  88. package/dist/protocol/notification.js +3 -1
  89. package/dist/retry/replay.d.ts +1 -0
  90. package/dist/retry/replay.js +6 -14
  91. package/dist/signal/attestation/WaAdvSignature.d.ts +50 -1
  92. package/dist/signal/attestation/WaAdvSignature.js +71 -1
  93. package/dist/signal/attestation/constants.d.ts +2 -0
  94. package/dist/signal/attestation/constants.js +3 -1
  95. package/dist/signal/index.d.ts +1 -0
  96. package/dist/signal/index.js +13 -1
  97. package/dist/transport/index.d.ts +2 -2
  98. package/dist/transport/index.js +4 -3
  99. package/dist/transport/node/builders/group.js +4 -3
  100. package/dist/transport/node/builders/mobile.d.ts +68 -0
  101. package/dist/transport/node/builders/mobile.js +120 -0
  102. package/dist/transport/node/builders/shortcake.d.ts +19 -0
  103. package/dist/transport/node/builders/shortcake.js +57 -0
  104. package/dist/transport/noise/WaClientPayload.d.ts +2 -0
  105. package/dist/transport/noise/WaClientPayload.js +17 -7
  106. package/dist/transport/wa-version-fetcher.d.ts +92 -0
  107. package/dist/transport/wa-version-fetcher.js +156 -0
  108. package/dist/util/index.d.ts +1 -0
  109. package/dist/util/index.js +3 -1
  110. package/package.json +1 -1
  111. package/dist/esm/transport/wa-web-version-fetcher.js +0 -91
  112. package/dist/transport/wa-web-version-fetcher.d.ts +0 -44
  113. package/dist/transport/wa-web-version-fetcher.js +0 -94
@@ -0,0 +1,43 @@
1
+ import { type Proto } from '../../proto';
2
+ /** A PN→LID mapping carried inside a history sync (one per chat peer with a LID). */
3
+ export interface PhoneNumberToLidMapping {
4
+ readonly pnJid: string;
5
+ readonly lidJid: string;
6
+ }
7
+ export interface HistorySyncBootstrapInput {
8
+ /**
9
+ * Chat-peer PN→LID pairs (the primary's own account excluded, only peers
10
+ * resolving to a LID). A fresh account with no chats yields an empty list.
11
+ */
12
+ readonly phoneNumberToLidMappings?: readonly PhoneNumberToLidMapping[];
13
+ readonly conversations?: readonly Proto.IConversation[];
14
+ readonly pushnames?: readonly Proto.IPushname[];
15
+ /** The companion pairing session id, echoed so the companion can bind it. */
16
+ readonly companionMetaNonce?: string;
17
+ }
18
+ /**
19
+ * Serializes a `HistorySync` and zlib-deflates it. The phone uses DEFLATE
20
+ * level 1; any zlib-framed level inflates identically, so it is only a
21
+ * size/speed tradeoff.
22
+ */
23
+ export declare function encodeHistorySync(content: Proto.IHistorySync): Promise<Uint8Array>;
24
+ /**
25
+ * Wraps a deflated `HistorySync` inline in a `HISTORY_SYNC_NOTIFICATION` protocol
26
+ * message. Only small syncs are inlined; larger ones ride an external
27
+ * `md-msg-hist` MMS blob referenced by the same notification.
28
+ */
29
+ export declare function buildInlineHistorySyncNotification(syncType: Proto.Message.HistorySyncType, deflatedPayload: Uint8Array, options?: {
30
+ readonly chunkOrder?: number;
31
+ readonly progress?: number;
32
+ }): Proto.Message.IProtocolMessage;
33
+ /**
34
+ * Builds the `INITIAL_BOOTSTRAP` `HISTORY_SYNC_NOTIFICATION` a mobile-primary
35
+ * pushes to a freshly-linked companion: a deflated `HistorySync` wrapped in a
36
+ * protocol message for delivery as an encrypted peer message. Note
37
+ * `HistorySync.syncType` and `HistorySyncNotification.syncType` are distinct
38
+ * enum types with shared values, so each field uses its own.
39
+ */
40
+ export declare function buildHistorySyncBootstrapMessage(input?: HistorySyncBootstrapInput): Promise<{
41
+ readonly message: Proto.Message.IProtocolMessage;
42
+ readonly payloadBytes: number;
43
+ }>;
@@ -0,0 +1,61 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.encodeHistorySync = encodeHistorySync;
4
+ exports.buildInlineHistorySyncNotification = buildInlineHistorySyncNotification;
5
+ exports.buildHistorySyncBootstrapMessage = buildHistorySyncBootstrapMessage;
6
+ const node_util_1 = require("node:util");
7
+ const node_zlib_1 = require("node:zlib");
8
+ const _proto_1 = require("../../proto");
9
+ const deflateAsync = (0, node_util_1.promisify)(node_zlib_1.deflate);
10
+ /**
11
+ * Serializes a `HistorySync` and zlib-deflates it. The phone uses DEFLATE
12
+ * level 1; any zlib-framed level inflates identically, so it is only a
13
+ * size/speed tradeoff.
14
+ */
15
+ async function encodeHistorySync(content) {
16
+ const serialized = _proto_1.proto.HistorySync.encode(content).finish();
17
+ const compressed = await deflateAsync(serialized, { level: 1 });
18
+ return new Uint8Array(compressed);
19
+ }
20
+ /**
21
+ * Wraps a deflated `HistorySync` inline in a `HISTORY_SYNC_NOTIFICATION` protocol
22
+ * message. Only small syncs are inlined; larger ones ride an external
23
+ * `md-msg-hist` MMS blob referenced by the same notification.
24
+ */
25
+ function buildInlineHistorySyncNotification(syncType, deflatedPayload, options = {}) {
26
+ return {
27
+ type: _proto_1.proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION,
28
+ historySyncNotification: {
29
+ syncType,
30
+ chunkOrder: options.chunkOrder ?? 0,
31
+ progress: options.progress ?? 100,
32
+ initialHistBootstrapInlinePayload: deflatedPayload
33
+ }
34
+ };
35
+ }
36
+ /**
37
+ * Builds the `INITIAL_BOOTSTRAP` `HISTORY_SYNC_NOTIFICATION` a mobile-primary
38
+ * pushes to a freshly-linked companion: a deflated `HistorySync` wrapped in a
39
+ * protocol message for delivery as an encrypted peer message. Note
40
+ * `HistorySync.syncType` and `HistorySyncNotification.syncType` are distinct
41
+ * enum types with shared values, so each field uses its own.
42
+ */
43
+ async function buildHistorySyncBootstrapMessage(input = {}) {
44
+ const historySync = {
45
+ syncType: _proto_1.proto.HistorySync.HistorySyncType.INITIAL_BOOTSTRAP,
46
+ chunkOrder: 0,
47
+ progress: 100,
48
+ conversations: input.conversations ? [...input.conversations] : [],
49
+ pushnames: input.pushnames ? [...input.pushnames] : [],
50
+ phoneNumberToLidMappings: (input.phoneNumberToLidMappings ?? []).map((mapping) => ({
51
+ pnJid: mapping.pnJid,
52
+ lidJid: mapping.lidJid
53
+ })),
54
+ ...(input.companionMetaNonce !== undefined
55
+ ? { companionMetaNonce: input.companionMetaNonce }
56
+ : {})
57
+ };
58
+ const payload = await encodeHistorySync(historySync);
59
+ const message = buildInlineHistorySyncNotification(_proto_1.proto.Message.HistorySyncType.INITIAL_BOOTSTRAP, payload);
60
+ return { message, payloadBytes: payload.byteLength };
61
+ }
@@ -4,6 +4,15 @@ exports.createAppStateSyncKeyProtocol = createAppStateSyncKeyProtocol;
4
4
  const _proto_1 = require("../../proto");
5
5
  const jid_1 = require("../../protocol/jid");
6
6
  const bytes_1 = require("../../util/bytes");
7
+ /**
8
+ * Valid app-state sync key ids are exactly 6 bytes: a 2-byte device id followed
9
+ * by a 4-byte big-endian epoch (matching the phone primary's `crypto_info`
10
+ * (device_id, epoch) schema and WhatsApp Web's `handleAppStateSyncKeyShare`,
11
+ * which fatally rejects any share whose keyId byte length is not 6). A primary
12
+ * must never emit a malformed key, so legacy/short key ids are dropped from
13
+ * outgoing shares rather than poisoning the peer's syncd engine.
14
+ */
15
+ const APP_STATE_SYNC_KEY_ID_LENGTH = 6;
7
16
  function createAppStateSyncKeyProtocol(options) {
8
17
  const { publishProtocolMessageToDevice, fanoutResolver, getCurrentCredentials, logger } = options;
9
18
  const requireCurrentIdentity = (context) => {
@@ -67,7 +76,12 @@ function createAppStateSyncKeyProtocol(options) {
67
76
  const seenKeyIds = new Set();
68
77
  const keyShareEntries = [];
69
78
  let sharedKeyCount = 0;
79
+ const droppedKeyIds = [];
70
80
  for (const key of keys) {
81
+ if (key.keyId.byteLength !== APP_STATE_SYNC_KEY_ID_LENGTH) {
82
+ droppedKeyIds.push((0, bytes_1.bytesToHex)(key.keyId));
83
+ continue;
84
+ }
71
85
  const keyHex = (0, bytes_1.bytesToHex)(key.keyId);
72
86
  if (seenKeyIds.has(keyHex)) {
73
87
  continue;
@@ -84,7 +98,10 @@ function createAppStateSyncKeyProtocol(options) {
84
98
  });
85
99
  }
86
100
  for (const keyId of missingKeyIds) {
87
- if (keyId.byteLength === 0) {
101
+ if (keyId.byteLength !== APP_STATE_SYNC_KEY_ID_LENGTH) {
102
+ if (keyId.byteLength > 0) {
103
+ droppedKeyIds.push((0, bytes_1.bytesToHex)(keyId));
104
+ }
88
105
  continue;
89
106
  }
90
107
  const keyHex = (0, bytes_1.bytesToHex)(keyId);
@@ -96,6 +113,13 @@ function createAppStateSyncKeyProtocol(options) {
96
113
  keyId: { keyId }
97
114
  });
98
115
  }
116
+ if (droppedKeyIds.length > 0) {
117
+ logger.warn('app-state refusing to share malformed sync keys: keyId byte length must be 6', {
118
+ droppedCount: droppedKeyIds.length,
119
+ totalExpected: keys.length + missingKeyIds.length,
120
+ sample: droppedKeyIds.slice(0, 5)
121
+ });
122
+ }
99
123
  const protocolMessage = {
100
124
  type: _proto_1.proto.Message.ProtocolMessage.Type.APP_STATE_SYNC_KEY_SHARE,
101
125
  appStateSyncKeyShare: {
@@ -0,0 +1,36 @@
1
+ /** A companion device this primary has linked. */
2
+ export interface CompanionRecord {
3
+ readonly deviceJid: string;
4
+ readonly keyIndex: number;
5
+ readonly companionIdentityPublicKey: Uint8Array;
6
+ readonly addedAtSeconds: number;
7
+ }
8
+ /**
9
+ * The primary's ADV epoch state. `rawId` is the account's stable ADV identity
10
+ * id; `currentKeyIndex` is the last-issued companion key index (companions get
11
+ * `currentKeyIndex + 1`). This MUST persist across restarts, otherwise reissuing
12
+ * an already-used key index breaks previously linked devices.
13
+ */
14
+ export interface CompanionHostEpochState {
15
+ readonly rawId: number;
16
+ readonly currentKeyIndex: number;
17
+ readonly companions: readonly CompanionRecord[];
18
+ }
19
+ /**
20
+ * Persistence hook for {@link CompanionHostEpochState}. Supply one via plugin
21
+ * options to survive restarts; without it the epoch is in-memory only (fine for
22
+ * a single-session smoke test, unsafe for production relinking).
23
+ */
24
+ export interface CompanionHostPersistence {
25
+ readonly load: () => Promise<CompanionHostEpochState | null> | CompanionHostEpochState | null;
26
+ readonly save: (state: CompanionHostEpochState) => Promise<void> | void;
27
+ }
28
+ /**
29
+ * File-backed {@link CompanionHostPersistence}: stores the ADV epoch as JSON at
30
+ * `filePath` (the companion identity keys are base64-encoded). Zero native
31
+ * dependencies - the state is tiny (the epoch header plus the linked-companion
32
+ * list). For a database-backed store, implement the two-method
33
+ * `CompanionHostPersistence` contract directly against your DB; there is no need
34
+ * for a dedicated core store domain.
35
+ */
36
+ export declare function createFileCompanionHostPersistence(filePath: string): CompanionHostPersistence;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createFileCompanionHostPersistence = createFileCompanionHostPersistence;
4
+ const promises_1 = require("node:fs/promises");
5
+ const node_path_1 = require("node:path");
6
+ const bytes_1 = require("../../util/bytes");
7
+ /**
8
+ * File-backed {@link CompanionHostPersistence}: stores the ADV epoch as JSON at
9
+ * `filePath` (the companion identity keys are base64-encoded). Zero native
10
+ * dependencies - the state is tiny (the epoch header plus the linked-companion
11
+ * list). For a database-backed store, implement the two-method
12
+ * `CompanionHostPersistence` contract directly against your DB; there is no need
13
+ * for a dedicated core store domain.
14
+ */
15
+ function createFileCompanionHostPersistence(filePath) {
16
+ return {
17
+ async load() {
18
+ let raw;
19
+ try {
20
+ raw = await (0, promises_1.readFile)(filePath, 'utf8');
21
+ }
22
+ catch (error) {
23
+ if (error.code === 'ENOENT') {
24
+ return null;
25
+ }
26
+ throw error;
27
+ }
28
+ const parsed = JSON.parse(raw);
29
+ return {
30
+ rawId: parsed.rawId,
31
+ currentKeyIndex: parsed.currentKeyIndex,
32
+ companions: parsed.companions.map((companion) => ({
33
+ deviceJid: companion.deviceJid,
34
+ keyIndex: companion.keyIndex,
35
+ companionIdentityPublicKey: (0, bytes_1.base64ToBytes)(companion.companionIdentityPublicKey),
36
+ addedAtSeconds: companion.addedAtSeconds
37
+ }))
38
+ };
39
+ },
40
+ async save(state) {
41
+ const serialized = {
42
+ rawId: state.rawId,
43
+ currentKeyIndex: state.currentKeyIndex,
44
+ companions: state.companions.map((companion) => ({
45
+ deviceJid: companion.deviceJid,
46
+ keyIndex: companion.keyIndex,
47
+ companionIdentityPublicKey: (0, bytes_1.bytesToBase64)(companion.companionIdentityPublicKey),
48
+ addedAtSeconds: companion.addedAtSeconds
49
+ }))
50
+ };
51
+ await (0, promises_1.mkdir)((0, node_path_1.dirname)(filePath), { recursive: true });
52
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
53
+ await (0, promises_1.writeFile)(tempPath, JSON.stringify(serialized), 'utf8');
54
+ await (0, promises_1.rename)(tempPath, filePath);
55
+ }
56
+ };
57
+ }
@@ -13,6 +13,7 @@ export interface WaClientPluginInstallInput {
13
13
  }
14
14
  /**
15
15
  * Installs {@link WaClientOptions.plugins} on `client`. Returns a dispose
16
- * function invoked by {@link WaClient.disconnect}.
16
+ * function invoked by {@link WaClient.disconnect}. Safe to call again after a
17
+ * dispose to reinstall the same plugins on reconnect.
17
18
  */
18
19
  export declare function installWaClientPlugins(client: WaClient, input: WaClientPluginInstallInput, plugins: readonly WaClientPluginDefinition[]): () => Promise<void>;
@@ -3,11 +3,36 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.installWaClientPlugins = installWaClientPlugins;
4
4
  const types_1 = require("../plugins/types");
5
5
  const primitives_1 = require("../../util/primitives");
6
+ const PLUGIN_REGISTRY = Symbol('waClientPluginRegistry');
7
+ /**
8
+ * Per-client store for exposed-plugin state that must survive a
9
+ * disconnect/connect cycle. Each `exposeAs` property is defined on the client
10
+ * exactly once (a non-configurable getter reading `instances`); reinstalls
11
+ * only repopulate the slot, so plugins can be torn down on disconnect and
12
+ * rebuilt on the next connect without redefining reserved members.
13
+ */
14
+ function getPluginRegistry(client) {
15
+ const holder = client;
16
+ const existing = holder[PLUGIN_REGISTRY];
17
+ if (existing) {
18
+ return existing;
19
+ }
20
+ const registry = { instances: new Map(), exposedDefined: new Set() };
21
+ Object.defineProperty(client, PLUGIN_REGISTRY, {
22
+ value: registry,
23
+ enumerable: false,
24
+ configurable: false,
25
+ writable: false
26
+ });
27
+ return registry;
28
+ }
6
29
  /**
7
30
  * Installs {@link WaClientOptions.plugins} on `client`. Returns a dispose
8
- * function invoked by {@link WaClient.disconnect}.
31
+ * function invoked by {@link WaClient.disconnect}. Safe to call again after a
32
+ * dispose to reinstall the same plugins on reconnect.
9
33
  */
10
34
  function installWaClientPlugins(client, input, plugins) {
35
+ const registry = getPluginRegistry(client);
11
36
  const seenIds = new Set();
12
37
  const seenExposeAs = new Set();
13
38
  const disposeCallbacks = [];
@@ -40,24 +65,32 @@ function installWaClientPlugins(client, input, plugins) {
40
65
  logger: input.logger.child({ plugin: plugin.id })
41
66
  };
42
67
  if ((0, types_1.isWaClientExposePluginDefinition)(plugin)) {
43
- if (seenExposeAs.has(plugin.exposeAs)) {
44
- throw new Error(`duplicate wa client plugin exposeAs: ${plugin.exposeAs}`);
68
+ const exposeAs = plugin.exposeAs;
69
+ if (seenExposeAs.has(exposeAs)) {
70
+ throw new Error(`duplicate wa client plugin exposeAs: ${exposeAs}`);
45
71
  }
46
- seenExposeAs.add(plugin.exposeAs);
47
- if (plugin.exposeAs in client) {
48
- throw new Error(`wa client plugin exposeAs "${plugin.exposeAs}" collides with a reserved client member`);
72
+ seenExposeAs.add(exposeAs);
73
+ if (!registry.exposedDefined.has(exposeAs)) {
74
+ if (exposeAs in client) {
75
+ throw new Error(`wa client plugin exposeAs "${exposeAs}" collides with a reserved client member`);
76
+ }
77
+ registry.exposedDefined.add(exposeAs);
78
+ Object.defineProperty(client, exposeAs, {
79
+ get: () => registry.instances.get(exposeAs),
80
+ enumerable: true,
81
+ configurable: false
82
+ });
49
83
  }
50
84
  const instance = plugin.setup(pluginCtx);
51
- Object.defineProperty(client, plugin.exposeAs, {
52
- get: () => instance,
53
- enumerable: true,
54
- configurable: false
85
+ registry.instances.set(exposeAs, instance);
86
+ registerDispose(() => {
87
+ registry.instances.delete(exposeAs);
55
88
  });
56
89
  if (plugin.dispose) {
57
90
  const dispose = plugin.dispose;
58
91
  registerDispose(() => dispose(instance, pluginCtx));
59
92
  }
60
- pluginCtx.logger.debug('wa client plugin installed', { exposeAs: plugin.exposeAs });
93
+ pluginCtx.logger.debug('wa client plugin installed', { exposeAs });
61
94
  }
62
95
  else {
63
96
  plugin.setup(pluginCtx);
@@ -1,8 +1,10 @@
1
1
  import type { AppStateCollectionName } from '../appstate/types';
2
2
  import type { DataForKey, WaAppstateActionKey, WaAppstateIndexArgs } from '../appstate-spec';
3
+ import type { WaShortcakeAssertionSigner } from '../auth/pairing/WaShortcakeFlow';
3
4
  import type { WaAuthClientOptions, WaAuthCredentials, WaAuthDangerousOptions, WaAuthSocketOptions } from '../auth/types';
4
5
  import type { WaCallGroupParticipant, WaCallType } from './events/call';
5
6
  import type { IncomingPresenceType, PresenceLastSeen } from './events/presence';
7
+ import type { CompanionHostPersistence } from './persistence/companion-host';
6
8
  import type { WaClientPluginDefinition } from './plugins/types';
7
9
  import type { WaMediaProcessor } from '../media/processor';
8
10
  import type { WaLinkPreviewOptions } from '../message/addons/link-preview/types';
@@ -82,6 +84,16 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
82
84
  * tests or pinning a specific edge.
83
85
  */
84
86
  readonly chatSocketUrls?: readonly string[];
87
+ /**
88
+ * Companion-hosting config for a mobile-primary session (see
89
+ * {@link WaClient.mobile}). `persistence` survives the ADV epoch across
90
+ * restarts (without it, relinking breaks previously linked devices);
91
+ * `includePem` adds the experimental `<pem>` node to the pair-device upload.
92
+ */
93
+ readonly companionHost?: {
94
+ readonly persistence?: CompanionHostPersistence;
95
+ readonly includePem?: boolean;
96
+ };
85
97
  /** Default timeout (ms) for IQ queries. Defaults to `WA_DEFAULTS.IQ_TIMEOUT_MS` (60s). */
86
98
  readonly iqTimeoutMs?: number;
87
99
  /** Default timeout (ms) for raw node `query()` calls when none is passed. */
@@ -118,8 +130,11 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
118
130
  * Automatically reconnect when the server rejects the noise handshake
119
131
  * with HTTP 405 / `failure_client_too_old`. On every 405 the client
120
132
  * logs a warning asking you to upgrade zapo, fetches the current
121
- * version from `web.whatsapp.com` via `fetchLatestWaWebVersion()`,
122
- * swaps it in for the next connect, and retries. Off by default.
133
+ * version, swaps it in for the next connect, and retries. Off by
134
+ * default. Web sessions fetch the WA Web version via
135
+ * `fetchLatestWaWebVersion()` (swapped in as the connect `version`);
136
+ * mobile sessions fetch the Android app version via
137
+ * `fetchLatestWaMobileVersion()` (swapped in as `deviceInfo.appVersion`).
123
138
  */
124
139
  readonly recoverFromClientTooOld?: boolean;
125
140
  /**
@@ -140,6 +155,7 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
140
155
  * Chat-event emission tuning. `emitSnapshotMutations: true` re-emits
141
156
  * `app_state_mutation` events for every mutation seen during a snapshot
142
157
  * sync (off by default – those mutations represent historical state).
158
+ * This client's own outbound app-state actions surface on `mutation_send`.
143
159
  */
144
160
  readonly chatEvents?: {
145
161
  readonly emitSnapshotMutations?: boolean;
@@ -193,6 +209,14 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
193
209
  * disables a security check the production code path enforces.
194
210
  */
195
211
  readonly dangerous?: WaClientDangerousOptions;
212
+ /**
213
+ * External WebAuthn signer. Configuring it enables handling a server-forced
214
+ * passkey ("Shortcake") prologue – e.g. when the server demands a passkey
215
+ * right after a successful pairing-code `companion_finish`. Called with the
216
+ * server's request options; returns the assertion + credential id. The
217
+ * credential source (real/virtual authenticator) stays outside the library.
218
+ */
219
+ readonly signPasskeyAssertion?: WaShortcakeAssertionSigner;
196
220
  }
197
221
  export interface WaClientDangerousOptions extends WaAuthDangerousOptions {
198
222
  /**
@@ -531,6 +555,22 @@ export interface WaIncomingMessageEvent extends Omit<WaIncomingBaseEvent, 'chatJ
531
555
  readonly pushName?: string;
532
556
  readonly message?: Proto.IMessage;
533
557
  }
558
+ /**
559
+ * An outbound message this client sent, surfaced with its decrypted
560
+ * {@link Proto.IMessage} – the symmetric counterpart to
561
+ * {@link WaIncomingMessageEvent}. Fires from {@link WaMessageCoordinator.send}
562
+ * as the stanza is built, so plugins/loggers can observe outgoing content
563
+ * (forwards, reactions, polls, media) that the encrypted `<message>` on the
564
+ * wire does not reveal.
565
+ */
566
+ export interface WaOutgoingMessageEvent {
567
+ /** Destination jid (chat / group / status). */
568
+ readonly to: string;
569
+ /** Outgoing stanza id, when assigned. */
570
+ readonly id?: string;
571
+ /** The decrypted message proto being sent. */
572
+ readonly message: Proto.IMessage;
573
+ }
534
574
  export interface WaIncomingProtocolMessageEvent extends WaIncomingMessageEvent {
535
575
  readonly protocolMessage: Proto.Message.IProtocolMessage;
536
576
  }
@@ -992,7 +1032,7 @@ export interface WaHistorySyncChunkEvent {
992
1032
  /** Server-reported progress, 0–100. */
993
1033
  readonly progress?: number;
994
1034
  }
995
- export type WaAppStateMutationSource = 'snapshot' | 'patch';
1035
+ export type WaAppStateMutationSource = 'snapshot' | 'patch' | 'local';
996
1036
  type MutationEventBase = {
997
1037
  readonly source: WaAppStateMutationSource;
998
1038
  readonly collection: AppStateCollectionName;
@@ -1013,6 +1053,12 @@ export type WaAppStateMutationEvent = {
1013
1053
  readonly operation: 'remove';
1014
1054
  } & MutationEventBase & WaAppstateIndexArgs<K>);
1015
1055
  }[WaAppstateActionKey];
1056
+ /**
1057
+ * Listener shared by the inbound `mutation` and outbound `mutation_send`
1058
+ * events. A single named alias so the (large) `WaAppStateMutationEvent` union
1059
+ * is referenced once by the event map rather than expanded per event.
1060
+ */
1061
+ type WaAppStateMutationListener = (event: WaAppStateMutationEvent) => void;
1016
1062
  export type WaConnectionEvent = {
1017
1063
  readonly status: 'open';
1018
1064
  readonly reason: WaConnectionOpenReason;
@@ -1071,6 +1117,16 @@ export interface WaClientEventMap {
1071
1117
  readonly auth_paired: (event: {
1072
1118
  readonly credentials: WaAuthCredentials;
1073
1119
  }) => void;
1120
+ /**
1121
+ * The server is forcing a passkey (Shortcake) to link this device – emitted
1122
+ * when the server pushes the prologue request. `hasSigner` is `true` when a
1123
+ * `signPasskeyAssertion` is configured (the handshake proceeds) and `false`
1124
+ * when none is set (the request is acked but the link cannot complete
1125
+ * headless, so the user should be told a passkey is required).
1126
+ */
1127
+ readonly auth_passkey_required: (event: {
1128
+ readonly hasSigner: boolean;
1129
+ }) => void;
1074
1130
  /**
1075
1131
  * Connection-state transitions: `'open'` (handshake + auth complete),
1076
1132
  * `'connecting'`, or `'close'` (with a `reason` and optional `code`). The
@@ -1084,6 +1140,13 @@ export interface WaClientEventMap {
1084
1140
  * `options.quote` for threads).
1085
1141
  */
1086
1142
  readonly message: (event: WaIncomingMessageEvent) => void;
1143
+ /**
1144
+ * An outbound message this client is sending, with its decrypted
1145
+ * {@link Proto.IMessage} – the symmetric counterpart to `message`. Lets
1146
+ * plugins/loggers observe outgoing content (forwards, reactions, polls,
1147
+ * media) the encrypted wire stanza hides.
1148
+ */
1149
+ readonly message_send: (event: WaOutgoingMessageEvent) => void;
1087
1150
  /**
1088
1151
  * A decrypted addon (poll vote, reaction, edit, comment, ...) attached to
1089
1152
  * a previous message. Fires unless `addons.autoDecrypt` is explicitly
@@ -1143,11 +1206,20 @@ export interface WaClientEventMap {
1143
1206
  /** Profile/group/community picture change notification – the new picture must still be fetched explicitly. */
1144
1207
  readonly picture: (event: WaPictureEvent) => void;
1145
1208
  /**
1146
- * A parsed app-state mutation crossed the sync boundary – chat mute/star/
1147
- * read/pin/archive/contact/label/etc. Use the discriminator
1148
- * (`event.action`) to branch on the mutation kind.
1209
+ * A parsed app-state mutation arriving from a sync – chat mute/star/read/
1210
+ * pin/archive/contact/label/etc. changed on another device. Inbound only;
1211
+ * this client's own outbound actions surface on `mutation_send`. Use the
1212
+ * discriminator (`event.schema`) to branch on the mutation kind.
1213
+ */
1214
+ readonly mutation: WaAppStateMutationListener;
1215
+ /**
1216
+ * An app-state action this client is sending (mute/star/read/pin/archive/
1217
+ * clear/delete/…) surfaced at action time – the outbound counterpart to
1218
+ * `mutation`, symmetric to how `message_send` pairs with `message`.
1219
+ * `event.source` is always `'local'`. Optimistic: emitted as the mutation
1220
+ * is enqueued, before the server flush confirms it.
1149
1221
  */
1150
- readonly mutation: (event: WaAppStateMutationEvent) => void;
1222
+ readonly mutation_send: WaAppStateMutationListener;
1151
1223
  /**
1152
1224
  * One chunk of history-sync data, fired both during the initial
1153
1225
  * bootstrap that the primary device pushes after pairing and for any
@@ -1185,6 +1257,17 @@ export interface WaClientEventMap {
1185
1257
  * `mobileTransport`.
1186
1258
  */
1187
1259
  readonly mobile_account_takeover_notice: (event: WaAccountTakeoverNoticeEvent) => void;
1260
+ /** A companion device was linked from this mobile-primary account. */
1261
+ readonly companion_host_linked: (result: {
1262
+ readonly deviceJid: string;
1263
+ readonly keyIndex: number;
1264
+ }) => void;
1265
+ /** A hosted companion device was revoked / unlinked. */
1266
+ readonly companion_host_revoked: (payload: {
1267
+ readonly deviceJid: string;
1268
+ }) => void;
1269
+ /** A companion-host link or provisioning operation failed. */
1270
+ readonly companion_host_error: (error: Error) => void;
1188
1271
  /** **debug** – the success node closing the noise handshake; emitted before `connection: { status: 'open' }`. */
1189
1272
  readonly debug_connection_success: (event: {
1190
1273
  readonly node: BinaryNode;
@@ -1,10 +1,12 @@
1
1
  import { buildCommsConfig, loadOrCreateCredentials, persistCredentials } from './credentials-flow.js';
2
2
  import { WaPairingFlow } from './pairing/WaPairingFlow.js';
3
3
  import { WaQrFlow } from './pairing/WaQrFlow.js';
4
- import { getWaCompanionPlatformId, WA_DEFAULTS } from '../protocol/constants.js';
4
+ import { WaShortcakeFlow } from './pairing/WaShortcakeFlow.js';
5
+ import { resolveWaDeviceIdentity, WA_NOTIFICATION_TYPES } from '../protocol/constants.js';
6
+ import { buildAckNode } from '../transport/node/builders/global.js';
7
+ import { resolveDevicePropsPlatformType } from '../transport/noise/WaClientPayload.js';
5
8
  import { uint8Equal } from '../util/bytes.js';
6
9
  import { toError } from '../util/primitives.js';
7
- import { getRuntimeOsDisplayName } from '../util/runtime.js';
8
10
  /**
9
11
  * Owns the auth/pairing lifecycle and persistence of {@link WaAuthCredentials}.
10
12
  * Exposed as `client.auth` on a {@link WaClient}.
@@ -17,12 +19,8 @@ import { getRuntimeOsDisplayName } from '../util/runtime.js';
17
19
  export class WaAuthClient {
18
20
  constructor(options, deps) {
19
21
  this.versionOverride = null;
20
- const deviceBrowser = options.deviceBrowser ?? WA_DEFAULTS.DEVICE_BROWSER;
21
- const device = Object.freeze({
22
- browser: deviceBrowser,
23
- osDisplayName: options.deviceOsDisplayName ?? getRuntimeOsDisplayName(),
24
- platform: options.devicePlatform ?? getWaCompanionPlatformId(deviceBrowser)
25
- });
22
+ this.mobileAppVersionOverride = null;
23
+ const device = resolveWaDeviceIdentity(options);
26
24
  this.options = Object.freeze({
27
25
  ...options,
28
26
  deviceBrowser: device.browser,
@@ -59,6 +57,23 @@ export class WaAuthClient {
59
57
  },
60
58
  dangerous: options.dangerous
61
59
  });
60
+ this.socket = deps.socket;
61
+ const signAssertion = deps.callbacks?.signPasskeyAssertion;
62
+ this.shortcakeFlow = signAssertion
63
+ ? new WaShortcakeFlow({
64
+ logger: this.logger,
65
+ socket: {
66
+ sendNode: deps.socket.sendNode,
67
+ query: (node, timeoutMs) => deps.socket.query(node, timeoutMs)
68
+ },
69
+ signAssertion,
70
+ auth: {
71
+ getCredentials: () => this.credentials,
72
+ updateCredentials: this.updateCredentials.bind(this)
73
+ },
74
+ deviceType: resolveDevicePropsPlatformType(device.browser)
75
+ })
76
+ : null;
62
77
  }
63
78
  /**
64
79
  * Returns a snapshot of auth readiness flags (connection, registration,
@@ -104,12 +119,15 @@ export class WaAuthClient {
104
119
  this.logger.trace('auth client building comms config');
105
120
  const override = this.versionOverride;
106
121
  this.versionOverride = null;
122
+ const mobileAppVersionOverride = this.mobileAppVersionOverride;
123
+ this.mobileAppVersionOverride = null;
107
124
  return buildCommsConfig(this.logger, this.requireCredentials(), socketOptions, {
108
125
  deviceBrowser: this.options.deviceBrowser,
109
126
  deviceOsDisplayName: this.options.deviceOsDisplayName,
110
127
  requireFullSync: this.options.requireFullSync,
111
128
  version: override ?? this.options.version,
112
129
  mobileTransport: this.options.mobileTransport,
130
+ mobileAppVersionOverride: mobileAppVersionOverride ?? undefined,
113
131
  noiseTrustedRootCa: overrides.noiseTrustedRootCa,
114
132
  disableNoiseCertificateChainVerification: overrides.disableNoiseCertificateChainVerification ??
115
133
  this.options.dangerous?.disableNoiseCertificateChainVerification
@@ -125,12 +143,24 @@ export class WaAuthClient {
125
143
  setNextConnectVersion(version) {
126
144
  this.versionOverride = version;
127
145
  }
146
+ /**
147
+ * One-shot override for the mobile `deviceInfo.appVersion` on the next
148
+ * {@link buildCommsConfig} call, then clears. The mobile transport reads
149
+ * its version from `deviceInfo.appVersion` (the login payload), not the
150
+ * `version` string, so {@link setNextConnectVersion} has no effect on a
151
+ * mobile session. Used by the `recoverFromClientTooOld` auto-retry to
152
+ * inject a fresh Android app version without mutating the user's options.
153
+ */
154
+ setNextConnectMobileAppVersion(appVersion) {
155
+ this.mobileAppVersionOverride = appVersion;
156
+ }
128
157
  /** Clears the in-memory QR and pairing sessions without touching storage. */
129
158
  // eslint-disable-next-line @typescript-eslint/require-await
130
159
  async clearTransientState() {
131
160
  this.logger.trace('auth client clear transient state');
132
161
  this.qrFlow.clear();
133
162
  this.pairingFlow.clearSession();
163
+ this.shortcakeFlow?.clearSession();
134
164
  }
135
165
  /**
136
166
  * Wipes credentials from memory **and** from the auth store. Does
@@ -289,9 +319,35 @@ export class WaAuthClient {
289
319
  }
290
320
  /** Dispatcher: returns `true` when `node` is a link-code companion notification. */
291
321
  async handleLinkCodeNotification(node) {
322
+ const type = node.attrs.type;
323
+ if (type === WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST ||
324
+ type === WA_NOTIFICATION_TYPES.CRSC_CONTINUATION) {
325
+ return this.runHandled(() => this.handleShortcakeNotification(node));
326
+ }
292
327
  this.logger.trace('auth client handleLinkCodeNotification', { id: node.attrs.id });
293
328
  return this.runHandled(() => this.pairingFlow.handleLinkCodeNotification(node));
294
329
  }
330
+ /**
331
+ * Handles the server-forced passkey (Shortcake) prologue. With a configured
332
+ * `signPasskeyAssertion` the full handshake runs; without one we just ack
333
+ * and warn instead of silently stalling – which is what happens today when
334
+ * the server demands a passkey after a successful pairing-code
335
+ * `companion_finish`.
336
+ */
337
+ async handleShortcakeNotification(node) {
338
+ if (node.attrs.type === WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
339
+ this.callbacks.onPasskeyRequired?.(this.shortcakeFlow !== null);
340
+ }
341
+ if (this.shortcakeFlow) {
342
+ return this.shortcakeFlow.handleIncomingNotification(node);
343
+ }
344
+ if (node.attrs.type === WA_NOTIFICATION_TYPES.PASSKEY_PROLOGUE_REQUEST) {
345
+ this.logger.warn('auth client: server requested passkey prologue but no signPasskeyAssertion configured', { id: node.attrs.id });
346
+ await this.socket.sendNode(buildAckNode({ kind: 'notification', node }));
347
+ return true;
348
+ }
349
+ return false;
350
+ }
295
351
  /** Dispatcher: returns `true` when `node` is a companion-registration refresh notification. */
296
352
  async handleCompanionRegRefreshNotification(node) {
297
353
  this.logger.trace('auth client handleCompanionRegRefreshNotification', {