@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 +1 -1
- package/dist/addon.mjs +1 -1
- package/dist/{dist-reK3lvnr.mjs → dist-BSQpoIVg.mjs} +128 -6
- package/dist/{dist-DIa87XAf.js → dist-Bz5ENMVc.js} +133 -5
- package/dist/ha-export/ha-export.addon.js +181 -41
- package/dist/ha-export/ha-export.addon.mjs +181 -41
- package/package.json +5 -3
package/dist/addon.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_dist = require("./dist-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
deviceId
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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 ===
|
|
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
|
|
1884
|
-
* COLLECTION cap, so an unpinned
|
|
1885
|
-
* first-registered provider,
|
|
1886
|
-
*
|
|
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 =
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
deviceId
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
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 ===
|
|
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
|
|
1879
|
-
* COLLECTION cap, so an unpinned
|
|
1880
|
-
* first-registered provider,
|
|
1881
|
-
*
|
|
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 =
|
|
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.
|
|
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.
|
|
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
|
}
|