@camstack/addon-import-alexa 0.2.186 → 0.2.187

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/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.187] - 2026-11-05
11
+
12
+ ### Fixed
13
+
14
+ - The integration-settings drop no longer refuses on an Alexa account; its legacy broker link is carried onto the broker record first
15
+
10
16
  ## [0.2.186] - 2026-11-05
11
17
 
12
18
  ### Changed
package/dist/addon.js CHANGED
@@ -8841,6 +8841,13 @@ var RecordsError = class extends Error {
8841
8841
  this.detail = detail;
8842
8842
  }
8843
8843
  };
8844
+ /** A write guarded by `ifRev` found another revision (or an existing row for `ifRev: 0`). */
8845
+ var RevConflictError = class extends RecordsError {
8846
+ constructor(detail) {
8847
+ super("rev-conflict", detail);
8848
+ this.name = "RevConflictError";
8849
+ }
8850
+ };
8844
8851
  /** The keychain could not store or serve a value: explicit, never an empty string. */
8845
8852
  var RecordSecretUnavailableError = class extends RecordsError {
8846
8853
  constructor(detail) {
@@ -14770,6 +14777,72 @@ function flattenPortsOver(handle) {
14770
14777
  };
14771
14778
  }
14772
14779
  /**
14780
+ * The addon-side ports of `moveIntegrationRowsToRecords` (planning#416). An addon runs the move
14781
+ * from its own process, so the leftover rows are reached through the migration-only door
14782
+ * (`migrations.readLegacyIntegrationRows` / `clearLegacyIntegrationRows`) on the hub's connection.
14783
+ * The door returns markers. This process is the owner, so it resolves and releases them with
14784
+ * `resolveSecretMarkers` — the same call the old settings door used — and only then asks the
14785
+ * door to delete the rows. The addon's own record kind is its records handle.
14786
+ */
14787
+ async function resolvedRows(stored, secrets) {
14788
+ if (secrets === null) return stored;
14789
+ const { value, unresolved } = await resolveSecretMarkers(stored, secrets);
14790
+ if (unresolved.length > 0) throw new Error(`${unresolved.length} secret(s) cannot be read from the keychain`);
14791
+ return value;
14792
+ }
14793
+ function integrationRowsPortsOverApi(deps) {
14794
+ const secrets = deps.secrets ?? null;
14795
+ return {
14796
+ warn: (message) => deps.log.warn(message),
14797
+ listIntegrations: async (addonId) => (await deps.api.list()).filter((i) => i.addonId === addonId).map((i) => ({
14798
+ id: i.id,
14799
+ name: i.name
14800
+ })),
14801
+ readRows: async (integrationId) => resolvedRows(await deps.api.readLegacyIntegrationRows({ integrationId }), secrets),
14802
+ putRecord: async (id, body) => {
14803
+ if (await deps.records.get(id) !== null) return "exists";
14804
+ try {
14805
+ await deps.records.put({
14806
+ id,
14807
+ data: body
14808
+ }, { ifRev: 0 });
14809
+ } catch (err) {
14810
+ if (err instanceof RevConflictError) return "exists";
14811
+ throw err;
14812
+ }
14813
+ return "created";
14814
+ },
14815
+ readRecord: async (id) => (await deps.records.getResolved(id))?.data ?? null,
14816
+ deleteRecord: async (id) => {
14817
+ await deps.records.delete(id);
14818
+ },
14819
+ clearRows: async (integrationId) => {
14820
+ const refs = collectSecretMarkerRefs(await deps.api.readLegacyIntegrationRows({ integrationId }));
14821
+ if (refs.length > 0 && secrets !== null) await secrets.delete(refs);
14822
+ await deps.api.clearLegacyIntegrationRows({ integrationId });
14823
+ }
14824
+ };
14825
+ }
14826
+ function defaultIntegrationApiOver(api) {
14827
+ return {
14828
+ list: () => api.integrations.list.query(),
14829
+ readLegacyIntegrationRows: (input) => api.migrations.readLegacyIntegrationRows.query(input),
14830
+ clearLegacyIntegrationRows: (input) => api.migrations.clearLegacyIntegrationRows.mutate(input),
14831
+ listDevices: (addonId) => api.deviceManager.listAll.query({
14832
+ addonId,
14833
+ projection: "slim"
14834
+ }),
14835
+ setDeviceIntegrationId: async (deviceId, integrationId) => {
14836
+ await api.deviceManager.setIntegrationId.mutate({
14837
+ deviceId,
14838
+ integrationId
14839
+ });
14840
+ },
14841
+ readDeviceHasConfig: async (deviceId) => Object.keys((await api.deviceManager.getDevice.query({ deviceId }))?.config ?? {}).length > 0,
14842
+ readDeviceIntegrationId: async (deviceId) => (await api.deviceManager.getDevice.query({ deviceId }))?.integrationId ?? null
14843
+ };
14844
+ }
14845
+ /**
14773
14846
  * The trainer contract — what the hub hands the cloud trainer and what it
14774
14847
  * gets back (cloud training, D805+).
14775
14848
  *
@@ -140596,7 +140669,7 @@ function isStringOrObject(value) {
140596
140669
  //#endregion
140597
140670
  //#region src/alexa-proxy-login-manager.ts
140598
140671
  /** The addon id the result page names to return to. */
140599
- var ADDON_ID = "import-alexa";
140672
+ var ADDON_ID$1 = "import-alexa";
140600
140673
  /** An attempt lives as long as a sign-in attempt does elsewhere (10 min). */
140601
140674
  var ATTEMPT_TTL_MS = 600 * 1e3;
140602
140675
  /** How long the browser waits on the Amazon page for alexa-cookie2 to finish registering. */
@@ -140681,16 +140754,16 @@ var AlexaProxyLoginManager = class {
140681
140754
  this.#logger.warn("alexa proxy-login: Amazon ended the login without an authorization code", { meta: { sessionId } });
140682
140755
  return {
140683
140756
  status: "error",
140684
- addonId: ADDON_ID,
140757
+ addonId: ADDON_ID$1,
140685
140758
  reason: "no_authorization_code"
140686
140759
  };
140687
140760
  }
140688
140761
  return await this.#session?.whenCaptured(this.#captureWaitMs) === true ? {
140689
140762
  status: "success",
140690
- addonId: ADDON_ID
140763
+ addonId: ADDON_ID$1
140691
140764
  } : {
140692
140765
  status: "error",
140693
- addonId: ADDON_ID,
140766
+ addonId: ADDON_ID$1,
140694
140767
  reason: "exchange_failed"
140695
140768
  };
140696
140769
  }
@@ -144966,6 +145039,79 @@ var AlexaSpeakController = class {
144966
145039
  }
144967
145040
  };
144968
145041
  //#endregion
145042
+ //#region src/migrations/integration-rows-to-record.ts
145043
+ /**
145044
+ * The Alexa integration's last legacy row, `brokerId` (the integration to broker link), moves onto
145045
+ * the broker record (planning#416): the runtime reads `broker.integrationId`, so the row is only
145046
+ * stamped across when the broker has no link yet, then every legacy row of the integration is
145047
+ * cleared (the door `integration_settings` is dropped through accepts an addon only once this
145048
+ * ran). Nothing is overwritten: a broker that already names an integration, or a `brokerId` that
145049
+ * names no broker, is left as it is and said so in a warning (ids only, never a value).
145050
+ * An empty door is a no-op, so a rerun is too.
145051
+ */
145052
+ var INTEGRATION_ROWS_TO_RECORD_ID = "import-alexa/2026-11-05-integration-rows-to-record";
145053
+ var ADDON_ID = "import-alexa";
145054
+ async function moveAlexaIntegrationRows(ports) {
145055
+ const integrations = await ports.listIntegrations(ADDON_ID);
145056
+ let stamped = 0;
145057
+ let cleared = 0;
145058
+ for (const integration of integrations) {
145059
+ const rows = await ports.readIntegrationRows(integration.id);
145060
+ if (Object.keys(rows).length === 0) continue;
145061
+ const brokerId = rows["brokerId"];
145062
+ if (typeof brokerId === "string" && brokerId !== "") {
145063
+ const broker = await ports.readBrokerLink(brokerId);
145064
+ if (broker === null) ports.warn(`integration '${integration.id}': brokerId '${brokerId}' names no broker record; nothing linked`);
145065
+ else if (broker.integrationId !== void 0) ports.warn(`integration '${integration.id}': broker '${brokerId}' already names integration '${broker.integrationId}'; the link is not changed`);
145066
+ else {
145067
+ await ports.stampBrokerLink(brokerId, integration.id);
145068
+ if ((await ports.readBrokerLink(brokerId))?.integrationId !== integration.id) throw new Error(`integration '${integration.id}': the link did not read back from broker '${brokerId}' - the legacy settings are kept`);
145069
+ stamped += 1;
145070
+ }
145071
+ }
145072
+ await ports.clearIntegrationRows(integration.id);
145073
+ cleared += 1;
145074
+ }
145075
+ return cleared === 0 ? {
145076
+ outcome: "already-done",
145077
+ summary: { integrations: integrations.length }
145078
+ } : {
145079
+ outcome: "done",
145080
+ summary: {
145081
+ integrations: integrations.length,
145082
+ stamped,
145083
+ cleared
145084
+ }
145085
+ };
145086
+ }
145087
+ /** The hub side (the registry list, the migration-only door) and the broker records side. */
145088
+ function alexaIntegrationRowsPorts(deps) {
145089
+ const brokers = deps.records.kind(alexaBrokersEntryKind);
145090
+ const rows = integrationRowsPortsOverApi({
145091
+ api: deps.api,
145092
+ secrets: deps.secrets,
145093
+ records: deps.records.kind({
145094
+ name: alexaBrokersEntryKind.name,
145095
+ declaration: alexaBrokersEntryKind.declaration,
145096
+ schema: record(string(), unknown())
145097
+ }),
145098
+ log: { warn: deps.warn }
145099
+ });
145100
+ return {
145101
+ warn: deps.warn,
145102
+ listIntegrations: rows.listIntegrations,
145103
+ readIntegrationRows: rows.readRows,
145104
+ clearIntegrationRows: rows.clearRows,
145105
+ readBrokerLink: async (id) => {
145106
+ const row = await brokers.get(id);
145107
+ return row === null ? null : { integrationId: row.data.integrationId };
145108
+ },
145109
+ stampBrokerLink: async (id, integrationId) => {
145110
+ await brokers.patch(id, { integrationId });
145111
+ }
145112
+ };
145113
+ }
145114
+ //#endregion
144969
145115
  //#region src/migrations/flatten-broker-records.ts
144970
145116
  /**
144971
145117
  * The Alexa broker records keep their account flat (planning#408 P3): the nested `connection`
@@ -145028,37 +145174,56 @@ async function stripEchoTargetVolumeAndMode(ports) {
145028
145174
  //#endregion
145029
145175
  //#region src/migrations/index.ts
145030
145176
  /** The `import-alexa` catalogue (D987): offered to the engine when the addon initialises. */
145031
- var ADDON_MIGRATIONS = [{
145032
- meta: {
145033
- id: "import-alexa/2026-11-01-strip-echo-target-volume-and-mode",
145034
- owner: "import-alexa",
145035
- level: "addon-state",
145036
- seq: 1,
145037
- dependsOn: [],
145038
- store: "settings",
145039
- scope: "cluster",
145040
- onFailure: "tolerate",
145041
- bodyKind: "procedure",
145042
- description: "Remove the retired volume and mode from every stored Echo target",
145043
- fixtureMark: "the Echo id and the name the migration does not know"
145044
- },
145045
- procedure: stripEchoTargetVolumeAndMode
145046
- }, {
145047
- meta: {
145048
- id: FLATTEN_BROKER_RECORDS_ID,
145049
- owner: "import-alexa",
145050
- level: "addon-state",
145051
- seq: 2,
145052
- dependsOn: [],
145053
- store: "settings",
145054
- scope: "cluster",
145055
- onFailure: "block-owner",
145056
- bodyKind: "procedure",
145057
- description: "Move each Alexa account from a nested connection object to flat top-level fields under the same id, reading every secret back first",
145058
- fixtureMark: "the integration link and the amazon domain of each account"
145059
- },
145060
- procedure: flattenBrokerRecords
145061
- }];
145177
+ var ADDON_MIGRATIONS = [
145178
+ {
145179
+ meta: {
145180
+ id: "import-alexa/2026-11-01-strip-echo-target-volume-and-mode",
145181
+ owner: "import-alexa",
145182
+ level: "addon-state",
145183
+ seq: 1,
145184
+ dependsOn: [],
145185
+ store: "settings",
145186
+ scope: "cluster",
145187
+ onFailure: "tolerate",
145188
+ bodyKind: "procedure",
145189
+ description: "Remove the retired volume and mode from every stored Echo target",
145190
+ fixtureMark: "the Echo id and the name the migration does not know"
145191
+ },
145192
+ procedure: stripEchoTargetVolumeAndMode
145193
+ },
145194
+ {
145195
+ meta: {
145196
+ id: FLATTEN_BROKER_RECORDS_ID,
145197
+ owner: "import-alexa",
145198
+ level: "addon-state",
145199
+ seq: 2,
145200
+ dependsOn: [],
145201
+ store: "settings",
145202
+ scope: "cluster",
145203
+ onFailure: "block-owner",
145204
+ bodyKind: "procedure",
145205
+ description: "Move each Alexa account from a nested connection object to flat top-level fields under the same id, reading every secret back first",
145206
+ fixtureMark: "the integration link and the amazon domain of each account"
145207
+ },
145208
+ procedure: flattenBrokerRecords
145209
+ },
145210
+ {
145211
+ meta: {
145212
+ id: INTEGRATION_ROWS_TO_RECORD_ID,
145213
+ owner: "import-alexa",
145214
+ level: "addon-state",
145215
+ seq: 3,
145216
+ dependsOn: [FLATTEN_BROKER_RECORDS_ID],
145217
+ store: "settings",
145218
+ scope: "cluster",
145219
+ onFailure: "block-owner",
145220
+ bodyKind: "procedure",
145221
+ description: "Stamp the integration link from the legacy brokerId row onto its broker record when the broker has none, then clear the legacy integration settings rows",
145222
+ fixtureMark: "the name of the broker and the integration it already names"
145223
+ },
145224
+ procedure: moveAlexaIntegrationRows
145225
+ }
145226
+ ];
145062
145227
  //#endregion
145063
145228
  //#region src/speak/icon-routes.ts
145064
145229
  var ALEXA_ICON_SVG = "<svg role=\"img\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#00CAFF\"><title>Amazon Alexa</title><path d=\"M12 0C5.37 0 0 5.37 0 12C0 18.09 4.53 23.11 10.4 23.9V21.5A1.59 1.59 0 0 0 9.32 19.97A8.41 8.41 0 0 1 3.6 11.8A8.37 8.37 0 0 1 12.09 3.6A8.4 8.4 0 0 1 20.4 12.31L20.39 12.38A8.68 8.68 0 0 1 20.36 12.76C20.36 12.83 20.35 12.9 20.34 12.96C20.34 13.04 20.33 13.12 20.32 13.19L20.3 13.29C19.27 20.07 10.45 23.87 10.4 23.9C10.92 23.97 11.46 24 12 24C18.63 24 24 18.63 24 12S18.63 0 12 0Z\"/></svg>";
@@ -145270,13 +145435,20 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
145270
145435
  ] };
145271
145436
  }
145272
145437
  migrationPorts(records) {
145273
- if (this.ctx.secrets === void 0) throw new Error("import-alexa: ctx.secrets is required");
145438
+ const secrets = this.ctx.secrets;
145439
+ if (secrets === void 0) throw new Error("import-alexa: ctx.secrets is required");
145274
145440
  return {
145275
145441
  readRows: () => Array.isArray(this.config.echoTargets) ? this.config.echoTargets : [],
145276
145442
  writeRows: async (rows) => {
145277
145443
  await this.updateGlobalSettings({ echoTargets: [...readEchoTargets({ echoTargets: rows })] });
145278
145444
  },
145279
- ...flattenPortsOver(records.kind(alexaBrokersEntryKind))
145445
+ ...flattenPortsOver(records.kind(alexaBrokersEntryKind)),
145446
+ ...alexaIntegrationRowsPorts({
145447
+ api: defaultIntegrationApiOver(this.ctx.api),
145448
+ records,
145449
+ secrets,
145450
+ warn: (message) => this.ctx.logger.warn(message)
145451
+ })
145280
145452
  };
145281
145453
  }
145282
145454
  /**
package/dist/addon.mjs CHANGED
@@ -8839,6 +8839,13 @@ var RecordsError = class extends Error {
8839
8839
  this.detail = detail;
8840
8840
  }
8841
8841
  };
8842
+ /** A write guarded by `ifRev` found another revision (or an existing row for `ifRev: 0`). */
8843
+ var RevConflictError = class extends RecordsError {
8844
+ constructor(detail) {
8845
+ super("rev-conflict", detail);
8846
+ this.name = "RevConflictError";
8847
+ }
8848
+ };
8842
8849
  /** The keychain could not store or serve a value: explicit, never an empty string. */
8843
8850
  var RecordSecretUnavailableError = class extends RecordsError {
8844
8851
  constructor(detail) {
@@ -14768,6 +14775,72 @@ function flattenPortsOver(handle) {
14768
14775
  };
14769
14776
  }
14770
14777
  /**
14778
+ * The addon-side ports of `moveIntegrationRowsToRecords` (planning#416). An addon runs the move
14779
+ * from its own process, so the leftover rows are reached through the migration-only door
14780
+ * (`migrations.readLegacyIntegrationRows` / `clearLegacyIntegrationRows`) on the hub's connection.
14781
+ * The door returns markers. This process is the owner, so it resolves and releases them with
14782
+ * `resolveSecretMarkers` — the same call the old settings door used — and only then asks the
14783
+ * door to delete the rows. The addon's own record kind is its records handle.
14784
+ */
14785
+ async function resolvedRows(stored, secrets) {
14786
+ if (secrets === null) return stored;
14787
+ const { value, unresolved } = await resolveSecretMarkers(stored, secrets);
14788
+ if (unresolved.length > 0) throw new Error(`${unresolved.length} secret(s) cannot be read from the keychain`);
14789
+ return value;
14790
+ }
14791
+ function integrationRowsPortsOverApi(deps) {
14792
+ const secrets = deps.secrets ?? null;
14793
+ return {
14794
+ warn: (message) => deps.log.warn(message),
14795
+ listIntegrations: async (addonId) => (await deps.api.list()).filter((i) => i.addonId === addonId).map((i) => ({
14796
+ id: i.id,
14797
+ name: i.name
14798
+ })),
14799
+ readRows: async (integrationId) => resolvedRows(await deps.api.readLegacyIntegrationRows({ integrationId }), secrets),
14800
+ putRecord: async (id, body) => {
14801
+ if (await deps.records.get(id) !== null) return "exists";
14802
+ try {
14803
+ await deps.records.put({
14804
+ id,
14805
+ data: body
14806
+ }, { ifRev: 0 });
14807
+ } catch (err) {
14808
+ if (err instanceof RevConflictError) return "exists";
14809
+ throw err;
14810
+ }
14811
+ return "created";
14812
+ },
14813
+ readRecord: async (id) => (await deps.records.getResolved(id))?.data ?? null,
14814
+ deleteRecord: async (id) => {
14815
+ await deps.records.delete(id);
14816
+ },
14817
+ clearRows: async (integrationId) => {
14818
+ const refs = collectSecretMarkerRefs(await deps.api.readLegacyIntegrationRows({ integrationId }));
14819
+ if (refs.length > 0 && secrets !== null) await secrets.delete(refs);
14820
+ await deps.api.clearLegacyIntegrationRows({ integrationId });
14821
+ }
14822
+ };
14823
+ }
14824
+ function defaultIntegrationApiOver(api) {
14825
+ return {
14826
+ list: () => api.integrations.list.query(),
14827
+ readLegacyIntegrationRows: (input) => api.migrations.readLegacyIntegrationRows.query(input),
14828
+ clearLegacyIntegrationRows: (input) => api.migrations.clearLegacyIntegrationRows.mutate(input),
14829
+ listDevices: (addonId) => api.deviceManager.listAll.query({
14830
+ addonId,
14831
+ projection: "slim"
14832
+ }),
14833
+ setDeviceIntegrationId: async (deviceId, integrationId) => {
14834
+ await api.deviceManager.setIntegrationId.mutate({
14835
+ deviceId,
14836
+ integrationId
14837
+ });
14838
+ },
14839
+ readDeviceHasConfig: async (deviceId) => Object.keys((await api.deviceManager.getDevice.query({ deviceId }))?.config ?? {}).length > 0,
14840
+ readDeviceIntegrationId: async (deviceId) => (await api.deviceManager.getDevice.query({ deviceId }))?.integrationId ?? null
14841
+ };
14842
+ }
14843
+ /**
14771
14844
  * The trainer contract — what the hub hands the cloud trainer and what it
14772
14845
  * gets back (cloud training, D805+).
14773
14846
  *
@@ -140594,7 +140667,7 @@ function isStringOrObject(value) {
140594
140667
  //#endregion
140595
140668
  //#region src/alexa-proxy-login-manager.ts
140596
140669
  /** The addon id the result page names to return to. */
140597
- var ADDON_ID = "import-alexa";
140670
+ var ADDON_ID$1 = "import-alexa";
140598
140671
  /** An attempt lives as long as a sign-in attempt does elsewhere (10 min). */
140599
140672
  var ATTEMPT_TTL_MS = 600 * 1e3;
140600
140673
  /** How long the browser waits on the Amazon page for alexa-cookie2 to finish registering. */
@@ -140679,16 +140752,16 @@ var AlexaProxyLoginManager = class {
140679
140752
  this.#logger.warn("alexa proxy-login: Amazon ended the login without an authorization code", { meta: { sessionId } });
140680
140753
  return {
140681
140754
  status: "error",
140682
- addonId: ADDON_ID,
140755
+ addonId: ADDON_ID$1,
140683
140756
  reason: "no_authorization_code"
140684
140757
  };
140685
140758
  }
140686
140759
  return await this.#session?.whenCaptured(this.#captureWaitMs) === true ? {
140687
140760
  status: "success",
140688
- addonId: ADDON_ID
140761
+ addonId: ADDON_ID$1
140689
140762
  } : {
140690
140763
  status: "error",
140691
- addonId: ADDON_ID,
140764
+ addonId: ADDON_ID$1,
140692
140765
  reason: "exchange_failed"
140693
140766
  };
140694
140767
  }
@@ -144964,6 +145037,79 @@ var AlexaSpeakController = class {
144964
145037
  }
144965
145038
  };
144966
145039
  //#endregion
145040
+ //#region src/migrations/integration-rows-to-record.ts
145041
+ /**
145042
+ * The Alexa integration's last legacy row, `brokerId` (the integration to broker link), moves onto
145043
+ * the broker record (planning#416): the runtime reads `broker.integrationId`, so the row is only
145044
+ * stamped across when the broker has no link yet, then every legacy row of the integration is
145045
+ * cleared (the door `integration_settings` is dropped through accepts an addon only once this
145046
+ * ran). Nothing is overwritten: a broker that already names an integration, or a `brokerId` that
145047
+ * names no broker, is left as it is and said so in a warning (ids only, never a value).
145048
+ * An empty door is a no-op, so a rerun is too.
145049
+ */
145050
+ var INTEGRATION_ROWS_TO_RECORD_ID = "import-alexa/2026-11-05-integration-rows-to-record";
145051
+ var ADDON_ID = "import-alexa";
145052
+ async function moveAlexaIntegrationRows(ports) {
145053
+ const integrations = await ports.listIntegrations(ADDON_ID);
145054
+ let stamped = 0;
145055
+ let cleared = 0;
145056
+ for (const integration of integrations) {
145057
+ const rows = await ports.readIntegrationRows(integration.id);
145058
+ if (Object.keys(rows).length === 0) continue;
145059
+ const brokerId = rows["brokerId"];
145060
+ if (typeof brokerId === "string" && brokerId !== "") {
145061
+ const broker = await ports.readBrokerLink(brokerId);
145062
+ if (broker === null) ports.warn(`integration '${integration.id}': brokerId '${brokerId}' names no broker record; nothing linked`);
145063
+ else if (broker.integrationId !== void 0) ports.warn(`integration '${integration.id}': broker '${brokerId}' already names integration '${broker.integrationId}'; the link is not changed`);
145064
+ else {
145065
+ await ports.stampBrokerLink(brokerId, integration.id);
145066
+ if ((await ports.readBrokerLink(brokerId))?.integrationId !== integration.id) throw new Error(`integration '${integration.id}': the link did not read back from broker '${brokerId}' - the legacy settings are kept`);
145067
+ stamped += 1;
145068
+ }
145069
+ }
145070
+ await ports.clearIntegrationRows(integration.id);
145071
+ cleared += 1;
145072
+ }
145073
+ return cleared === 0 ? {
145074
+ outcome: "already-done",
145075
+ summary: { integrations: integrations.length }
145076
+ } : {
145077
+ outcome: "done",
145078
+ summary: {
145079
+ integrations: integrations.length,
145080
+ stamped,
145081
+ cleared
145082
+ }
145083
+ };
145084
+ }
145085
+ /** The hub side (the registry list, the migration-only door) and the broker records side. */
145086
+ function alexaIntegrationRowsPorts(deps) {
145087
+ const brokers = deps.records.kind(alexaBrokersEntryKind);
145088
+ const rows = integrationRowsPortsOverApi({
145089
+ api: deps.api,
145090
+ secrets: deps.secrets,
145091
+ records: deps.records.kind({
145092
+ name: alexaBrokersEntryKind.name,
145093
+ declaration: alexaBrokersEntryKind.declaration,
145094
+ schema: record(string(), unknown())
145095
+ }),
145096
+ log: { warn: deps.warn }
145097
+ });
145098
+ return {
145099
+ warn: deps.warn,
145100
+ listIntegrations: rows.listIntegrations,
145101
+ readIntegrationRows: rows.readRows,
145102
+ clearIntegrationRows: rows.clearRows,
145103
+ readBrokerLink: async (id) => {
145104
+ const row = await brokers.get(id);
145105
+ return row === null ? null : { integrationId: row.data.integrationId };
145106
+ },
145107
+ stampBrokerLink: async (id, integrationId) => {
145108
+ await brokers.patch(id, { integrationId });
145109
+ }
145110
+ };
145111
+ }
145112
+ //#endregion
144967
145113
  //#region src/migrations/flatten-broker-records.ts
144968
145114
  /**
144969
145115
  * The Alexa broker records keep their account flat (planning#408 P3): the nested `connection`
@@ -145026,37 +145172,56 @@ async function stripEchoTargetVolumeAndMode(ports) {
145026
145172
  //#endregion
145027
145173
  //#region src/migrations/index.ts
145028
145174
  /** The `import-alexa` catalogue (D987): offered to the engine when the addon initialises. */
145029
- var ADDON_MIGRATIONS = [{
145030
- meta: {
145031
- id: "import-alexa/2026-11-01-strip-echo-target-volume-and-mode",
145032
- owner: "import-alexa",
145033
- level: "addon-state",
145034
- seq: 1,
145035
- dependsOn: [],
145036
- store: "settings",
145037
- scope: "cluster",
145038
- onFailure: "tolerate",
145039
- bodyKind: "procedure",
145040
- description: "Remove the retired volume and mode from every stored Echo target",
145041
- fixtureMark: "the Echo id and the name the migration does not know"
145042
- },
145043
- procedure: stripEchoTargetVolumeAndMode
145044
- }, {
145045
- meta: {
145046
- id: FLATTEN_BROKER_RECORDS_ID,
145047
- owner: "import-alexa",
145048
- level: "addon-state",
145049
- seq: 2,
145050
- dependsOn: [],
145051
- store: "settings",
145052
- scope: "cluster",
145053
- onFailure: "block-owner",
145054
- bodyKind: "procedure",
145055
- description: "Move each Alexa account from a nested connection object to flat top-level fields under the same id, reading every secret back first",
145056
- fixtureMark: "the integration link and the amazon domain of each account"
145057
- },
145058
- procedure: flattenBrokerRecords
145059
- }];
145175
+ var ADDON_MIGRATIONS = [
145176
+ {
145177
+ meta: {
145178
+ id: "import-alexa/2026-11-01-strip-echo-target-volume-and-mode",
145179
+ owner: "import-alexa",
145180
+ level: "addon-state",
145181
+ seq: 1,
145182
+ dependsOn: [],
145183
+ store: "settings",
145184
+ scope: "cluster",
145185
+ onFailure: "tolerate",
145186
+ bodyKind: "procedure",
145187
+ description: "Remove the retired volume and mode from every stored Echo target",
145188
+ fixtureMark: "the Echo id and the name the migration does not know"
145189
+ },
145190
+ procedure: stripEchoTargetVolumeAndMode
145191
+ },
145192
+ {
145193
+ meta: {
145194
+ id: FLATTEN_BROKER_RECORDS_ID,
145195
+ owner: "import-alexa",
145196
+ level: "addon-state",
145197
+ seq: 2,
145198
+ dependsOn: [],
145199
+ store: "settings",
145200
+ scope: "cluster",
145201
+ onFailure: "block-owner",
145202
+ bodyKind: "procedure",
145203
+ description: "Move each Alexa account from a nested connection object to flat top-level fields under the same id, reading every secret back first",
145204
+ fixtureMark: "the integration link and the amazon domain of each account"
145205
+ },
145206
+ procedure: flattenBrokerRecords
145207
+ },
145208
+ {
145209
+ meta: {
145210
+ id: INTEGRATION_ROWS_TO_RECORD_ID,
145211
+ owner: "import-alexa",
145212
+ level: "addon-state",
145213
+ seq: 3,
145214
+ dependsOn: [FLATTEN_BROKER_RECORDS_ID],
145215
+ store: "settings",
145216
+ scope: "cluster",
145217
+ onFailure: "block-owner",
145218
+ bodyKind: "procedure",
145219
+ description: "Stamp the integration link from the legacy brokerId row onto its broker record when the broker has none, then clear the legacy integration settings rows",
145220
+ fixtureMark: "the name of the broker and the integration it already names"
145221
+ },
145222
+ procedure: moveAlexaIntegrationRows
145223
+ }
145224
+ ];
145060
145225
  //#endregion
145061
145226
  //#region src/speak/icon-routes.ts
145062
145227
  var ALEXA_ICON_SVG = "<svg role=\"img\" viewBox=\"0 0 24 24\" xmlns=\"http://www.w3.org/2000/svg\" fill=\"#00CAFF\"><title>Amazon Alexa</title><path d=\"M12 0C5.37 0 0 5.37 0 12C0 18.09 4.53 23.11 10.4 23.9V21.5A1.59 1.59 0 0 0 9.32 19.97A8.41 8.41 0 0 1 3.6 11.8A8.37 8.37 0 0 1 12.09 3.6A8.4 8.4 0 0 1 20.4 12.31L20.39 12.38A8.68 8.68 0 0 1 20.36 12.76C20.36 12.83 20.35 12.9 20.34 12.96C20.34 13.04 20.33 13.12 20.32 13.19L20.3 13.29C19.27 20.07 10.45 23.87 10.4 23.9C10.92 23.97 11.46 24 12 24C18.63 24 24 18.63 24 12S18.63 0 12 0Z\"/></svg>";
@@ -145268,13 +145433,20 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
145268
145433
  ] };
145269
145434
  }
145270
145435
  migrationPorts(records) {
145271
- if (this.ctx.secrets === void 0) throw new Error("import-alexa: ctx.secrets is required");
145436
+ const secrets = this.ctx.secrets;
145437
+ if (secrets === void 0) throw new Error("import-alexa: ctx.secrets is required");
145272
145438
  return {
145273
145439
  readRows: () => Array.isArray(this.config.echoTargets) ? this.config.echoTargets : [],
145274
145440
  writeRows: async (rows) => {
145275
145441
  await this.updateGlobalSettings({ echoTargets: [...readEchoTargets({ echoTargets: rows })] });
145276
145442
  },
145277
- ...flattenPortsOver(records.kind(alexaBrokersEntryKind))
145443
+ ...flattenPortsOver(records.kind(alexaBrokersEntryKind)),
145444
+ ...alexaIntegrationRowsPorts({
145445
+ api: defaultIntegrationApiOver(this.ctx.api),
145446
+ records,
145447
+ secrets,
145448
+ warn: (message) => this.ctx.logger.warn(message)
145449
+ })
145278
145450
  };
145279
145451
  }
145280
145452
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-import-alexa",
3
- "version": "0.2.186",
3
+ "version": "0.2.187",
4
4
  "description": "Alexa device-import provider for CamStack — imports the smart-home devices in a user's Alexa account via the unofficial alexa-remote2 cookie/token client (the inverse of the Alexa exporter)",
5
5
  "keywords": [
6
6
  "camstack",