@oxidezap/baileyrs 0.1.3 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -143,7 +143,14 @@ const append = (data, historyCache, event, eventData, logger) => {
143
143
  case 'chats.upsert': {
144
144
  for (const chat of eventData) {
145
145
  const id = chat.id || '';
146
- let existing = data.chatUpserts[id] || data.historySets.chats[id];
146
+ // The history set is only consulted for a chat that *has* an id.
147
+ // Upstream guards the lookup with `id &&`, and this port had dropped
148
+ // it: an id-less chat then folded into whatever id-less entry a
149
+ // buffered history set happened to carry, summing their unread counts,
150
+ // where upstream releases it as its own `chats.upsert`. Found by the
151
+ // buffer differential once history rows started drawing from the same
152
+ // identity pool as live traffic.
153
+ let existing = data.chatUpserts[id] || (id ? data.historySets.chats[id] : undefined);
147
154
  if (existing)
148
155
  concatChats(existing, chat);
149
156
  else {
@@ -185,14 +192,37 @@ const append = (data, historyCache, event, eventData, logger) => {
185
192
  case 'contacts.upsert': {
186
193
  for (const contact of eventData) {
187
194
  const existing = data.contactUpserts[contact.id] || data.historySets.contacts[contact.id];
188
- if (existing)
189
- Object.assign(existing, trimUndefined(contact));
190
- else
191
- data.contactUpserts[contact.id] = contact;
195
+ // A `contacts.update` already buffered for this id arrived *before*
196
+ // this upsert, so it is folded in first and the upsert's own values
197
+ // win where the two carry the same field. Folding it last — which is
198
+ // what this did — let a stale name overwrite the one that came after
199
+ // it, and the buffer released the older of the two. Fields only the
200
+ // update carried still survive, which upstream drops.
192
201
  const pending = data.contactUpdates[contact.id];
193
- if (pending) {
194
- Object.assign(existing || contact, pending);
202
+ if (pending)
195
203
  delete data.contactUpdates[contact.id];
204
+ if (existing) {
205
+ if (pending)
206
+ Object.assign(existing, pending);
207
+ Object.assign(existing, trimUndefined(contact));
208
+ }
209
+ else {
210
+ // Filling the gaps in `contact` rather than merging onto `pending`
211
+ // and storing that: `pending` was grown from `{}` by the update
212
+ // branch, and accumulating into it — or into a fresh literal —
213
+ // measured about twice the cost of writing into the contact the
214
+ // caller handed us, which arrives with a settled shape. An
215
+ // explicitly-`undefined` field counts as absent, which is what
216
+ // `trimUndefined` would decide and costs no second pass.
217
+ if (pending) {
218
+ const target = contact;
219
+ const source = pending;
220
+ for (const field in source) {
221
+ if (target[field] === undefined)
222
+ target[field] = source[field];
223
+ }
224
+ }
225
+ data.contactUpserts[contact.id] = contact;
196
226
  }
197
227
  }
198
228
  break;
@@ -326,17 +356,21 @@ const consolidateEvents = (data) => {
326
356
  if (Array.isArray(values) && values.length > 0)
327
357
  events[event] = values;
328
358
  };
359
+ // The order of these writes is the contract, not an implementation detail.
360
+ // A flush walks `Object.keys()` of this map, so insertion order decides both
361
+ // the key order a `process()` handler iterates and the order the individual
362
+ // events are re-dispatched to `.on()` listeners. Handlers that assume
363
+ // upstream's sequence — messages before the contacts they reference — see a
364
+ // different interleaving if these move. Keep them aligned with upstream's
365
+ // `consolidateEvents`.
329
366
  assignArray('chats.upsert', Object.values(data.chatUpserts));
330
367
  assignArray('chats.update', Object.values(data.chatUpdates));
331
368
  assignArray('chats.delete', [...data.chatDeletes]);
332
- assignArray('contacts.upsert', Object.values(data.contactUpserts));
333
- assignArray('contacts.update', Object.values(data.contactUpdates));
334
- assignArray('messages.update', Object.values(data.messageUpdates));
335
- assignArray('groups.update', Object.values(data.groupUpdates));
336
369
  const upserts = Object.values(data.messageUpserts);
337
370
  if (upserts.length) {
338
371
  events['messages.upsert'] = { messages: upserts.map(item => item.message), type: upserts[0].type };
339
372
  }
373
+ assignArray('messages.update', Object.values(data.messageUpdates));
340
374
  const deleted = Object.values(data.messageDeletes);
341
375
  if (deleted.length)
342
376
  events['messages.delete'] = { keys: deleted };
@@ -346,6 +380,9 @@ const consolidateEvents = (data) => {
346
380
  const receipts = Object.values(data.messageReceipts).flatMap(({ key, userReceipt }) => userReceipt.map(receipt => ({ key, receipt })));
347
381
  if (receipts.length)
348
382
  events['message-receipt.update'] = receipts;
383
+ assignArray('contacts.upsert', Object.values(data.contactUpserts));
384
+ assignArray('contacts.update', Object.values(data.contactUpdates));
385
+ assignArray('groups.update', Object.values(data.groupUpdates));
349
386
  return events;
350
387
  };
351
388
  /**
@@ -228,22 +228,45 @@ export const generateForwardMessageContent = (message, forceForward) => {
228
228
  throw new Boom('no content in message', { statusCode: 400 });
229
229
  }
230
230
  content = normalizeMessageContent(content);
231
- // Shallow clone — only the inner message object gets modified (contextInfo)
231
+ // Shallow clone of the outer map one entry on it is rewritten below.
232
232
  content = { ...content };
233
233
  let key = Object.keys(content)[0];
234
234
  let score = content?.[key]?.contextInfo?.forwardingScore || 0;
235
235
  score += message.key.fromMe && !forceForward ? 0 : 1;
236
+ const contextInfo = score > 0 ? { forwardingScore: score, isForwarded: true } : {};
237
+ // The nested message object is the *caller's*, reached through the shallow
238
+ // clone above, so writing `contextInfo` onto it wrote into their argument.
239
+ // And it replaces rather than merges: a caller who forwarded a quoted message
240
+ // found `stanzaId` and `participant` gone from their own object afterwards.
241
+ // Upstream leaves the argument untouched.
242
+ //
243
+ // Rebuilt rather than deep-copied, and only the one object being written.
244
+ // Upstream's copy is `proto.Message.decode(proto.Message.encode(content))` —
245
+ // a full serialise/parse round trip on a hot send path — which is not worth
246
+ // paying to fix an aliasing bug.
236
247
  if (key === 'conversation') {
237
- content.extendedTextMessage = { text: content[key] };
248
+ // This object is created here, so nothing of the caller's is aliased and
249
+ // the `contextInfo` goes straight in. Same allocation count as before.
250
+ content.extendedTextMessage = { text: content[key], contextInfo };
238
251
  delete content.conversation;
239
252
  key = 'extendedTextMessage';
240
- }
241
- const key_ = content?.[key];
242
- if (score > 0) {
243
- key_.contextInfo = { forwardingScore: score, isForwarded: true };
253
+ return content;
254
+ }
255
+ const nested = content[key];
256
+ // A plain object is the only thing worth copying, and the only thing that can
257
+ // be the caller's to damage. Anything else — an absent slot on an empty
258
+ // message, a primitive, an array — keeps the original assignment, which
259
+ // throws for exactly the inputs it threw for before and that upstream throws
260
+ // for too. Spreading those instead invented a property named `"undefined"`
261
+ // where upstream raised a TypeError.
262
+ if (typeof nested === 'object' && nested !== null && !Array.isArray(nested)) {
263
+ // One shallow spread, of exactly the object being modified. This is the
264
+ // whole cost of the fix, and it is unavoidable: not writing into the
265
+ // caller's object means writing into a different one.
266
+ content[key] = { ...nested, contextInfo };
244
267
  }
245
268
  else {
246
- key_.contextInfo = {};
269
+ nested.contextInfo = contextInfo;
247
270
  }
248
271
  return content;
249
272
  };
@@ -36,6 +36,15 @@ export declare const processHistoryMessage: (item: proto.IHistorySync, logger?:
36
36
  export declare const downloadHistory: (msg: proto.Message.IHistorySyncNotification, options: RequestInit) => Promise<proto.HistorySync>;
37
37
  /** Resolve inline or external history-sync content and normalize its public payload. */
38
38
  export declare const downloadAndProcessHistorySyncNotification: (msg: proto.Message.IHistorySyncNotification, options: RequestInit, logger?: ILogger) => Promise<ProcessedHistorySync>;
39
- /** Extract a history-sync notification through the same wrapper normalization as upstream. */
40
- export declare const getHistoryMsg: (message: proto.IMessage) => proto.Message.IHistorySyncNotification;
39
+ /**
40
+ * Extract a history-sync notification through the same wrapper normalization as
41
+ * upstream.
42
+ *
43
+ * Returns `undefined` when the message carries none. It used to throw a Boom
44
+ * 400, which breaks the shape every caller writes against a drop-in API —
45
+ * `const h = getHistoryMsg(msg); if (!h) return` crashed instead of returning.
46
+ * "Absent" is the ordinary case here, not an error: any message that is not a
47
+ * history sync takes this path.
48
+ */
49
+ export declare const getHistoryMsg: (message: proto.IMessage) => proto.Message.IHistorySyncNotification | undefined;
41
50
  //# sourceMappingURL=process-history-message.d.ts.map
@@ -14,7 +14,6 @@ import { inflateZlib } from '@oxidezap/whatsapp-rust-bridge';
14
14
  import { proto } from '../WAProto/runtime.js';
15
15
  import { WAProto } from '../Types/index.js';
16
16
  import { isHostedLidUser, isHostedPnUser, isLidUser, isPnUser } from '../WABinary/jid-utils.js';
17
- import { Boom } from './boom.js';
18
17
  import { toNumber } from './generics.js';
19
18
  import { downloadContentFromMessage, normalizeMessageContent } from './messages.js';
20
19
  import { createSparseArray } from './sparse-array.js';
@@ -199,13 +198,18 @@ export const downloadAndProcessHistorySyncNotification = async (msg, options, lo
199
198
  : await downloadHistory(msg, options);
200
199
  return processHistoryMessage(historyMsg, logger);
201
200
  };
202
- /** Extract a history-sync notification through the same wrapper normalization as upstream. */
201
+ /**
202
+ * Extract a history-sync notification through the same wrapper normalization as
203
+ * upstream.
204
+ *
205
+ * Returns `undefined` when the message carries none. It used to throw a Boom
206
+ * 400, which breaks the shape every caller writes against a drop-in API —
207
+ * `const h = getHistoryMsg(msg); if (!h) return` crashed instead of returning.
208
+ * "Absent" is the ordinary case here, not an error: any message that is not a
209
+ * history sync takes this path.
210
+ */
203
211
  export const getHistoryMsg = (message) => {
204
212
  const normalizedContent = message ? normalizeMessageContent(message) : undefined;
205
- const historySyncNotification = normalizedContent?.protocolMessage?.historySyncNotification;
206
- if (!historySyncNotification) {
207
- throw new Boom('Message does not contain a history sync notification', { statusCode: 400 });
208
- }
209
- return historySyncNotification;
213
+ return normalizedContent?.protocolMessage?.historySyncNotification ?? undefined;
210
214
  };
211
215
  //# sourceMappingURL=process-history-message.js.map
@@ -1,7 +1,44 @@
1
1
  import { mkdir, stat } from 'node:fs/promises';
2
+ import { createDeviceProjection } from '../Compatibility/legacy-store/device.js';
2
3
  import { projectNativeStore } from '../Compatibility/legacy-store/native-projection.js';
3
4
  import { initAuthCreds } from './generics.js';
4
5
  import { useBridgeStore } from './use-bridge-store.js';
6
+ /**
7
+ * The store namespace and keys the engine writes its own device under.
8
+ *
9
+ * `<folder>/device-device.bin` and `<folder>/device-account.bin` on disk.
10
+ */
11
+ const DEVICE_STORE = 'device';
12
+ const DEVICE_RECORDS = ['device', 'account'];
13
+ /**
14
+ * Rebuild the credential mirror from the device the engine persisted.
15
+ *
16
+ * Without this the mirror is whatever `initAuthCreds()` just made up: every
17
+ * restart handed back `registered: false` and `me: undefined` for a session
18
+ * that was paired and working, because nothing on this path ever read the
19
+ * device back. The hydration itself already existed for `wrapLegacyStore`
20
+ * (`Compatibility/legacy-store/adapter.ts`), which only runs for callers that
21
+ * bring their own `{ creds, keys }`; a caller using this function goes straight
22
+ * to the bridge store and used to skip it entirely.
23
+ *
24
+ * A record that fails to decode is skipped rather than fatal: a mirror missing
25
+ * a field is worth less than a socket that will not start, and the engine reads
26
+ * its own device from the same bytes regardless of what this makes of them.
27
+ */
28
+ const hydrateFromStore = async (store, creds) => {
29
+ const projection = createDeviceProjection(creds);
30
+ for (const record of DEVICE_RECORDS) {
31
+ const payload = await store.get(DEVICE_STORE, record);
32
+ if (!payload)
33
+ continue;
34
+ try {
35
+ projection.prepare(record, payload)();
36
+ }
37
+ catch {
38
+ /* a record we cannot read leaves that part of the mirror at its default */
39
+ }
40
+ }
41
+ };
5
42
  /**
6
43
  * Creates a file-based authentication state for the Rust bridge.
7
44
  *
@@ -30,6 +67,14 @@ export const useMultiFileAuthState = async (folder) => {
30
67
  }
31
68
  const store = await useBridgeStore(folder);
32
69
  const creds = initAuthCreds();
70
+ // Before `projectNativeStore`, and that ordering is the whole point: the
71
+ // projection captures `signedIdentityKey.public` and `registrationId` off
72
+ // `creds` when it is called, and builds the Signal codecs around them.
73
+ // Hydrating afterwards would leave those codecs holding the identity of the
74
+ // throwaway `initAuthCreds()` rather than the device's, so a legacy session
75
+ // written through this store would be imported under the wrong local
76
+ // identity.
77
+ await hydrateFromStore(store, creds);
33
78
  const keys = projectNativeStore(store, creds);
34
79
  return {
35
80
  state: { creds, keys, store },
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oxidezap/baileyrs",
3
3
  "type": "module",
4
- "version": "0.1.3",
4
+ "version": "0.2.0",
5
5
  "description": "A Rust-powered WhatsApp Web library for JavaScript, with a Baileys-compatible API",
6
6
  "keywords": [
7
7
  "whatsapp",
@@ -47,6 +47,7 @@
47
47
  "lib/**/*",
48
48
  "!lib/**/*.map",
49
49
  "!lib/**/__tests__/**",
50
+ "!lib/__fuzz__/**",
50
51
  "!lib/**/*.test.*",
51
52
  "!lib/**/*.test-e2e.*"
52
53
  ],
@@ -58,6 +59,7 @@
58
59
  "compat:audit:missing": "npm run build --silent && node scripts/compatibility/audit.ts --only-missing",
59
60
  "compat:audit:proto": "node scripts/compatibility/proto-runtime-audit.ts --details --strict",
60
61
  "compat:audit:wire": "node scripts/compatibility/wire-fidelity-audit.ts --details --strict",
62
+ "compat:audit:lifecycle": "node scripts/compatibility/lifecycle-contract-audit.ts --strict",
61
63
  "compat:audit:strict": "npm run build --silent && node scripts/compatibility/audit.ts --strict --only-missing",
62
64
  "compat:sync-waproto": "node scripts/compatibility/waproto-facade.ts --sync",
63
65
  "compat:check-waproto": "node scripts/compatibility/waproto-facade.ts --check",
@@ -70,13 +72,17 @@
70
72
  "prepack": "npm run build && node scripts/check-pack.ts",
71
73
  "prepare": "npm run build",
72
74
  "test": "node --test",
75
+ "fuzz": "node --test --test-timeout=600000 ./src/__fuzz__/**/*.test.ts",
76
+ "fuzz:deep": "FUZZ_MODE=deep node --expose-gc --test --test-timeout=1800000 ./src/__fuzz__/**/*.test.ts",
77
+ "fuzz:record": "FUZZ_RECORD=1 node --test --test-timeout=600000 ./src/__fuzz__/**/*.test.ts",
78
+ "fuzz:report": "node scripts/fuzz/report.ts",
73
79
  "test:compat-auditor": "node --test scripts/compatibility/__tests__/audit.test.ts",
74
80
  "typecheck:compat-auditor": "npm run build --silent && npm run compat:check-waproto --silent && npm run compat:layers --silent && tsc -p scripts/compatibility/tsconfig.json",
75
- "test:e2e": "NODE_TLS_REJECT_UNAUTHORIZED=0 ADV_SECRET_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= node --expose-gc --test --test-concurrency=1 ./src/__tests__/e2e/*.test-e2e.ts"
81
+ "test:e2e": "NODE_TLS_REJECT_UNAUTHORIZED=0 ADV_SECRET_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= node --expose-gc --test --test-concurrency=1 ./src/__tests__/e2e/*.test-e2e.ts ./scripts/compatibility/__tests__/*.test-e2e.ts"
76
82
  },
77
83
  "dependencies": {
78
84
  "@hapi/boom": "^9.1.4",
79
- "@oxidezap/whatsapp-rust-bridge": "0.7.1",
85
+ "@oxidezap/whatsapp-rust-bridge": "0.11.0",
80
86
  "long": "^5.3.2",
81
87
  "pino": "^10.3.1",
82
88
  "protobufjs": "^7.6.5"