@mega-yfue/eufy-sdk 0.3.0-beta.3 → 0.3.0-beta.5

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.
@@ -377,8 +377,9 @@ export type EufyMegaEventMap = {
377
377
  * on `error`.
378
378
  *
379
379
  * The error carries the rate: `err.retryAfterMs` is how long the next session replacement is barred
380
- * for, and `err.contended` says this session is being displaced by another client rather than expiring
381
- * — which a re-login does not answer. A login made before that wait elapses extends it.
380
+ * for, and `err.contended` says the token was rejected again soon after being replaced, which is how
381
+ * displacement by another client looks and which a re-login does not answer. A login made before that
382
+ * wait elapses extends it.
382
383
  */
383
384
  sessionExpired: [err: SessionExpiredError];
384
385
  error: [err: Error];
package/dist/index.js CHANGED
@@ -1102,8 +1102,9 @@ var SessionExpiredError = class extends Error {
1102
1102
  /**
1103
1103
  * Whether this rejection landed inside that bar — a token replaced recently and rejected again since.
1104
1104
  *
1105
- * It says the session is being DISPLACED rather than expiring: something else is signing in on this
1106
- * account, and replacing the token again only trades one login for another.
1105
+ * That is the shape displacement by another client signed in on this account has, where replacing the
1106
+ * token again only trades one login for another. It is not proof of one, since a cloud refusing to
1107
+ * re-issue the session looks the same.
1107
1108
  */
1108
1109
  contended;
1109
1110
  constructor(message, opts = {}) {
@@ -1149,7 +1150,7 @@ var LoginStatus = {
1149
1150
  var REAUTH_HOLD_OFF_MS = 6e4;
1150
1151
  var REAUTH_HOLD_OFF_CAP_MS = 30 * 6e4;
1151
1152
  var REAUTH_STABLE_MS = 10 * 6e4;
1152
- var CONTENDED_SESSION_HINT = "another client signed in on this account keeps displacing this session \u2014 the cloud holds about one session per account and device identity, so each login ends the other's; where the other client is another SDK install, give each its own openudid";
1153
+ var CONTENDED_SESSION_HINT = "the token was rejected again soon after it was last replaced; one possible cause is another client signed in with the same account and device identity, since the cloud holds about one session per pair and each login ends the other's (where that is another SDK install, give each its own openudid)";
1153
1154
  function tokenRejected(code, msg) {
1154
1155
  return code === EufyCloudErrorCode.SESSION_KICKED || /user_id is empty|invalid[^,]*\btoken\b|\btoken\b[^,]*(expired|error|not exist)|kicked|(?:\btoken\b|\bsession\b)[^,]*does not exist|unauthor/i.test(msg ?? "");
1155
1156
  }
@@ -1163,6 +1164,8 @@ var MegaHttpClient = class {
1163
1164
  sessionKey;
1164
1165
  /** Per-host ECDH session keys for non-mega gateways (e.g. eufylife) keyed by host. */
1165
1166
  sessionKeys = /* @__PURE__ */ new Map();
1167
+ /** The in-flight key exchanges, by host and the token they carry — see {@link ensureSessionKey}. */
1168
+ keyExchanges = /* @__PURE__ */ new Map();
1166
1169
  /**
1167
1170
  * The held credential. `userId` is the login reply's `ap_cloud_user_id` where it has one — the Anker
1168
1171
  * Passport cloud's id — while `accountUserId` is the eufy account's own `user_id`.
@@ -1384,6 +1387,20 @@ var MegaHttpClient = class {
1384
1387
  return cached2;
1385
1388
  }
1386
1389
  const host = isEufylife ? targetHost : this.bootstrapDomain ?? `app-openapi-${this.region}.eufy.com`;
1390
+ const flight = `${host}
1391
+ ${this.auth_?.authToken ?? ""}`;
1392
+ const joining = this.keyExchanges.get(flight);
1393
+ if (joining)
1394
+ return joining;
1395
+ const exchange = this.exchangeSessionKey(host, isEufylife).finally(() => {
1396
+ if (this.keyExchanges.get(flight) === exchange)
1397
+ this.keyExchanges.delete(flight);
1398
+ });
1399
+ this.keyExchanges.set(flight, exchange);
1400
+ return exchange;
1401
+ }
1402
+ /** One key exchange against `host`, installed as that host's key. */
1403
+ async exchangeSessionKey(host, isEufylife) {
1387
1404
  const kxPath = isEufylife ? "/v3/openapi/oauth/key/exchange" : "/openapi/oauth/key/exchange";
1388
1405
  const prep = prepareKeyExchange(isEufylife ? EUFYLIFE_LOCAL_KEY_HEX : void 0);
1389
1406
  const res = await this.httpPost(`https://${host}${kxPath}`, { client_public_key: prep.encryptedClientPublicKey }, {
@@ -1399,7 +1416,7 @@ var MegaHttpClient = class {
1399
1416
  }
1400
1417
  const entry = finishKeyExchange(prep, env.data.server_public_key);
1401
1418
  if (isEufylife)
1402
- this.sessionKeys.set(targetHost, entry);
1419
+ this.sessionKeys.set(host, entry);
1403
1420
  else
1404
1421
  this.sessionKey = entry;
1405
1422
  return entry;
@@ -1470,9 +1487,10 @@ var MegaHttpClient = class {
1470
1487
  const identityError = authed && (last?.code === EufyCloudErrorCode.IDENTITY_KEY_STALE || /get identity error|identity error/i.test(last?.msg ?? ""));
1471
1488
  if (identityError && !retry.identity && this.auth_) {
1472
1489
  this.logger.debug("[mega] identity error \u2192 re-exchanging session key and retrying");
1473
- if (host.includes(".eufylife.com"))
1474
- this.sessionKeys.delete(host);
1475
- else
1490
+ if (host.includes(".eufylife.com")) {
1491
+ if (this.sessionKeys.get(host) === entry)
1492
+ this.sessionKeys.delete(host);
1493
+ } else if (this.sessionKey === entry)
1476
1494
  this.sessionKey = void 0;
1477
1495
  try {
1478
1496
  await this.ensureSessionKey(host);
@@ -1832,14 +1850,14 @@ var MegaHttpClient = class {
1832
1850
  * like the same device — and the cloud keeps one session per device. Each finds its token rejected, replaces
1833
1851
  * it, and evicts the other: an unbounded login war, silent, and repeated logins are exactly what makes an
1834
1852
  * account start demanding captchas. The first replacement is immediate, because a token displaced once is
1835
- * the ordinary case; a second one soon after is evidence of contention rather than expiry, so the wait grows
1836
- * and a caller is told the honest reason instead of being served a fight.
1853
+ * the ordinary case; a second one soon after has the shape of contention rather than expiry, so the wait grows
1854
+ * and a caller is told what was seen instead of being served a fight.
1837
1855
  */
1838
1856
  recoveryDue() {
1839
1857
  const remaining = this.holdOffRemainingMs();
1840
1858
  if (remaining === 0)
1841
1859
  return true;
1842
- this.logger.warn(`[mega] token rejected ${Math.round((Date.now() - this.lastRecoveryAt) / 1e3)}s after the last replacement \u2014 holding off ${Math.round(remaining / 1e3)}s more. ${CONTENDED_SESSION_HINT}`);
1860
+ this.logger.warn(`[mega] holding off ${Math.round(remaining / 1e3)}s more before replacing the token again (last replaced ${Math.round((Date.now() - this.lastRecoveryAt) / 1e3)}s ago): ${CONTENDED_SESSION_HINT}`);
1843
1861
  return false;
1844
1862
  }
1845
1863
  /**
@@ -23798,24 +23816,35 @@ function readNullTerminated(buf) {
23798
23816
  function nonemptyString(value) {
23799
23817
  return typeof value === "string" && value.trim().length > 0;
23800
23818
  }
23801
- function normalizePushEvent(raw) {
23802
- const env = raw.payload ?? {};
23803
- let inner = env.payload ?? env;
23804
- if (typeof inner === "string") {
23805
- try {
23806
- inner = JSON.parse(inner);
23807
- } catch {
23808
- inner = void 0;
23819
+ function payloadLevels(env) {
23820
+ const levels = [env];
23821
+ for (let level = env; level.payload != null; ) {
23822
+ let next = level.payload;
23823
+ if (typeof next === "string") {
23824
+ try {
23825
+ next = JSON.parse(next);
23826
+ } catch {
23827
+ next = void 0;
23828
+ }
23809
23829
  }
23830
+ level = typeof next === "object" && next ? next : {};
23831
+ levels.push(level);
23810
23832
  }
23811
- const p = typeof inner === "object" && inner ? inner : {};
23833
+ return levels;
23834
+ }
23835
+ function normalizePushEvent(raw) {
23836
+ const env = raw.payload ?? {};
23837
+ const levels = payloadLevels(env);
23838
+ const p = levels[levels.length - 1];
23839
+ const deepestFirst = [...levels].reverse();
23840
+ const deviceClaims = deepestFirst.map((l) => l.device_sn).filter(nonemptyString);
23841
+ const levelStations = deepestFirst.map((l) => l.station_sn).filter(nonemptyString);
23842
+ const stationClaims = nonemptyString(p.s) ? [...levelStations, p.s] : levelStations;
23812
23843
  const eventType = p.event_type ?? p.a;
23813
23844
  const url = nonemptyString(p.pic_url) ? p.pic_url : nonemptyString(p.thumbnail) ? p.thumbnail : void 0;
23814
23845
  let thumbnailCandidate;
23815
23846
  if (url) {
23816
- const deviceClaims = [p.device_sn, env.device_sn].filter(nonemptyString);
23817
23847
  const deviceSn = deviceClaims[0];
23818
- const stationClaims = [p.station_sn, env.station_sn, p.s].filter(nonemptyString);
23819
23848
  const stationSn = stationClaims[0];
23820
23849
  thumbnailCandidate = {
23821
23850
  url,
@@ -23826,8 +23855,8 @@ function normalizePushEvent(raw) {
23826
23855
  };
23827
23856
  }
23828
23857
  return {
23829
- deviceSn: p.device_sn ?? env.device_sn ?? p.s,
23830
- stationSn: p.station_sn ?? env.station_sn,
23858
+ deviceSn: deviceClaims[0] ?? (nonemptyString(p.s) ? p.s : void 0),
23859
+ stationSn: levelStations[0],
23831
23860
  eventType,
23832
23861
  thumbnailUrl: p.pic_url ?? p.thumbnail,
23833
23862
  thumbnailCandidate,
@@ -23967,6 +23996,13 @@ var PushClient = class _PushClient extends EventEmitter8 {
23967
23996
  }
23968
23997
  this.socket?.destroy();
23969
23998
  }
23999
+ /**
24000
+ * Decode one MCS `DataMessageStanza` into a {@link RawPushMessage} and the normalised event.
24001
+ *
24002
+ * `payload` carries the whole app_data envelope, with its `payload` entry (base64 of NUL-terminated
24003
+ * JSON) parsed in place: the envelope's own keys, `device_sn` and `station_sn` among them, sit beside
24004
+ * that entry.
24005
+ */
23970
24006
  handleDataMessage(object) {
23971
24007
  if (object?.persistentId)
23972
24008
  this.persistentIds.push(object.persistentId);
@@ -23991,7 +24027,7 @@ var PushClient = class _PushClient extends EventEmitter8 {
23991
24027
  persistentId: object?.persistentId,
23992
24028
  ttl: object?.ttl,
23993
24029
  sent: object?.sent,
23994
- payload: data.payload ?? data
24030
+ payload: data
23995
24031
  };
23996
24032
  this.emit("message", raw);
23997
24033
  const event = normalizePushEvent(raw);