homebridge-tuya-plus 3.14.0-dev.35 → 3.14.0-dev.36

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.
package/AGENTS.md CHANGED
@@ -144,6 +144,12 @@ optional, off-by-default flags read through `_debugConfig()` and coerced with
144
144
  the whole platform runs over the Tuya Cloud fallback. Lets the cloud path be
145
145
  exercised end-to-end without taking devices off the LAN (needs a configured
146
146
  `cloud` block to be useful).
147
+ - `debug.logCloudHttp` — trace every Tuya Cloud HTTP request/response (method,
148
+ path, headers, body, status, response) at `debug`, with all credentials
149
+ (token, signature, password, access key/id, uid) redacted. Use it to see
150
+ exactly what a failing `POST …/commands` sent and how the cloud replied (e.g.
151
+ the `code`/`value` behind a `2008`). Verbose and per-request — keep it off in
152
+ normal operation, and remember Homebridge debug logging must be on to see it.
147
153
 
148
154
  ## Git & PR workflow
149
155
 
package/index.js CHANGED
@@ -402,7 +402,11 @@ class TuyaLan {
402
402
  );
403
403
  }
404
404
 
405
- this.cloudApi = new TuyaCloudApi({ ...cloudCfg, log: this.log });
405
+ this.cloudApi = new TuyaCloudApi({
406
+ ...cloudCfg,
407
+ log: this.log,
408
+ logHttp: coerceBoolean(this._debugConfig().logCloudHttp),
409
+ });
406
410
  this.cloudMessaging = new TuyaCloudMessaging({
407
411
  api: this.cloudApi,
408
412
  log: this.log,
@@ -53,9 +53,18 @@ const REGION_ENDPOINTS = {
53
53
  // see one we drop the cached token and retry the call once.
54
54
  const TOKEN_INVALID_CODES = new Set([1010, 1011, 1004]);
55
55
 
56
+ // Header/body/response field names whose values may be a credential or token.
57
+ // Their values are masked before an HTTP trace is logged (debug.logCloudHttp),
58
+ // so the trace never leaks the access token, signature, account password, uid,
59
+ // or access key/id.
60
+ const SECRET_FIELDS = /^(access_token|refresh_token|password|sign|secret|access_?key|access_?id|client_id|uid|localKey|key)$/i;
61
+
56
62
  class TuyaCloudApi {
57
- constructor({accessId, accessKey, region, endpoint, username, password, countryCode, schema, log} = {}) {
63
+ constructor({accessId, accessKey, region, endpoint, username, password, countryCode, schema, log, logHttp} = {}) {
58
64
  this.log = log || console;
65
+ // Dev/diagnostic switch (debug.logCloudHttp): trace every cloud HTTP
66
+ // request/response, with credentials redacted. Off by default.
67
+ this.logHttp = !!logHttp;
59
68
  this.accessId = ('' + (accessId || '')).trim();
60
69
  this.accessKey = ('' + (accessKey || '')).trim();
61
70
 
@@ -280,6 +289,46 @@ class TuyaCloudApi {
280
289
 
281
290
  /* ------------------------- transport ------------------------------- */
282
291
 
292
+ _maskSecret(value) {
293
+ const s = '' + value;
294
+ return s.length <= 8 ? '***' : `${s.slice(0, 4)}…${s.slice(-4)}`;
295
+ }
296
+
297
+ // Deep copy with any credential-looking field masked, so a full HTTP trace
298
+ // can be logged without leaking the token, signature, account password, or
299
+ // access key/id (the device `value`s themselves are not secret and stay).
300
+ _redactForLog(value) {
301
+ if (Array.isArray(value)) return value.map(v => this._redactForLog(v));
302
+ if (value && typeof value === 'object') {
303
+ const out = {};
304
+ for (const k of Object.keys(value)) {
305
+ out[k] = SECRET_FIELDS.test(k) ? this._maskSecret(value[k]) : this._redactForLog(value[k]);
306
+ }
307
+ return out;
308
+ }
309
+ return value;
310
+ }
311
+
312
+ // One redacted request/response trace line, emitted only when the
313
+ // debug.logCloudHttp switch is set. It's routine, per-request and verbose, so
314
+ // it goes to debug. Never throws and never leaks a secret (see _redactForLog).
315
+ _traceHttp(method, urlPath, headers, bodyStr, statusCode, responseBody) {
316
+ if (!this.logHttp) return;
317
+ try {
318
+ let reqBody = '(none)';
319
+ if (bodyStr) {
320
+ try { reqBody = JSON.stringify(this._redactForLog(JSON.parse(bodyStr))); }
321
+ catch (_) { reqBody = ('' + bodyStr).slice(0, 200); }
322
+ }
323
+ this.log.debug(
324
+ `[TuyaCloud HTTP] ${method} ${urlPath} → HTTP ${statusCode}` +
325
+ ` | req.headers=${JSON.stringify(this._redactForLog(headers))}` +
326
+ ` | req.body=${reqBody}` +
327
+ ` | res=${JSON.stringify(this._redactForLog(responseBody))}`
328
+ );
329
+ } catch (_) { /* logging must never throw */ }
330
+ }
331
+
283
332
  _httpsRequest(method, urlPath, headers, bodyStr) {
284
333
  return new Promise((resolve, reject) => {
285
334
  let url;
@@ -300,15 +349,22 @@ class TuyaCloudApi {
300
349
  resp.setEncoding('utf8');
301
350
  resp.on('data', chunk => { data += chunk; });
302
351
  resp.on('end', () => {
352
+ let parsed;
303
353
  try {
304
- resolve(JSON.parse(data));
354
+ parsed = JSON.parse(data);
305
355
  } catch (ex) {
306
- reject(new Error(`Tuya Cloud returned non-JSON (HTTP ${resp.statusCode}): ${('' + data).slice(0, 200)}`));
356
+ this._traceHttp(method, urlPath, headers, bodyStr, resp.statusCode, data);
357
+ return reject(new Error(`Tuya Cloud returned non-JSON (HTTP ${resp.statusCode}): ${('' + data).slice(0, 200)}`));
307
358
  }
359
+ this._traceHttp(method, urlPath, headers, bodyStr, resp.statusCode, parsed);
360
+ resolve(parsed);
308
361
  });
309
362
  });
310
363
 
311
- req.on('error', reject);
364
+ req.on('error', err => {
365
+ if (this.logHttp) { try { this.log.debug(`[TuyaCloud HTTP] ${method} ${urlPath} → error: ${err.message}`); } catch (_) { /* never throw */ } }
366
+ reject(err);
367
+ });
312
368
  req.setTimeout(20000, () => req.destroy(new Error('request timed out')));
313
369
  if (bodyStr) req.write(bodyStr);
314
370
  req.end();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "homebridge-tuya-plus",
3
- "version": "3.14.0-dev.35",
3
+ "version": "3.14.0-dev.36",
4
4
  "description": "A community-maintained Homebridge plugin for controlling Tuya devices in HomeKit. LAN-first, with an optional Tuya Cloud fallback for any device the LAN can't reach.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -219,3 +219,58 @@ describe('TuyaCloudApi — endpoints', () => {
219
219
  await expect(api.request('GET', '/x')).rejects.toThrow(/no permissions/);
220
220
  });
221
221
  });
222
+
223
+ describe('TuyaCloudApi — HTTP trace (debug.logCloudHttp)', () => {
224
+ const makeLog = () => ({info: () => {}, warn: () => {}, error: () => {}, debug: jest.fn()});
225
+
226
+ test('logHttp is off unless explicitly enabled', () => {
227
+ expect(makeApi().logHttp).toBe(false);
228
+ expect(makeApi({logHttp: true}).logHttp).toBe(true);
229
+ });
230
+
231
+ test('redaction masks credentials but keeps device data', () => {
232
+ const api = makeApi();
233
+ const redacted = api._redactForLog({
234
+ client_id: 'aid1234567890', access_token: 'tok_abcdef123456', sign: 'SIGNATUREVALUE', t: '1700', nonce: 'n-1',
235
+ result: {access_token: 'inner-token-xyz', refresh_token: 'refresh-xyz', uid: 'uid-1234567', online: true},
236
+ commands: [{code: 'switch_1', value: true}]
237
+ });
238
+
239
+ expect(redacted.client_id).not.toBe('aid1234567890');
240
+ expect(redacted.access_token).not.toBe('tok_abcdef123456');
241
+ expect(redacted.sign).not.toBe('SIGNATUREVALUE');
242
+ expect(redacted.result.access_token).not.toBe('inner-token-xyz');
243
+ expect(redacted.result.refresh_token).not.toBe('refresh-xyz');
244
+ expect(redacted.result.uid).not.toBe('uid-1234567');
245
+ // Non-secret fields and the actual device data-points pass through intact.
246
+ expect(redacted.t).toBe('1700');
247
+ expect(redacted.nonce).toBe('n-1');
248
+ expect(redacted.result.online).toBe(true);
249
+ expect(redacted.commands).toEqual([{code: 'switch_1', value: true}]);
250
+ });
251
+
252
+ test('no trace is logged when the switch is off', () => {
253
+ const log = makeLog();
254
+ const api = makeApi({log});
255
+ api._traceHttp('POST', '/v1.0/devices/dev/commands', {sign: 'X'}, '{"commands":[]}', 200, {success: true});
256
+ expect(log.debug).not.toHaveBeenCalled();
257
+ });
258
+
259
+ test('a trace carries the request/response but never the raw token or signature', () => {
260
+ const log = makeLog();
261
+ const api = makeApi({log, logHttp: true});
262
+ const headers = {client_id: 'aid', sign: 'SIGN_SECRET_VALUE', access_token: 'TOKEN_SECRET_VALUE', t: '1', nonce: 'n', 'Content-Type': 'application/json'};
263
+ const body = JSON.stringify({commands: [{code: 'wfh_open', value: true}]});
264
+
265
+ api._traceHttp('POST', '/v1.0/devices/dev/commands', headers, body, 200, {success: false, code: 2008, msg: 'command or value not support'});
266
+
267
+ expect(log.debug).toHaveBeenCalledTimes(1);
268
+ const line = log.debug.mock.calls[0][0];
269
+ expect(line).toContain('POST /v1.0/devices/dev/commands');
270
+ expect(line).toContain('wfh_open'); // the attempted command is visible
271
+ expect(line).toContain('2008'); // and the cloud's verdict
272
+ expect(line).toContain('command or value not support');
273
+ expect(line).not.toContain('TOKEN_SECRET_VALUE'); // …but secrets are not
274
+ expect(line).not.toContain('SIGN_SECRET_VALUE');
275
+ });
276
+ });