@camstack/addon-export-alexa 1.2.133 → 1.2.134

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.
@@ -49844,6 +49844,9 @@ function parseNonNegativeInt(s) {
49844
49844
  /**
49845
49845
  * Shared Alexa `Alexa/ErrorResponse` envelope builder.
49846
49846
  *
49847
+ * Certification checks the shape: `correlationToken` (set here) and
49848
+ * `endpoint` (set by `withErrorEndpoint` in the router) are both required.
49849
+ *
49847
49850
  * Lives in its own module so every handler (and the directive router)
49848
49851
  * can emit its own namespace-appropriate error without circular imports.
49849
49852
  */
@@ -49852,8 +49855,10 @@ object({ event: object({
49852
49855
  namespace: literal("Alexa"),
49853
49856
  name: literal("ErrorResponse"),
49854
49857
  messageId: string(),
49858
+ correlationToken: string().optional(),
49855
49859
  payloadVersion: literal("3")
49856
49860
  }),
49861
+ endpoint: object({ endpointId: string() }).optional(),
49857
49862
  payload: object({
49858
49863
  type: string(),
49859
49864
  message: string()
@@ -49865,6 +49870,7 @@ function errorEnvelope(header, type, message) {
49865
49870
  namespace: "Alexa",
49866
49871
  name: "ErrorResponse",
49867
49872
  messageId: header.messageId,
49873
+ ...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
49868
49874
  payloadVersion: "3"
49869
49875
  },
49870
49876
  payload: {
@@ -50225,18 +50231,43 @@ async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps,
50225
50231
  },
50226
50232
  meta: { endpointId: endpoint.endpointId }
50227
50233
  });
50234
+ return sessionEvent("SessionDisconnected", header, endpoint.endpointId, parsed.data.payload.sessionId);
50235
+ }
50236
+ /**
50237
+ * The response to `SessionConnected` / `SessionDisconnected` is the event of
50238
+ * the same name with the session id (alexa-rtcsessioncontroller) — not a
50239
+ * generic `Alexa.Response`.
50240
+ */
50241
+ function sessionEvent(name, header, endpointId, sessionId) {
50228
50242
  return { event: {
50229
50243
  header: {
50230
- namespace: "Alexa",
50231
- name: "Response",
50244
+ namespace: "Alexa.RTCSessionController",
50245
+ name,
50232
50246
  messageId: header.messageId,
50233
50247
  correlationToken: header.correlationToken,
50234
50248
  payloadVersion: "3"
50235
50249
  },
50236
- endpoint: { endpointId: endpoint.endpointId },
50237
- payload: {}
50250
+ endpoint: { endpointId },
50251
+ payload: { sessionId }
50238
50252
  } };
50239
50253
  }
50254
+ /**
50255
+ * `SessionConnected` — Alexa reports the media path is up. Nothing to do
50256
+ * hub-side (the session already exists from the offer); acknowledge it. Amazon
50257
+ * notes it is not always sent, so nothing may depend on it.
50258
+ */
50259
+ function handleSessionConnected(header, endpointRaw, payloadRaw, deps) {
50260
+ const parsed = SessionDisconnectedDirectiveSchema.safeParse({
50261
+ endpoint: endpointRaw,
50262
+ payload: payloadRaw ?? {}
50263
+ });
50264
+ if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
50265
+ deps.logger.info("alexa rtc-session: SessionConnected", {
50266
+ tags: { sessionId: parsed.data.payload.sessionId },
50267
+ meta: { endpointId: parsed.data.endpoint.endpointId }
50268
+ });
50269
+ return sessionEvent("SessionConnected", header, parsed.data.endpoint.endpointId, parsed.data.payload.sessionId);
50270
+ }
50240
50271
  //#endregion
50241
50272
  //#region src/broker-client.ts
50242
50273
  /**
@@ -50442,6 +50473,24 @@ var CAMERA_STREAM_CONTROLLER = {
50442
50473
  audioCodecs: ["AAC"]
50443
50474
  }]
50444
50475
  };
50476
+ /**
50477
+ * `Alexa.EndpointHealth` — REQUIRED on a sensor endpoint (alexa-endpointhealth;
50478
+ * alexa-motionsensor: "Must also implement `Alexa.EndpointHealth`"), and every
50479
+ * camera endpoint may carry `Alexa.MotionSensor`. Declared on every camera so
50480
+ * Alexa can tell an offline camera from a broken one: `connectivity` comes
50481
+ * from the `device-status` cap in ReportState and is pushed as a ChangeReport
50482
+ * on `DeviceOnline` / `DeviceOffline` by the Event Gateway.
50483
+ */
50484
+ var ENDPOINT_HEALTH = {
50485
+ type: "AlexaInterface",
50486
+ interface: "Alexa.EndpointHealth",
50487
+ version: "3",
50488
+ properties: {
50489
+ supported: [{ name: "connectivity" }],
50490
+ proactivelyReported: true,
50491
+ retrievable: true
50492
+ }
50493
+ };
50445
50494
  var DOORBELL_EVENT_SOURCE = {
50446
50495
  type: "AlexaInterface",
50447
50496
  interface: "Alexa.DoorbellEventSource",
@@ -50643,7 +50692,8 @@ function buildParentCameraEndpoint(input) {
50643
50692
  const capabilities = [
50644
50693
  ALEXA_BASE_CAPABILITY,
50645
50694
  CAMERA_STREAM_CONTROLLER,
50646
- rtcSessionController(input.hasIntercom)
50695
+ rtcSessionController(input.hasIntercom),
50696
+ ENDPOINT_HEALTH
50647
50697
  ];
50648
50698
  if (input.isDoorbell) capabilities.push(DOORBELL_EVENT_SOURCE);
50649
50699
  if (input.hasMotion) capabilities.push(MOTION_SENSOR);
@@ -51467,6 +51517,20 @@ async function collectCameraProperties(api, deviceId, properties, timeOfSample,
51467
51517
  timeOfSample,
51468
51518
  uncertaintyInMilliseconds: 0
51469
51519
  });
51520
+ const online = boolField(await api.deviceStatus.getStatus.query({ deviceId }).catch((err) => {
51521
+ logger.warn("alexa report-state: deviceStatus.getStatus failed — connectivity omitted", {
51522
+ tags: { deviceId },
51523
+ meta: { error: err instanceof Error ? err.message : String(err) }
51524
+ });
51525
+ return null;
51526
+ }), "online");
51527
+ if (online !== null) properties.push({
51528
+ namespace: "Alexa.EndpointHealth",
51529
+ name: "connectivity",
51530
+ value: { value: online ? "OK" : "UNREACHABLE" },
51531
+ timeOfSample,
51532
+ uncertaintyInMilliseconds: 0
51533
+ });
51470
51534
  const detected = boolField(await api.motion.getStatus.query({ deviceId }).catch((err) => {
51471
51535
  logger.info("alexa report-state: motion.getStatus skipped", { meta: {
51472
51536
  deviceId,
@@ -51849,7 +51913,37 @@ function assertDeviceScope(scopes) {
51849
51913
  function requireDeviceScope(scopes, access) {
51850
51914
  if (!scopesAllowDeviceCap(scopes, access)) throw new Error(`directive token not authorized for device ${access}`);
51851
51915
  }
51916
+ /**
51917
+ * Give an `Alexa.ErrorResponse` the directive's endpoint when it lacks one.
51918
+ * Amazon requires `endpoint` on every ErrorResponse to an endpoint directive
51919
+ * (alexa-errorresponse); the handlers build their errors from the header
51920
+ * alone, so the router — which holds the endpoint for every path — adds it.
51921
+ */
51922
+ function withErrorEndpoint(body, endpoint) {
51923
+ const endpointId = typeof endpoint === "object" && endpoint !== null && typeof endpoint.endpointId === "string" ? endpoint.endpointId : null;
51924
+ if (endpointId === null || typeof body !== "object" || body === null) return body;
51925
+ const event = body.event;
51926
+ if (typeof event !== "object" || event === null) return body;
51927
+ const ev = event;
51928
+ if (ev.header?.namespace !== "Alexa" || ev.header?.name !== "ErrorResponse") return body;
51929
+ if (ev.endpoint !== void 0) return body;
51930
+ return {
51931
+ ...body,
51932
+ event: {
51933
+ ...ev,
51934
+ endpoint: { endpointId }
51935
+ }
51936
+ };
51937
+ }
51852
51938
  async function dispatchDirective(rawBody, deps, callerIdentity) {
51939
+ const result = await dispatchParsedDirective(rawBody, deps, callerIdentity);
51940
+ const endpoint = rawBody?.directive?.endpoint;
51941
+ return {
51942
+ status: result.status,
51943
+ body: withErrorEndpoint(result.body, endpoint)
51944
+ };
51945
+ }
51946
+ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
51853
51947
  const parsed = DirectiveRequestSchema.safeParse(rawBody);
51854
51948
  if (!parsed.success) return {
51855
51949
  status: 400,
@@ -51871,6 +51965,7 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
51871
51965
  "Alexa.Discovery/Discover": "view",
51872
51966
  "Alexa.CameraStreamController/InitializeCameraStreams": "view",
51873
51967
  "Alexa.RTCSessionController/InitiateSessionWithOffer": "view",
51968
+ "Alexa.RTCSessionController/SessionConnected": "view",
51874
51969
  "Alexa.RTCSessionController/SessionDisconnected": "view",
51875
51970
  "Alexa.PowerController/TurnOn": "create",
51876
51971
  "Alexa.PowerController/TurnOff": "create",
@@ -51905,6 +52000,10 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
51905
52000
  status: 200,
51906
52001
  body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity)
51907
52002
  };
52003
+ if (key === "Alexa.RTCSessionController/SessionConnected") return {
52004
+ status: 200,
52005
+ body: handleSessionConnected(header, endpoint, payload, deps.rtcSession)
52006
+ };
51908
52007
  if (key === "Alexa.RTCSessionController/SessionDisconnected") return {
51909
52008
  status: 200,
51910
52009
  body: await handleSessionDisconnected(header, endpoint, payload, deps.rtcSession, callerIdentity)
@@ -52411,10 +52510,12 @@ var DEFAULT_MOTION_CLEAR_HOLD_MS = 5e3;
52411
52510
  var MotionClearHold = class {
52412
52511
  entries = /* @__PURE__ */ new Map();
52413
52512
  holdMs;
52513
+ minDetectedMs;
52414
52514
  setTimer;
52415
52515
  clearTimer;
52416
52516
  constructor(options) {
52417
52517
  this.holdMs = options.holdMs;
52518
+ this.minDetectedMs = options.minDetectedMs ?? 3e4;
52418
52519
  this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
52419
52520
  this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
52420
52521
  }
@@ -52432,13 +52533,15 @@ var MotionClearHold = class {
52432
52533
  if (entry.timer !== null) this.clearTimer(entry.timer);
52433
52534
  this.entries.set(key, {
52434
52535
  state: "detected",
52435
- timer: null
52536
+ timer: null,
52537
+ detectedAt: entry.detectedAt
52436
52538
  });
52437
52539
  return false;
52438
52540
  }
52439
52541
  this.entries.set(key, {
52440
52542
  state: "detected",
52441
- timer: null
52543
+ timer: null,
52544
+ detectedAt: Date.now()
52442
52545
  });
52443
52546
  return true;
52444
52547
  }
@@ -52459,16 +52562,21 @@ var MotionClearHold = class {
52459
52562
  onCleared(key, release) {
52460
52563
  const entry = this.entries.get(key);
52461
52564
  if (entry !== void 0 && entry.state !== "detected") return;
52565
+ const detectedAt = entry?.detectedAt ?? null;
52566
+ const sinceDetected = detectedAt === null ? 0 : Date.now() - detectedAt;
52567
+ const delay = Math.max(this.holdMs, this.minDetectedMs - sinceDetected);
52462
52568
  const timer = this.setTimer(() => {
52463
52569
  this.entries.set(key, {
52464
52570
  state: "idle",
52465
- timer: null
52571
+ timer: null,
52572
+ detectedAt: null
52466
52573
  });
52467
52574
  release();
52468
- }, this.holdMs);
52575
+ }, delay);
52469
52576
  this.entries.set(key, {
52470
52577
  state: "holding",
52471
- timer
52578
+ timer,
52579
+ detectedAt
52472
52580
  });
52473
52581
  }
52474
52582
  /** What Alexa currently believes for this key — for logs and assertions. */
@@ -52522,6 +52630,9 @@ var REFRESH_PROACTIVE_WINDOW_MS = 300 * 1e3;
52522
52630
  var REFRESH_SCAN_INTERVAL_MS = 60 * 1e3;
52523
52631
  /** After a failed legacy-grant migration, wait this long before trying again. */
52524
52632
  var MIGRATION_RETRY_MS = 300 * 1e3;
52633
+ /** The interface a camera endpoint must declare for a connectivity
52634
+ * ChangeReport to mean anything to the Alexa cloud. */
52635
+ var ENDPOINT_HEALTH_INTERFACE = "Alexa.EndpointHealth";
52525
52636
  /** The interface an endpoint must advertise for a `DoorbellPress` to survive
52526
52637
  * the Alexa cloud. */
52527
52638
  var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
@@ -52553,6 +52664,10 @@ var LwaEventGateway = class {
52553
52664
  unsubscribe = null;
52554
52665
  unsubscribeMotion = null;
52555
52666
  unsubscribeBattery = null;
52667
+ unsubscribeConnectivity = [];
52668
+ /** Last connectivity reported per `userId|endpointId` — a camera that
52669
+ * re-announces the same state must not become a ChangeReport stream. */
52670
+ lastConnectivity = /* @__PURE__ */ new Map();
52556
52671
  refreshTimer = null;
52557
52672
  lastSentAt = /* @__PURE__ */ new Map();
52558
52673
  /**
@@ -52601,6 +52716,11 @@ var LwaEventGateway = class {
52601
52716
  this.deps.logger.warn("alexa lwa-gateway: battery handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
52602
52717
  });
52603
52718
  });
52719
+ this.unsubscribeConnectivity = [[EventCategory.DeviceOnline, "OK"], [EventCategory.DeviceOffline, "UNREACHABLE"]].map(([category, connectivity]) => this.deps.ctx.eventBus.subscribe({ category }, (event) => {
52720
+ this.onConnectivityChanged(event.data, connectivity).catch((err) => {
52721
+ this.deps.logger.warn("alexa lwa-gateway: connectivity handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
52722
+ });
52723
+ }));
52604
52724
  this.refreshTimer = setInterval(() => {
52605
52725
  this.refreshExpiringTokens().catch((err) => {
52606
52726
  this.deps.logger.warn("alexa lwa-gateway: token refresh scan crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
@@ -52615,6 +52735,9 @@ var LwaEventGateway = class {
52615
52735
  this.unsubscribeMotion = null;
52616
52736
  if (this.unsubscribeBattery) this.unsubscribeBattery();
52617
52737
  this.unsubscribeBattery = null;
52738
+ for (const off of this.unsubscribeConnectivity) off();
52739
+ this.unsubscribeConnectivity = [];
52740
+ this.lastConnectivity.clear();
52618
52741
  if (this.refreshTimer) clearInterval(this.refreshTimer);
52619
52742
  this.refreshTimer = null;
52620
52743
  this.lastSentAt.clear();
@@ -52819,6 +52942,33 @@ var LwaEventGateway = class {
52819
52942
  }
52820
52943
  }
52821
52944
  /**
52945
+ * A camera went offline or came back. Amazon requires the `connectivity`
52946
+ * ChangeReport within 3 s of the change (alexa-endpointhealth). Deduped per
52947
+ * account and endpoint, and skipped for an endpoint Alexa is known to hold
52948
+ * WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
52949
+ */
52950
+ async onConnectivityChanged(data, connectivity) {
52951
+ if (typeof data?.deviceId !== "number") return;
52952
+ if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
52953
+ const signature = this.deps.getExportedSignature(data.deviceId);
52954
+ if (signature !== null && !signature.includes(ENDPOINT_HEALTH_INTERFACE)) return;
52955
+ const linked = this.deps.listLinkedAccounts();
52956
+ if (linked.length === 0) return;
52957
+ if (!this.hasProactiveCredentials()) return;
52958
+ const endpointId = String(data.deviceId);
52959
+ for (const account of linked) {
52960
+ const key = `${account.userId}|${endpointId}`;
52961
+ if (this.lastConnectivity.get(key) === connectivity) continue;
52962
+ this.lastConnectivity.set(key, connectivity);
52963
+ await this.postEventBody(account.userId, endpointId, (accessToken) => buildConnectivityChangeReportPayload({
52964
+ endpointId,
52965
+ connectivity,
52966
+ timestamp: Date.now(),
52967
+ accessToken
52968
+ }), `connectivity ${connectivity}`);
52969
+ }
52970
+ }
52971
+ /**
52822
52972
  * Send a one-shot `DeleteReport` for arbitrary endpoint ids per linked
52823
52973
  * account. Used to clean up stale entries Alexa still shows from prior
52824
52974
  * syncs (different addon version, different account, or an aborted
@@ -53139,6 +53289,36 @@ function buildBatteryChangeReportPayload(opts) {
53139
53289
  context: { properties: [] }
53140
53290
  };
53141
53291
  }
53292
+ function buildConnectivityChangeReportPayload(opts) {
53293
+ return {
53294
+ event: {
53295
+ header: {
53296
+ namespace: "Alexa",
53297
+ name: "ChangeReport",
53298
+ messageId: (0, node_crypto.randomUUID)(),
53299
+ payloadVersion: "3"
53300
+ },
53301
+ endpoint: {
53302
+ scope: {
53303
+ type: "BearerToken",
53304
+ token: opts.accessToken
53305
+ },
53306
+ endpointId: opts.endpointId
53307
+ },
53308
+ payload: { change: {
53309
+ cause: { type: "PERIODIC_POLL" },
53310
+ properties: [{
53311
+ namespace: "Alexa.EndpointHealth",
53312
+ name: "connectivity",
53313
+ value: { value: opts.connectivity },
53314
+ timeOfSample: new Date(opts.timestamp).toISOString(),
53315
+ uncertaintyInMilliseconds: 0
53316
+ }]
53317
+ } }
53318
+ },
53319
+ context: { properties: [] }
53320
+ };
53321
+ }
53142
53322
  function buildSmartVisionObjectDetectionEvent(opts) {
53143
53323
  const timeOfSample = new Date(opts.timestamp).toISOString();
53144
53324
  return { event: {
@@ -1,4 +1,5 @@
1
1
  import * as crypto$1 from "node:crypto";
2
+ import { randomUUID } from "node:crypto";
2
3
  //#region ../types/dist/event-category-BGmz_oxW.mjs
3
4
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
4
5
  EventCategory["SystemBoot"] = "system.boot";
@@ -49817,6 +49818,9 @@ function parseNonNegativeInt(s) {
49817
49818
  /**
49818
49819
  * Shared Alexa `Alexa/ErrorResponse` envelope builder.
49819
49820
  *
49821
+ * Certification checks the shape: `correlationToken` (set here) and
49822
+ * `endpoint` (set by `withErrorEndpoint` in the router) are both required.
49823
+ *
49820
49824
  * Lives in its own module so every handler (and the directive router)
49821
49825
  * can emit its own namespace-appropriate error without circular imports.
49822
49826
  */
@@ -49825,8 +49829,10 @@ object({ event: object({
49825
49829
  namespace: literal("Alexa"),
49826
49830
  name: literal("ErrorResponse"),
49827
49831
  messageId: string(),
49832
+ correlationToken: string().optional(),
49828
49833
  payloadVersion: literal("3")
49829
49834
  }),
49835
+ endpoint: object({ endpointId: string() }).optional(),
49830
49836
  payload: object({
49831
49837
  type: string(),
49832
49838
  message: string()
@@ -49838,6 +49844,7 @@ function errorEnvelope(header, type, message) {
49838
49844
  namespace: "Alexa",
49839
49845
  name: "ErrorResponse",
49840
49846
  messageId: header.messageId,
49847
+ ...header.correlationToken !== void 0 ? { correlationToken: header.correlationToken } : {},
49841
49848
  payloadVersion: "3"
49842
49849
  },
49843
49850
  payload: {
@@ -50198,18 +50205,43 @@ async function handleSessionDisconnected(header, endpointRaw, payloadRaw, deps,
50198
50205
  },
50199
50206
  meta: { endpointId: endpoint.endpointId }
50200
50207
  });
50208
+ return sessionEvent("SessionDisconnected", header, endpoint.endpointId, parsed.data.payload.sessionId);
50209
+ }
50210
+ /**
50211
+ * The response to `SessionConnected` / `SessionDisconnected` is the event of
50212
+ * the same name with the session id (alexa-rtcsessioncontroller) — not a
50213
+ * generic `Alexa.Response`.
50214
+ */
50215
+ function sessionEvent(name, header, endpointId, sessionId) {
50201
50216
  return { event: {
50202
50217
  header: {
50203
- namespace: "Alexa",
50204
- name: "Response",
50218
+ namespace: "Alexa.RTCSessionController",
50219
+ name,
50205
50220
  messageId: header.messageId,
50206
50221
  correlationToken: header.correlationToken,
50207
50222
  payloadVersion: "3"
50208
50223
  },
50209
- endpoint: { endpointId: endpoint.endpointId },
50210
- payload: {}
50224
+ endpoint: { endpointId },
50225
+ payload: { sessionId }
50211
50226
  } };
50212
50227
  }
50228
+ /**
50229
+ * `SessionConnected` — Alexa reports the media path is up. Nothing to do
50230
+ * hub-side (the session already exists from the offer); acknowledge it. Amazon
50231
+ * notes it is not always sent, so nothing may depend on it.
50232
+ */
50233
+ function handleSessionConnected(header, endpointRaw, payloadRaw, deps) {
50234
+ const parsed = SessionDisconnectedDirectiveSchema.safeParse({
50235
+ endpoint: endpointRaw,
50236
+ payload: payloadRaw ?? {}
50237
+ });
50238
+ if (!parsed.success) return errorEnvelope(header, "INVALID_DIRECTIVE", `payload validation failed: ${parsed.error.message}`);
50239
+ deps.logger.info("alexa rtc-session: SessionConnected", {
50240
+ tags: { sessionId: parsed.data.payload.sessionId },
50241
+ meta: { endpointId: parsed.data.endpoint.endpointId }
50242
+ });
50243
+ return sessionEvent("SessionConnected", header, parsed.data.endpoint.endpointId, parsed.data.payload.sessionId);
50244
+ }
50213
50245
  //#endregion
50214
50246
  //#region src/broker-client.ts
50215
50247
  /**
@@ -50415,6 +50447,24 @@ var CAMERA_STREAM_CONTROLLER = {
50415
50447
  audioCodecs: ["AAC"]
50416
50448
  }]
50417
50449
  };
50450
+ /**
50451
+ * `Alexa.EndpointHealth` — REQUIRED on a sensor endpoint (alexa-endpointhealth;
50452
+ * alexa-motionsensor: "Must also implement `Alexa.EndpointHealth`"), and every
50453
+ * camera endpoint may carry `Alexa.MotionSensor`. Declared on every camera so
50454
+ * Alexa can tell an offline camera from a broken one: `connectivity` comes
50455
+ * from the `device-status` cap in ReportState and is pushed as a ChangeReport
50456
+ * on `DeviceOnline` / `DeviceOffline` by the Event Gateway.
50457
+ */
50458
+ var ENDPOINT_HEALTH = {
50459
+ type: "AlexaInterface",
50460
+ interface: "Alexa.EndpointHealth",
50461
+ version: "3",
50462
+ properties: {
50463
+ supported: [{ name: "connectivity" }],
50464
+ proactivelyReported: true,
50465
+ retrievable: true
50466
+ }
50467
+ };
50418
50468
  var DOORBELL_EVENT_SOURCE = {
50419
50469
  type: "AlexaInterface",
50420
50470
  interface: "Alexa.DoorbellEventSource",
@@ -50616,7 +50666,8 @@ function buildParentCameraEndpoint(input) {
50616
50666
  const capabilities = [
50617
50667
  ALEXA_BASE_CAPABILITY,
50618
50668
  CAMERA_STREAM_CONTROLLER,
50619
- rtcSessionController(input.hasIntercom)
50669
+ rtcSessionController(input.hasIntercom),
50670
+ ENDPOINT_HEALTH
50620
50671
  ];
50621
50672
  if (input.isDoorbell) capabilities.push(DOORBELL_EVENT_SOURCE);
50622
50673
  if (input.hasMotion) capabilities.push(MOTION_SENSOR);
@@ -51440,6 +51491,20 @@ async function collectCameraProperties(api, deviceId, properties, timeOfSample,
51440
51491
  timeOfSample,
51441
51492
  uncertaintyInMilliseconds: 0
51442
51493
  });
51494
+ const online = boolField(await api.deviceStatus.getStatus.query({ deviceId }).catch((err) => {
51495
+ logger.warn("alexa report-state: deviceStatus.getStatus failed — connectivity omitted", {
51496
+ tags: { deviceId },
51497
+ meta: { error: err instanceof Error ? err.message : String(err) }
51498
+ });
51499
+ return null;
51500
+ }), "online");
51501
+ if (online !== null) properties.push({
51502
+ namespace: "Alexa.EndpointHealth",
51503
+ name: "connectivity",
51504
+ value: { value: online ? "OK" : "UNREACHABLE" },
51505
+ timeOfSample,
51506
+ uncertaintyInMilliseconds: 0
51507
+ });
51443
51508
  const detected = boolField(await api.motion.getStatus.query({ deviceId }).catch((err) => {
51444
51509
  logger.info("alexa report-state: motion.getStatus skipped", { meta: {
51445
51510
  deviceId,
@@ -51822,7 +51887,37 @@ function assertDeviceScope(scopes) {
51822
51887
  function requireDeviceScope(scopes, access) {
51823
51888
  if (!scopesAllowDeviceCap(scopes, access)) throw new Error(`directive token not authorized for device ${access}`);
51824
51889
  }
51890
+ /**
51891
+ * Give an `Alexa.ErrorResponse` the directive's endpoint when it lacks one.
51892
+ * Amazon requires `endpoint` on every ErrorResponse to an endpoint directive
51893
+ * (alexa-errorresponse); the handlers build their errors from the header
51894
+ * alone, so the router — which holds the endpoint for every path — adds it.
51895
+ */
51896
+ function withErrorEndpoint(body, endpoint) {
51897
+ const endpointId = typeof endpoint === "object" && endpoint !== null && typeof endpoint.endpointId === "string" ? endpoint.endpointId : null;
51898
+ if (endpointId === null || typeof body !== "object" || body === null) return body;
51899
+ const event = body.event;
51900
+ if (typeof event !== "object" || event === null) return body;
51901
+ const ev = event;
51902
+ if (ev.header?.namespace !== "Alexa" || ev.header?.name !== "ErrorResponse") return body;
51903
+ if (ev.endpoint !== void 0) return body;
51904
+ return {
51905
+ ...body,
51906
+ event: {
51907
+ ...ev,
51908
+ endpoint: { endpointId }
51909
+ }
51910
+ };
51911
+ }
51825
51912
  async function dispatchDirective(rawBody, deps, callerIdentity) {
51913
+ const result = await dispatchParsedDirective(rawBody, deps, callerIdentity);
51914
+ const endpoint = rawBody?.directive?.endpoint;
51915
+ return {
51916
+ status: result.status,
51917
+ body: withErrorEndpoint(result.body, endpoint)
51918
+ };
51919
+ }
51920
+ async function dispatchParsedDirective(rawBody, deps, callerIdentity) {
51826
51921
  const parsed = DirectiveRequestSchema.safeParse(rawBody);
51827
51922
  if (!parsed.success) return {
51828
51923
  status: 400,
@@ -51844,6 +51939,7 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
51844
51939
  "Alexa.Discovery/Discover": "view",
51845
51940
  "Alexa.CameraStreamController/InitializeCameraStreams": "view",
51846
51941
  "Alexa.RTCSessionController/InitiateSessionWithOffer": "view",
51942
+ "Alexa.RTCSessionController/SessionConnected": "view",
51847
51943
  "Alexa.RTCSessionController/SessionDisconnected": "view",
51848
51944
  "Alexa.PowerController/TurnOn": "create",
51849
51945
  "Alexa.PowerController/TurnOff": "create",
@@ -51878,6 +51974,10 @@ async function dispatchDirective(rawBody, deps, callerIdentity) {
51878
51974
  status: 200,
51879
51975
  body: await handleInitiateSessionWithOffer(header, endpoint, payload, deps.rtcSession, callerIdentity)
51880
51976
  };
51977
+ if (key === "Alexa.RTCSessionController/SessionConnected") return {
51978
+ status: 200,
51979
+ body: handleSessionConnected(header, endpoint, payload, deps.rtcSession)
51980
+ };
51881
51981
  if (key === "Alexa.RTCSessionController/SessionDisconnected") return {
51882
51982
  status: 200,
51883
51983
  body: await handleSessionDisconnected(header, endpoint, payload, deps.rtcSession, callerIdentity)
@@ -52384,10 +52484,12 @@ var DEFAULT_MOTION_CLEAR_HOLD_MS = 5e3;
52384
52484
  var MotionClearHold = class {
52385
52485
  entries = /* @__PURE__ */ new Map();
52386
52486
  holdMs;
52487
+ minDetectedMs;
52387
52488
  setTimer;
52388
52489
  clearTimer;
52389
52490
  constructor(options) {
52390
52491
  this.holdMs = options.holdMs;
52492
+ this.minDetectedMs = options.minDetectedMs ?? 3e4;
52391
52493
  this.setTimer = options.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
52392
52494
  this.clearTimer = options.clearTimer ?? ((handle) => clearTimeout(handle));
52393
52495
  }
@@ -52405,13 +52507,15 @@ var MotionClearHold = class {
52405
52507
  if (entry.timer !== null) this.clearTimer(entry.timer);
52406
52508
  this.entries.set(key, {
52407
52509
  state: "detected",
52408
- timer: null
52510
+ timer: null,
52511
+ detectedAt: entry.detectedAt
52409
52512
  });
52410
52513
  return false;
52411
52514
  }
52412
52515
  this.entries.set(key, {
52413
52516
  state: "detected",
52414
- timer: null
52517
+ timer: null,
52518
+ detectedAt: Date.now()
52415
52519
  });
52416
52520
  return true;
52417
52521
  }
@@ -52432,16 +52536,21 @@ var MotionClearHold = class {
52432
52536
  onCleared(key, release) {
52433
52537
  const entry = this.entries.get(key);
52434
52538
  if (entry !== void 0 && entry.state !== "detected") return;
52539
+ const detectedAt = entry?.detectedAt ?? null;
52540
+ const sinceDetected = detectedAt === null ? 0 : Date.now() - detectedAt;
52541
+ const delay = Math.max(this.holdMs, this.minDetectedMs - sinceDetected);
52435
52542
  const timer = this.setTimer(() => {
52436
52543
  this.entries.set(key, {
52437
52544
  state: "idle",
52438
- timer: null
52545
+ timer: null,
52546
+ detectedAt: null
52439
52547
  });
52440
52548
  release();
52441
- }, this.holdMs);
52549
+ }, delay);
52442
52550
  this.entries.set(key, {
52443
52551
  state: "holding",
52444
- timer
52552
+ timer,
52553
+ detectedAt
52445
52554
  });
52446
52555
  }
52447
52556
  /** What Alexa currently believes for this key — for logs and assertions. */
@@ -52495,6 +52604,9 @@ var REFRESH_PROACTIVE_WINDOW_MS = 300 * 1e3;
52495
52604
  var REFRESH_SCAN_INTERVAL_MS = 60 * 1e3;
52496
52605
  /** After a failed legacy-grant migration, wait this long before trying again. */
52497
52606
  var MIGRATION_RETRY_MS = 300 * 1e3;
52607
+ /** The interface a camera endpoint must declare for a connectivity
52608
+ * ChangeReport to mean anything to the Alexa cloud. */
52609
+ var ENDPOINT_HEALTH_INTERFACE = "Alexa.EndpointHealth";
52498
52610
  /** The interface an endpoint must advertise for a `DoorbellPress` to survive
52499
52611
  * the Alexa cloud. */
52500
52612
  var DOORBELL_INTERFACE = "Alexa.DoorbellEventSource";
@@ -52526,6 +52638,10 @@ var LwaEventGateway = class {
52526
52638
  unsubscribe = null;
52527
52639
  unsubscribeMotion = null;
52528
52640
  unsubscribeBattery = null;
52641
+ unsubscribeConnectivity = [];
52642
+ /** Last connectivity reported per `userId|endpointId` — a camera that
52643
+ * re-announces the same state must not become a ChangeReport stream. */
52644
+ lastConnectivity = /* @__PURE__ */ new Map();
52529
52645
  refreshTimer = null;
52530
52646
  lastSentAt = /* @__PURE__ */ new Map();
52531
52647
  /**
@@ -52574,6 +52690,11 @@ var LwaEventGateway = class {
52574
52690
  this.deps.logger.warn("alexa lwa-gateway: battery handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
52575
52691
  });
52576
52692
  });
52693
+ this.unsubscribeConnectivity = [[EventCategory.DeviceOnline, "OK"], [EventCategory.DeviceOffline, "UNREACHABLE"]].map(([category, connectivity]) => this.deps.ctx.eventBus.subscribe({ category }, (event) => {
52694
+ this.onConnectivityChanged(event.data, connectivity).catch((err) => {
52695
+ this.deps.logger.warn("alexa lwa-gateway: connectivity handler crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
52696
+ });
52697
+ }));
52577
52698
  this.refreshTimer = setInterval(() => {
52578
52699
  this.refreshExpiringTokens().catch((err) => {
52579
52700
  this.deps.logger.warn("alexa lwa-gateway: token refresh scan crashed", { meta: { error: err instanceof Error ? err.message : String(err) } });
@@ -52588,6 +52709,9 @@ var LwaEventGateway = class {
52588
52709
  this.unsubscribeMotion = null;
52589
52710
  if (this.unsubscribeBattery) this.unsubscribeBattery();
52590
52711
  this.unsubscribeBattery = null;
52712
+ for (const off of this.unsubscribeConnectivity) off();
52713
+ this.unsubscribeConnectivity = [];
52714
+ this.lastConnectivity.clear();
52591
52715
  if (this.refreshTimer) clearInterval(this.refreshTimer);
52592
52716
  this.refreshTimer = null;
52593
52717
  this.lastSentAt.clear();
@@ -52792,6 +52916,33 @@ var LwaEventGateway = class {
52792
52916
  }
52793
52917
  }
52794
52918
  /**
52919
+ * A camera went offline or came back. Amazon requires the `connectivity`
52920
+ * ChangeReport within 3 s of the change (alexa-endpointhealth). Deduped per
52921
+ * account and endpoint, and skipped for an endpoint Alexa is known to hold
52922
+ * WITHOUT `Alexa.EndpointHealth` — the cloud would drop it (D76).
52923
+ */
52924
+ async onConnectivityChanged(data, connectivity) {
52925
+ if (typeof data?.deviceId !== "number") return;
52926
+ if (!this.deps.listExposed().some((e) => e.deviceId === data.deviceId)) return;
52927
+ const signature = this.deps.getExportedSignature(data.deviceId);
52928
+ if (signature !== null && !signature.includes(ENDPOINT_HEALTH_INTERFACE)) return;
52929
+ const linked = this.deps.listLinkedAccounts();
52930
+ if (linked.length === 0) return;
52931
+ if (!this.hasProactiveCredentials()) return;
52932
+ const endpointId = String(data.deviceId);
52933
+ for (const account of linked) {
52934
+ const key = `${account.userId}|${endpointId}`;
52935
+ if (this.lastConnectivity.get(key) === connectivity) continue;
52936
+ this.lastConnectivity.set(key, connectivity);
52937
+ await this.postEventBody(account.userId, endpointId, (accessToken) => buildConnectivityChangeReportPayload({
52938
+ endpointId,
52939
+ connectivity,
52940
+ timestamp: Date.now(),
52941
+ accessToken
52942
+ }), `connectivity ${connectivity}`);
52943
+ }
52944
+ }
52945
+ /**
52795
52946
  * Send a one-shot `DeleteReport` for arbitrary endpoint ids per linked
52796
52947
  * account. Used to clean up stale entries Alexa still shows from prior
52797
52948
  * syncs (different addon version, different account, or an aborted
@@ -53112,6 +53263,36 @@ function buildBatteryChangeReportPayload(opts) {
53112
53263
  context: { properties: [] }
53113
53264
  };
53114
53265
  }
53266
+ function buildConnectivityChangeReportPayload(opts) {
53267
+ return {
53268
+ event: {
53269
+ header: {
53270
+ namespace: "Alexa",
53271
+ name: "ChangeReport",
53272
+ messageId: randomUUID(),
53273
+ payloadVersion: "3"
53274
+ },
53275
+ endpoint: {
53276
+ scope: {
53277
+ type: "BearerToken",
53278
+ token: opts.accessToken
53279
+ },
53280
+ endpointId: opts.endpointId
53281
+ },
53282
+ payload: { change: {
53283
+ cause: { type: "PERIODIC_POLL" },
53284
+ properties: [{
53285
+ namespace: "Alexa.EndpointHealth",
53286
+ name: "connectivity",
53287
+ value: { value: opts.connectivity },
53288
+ timeOfSample: new Date(opts.timestamp).toISOString(),
53289
+ uncertaintyInMilliseconds: 0
53290
+ }]
53291
+ } }
53292
+ },
53293
+ context: { properties: [] }
53294
+ };
53295
+ }
53115
53296
  function buildSmartVisionObjectDetectionEvent(opts) {
53116
53297
  const timeOfSample = new Date(opts.timestamp).toISOString();
53117
53298
  return { event: {
@@ -22,6 +22,14 @@ The same code runs the built-in CamStack skill and any operator's own
22
22
  | `POST <FUNCTION_URL>/broker/rotate` | `Bearer <credential>` → `{ brokerCredential }`; the old one stops working. |
23
23
  | `POST <FUNCTION_URL>/broker/revoke` | `Bearer <credential>` → deletes the link (and the refresh token). |
24
24
 
25
+ Errors Alexa receives follow the Smart Home contract: every `Alexa.ErrorResponse`
26
+ carries the directive's `correlationToken` and `endpoint`; a hub that cannot be
27
+ reached (network error, or a tunnel's 502–504 / 52x page) is
28
+ `BRIDGE_UNREACHABLE`; a hub reply that is not an Alexa event becomes
29
+ `INTERNAL_ERROR`; AcceptGrant failures are `Alexa.Authorization` /
30
+ `ACCEPT_GRANT_FAILED`. The `/authorize` page accepts only an `https://` hub URL
31
+ and shows its error on the page.
32
+
25
33
  ### The two credential pairs
26
34
 
27
35
  | Pair | Secrets Manager id | Authenticates | Who else holds it |
@@ -55,6 +55,8 @@ const { createSecretSource } = require('./secrets.js')
55
55
  const { createDynamoTokenStore } = require('./token-store.js')
56
56
  const {
57
57
  decodeBody,
58
+ acceptGrantErrorEnvelope,
59
+ completeErrorResponse,
58
60
  decodeJwtPayload,
59
61
  describe,
60
62
  encodeFormUrlEncoded,
@@ -121,7 +123,12 @@ function createHandler(overrides) {
121
123
  } catch (err) {
122
124
  deps.log.error('camstack-alexa: unhandled error', describe(err))
123
125
  if (event && event.directive && event.directive.header) {
124
- return errorEnvelope(event.directive.header, 'INTERNAL_ERROR', describe(err))
126
+ return errorEnvelope(
127
+ event.directive.header,
128
+ 'INTERNAL_ERROR',
129
+ describe(err),
130
+ event.directive.endpoint,
131
+ )
125
132
  }
126
133
  return jsonResponse(500, { error: 'internal_error', message: describe(err) })
127
134
  }
@@ -164,25 +171,36 @@ function handleAuthorizePage(event) {
164
171
  button { margin-top: 1rem; width: 100%; padding: 0.7rem; font-size: 0.95rem; font-weight: 500; background: #2563eb; color: white; border: none; border-radius: 0.375rem; cursor: pointer; }
165
172
  button:hover { background: #1d4ed8; }
166
173
  .hint { font-size: 0.8rem; color: #6b7280; margin-top: 0.5rem; }
174
+ .err { font-size: 0.85rem; color: #b91c1c; margin: 0.75rem 0 0; }
167
175
  </style>
168
176
  </head>
169
177
  <body>
170
178
  <div class="card">
171
179
  <h1>Link CamStack to Alexa</h1>
172
180
  <p>Enter the public HTTPS URL of your CamStack hub. Alexa will then ask the hub to authorize this Echo.</p>
173
- <form id="f">
181
+ <form id="f" novalidate>
174
182
  <label for="hubUrl">Hub URL</label>
175
183
  <input id="hubUrl" name="hubUrl" type="url" placeholder="https://camstack.example.com" required autofocus>
176
184
  <p class="hint">Must be reachable from the public internet (Cloudflare Tunnel, Tailscale Funnel, etc.).</p>
185
+ <p id="err" class="err" role="alert" hidden></p>
177
186
  <button type="submit">Continue</button>
178
187
  </form>
179
188
  </div>
180
189
  <script>
181
190
  document.getElementById('f').addEventListener('submit', function (ev) {
182
191
  ev.preventDefault()
183
- var hubUrl = document.getElementById('hubUrl').value.trim().replace(/\\/$/, '')
184
- if (!hubUrl) return
185
- var qs = ${JSON.stringify(qs)}
192
+ var err = document.getElementById('err')
193
+ var hubUrl = document.getElementById('hubUrl').value.trim().replace(/\\/+$/, '')
194
+ var parsed = null
195
+ try { parsed = new URL(hubUrl) } catch (e) { parsed = null }
196
+ if (!parsed || parsed.protocol !== 'https:' || !parsed.hostname) {
197
+ err.textContent = 'Enter the full address of your hub, starting with https:// (for example https://camstack.example.com).'
198
+ err.hidden = false
199
+ return
200
+ }
201
+ err.hidden = true
202
+ hubUrl = parsed.origin + parsed.pathname.replace(/\\/+$/, '')
203
+ var qs = ${jsonForScript(qs)}
186
204
  var url = hubUrl + '/api/oauth2/authorize' + '?' + (qs ? qs + '&' : '') + 'integration=export-alexa'
187
205
  window.location.href = url
188
206
  })
@@ -196,6 +214,11 @@ function handleAuthorizePage(event) {
196
214
  }
197
215
  }
198
216
 
217
+ /** JSON for an inline <script>: `<` escaped so the value cannot close the tag. */
218
+ function jsonForScript(value) {
219
+ return JSON.stringify(value).replace(/</g, '\\u003c')
220
+ }
221
+
199
222
  /** Constant-time string equality that does not leak length via timing. */
200
223
  function safeEqual(a, b) {
201
224
  const ab = Buffer.from(String(a), 'utf8')
@@ -310,22 +333,22 @@ async function handleAcceptGrant(event, deps) {
310
333
  const grantCode = payload.grant && payload.grant.code
311
334
  const granteeToken = payload.grantee && payload.grantee.token
312
335
  if (!grantCode || !granteeToken) {
313
- return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'payload missing grant.code or grantee.token')
336
+ return acceptGrantErrorEnvelope(header, 'payload missing grant.code or grantee.token')
314
337
  }
315
338
  const claims = decodeJwtPayload(granteeToken)
316
339
  if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
317
- return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'grantee token has no hubUrl claim')
340
+ return acceptGrantErrorEnvelope(header, 'grantee token has no hubUrl claim')
318
341
  }
319
342
  const redirectUri = deps.env.LAMBDA_REDIRECT_URI
320
343
  if (!redirectUri) {
321
- return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', 'LAMBDA_REDIRECT_URI not set on the Lambda')
344
+ return acceptGrantErrorEnvelope(header, 'LAMBDA_REDIRECT_URI not set on the Lambda')
322
345
  }
323
346
 
324
347
  let tokens
325
348
  try {
326
349
  tokens = await exchangeGrantCode(deps, grantCode, redirectUri)
327
350
  } catch (err) {
328
- return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', describe(err))
351
+ return acceptGrantErrorEnvelope(header, describe(err))
329
352
  }
330
353
  const hubUrl = normaliseHubUrl(claims.hubUrl)
331
354
 
@@ -333,9 +356,11 @@ async function handleAcceptGrant(event, deps) {
333
356
  const outcome = await linkViaBroker(deps, { hubUrl, granteeToken, tokens })
334
357
  if (outcome.kind === 'linked') return acceptGrantResponse(header)
335
358
  if (outcome.kind !== 'hub-without-broker' || deps.env.LEGACY_HUB_FALLBACK === '0') {
336
- return errorEnvelope(header, 'ACCEPT_GRANT_FAILED', outcome.message)
359
+ return acceptGrantErrorEnvelope(header, outcome.message)
337
360
  }
338
- deps.log.warn('accept_grant: hub has no broker route; falling back to the legacy token hand-off')
361
+ deps.log.warn(
362
+ 'accept_grant: hub has no broker route; falling back to the legacy token hand-off',
363
+ )
339
364
  }
340
365
  return deliverTokensToLegacyHub(deps, header, { hubUrl, granteeToken, tokens })
341
366
  }
@@ -392,43 +417,82 @@ async function deliverTokensToLegacyHub(deps, header, input) {
392
417
  })
393
418
  if (!callbackResp.ok) {
394
419
  const text = await callbackResp.text().catch(() => '')
395
- return errorEnvelope(
420
+ return acceptGrantErrorEnvelope(
396
421
  header,
397
- 'ACCEPT_GRANT_FAILED',
398
422
  `hub callback ${callbackResp.status}: ${text.slice(0, 200)}`,
399
423
  )
400
424
  }
401
425
  return acceptGrantResponse(header)
402
426
  }
403
427
 
428
+ /** HTTP statuses that mean the hub itself is down or unreachable behind its tunnel. */
429
+ function isBridgeDownStatus(status) {
430
+ return status === 502 || status === 503 || status === 504 || (status >= 520 && status <= 530)
431
+ }
432
+
404
433
  async function forwardDirective(event, deps) {
405
- const header = event.directive.header
406
- const bearer = extractBearer(event.directive)
434
+ const directive = event.directive
435
+ const header = directive.header
436
+ const endpoint = directive.endpoint
437
+ const bearer = extractBearer(directive)
407
438
  if (!bearer) {
408
- return errorEnvelope(header, 'INVALID_AUTHORIZATION_CREDENTIAL', 'directive has no bearer token')
439
+ return errorEnvelope(
440
+ header,
441
+ 'INVALID_AUTHORIZATION_CREDENTIAL',
442
+ 'directive has no bearer token',
443
+ endpoint,
444
+ )
409
445
  }
410
446
  const claims = decodeJwtPayload(bearer)
411
447
  if (!claims || typeof claims.hubUrl !== 'string' || claims.hubUrl.length === 0) {
412
- return errorEnvelope(header, 'INVALID_AUTHORIZATION_CREDENTIAL', 'bearer has no hubUrl claim')
448
+ return errorEnvelope(
449
+ header,
450
+ 'INVALID_AUTHORIZATION_CREDENTIAL',
451
+ 'bearer has no hubUrl claim',
452
+ endpoint,
453
+ )
413
454
  }
414
455
  const target = `${normaliseHubUrl(claims.hubUrl)}/addon/export-alexa/directive`
415
- const resp = await deps.fetch(target, {
416
- method: 'POST',
417
- headers: {
418
- ...hubHeaders(deps, bearer),
419
- // Where this skill's broker lives. A hub linked before D753 uses it to
420
- // migrate its refresh token here (zero-touch); the hub only believes it
421
- // on a directive whose bearer it verified.
422
- ...(brokerAvailable(deps) ? { [BROKER_HEADER]: deps.publicUrl } : {}),
423
- },
424
- body: JSON.stringify(event),
425
- })
456
+ let resp
457
+ try {
458
+ resp = await deps.fetch(target, {
459
+ method: 'POST',
460
+ headers: {
461
+ ...hubHeaders(deps, bearer),
462
+ // Where this skill's broker lives. A hub linked before D753 uses it to
463
+ // migrate its refresh token here (zero-touch); the hub only believes it
464
+ // on a directive whose bearer it verified.
465
+ ...(brokerAvailable(deps) ? { [BROKER_HEADER]: deps.publicUrl } : {}),
466
+ },
467
+ body: JSON.stringify(event),
468
+ })
469
+ } catch (err) {
470
+ // The hub is the bridge every endpoint sits behind (alexa-errorresponse).
471
+ return errorEnvelope(
472
+ header,
473
+ 'BRIDGE_UNREACHABLE',
474
+ `hub unreachable: ${describe(err)}`,
475
+ endpoint,
476
+ )
477
+ }
426
478
  const text = await resp.text()
479
+ let parsed
427
480
  try {
428
- return JSON.parse(text)
481
+ parsed = JSON.parse(text)
429
482
  } catch {
430
- return errorEnvelope(header, 'INTERNAL_ERROR', `hub returned non-JSON: ${text.slice(0, 256)}`)
483
+ const type = isBridgeDownStatus(resp.status) ? 'BRIDGE_UNREACHABLE' : 'INTERNAL_ERROR'
484
+ return errorEnvelope(header, type, `hub returned ${resp.status}, non-JSON`, endpoint)
485
+ }
486
+ if (!parsed || typeof parsed !== 'object' || !parsed.event || !parsed.event.header) {
487
+ // e.g. `{ error: 'invalid_ingress_token' }` — never hand Alexa a non-event.
488
+ return errorEnvelope(
489
+ header,
490
+ 'INTERNAL_ERROR',
491
+ `hub returned ${resp.status} without an Alexa event`,
492
+ endpoint,
493
+ )
431
494
  }
495
+ return completeErrorResponse(parsed, directive)
432
496
  }
433
497
 
434
498
  function extractBearer(directive) {
@@ -94,20 +94,71 @@ function jsonResponse(statusCode, body) {
94
94
  }
95
95
  }
96
96
 
97
- function errorEnvelope(header, type, message) {
97
+ /**
98
+ * `Alexa.ErrorResponse` for a directive. Amazon requires the directive's
99
+ * `correlationToken` and the target `endpoint` on it (alexa-errorresponse);
100
+ * both are copied when the directive carried them.
101
+ */
102
+ function errorEnvelope(header, type, message, endpoint) {
103
+ const endpointId =
104
+ endpoint && typeof endpoint.endpointId === 'string' ? endpoint.endpointId : null
98
105
  return {
99
106
  event: {
100
107
  header: {
101
108
  namespace: 'Alexa',
102
109
  name: 'ErrorResponse',
103
110
  messageId: header.messageId,
111
+ ...(typeof header.correlationToken === 'string'
112
+ ? { correlationToken: header.correlationToken }
113
+ : {}),
104
114
  payloadVersion: '3',
105
115
  },
116
+ ...(endpointId !== null ? { endpoint: { endpointId } } : {}),
106
117
  payload: { type, message },
107
118
  },
108
119
  }
109
120
  }
110
121
 
122
+ /** AcceptGrant's own error: `Alexa.Authorization/ErrorResponse` (alexa-authorization). */
123
+ function acceptGrantErrorEnvelope(header, message) {
124
+ return {
125
+ event: {
126
+ header: {
127
+ namespace: 'Alexa.Authorization',
128
+ name: 'ErrorResponse',
129
+ messageId: header.messageId,
130
+ payloadVersion: '3',
131
+ },
132
+ payload: { type: 'ACCEPT_GRANT_FAILED', message },
133
+ },
134
+ }
135
+ }
136
+
137
+ /**
138
+ * Fill in what an `Alexa.ErrorResponse` from a hub may lack — a hub running an
139
+ * older addon omits `correlationToken` and `endpoint`. Any other body is
140
+ * returned as it is.
141
+ */
142
+ function completeErrorResponse(body, directive) {
143
+ const ev = body && body.event
144
+ if (!ev || !ev.header || ev.header.name !== 'ErrorResponse' || ev.header.namespace !== 'Alexa') {
145
+ return body
146
+ }
147
+ const header = { ...ev.header }
148
+ if (
149
+ header.correlationToken === undefined &&
150
+ typeof directive.header.correlationToken === 'string'
151
+ ) {
152
+ header.correlationToken = directive.header.correlationToken
153
+ }
154
+ const endpointId =
155
+ directive.endpoint && typeof directive.endpoint.endpointId === 'string'
156
+ ? directive.endpoint.endpointId
157
+ : null
158
+ const endpoint = ev.endpoint || (endpointId !== null ? { endpointId } : undefined)
159
+ return { ...body, event: { ...ev, header, ...(endpoint ? { endpoint } : {}) } }
160
+ }
161
+
111
162
  function describe(err) {
112
163
  if (err instanceof Error) return err.message
113
164
  return String(err)
@@ -124,5 +175,7 @@ module.exports = {
124
175
  resolveBrokerPublicUrl,
125
176
  jsonResponse,
126
177
  errorEnvelope,
178
+ acceptGrantErrorEnvelope,
179
+ completeErrorResponse,
127
180
  describe,
128
181
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-export-alexa",
3
- "version": "1.2.133",
3
+ "version": "1.2.134",
4
4
  "description": "Alexa Smart Home Skill export — hub-side OAuth provider + directive handler. The companion AWS Lambda forwards Alexa directives to this addon's /addon/export-alexa/directive endpoint.",
5
5
  "keywords": [
6
6
  "camstack",