@camstack/addon-import-alexa 0.2.185 → 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,18 @@ 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
+
16
+ ## [0.2.186] - 2026-11-05
17
+
18
+ ### Changed
19
+
20
+ - An Alexa integration links to its account through the account record
21
+
10
22
  ## [0.2.185] - 2026-11-04
11
23
 
12
24
  ### Changed
package/dist/addon.js CHANGED
@@ -6044,7 +6044,7 @@ function number(params) {
6044
6044
  return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
6045
6045
  }
6046
6046
  //#endregion
6047
- //#region ../types/dist/sleep-Cv4Zmie9.mjs
6047
+ //#region ../types/dist/sleep-9TIHFj1w.mjs
6048
6048
  /**
6049
6049
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6050
6050
  * window to float samples (D455).
@@ -7988,10 +7988,9 @@ var BaseAddon = class {
7988
7988
  *
7989
7989
  * Override it only when an `instanceMode: multiple` addon's instances differ: an NVR integration
7990
7990
  * needs its connection fields, the default integration the addon's own sections. It is the
7991
- * SHAPE of the form only — the values live in `integrations.getSettings`, and the write path is
7992
- * unchanged (`integrations.setSettings` for an account integration).
7991
+ * SHAPE of the form only — the values live on the instance record.
7993
7992
  */
7994
- async integrationSettingsSchema(_instance) {
7993
+ async integrationFormSchema(_instance) {
7995
7994
  return null;
7996
7995
  }
7997
7996
  /**
@@ -8037,7 +8036,7 @@ var BaseAddon = class {
8037
8036
  * per-instance schema when it declares one, else its global schema (what they always showed).
8038
8037
  */
8039
8038
  async getIntegrationFormSchema(instance, nodeId) {
8040
- const scoped = await this.integrationSettingsSchema(instance);
8039
+ const scoped = await this.integrationFormSchema(instance);
8041
8040
  if (scoped === null) return this.getGlobalSettings(void 0, void 0, nodeId);
8042
8041
  return hydrateSchema(scoped, {}, { inlineSecretsAsViews: true });
8043
8042
  }
@@ -8842,6 +8841,13 @@ var RecordsError = class extends Error {
8842
8841
  this.detail = detail;
8843
8842
  }
8844
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
+ };
8845
8851
  /** The keychain could not store or serve a value: explicit, never an empty string. */
8846
8852
  var RecordSecretUnavailableError = class extends RecordsError {
8847
8853
  constructor(detail) {
@@ -9311,6 +9317,7 @@ function defineEntryKind(name, spec) {
9311
9317
  class: "config",
9312
9318
  writeTrigger: spec.writeTrigger ?? "an operator adds or edits an entry in the entries UI",
9313
9319
  retention: { none: "an entry goes only when the operator deletes it" },
9320
+ ...spec.maxDataBytes === void 0 ? {} : { maxDataBytes: spec.maxDataBytes },
9314
9321
  ...spec.database === void 0 ? {} : { database: spec.database },
9315
9322
  ...spec.indexes === void 0 ? {} : { indexes: spec.indexes },
9316
9323
  ...spec.secretArrayKeys === void 0 ? {} : { secretArrayKeys: spec.secretArrayKeys },
@@ -11980,7 +11987,7 @@ object({
11980
11987
  params: record(string(), union([string(), number$1()])).optional()
11981
11988
  });
11982
11989
  //#endregion
11983
- //#region ../types/dist/composition-CRNwcFYY.mjs
11990
+ //#region ../types/dist/composition-pOHbTzRB.mjs
11984
11991
  /**
11985
11992
  * The ffmpeg build every CamStack node downloads, per platform and arch.
11986
11993
  *
@@ -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
  *
@@ -50460,6 +50533,9 @@ var RunningProposalEvidenceSchema = object({
50460
50533
  runsFound: number$1().int().nonnegative(),
50461
50534
  pausesFound: number$1().int().nonnegative(),
50462
50535
  longestPauseMs: number$1().nonnegative(),
50536
+ /** Episodes shorter than ~90 s (an anti-crease pulse, a display waking): never a run, never a pause. */
50537
+ pulsesFound: number$1().int().nonnegative(),
50538
+ longestPulseMs: number$1().nonnegative(),
50463
50539
  idleFloorW: number$1(),
50464
50540
  noiseCeilingW: number$1(),
50465
50541
  plateauW: number$1(),
@@ -56305,11 +56381,7 @@ var CreateIntegrationInputSchema = object({
56305
56381
  info: record(string(), unknown()).optional(),
56306
56382
  /** `stableId` of the discovery candidate the form was seeded from (D984). */
56307
56383
  originStableId: string().optional(),
56308
- settings: record(string(), unknown()).optional(),
56309
- /** Keys of `settings` the form's schema declares secret (by type) — kept in
56310
- * the keychain, not the table (D787). */
56311
- secretKeys: array(string()).optional(),
56312
- skipRestart: boolean().optional()
56384
+ settings: record(string(), unknown()).optional()
56313
56385
  });
56314
56386
  var UpdateIntegrationInputSchema = object({
56315
56387
  id: string(),
@@ -56330,16 +56402,6 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
56330
56402
  }), method(object({ id: string() }), DeleteIntegrationResultSchema, {
56331
56403
  kind: "mutation",
56332
56404
  auth: "admin"
56333
- }), method(object({ id: string() }), record(string(), unknown()), { auth: "admin" }), method(object({
56334
- id: string(),
56335
- settings: record(string(), unknown()),
56336
- /** Keys the form's schema declares secret (by type) — keychain-stored (D787). */
56337
- secretKeys: array(string()).optional(),
56338
- /** The integration's own addon applies the change in-process and asks the hub not to respawn it. */
56339
- skipRestart: boolean().optional()
56340
- }), object({ success: boolean() }), {
56341
- kind: "mutation",
56342
- auth: "admin"
56343
56405
  }), method(_void(), array(AvailableIntegrationTypeSchema), { auth: "admin" }), method(object({
56344
56406
  addonId: string(),
56345
56407
  settings: record(string(), unknown())
@@ -59316,6 +59378,27 @@ method(object({}), object({ entries: array(MigrationLedgerEntrySchema) }), { aut
59316
59378
  }), {
59317
59379
  kind: "mutation",
59318
59380
  auth: "admin"
59381
+ }), method(object({ integrationId: string().min(1) }), record(string(), unknown()), { auth: "admin" }), method(object({ integrationId: string().min(1) }), object({
59382
+ rows: number$1().int().nonnegative(),
59383
+ secrets: number$1().int().nonnegative()
59384
+ }), {
59385
+ kind: "mutation",
59386
+ auth: "admin"
59387
+ }), method(object({
59388
+ integrationId: string().min(1),
59389
+ patch: record(string(), unknown())
59390
+ }), object({ written: boolean() }), {
59391
+ kind: "mutation",
59392
+ auth: "admin"
59393
+ }), method(object({
59394
+ integrationId: string().min(1),
59395
+ keys: array(string().min(1))
59396
+ }), object({
59397
+ present: boolean(),
59398
+ removed: record(string(), unknown())
59399
+ }), {
59400
+ kind: "mutation",
59401
+ auth: "admin"
59319
59402
  });
59320
59403
  method(object({
59321
59404
  sourceUrl: string(),
@@ -80642,11 +80725,15 @@ discriminatedUnion("kind", [object({
80642
80725
  * 1. idle floor F = time-weighted median; noise ceiling N = highest value seen
80643
80726
  * near F. A device that is not near one level most of the time has no floor.
80644
80727
  * 2. coarse activity = above max(2N, N + 5 W); consecutive stretches are episodes.
80645
- * 3. gaps between episodes split in two by the largest log-scale jump (>= 3x):
80646
- * the short ones are pauses INSIDE a run (a soak), the long ones separate runs.
80728
+ * 3. an episode shorter than `PULSE_MAX_MS` is a PULSE (an anti-crease tumble, a
80729
+ * display waking): it is neither a run nor a pause. Among the remaining
80730
+ * episodes, gaps over `PAUSE_MAX_MS` always separate runs; the rest split in
80731
+ * two by the largest log-scale jump (>= 3x): the short ones are pauses INSIDE
80732
+ * a run (a soak), the long ones separate runs. A stretch under `MIN_RUN_MS`
80733
+ * is no run.
80647
80734
  * 4. T = geometric mean of N and the plateau's 10th percentile, snapped to a
80648
- * grid a person would type; stop dwell covers the longest pause; start dwell
80649
- * covers the longest standby blip above T.
80735
+ * grid a person would type; stop dwell covers the longest pause and the
80736
+ * kind's floor; start dwell covers the longest pulse and standby blip above T.
80650
80737
  * 5. the numbers are proved by REPLAY through the real `dwell` evaluation: each
80651
80738
  * run must hold exactly one true interval and nothing may flap outside.
80652
80739
  */
@@ -80657,6 +80744,10 @@ var MINUTE_MS$1 = 60 * SECOND_MS;
80657
80744
  30 * SECOND_MS;
80658
80745
  60 * SECOND_MS;
80659
80746
  20 * MINUTE_MS$1;
80747
+ 90 * SECOND_MS;
80748
+ 30 * MINUTE_MS$1;
80749
+ 10 * MINUTE_MS$1;
80750
+ DeviceType.Dryer, 3 * MINUTE_MS$1, DeviceType.Dishwasher, 3 * MINUTE_MS$1, DeviceType.Washer, 10 * MINUTE_MS$1;
80660
80751
  10 * SECOND_MS;
80661
80752
  10 * SECOND_MS;
80662
80753
  DeviceFeature.FanSpeed, DeviceFeature.FanPreset, DeviceFeature.FanDirection, DeviceFeature.FanOscillating, DeviceFeature.CoverStoppable, DeviceFeature.CoverPositionable, DeviceFeature.CoverTilt, DeviceFeature.ValvePositionable, DeviceFeature.MediaPlayerVolume, DeviceFeature.ClimateFanMode;
@@ -85976,13 +86067,6 @@ Object.freeze({
85976
86067
  access: "view",
85977
86068
  role: "admin"
85978
86069
  },
85979
- "integrations.getSettings": {
85980
- capName: "integrations",
85981
- capScope: "system",
85982
- addonId: null,
85983
- access: "view",
85984
- role: "admin"
85985
- },
85986
86070
  "integrations.list": {
85987
86071
  capName: "integrations",
85988
86072
  capScope: "system",
@@ -85990,13 +86074,6 @@ Object.freeze({
85990
86074
  access: "view",
85991
86075
  role: "admin"
85992
86076
  },
85993
- "integrations.setSettings": {
85994
- capName: "integrations",
85995
- capScope: "system",
85996
- addonId: null,
85997
- access: "create",
85998
- role: "admin"
85999
- },
86000
86077
  "integrations.testConnection": {
86001
86078
  capName: "integrations",
86002
86079
  capScope: "system",
@@ -86991,6 +87068,13 @@ Object.freeze({
86991
87068
  access: "view",
86992
87069
  role: "admin"
86993
87070
  },
87071
+ "migrations.clearLegacyIntegrationRows": {
87072
+ capName: "migrations",
87073
+ capScope: "system",
87074
+ addonId: null,
87075
+ access: "delete",
87076
+ role: "admin"
87077
+ },
86994
87078
  "migrations.declareOwner": {
86995
87079
  capName: "migrations",
86996
87080
  capScope: "system",
@@ -86998,6 +87082,13 @@ Object.freeze({
86998
87082
  access: "create",
86999
87083
  role: "admin"
87000
87084
  },
87085
+ "migrations.deleteLegacyIntegrationRowKeys": {
87086
+ capName: "migrations",
87087
+ capScope: "system",
87088
+ addonId: null,
87089
+ access: "delete",
87090
+ role: "admin"
87091
+ },
87001
87092
  "migrations.dryRun": {
87002
87093
  capName: "migrations",
87003
87094
  capScope: "system",
@@ -87033,6 +87124,20 @@ Object.freeze({
87033
87124
  access: "create",
87034
87125
  role: "admin"
87035
87126
  },
87127
+ "migrations.patchLegacyIntegrationRows": {
87128
+ capName: "migrations",
87129
+ capScope: "system",
87130
+ addonId: null,
87131
+ access: "create",
87132
+ role: "admin"
87133
+ },
87134
+ "migrations.readLegacyIntegrationRows": {
87135
+ capName: "migrations",
87136
+ capScope: "system",
87137
+ addonId: null,
87138
+ access: "view",
87139
+ role: "admin"
87140
+ },
87036
87141
  "migrations.restorePreMigration": {
87037
87142
  capName: "migrations",
87038
87143
  capScope: "system",
@@ -140564,7 +140669,7 @@ function isStringOrObject(value) {
140564
140669
  //#endregion
140565
140670
  //#region src/alexa-proxy-login-manager.ts
140566
140671
  /** The addon id the result page names to return to. */
140567
- var ADDON_ID = "import-alexa";
140672
+ var ADDON_ID$1 = "import-alexa";
140568
140673
  /** An attempt lives as long as a sign-in attempt does elsewhere (10 min). */
140569
140674
  var ATTEMPT_TTL_MS = 600 * 1e3;
140570
140675
  /** How long the browser waits on the Amazon page for alexa-cookie2 to finish registering. */
@@ -140649,16 +140754,16 @@ var AlexaProxyLoginManager = class {
140649
140754
  this.#logger.warn("alexa proxy-login: Amazon ended the login without an authorization code", { meta: { sessionId } });
140650
140755
  return {
140651
140756
  status: "error",
140652
- addonId: ADDON_ID,
140757
+ addonId: ADDON_ID$1,
140653
140758
  reason: "no_authorization_code"
140654
140759
  };
140655
140760
  }
140656
140761
  return await this.#session?.whenCaptured(this.#captureWaitMs) === true ? {
140657
140762
  status: "success",
140658
- addonId: ADDON_ID
140763
+ addonId: ADDON_ID$1
140659
140764
  } : {
140660
140765
  status: "error",
140661
- addonId: ADDON_ID,
140766
+ addonId: ADDON_ID$1,
140662
140767
  reason: "exchange_failed"
140663
140768
  };
140664
140769
  }
@@ -144934,6 +145039,79 @@ var AlexaSpeakController = class {
144934
145039
  }
144935
145040
  };
144936
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
144937
145115
  //#region src/migrations/flatten-broker-records.ts
144938
145116
  /**
144939
145117
  * The Alexa broker records keep their account flat (planning#408 P3): the nested `connection`
@@ -144996,37 +145174,56 @@ async function stripEchoTargetVolumeAndMode(ports) {
144996
145174
  //#endregion
144997
145175
  //#region src/migrations/index.ts
144998
145176
  /** The `import-alexa` catalogue (D987): offered to the engine when the addon initialises. */
144999
- var ADDON_MIGRATIONS = [{
145000
- meta: {
145001
- id: "import-alexa/2026-11-01-strip-echo-target-volume-and-mode",
145002
- owner: "import-alexa",
145003
- level: "addon-state",
145004
- seq: 1,
145005
- dependsOn: [],
145006
- store: "settings",
145007
- scope: "cluster",
145008
- onFailure: "tolerate",
145009
- bodyKind: "procedure",
145010
- description: "Remove the retired volume and mode from every stored Echo target",
145011
- fixtureMark: "the Echo id and the name the migration does not know"
145012
- },
145013
- procedure: stripEchoTargetVolumeAndMode
145014
- }, {
145015
- meta: {
145016
- id: FLATTEN_BROKER_RECORDS_ID,
145017
- owner: "import-alexa",
145018
- level: "addon-state",
145019
- seq: 2,
145020
- dependsOn: [],
145021
- store: "settings",
145022
- scope: "cluster",
145023
- onFailure: "block-owner",
145024
- bodyKind: "procedure",
145025
- description: "Move each Alexa account from a nested connection object to flat top-level fields under the same id, reading every secret back first",
145026
- fixtureMark: "the integration link and the amazon domain of each account"
145027
- },
145028
- procedure: flattenBrokerRecords
145029
- }];
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
+ ];
145030
145227
  //#endregion
145031
145228
  //#region src/speak/icon-routes.ts
145032
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>";
@@ -145238,13 +145435,20 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
145238
145435
  ] };
145239
145436
  }
145240
145437
  migrationPorts(records) {
145241
- 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");
145242
145440
  return {
145243
145441
  readRows: () => Array.isArray(this.config.echoTargets) ? this.config.echoTargets : [],
145244
145442
  writeRows: async (rows) => {
145245
145443
  await this.updateGlobalSettings({ echoTargets: [...readEchoTargets({ echoTargets: rows })] });
145246
145444
  },
145247
- ...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
+ })
145248
145452
  };
145249
145453
  }
145250
145454
  /**
@@ -145507,18 +145711,10 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
145507
145711
  try {
145508
145712
  const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id);
145509
145713
  const surviving = new Set(mine.map((i) => i.id));
145510
- for (const integration of mine) {
145511
- const settings = await this.ctx.api.integrations.getSettings.query({ id: integration.id });
145512
- const brokerId = typeof settings["brokerId"] === "string" ? settings["brokerId"] : void 0;
145513
- if (brokerId === void 0) continue;
145514
- if (!this.activeBrokers.some((b) => b.id === brokerId)) {
145515
- this.ctx.logger.warn("Alexa integration→broker: linked broker not found", { meta: {
145516
- integrationId: integration.id,
145517
- brokerId
145518
- } });
145519
- continue;
145520
- }
145521
- this.requireRegistry().linkIntegration(integration.id, brokerId);
145714
+ for (const broker of this.activeBrokers) {
145715
+ const integrationId = broker.integrationId;
145716
+ if (integrationId === void 0 || !surviving.has(integrationId)) continue;
145717
+ this.requireRegistry().linkIntegration(integrationId, broker.id);
145522
145718
  }
145523
145719
  const toRemove = this.activeBrokers.filter((b) => b.integrationId !== void 0 && !surviving.has(b.integrationId)).map((b) => b.id);
145524
145720
  if (toRemove.length === 0) return;
@@ -145560,9 +145756,9 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
145560
145756
  },
145561
145757
  logger: this.ctx.logger,
145562
145758
  getBrokerIdForIntegration: async (id) => {
145563
- const brokerId = (await this.ctx.api.integrations.getSettings.query({ id }))["brokerId"];
145564
- if (typeof brokerId !== "string") throw new Error(`integration ${id} has no linked brokerId`);
145565
- return brokerId;
145759
+ const broker = this.activeBrokers.find((entry) => entry.integrationId === id);
145760
+ if (broker === void 0) throw new Error(`integration ${id} has no linked brokerId`);
145761
+ return broker.id;
145566
145762
  },
145567
145763
  listAdoptedAlexa: async () => {
145568
145764
  const reg = this.ctx.kernel.deviceRegistry;
package/dist/addon.mjs CHANGED
@@ -6042,7 +6042,7 @@ function number(params) {
6042
6042
  return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
6043
6043
  }
6044
6044
  //#endregion
6045
- //#region ../types/dist/sleep-Cv4Zmie9.mjs
6045
+ //#region ../types/dist/sleep-9TIHFj1w.mjs
6046
6046
  /**
6047
6047
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6048
6048
  * window to float samples (D455).
@@ -7986,10 +7986,9 @@ var BaseAddon = class {
7986
7986
  *
7987
7987
  * Override it only when an `instanceMode: multiple` addon's instances differ: an NVR integration
7988
7988
  * needs its connection fields, the default integration the addon's own sections. It is the
7989
- * SHAPE of the form only — the values live in `integrations.getSettings`, and the write path is
7990
- * unchanged (`integrations.setSettings` for an account integration).
7989
+ * SHAPE of the form only — the values live on the instance record.
7991
7990
  */
7992
- async integrationSettingsSchema(_instance) {
7991
+ async integrationFormSchema(_instance) {
7993
7992
  return null;
7994
7993
  }
7995
7994
  /**
@@ -8035,7 +8034,7 @@ var BaseAddon = class {
8035
8034
  * per-instance schema when it declares one, else its global schema (what they always showed).
8036
8035
  */
8037
8036
  async getIntegrationFormSchema(instance, nodeId) {
8038
- const scoped = await this.integrationSettingsSchema(instance);
8037
+ const scoped = await this.integrationFormSchema(instance);
8039
8038
  if (scoped === null) return this.getGlobalSettings(void 0, void 0, nodeId);
8040
8039
  return hydrateSchema(scoped, {}, { inlineSecretsAsViews: true });
8041
8040
  }
@@ -8840,6 +8839,13 @@ var RecordsError = class extends Error {
8840
8839
  this.detail = detail;
8841
8840
  }
8842
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
+ };
8843
8849
  /** The keychain could not store or serve a value: explicit, never an empty string. */
8844
8850
  var RecordSecretUnavailableError = class extends RecordsError {
8845
8851
  constructor(detail) {
@@ -9309,6 +9315,7 @@ function defineEntryKind(name, spec) {
9309
9315
  class: "config",
9310
9316
  writeTrigger: spec.writeTrigger ?? "an operator adds or edits an entry in the entries UI",
9311
9317
  retention: { none: "an entry goes only when the operator deletes it" },
9318
+ ...spec.maxDataBytes === void 0 ? {} : { maxDataBytes: spec.maxDataBytes },
9312
9319
  ...spec.database === void 0 ? {} : { database: spec.database },
9313
9320
  ...spec.indexes === void 0 ? {} : { indexes: spec.indexes },
9314
9321
  ...spec.secretArrayKeys === void 0 ? {} : { secretArrayKeys: spec.secretArrayKeys },
@@ -11978,7 +11985,7 @@ object({
11978
11985
  params: record(string(), union([string(), number$1()])).optional()
11979
11986
  });
11980
11987
  //#endregion
11981
- //#region ../types/dist/composition-CRNwcFYY.mjs
11988
+ //#region ../types/dist/composition-pOHbTzRB.mjs
11982
11989
  /**
11983
11990
  * The ffmpeg build every CamStack node downloads, per platform and arch.
11984
11991
  *
@@ -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
  *
@@ -50458,6 +50531,9 @@ var RunningProposalEvidenceSchema = object({
50458
50531
  runsFound: number$1().int().nonnegative(),
50459
50532
  pausesFound: number$1().int().nonnegative(),
50460
50533
  longestPauseMs: number$1().nonnegative(),
50534
+ /** Episodes shorter than ~90 s (an anti-crease pulse, a display waking): never a run, never a pause. */
50535
+ pulsesFound: number$1().int().nonnegative(),
50536
+ longestPulseMs: number$1().nonnegative(),
50461
50537
  idleFloorW: number$1(),
50462
50538
  noiseCeilingW: number$1(),
50463
50539
  plateauW: number$1(),
@@ -56303,11 +56379,7 @@ var CreateIntegrationInputSchema = object({
56303
56379
  info: record(string(), unknown()).optional(),
56304
56380
  /** `stableId` of the discovery candidate the form was seeded from (D984). */
56305
56381
  originStableId: string().optional(),
56306
- settings: record(string(), unknown()).optional(),
56307
- /** Keys of `settings` the form's schema declares secret (by type) — kept in
56308
- * the keychain, not the table (D787). */
56309
- secretKeys: array(string()).optional(),
56310
- skipRestart: boolean().optional()
56382
+ settings: record(string(), unknown()).optional()
56311
56383
  });
56312
56384
  var UpdateIntegrationInputSchema = object({
56313
56385
  id: string(),
@@ -56328,16 +56400,6 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
56328
56400
  }), method(object({ id: string() }), DeleteIntegrationResultSchema, {
56329
56401
  kind: "mutation",
56330
56402
  auth: "admin"
56331
- }), method(object({ id: string() }), record(string(), unknown()), { auth: "admin" }), method(object({
56332
- id: string(),
56333
- settings: record(string(), unknown()),
56334
- /** Keys the form's schema declares secret (by type) — keychain-stored (D787). */
56335
- secretKeys: array(string()).optional(),
56336
- /** The integration's own addon applies the change in-process and asks the hub not to respawn it. */
56337
- skipRestart: boolean().optional()
56338
- }), object({ success: boolean() }), {
56339
- kind: "mutation",
56340
- auth: "admin"
56341
56403
  }), method(_void(), array(AvailableIntegrationTypeSchema), { auth: "admin" }), method(object({
56342
56404
  addonId: string(),
56343
56405
  settings: record(string(), unknown())
@@ -59314,6 +59376,27 @@ method(object({}), object({ entries: array(MigrationLedgerEntrySchema) }), { aut
59314
59376
  }), {
59315
59377
  kind: "mutation",
59316
59378
  auth: "admin"
59379
+ }), method(object({ integrationId: string().min(1) }), record(string(), unknown()), { auth: "admin" }), method(object({ integrationId: string().min(1) }), object({
59380
+ rows: number$1().int().nonnegative(),
59381
+ secrets: number$1().int().nonnegative()
59382
+ }), {
59383
+ kind: "mutation",
59384
+ auth: "admin"
59385
+ }), method(object({
59386
+ integrationId: string().min(1),
59387
+ patch: record(string(), unknown())
59388
+ }), object({ written: boolean() }), {
59389
+ kind: "mutation",
59390
+ auth: "admin"
59391
+ }), method(object({
59392
+ integrationId: string().min(1),
59393
+ keys: array(string().min(1))
59394
+ }), object({
59395
+ present: boolean(),
59396
+ removed: record(string(), unknown())
59397
+ }), {
59398
+ kind: "mutation",
59399
+ auth: "admin"
59317
59400
  });
59318
59401
  method(object({
59319
59402
  sourceUrl: string(),
@@ -80640,11 +80723,15 @@ discriminatedUnion("kind", [object({
80640
80723
  * 1. idle floor F = time-weighted median; noise ceiling N = highest value seen
80641
80724
  * near F. A device that is not near one level most of the time has no floor.
80642
80725
  * 2. coarse activity = above max(2N, N + 5 W); consecutive stretches are episodes.
80643
- * 3. gaps between episodes split in two by the largest log-scale jump (>= 3x):
80644
- * the short ones are pauses INSIDE a run (a soak), the long ones separate runs.
80726
+ * 3. an episode shorter than `PULSE_MAX_MS` is a PULSE (an anti-crease tumble, a
80727
+ * display waking): it is neither a run nor a pause. Among the remaining
80728
+ * episodes, gaps over `PAUSE_MAX_MS` always separate runs; the rest split in
80729
+ * two by the largest log-scale jump (>= 3x): the short ones are pauses INSIDE
80730
+ * a run (a soak), the long ones separate runs. A stretch under `MIN_RUN_MS`
80731
+ * is no run.
80645
80732
  * 4. T = geometric mean of N and the plateau's 10th percentile, snapped to a
80646
- * grid a person would type; stop dwell covers the longest pause; start dwell
80647
- * covers the longest standby blip above T.
80733
+ * grid a person would type; stop dwell covers the longest pause and the
80734
+ * kind's floor; start dwell covers the longest pulse and standby blip above T.
80648
80735
  * 5. the numbers are proved by REPLAY through the real `dwell` evaluation: each
80649
80736
  * run must hold exactly one true interval and nothing may flap outside.
80650
80737
  */
@@ -80655,6 +80742,10 @@ var MINUTE_MS$1 = 60 * SECOND_MS;
80655
80742
  30 * SECOND_MS;
80656
80743
  60 * SECOND_MS;
80657
80744
  20 * MINUTE_MS$1;
80745
+ 90 * SECOND_MS;
80746
+ 30 * MINUTE_MS$1;
80747
+ 10 * MINUTE_MS$1;
80748
+ DeviceType.Dryer, 3 * MINUTE_MS$1, DeviceType.Dishwasher, 3 * MINUTE_MS$1, DeviceType.Washer, 10 * MINUTE_MS$1;
80658
80749
  10 * SECOND_MS;
80659
80750
  10 * SECOND_MS;
80660
80751
  DeviceFeature.FanSpeed, DeviceFeature.FanPreset, DeviceFeature.FanDirection, DeviceFeature.FanOscillating, DeviceFeature.CoverStoppable, DeviceFeature.CoverPositionable, DeviceFeature.CoverTilt, DeviceFeature.ValvePositionable, DeviceFeature.MediaPlayerVolume, DeviceFeature.ClimateFanMode;
@@ -85974,13 +86065,6 @@ Object.freeze({
85974
86065
  access: "view",
85975
86066
  role: "admin"
85976
86067
  },
85977
- "integrations.getSettings": {
85978
- capName: "integrations",
85979
- capScope: "system",
85980
- addonId: null,
85981
- access: "view",
85982
- role: "admin"
85983
- },
85984
86068
  "integrations.list": {
85985
86069
  capName: "integrations",
85986
86070
  capScope: "system",
@@ -85988,13 +86072,6 @@ Object.freeze({
85988
86072
  access: "view",
85989
86073
  role: "admin"
85990
86074
  },
85991
- "integrations.setSettings": {
85992
- capName: "integrations",
85993
- capScope: "system",
85994
- addonId: null,
85995
- access: "create",
85996
- role: "admin"
85997
- },
85998
86075
  "integrations.testConnection": {
85999
86076
  capName: "integrations",
86000
86077
  capScope: "system",
@@ -86989,6 +87066,13 @@ Object.freeze({
86989
87066
  access: "view",
86990
87067
  role: "admin"
86991
87068
  },
87069
+ "migrations.clearLegacyIntegrationRows": {
87070
+ capName: "migrations",
87071
+ capScope: "system",
87072
+ addonId: null,
87073
+ access: "delete",
87074
+ role: "admin"
87075
+ },
86992
87076
  "migrations.declareOwner": {
86993
87077
  capName: "migrations",
86994
87078
  capScope: "system",
@@ -86996,6 +87080,13 @@ Object.freeze({
86996
87080
  access: "create",
86997
87081
  role: "admin"
86998
87082
  },
87083
+ "migrations.deleteLegacyIntegrationRowKeys": {
87084
+ capName: "migrations",
87085
+ capScope: "system",
87086
+ addonId: null,
87087
+ access: "delete",
87088
+ role: "admin"
87089
+ },
86999
87090
  "migrations.dryRun": {
87000
87091
  capName: "migrations",
87001
87092
  capScope: "system",
@@ -87031,6 +87122,20 @@ Object.freeze({
87031
87122
  access: "create",
87032
87123
  role: "admin"
87033
87124
  },
87125
+ "migrations.patchLegacyIntegrationRows": {
87126
+ capName: "migrations",
87127
+ capScope: "system",
87128
+ addonId: null,
87129
+ access: "create",
87130
+ role: "admin"
87131
+ },
87132
+ "migrations.readLegacyIntegrationRows": {
87133
+ capName: "migrations",
87134
+ capScope: "system",
87135
+ addonId: null,
87136
+ access: "view",
87137
+ role: "admin"
87138
+ },
87034
87139
  "migrations.restorePreMigration": {
87035
87140
  capName: "migrations",
87036
87141
  capScope: "system",
@@ -140562,7 +140667,7 @@ function isStringOrObject(value) {
140562
140667
  //#endregion
140563
140668
  //#region src/alexa-proxy-login-manager.ts
140564
140669
  /** The addon id the result page names to return to. */
140565
- var ADDON_ID = "import-alexa";
140670
+ var ADDON_ID$1 = "import-alexa";
140566
140671
  /** An attempt lives as long as a sign-in attempt does elsewhere (10 min). */
140567
140672
  var ATTEMPT_TTL_MS = 600 * 1e3;
140568
140673
  /** How long the browser waits on the Amazon page for alexa-cookie2 to finish registering. */
@@ -140647,16 +140752,16 @@ var AlexaProxyLoginManager = class {
140647
140752
  this.#logger.warn("alexa proxy-login: Amazon ended the login without an authorization code", { meta: { sessionId } });
140648
140753
  return {
140649
140754
  status: "error",
140650
- addonId: ADDON_ID,
140755
+ addonId: ADDON_ID$1,
140651
140756
  reason: "no_authorization_code"
140652
140757
  };
140653
140758
  }
140654
140759
  return await this.#session?.whenCaptured(this.#captureWaitMs) === true ? {
140655
140760
  status: "success",
140656
- addonId: ADDON_ID
140761
+ addonId: ADDON_ID$1
140657
140762
  } : {
140658
140763
  status: "error",
140659
- addonId: ADDON_ID,
140764
+ addonId: ADDON_ID$1,
140660
140765
  reason: "exchange_failed"
140661
140766
  };
140662
140767
  }
@@ -144932,6 +145037,79 @@ var AlexaSpeakController = class {
144932
145037
  }
144933
145038
  };
144934
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
144935
145113
  //#region src/migrations/flatten-broker-records.ts
144936
145114
  /**
144937
145115
  * The Alexa broker records keep their account flat (planning#408 P3): the nested `connection`
@@ -144994,37 +145172,56 @@ async function stripEchoTargetVolumeAndMode(ports) {
144994
145172
  //#endregion
144995
145173
  //#region src/migrations/index.ts
144996
145174
  /** The `import-alexa` catalogue (D987): offered to the engine when the addon initialises. */
144997
- var ADDON_MIGRATIONS = [{
144998
- meta: {
144999
- id: "import-alexa/2026-11-01-strip-echo-target-volume-and-mode",
145000
- owner: "import-alexa",
145001
- level: "addon-state",
145002
- seq: 1,
145003
- dependsOn: [],
145004
- store: "settings",
145005
- scope: "cluster",
145006
- onFailure: "tolerate",
145007
- bodyKind: "procedure",
145008
- description: "Remove the retired volume and mode from every stored Echo target",
145009
- fixtureMark: "the Echo id and the name the migration does not know"
145010
- },
145011
- procedure: stripEchoTargetVolumeAndMode
145012
- }, {
145013
- meta: {
145014
- id: FLATTEN_BROKER_RECORDS_ID,
145015
- owner: "import-alexa",
145016
- level: "addon-state",
145017
- seq: 2,
145018
- dependsOn: [],
145019
- store: "settings",
145020
- scope: "cluster",
145021
- onFailure: "block-owner",
145022
- bodyKind: "procedure",
145023
- description: "Move each Alexa account from a nested connection object to flat top-level fields under the same id, reading every secret back first",
145024
- fixtureMark: "the integration link and the amazon domain of each account"
145025
- },
145026
- procedure: flattenBrokerRecords
145027
- }];
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
+ ];
145028
145225
  //#endregion
145029
145226
  //#region src/speak/icon-routes.ts
145030
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>";
@@ -145236,13 +145433,20 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
145236
145433
  ] };
145237
145434
  }
145238
145435
  migrationPorts(records) {
145239
- 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");
145240
145438
  return {
145241
145439
  readRows: () => Array.isArray(this.config.echoTargets) ? this.config.echoTargets : [],
145242
145440
  writeRows: async (rows) => {
145243
145441
  await this.updateGlobalSettings({ echoTargets: [...readEchoTargets({ echoTargets: rows })] });
145244
145442
  },
145245
- ...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
+ })
145246
145450
  };
145247
145451
  }
145248
145452
  /**
@@ -145505,18 +145709,10 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
145505
145709
  try {
145506
145710
  const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id);
145507
145711
  const surviving = new Set(mine.map((i) => i.id));
145508
- for (const integration of mine) {
145509
- const settings = await this.ctx.api.integrations.getSettings.query({ id: integration.id });
145510
- const brokerId = typeof settings["brokerId"] === "string" ? settings["brokerId"] : void 0;
145511
- if (brokerId === void 0) continue;
145512
- if (!this.activeBrokers.some((b) => b.id === brokerId)) {
145513
- this.ctx.logger.warn("Alexa integration→broker: linked broker not found", { meta: {
145514
- integrationId: integration.id,
145515
- brokerId
145516
- } });
145517
- continue;
145518
- }
145519
- this.requireRegistry().linkIntegration(integration.id, brokerId);
145712
+ for (const broker of this.activeBrokers) {
145713
+ const integrationId = broker.integrationId;
145714
+ if (integrationId === void 0 || !surviving.has(integrationId)) continue;
145715
+ this.requireRegistry().linkIntegration(integrationId, broker.id);
145520
145716
  }
145521
145717
  const toRemove = this.activeBrokers.filter((b) => b.integrationId !== void 0 && !surviving.has(b.integrationId)).map((b) => b.id);
145522
145718
  if (toRemove.length === 0) return;
@@ -145558,9 +145754,9 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
145558
145754
  },
145559
145755
  logger: this.ctx.logger,
145560
145756
  getBrokerIdForIntegration: async (id) => {
145561
- const brokerId = (await this.ctx.api.integrations.getSettings.query({ id }))["brokerId"];
145562
- if (typeof brokerId !== "string") throw new Error(`integration ${id} has no linked brokerId`);
145563
- return brokerId;
145757
+ const broker = this.activeBrokers.find((entry) => entry.integrationId === id);
145758
+ if (broker === void 0) throw new Error(`integration ${id} has no linked brokerId`);
145759
+ return broker.id;
145564
145760
  },
145565
145761
  listAdoptedAlexa: async () => {
145566
145762
  const reg = this.ctx.kernel.deviceRegistry;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-import-alexa",
3
- "version": "0.2.185",
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",
@@ -72,7 +72,7 @@
72
72
  }
73
73
  ],
74
74
  "requires": {
75
- "server": ">=1.2.763"
75
+ "server": ">=1.2.770"
76
76
  }
77
77
  },
78
78
  "files": [