@camstack/addon-provider-homeassistant 1.2.18 → 1.2.19

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/dist/addon.js CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_dist = require("./dist-DuKJzmrJ.js");
2
+ const require_dist = require("./dist-Bz5ENMVc.js");
3
3
  let node_crypto = require("node:crypto");
4
4
  let node_zlib = require("node:zlib");
5
5
  //#region src/ha-device-source.ts
package/dist/addon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as tamperCapability, A as eventEmitterCapability, B as motionCapability, C as connectivityCapability, Ct as url, D as deviceAdoptionCapability, E as coverCapability, F as humiditySensorCapability, H as notificationOutputCapability, I as imageCapability, J as presenceCapability, K as powerMeterCapability, L as lawnMowerControlCapability, M as floodCapability, N as gasCapability, P as humidifierCapability, Q as switchCapability, R as lockControlCapability, S as colorCapability, St as unknown, T as controlCapability, U as notifierCapability, V as normalizeUnit, W as numericSensorCapability, X as scriptRunnerCapability, Y as pressureSensorCapability, Z as smokeCapability, _ as brokerCapability, _t as number, a as EnumSensorDateTimeFormatSchema, at as waterHeaterCapability, b as carbonMonoxideCapability, bt as string, c as addonRoutesCapability, d as ambientLightSensorCapability, dt as DeviceType, et as temperatureSensorCapability, f as automationControlCapability, ft as createEvent, g as brightnessCapability, gt as literal, h as binaryCapability, ht as discriminatedUnion, it as vibrationCapability, j as fanControlCapability, k as enumSensorCapability, l as airQualitySensorCapability, lt as DeviceFeature, m as bestLocationMatch, mt as array, n as BaseDeviceProvider, nt as vacuumControlCapability, o as TargetSchema, ot as weatherCapability, p as batteryCapability, pt as _enum, q as prepareNotification, rt as valveCapability, s as accessoriesCapability, st as errMsg, t as BaseDevice, tt as updateCapability, u as alarmPanelCapability, ut as DeviceRole, v as buildAddonRouteProvider, vt as object, w as contactCapability, wt as EventCategory, x as climateControlCapability, xt as union, y as buttonCapability, yt as record, z as mediaPlayerCapability } from "./dist-UfT_fD3-.mjs";
1
+ import { $ as tamperCapability, A as eventEmitterCapability, B as motionCapability, C as connectivityCapability, Ct as url, D as deviceAdoptionCapability, E as coverCapability, F as humiditySensorCapability, H as notificationOutputCapability, I as imageCapability, J as presenceCapability, K as powerMeterCapability, L as lawnMowerControlCapability, M as floodCapability, N as gasCapability, P as humidifierCapability, Q as switchCapability, R as lockControlCapability, S as colorCapability, St as unknown, T as controlCapability, U as notifierCapability, V as normalizeUnit, W as numericSensorCapability, X as scriptRunnerCapability, Y as pressureSensorCapability, Z as smokeCapability, _ as brokerCapability, _t as number, a as EnumSensorDateTimeFormatSchema, at as waterHeaterCapability, b as carbonMonoxideCapability, bt as string, c as addonRoutesCapability, d as ambientLightSensorCapability, dt as DeviceType, et as temperatureSensorCapability, f as automationControlCapability, ft as createEvent, g as brightnessCapability, gt as literal, h as binaryCapability, ht as discriminatedUnion, it as vibrationCapability, j as fanControlCapability, k as enumSensorCapability, l as airQualitySensorCapability, lt as DeviceFeature, m as bestLocationMatch, mt as array, n as BaseDeviceProvider, nt as vacuumControlCapability, o as TargetSchema, ot as weatherCapability, p as batteryCapability, pt as _enum, q as prepareNotification, rt as valveCapability, s as accessoriesCapability, st as errMsg, t as BaseDevice, tt as updateCapability, u as alarmPanelCapability, ut as DeviceRole, v as buildAddonRouteProvider, vt as object, w as contactCapability, wt as EventCategory, x as climateControlCapability, xt as union, y as buttonCapability, yt as record, z as mediaPlayerCapability } from "./dist-BSQpoIVg.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";
4
4
  //#region src/ha-device-source.ts
@@ -15351,21 +15351,32 @@ object({
15351
15351
  * core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
15352
15352
  * `/api/oauth2/integrations` are built from this collection alone.
15353
15353
  *
15354
- * **Scopes.** `requestedScopes` is baked into every token this integration is
15355
- * ever issued and the operator consents to it once. Derive it from the tRPC
15356
- * paths the client calls **with that token**, against `METHOD_ACCESS_MAP`, and
15357
- * prefer a narrow `capability:` scope to a `category:` one unless the client
15358
- * genuinely needs a whole family. A category scope grants every future member
15354
+ * **Scopes. `requestedScopes` has exactly ONE meaning: what the integration
15355
+ * NEEDS to function.** Not a blast radius, not a conservative
15356
+ * under-declaration, not a description of some other path the addon happens to
15357
+ * have. Derive it from what the client actually calls **with this token** —
15358
+ * every tRPC path against `METHOD_ACCESS_MAP`, plus an `addon:` grant for every
15359
+ * addon HTTP route it posts to — and write the call that justifies each entry
15360
+ * next to it. Two integrations once used this field to mean two different
15361
+ * things; the operator ruled there is one meaning, and any third integration
15362
+ * inherits it (2026-08-09).
15363
+ *
15364
+ * This is not documentation, it is the ENFORCEMENT INPUT. Since
15365
+ * [D103](../../../../docs/decisions/adr-0103.md) the `/addon/:addonId/*` gate
15366
+ * checks an integration token's grant before letting it reach an
15367
+ * `access: 'authenticated'` route, so an **under-declaration is an integration
15368
+ * that stops working** — a missing `addon:` entry means `403 Token scope
15369
+ * mismatch` on every control the client tries to actuate. Widen the descriptor
15370
+ * honestly rather than weakening a check to make a route pass.
15371
+ *
15372
+ * Prefer a narrow `capability:` scope to a `category:` one unless the client
15373
+ * genuinely needs a whole family; a category scope grants every future member
15359
15374
  * of that category too. `category:system [create]` has been rejected once and
15360
15375
  * should stay rejected: it hands `addons.installPackage` to an integration.
15361
15376
  *
15362
- * What it does NOT cover: calls the ADDON makes over `ctx.api`, which run as
15363
- * the addon and are not scope-checked. Alexa's descriptor is narrower than
15364
- * Home Assistant's for exactly that reason its Lambda posts directives and
15365
- * the addon does the work, while the Home Assistant component calls tRPC
15366
- * directly with the token. So `requestedScopes` describes the blast radius of
15367
- * the GRANT, not the reach of the integration; do not widen one to describe the
15368
- * other.
15377
+ * Calls the ADDON itself makes over `ctx.api` run as the addon and are not
15378
+ * scope-checked, so they are not what this field describes but reaching the
15379
+ * addon's route in the first place IS, and that is the entry to declare.
15369
15380
  *
15370
15381
  * **The boot window.** An addon registers its provider after its runner forks
15371
15382
  * and initialises, so between hub start and that moment this collection is
@@ -15406,7 +15417,30 @@ var OauthIntegrationDescriptorSchema = object({
15406
15417
  * present, /api/oauth2/authorize bakes THIS into the code instead of the
15407
15418
  * hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
15408
15419
  * the hub's env) drives the claim that its cloud Lambda routes back on. */
15409
- hubUrl: string().optional()
15420
+ hubUrl: string().optional(),
15421
+ /**
15422
+ * How long a REFRESH token issued for this integration lives — seconds, or
15423
+ * `'never'` for a token minted with no `exp` claim at all. Omit to keep the
15424
+ * 30-day default, which is what every link used before this field existed.
15425
+ *
15426
+ * Declared here for the same reason `requestedScopes` is: the integration
15427
+ * knows what it needs. Amazon's account linking and a Home Assistant config
15428
+ * entry are both meant to survive indefinitely, and re-linking is a manual
15429
+ * user action, so a 30-day expiry silently unlinks a working integration.
15430
+ *
15431
+ * **The security posture, stated so it is owned deliberately.** A refresh
15432
+ * token that never expires is permanent access if it leaks. What bounds it is
15433
+ * revocation, not time: `oauthRefresh` re-reads the session on every use and
15434
+ * returns `null` once `revokedAt` is set, as does `oauthVerifyAccessToken`.
15435
+ * The one gap is the ACCESS token — it is a plain signed JWT that nothing
15436
+ * re-checks against the session on the `/trpc` and `/addon/*` paths, so
15437
+ * revoking a link takes effect there only after its remaining hour. That hour
15438
+ * is why the access TTL is not configurable.
15439
+ *
15440
+ * The value is baked into the authorization code at `/authorize` and travels
15441
+ * on the tokens, so editing this field changes FUTURE links only.
15442
+ */
15443
+ refreshTokenTtlSec: union([number().int().positive(), literal("never")]).optional()
15410
15444
  });
15411
15445
  var oauthIntegrationCapability = {
15412
15446
  name: "oauth-integration",
@@ -18549,11 +18583,29 @@ var SsoBridgeClaimsSchema = object({
18549
18583
  codeChallenge: string().optional(),
18550
18584
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
18551
18585
  * tokens so the verify path can check the session is not revoked. */
18552
- sessionId: string().optional()
18586
+ sessionId: string().optional(),
18587
+ /**
18588
+ * The refresh lifetime this LINK was created with, in seconds, or `'never'`.
18589
+ * Baked into the code at `/authorize` from the integration's descriptor and
18590
+ * carried forward so `oauthRefresh` re-mints with the same lifetime. It rides
18591
+ * on the token rather than being re-read from the descriptor on purpose:
18592
+ * editing a descriptor must not retroactively extend or shorten a link the
18593
+ * operator already consented to.
18594
+ */
18595
+ refreshTtl: union([number().int().positive(), literal("never")]).optional()
18553
18596
  });
18554
18597
  method(object({
18555
18598
  claims: SsoBridgeClaimsSchema,
18556
- ttlSec: number().int().positive().optional()
18599
+ /**
18600
+ * Seconds, or `'never'` for a token minted with NO `exp` claim.
18601
+ *
18602
+ * `'never'` is a literal rather than `undefined`/`0` because omitting
18603
+ * this field already means "the 5-minute SSO hand-off default", and
18604
+ * `jwt.sign` THROWS on `{ expiresIn: undefined }` — a "no expiry" that
18605
+ * went through the numeric path would fail at mint time and break
18606
+ * linking rather than produce an eternal token.
18607
+ */
18608
+ ttlSec: union([number().int().positive(), literal("never")]).optional()
18557
18609
  }), object({ token: string() })), method(object({ token: string() }), SsoBridgeClaimsSchema.nullable());
18558
18610
  var ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [object({
18559
18611
  providerId: string().min(1),
@@ -25834,7 +25886,12 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
25834
25886
  hubUrl: string(),
25835
25887
  /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
25836
25888
  * that carries one can ONLY be exchanged with the matching verifier. */
25837
- codeChallenge: string().optional()
25889
+ codeChallenge: string().optional(),
25890
+ /** The integration's declared refresh lifetime — seconds, or `'never'`.
25891
+ * From `OauthIntegrationDescriptor.refreshTokenTtlSec`. Baked into the
25892
+ * code so the link carries its own lifetime; omit for the 30-day
25893
+ * default. */
25894
+ refreshTtlSec: union([number().int().positive(), literal("never")]).optional()
25838
25895
  }), object({ code: string() }), {
25839
25896
  kind: "mutation",
25840
25897
  access: "create"
@@ -15351,21 +15351,32 @@ object({
15351
15351
  * core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
15352
15352
  * `/api/oauth2/integrations` are built from this collection alone.
15353
15353
  *
15354
- * **Scopes.** `requestedScopes` is baked into every token this integration is
15355
- * ever issued and the operator consents to it once. Derive it from the tRPC
15356
- * paths the client calls **with that token**, against `METHOD_ACCESS_MAP`, and
15357
- * prefer a narrow `capability:` scope to a `category:` one unless the client
15358
- * genuinely needs a whole family. A category scope grants every future member
15354
+ * **Scopes. `requestedScopes` has exactly ONE meaning: what the integration
15355
+ * NEEDS to function.** Not a blast radius, not a conservative
15356
+ * under-declaration, not a description of some other path the addon happens to
15357
+ * have. Derive it from what the client actually calls **with this token** —
15358
+ * every tRPC path against `METHOD_ACCESS_MAP`, plus an `addon:` grant for every
15359
+ * addon HTTP route it posts to — and write the call that justifies each entry
15360
+ * next to it. Two integrations once used this field to mean two different
15361
+ * things; the operator ruled there is one meaning, and any third integration
15362
+ * inherits it (2026-08-09).
15363
+ *
15364
+ * This is not documentation, it is the ENFORCEMENT INPUT. Since
15365
+ * [D103](../../../../docs/decisions/adr-0103.md) the `/addon/:addonId/*` gate
15366
+ * checks an integration token's grant before letting it reach an
15367
+ * `access: 'authenticated'` route, so an **under-declaration is an integration
15368
+ * that stops working** — a missing `addon:` entry means `403 Token scope
15369
+ * mismatch` on every control the client tries to actuate. Widen the descriptor
15370
+ * honestly rather than weakening a check to make a route pass.
15371
+ *
15372
+ * Prefer a narrow `capability:` scope to a `category:` one unless the client
15373
+ * genuinely needs a whole family; a category scope grants every future member
15359
15374
  * of that category too. `category:system [create]` has been rejected once and
15360
15375
  * should stay rejected: it hands `addons.installPackage` to an integration.
15361
15376
  *
15362
- * What it does NOT cover: calls the ADDON makes over `ctx.api`, which run as
15363
- * the addon and are not scope-checked. Alexa's descriptor is narrower than
15364
- * Home Assistant's for exactly that reason its Lambda posts directives and
15365
- * the addon does the work, while the Home Assistant component calls tRPC
15366
- * directly with the token. So `requestedScopes` describes the blast radius of
15367
- * the GRANT, not the reach of the integration; do not widen one to describe the
15368
- * other.
15377
+ * Calls the ADDON itself makes over `ctx.api` run as the addon and are not
15378
+ * scope-checked, so they are not what this field describes but reaching the
15379
+ * addon's route in the first place IS, and that is the entry to declare.
15369
15380
  *
15370
15381
  * **The boot window.** An addon registers its provider after its runner forks
15371
15382
  * and initialises, so between hub start and that moment this collection is
@@ -15406,7 +15417,30 @@ var OauthIntegrationDescriptorSchema = object({
15406
15417
  * present, /api/oauth2/authorize bakes THIS into the code instead of the
15407
15418
  * hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
15408
15419
  * the hub's env) drives the claim that its cloud Lambda routes back on. */
15409
- hubUrl: string().optional()
15420
+ hubUrl: string().optional(),
15421
+ /**
15422
+ * How long a REFRESH token issued for this integration lives — seconds, or
15423
+ * `'never'` for a token minted with no `exp` claim at all. Omit to keep the
15424
+ * 30-day default, which is what every link used before this field existed.
15425
+ *
15426
+ * Declared here for the same reason `requestedScopes` is: the integration
15427
+ * knows what it needs. Amazon's account linking and a Home Assistant config
15428
+ * entry are both meant to survive indefinitely, and re-linking is a manual
15429
+ * user action, so a 30-day expiry silently unlinks a working integration.
15430
+ *
15431
+ * **The security posture, stated so it is owned deliberately.** A refresh
15432
+ * token that never expires is permanent access if it leaks. What bounds it is
15433
+ * revocation, not time: `oauthRefresh` re-reads the session on every use and
15434
+ * returns `null` once `revokedAt` is set, as does `oauthVerifyAccessToken`.
15435
+ * The one gap is the ACCESS token — it is a plain signed JWT that nothing
15436
+ * re-checks against the session on the `/trpc` and `/addon/*` paths, so
15437
+ * revoking a link takes effect there only after its remaining hour. That hour
15438
+ * is why the access TTL is not configurable.
15439
+ *
15440
+ * The value is baked into the authorization code at `/authorize` and travels
15441
+ * on the tokens, so editing this field changes FUTURE links only.
15442
+ */
15443
+ refreshTokenTtlSec: union([number().int().positive(), literal("never")]).optional()
15410
15444
  });
15411
15445
  var oauthIntegrationCapability = {
15412
15446
  name: "oauth-integration",
@@ -18549,11 +18583,29 @@ var SsoBridgeClaimsSchema = object({
18549
18583
  codeChallenge: string().optional(),
18550
18584
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
18551
18585
  * tokens so the verify path can check the session is not revoked. */
18552
- sessionId: string().optional()
18586
+ sessionId: string().optional(),
18587
+ /**
18588
+ * The refresh lifetime this LINK was created with, in seconds, or `'never'`.
18589
+ * Baked into the code at `/authorize` from the integration's descriptor and
18590
+ * carried forward so `oauthRefresh` re-mints with the same lifetime. It rides
18591
+ * on the token rather than being re-read from the descriptor on purpose:
18592
+ * editing a descriptor must not retroactively extend or shorten a link the
18593
+ * operator already consented to.
18594
+ */
18595
+ refreshTtl: union([number().int().positive(), literal("never")]).optional()
18553
18596
  });
18554
18597
  method(object({
18555
18598
  claims: SsoBridgeClaimsSchema,
18556
- ttlSec: number().int().positive().optional()
18599
+ /**
18600
+ * Seconds, or `'never'` for a token minted with NO `exp` claim.
18601
+ *
18602
+ * `'never'` is a literal rather than `undefined`/`0` because omitting
18603
+ * this field already means "the 5-minute SSO hand-off default", and
18604
+ * `jwt.sign` THROWS on `{ expiresIn: undefined }` — a "no expiry" that
18605
+ * went through the numeric path would fail at mint time and break
18606
+ * linking rather than produce an eternal token.
18607
+ */
18608
+ ttlSec: union([number().int().positive(), literal("never")]).optional()
18557
18609
  }), object({ token: string() })), method(object({ token: string() }), SsoBridgeClaimsSchema.nullable());
18558
18610
  var ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [object({
18559
18611
  providerId: string().min(1),
@@ -25834,7 +25886,12 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
25834
25886
  hubUrl: string(),
25835
25887
  /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
25836
25888
  * that carries one can ONLY be exchanged with the matching verifier. */
25837
- codeChallenge: string().optional()
25889
+ codeChallenge: string().optional(),
25890
+ /** The integration's declared refresh lifetime — seconds, or `'never'`.
25891
+ * From `OauthIntegrationDescriptor.refreshTokenTtlSec`. Baked into the
25892
+ * code so the link carries its own lifetime; omit for the 30-day
25893
+ * default. */
25894
+ refreshTtlSec: union([number().int().positive(), literal("never")]).optional()
25838
25895
  }), object({ code: string() }), {
25839
25896
  kind: "mutation",
25840
25897
  access: "create"
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  //#endregion
6
- const require_dist = require("../dist-DuKJzmrJ.js");
6
+ const require_dist = require("../dist-Bz5ENMVc.js");
7
7
  let node_crypto = require("node:crypto");
8
8
  //#region src/ha-export/topics.ts
9
9
  /**
@@ -872,9 +872,38 @@ function pruneBrokers(membership, knownBrokerIds) {
872
872
  return next;
873
873
  }
874
874
  /**
875
- * Scopes granted to a linked Home Assistant, derived from the tRPC paths the
876
- * component actually calls. `category:system [create]` was rejected: it would
877
- * hand `addons.installPackage` to a home-automation bridge.
875
+ * The brokers the operator has switched ON, read from the RAW addon store.
876
+ *
877
+ * `exportTo:<brokerId>` is a **dynamic** settings key — one per broker the
878
+ * hub happens to know about — so it can never appear in the addon's
879
+ * `DEFAULT_CONFIG`. `BaseAddon.resolveConfig` copies only keys that are
880
+ * present in the defaults ("the store can contain extra keys … without
881
+ * polluting the typed config"), so every `exportTo:*` key is dropped on the
882
+ * way into `this.config` and reading one there yields `undefined` for ever.
883
+ *
884
+ * That is not theoretical. On 2026-08-09 the switch was on
885
+ * (`getGlobalSettings` returned `exportTo:ha_001 → value: true`, because the
886
+ * settings FORM reads the store) and every reconcile still logged
887
+ * `brokers=0`: nothing was ever exported, the per-device Export tab rendered
888
+ * its "Not configured" card, and no operator action could have changed
889
+ * either. The store is the single authority — read it, do not mirror it into
890
+ * a typed field that would then be a second one (D62).
891
+ *
892
+ * A broker that is no longer known is not enabled, whatever the store says:
893
+ * a stale `exportTo:` key for a deleted instance must not resurrect it.
894
+ */
895
+ function enabledBrokerIds(store, knownBrokerIds) {
896
+ return knownBrokerIds.filter((brokerId) => store[`exportTo:${brokerId}`] === true);
897
+ }
898
+ /**
899
+ * What a linked Home Assistant NEEDS to function — one entry per thing it
900
+ * actually calls with this token, each named. Not a blast radius, not a
901
+ * conservative under-declaration: `requestedScopes` has exactly one meaning
902
+ * across every integration, and since D103 it is also the ENFORCEMENT input, so
903
+ * an under-declaration is an integration that stops working.
904
+ *
905
+ * `category:system [create]` was rejected and stays rejected: it would hand
906
+ * `addons.installPackage` to a home-automation bridge.
878
907
  */
879
908
  var HOME_ASSISTANT_OAUTH_INTEGRATION = {
880
909
  integrationId: "homeassistant",
@@ -899,11 +928,54 @@ var HOME_ASSISTANT_OAUTH_INTEGRATION = {
899
928
  type: "capability",
900
929
  target: "pipeline-orchestrator",
901
930
  access: ["view", "create"]
902
- }
931
+ },
932
+ (
933
+ /**
934
+ * `deviceExport.listExposedDevices` — the membership read that decides WHICH
935
+ * devices the component imports. It is pinned to this addon so the unpinned
936
+ * aggregate (which merges Alexa's and HomeKit's exposed sets, D12) cannot
937
+ * leak another exporter's devices into Home Assistant.
938
+ *
939
+ * Missing until 2026-08-09, which broke setup outright: the component's very
940
+ * first authenticated call is this one, and the hub answered "rejected the
941
+ * token" — reported by Home Assistant as `Configurazione non riuscita:
942
+ * deviceExport.listExposedDevices: hub rejected the token`. The call was
943
+ * introduced with the export filter and the descriptor was never widened for
944
+ * it. `system` scope, so a capability grant is what it needs — the `addon:`
945
+ * grant below covers the HTTP command route and does NOT cover this.
946
+ */
947
+ {
948
+ type: "capability",
949
+ target: "device-export",
950
+ access: ["view"]
951
+ }),
952
+ (
953
+ /**
954
+ * `POST /addon/homeassistant-export/command` — the ONLY path by which an
955
+ * actuated Home Assistant entity (a camera switch, a PTZ button, reboot,
956
+ * snooze) reaches CamStack. Added when D103 made the addon-route gate check
957
+ * the grant instead of accepting any valid hub JWT; without it every HA
958
+ * control entity answers `403 Token scope mismatch`. `view` is here for the
959
+ * same addon's future GET routes, so a read does not force a re-link.
960
+ */
961
+ {
962
+ type: "addon",
963
+ target: "homeassistant-export",
964
+ access: ["view", "create"]
965
+ })
903
966
  ],
904
967
  allowedRedirectPrefixes: ["https://my.home-assistant.io/redirect/oauth"],
905
968
  allowedPrivateHostPaths: ["/auth/external/callback"],
906
- requiresPkce: true
969
+ requiresPkce: true,
970
+ /**
971
+ * One year. A Home Assistant config entry is meant to survive indefinitely
972
+ * and re-linking is a manual trip through the UI, so the 30-day default
973
+ * silently unlinked a working integration a month after setup. Finite rather
974
+ * than `'never'` (which Alexa uses): an HA instance refreshes on a timer
975
+ * while it is running, so a year is far beyond any realistic downtime, and a
976
+ * bounded token is the better default where nothing is lost by it.
977
+ */
978
+ refreshTokenTtlSec: 365 * 24 * 60 * 60
907
979
  };
908
980
  //#endregion
909
981
  //#region src/ha-export/push-client.ts
@@ -1299,6 +1371,19 @@ function projectCameraSwitches(deviceKey, switches) {
1299
1371
  value: bool(sw.enabled)
1300
1372
  }));
1301
1373
  }
1374
+ /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
1375
+ var HA_BROKER_KIND = "home-assistant";
1376
+ /**
1377
+ * Every Home Assistant broker currently registered, cluster-wide.
1378
+ *
1379
+ * Unpinned by design (see the module docblock) and filtered by `kind`, which is
1380
+ * the contract check that matters: whatever mix of providers answered the
1381
+ * union, only `home-assistant` brokers are ever exported to over Home
1382
+ * Assistant's transport.
1383
+ */
1384
+ async function listHomeAssistantBrokers(query) {
1385
+ return (await query({})).filter((broker) => broker.kind === HA_BROKER_KIND);
1386
+ }
1302
1387
  //#endregion
1303
1388
  //#region src/ha-export/ha-export.addon.ts
1304
1389
  /**
@@ -1336,8 +1421,6 @@ function projectCameraSwitches(deviceKey, switches) {
1336
1421
  * different sets of cameras, and the per-device Export panel shows
1337
1422
  * one switch per broker over the single membership store.
1338
1423
  */
1339
- /** The addon that owns the import direction — never export back to it. */
1340
- var HA_PROVIDER_ADDON_ID = "provider-homeassistant";
1341
1424
  var ADDON_ID = "homeassistant-export";
1342
1425
  /** Where the custom component registers its push view inside HA. */
1343
1426
  var PUSH_PATH = "/api/camstack/push";
@@ -1699,7 +1782,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1699
1782
  * exported back to it: HA would mirror its own entity across the
1700
1783
  * bridge, and any automation touching it would round-trip.
1701
1784
  */
1702
- if (device.addonId === HA_PROVIDER_ADDON_ID) {
1785
+ if (device.addonId === "provider-homeassistant") {
1703
1786
  this.ctx.logger.warn("ha-export: refusing to export a device imported from Home Assistant", { tags: { deviceId: numericId } });
1704
1787
  continue;
1705
1788
  }
@@ -1919,13 +2002,14 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1919
2002
  * ones the operator ticked.
1920
2003
  *
1921
2004
  * Credentials come through the `broker` cap — the sanctioned no-import
1922
- * path — and the owning `addonId` is sent with the read: `broker` is a
1923
- * COLLECTION cap, so an unpinned id-keyed call routes to the
1924
- * first-registered provider, which returns `null` for a broker it does
1925
- * not own.
2005
+ * path — and EVERY call carries the owning `addonId`: `broker` is a
2006
+ * COLLECTION cap, so an unpinned call is answered by the
2007
+ * first-registered provider (Homematic, on the operator's hub), which
2008
+ * knows nothing about `ha_*`. `broker-source.ts` holds the pin and the
2009
+ * reasoning.
1926
2010
  */
1927
2011
  async refreshBrokers() {
1928
- const haBrokers = (await this.ctx.api.broker.list.query({})).filter((broker) => broker.kind === "home-assistant");
2012
+ const haBrokers = await listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
1929
2013
  const known = haBrokers.map((broker) => ({
1930
2014
  id: broker.id,
1931
2015
  name: broker.name
@@ -1935,6 +2019,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1935
2019
  knownBrokers: known,
1936
2020
  membership: pruned
1937
2021
  });
2022
+ await this.refreshEnabledBrokers(known);
1938
2023
  const wanted = new Set(this.enabledBrokerIds());
1939
2024
  for (const [brokerId, link] of this.links) {
1940
2025
  if (wanted.has(brokerId)) continue;
@@ -2006,8 +2091,23 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2006
2091
  return null;
2007
2092
  }
2008
2093
  }
2094
+ /**
2095
+ * The brokers switched on, as of the last {@link refreshEnabledBrokers}.
2096
+ *
2097
+ * NOT read from `this.config`: `exportTo:<brokerId>` is a dynamic key and
2098
+ * `BaseAddon.resolveConfig` copies only keys present in `DEFAULT_CONFIG`, so
2099
+ * it is `undefined` there for ever — see `enabledBrokerIds` in
2100
+ * `membership.ts` for what that cost. The store is the authority; this is a
2101
+ * read-through cache of it, refreshed at the top of every reconcile pass and
2102
+ * before the per-device panel renders, never written to.
2103
+ */
2104
+ enabled = [];
2105
+ async refreshEnabledBrokers(known) {
2106
+ const store = await this.resolveGlobalStore();
2107
+ this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id));
2108
+ }
2009
2109
  enabledBrokerIds() {
2010
- return this.config.knownBrokers.filter((broker) => this.config[`exportTo:${broker.id}`] === true).map((broker) => broker.id);
2110
+ return this.enabled;
2011
2111
  }
2012
2112
  brokerName(brokerId) {
2013
2113
  return this.config.knownBrokers.find((broker) => broker.id === brokerId)?.name ?? brokerId;
@@ -2346,7 +2446,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2346
2446
  * exporters' identically-named fields and the operator sees another
2347
2447
  * exporter's value in this one's toggle.
2348
2448
  */
2349
- buildContribution(deviceId) {
2449
+ async buildContribution(deviceId) {
2450
+ await this.refreshEnabledBrokers(this.config.knownBrokers);
2350
2451
  const idStr = String(deviceId);
2351
2452
  const enabled = this.enabledBrokerIds();
2352
2453
  const fields = enabled.map((brokerId) => ({
@@ -1,4 +1,4 @@
1
- import { G as oauthIntegrationCapability, O as deviceExportCapability, bt as string, c as addonRoutesCapability, ct as BaseAddon, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, v as buildAddonRouteProvider, wt as EventCategory } from "../dist-UfT_fD3-.mjs";
1
+ import { G as oauthIntegrationCapability, O as deviceExportCapability, bt as string, c as addonRoutesCapability, ct as BaseAddon, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, v as buildAddonRouteProvider, wt as EventCategory } from "../dist-BSQpoIVg.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  //#region src/ha-export/topics.ts
4
4
  /**
@@ -867,9 +867,38 @@ function pruneBrokers(membership, knownBrokerIds) {
867
867
  return next;
868
868
  }
869
869
  /**
870
- * Scopes granted to a linked Home Assistant, derived from the tRPC paths the
871
- * component actually calls. `category:system [create]` was rejected: it would
872
- * hand `addons.installPackage` to a home-automation bridge.
870
+ * The brokers the operator has switched ON, read from the RAW addon store.
871
+ *
872
+ * `exportTo:<brokerId>` is a **dynamic** settings key — one per broker the
873
+ * hub happens to know about — so it can never appear in the addon's
874
+ * `DEFAULT_CONFIG`. `BaseAddon.resolveConfig` copies only keys that are
875
+ * present in the defaults ("the store can contain extra keys … without
876
+ * polluting the typed config"), so every `exportTo:*` key is dropped on the
877
+ * way into `this.config` and reading one there yields `undefined` for ever.
878
+ *
879
+ * That is not theoretical. On 2026-08-09 the switch was on
880
+ * (`getGlobalSettings` returned `exportTo:ha_001 → value: true`, because the
881
+ * settings FORM reads the store) and every reconcile still logged
882
+ * `brokers=0`: nothing was ever exported, the per-device Export tab rendered
883
+ * its "Not configured" card, and no operator action could have changed
884
+ * either. The store is the single authority — read it, do not mirror it into
885
+ * a typed field that would then be a second one (D62).
886
+ *
887
+ * A broker that is no longer known is not enabled, whatever the store says:
888
+ * a stale `exportTo:` key for a deleted instance must not resurrect it.
889
+ */
890
+ function enabledBrokerIds(store, knownBrokerIds) {
891
+ return knownBrokerIds.filter((brokerId) => store[`exportTo:${brokerId}`] === true);
892
+ }
893
+ /**
894
+ * What a linked Home Assistant NEEDS to function — one entry per thing it
895
+ * actually calls with this token, each named. Not a blast radius, not a
896
+ * conservative under-declaration: `requestedScopes` has exactly one meaning
897
+ * across every integration, and since D103 it is also the ENFORCEMENT input, so
898
+ * an under-declaration is an integration that stops working.
899
+ *
900
+ * `category:system [create]` was rejected and stays rejected: it would hand
901
+ * `addons.installPackage` to a home-automation bridge.
873
902
  */
874
903
  var HOME_ASSISTANT_OAUTH_INTEGRATION = {
875
904
  integrationId: "homeassistant",
@@ -894,11 +923,54 @@ var HOME_ASSISTANT_OAUTH_INTEGRATION = {
894
923
  type: "capability",
895
924
  target: "pipeline-orchestrator",
896
925
  access: ["view", "create"]
897
- }
926
+ },
927
+ (
928
+ /**
929
+ * `deviceExport.listExposedDevices` — the membership read that decides WHICH
930
+ * devices the component imports. It is pinned to this addon so the unpinned
931
+ * aggregate (which merges Alexa's and HomeKit's exposed sets, D12) cannot
932
+ * leak another exporter's devices into Home Assistant.
933
+ *
934
+ * Missing until 2026-08-09, which broke setup outright: the component's very
935
+ * first authenticated call is this one, and the hub answered "rejected the
936
+ * token" — reported by Home Assistant as `Configurazione non riuscita:
937
+ * deviceExport.listExposedDevices: hub rejected the token`. The call was
938
+ * introduced with the export filter and the descriptor was never widened for
939
+ * it. `system` scope, so a capability grant is what it needs — the `addon:`
940
+ * grant below covers the HTTP command route and does NOT cover this.
941
+ */
942
+ {
943
+ type: "capability",
944
+ target: "device-export",
945
+ access: ["view"]
946
+ }),
947
+ (
948
+ /**
949
+ * `POST /addon/homeassistant-export/command` — the ONLY path by which an
950
+ * actuated Home Assistant entity (a camera switch, a PTZ button, reboot,
951
+ * snooze) reaches CamStack. Added when D103 made the addon-route gate check
952
+ * the grant instead of accepting any valid hub JWT; without it every HA
953
+ * control entity answers `403 Token scope mismatch`. `view` is here for the
954
+ * same addon's future GET routes, so a read does not force a re-link.
955
+ */
956
+ {
957
+ type: "addon",
958
+ target: "homeassistant-export",
959
+ access: ["view", "create"]
960
+ })
898
961
  ],
899
962
  allowedRedirectPrefixes: ["https://my.home-assistant.io/redirect/oauth"],
900
963
  allowedPrivateHostPaths: ["/auth/external/callback"],
901
- requiresPkce: true
964
+ requiresPkce: true,
965
+ /**
966
+ * One year. A Home Assistant config entry is meant to survive indefinitely
967
+ * and re-linking is a manual trip through the UI, so the 30-day default
968
+ * silently unlinked a working integration a month after setup. Finite rather
969
+ * than `'never'` (which Alexa uses): an HA instance refreshes on a timer
970
+ * while it is running, so a year is far beyond any realistic downtime, and a
971
+ * bounded token is the better default where nothing is lost by it.
972
+ */
973
+ refreshTokenTtlSec: 365 * 24 * 60 * 60
902
974
  };
903
975
  //#endregion
904
976
  //#region src/ha-export/push-client.ts
@@ -1294,6 +1366,19 @@ function projectCameraSwitches(deviceKey, switches) {
1294
1366
  value: bool(sw.enabled)
1295
1367
  }));
1296
1368
  }
1369
+ /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
1370
+ var HA_BROKER_KIND = "home-assistant";
1371
+ /**
1372
+ * Every Home Assistant broker currently registered, cluster-wide.
1373
+ *
1374
+ * Unpinned by design (see the module docblock) and filtered by `kind`, which is
1375
+ * the contract check that matters: whatever mix of providers answered the
1376
+ * union, only `home-assistant` brokers are ever exported to over Home
1377
+ * Assistant's transport.
1378
+ */
1379
+ async function listHomeAssistantBrokers(query) {
1380
+ return (await query({})).filter((broker) => broker.kind === HA_BROKER_KIND);
1381
+ }
1297
1382
  //#endregion
1298
1383
  //#region src/ha-export/ha-export.addon.ts
1299
1384
  /**
@@ -1331,8 +1416,6 @@ function projectCameraSwitches(deviceKey, switches) {
1331
1416
  * different sets of cameras, and the per-device Export panel shows
1332
1417
  * one switch per broker over the single membership store.
1333
1418
  */
1334
- /** The addon that owns the import direction — never export back to it. */
1335
- var HA_PROVIDER_ADDON_ID = "provider-homeassistant";
1336
1419
  var ADDON_ID = "homeassistant-export";
1337
1420
  /** Where the custom component registers its push view inside HA. */
1338
1421
  var PUSH_PATH = "/api/camstack/push";
@@ -1694,7 +1777,7 @@ var HaExportAddon = class extends BaseAddon {
1694
1777
  * exported back to it: HA would mirror its own entity across the
1695
1778
  * bridge, and any automation touching it would round-trip.
1696
1779
  */
1697
- if (device.addonId === HA_PROVIDER_ADDON_ID) {
1780
+ if (device.addonId === "provider-homeassistant") {
1698
1781
  this.ctx.logger.warn("ha-export: refusing to export a device imported from Home Assistant", { tags: { deviceId: numericId } });
1699
1782
  continue;
1700
1783
  }
@@ -1914,13 +1997,14 @@ var HaExportAddon = class extends BaseAddon {
1914
1997
  * ones the operator ticked.
1915
1998
  *
1916
1999
  * Credentials come through the `broker` cap — the sanctioned no-import
1917
- * path — and the owning `addonId` is sent with the read: `broker` is a
1918
- * COLLECTION cap, so an unpinned id-keyed call routes to the
1919
- * first-registered provider, which returns `null` for a broker it does
1920
- * not own.
2000
+ * path — and EVERY call carries the owning `addonId`: `broker` is a
2001
+ * COLLECTION cap, so an unpinned call is answered by the
2002
+ * first-registered provider (Homematic, on the operator's hub), which
2003
+ * knows nothing about `ha_*`. `broker-source.ts` holds the pin and the
2004
+ * reasoning.
1921
2005
  */
1922
2006
  async refreshBrokers() {
1923
- const haBrokers = (await this.ctx.api.broker.list.query({})).filter((broker) => broker.kind === "home-assistant");
2007
+ const haBrokers = await listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
1924
2008
  const known = haBrokers.map((broker) => ({
1925
2009
  id: broker.id,
1926
2010
  name: broker.name
@@ -1930,6 +2014,7 @@ var HaExportAddon = class extends BaseAddon {
1930
2014
  knownBrokers: known,
1931
2015
  membership: pruned
1932
2016
  });
2017
+ await this.refreshEnabledBrokers(known);
1933
2018
  const wanted = new Set(this.enabledBrokerIds());
1934
2019
  for (const [brokerId, link] of this.links) {
1935
2020
  if (wanted.has(brokerId)) continue;
@@ -2001,8 +2086,23 @@ var HaExportAddon = class extends BaseAddon {
2001
2086
  return null;
2002
2087
  }
2003
2088
  }
2089
+ /**
2090
+ * The brokers switched on, as of the last {@link refreshEnabledBrokers}.
2091
+ *
2092
+ * NOT read from `this.config`: `exportTo:<brokerId>` is a dynamic key and
2093
+ * `BaseAddon.resolveConfig` copies only keys present in `DEFAULT_CONFIG`, so
2094
+ * it is `undefined` there for ever — see `enabledBrokerIds` in
2095
+ * `membership.ts` for what that cost. The store is the authority; this is a
2096
+ * read-through cache of it, refreshed at the top of every reconcile pass and
2097
+ * before the per-device panel renders, never written to.
2098
+ */
2099
+ enabled = [];
2100
+ async refreshEnabledBrokers(known) {
2101
+ const store = await this.resolveGlobalStore();
2102
+ this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id));
2103
+ }
2004
2104
  enabledBrokerIds() {
2005
- return this.config.knownBrokers.filter((broker) => this.config[`exportTo:${broker.id}`] === true).map((broker) => broker.id);
2105
+ return this.enabled;
2006
2106
  }
2007
2107
  brokerName(brokerId) {
2008
2108
  return this.config.knownBrokers.find((broker) => broker.id === brokerId)?.name ?? brokerId;
@@ -2341,7 +2441,8 @@ var HaExportAddon = class extends BaseAddon {
2341
2441
  * exporters' identically-named fields and the operator sees another
2342
2442
  * exporter's value in this one's toggle.
2343
2443
  */
2344
- buildContribution(deviceId) {
2444
+ async buildContribution(deviceId) {
2445
+ await this.refreshEnabledBrokers(this.config.knownBrokers);
2345
2446
  const idStr = String(deviceId);
2346
2447
  const enabled = this.enabledBrokerIds();
2347
2448
  const fields = enabled.map((brokerId) => ({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.18",
3
+ "version": "1.2.19",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",
@@ -46,8 +46,7 @@
46
46
  "brokerKind": "home-assistant",
47
47
  "supportsLocationImport": true,
48
48
  "execution": {
49
- "placement": "hub-only",
50
- "group": "notifiers"
49
+ "placement": "hub-only"
51
50
  },
52
51
  "capabilities": [
53
52
  {