@camstack/addon-import-alexa 0.2.174 → 0.2.175

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/CHANGELOG.md CHANGED
@@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.175] - 2026-11-01
11
+
12
+ ### Changed
13
+
14
+ - Alexa cookie login: the login proxy no longer forwards Amazon's upgrade-insecure-requests directive, which stopped the page from completing over http
15
+ - Echo sign-in through a broker now uses the cookies Amazon returns for the token directly, and a failed sign-in names its step
16
+
10
17
  ## [0.2.174] - 2026-11-01
11
18
 
12
19
  ### Changed
package/dist/addon.js CHANGED
@@ -133240,6 +133240,7 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
133240
133240
  _options.proxyLogLevel === "debug" && _options.logger && _options.logger(`Alexa-Cookie: Proxy-Request: (modified:${modified})${customStringify(proxyReq, null, 2)}`);
133241
133241
  }
133242
133242
  function onProxyRes(proxyRes, req, res) {
133243
+ stripUpgradeInsecureRequestsHeaders(proxyRes);
133243
133244
  const url = req.originalUrl || req.url;
133244
133245
  if (url.endsWith(".ico") || url.endsWith(".js") || url.endsWith(".ttf") || url.endsWith(".svg") || url.endsWith(".png") || url.endsWith(".appcache")) return;
133245
133246
  if (url.startsWith("/ap/uedata")) return;
@@ -133332,7 +133333,18 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
133332
133333
  callbackListening = null;
133333
133334
  });
133334
133335
  }
133336
+ function stripUpgradeInsecureRequestsHeaders(proxyRes) {
133337
+ const headers = proxyRes && proxyRes.headers;
133338
+ if (!headers) return;
133339
+ for (const name of ["content-security-policy", "content-security-policy-report-only"]) {
133340
+ if (headers[name] === void 0) continue;
133341
+ const kept = (Array.isArray(headers[name]) ? headers[name] : [headers[name]]).map((v) => String(v).split(";").map((d) => d.trim()).filter((d) => d && !/^upgrade-insecure-requests$/i.test(d)).join("; ")).filter((v) => v);
133342
+ if (kept.length) headers[name] = kept.length === 1 ? kept[0] : kept;
133343
+ else delete headers[name];
133344
+ }
133345
+ }
133335
133346
  module.exports.initAmazonProxy = initAmazonProxy;
133347
+ module.exports.stripUpgradeInsecureRequestsHeaders = stripUpgradeInsecureRequestsHeaders;
133336
133348
  }));
133337
133349
  //#endregion
133338
133350
  //#region ../../node_modules/alexa-cookie2/alexa-cookie.js
@@ -140954,7 +140966,7 @@ var BrokerEcho = class {
140954
140966
  try {
140955
140967
  this.currentDevices = [...parseDevices(await this.deps.worker.request({ op: "devices" }))];
140956
140968
  } catch (err) {
140957
- this.deps.logger.warn("alexa speak: device refresh failed", {
140969
+ this.deps.logger.warn("alexa speak: step 3 of 3, device list failed after a good sign-in", {
140958
140970
  tags: { brokerId: this.deps.brokerId },
140959
140971
  meta: { error: err instanceof Error ? err.message : String(err) }
140960
140972
  });
@@ -141484,6 +141496,7 @@ function createAlexaNotificationProvider(ports) {
141484
141496
  return found;
141485
141497
  },
141486
141498
  async discoverTargets(input) {
141499
+ for (const broker of ports.brokers()) if (!broker.ready) ports.logger.warn(`alexa echo discovery: no Echo listed for '${broker.name}', the Amazon account is not signed in for this broker`, { tags: { brokerId: broker.id } });
141487
141500
  const named = readString(input.config ?? {}, "brokerId");
141488
141501
  if (named.length > 0) {
141489
141502
  const owner = ports.brokers().find((broker) => broker.id === named);
package/dist/addon.mjs CHANGED
@@ -133238,6 +133238,7 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
133238
133238
  _options.proxyLogLevel === "debug" && _options.logger && _options.logger(`Alexa-Cookie: Proxy-Request: (modified:${modified})${customStringify(proxyReq, null, 2)}`);
133239
133239
  }
133240
133240
  function onProxyRes(proxyRes, req, res) {
133241
+ stripUpgradeInsecureRequestsHeaders(proxyRes);
133241
133242
  const url = req.originalUrl || req.url;
133242
133243
  if (url.endsWith(".ico") || url.endsWith(".js") || url.endsWith(".ttf") || url.endsWith(".svg") || url.endsWith(".png") || url.endsWith(".appcache")) return;
133243
133244
  if (url.startsWith("/ap/uedata")) return;
@@ -133330,7 +133331,18 @@ var require_proxy = /* @__PURE__ */ __commonJSMin(((exports, module) => {
133330
133331
  callbackListening = null;
133331
133332
  });
133332
133333
  }
133334
+ function stripUpgradeInsecureRequestsHeaders(proxyRes) {
133335
+ const headers = proxyRes && proxyRes.headers;
133336
+ if (!headers) return;
133337
+ for (const name of ["content-security-policy", "content-security-policy-report-only"]) {
133338
+ if (headers[name] === void 0) continue;
133339
+ const kept = (Array.isArray(headers[name]) ? headers[name] : [headers[name]]).map((v) => String(v).split(";").map((d) => d.trim()).filter((d) => d && !/^upgrade-insecure-requests$/i.test(d)).join("; ")).filter((v) => v);
133340
+ if (kept.length) headers[name] = kept.length === 1 ? kept[0] : kept;
133341
+ else delete headers[name];
133342
+ }
133343
+ }
133333
133344
  module.exports.initAmazonProxy = initAmazonProxy;
133345
+ module.exports.stripUpgradeInsecureRequestsHeaders = stripUpgradeInsecureRequestsHeaders;
133334
133346
  }));
133335
133347
  //#endregion
133336
133348
  //#region ../../node_modules/alexa-cookie2/alexa-cookie.js
@@ -140952,7 +140964,7 @@ var BrokerEcho = class {
140952
140964
  try {
140953
140965
  this.currentDevices = [...parseDevices(await this.deps.worker.request({ op: "devices" }))];
140954
140966
  } catch (err) {
140955
- this.deps.logger.warn("alexa speak: device refresh failed", {
140967
+ this.deps.logger.warn("alexa speak: step 3 of 3, device list failed after a good sign-in", {
140956
140968
  tags: { brokerId: this.deps.brokerId },
140957
140969
  meta: { error: err instanceof Error ? err.message : String(err) }
140958
140970
  });
@@ -141482,6 +141494,7 @@ function createAlexaNotificationProvider(ports) {
141482
141494
  return found;
141483
141495
  },
141484
141496
  async discoverTargets(input) {
141497
+ for (const broker of ports.brokers()) if (!broker.ready) ports.logger.warn(`alexa echo discovery: no Echo listed for '${broker.name}', the Amazon account is not signed in for this broker`, { tags: { brokerId: broker.id } });
141485
141498
  const named = readString(input.config ?? {}, "brokerId");
141486
141499
  if (named.length > 0) {
141487
141500
  const owner = ports.brokers().find((broker) => broker.id === named);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-import-alexa",
3
- "version": "0.2.174",
3
+ "version": "0.2.175",
4
4
  "description": "Alexa device-import provider for CamStack — imports the smart-home devices in a user's Alexa account via the unofficial alexa-remote2 cookie/token client (the inverse of the Alexa exporter)",
5
5
  "keywords": [
6
6
  "camstack",
@@ -68,7 +68,7 @@
68
68
  }
69
69
  ],
70
70
  "requires": {
71
- "server": ">=1.2.719"
71
+ "server": ">=1.2.723"
72
72
  }
73
73
  },
74
74
  "files": [
@@ -119,14 +119,32 @@ class AlexaPyGateway:
119
119
  bare_domain(domain), label, "", session_output(session_path), False, oauth=oauth
120
120
  )
121
121
  self._login = login
122
+ cookies: dict = {}
122
123
  if refresh_token != "":
123
124
  exchanged = await _exchange_refresh_token(login, device_app_name)
124
125
  if not exchanged:
125
- return {"status": "failed", "error": "Amazon did not accept the broker's token"}
126
+ return {
127
+ "status": "failed",
128
+ "error": "step 1 of 3, token exchange: Amazon refused the broker's token",
129
+ }
126
130
  await login.save_cookiefile()
127
- cookies = await login.load_cookie()
128
- if not cookies:
129
- return {"status": "failed", "error": "no Amazon session to start from"}
131
+ # The exchange put its cookies in the live session; use them as they are. Reading
132
+ # them back through the cookie file filters them by host and can come back empty.
133
+ cookies = _session_cookies(login)
134
+ if not cookies:
135
+ cookies = await login.load_cookie() or {}
136
+ if not cookies:
137
+ return {
138
+ "status": "failed",
139
+ "error": "step 2 of 3, cookies: the token exchange was accepted but returned no cookies",
140
+ }
141
+ else:
142
+ cookies = await login.load_cookie() or {}
143
+ if not cookies:
144
+ return {
145
+ "status": "failed",
146
+ "error": "step 2 of 3, cookies: no stored Amazon session and no broker token to start from",
147
+ }
130
148
  await login.login(cookies=cookies)
131
149
  await self._persist_session()
132
150
  return self._status_result()
@@ -213,6 +231,22 @@ def _import_alexapy():
213
231
  return AlexaLogin, AlexaAPI
214
232
 
215
233
 
234
+ def _session_cookies(login) -> dict:
235
+ """Every cookie the login's live session holds, by name. Values only leave this process
236
+ inside the library; nothing here logs them."""
237
+ session = getattr(login, "_session", None)
238
+ jar = getattr(session, "cookie_jar", None)
239
+ found: dict = {}
240
+ if jar is None:
241
+ return found
242
+ for morsel in jar:
243
+ name = getattr(morsel, "key", None)
244
+ value = getattr(morsel, "value", None)
245
+ if isinstance(name, str) and isinstance(value, str) and name != "":
246
+ found[name] = value.strip('"')
247
+ return found
248
+
249
+
216
250
  async def _exchange_refresh_token(login, device_app_name: str) -> bool:
217
251
  """Trade the broker's refresh token for cookies, under the name that token was registered with."""
218
252
  import alexapy.alexalogin as module
@@ -19,7 +19,9 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
19
19
  from alexa_gateway import AlexaPyGateway, bare_domain, normalize_device, session_output
20
20
 
21
21
 
22
- def install_fake_alexapy(exchange_ok: bool = True, stored_cookies: dict | None = None):
22
+ def install_fake_alexapy(
23
+ exchange_ok: bool = True, stored_cookies: dict | None = None, file_readback: bool = True
24
+ ):
23
25
  """Put a fake `alexapy` (and `alexapy.alexalogin`) in sys.modules; return the call log."""
24
26
  calls: dict = {"exchange_app_names": [], "logins": [], "saved": 0}
25
27
  module = types.ModuleType("alexapy.alexalogin")
@@ -34,10 +36,13 @@ def install_fake_alexapy(exchange_ok: bool = True, stored_cookies: dict | None =
34
36
  calls["jar_path"] = outputpath(".storage/alexa_media.x.pickle")
35
37
  self.status = {"login_successful": True}
36
38
  self.refresh_token = (oauth or {}).get("refresh_token")
39
+ self._session = types.SimpleNamespace(cookie_jar=[])
37
40
 
38
41
  async def exchange_token_for_cookies(self):
39
42
  calls["exchange_app_names"].append(module.APP_NAME)
40
43
  calls["exchanged_token"] = self.refresh_token
44
+ if exchange_ok:
45
+ self._session.cookie_jar.append(types.SimpleNamespace(key="session-id", value="1"))
41
46
  return exchange_ok
42
47
 
43
48
  async def save_cookiefile(self):
@@ -45,7 +50,9 @@ def install_fake_alexapy(exchange_ok: bool = True, stored_cookies: dict | None =
45
50
  Path(calls["jar_path"]).write_bytes(b"jar")
46
51
 
47
52
  async def load_cookie(self):
48
- return {"session-id": "1"} if (exchange_ok or stored_cookies) else {}
53
+ if stored_cookies:
54
+ return stored_cookies
55
+ return {"session-id": "1"} if (exchange_ok and file_readback) else {}
49
56
 
50
57
  async def login(self, cookies=None, data=None):
51
58
  calls["logins"].append(cookies)
@@ -68,6 +75,10 @@ def run(coro):
68
75
  return asyncio.run(coro)
69
76
 
70
77
 
78
+ async def _accept_without_cookies(self):
79
+ return True
80
+
81
+
71
82
  class GatewayTests(unittest.TestCase):
72
83
  def tearDown(self) -> None:
73
84
  remove_fake_alexapy()
@@ -113,6 +124,27 @@ class GatewayTests(unittest.TestCase):
113
124
  self.assertEqual(calls["logins"], [])
114
125
  self.assertEqual(module.APP_NAME, "Alexa Media Player")
115
126
 
127
+ def test_cookies_from_the_exchange_are_used_when_the_file_reads_back_empty(self) -> None:
128
+ calls, _ = install_fake_alexapy(file_readback=False)
129
+ with tempfile.TemporaryDirectory() as directory:
130
+ result = run(
131
+ AlexaPyGateway().login_broker("alexa_002", "t", "", "amazon.com", "NA", str(Path(directory) / "s"))
132
+ )
133
+ self.assertEqual(result, {"status": "ok"})
134
+ self.assertEqual(calls["logins"], [{"session-id": "1"}])
135
+
136
+ def test_an_exchange_that_yields_no_cookies_names_its_step(self) -> None:
137
+ calls, _ = install_fake_alexapy(file_readback=False)
138
+ sys.modules["alexapy"].AlexaLogin.exchange_token_for_cookies = _accept_without_cookies
139
+ with tempfile.TemporaryDirectory() as directory:
140
+ result = run(
141
+ AlexaPyGateway().login_broker("alexa_002", "t", "", "amazon.com", "NA", str(Path(directory) / "s"))
142
+ )
143
+ self.assertEqual(result["status"], "failed")
144
+ self.assertIn("cookies", result["error"])
145
+ self.assertIn("exchange was accepted", result["error"])
146
+ self.assertEqual(calls["logins"], [])
147
+
116
148
  def test_with_no_token_only_the_stored_jar_is_tried(self) -> None:
117
149
  calls, _ = install_fake_alexapy(stored_cookies={"session-id": "1"})
118
150
  with tempfile.TemporaryDirectory() as directory: