@camstack/addon-provider-homeassistant 1.2.17 → 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-DIa87XAf.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 temperatureSensorCapability, A as eventEmitterCapability, B as motionCapability, C as connectivityCapability, Ct as EventCategory, D as deviceAdoptionCapability, E as coverCapability, F as humiditySensorCapability, G as powerMeterCapability, H as notificationOutputCapability, I as imageCapability, J as pressureSensorCapability, K as prepareNotification, L as lawnMowerControlCapability, M as floodCapability, N as gasCapability, P as humidifierCapability, Q as tamperCapability, R as lockControlCapability, S as colorCapability, St as url, T as controlCapability, U as notifierCapability, V as normalizeUnit, W as numericSensorCapability, X as smokeCapability, Y as scriptRunnerCapability, Z as switchCapability, _ as brokerCapability, _t as object, a as EnumSensorDateTimeFormatSchema, at as weatherCapability, b as carbonMonoxideCapability, bt as union, c as addonRoutesCapability, ct as DeviceFeature, d as ambientLightSensorCapability, dt as createEvent, et as updateCapability, f as automationControlCapability, ft as _enum, g as brightnessCapability, gt as number, h as binaryCapability, ht as literal, it as waterHeaterCapability, j as fanControlCapability, k as enumSensorCapability, l as airQualitySensorCapability, lt as DeviceRole, m as bestLocationMatch, mt as discriminatedUnion, n as BaseDeviceProvider, nt as valveCapability, o as TargetSchema, ot as errMsg, p as batteryCapability, pt as array, q as presenceCapability, rt as vibrationCapability, s as accessoriesCapability, t as BaseDevice, tt as vacuumControlCapability, u as alarmPanelCapability, ut as DeviceType, v as buildAddonRouteProvider, vt as record, w as contactCapability, x as climateControlCapability, xt as unknown, y as buttonCapability, yt as string, z as mediaPlayerCapability } from "./dist-reK3lvnr.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
@@ -15317,6 +15317,76 @@ object({
15317
15317
  * Each provider returns a static descriptor; the core enumerates them
15318
15318
  * to validate the `integration=` query param and resolve the consent
15319
15319
  * label + the scopes baked into the issued token.
15320
+ *
15321
+ * ## Declaring one
15322
+ *
15323
+ * An OAuth client is integration-specific knowledge — who the client is, what
15324
+ * it may ask for, where it may be sent — so it is declared by the ADDON that
15325
+ * owns the integration, never by the kernel and never as a branch inside
15326
+ * `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
15327
+ * steps, no others:
15328
+ *
15329
+ * 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
15330
+ * manifest entry. This is also what tells the hub, at addon-LOAD time, that
15331
+ * a descriptor is owed — see "the boot window" below.
15332
+ * 2. Return a provider from `onInitialize()`:
15333
+ *
15334
+ * ```ts
15335
+ * const provider: IOauthIntegrationProvider = {
15336
+ * getDescriptor: async () => ({
15337
+ * integrationId: 'my-thing', // the `integration=` query param
15338
+ * displayName: 'My Thing',
15339
+ * requestedScopes: [ … ], // see below
15340
+ * allowedRedirectPrefixes: ['https://callback.example/'],
15341
+ * }),
15342
+ * }
15343
+ * return [{ capability: oauthIntegrationCapability, provider }]
15344
+ * ```
15345
+ *
15346
+ * The descriptor must be **static** — it is read on the authorize path, so
15347
+ * never put an await on network or disk behind it, and never register it
15348
+ * behind one either (a provider is registered only once `onInitialize`
15349
+ * RETURNS, so anything awaited before the return delays linking).
15350
+ * 3. Nothing else. There is no allow-list to join, no id to register with the
15351
+ * core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
15352
+ * `/api/oauth2/integrations` are built from this collection alone.
15353
+ *
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
15374
+ * of that category too. `category:system [create]` has been rejected once and
15375
+ * should stay rejected: it hands `addons.installPackage` to an integration.
15376
+ *
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.
15380
+ *
15381
+ * **The boot window.** An addon registers its provider after its runner forks
15382
+ * and initialises, so between hub start and that moment this collection is
15383
+ * incomplete and an `integrationId` can be legitimately absent. The core does
15384
+ * not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
15385
+ * it compares the manifest declarers against the registered providers and
15386
+ * answers `503 temporarily_unavailable` (with `Retry-After` and the pending
15387
+ * addon ids) instead of `400 unknown integration`, and reports
15388
+ * `complete: false` on `GET /api/oauth2/integrations`. A client should retry
15389
+ * while the list is incomplete rather than conclude the hub cannot do OAuth.
15320
15390
  */
15321
15391
  var OauthIntegrationDescriptorSchema = object({
15322
15392
  /** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
@@ -15347,9 +15417,38 @@ var OauthIntegrationDescriptorSchema = object({
15347
15417
  * present, /api/oauth2/authorize bakes THIS into the code instead of the
15348
15418
  * hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
15349
15419
  * the hub's env) drives the claim that its cloud Lambda routes back on. */
15350
- 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()
15351
15444
  });
15352
- method(_void(), OauthIntegrationDescriptorSchema);
15445
+ var oauthIntegrationCapability = {
15446
+ name: "oauth-integration",
15447
+ scope: "system",
15448
+ mode: "collection",
15449
+ internal: true,
15450
+ methods: { getDescriptor: method(_void(), OauthIntegrationDescriptorSchema) }
15451
+ };
15353
15452
  /**
15354
15453
  * pipeline-analytics — device-scoped wrapper cap. Refines raw
15355
15454
  * per-frame detections emitted by the pipeline runner into tracked
@@ -18484,11 +18583,29 @@ var SsoBridgeClaimsSchema = object({
18484
18583
  codeChallenge: string().optional(),
18485
18584
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
18486
18585
  * tokens so the verify path can check the session is not revoked. */
18487
- 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()
18488
18596
  });
18489
18597
  method(object({
18490
18598
  claims: SsoBridgeClaimsSchema,
18491
- 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()
18492
18609
  }), object({ token: string() })), method(object({ token: string() }), SsoBridgeClaimsSchema.nullable());
18493
18610
  var ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [object({
18494
18611
  providerId: string().min(1),
@@ -25769,7 +25886,12 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
25769
25886
  hubUrl: string(),
25770
25887
  /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
25771
25888
  * that carries one can ONLY be exchanged with the matching verifier. */
25772
- 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()
25773
25895
  }), object({ code: string() }), {
25774
25896
  kind: "mutation",
25775
25897
  access: "create"
@@ -33351,4 +33473,4 @@ function bestLocationMatch(externalName, existing, threshold = .8) {
33351
33473
  return best;
33352
33474
  }
33353
33475
  //#endregion
33354
- export { temperatureSensorCapability as $, eventEmitterCapability as A, motionCapability as B, connectivityCapability as C, EventCategory as Ct, deviceAdoptionCapability as D, coverCapability as E, humiditySensorCapability as F, powerMeterCapability as G, notificationOutputCapability as H, imageCapability as I, pressureSensorCapability as J, prepareNotification as K, lawnMowerControlCapability as L, floodCapability as M, gasCapability as N, deviceExportCapability as O, humidifierCapability as P, tamperCapability as Q, lockControlCapability as R, colorCapability as S, url as St, controlCapability as T, notifierCapability as U, normalizeUnit as V, numericSensorCapability as W, smokeCapability as X, scriptRunnerCapability as Y, switchCapability as Z, brokerCapability as _, object as _t, EnumSensorDateTimeFormatSchema as a, weatherCapability as at, carbonMonoxideCapability as b, union as bt, addonRoutesCapability as c, DeviceFeature as ct, ambientLightSensorCapability as d, createEvent as dt, updateCapability as et, automationControlCapability as f, _enum as ft, brightnessCapability as g, number as gt, binaryCapability as h, literal as ht, CameraSwitchIdSchema as i, waterHeaterCapability as it, fanControlCapability as j, enumSensorCapability as k, airQualitySensorCapability as l, DeviceRole as lt, bestLocationMatch as m, discriminatedUnion as mt, BaseDeviceProvider as n, valveCapability as nt, TargetSchema as o, errMsg as ot, batteryCapability as p, array as pt, presenceCapability as q, COCO_TO_MACRO as r, vibrationCapability as rt, accessoriesCapability as s, BaseAddon as st, BaseDevice as t, vacuumControlCapability as tt, alarmPanelCapability as u, DeviceType as ut, buildAddonRouteProvider as v, record as vt, contactCapability as w, climateControlCapability as x, unknown as xt, buttonCapability as y, string as yt, mediaPlayerCapability as z };
33476
+ export { tamperCapability as $, eventEmitterCapability as A, motionCapability as B, connectivityCapability as C, url as Ct, deviceAdoptionCapability as D, coverCapability as E, humiditySensorCapability as F, oauthIntegrationCapability as G, notificationOutputCapability as H, imageCapability as I, presenceCapability as J, powerMeterCapability as K, lawnMowerControlCapability as L, floodCapability as M, gasCapability as N, deviceExportCapability as O, humidifierCapability as P, switchCapability as Q, lockControlCapability as R, colorCapability as S, unknown as St, controlCapability as T, notifierCapability as U, normalizeUnit as V, numericSensorCapability as W, scriptRunnerCapability as X, pressureSensorCapability as Y, smokeCapability as Z, brokerCapability as _, number as _t, EnumSensorDateTimeFormatSchema as a, waterHeaterCapability as at, carbonMonoxideCapability as b, string as bt, addonRoutesCapability as c, BaseAddon as ct, ambientLightSensorCapability as d, DeviceType as dt, temperatureSensorCapability as et, automationControlCapability as f, createEvent as ft, brightnessCapability as g, literal as gt, binaryCapability as h, discriminatedUnion as ht, CameraSwitchIdSchema as i, vibrationCapability as it, fanControlCapability as j, enumSensorCapability as k, airQualitySensorCapability as l, DeviceFeature as lt, bestLocationMatch as m, array as mt, BaseDeviceProvider as n, vacuumControlCapability as nt, TargetSchema as o, weatherCapability as ot, batteryCapability as p, _enum as pt, prepareNotification as q, COCO_TO_MACRO as r, valveCapability as rt, accessoriesCapability as s, errMsg as st, BaseDevice as t, updateCapability as tt, alarmPanelCapability as u, DeviceRole as ut, buildAddonRouteProvider as v, object as vt, contactCapability as w, EventCategory as wt, climateControlCapability as x, union as xt, buttonCapability as y, record as yt, mediaPlayerCapability as z };
@@ -15317,6 +15317,76 @@ object({
15317
15317
  * Each provider returns a static descriptor; the core enumerates them
15318
15318
  * to validate the `integration=` query param and resolve the consent
15319
15319
  * label + the scopes baked into the issued token.
15320
+ *
15321
+ * ## Declaring one
15322
+ *
15323
+ * An OAuth client is integration-specific knowledge — who the client is, what
15324
+ * it may ask for, where it may be sent — so it is declared by the ADDON that
15325
+ * owns the integration, never by the kernel and never as a branch inside
15326
+ * `oauth2-routes.ts` ([D101](../../../../docs/decisions/adr-0101.md)). Three
15327
+ * steps, no others:
15328
+ *
15329
+ * 1. Add `{ "name": "oauth-integration" }` to the addon's `camstack.addons[]`
15330
+ * manifest entry. This is also what tells the hub, at addon-LOAD time, that
15331
+ * a descriptor is owed — see "the boot window" below.
15332
+ * 2. Return a provider from `onInitialize()`:
15333
+ *
15334
+ * ```ts
15335
+ * const provider: IOauthIntegrationProvider = {
15336
+ * getDescriptor: async () => ({
15337
+ * integrationId: 'my-thing', // the `integration=` query param
15338
+ * displayName: 'My Thing',
15339
+ * requestedScopes: [ … ], // see below
15340
+ * allowedRedirectPrefixes: ['https://callback.example/'],
15341
+ * }),
15342
+ * }
15343
+ * return [{ capability: oauthIntegrationCapability, provider }]
15344
+ * ```
15345
+ *
15346
+ * The descriptor must be **static** — it is read on the authorize path, so
15347
+ * never put an await on network or disk behind it, and never register it
15348
+ * behind one either (a provider is registered only once `onInitialize`
15349
+ * RETURNS, so anything awaited before the return delays linking).
15350
+ * 3. Nothing else. There is no allow-list to join, no id to register with the
15351
+ * core, and no per-integration branch anywhere: `/api/oauth2/authorize` and
15352
+ * `/api/oauth2/integrations` are built from this collection alone.
15353
+ *
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
15374
+ * of that category too. `category:system [create]` has been rejected once and
15375
+ * should stay rejected: it hands `addons.installPackage` to an integration.
15376
+ *
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.
15380
+ *
15381
+ * **The boot window.** An addon registers its provider after its runner forks
15382
+ * and initialises, so between hub start and that moment this collection is
15383
+ * incomplete and an `integrationId` can be legitimately absent. The core does
15384
+ * not wait, poll or cache around this ([D3](../../../../docs/decisions/adr-0003.md)):
15385
+ * it compares the manifest declarers against the registered providers and
15386
+ * answers `503 temporarily_unavailable` (with `Retry-After` and the pending
15387
+ * addon ids) instead of `400 unknown integration`, and reports
15388
+ * `complete: false` on `GET /api/oauth2/integrations`. A client should retry
15389
+ * while the list is incomplete rather than conclude the hub cannot do OAuth.
15320
15390
  */
15321
15391
  var OauthIntegrationDescriptorSchema = object({
15322
15392
  /** Stable id used as the `integration=` query param, e.g. 'export-alexa'. */
@@ -15347,9 +15417,38 @@ var OauthIntegrationDescriptorSchema = object({
15347
15417
  * present, /api/oauth2/authorize bakes THIS into the code instead of the
15348
15418
  * hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
15349
15419
  * the hub's env) drives the claim that its cloud Lambda routes back on. */
15350
- 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()
15351
15444
  });
15352
- method(_void(), OauthIntegrationDescriptorSchema);
15445
+ var oauthIntegrationCapability = {
15446
+ name: "oauth-integration",
15447
+ scope: "system",
15448
+ mode: "collection",
15449
+ internal: true,
15450
+ methods: { getDescriptor: method(_void(), OauthIntegrationDescriptorSchema) }
15451
+ };
15353
15452
  /**
15354
15453
  * pipeline-analytics — device-scoped wrapper cap. Refines raw
15355
15454
  * per-frame detections emitted by the pipeline runner into tracked
@@ -18484,11 +18583,29 @@ var SsoBridgeClaimsSchema = object({
18484
18583
  codeChallenge: string().optional(),
18485
18584
  /** OAuth session registry id — set on `oauth-access`/`oauth-refresh`
18486
18585
  * tokens so the verify path can check the session is not revoked. */
18487
- 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()
18488
18596
  });
18489
18597
  method(object({
18490
18598
  claims: SsoBridgeClaimsSchema,
18491
- 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()
18492
18609
  }), object({ token: string() })), method(object({ token: string() }), SsoBridgeClaimsSchema.nullable());
18493
18610
  var ProviderListEntrySchema = discriminatedUnion("shouldSaveDiskSpace", [object({
18494
18611
  providerId: string().min(1),
@@ -25769,7 +25886,12 @@ method(_void(), array(UserSummarySchema), { auth: "admin" }), method(CreateUserI
25769
25886
  hubUrl: string(),
25770
25887
  /** PKCE (RFC 7636) S256 challenge. Baked into the signed code; a code
25771
25888
  * that carries one can ONLY be exchanged with the matching verifier. */
25772
- 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()
25773
25895
  }), object({ code: string() }), {
25774
25896
  kind: "mutation",
25775
25897
  access: "create"
@@ -33687,6 +33809,12 @@ Object.defineProperty(exports, "numericSensorCapability", {
33687
33809
  return numericSensorCapability;
33688
33810
  }
33689
33811
  });
33812
+ Object.defineProperty(exports, "oauthIntegrationCapability", {
33813
+ enumerable: true,
33814
+ get: function() {
33815
+ return oauthIntegrationCapability;
33816
+ }
33817
+ });
33690
33818
  Object.defineProperty(exports, "object", {
33691
33819
  enumerable: true,
33692
33820
  get: function() {
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
5
  //#endregion
6
- const require_dist = require("../dist-DIa87XAf.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
  /**
@@ -871,6 +871,112 @@ function pruneBrokers(membership, knownBrokerIds) {
871
871
  for (const brokerId of kept) next[brokerId] = membership[brokerId] ?? [];
872
872
  return next;
873
873
  }
874
+ /**
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.
907
+ */
908
+ var HOME_ASSISTANT_OAUTH_INTEGRATION = {
909
+ integrationId: "homeassistant",
910
+ displayName: "Home Assistant",
911
+ requestedScopes: [
912
+ {
913
+ type: "category",
914
+ target: "device",
915
+ access: ["view", "create"]
916
+ },
917
+ {
918
+ type: "capability",
919
+ target: "device-manager",
920
+ access: ["view"]
921
+ },
922
+ {
923
+ type: "capability",
924
+ target: "device-state",
925
+ access: ["view"]
926
+ },
927
+ {
928
+ type: "capability",
929
+ target: "pipeline-orchestrator",
930
+ access: ["view", "create"]
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
+ })
966
+ ],
967
+ allowedRedirectPrefixes: ["https://my.home-assistant.io/redirect/oauth"],
968
+ allowedPrivateHostPaths: ["/auth/external/callback"],
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
979
+ };
874
980
  //#endregion
875
981
  //#region src/ha-export/push-client.ts
876
982
  /** How long state updates accumulate before one POST carries them all. */
@@ -1265,6 +1371,19 @@ function projectCameraSwitches(deviceKey, switches) {
1265
1371
  value: bool(sw.enabled)
1266
1372
  }));
1267
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
+ }
1268
1387
  //#endregion
1269
1388
  //#region src/ha-export/ha-export.addon.ts
1270
1389
  /**
@@ -1302,8 +1421,6 @@ function projectCameraSwitches(deviceKey, switches) {
1302
1421
  * different sets of cameras, and the per-device Export panel shows
1303
1422
  * one switch per broker over the single membership store.
1304
1423
  */
1305
- /** The addon that owns the import direction — never export back to it. */
1306
- var HA_PROVIDER_ADDON_ID = "provider-homeassistant";
1307
1424
  var ADDON_ID = "homeassistant-export";
1308
1425
  /** Where the custom component registers its push view inside HA. */
1309
1426
  var PUSH_PATH = "/api/camstack/push";
@@ -1374,12 +1491,10 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1374
1491
  } else this.mediaSecret = existing;
1375
1492
  this.wireListeners();
1376
1493
  await this.serveMediaPlane();
1377
- try {
1378
- await this.reconcile("boot");
1379
- } catch (err) {
1494
+ this.reconcile("boot").catch((err) => {
1380
1495
  this.lastError = errMsg(err);
1381
1496
  this.ctx.logger.warn("ha-export: initial reconcile failed", { meta: { error: this.lastError } });
1382
- }
1497
+ });
1383
1498
  this.startTimer();
1384
1499
  this.ctx.addDisposer(async () => {
1385
1500
  this.stopTimer();
@@ -1387,33 +1502,40 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1387
1502
  for (const link of this.links.values()) await link.client.dispose();
1388
1503
  this.links.clear();
1389
1504
  });
1390
- return [{
1391
- capability: require_dist.deviceExportCapability,
1392
- provider: {
1393
- getStatus: async () => this.buildStatus(),
1394
- listSupportedDeviceKinds: async () => [...SUPPORTED_DEVICE_KINDS],
1395
- listExposedDevices: async () => exposedDeviceIds(this.config.membership, this.enabledBrokerIds()).map((deviceId) => ({
1396
- deviceId,
1397
- exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
1398
- })),
1399
- exposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, true),
1400
- unexposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, false),
1401
- getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
1402
- getDeviceLiveContribution: async () => null,
1403
- applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
1404
- }
1405
- }, {
1406
- capability: require_dist.addonRoutesCapability,
1407
- provider: require_dist.buildAddonRouteProvider(ADDON_ID, [{
1408
- method: "POST",
1409
- path: "/command",
1410
- access: "authenticated",
1411
- description: "Home Assistant → CamStack command: {topic, value}",
1412
- handler: async (request, reply) => {
1413
- await this.handleCommandRoute(request.body, reply);
1505
+ return [
1506
+ {
1507
+ capability: require_dist.deviceExportCapability,
1508
+ provider: {
1509
+ getStatus: async () => this.buildStatus(),
1510
+ listSupportedDeviceKinds: async () => [...SUPPORTED_DEVICE_KINDS],
1511
+ listExposedDevices: async () => exposedDeviceIds(this.config.membership, this.enabledBrokerIds()).map((deviceId) => ({
1512
+ deviceId,
1513
+ exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
1514
+ })),
1515
+ exposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, true),
1516
+ unexposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, false),
1517
+ getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
1518
+ getDeviceLiveContribution: async () => null,
1519
+ applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
1414
1520
  }
1415
- }])
1416
- }];
1521
+ },
1522
+ {
1523
+ capability: require_dist.addonRoutesCapability,
1524
+ provider: require_dist.buildAddonRouteProvider(ADDON_ID, [{
1525
+ method: "POST",
1526
+ path: "/command",
1527
+ access: "authenticated",
1528
+ description: "Home Assistant → CamStack command: {topic, value}",
1529
+ handler: async (request, reply) => {
1530
+ await this.handleCommandRoute(request.body, reply);
1531
+ }
1532
+ }])
1533
+ },
1534
+ {
1535
+ capability: require_dist.oauthIntegrationCapability,
1536
+ provider: { getDescriptor: async () => HOME_ASSISTANT_OAUTH_INTEGRATION }
1537
+ }
1538
+ ];
1417
1539
  }
1418
1540
  async onConfigChanged() {
1419
1541
  await this.reconcile("config-changed");
@@ -1660,7 +1782,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1660
1782
  * exported back to it: HA would mirror its own entity across the
1661
1783
  * bridge, and any automation touching it would round-trip.
1662
1784
  */
1663
- if (device.addonId === HA_PROVIDER_ADDON_ID) {
1785
+ if (device.addonId === "provider-homeassistant") {
1664
1786
  this.ctx.logger.warn("ha-export: refusing to export a device imported from Home Assistant", { tags: { deviceId: numericId } });
1665
1787
  continue;
1666
1788
  }
@@ -1880,13 +2002,14 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1880
2002
  * ones the operator ticked.
1881
2003
  *
1882
2004
  * Credentials come through the `broker` cap — the sanctioned no-import
1883
- * path — and the owning `addonId` is sent with the read: `broker` is a
1884
- * COLLECTION cap, so an unpinned id-keyed call routes to the
1885
- * first-registered provider, which returns `null` for a broker it does
1886
- * 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.
1887
2010
  */
1888
2011
  async refreshBrokers() {
1889
- 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));
1890
2013
  const known = haBrokers.map((broker) => ({
1891
2014
  id: broker.id,
1892
2015
  name: broker.name
@@ -1896,6 +2019,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1896
2019
  knownBrokers: known,
1897
2020
  membership: pruned
1898
2021
  });
2022
+ await this.refreshEnabledBrokers(known);
1899
2023
  const wanted = new Set(this.enabledBrokerIds());
1900
2024
  for (const [brokerId, link] of this.links) {
1901
2025
  if (wanted.has(brokerId)) continue;
@@ -1967,8 +2091,23 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1967
2091
  return null;
1968
2092
  }
1969
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
+ }
1970
2109
  enabledBrokerIds() {
1971
- return this.config.knownBrokers.filter((broker) => this.config[`exportTo:${broker.id}`] === true).map((broker) => broker.id);
2110
+ return this.enabled;
1972
2111
  }
1973
2112
  brokerName(brokerId) {
1974
2113
  return this.config.knownBrokers.find((broker) => broker.id === brokerId)?.name ?? brokerId;
@@ -2307,7 +2446,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
2307
2446
  * exporters' identically-named fields and the operator sees another
2308
2447
  * exporter's value in this one's toggle.
2309
2448
  */
2310
- buildContribution(deviceId) {
2449
+ async buildContribution(deviceId) {
2450
+ await this.refreshEnabledBrokers(this.config.knownBrokers);
2311
2451
  const idStr = String(deviceId);
2312
2452
  const enabled = this.enabledBrokerIds();
2313
2453
  const fields = enabled.map((brokerId) => ({
@@ -1,4 +1,4 @@
1
- import { Ct as EventCategory, O as deviceExportCapability, c as addonRoutesCapability, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, st as BaseAddon, v as buildAddonRouteProvider, yt as string } from "../dist-reK3lvnr.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
  /**
@@ -866,6 +866,112 @@ function pruneBrokers(membership, knownBrokerIds) {
866
866
  for (const brokerId of kept) next[brokerId] = membership[brokerId] ?? [];
867
867
  return next;
868
868
  }
869
+ /**
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.
902
+ */
903
+ var HOME_ASSISTANT_OAUTH_INTEGRATION = {
904
+ integrationId: "homeassistant",
905
+ displayName: "Home Assistant",
906
+ requestedScopes: [
907
+ {
908
+ type: "category",
909
+ target: "device",
910
+ access: ["view", "create"]
911
+ },
912
+ {
913
+ type: "capability",
914
+ target: "device-manager",
915
+ access: ["view"]
916
+ },
917
+ {
918
+ type: "capability",
919
+ target: "device-state",
920
+ access: ["view"]
921
+ },
922
+ {
923
+ type: "capability",
924
+ target: "pipeline-orchestrator",
925
+ access: ["view", "create"]
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
+ })
961
+ ],
962
+ allowedRedirectPrefixes: ["https://my.home-assistant.io/redirect/oauth"],
963
+ allowedPrivateHostPaths: ["/auth/external/callback"],
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
974
+ };
869
975
  //#endregion
870
976
  //#region src/ha-export/push-client.ts
871
977
  /** How long state updates accumulate before one POST carries them all. */
@@ -1260,6 +1366,19 @@ function projectCameraSwitches(deviceKey, switches) {
1260
1366
  value: bool(sw.enabled)
1261
1367
  }));
1262
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
+ }
1263
1382
  //#endregion
1264
1383
  //#region src/ha-export/ha-export.addon.ts
1265
1384
  /**
@@ -1297,8 +1416,6 @@ function projectCameraSwitches(deviceKey, switches) {
1297
1416
  * different sets of cameras, and the per-device Export panel shows
1298
1417
  * one switch per broker over the single membership store.
1299
1418
  */
1300
- /** The addon that owns the import direction — never export back to it. */
1301
- var HA_PROVIDER_ADDON_ID = "provider-homeassistant";
1302
1419
  var ADDON_ID = "homeassistant-export";
1303
1420
  /** Where the custom component registers its push view inside HA. */
1304
1421
  var PUSH_PATH = "/api/camstack/push";
@@ -1369,12 +1486,10 @@ var HaExportAddon = class extends BaseAddon {
1369
1486
  } else this.mediaSecret = existing;
1370
1487
  this.wireListeners();
1371
1488
  await this.serveMediaPlane();
1372
- try {
1373
- await this.reconcile("boot");
1374
- } catch (err) {
1489
+ this.reconcile("boot").catch((err) => {
1375
1490
  this.lastError = errMsg(err);
1376
1491
  this.ctx.logger.warn("ha-export: initial reconcile failed", { meta: { error: this.lastError } });
1377
- }
1492
+ });
1378
1493
  this.startTimer();
1379
1494
  this.ctx.addDisposer(async () => {
1380
1495
  this.stopTimer();
@@ -1382,33 +1497,40 @@ var HaExportAddon = class extends BaseAddon {
1382
1497
  for (const link of this.links.values()) await link.client.dispose();
1383
1498
  this.links.clear();
1384
1499
  });
1385
- return [{
1386
- capability: deviceExportCapability,
1387
- provider: {
1388
- getStatus: async () => this.buildStatus(),
1389
- listSupportedDeviceKinds: async () => [...SUPPORTED_DEVICE_KINDS],
1390
- listExposedDevices: async () => exposedDeviceIds(this.config.membership, this.enabledBrokerIds()).map((deviceId) => ({
1391
- deviceId,
1392
- exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
1393
- })),
1394
- exposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, true),
1395
- unexposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, false),
1396
- getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
1397
- getDeviceLiveContribution: async () => null,
1398
- applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
1399
- }
1400
- }, {
1401
- capability: addonRoutesCapability,
1402
- provider: buildAddonRouteProvider(ADDON_ID, [{
1403
- method: "POST",
1404
- path: "/command",
1405
- access: "authenticated",
1406
- description: "Home Assistant → CamStack command: {topic, value}",
1407
- handler: async (request, reply) => {
1408
- await this.handleCommandRoute(request.body, reply);
1500
+ return [
1501
+ {
1502
+ capability: deviceExportCapability,
1503
+ provider: {
1504
+ getStatus: async () => this.buildStatus(),
1505
+ listSupportedDeviceKinds: async () => [...SUPPORTED_DEVICE_KINDS],
1506
+ listExposedDevices: async () => exposedDeviceIds(this.config.membership, this.enabledBrokerIds()).map((deviceId) => ({
1507
+ deviceId,
1508
+ exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
1509
+ })),
1510
+ exposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, true),
1511
+ unexposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, false),
1512
+ getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
1513
+ getDeviceLiveContribution: async () => null,
1514
+ applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
1409
1515
  }
1410
- }])
1411
- }];
1516
+ },
1517
+ {
1518
+ capability: addonRoutesCapability,
1519
+ provider: buildAddonRouteProvider(ADDON_ID, [{
1520
+ method: "POST",
1521
+ path: "/command",
1522
+ access: "authenticated",
1523
+ description: "Home Assistant → CamStack command: {topic, value}",
1524
+ handler: async (request, reply) => {
1525
+ await this.handleCommandRoute(request.body, reply);
1526
+ }
1527
+ }])
1528
+ },
1529
+ {
1530
+ capability: oauthIntegrationCapability,
1531
+ provider: { getDescriptor: async () => HOME_ASSISTANT_OAUTH_INTEGRATION }
1532
+ }
1533
+ ];
1412
1534
  }
1413
1535
  async onConfigChanged() {
1414
1536
  await this.reconcile("config-changed");
@@ -1655,7 +1777,7 @@ var HaExportAddon = class extends BaseAddon {
1655
1777
  * exported back to it: HA would mirror its own entity across the
1656
1778
  * bridge, and any automation touching it would round-trip.
1657
1779
  */
1658
- if (device.addonId === HA_PROVIDER_ADDON_ID) {
1780
+ if (device.addonId === "provider-homeassistant") {
1659
1781
  this.ctx.logger.warn("ha-export: refusing to export a device imported from Home Assistant", { tags: { deviceId: numericId } });
1660
1782
  continue;
1661
1783
  }
@@ -1875,13 +1997,14 @@ var HaExportAddon = class extends BaseAddon {
1875
1997
  * ones the operator ticked.
1876
1998
  *
1877
1999
  * Credentials come through the `broker` cap — the sanctioned no-import
1878
- * path — and the owning `addonId` is sent with the read: `broker` is a
1879
- * COLLECTION cap, so an unpinned id-keyed call routes to the
1880
- * first-registered provider, which returns `null` for a broker it does
1881
- * 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.
1882
2005
  */
1883
2006
  async refreshBrokers() {
1884
- 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));
1885
2008
  const known = haBrokers.map((broker) => ({
1886
2009
  id: broker.id,
1887
2010
  name: broker.name
@@ -1891,6 +2014,7 @@ var HaExportAddon = class extends BaseAddon {
1891
2014
  knownBrokers: known,
1892
2015
  membership: pruned
1893
2016
  });
2017
+ await this.refreshEnabledBrokers(known);
1894
2018
  const wanted = new Set(this.enabledBrokerIds());
1895
2019
  for (const [brokerId, link] of this.links) {
1896
2020
  if (wanted.has(brokerId)) continue;
@@ -1962,8 +2086,23 @@ var HaExportAddon = class extends BaseAddon {
1962
2086
  return null;
1963
2087
  }
1964
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
+ }
1965
2104
  enabledBrokerIds() {
1966
- return this.config.knownBrokers.filter((broker) => this.config[`exportTo:${broker.id}`] === true).map((broker) => broker.id);
2105
+ return this.enabled;
1967
2106
  }
1968
2107
  brokerName(brokerId) {
1969
2108
  return this.config.knownBrokers.find((broker) => broker.id === brokerId)?.name ?? brokerId;
@@ -2302,7 +2441,8 @@ var HaExportAddon = class extends BaseAddon {
2302
2441
  * exporters' identically-named fields and the operator sees another
2303
2442
  * exporter's value in this one's toggle.
2304
2443
  */
2305
- buildContribution(deviceId) {
2444
+ async buildContribution(deviceId) {
2445
+ await this.refreshEnabledBrokers(this.config.knownBrokers);
2306
2446
  const idStr = String(deviceId);
2307
2447
  const enabled = this.enabledBrokerIds();
2308
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.17",
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
  {
@@ -106,6 +105,9 @@
106
105
  },
107
106
  {
108
107
  "name": "addon-routes"
108
+ },
109
+ {
110
+ "name": "oauth-integration"
109
111
  }
110
112
  ]
111
113
  }