@camstack/addon-provider-bosch 0.1.35 → 0.1.37

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.
@@ -1,5 +1,5 @@
1
- import { A as isSecretMarkerLike, B as unknown, D as customAction, E as createEvent, H as DeviceType, I as object, L as record, M as array, N as boolean, O as defineCustomActions, P as literal, R as string, S as runningStateCapability, U as errMsg, V as DeviceRole, W as EventCategory, _ as ingestReading, b as quantityFromUnit, c as binaryCapability, d as consumablesCapability, f as deviceAdoptionCapability, h as eventEmitterCapability, i as RUNNING_STATE_COLD, j as _enum, k as isSecretMarker, m as enumSensorCapability, n as BaseDeviceProvider, o as VendorLoginProxy, p as dishwasherCapability, r as DishwasherOperationStateSchema, t as BaseDevice, u as connectionTestCapability, v as numericSensorCapability, x as runningFromDishwasherOperation, y as powerMeterCapability, z as union } from "../dist-CkMPcvma.mjs";
2
- import { a as createSignInAttemptStore, i as openSignInProxy, n as cascadeRemoveDevicesForIntegration, o as loginFailureForAttempt, r as createSignInClaims, s as parseOAuthCallback, t as setIntegrationDevicesOnline } from "../account-integration-offline-Bt3N9Zdk.mjs";
1
+ import { $ as errMsg, B as isSecretMarkerLike, C as ingestReading, D as quantityFromUnit, E as powerMeterCapability, F as defineAddonMigrations, H as array, I as defineCustomActions, J as string, K as object, L as defineEntryKind, N as createEvent, O as runningFromDishwasherOperation, P as customAction, Q as DeviceType, R as entryBody, T as numericSensorCapability, U as boolean, V as _enum, W as literal, X as unknown, Y as union, Z as DeviceRole, _ as defaultIntegrationApiOver, a as BaseDevice, b as enumSensorCapability, c as RUNNING_STATE_COLD, d as VendorLoginProxy, et as EventCategory, g as consumablesCapability, h as connectionTestCapability, i as testBoschInstance, k as runningStateCapability, l as RecordIntegrationInstances, n as boschRowsPorts, o as BaseDeviceProvider, p as binaryCapability, q as record, r as keepBodyKeys, s as DishwasherOperationStateSchema, t as boschRowsMigration, v as deviceAdoptionCapability, w as integrationInstancesCapability, x as eventEmitterCapability, y as dishwasherCapability, z as isSecretMarker } from "../bosch-instances-BQiyzlPt.mjs";
2
+ import { a as createSignInAttemptStore, i as openSignInProxy, n as cascadeRemoveDevicesForIntegration, o as loginFailureForAttempt, r as createSignInClaims, s as parseOAuthCallback, t as setIntegrationDevicesOnline } from "../account-integration-offline-nChgZUlC.mjs";
3
3
  import { createHash, randomBytes } from "node:crypto";
4
4
  import { connect } from "node:tls";
5
5
  import { inflateRawSync } from "node:zlib";
@@ -1017,15 +1017,6 @@ object({
1017
1017
  });
1018
1018
  /** Setting key of the keychain-stored credentials of every paired appliance. */
1019
1019
  var APPLIANCE_SECRETS_KEY = "applianceSecrets";
1020
- /**
1021
- * The integration-settings keys that hold a credential — what a write from this addon names
1022
- * as `secretKeys` so the hub moves them into the keychain.
1023
- */
1024
- var HOME_CONNECT_SECRET_KEYS = [
1025
- "token",
1026
- "refresh",
1027
- APPLIANCE_SECRETS_KEY
1028
- ];
1029
1020
  /** One appliance as persisted in the integration's `appliances` setting: no credential on it. */
1030
1021
  var homeConnectApplianceRowSchema = object({
1031
1022
  haId: string().min(1),
@@ -1127,7 +1118,7 @@ function splitApplianceSecrets(appliances) {
1127
1118
  };
1128
1119
  }
1129
1120
  /**
1130
- * Before `integrations.setSettings`: keep objects as-is (serializes as `json`), parse
1121
+ * Before the record write: keep objects as-is (serializes as `json`), parse
1131
1122
  * textarea JSON strings into objects so they are not re-serialized as `string` and
1132
1123
  * corrupted on read by `deserializeSetting(String(object))` — and move any credential
1133
1124
  * still sitting on an appliance row into `applianceSecrets`, so a row is never written
@@ -1184,29 +1175,6 @@ function moveInlineSecrets(settings) {
1184
1175
  ...Object.keys(merged).length > 0 ? { [APPLIANCE_SECRETS_KEY]: JSON.stringify(merged) } : {}
1185
1176
  };
1186
1177
  }
1187
- /**
1188
- * One-time adopt: the settings that must be written so a pairing key found on an appliance
1189
- * row moves into the keychain. `null` when nothing needs moving. The caller writes it with
1190
- * {@link HOME_CONNECT_SECRET_KEYS} as `secretKeys`.
1191
- */
1192
- function legacyApplianceSecretPatch(settings) {
1193
- const moved = moveInlineSecrets({
1194
- ...settings,
1195
- appliances: parseJsonValue(settings["appliances"])
1196
- });
1197
- const rows = moved["appliances"];
1198
- const secrets = moved[APPLIANCE_SECRETS_KEY];
1199
- if (!Array.isArray(rows) || typeof secrets !== "string") return null;
1200
- const parsedRows = [];
1201
- for (const row of rows) {
1202
- const parsed = homeConnectApplianceRowSchema.safeParse(row);
1203
- if (parsed.success) parsedRows.push(parsed.data);
1204
- }
1205
- return {
1206
- appliances: parsedRows,
1207
- applianceSecrets: secrets
1208
- };
1209
- }
1210
1178
  function connectionFromSettings(settings) {
1211
1179
  const normalized = homeConnectSettingsForPersist(settings);
1212
1180
  const rows = legacyRowsOf(normalized["appliances"]);
@@ -3690,33 +3658,23 @@ function buildHomeConnectConnectionTestProvider(deps) {
3690
3658
  }
3691
3659
  //#endregion
3692
3660
  //#region src/home-connect/home-connect-connection-persist.ts
3693
- function locked(value) {
3694
- return isSecretMarker(value) || isSecretMarkerLike(value);
3695
- }
3696
3661
  /**
3697
3662
  * Persist what a connection learned at runtime — enriched IDDF entity maps and a rotated
3698
- * access / refresh token — in ONE `setSettings` write. The tokens are named in `secretKeys`
3699
- * so they land in the keychain, never on the plain blob. Writes nothing when a stored secret
3700
- * cannot be read right now: the write would drop a key it cannot see or be refused. Never
3701
- * throws and never logs a value.
3663
+ * access / refresh token — in ONE patch of the record. Writes nothing when the stored secrets
3664
+ * cannot be read right now: the patch would replace a key it cannot see. Never throws and
3665
+ * never logs a value.
3702
3666
  */
3703
3667
  function buildHomeConnectConnectionPersister(deps) {
3704
3668
  return async (integrationId, connection) => {
3705
3669
  if (!connection.appliances || connection.appliances.length === 0) return;
3706
3670
  try {
3707
- const settings = await deps.getSettings(integrationId);
3708
- const stored = readApplianceSecrets(settings[APPLIANCE_SECRETS_KEY]);
3709
- const lockedTokens = ["token", "refresh"].filter((key) => locked(settings[key]));
3710
- if (stored.kind === "unresolved" || stored.kind === "invalid" || lockedTokens.length > 0) {
3711
- deps.logger.warn("Home Connect not persisting the connection — stored secrets are locked or unreadable", {
3712
- tags: { integrationId },
3713
- meta: {
3714
- applianceSecrets: stored.kind,
3715
- lockedTokens
3716
- }
3717
- });
3671
+ const stored = await deps.read(integrationId);
3672
+ if (stored === null) return;
3673
+ if (stored.locked) {
3674
+ deps.logger.warn("Home Connect not persisting the connection — stored secrets are locked or unreadable", { tags: { integrationId } });
3718
3675
  return;
3719
3676
  }
3677
+ const settings = stored.settings;
3720
3678
  const refresh = connection.refresh ?? "";
3721
3679
  const merged = homeConnectSettingsForPersist({
3722
3680
  ...settings,
@@ -3724,10 +3682,11 @@ function buildHomeConnectConnectionPersister(deps) {
3724
3682
  ...refresh.length > 0 ? { refresh } : {},
3725
3683
  appliances: mergeAppliancesForPersist(settings["appliances"], connection.appliances)
3726
3684
  });
3727
- await deps.setSettings({
3728
- id: integrationId,
3729
- settings: merged,
3730
- secretKeys: [...HOME_CONNECT_SECRET_KEYS]
3685
+ await deps.patch(integrationId, {
3686
+ token: merged["token"],
3687
+ ...merged["refresh"] !== void 0 ? { refresh: merged["refresh"] } : {},
3688
+ appliances: merged["appliances"],
3689
+ ...merged["applianceSecrets"] !== void 0 ? { [APPLIANCE_SECRETS_KEY]: merged[APPLIANCE_SECRETS_KEY] } : {}
3731
3690
  });
3732
3691
  } catch (err) {
3733
3692
  deps.logger.warn("Home Connect failed to persist the connection", {
@@ -5265,43 +5224,136 @@ var HomeConnectApplianceDevice = class extends BaseDevice {
5265
5224
  return out;
5266
5225
  }
5267
5226
  };
5268
- //#endregion
5269
- //#region src/home-connect/home-connect-secret-adopt.ts
5227
+ var homeConnectConnectionsEntryKind = defineEntryKind("connections", {
5228
+ title: "Home Connect accounts",
5229
+ schema: entryBody({
5230
+ token: string().optional().meta({ secret: true }),
5231
+ refresh: string().optional().meta({ secret: true }),
5232
+ /** JSON of `haId -> { psk, iv64? }`: one string in the keychain. */
5233
+ applianceSecrets: string().optional().meta({ secret: true }),
5234
+ appliances: array(homeConnectApplianceRowSchema).optional(),
5235
+ accountLabel: string().optional()
5236
+ }),
5237
+ writeTrigger: "an operator signs in, edits or removes a Home Connect account, or a connection stores what it learned",
5238
+ maxDataBytes: 1024 * 1024
5239
+ });
5240
+ /** Every kind this addon declares, keyed by its name. */
5241
+ var homeConnectRecordKinds = { connections: homeConnectConnectionsEntryKind };
5242
+ var LEGACY_KEYS = [
5243
+ "token",
5244
+ "refresh",
5245
+ APPLIANCE_SECRETS_KEY,
5246
+ "appliances",
5247
+ "accountLabel",
5248
+ "accountEmail",
5249
+ "accountPassword",
5250
+ "authorizeUrl",
5251
+ "verifier",
5252
+ "callback",
5253
+ "state"
5254
+ ];
5255
+ var SECRET_KEYS = [
5256
+ "token",
5257
+ "refresh",
5258
+ APPLIANCE_SECRETS_KEY
5259
+ ];
5260
+ /** The `appliances` row value as an array: a JSON string is parsed, anything else is as stored. */
5261
+ function parseStoredRows(raw) {
5262
+ if (typeof raw !== "string") return raw;
5263
+ try {
5264
+ return JSON.parse(raw);
5265
+ } catch {
5266
+ return;
5267
+ }
5268
+ }
5269
+ function text(value) {
5270
+ return typeof value === "string" && value.length > 0 ? value : void 0;
5271
+ }
5270
5272
  /**
5271
- * One-time adopt: an integration saved before the keychain move carries each appliance's
5272
- * pairing key (`psk`, `iv64`) on its row, in clear text. Plan the move into `applianceSecrets`
5273
- * (keychain), with the rows stripped.
5274
- *
5275
- * `null` when there is nothing to move — settings already migrated, or the stored keys are
5276
- * locked (the move could not see what it would replace). Idempotent.
5273
+ * The record body of one account, from its legacy rows (secrets resolved). A pairing key still
5274
+ * sitting on an appliance row (the shape before the keychain move) is carried into
5275
+ * `applianceSecrets` here, once, and never again.
5277
5276
  */
5278
- function planLegacyApplianceSecretAdopt(deps, integrationId, settings) {
5279
- const patch = legacyApplianceSecretPatch(settings);
5280
- if (patch === null) return null;
5281
- const next = {
5282
- ...settings,
5283
- ...patch
5277
+ function homeConnectBodyFromRows(values, log, integration) {
5278
+ const dropped = Object.keys(values).filter((key) => !LEGACY_KEYS.includes(key));
5279
+ if (dropped.length > 0) log.warn(`integration '${integration.id}': legacy settings nothing reads any more are dropped: ${dropped.join(", ")}`);
5280
+ const normalized = homeConnectSettingsForPersist({ ...values });
5281
+ const storedRows = parseStoredRows(values["appliances"]);
5282
+ if (Array.isArray(storedRows)) for (const row of storedRows) {
5283
+ const parsed = homeConnectApplianceRowSchema.safeParse(row);
5284
+ if (parsed.success) continue;
5285
+ const haId = typeof row === "object" && row !== null && typeof Reflect.get(row, "haId") === "string" ? String(Reflect.get(row, "haId")) : "(no haId)";
5286
+ const paths = parsed.error.issues.map((issue) => issue.path.join(".") || "(row)").join(", ");
5287
+ log.warn(`integration '${integration.id}': appliance row '${haId}' could not be read and is dropped (invalid: ${paths})`);
5288
+ }
5289
+ const appliances = [];
5290
+ const keptRows = normalized["appliances"];
5291
+ if (Array.isArray(keptRows)) for (const row of keptRows) {
5292
+ const parsed = homeConnectApplianceRowSchema.safeParse(row);
5293
+ if (parsed.success) appliances.push(parsed.data);
5294
+ }
5295
+ const token = text(normalized["token"]);
5296
+ const refresh = text(normalized["refresh"]);
5297
+ const applianceSecrets = text(normalized[APPLIANCE_SECRETS_KEY]);
5298
+ const accountLabel = text(normalized["accountLabel"]);
5299
+ return {
5300
+ name: integration.name,
5301
+ ...token !== void 0 ? { token } : {},
5302
+ ...refresh !== void 0 ? { refresh } : {},
5303
+ ...applianceSecrets !== void 0 ? { applianceSecrets } : {},
5304
+ ...appliances.length > 0 ? { appliances } : {},
5305
+ ...accountLabel !== void 0 ? { accountLabel } : {}
5284
5306
  };
5307
+ }
5308
+ /** The whole `bosch-home-connect` catalogue (D987): offered to the engine at `onInitialize`. */
5309
+ var HOME_CONNECT_MIGRATIONS = [boschRowsMigration({
5310
+ addonId: "bosch-home-connect",
5311
+ id: "bosch-home-connect/2026-11-05-integration-rows-to-record",
5312
+ description: "Move every Home Connect account into a typed instance record keyed by its registry id (pairing keys still on an appliance row go to the appliance secrets), then clear the legacy integration settings rows and their keychain values",
5313
+ fixtureMark: "a legacy setting key the migration does not know",
5314
+ kind: homeConnectConnectionsEntryKind,
5315
+ bodyFrom: homeConnectBodyFromRows
5316
+ })];
5317
+ function homeConnectRowsPorts(deps) {
5318
+ return boschRowsPorts({
5319
+ ...deps,
5320
+ kind: homeConnectConnectionsEntryKind
5321
+ });
5322
+ }
5323
+ async function homeConnectStoredOf(instances, id) {
5324
+ const read = await instances.readResolved(id);
5325
+ if (read === null) return null;
5285
5326
  return {
5286
- settings: next,
5287
- persist: async () => {
5288
- try {
5289
- await deps.setSettings({
5290
- id: integrationId,
5291
- settings: next,
5292
- secretKeys: [...HOME_CONNECT_SECRET_KEYS]
5293
- });
5294
- deps.logger.info("Home Connect moved the appliance pairing keys into the keychain", {
5295
- tags: { integrationId },
5296
- meta: { appliances: patch.appliances.length }
5297
- });
5298
- } catch (err) {
5299
- deps.logger.warn("Home Connect could not move the appliance pairing keys into the keychain", {
5300
- tags: { integrationId },
5301
- meta: { error: errMsg(err) }
5302
- });
5303
- }
5304
- }
5327
+ settings: { ...read.body },
5328
+ locked: read.secrets === "unavailable"
5329
+ };
5330
+ }
5331
+ function buildHomeConnectInstances(records, deps) {
5332
+ const handle = records.kind(homeConnectConnectionsEntryKind);
5333
+ const instances = new RecordIntegrationInstances(records, homeConnectConnectionsEntryKind, {
5334
+ description: {
5335
+ role: "devices",
5336
+ instanceMode: "multiple",
5337
+ kind: "account",
5338
+ canTest: true
5339
+ },
5340
+ formSchema: () => buildHomeConnectFormSchema(),
5341
+ initialBody: (name) => ({ name }),
5342
+ configured: (body) => connectionFromSettings(body) !== null,
5343
+ onChanged: async (_id, change) => {
5344
+ if (change !== "deleted") await deps.onChanged();
5345
+ },
5346
+ test: (input) => testBoschInstance({
5347
+ ...input,
5348
+ tester: deps.tester,
5349
+ handle,
5350
+ secretKeys: SECRET_KEYS
5351
+ })
5352
+ });
5353
+ return {
5354
+ instances,
5355
+ handle,
5356
+ provider: keepBodyKeys(instances.toProvider(), new Set(Object.keys(homeConnectConnectionsEntryKind.schema.shape)))
5305
5357
  };
5306
5358
  }
5307
5359
  //#endregion
@@ -5316,6 +5368,7 @@ var BoschHomeConnectProviderAddon = class extends BaseDeviceProvider {
5316
5368
  [DeviceType.EventEmitter]: HomeConnectEventDevice
5317
5369
  };
5318
5370
  clients = null;
5371
+ instances = null;
5319
5372
  reconciler = null;
5320
5373
  loginProxy = null;
5321
5374
  loginPlane = null;
@@ -5324,6 +5377,20 @@ var BoschHomeConnectProviderAddon = class extends BaseDeviceProvider {
5324
5377
  }
5325
5378
  async onInitialize() {
5326
5379
  const regs = this.providerRegistrationsFromSuper(await super.onInitialize());
5380
+ const records = this.ctx.records;
5381
+ if (records === void 0) throw new Error("bosch-home-connect: ctx.records is required (hub-only addon)");
5382
+ await records.declare(homeConnectRecordKinds);
5383
+ await this.runMigrations(defineAddonMigrations(HOME_CONNECT_MIGRATIONS, homeConnectRowsPorts({
5384
+ api: defaultIntegrationApiOver(this.ctx.api),
5385
+ secrets: this.ctx.secrets ?? null,
5386
+ records,
5387
+ warn: (message) => this.ctx.logger.warn(message)
5388
+ })));
5389
+ const built = buildHomeConnectInstances(records, {
5390
+ onChanged: () => this.reconcileIntegrations(),
5391
+ tester: this.buildConnectionTestProvider()
5392
+ });
5393
+ this.instances = built;
5327
5394
  await this.serveLoginProxy();
5328
5395
  this.clients = new HomeConnectClientRegistry(this.ctx.logger, { onConnectionUpdated: (integrationId, connection) => {
5329
5396
  this.persistIntegrationAppliances(integrationId, connection);
@@ -5349,6 +5416,10 @@ var BoschHomeConnectProviderAddon = class extends BaseDeviceProvider {
5349
5416
  {
5350
5417
  capability: connectionTestCapability,
5351
5418
  provider: this.buildConnectionTestProvider()
5419
+ },
5420
+ {
5421
+ capability: integrationInstancesCapability,
5422
+ provider: built.provider
5352
5423
  }
5353
5424
  ],
5354
5425
  customActions: homeConnectActions,
@@ -5408,6 +5479,7 @@ var BoschHomeConnectProviderAddon = class extends BaseDeviceProvider {
5408
5479
  this.ctx.logger.warn("Home Connect shutdown error", { meta: { error: errMsg(err) } });
5409
5480
  }
5410
5481
  this.clients = null;
5482
+ this.instances = null;
5411
5483
  await super.onShutdown();
5412
5484
  }
5413
5485
  requireClients() {
@@ -5425,8 +5497,8 @@ var BoschHomeConnectProviderAddon = class extends BaseDeviceProvider {
5425
5497
  /** Enriched IDDF maps and a rotated token pair, in one keychain-aware write. */
5426
5498
  persistIntegrationAppliances(integrationId, connection) {
5427
5499
  return buildHomeConnectConnectionPersister({
5428
- getSettings: (id) => this.ctx.api.integrations.getSettings.query({ id }),
5429
- setSettings: (input) => this.ctx.api.integrations.setSettings.mutate(input),
5500
+ read: (id) => homeConnectStoredOf(this.requireInstances().instances, id),
5501
+ patch: (id, patch) => this.requireInstances().handle.patch(id, patch),
5430
5502
  logger: this.ctx.logger
5431
5503
  })(integrationId, connection);
5432
5504
  }
@@ -5435,15 +5507,14 @@ var BoschHomeConnectProviderAddon = class extends BaseDeviceProvider {
5435
5507
  try {
5436
5508
  const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id && i.enabled);
5437
5509
  const surviving = /* @__PURE__ */ new Set();
5438
- const secretMoves = [];
5439
5510
  for (const integration of mine) try {
5440
- const stored = await this.ctx.api.integrations.getSettings.query({ id: integration.id });
5441
- const adopt = planLegacyApplianceSecretAdopt({
5442
- setSettings: (input) => this.ctx.api.integrations.setSettings.mutate(input),
5443
- logger: this.ctx.logger
5444
- }, integration.id, stored);
5445
- if (adopt !== null) secretMoves.push(adopt);
5446
- const settings = adopt?.settings ?? stored;
5511
+ const stored = await homeConnectStoredOf(this.requireInstances().instances, integration.id);
5512
+ if (stored === null) continue;
5513
+ if (stored.locked) {
5514
+ this.ctx.logger.warn("Home Connect reconcile skipped: the keychain is locked", { meta: { integrationId: integration.id } });
5515
+ continue;
5516
+ }
5517
+ const settings = stored.settings;
5447
5518
  const connection = connectionFromSettings(settings);
5448
5519
  if (!connection) {
5449
5520
  logHomeConnectSettingsGap(this.ctx.logger, integration.id, settings);
@@ -5467,7 +5538,6 @@ var BoschHomeConnectProviderAddon = class extends BaseDeviceProvider {
5467
5538
  error: errMsg(err)
5468
5539
  } });
5469
5540
  }
5470
- for (const move of secretMoves) move.persist();
5471
5541
  const vanished = reg.list().filter((id) => !surviving.has(id));
5472
5542
  await reg.retain(surviving);
5473
5543
  const deviceReg = this.ctx.kernel.deviceRegistry;
@@ -5595,9 +5665,6 @@ var BoschHomeConnectProviderAddon = class extends BaseDeviceProvider {
5595
5665
  logger: this.ctx.logger
5596
5666
  }, trigger);
5597
5667
  }
5598
- globalSettingsSchema() {
5599
- return buildHomeConnectFormSchema();
5600
- }
5601
5668
  async supportsManualCreation() {
5602
5669
  return false;
5603
5670
  }
@@ -5607,6 +5674,10 @@ var BoschHomeConnectProviderAddon = class extends BaseDeviceProvider {
5607
5674
  async onCreateDevice(_type, _config) {
5608
5675
  throw new Error("Home Connect devices are adopted from the gateway");
5609
5676
  }
5677
+ requireInstances() {
5678
+ if (!this.instances) throw new Error("Home Connect provider not initialised");
5679
+ return this.instances;
5680
+ }
5610
5681
  setIntegrationDevicesOnline(integrationId, online) {
5611
5682
  const reg = this.ctx.kernel.deviceRegistry;
5612
5683
  if (!reg) return;