@camstack/addon-import-alexa 0.2.188 → 0.2.190

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/addon.js CHANGED
@@ -6044,7 +6044,7 @@ function number(params) {
6044
6044
  return /* @__PURE__ */ _coercedNumber(ZodNumber, params);
6045
6045
  }
6046
6046
  //#endregion
6047
- //#region ../types/dist/sleep-9TIHFj1w.mjs
6047
+ //#region ../types/dist/sleep-BX_MqzUe.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).
@@ -6853,6 +6853,12 @@ function projectStore(store, perNodeKeys, nodeId) {
6853
6853
  }
6854
6854
  return out;
6855
6855
  }
6856
+ /** Every scope, in the order the guard and the docs list them. */
6857
+ var SCOPED_SETTINGS_SCOPES = [
6858
+ "cluster",
6859
+ "device-defaults",
6860
+ "node"
6861
+ ];
6856
6862
  /**
6857
6863
  * The address of one keychain value: `secret://<owner>/<scope>/<fieldPath>`.
6858
6864
  *
@@ -7667,6 +7673,28 @@ var SECRET_RETRY_MAX_MS = 5 * 6e4;
7667
7673
  /** Coalesces a burst of `keychain.changed` events into one re-resolve. */
7668
7674
  var SECRET_CHANGE_DEBOUNCE_MS = 250;
7669
7675
  /**
7676
+ * Pair a catalogue with the function that builds its ports FROM THE MIGRATION API. The one
7677
+ * place a catalogue is bound: a migration reaches nothing a ports binder cannot reach
7678
+ * through `api` (guard `check-migration-ports-surface`).
7679
+ */
7680
+ function migrationSet(catalogue, bindPorts) {
7681
+ return { bind: (api) => defineAddonMigrations(catalogue, bindPorts(api)) };
7682
+ }
7683
+ /** A mixed array is a mistake: the other kind would be dropped without a word. */
7684
+ function refuseMixed(form, kept, dropped) {
7685
+ if (dropped > 0) throw new Error(`runMigrations(${form}): ${dropped} of ${kept + dropped} entries are of the other kind and would be dropped; pass one kind per call`);
7686
+ }
7687
+ function catalogueOf(list) {
7688
+ const kept = list.filter((entry) => !("execute" in entry));
7689
+ refuseMixed("catalogue, (api) => ports", kept.length, list.length - kept.length);
7690
+ return kept;
7691
+ }
7692
+ function boundOf(list) {
7693
+ const kept = list.filter((entry) => "execute" in entry);
7694
+ refuseMixed("bound migrations", kept.length, list.length - kept.length);
7695
+ return kept;
7696
+ }
7697
+ /**
7670
7698
  * Base class for CamStack addons. Eliminates settings boilerplate:
7671
7699
  *
7672
7700
  * - Typed `config` property with automatic resolution from store + defaults
@@ -7744,6 +7772,13 @@ var BaseAddon = class {
7744
7772
  */
7745
7773
  secretStatePaths = [];
7746
7774
  _addonSecretTree = null;
7775
+ /**
7776
+ * D986 M4: the scoped schemas this addon declared, resolved once per capability
7777
+ * (`loadScopedSchemas`) so the sync readers (`perNodeKeys`, the secret tree, the restart check)
7778
+ * see them. Empty until loaded: the readers then fall back to {@link globalSettingsSchema}.
7779
+ */
7780
+ _scopedByCap = /* @__PURE__ */ new Map();
7781
+ _nodeKeysByCap = /* @__PURE__ */ new Map();
7747
7782
  _deviceSecretTree = null;
7748
7783
  /** Refs the last resolved config pointed at — a change to one re-resolves. */
7749
7784
  _configSecretRefs = /* @__PURE__ */ new Set();
@@ -7813,6 +7848,7 @@ var BaseAddon = class {
7813
7848
  this._ctx = context;
7814
7849
  this._secretSettings = null;
7815
7850
  }
7851
+ await this.loadScopedSchemas();
7816
7852
  await this.resolveConfig();
7817
7853
  if (secrets !== void 0) this._secretChangeUnsub = secrets.onChange((ref) => {
7818
7854
  if (this._configSecretRefs.has(ref)) this.scheduleSecretReresolve(SECRET_CHANGE_DEBOUNCE_MS);
@@ -7822,25 +7858,24 @@ var BaseAddon = class {
7822
7858
  const normalized = normalizeAddonInitResult(result);
7823
7859
  const providers = normalized && "providers" in normalized && normalized.providers ? normalized.providers : [];
7824
7860
  this._registeredCapNames = providers.map((p) => p.capability.name);
7861
+ await this.loadScopedSchemas();
7825
7862
  return normalized;
7826
7863
  }
7864
+ async runMigrations(first, second, third) {
7865
+ if (typeof second === "function") return this.runMigrationSets([migrationSet(catalogueOf(first), second)], third);
7866
+ return this.runBound(boundOf(first), second);
7867
+ }
7827
7868
  /**
7828
- * Run this addon's migration catalogue (D987 M3) through the hub's `migrations`
7829
- * engine. Call it at the point of the boot where the data must already have the new
7830
- * shape — `onInitialize`, or `onRestoreDevices` for a migration that needs the
7831
- * restored devices — with the catalogue bound to the addon's ports
7832
- * (`defineAddonMigrations(ADDON_MIGRATIONS, ports)`).
7833
- *
7834
- * An engine that cannot be reached or refuses the catalogue is logged and runs
7835
- * nothing; the boot continues. A migration that FAILED (or was blocked by one)
7836
- * and declares `onFailure: 'block-owner'` makes this throw, so the addon does not
7837
- * serve data whose shape it cannot trust; `tolerate` migrations never do.
7838
- *
7839
- * ALWAYS pass the whole catalogue; a phase that must run only some of it passes
7840
- * `{ only: [ids] }` — never a filtered catalogue (the engine would judge the
7841
- * omitted applied ids "newer").
7869
+ * Several catalogues of ONE owner in one declaration (the engine replaces an owner's
7870
+ * catalogue on every declare, so two calls would each judge the other's ids "newer").
7871
+ * Build each entry with `migrationSet(catalogue, (api) => ports)`.
7842
7872
  */
7843
- async runMigrations(migrations, options) {
7873
+ async runMigrationSets(sets, options) {
7874
+ const api = this.ctx.migrationApi;
7875
+ if (api === void 0) throw new Error(`${this.ctx.id}: the migration API is not available on this context`);
7876
+ return this.runBound(sets.flatMap((set) => set.bind(api)), options);
7877
+ }
7878
+ async runBound(migrations, options) {
7844
7879
  const api = this.ctx.api.migrations;
7845
7880
  const report = await runAddonMigrations({
7846
7881
  declareOwner: (input) => api.declareOwner.mutate(input),
@@ -7910,6 +7945,16 @@ var BaseAddon = class {
7910
7945
  schema(schema) {
7911
7946
  return schema;
7912
7947
  }
7948
+ /**
7949
+ * The capability names whose scoped settings schemas ({@link clusterSettingsSchema},
7950
+ * {@link nodeSettingsSchema}, {@link deviceDefaultsSchema}) this addon declares. Read BEFORE
7951
+ * `onInitialize`, when the providers it registers are not known yet: the first config resolve
7952
+ * must already know which keys are per node. Names the providers register at init are added
7953
+ * once known.
7954
+ */
7955
+ scopedSettingsCapabilities() {
7956
+ return [];
7957
+ }
7913
7958
  /** Override to provide global-level settings UI schema. */
7914
7959
  globalSettingsSchema(_cap) {
7915
7960
  return null;
@@ -7953,7 +7998,8 @@ var BaseAddon = class {
7953
7998
  return [];
7954
7999
  }
7955
8000
  async getGlobalSettings(overlay, cap, nodeId) {
7956
- const schema = this.globalSettingsSchema(cap);
8001
+ await this.loadScopedSchemas(cap);
8002
+ const schema = this.globalSettingsSchema(cap) ?? this.scopedFormSchema(cap);
7957
8003
  if (!schema) return { sections: [] };
7958
8004
  const projected = await this.readRawProjectedStore(nodeId, cap);
7959
8005
  return hydrateSchema(schema, overlay ? await this.overlayKeepingMarkers(projected, overlay) : projected, { inlineSecretsAsViews: true });
@@ -8084,6 +8130,7 @@ var BaseAddon = class {
8084
8130
  * resolved blob back is a no-op for every unchanged secret.
8085
8131
  */
8086
8132
  async resolveGlobalStore(nodeId, cap) {
8133
+ await this.loadScopedSchemas(cap);
8087
8134
  const raw = await this._ctx?.settings?.readAddonStore() ?? {};
8088
8135
  return this.projectForNode(raw, nodeId, cap);
8089
8136
  }
@@ -8094,6 +8141,7 @@ var BaseAddon = class {
8094
8141
  }
8095
8142
  /** The PROJECTED store with its markers — the form hydrate only (D798). */
8096
8143
  async readRawProjectedStore(nodeId, cap) {
8144
+ await this.loadScopedSchemas(cap);
8097
8145
  const raw = await this.rawSettings()?.readAddonStore() ?? {};
8098
8146
  return this.projectForNode(raw, nodeId, cap);
8099
8147
  }
@@ -8196,6 +8244,7 @@ var BaseAddon = class {
8196
8244
  await this.applyGlobalPatch(patch, nodeId);
8197
8245
  }
8198
8246
  async applyGlobalPatch(barePatch, nodeId) {
8247
+ await this.loadScopedSchemas();
8199
8248
  const keys = this.perNodeKeys();
8200
8249
  const localNode = normalizeNodeId(this._ctx?.kernel?.localNodeId);
8201
8250
  const target = normalizeNodeId(nodeId ?? this._ctx?.kernel?.localNodeId);
@@ -8209,7 +8258,8 @@ var BaseAddon = class {
8209
8258
  await this.resolveConfig();
8210
8259
  await this.onConfigChanged();
8211
8260
  this.emitLifecycle(EventCategory.AddonUpdated, { level: "global" });
8212
- this.maybeAutoRestart(Object.keys(barePatch), this.globalSettingsSchema());
8261
+ const legacy = this.globalSettingsSchema();
8262
+ this.maybeAutoRestart(Object.keys(barePatch), [...legacy === null ? [] : [legacy], ...this.loadedScopedSchemas().map((entry) => entry.schema)]);
8213
8263
  }
8214
8264
  /**
8215
8265
  * The set of field keys the global settings schema declares `perNode: true`
@@ -8217,14 +8267,67 @@ var BaseAddon = class {
8217
8267
  * declarations). Empty set ⇒ every per-node code path is bypassed and the
8218
8268
  * settings API behaves exactly like the legacy node-agnostic one.
8219
8269
  */
8220
- _perNodeKeysCache = /* @__PURE__ */ new Map();
8270
+ /**
8271
+ * Load the scoped schemas of every capability this addon declares (the override and the
8272
+ * providers registered at init, plus `extraCap`) that is not loaded yet, then drop what was
8273
+ * derived from the previous set. Idempotent.
8274
+ */
8275
+ async loadScopedSchemas(extraCap) {
8276
+ const caps = new Set([
8277
+ ...this.scopedSettingsCapabilities(),
8278
+ ...this._registeredCapNames,
8279
+ ...extraCap === void 0 ? [] : [extraCap]
8280
+ ]);
8281
+ let added = false;
8282
+ for (const cap of caps) {
8283
+ if (this._scopedByCap.has(cap)) continue;
8284
+ const loaded = [];
8285
+ for (const scope of SCOPED_SETTINGS_SCOPES) {
8286
+ const schema = await this.getScopedSettingsSchema(scope, cap);
8287
+ if (schema !== null) loaded.push({
8288
+ scope,
8289
+ schema
8290
+ });
8291
+ }
8292
+ this._scopedByCap.set(cap, loaded);
8293
+ added = true;
8294
+ }
8295
+ if (!added) return;
8296
+ this._nodeKeysByCap = /* @__PURE__ */ new Map();
8297
+ this._addonSecretTree = null;
8298
+ }
8299
+ /**
8300
+ * The form the settings API serves while an addon declares no {@link globalSettingsSchema}: the
8301
+ * sections of its scoped schemas, so dropping the old hook does not blank the form.
8302
+ */
8303
+ scopedFormSchema(cap) {
8304
+ const byId = /* @__PURE__ */ new Map();
8305
+ for (const entry of this.loadedScopedSchemas(cap)) for (const section of entry.schema.sections) if (!byId.has(section.id)) byId.set(section.id, section);
8306
+ return byId.size === 0 ? null : { sections: [...byId.values()] };
8307
+ }
8308
+ /** Every loaded scoped schema of `cap`, or of every loaded capability. */
8309
+ loadedScopedSchemas(cap) {
8310
+ if (cap !== void 0) return this._scopedByCap.get(cap) ?? [];
8311
+ return [...this._scopedByCap.values()].flat();
8312
+ }
8313
+ /**
8314
+ * The field keys that are stored per node: every field of a {@link nodeSettingsSchema}, plus
8315
+ * the fields the legacy {@link globalSettingsSchema} still marks `perNode: true`. Derived once
8316
+ * per `cap` argument and memoized (schemas are static declarations). Empty set ⇒ every
8317
+ * per-node code path is bypassed and the settings API behaves like the node-agnostic one.
8318
+ * Call {@link loadScopedSchemas} first (the async seams do).
8319
+ */
8221
8320
  perNodeKeys(cap) {
8222
8321
  const cacheKey = cap ?? "";
8223
- const cached = this._perNodeKeysCache.get(cacheKey);
8322
+ const cached = this._nodeKeysByCap.get(cacheKey);
8224
8323
  if (cached) return cached;
8225
- const schema = this.globalSettingsSchema(cap);
8226
- const keys = new Set(schema ? schema.sections.flatMap((section) => collectPerNodeFieldKeys(section.fields)) : []);
8227
- this._perNodeKeysCache.set(cacheKey, keys);
8324
+ const legacy = this.globalSettingsSchema(cap);
8325
+ const keys = new Set(legacy ? legacy.sections.flatMap((section) => collectPerNodeFieldKeys(section.fields)) : []);
8326
+ for (const { scope, schema } of this.loadedScopedSchemas(cap)) {
8327
+ if (scope !== "node") continue;
8328
+ for (const section of schema.sections) for (const key of collectValueFieldKeys(section.fields)) keys.add(key);
8329
+ }
8330
+ this._nodeKeysByCap.set(cacheKey, keys);
8228
8331
  return keys;
8229
8332
  }
8230
8333
  /**
@@ -8234,10 +8337,9 @@ var BaseAddon = class {
8234
8337
  * time to return its response before the addon is torn down and
8235
8338
  * re-initialised by `AddonRegistryService.restartAddon`.
8236
8339
  */
8237
- maybeAutoRestart(patchKeys, schema) {
8238
- if (!schema) return;
8340
+ maybeAutoRestart(patchKeys, schemas) {
8239
8341
  const restartKeys = /* @__PURE__ */ new Set();
8240
- for (const section of schema.sections) for (const field of section.fields) {
8342
+ for (const schema of schemas) for (const section of schema.sections) for (const field of section.fields) {
8241
8343
  if (field.type === "separator" || field.type === "info") continue;
8242
8344
  if (field.requiresRestart) restartKeys.add(field.key);
8243
8345
  }
@@ -8433,12 +8535,14 @@ var BaseAddon = class {
8433
8535
  * here, in the addon's own process, so the hub keeps no copy of it.
8434
8536
  */
8435
8537
  async getDeclaredSettingsKeys() {
8538
+ await this.loadScopedSchemas();
8436
8539
  return { keys: [...this.declaredKeySet()].toSorted() };
8437
8540
  }
8438
8541
  declaredKeySet() {
8439
8542
  const keys = new Set(Object.keys(this.defaults));
8440
8543
  const schema = this.globalSettingsSchema();
8441
8544
  if (schema) for (const section of schema.sections) for (const key of collectFieldKeys(section.fields)) keys.add(key);
8545
+ for (const { schema: scoped } of this.loadedScopedSchemas()) for (const section of scoped.sections) for (const key of collectFieldKeys(section.fields)) keys.add(key);
8442
8546
  for (const key of this._declaredStateKeys) keys.add(key);
8443
8547
  for (const path of this.secretStatePaths) {
8444
8548
  const root = path.split(/[.[]/, 1)[0];
@@ -8534,7 +8638,11 @@ var BaseAddon = class {
8534
8638
  }
8535
8639
  /** The addon store's secret tree: schema fields by TYPE, plus `secretStatePaths`. */
8536
8640
  addonSecretTree() {
8537
- this._addonSecretTree ??= mergeSecretStatePaths(secretTreeFromSchema(this.globalSettingsSchema()), this.secretStatePaths);
8641
+ if (this._addonSecretTree === null) {
8642
+ const tree = new Map(secretTreeFromSchema(this.globalSettingsSchema()));
8643
+ for (const { schema } of this.loadedScopedSchemas()) for (const [key, node] of secretTreeFromSchema(schema)) tree.set(key, node);
8644
+ this._addonSecretTree = mergeSecretStatePaths(tree, this.secretStatePaths);
8645
+ }
8538
8646
  return this._addonSecretTree;
8539
8647
  }
8540
8648
  deviceSecretTree() {
@@ -8784,6 +8892,23 @@ function dropPerNodeFields(fields) {
8784
8892
  }
8785
8893
  return kept;
8786
8894
  }
8895
+ /** The keys of the value-holding fields, recursing into layout containers. */
8896
+ function collectValueFieldKeys(fields) {
8897
+ const collected = [];
8898
+ for (const field of fields) {
8899
+ if (field.type === "group") {
8900
+ collected.push(...collectValueFieldKeys(field.fields));
8901
+ continue;
8902
+ }
8903
+ if (field.type === "sub-tabs") {
8904
+ for (const tab of field.tabs) collected.push(...collectValueFieldKeys(tab.fields));
8905
+ continue;
8906
+ }
8907
+ if (field.type === "separator" || field.type === "info") continue;
8908
+ if ("key" in field && typeof field.key === "string") collected.push(field.key);
8909
+ }
8910
+ return collected;
8911
+ }
8787
8912
  function collectFieldKeys(fields) {
8788
8913
  const collected = [];
8789
8914
  for (const field of fields) {
@@ -13585,7 +13710,7 @@ var RecordIntegrationInstances = class {
13585
13710
  return record === null ? null : this.viewOf(record);
13586
13711
  };
13587
13712
  /** Insert ONE row (`ifRev: 0`: an existing id is refused, never overwritten). */
13588
- createInstance = async ({ id, name, kind }) => {
13713
+ createInstance = async ({ id, name, kind, values }) => {
13589
13714
  const minted = id ?? this.hooks.mintId?.({
13590
13715
  name,
13591
13716
  ...kind !== void 0 ? { kind } : {}
@@ -13595,10 +13720,20 @@ var RecordIntegrationInstances = class {
13595
13720
  id: minted,
13596
13721
  data: this.hooks.initialBody(name, kind)
13597
13722
  }, { ifRev: 0 });
13723
+ const patch = this.patchOf(values ?? {});
13724
+ if (Object.keys(patch).length > 0) await this.handle.patch(minted, patch);
13598
13725
  await this.hooks.onChanged?.(minted, "created");
13599
13726
  return { id: minted };
13600
13727
  };
13601
13728
  updateInstance = async ({ id, name, values }) => {
13729
+ const patch = this.patchOf(values);
13730
+ if (name !== void 0) patch[this.nameField] = name;
13731
+ await this.handle.patch(id, patch);
13732
+ await this.hooks.onChanged?.(id, "updated");
13733
+ return { ok: true };
13734
+ };
13735
+ /** The record patch a form `values` object means: secrets kept, cleared or replaced. */
13736
+ patchOf(values) {
13602
13737
  const patch = {};
13603
13738
  for (const [key, value] of Object.entries(values)) {
13604
13739
  if (value === void 0) continue;
@@ -13609,11 +13744,8 @@ var RecordIntegrationInstances = class {
13609
13744
  else if (isSecretClear(value)) patch[key] = null;
13610
13745
  else if (typeof value === "string" && value !== "" && value !== "__camstack_redacted__" && !isSecretFieldView(value)) patch[key] = value;
13611
13746
  }
13612
- if (name !== void 0) patch[this.nameField] = name;
13613
- await this.handle.patch(id, patch);
13614
- await this.hooks.onChanged?.(id, "updated");
13615
- return { ok: true };
13616
- };
13747
+ return patch;
13748
+ }
13617
13749
  /** The switch lives in the record body; the addon is told so it applies it (D996). */
13618
13750
  setInstanceEnabled = async ({ id, enabled }) => {
13619
13751
  if (this.registryOwnsEnabled) throw new Error(`instance "${id}": the integrations registry owns the on/off switch of this addon; set it with integrations.update`);
@@ -13650,6 +13782,76 @@ var RecordIntegrationInstances = class {
13650
13782
  };
13651
13783
  }
13652
13784
  };
13785
+ /** No part holds the id a call named. */
13786
+ var UnknownInstanceError = class extends Error {
13787
+ instanceId;
13788
+ constructor(instanceId) {
13789
+ super(`Unknown instance '${instanceId}'`);
13790
+ this.instanceId = instanceId;
13791
+ this.name = "UnknownInstanceError";
13792
+ }
13793
+ };
13794
+ /** A create or a form named no kind, or a kind no part serves. */
13795
+ var UnknownInstanceKindError = class extends Error {
13796
+ kindId;
13797
+ constructor(kindId) {
13798
+ super(kindId === void 0 ? "This provider has several kinds: name one" : `Unknown instance kind '${kindId}'`);
13799
+ this.kindId = kindId;
13800
+ this.name = "UnknownInstanceKindError";
13801
+ }
13802
+ };
13803
+ function composeInstanceKinds(parts, base) {
13804
+ const byKind = (kindId) => {
13805
+ if (kindId === void 0) {
13806
+ const only = parts.length === 1 ? parts[0] : void 0;
13807
+ if (only === void 0) throw new UnknownInstanceKindError(void 0);
13808
+ return only;
13809
+ }
13810
+ const part = parts.find((candidate) => candidate.kind.id === kindId);
13811
+ if (part === void 0) throw new UnknownInstanceKindError(kindId);
13812
+ return part;
13813
+ };
13814
+ const holderOf = async (id) => {
13815
+ for (const part of parts) if (await part.provider.getInstance({ id }) !== null) return part;
13816
+ throw new UnknownInstanceError(id);
13817
+ };
13818
+ const tagged = (part, instance) => ({
13819
+ ...instance,
13820
+ kind: part.kind.id
13821
+ });
13822
+ return {
13823
+ describe: async () => ({
13824
+ ...base,
13825
+ kinds: parts.map((part) => part.kind)
13826
+ }),
13827
+ instanceFormSchema: async ({ id, kind }) => {
13828
+ return (id === null ? byKind(kind) : await holderOf(id)).provider.instanceFormSchema({ id });
13829
+ },
13830
+ listInstances: async () => {
13831
+ return (await Promise.all(parts.map(async (part) => (await part.provider.listInstances()).map((instance) => tagged(part, instance))))).flat();
13832
+ },
13833
+ getInstance: async ({ id }) => {
13834
+ for (const part of parts) {
13835
+ const found = await part.provider.getInstance({ id });
13836
+ if (found !== null) return tagged(part, found);
13837
+ }
13838
+ return null;
13839
+ },
13840
+ createInstance: async (input) => byKind(input.kind).provider.createInstance(input),
13841
+ updateInstance: async (input) => (await holderOf(input.id)).provider.updateInstance(input),
13842
+ setInstanceEnabled: async (input) => (await holderOf(input.id)).provider.setInstanceEnabled(input),
13843
+ deleteInstance: async (input) => (await holderOf(input.id)).provider.deleteInstance(input),
13844
+ testInstance: async (input) => {
13845
+ const test = (input.id === null ? byKind(input.kind) : await holderOf(input.id)).provider.testInstance;
13846
+ if (test === void 0) return {
13847
+ success: false,
13848
+ status: "inconclusive",
13849
+ error: "This kind cannot be tested"
13850
+ };
13851
+ return test(input);
13852
+ }
13853
+ };
13854
+ }
13653
13855
  var EncodeProfileSchema = object({
13654
13856
  video: object({
13655
13857
  codec: _enum([
@@ -15930,6 +16132,39 @@ var UnstampedEventMediaCountSchema = object({
15930
16132
  /** Sum across both, or `null` when either lane could not be counted. */
15931
16133
  total: number$1().int().nonnegative().nullable()
15932
16134
  }).nullable();
16135
+ /**
16136
+ * Where the stored event media sits, per storage location: how many media rows
16137
+ * and how many bytes carry each location id, how many carry none (legacy,
16138
+ * resolved to the default `eventMedia` location), and how many name a location
16139
+ * that was not asked about (a deleted or never-listed one).
16140
+ *
16141
+ * One covering-index aggregate per asked location plus the NULL one, and one
16142
+ * unfiltered total that scans the covering index: O(rows), not a walk of the
16143
+ * rows themselves. `unlisted` is total minus the ASKED locations minus NULL, so
16144
+ * it is a deleted location only when every location id was asked.
16145
+ * `null` is "could not be measured", never zero.
16146
+ */
16147
+ var MediaLocationCensusInputSchema = object({ locationIds: array(string().min(1)).max(50).readonly() });
16148
+ var MediaLocationCensusSchema = object({
16149
+ locations: array(object({
16150
+ locationId: string(),
16151
+ rows: number$1().int().nonnegative(),
16152
+ bytes: number$1().nonnegative()
16153
+ })).readonly(),
16154
+ unstamped: object({
16155
+ rows: number$1().int().nonnegative(),
16156
+ bytes: number$1().nonnegative()
16157
+ }),
16158
+ /** Rows stamped with a location id that was not in the asked list. */
16159
+ unlisted: object({
16160
+ rows: number$1().int().nonnegative(),
16161
+ bytes: number$1().nonnegative()
16162
+ }),
16163
+ total: object({
16164
+ rows: number$1().int().nonnegative(),
16165
+ bytes: number$1().nonnegative()
16166
+ })
16167
+ });
15933
16168
  var StorageMigrationMediaMoveInputSchema = RelocateMediaInputSchema.extend({ leaseId: string().min(1) });
15934
16169
  /** The independently selectable logical storage classes — every class
15935
16170
  * `storage.listLocationDeclarations` reports, so an operator never meets a
@@ -47007,52 +47242,7 @@ onBrightnessChanged: { data: object({
47007
47242
  minIntervalMs: 0
47008
47243
  } } }
47009
47244
  };
47010
- /**
47011
- * `broker` — unified pub/sub broker registry, system-scoped collection.
47012
- *
47013
- * The cap models any kind-tagged message broker the user wants to
47014
- * register and that other addons might consume. The first two kinds
47015
- * are MQTT (mosquitto / aedes embedded / cloud bridge) and
47016
- * Home Assistant (HA WebSocket — subscribe_entities + call_service).
47017
- * Future kinds (Zigbee2MQTT bridge, ZHA, KNX, Telegram, …) plug in
47018
- * the same surface.
47019
- *
47020
- * Why one cap, not one cap per kind:
47021
- * - The integrations page wants a single table of "every broker"
47022
- * across kinds; a unified cap drives it without join logic.
47023
- * - Generic admin operations (add / remove / test / status) are
47024
- * identical across kinds — duplicating them per cap was busywork.
47025
- * - The `network-access` cap precedent: same interface, many
47026
- * `providerKind: 'ingress'` implementations (Tailscale, ngrok, …).
47027
- *
47028
- * What stays kind-specific:
47029
- * - `getBrokerConfig` payload (kind decides whether it returns
47030
- * mqtt URL+creds, HA baseUrl+token, …) — typed as
47031
- * `Record<string, unknown>` at the wire; consumers narrow.
47032
- * - `publish` / `subscribe` / `getState` arguments (target / filter /
47033
- * key) — also typed as `Record<string, unknown>` so each kind can
47034
- * evolve its surface (MQTT topic+qos vs HA entity_id+domain)
47035
- * without rebreaking the cap signature.
47036
- * - `add` `settings` payload (different fields per kind, validated by
47037
- * the kind-specific provider).
47038
- *
47039
- * Bidirectionality:
47040
- * - `publish` is RPC — synchronous result (provider-defined).
47041
- * - `subscribe` returns a subscription handle; the actual message
47042
- * stream flows over the typed event-bus as `broker.message` events
47043
- * keyed by `(brokerId, subscriptionId)`. Consumers filter in their
47044
- * event handler. Subscriptions persist across reconnects — the
47045
- * provider re-subscribes upstream after a transport drop.
47046
- *
47047
- * Why event-bus push (not a subscription RPC stream):
47048
- * - Matches the rest of CamStack's D8 contract — events for telemetry,
47049
- * RPC for loss-is-a-bug. Broker messages are telemetry (a single
47050
- * drop is recoverable via `getState({key})`).
47051
- * - Frees `broker.subscribe` from holding a long-lived RPC channel.
47052
- * - Cross-process: events already route through `$event-bus`; no
47053
- * per-broker wire-shaping needed.
47054
- */
47055
- var BrokerStatusEnum = _enum([
47245
+ _enum([
47056
47246
  "connected",
47057
47247
  "disconnected",
47058
47248
  "connecting",
@@ -47060,141 +47250,12 @@ var BrokerStatusEnum = _enum([
47060
47250
  "unreachable",
47061
47251
  "error"
47062
47252
  ]);
47063
- var BrokerInfoSchema = object({
47064
- /** Stable broker id. Persisted; survives addon restarts. */
47065
- id: string(),
47066
- /** Addon id of the provider that OWNS this broker.
47067
- *
47068
- * The `broker` cap is a system-scoped collection: several addons
47069
- * register a `broker` provider, each owning a DISJOINT set of
47070
- * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
47071
- * `ha_*`). A broker therefore belongs to exactly one addon — like a
47072
- * device belongs to one integration. The admin UI threads this id
47073
- * back as the `{ addonId }` system-collection selector on every
47074
- * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
47075
- * `testConnection`), so the call routes to the OWNING provider
47076
- * instead of defaulting to the first-registered one. */
47077
- addonId: string(),
47078
- /** Human-readable name (operator-chosen at add-time). */
47079
- name: string(),
47080
- /** Provider-defined kind tag — `mqtt` / `home-assistant` / future. */
47081
- kind: string(),
47082
- status: BrokerStatusEnum,
47083
- /** Free-form provider-specific info (HA version, MQTT broker
47084
- * flavour, latency, mTLS active, …). */
47085
- info: record(string(), unknown()),
47086
- /** Ms epoch of the last connection probe / status update. */
47087
- lastCheckedAt: number$1().nullable(),
47088
- /** Last error message, when `status` indicates failure. */
47089
- error: string().nullable()
47090
- });
47091
47253
  var RegistryStatusSchema = object({
47092
47254
  brokerCount: number$1().int().nonnegative(),
47093
47255
  connectedCount: number$1().int().nonnegative()
47094
47256
  });
47095
- /**
47096
- * One entry per `broker` provider — which addon provides which broker
47097
- * kind(s). The unified create picker reads this to know, for each
47098
- * (addonId, kind) pair the operator can pick, where to route the
47099
- * follow-up `getSettingsSchema` / `testSettings` / `add` calls (via the
47100
- * `{ addonId }` selector). A provider only advertises a kind here when
47101
- * it implements `getSettingsSchema` for that kind (so the picker never
47102
- * offers a kind whose creation form can't be rendered).
47103
- */
47104
- var BrokerProviderInfoSchema = object({
47105
- /** Addon id of the `broker` provider this entry describes. */
47106
- addonId: string(),
47107
- /** Broker kinds this provider can create, with a display label. */
47108
- kinds: array(object({
47109
- kind: string(),
47110
- label: string(),
47111
- /**
47112
- * Relative hub path to start an interactive account link (OAuth popup).
47113
- * May include `{{brokerId}}`; admin-ui substitutes and appends `?popup=1`.
47114
- */
47115
- interactiveConnectPath: string().min(1).optional(),
47116
- /** Relative hub path to disconnect the linked account (same popup pattern). */
47117
- interactiveDisconnectPath: string().min(1).optional(),
47118
- /**
47119
- * The kind signs in through a server-held interactive session instead of
47120
- * (or before) typed credentials. `basePath` is a hub-relative route set
47121
- * served by the owning addon (`/addon/<id>/...`) speaking the sign-in
47122
- * contract: `POST {basePath}/start` (body: the form's settings) answers
47123
- * `{ sessionId, loginUrl }`; `GET {basePath}/status?sessionId=` answers
47124
- * `{ phase: 'starting' | 'awaiting-login' | 'captured' | 'error' |
47125
- * 'cancelled', loginUrl, error }`; `POST {basePath}/commit`
47126
- * (`{ sessionId, name, brokerId? }`) stores the credentials on a new or
47127
- * existing broker and answers `{ brokerId }`; `POST {basePath}/cancel`
47128
- * (`{ sessionId }`) drops the session. The dialog that edits a broker
47129
- * offers it as the default path; the fields the session fills are
47130
- * marked `filledBySignIn` in the kind's settings schema.
47131
- *
47132
- * `methods` lists the ways the session can sign in, recommended first:
47133
- * `'device-code'` adds `POST {basePath}/code/start` (body: the form's
47134
- * settings) answering `{ sessionId, userCode, verificationUrl,
47135
- * expiresAt }` — the operator types `userCode` at `verificationUrl` on
47136
- * any device, and the same `status` / `commit` / `cancel` routes follow
47137
- * (status then carries `method`, `userCode`, `verificationUrl`,
47138
- * `expiresAt` and the phases `'awaiting-code'` / `'expired'`);
47139
- * `'proxy'` is the `{basePath}/start` flow above. Absent means
47140
- * `['proxy']`.
47141
- */
47142
- signIn: object({
47143
- basePath: string().min(1),
47144
- methods: array(_enum(["device-code", "proxy"])).optional()
47145
- }).optional()
47146
- }))
47147
- });
47148
- var ListInputSchema = object({
47149
- /** Optional kind filter — `list({kind:'home-assistant'})` returns
47150
- * only HA brokers. Omit to list every broker. */
47151
- kind: string().optional() });
47152
47257
  var GetInputSchema = object({ id: string() });
47153
- var AddInputSchema = object({
47154
- kind: string().min(1),
47155
- name: string().min(1),
47156
- /**
47157
- * ADOPT an existing id instead of minting a new one.
47158
- *
47159
- * Written the day this cost an outage. A broker's id is not a detail: HA
47160
- * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
47161
- * lost from config and re-added as `ha_001` leaves every one of its devices
47162
- * bound to a broker that no longer exists. Re-entering the password under the
47163
- * ORIGINAL id turns a multi-step device migration back into re-entering a
47164
- * password.
47165
- *
47166
- * A provider MUST refuse an id that is already in use — adopting a live
47167
- * broker's id would silently take it over.
47168
- */
47169
- id: string().min(1).optional(),
47170
- /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
47171
- * `{baseUrl,accessToken}`). Validated by the kind-specific provider
47172
- * branch on receipt — invalid shape rejects the add. */
47173
- settings: record(string(), unknown())
47174
- });
47175
- var AddResultSchema = object({ id: string() });
47176
- var RemoveInputSchema = object({ id: string() });
47177
- var TestConnectionResultSchema = discriminatedUnion("ok", [object({
47178
- ok: literal(true),
47179
- latencyMs: number$1().nonnegative()
47180
- }), object({
47181
- ok: literal(false),
47182
- error: string()
47183
- })]);
47184
47258
  var SettingsRecordSchema$1 = record(string(), unknown());
47185
- var SettingsSchemaInputSchema = object({ kind: string() });
47186
- var TestSettingsInputSchema = object({
47187
- kind: string(),
47188
- settings: SettingsRecordSchema$1
47189
- });
47190
- var TestSettingsResultSchema = discriminatedUnion("ok", [object({
47191
- ok: literal(true),
47192
- latencyMs: number$1().nonnegative().optional()
47193
- }).strict(), object({
47194
- ok: literal(false),
47195
- error: string()
47196
- })]);
47197
- var SettingsSchemaResultSchema = unknown().nullable();
47198
47259
  var PublishInputSchema = object({
47199
47260
  brokerId: string(),
47200
47261
  /** Kind-specific routing target.
@@ -47238,51 +47299,12 @@ var brokerCapability = {
47238
47299
  kind: "poll"
47239
47300
  },
47240
47301
  methods: {
47241
- list: method(ListInputSchema, array(BrokerInfoSchema)),
47242
- get: method(GetInputSchema, BrokerInfoSchema.nullable()),
47243
- /** Enumerate which addon provides which broker kind(s) for the
47244
- * unified create picker. The auto-mount fans this array across
47245
- * every registered `broker` provider (array-output method), so the
47246
- * picker sees every kind from every provider in one call. */
47247
- listProviders: method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }),
47248
- add: method(AddInputSchema, AddResultSchema, {
47249
- kind: "mutation",
47250
- auth: "admin"
47251
- }),
47252
- remove: method(RemoveInputSchema, _void(), {
47253
- kind: "mutation",
47254
- auth: "admin"
47255
- }),
47256
- testConnection: method(GetInputSchema, TestConnectionResultSchema, {
47257
- kind: "mutation",
47258
- auth: "admin"
47259
- }),
47260
- /** Read the persisted settings record for a broker (kind-specific
47261
- * shape). Admin-only — settings may contain secrets. Returns `null`
47262
- * when the broker id is unknown to the provider (the collection
47263
- * fallback may route a foreign id to the first provider). */
47264
- getSettings: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
47265
- /** Overwrite the persisted settings record. The kind-specific
47266
- * provider validates the shape and applies the change (reconnects
47267
- * if credentials changed). */
47268
- setSettings: method(object({
47269
- id: string(),
47270
- settings: SettingsRecordSchema$1
47271
- }), _void(), {
47272
- kind: "mutation",
47273
- auth: "admin"
47274
- }),
47275
47302
  /** Returns the kind-specific connection config the consumer needs
47276
47303
  * to open its own client (MQTT pattern: `{url, username, password,
47277
47304
  * clientIdPrefix}`). HA providers MAY return the auth envelope
47278
47305
  * but typical HA consumers use `publish` / `subscribe` instead.
47279
47306
  * Returns `null` when the broker id is unknown to the provider. */
47280
47307
  getBrokerConfig: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
47281
- getSettingsSchema: method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }),
47282
- testSettings: method(TestSettingsInputSchema, TestSettingsResultSchema, {
47283
- kind: "mutation",
47284
- auth: "admin"
47285
- }),
47286
47308
  publish: method(PublishInputSchema, unknown(), {
47287
47309
  kind: "mutation",
47288
47310
  auth: "admin"
@@ -52129,7 +52151,14 @@ var DiscoveryCandidateSchema = object({
52129
52151
  /** `stableId` of the gateway candidate this device was found BEHIND. */
52130
52152
  parentStableId: string().optional(),
52131
52153
  /** `false` = shown for context but not pickable. Default true. */
52132
- selectable: boolean().optional()
52154
+ selectable: boolean().optional(),
52155
+ /**
52156
+ * Keys of the provider's creation form (`getChildCreationSchema`) this candidate
52157
+ * cannot be adopted without — a key for an encrypted board, a pairing code. The
52158
+ * admin asks for exactly these, on the creation form prefilled with
52159
+ * `prefilledConfig`, before it adopts. Absent or empty = adopt as it is.
52160
+ */
52161
+ needsParams: array(string()).optional()
52133
52162
  });
52134
52163
  /**
52135
52164
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -52256,8 +52285,14 @@ var deviceProviderCapability = {
52256
52285
  * provider does not support manual creation. Lets the Add-Device dialog
52257
52286
  * pick the right type instead of assuming Camera.
52258
52287
  */
52259
- getManualCreationType: method(object({}), object({ deviceType: _enum(DeviceType).nullable() })),
52260
- adoptDiscoveredDevice: method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
52288
+ getManualCreationType: method(object({
52289
+ /** The integration asked about; a provider whose integrations differ answers per one. */
52290
+ integrationId: string().optional() }), object({ deviceType: _enum(DeviceType).nullable() })),
52291
+ adoptDiscoveredDevice: method(object({
52292
+ candidate: DiscoveryCandidateSchema,
52293
+ /** The integration the operator adopts into; a provider with several instances needs it. */
52294
+ integrationId: string().optional()
52295
+ }), DeviceSummarySchema, {
52261
52296
  kind: "mutation",
52262
52297
  auth: "admin"
52263
52298
  }),
@@ -52268,10 +52303,16 @@ var deviceProviderCapability = {
52268
52303
  * devices of that type. The output is a `ConfigUISchema` — the
52269
52304
  * router type-asserts it at the boundary.
52270
52305
  */
52271
- getChildCreationSchema: method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema),
52306
+ getChildCreationSchema: method(object({
52307
+ type: _enum(DeviceType),
52308
+ /** The integration asked about; a provider whose integrations differ answers per one. */
52309
+ integrationId: string().optional()
52310
+ }), CreationSchemaOutputSchema),
52272
52311
  createDevice: method(object({
52273
52312
  type: _enum(DeviceType),
52274
- config: record(string(), unknown())
52313
+ config: record(string(), unknown()),
52314
+ /** The integration the operator adds the device to; a provider with several instances needs it. */
52315
+ integrationId: string().optional()
52275
52316
  }), DeviceSummarySchema, {
52276
52317
  kind: "mutation",
52277
52318
  auth: "admin"
@@ -52996,7 +53037,10 @@ method(object({
52996
53037
  auth: "admin"
52997
53038
  }), method(object({
52998
53039
  addonId: string(),
52999
- type: _enum(DeviceType)
53040
+ type: _enum(DeviceType),
53041
+ /** The integration the form is for; a provider whose integrations differ (a hub
53042
+ * integration takes no manual add) answers per integration. Absent = the addon's own. */
53043
+ integrationId: string().optional()
53000
53044
  }), unknown().nullable()), method(object({
53001
53045
  addonId: string(),
53002
53046
  type: _enum(DeviceType),
@@ -53080,7 +53124,11 @@ method(object({
53080
53124
  kind: "mutation",
53081
53125
  auth: "admin",
53082
53126
  timeoutMs: 12e4
53083
- }), method(object({ addonId: string() }), object({ deviceType: _enum(DeviceType).nullable() }), { auth: "admin" }), method(object({ addonId: string() }), unknown(), { auth: "admin" }), method(object({
53127
+ }), method(object({
53128
+ addonId: string(),
53129
+ /** The integration the Add-Device dialog is open on; absent = the addon's own. */
53130
+ integrationId: string().optional()
53131
+ }), object({ deviceType: _enum(DeviceType).nullable() }), { auth: "admin" }), method(object({ addonId: string() }), unknown(), { auth: "admin" }), method(object({
53084
53132
  deviceId: number$1(),
53085
53133
  key: string(),
53086
53134
  value: unknown()
@@ -56360,7 +56408,7 @@ var TestConnectionStatusEnum = _enum([
56360
56408
  "inconclusive",
56361
56409
  "unsupported"
56362
56410
  ]);
56363
- var TestConnectionResultSchema$1 = object({
56411
+ var TestConnectionResultSchema = object({
56364
56412
  /** True ONLY for `validated`. Never true for a test that did not run. */
56365
56413
  success: boolean(),
56366
56414
  error: string().optional(),
@@ -56405,7 +56453,7 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
56405
56453
  }), method(_void(), array(AvailableIntegrationTypeSchema), { auth: "admin" }), method(object({
56406
56454
  addonId: string(),
56407
56455
  settings: record(string(), unknown())
56408
- }), TestConnectionResultSchema$1, {
56456
+ }), TestConnectionResultSchema, {
56409
56457
  kind: "mutation",
56410
56458
  auth: "admin"
56411
56459
  });
@@ -56432,6 +56480,14 @@ var ConfigSchemaPassthrough$3 = custom();
56432
56480
  var IntegrationInstanceIdSchema = string().min(1);
56433
56481
  var IntegrationInstanceModeSchema = _enum(["single", "multiple"]);
56434
56482
  /**
56483
+ * What the addon's instances ARE for the Integrations page (planning#416). `devices`: each
56484
+ * instance is an integration that brings devices (the registry owns its on/off switch);
56485
+ * `services`: a connection configured from the addon's own function page, never listed on
56486
+ * Integrations.
56487
+ * It is the same vocabulary as the manifest's `integrationRole`, which stays as an override.
56488
+ */
56489
+ var IntegrationInstanceRoleSchema = _enum(["devices", "services"]);
56490
+ /**
56435
56491
  * One kind of instance a provider can create (an adapter, an account type). The sign-in and
56436
56492
  * connect fields follow the `broker` contract (`broker.cap.ts`): `signIn.basePath` is a
56437
56493
  * hub-relative route set speaking the sign-in contract, `connectPath` / `disconnectPath` open an
@@ -56442,6 +56498,22 @@ var IntegrationInstanceKindSchema = object({
56442
56498
  /** Stable id of the kind, sent back as `createInstance.kind`. */
56443
56499
  id: string().min(1),
56444
56500
  label: string().min(1),
56501
+ /**
56502
+ * The function capability this kind is configured for (`broker`, `smtp-provider`,
56503
+ * `device-provider`, ...). A page section is the kinds whose `functionCap` it names (D1013); a
56504
+ * provider with more than one section must name it on every kind.
56505
+ */
56506
+ functionCap: string().min(1),
56507
+ /**
56508
+ * For `functionCap: 'broker'`: the registry kind of the broker an instance of this kind is
56509
+ * (`matter`, `home-assistant`, ...). A caller that needs a broker of one kind (a link field)
56510
+ * finds the instances whose kind names it; absent takes the kind `id`.
56511
+ */
56512
+ brokerKind: string().min(1).optional(),
56513
+ /** What instances of this kind are for the Integrations page; absent takes the provider's `role`. */
56514
+ role: IntegrationInstanceRoleSchema.optional(),
56515
+ /** `testInstance` really probes the remote for this kind; absent takes the provider's `canTest`. */
56516
+ canTest: boolean().optional(),
56445
56517
  /** The kind signs in through a server-held interactive session. */
56446
56518
  signIn: object({
56447
56519
  basePath: string().min(1),
@@ -56454,11 +56526,7 @@ var IntegrationInstanceKindSchema = object({
56454
56526
  });
56455
56527
  var IntegrationInstanceDescriptionSchema = object({
56456
56528
  /** What the instances are for the Integrations page; see {@link IntegrationInstanceRoleSchema}. */
56457
- role: _enum([
56458
- "devices",
56459
- "services",
56460
- "both"
56461
- ]),
56529
+ role: IntegrationInstanceRoleSchema,
56462
56530
  /** How the devices of an instance are found; absent means `{ mode: 'none' }`. */
56463
56531
  discovery: object({
56464
56532
  mode: _enum([
@@ -56533,7 +56601,11 @@ var integrationInstancesCapability = {
56533
56601
  methods: {
56534
56602
  describe: method(_void(), IntegrationInstanceDescriptionSchema, { auth: "admin" }),
56535
56603
  /** The form; `id: null` is the create form. */
56536
- instanceFormSchema: method(object({ id: IntegrationInstanceIdSchema.nullable() }), ConfigSchemaPassthrough$3, { auth: "admin" }),
56604
+ instanceFormSchema: method(object({
56605
+ id: IntegrationInstanceIdSchema.nullable(),
56606
+ /** The kind of the create form (`id: null`) of a provider with several. */
56607
+ kind: string().min(1).optional()
56608
+ }), ConfigSchemaPassthrough$3, { auth: "admin" }),
56537
56609
  listInstances: method(_void(), array(IntegrationInstanceSchema).readonly(), { auth: "admin" }),
56538
56610
  getInstance: method(object({ id: IntegrationInstanceIdSchema }), IntegrationInstanceSchema.nullable(), { auth: "admin" }),
56539
56611
  /**
@@ -56543,7 +56615,9 @@ var integrationInstancesCapability = {
56543
56615
  createInstance: method(object({
56544
56616
  id: IntegrationInstanceIdSchema.optional(),
56545
56617
  name: string().min(1),
56546
- kind: string().min(1).optional()
56618
+ kind: string().min(1).optional(),
56619
+ /** Form values written with the record (a link picked in the create dialog). */
56620
+ values: record(string(), unknown()).optional()
56547
56621
  }), object({ id: string() }), {
56548
56622
  kind: "mutation",
56549
56623
  auth: "admin"
@@ -56570,8 +56644,10 @@ var integrationInstancesCapability = {
56570
56644
  }),
56571
56645
  testInstance: optionalMethod(object({
56572
56646
  id: IntegrationInstanceIdSchema.nullable(),
56573
- values: record(string(), unknown())
56574
- }), TestConnectionResultSchema$1, {
56647
+ values: record(string(), unknown()),
56648
+ /** The kind to test when `id` is null and the provider has several. */
56649
+ kind: string().min(1).optional()
56650
+ }), TestConnectionResultSchema, {
56575
56651
  kind: "mutation",
56576
56652
  auth: "admin"
56577
56653
  })
@@ -59328,6 +59404,380 @@ var SystemMetricsSchema = object({
59328
59404
  gpuMemoryPercent: number$1().optional()
59329
59405
  });
59330
59406
  method(_void(), SystemResourceSnapshotSchema), method(_void(), SystemResourceSnapshotSchema.nullable()), method(_void(), SystemMetricsSchema), method(object({ dirPath: string() }), DiskSpaceInfoSchema), method(_void(), MetricsGpuInfoSchema.nullable()), method(_void(), number$1().nullable()), method(object({ pids: array(number$1()) }), array(PidResourceStatsSchema)), method(_void(), array(AddonInstanceSchema).readonly()), method(object({ addonId: string() }), PidResourceStatsSchema.nullable()), method(_void(), array(NodeProcessSchema).readonly()), method(GetLoadSeriesInputSchema, NodeLoadSeriesSchema);
59407
+ /**
59408
+ * records - an addon's own facts and documents (D881): ledgers, audit rows,
59409
+ * config documents, mirrors. The third storage API next to `ctx.settings`
59410
+ * (operator intent, D870) and device-history (numbers over time, D850).
59411
+ *
59412
+ * Who the OWNER is: `callerAddonId`, stamped by the transport on a call that
59413
+ * crossed a process and stated by a hub in-process caller (`ctx.records` does
59414
+ * that with the addon id closed over). It is deliberately NOT in any input
59415
+ * schema, so a session can never supply it, and a call with no stamp is refused
59416
+ * `records/no-caller`. A kind is readable and writable only by its owner; other
59417
+ * addons go through the owner's capability.
59418
+ *
59419
+ * Every read is three-way: a row, `null`, or the call THROWS. A failed read is
59420
+ * never `[]`. Secret fields are never on the wire: a read shows the
59421
+ * redaction token, `secretRefs` hands the owner's `ctx.secrets` the refs it
59422
+ * resolves itself, and a write may carry only a keychain marker bound to the
59423
+ * row's own ref.
59424
+ *
59425
+ * Internal: addons reach it through `ctx.records`, never directly.
59426
+ */
59427
+ var RecordIdSchema = string().min(1).max(256);
59428
+ /** A JSON object body. Size is the engine's check (`maxDataBytes`), not a schema's. */
59429
+ var RecordBodySchema = record(string(), unknown());
59430
+ var RecordInputSchema = object({
59431
+ id: RecordIdSchema,
59432
+ /** The device this row belongs to (D865 removal, per-device reads). */
59433
+ deviceId: number$1().int().optional(),
59434
+ /** The domain clock, ms: observed / created / opened. */
59435
+ at: number$1().int().optional(),
59436
+ /** Producer-set expiry, ms. Refused on a kind whose retention is an engine TTL. */
59437
+ expiresAt: number$1().int().optional(),
59438
+ data: RecordBodySchema
59439
+ });
59440
+ var RecordViewSchema = object({
59441
+ id: string(),
59442
+ deviceId: number$1().int().nullable(),
59443
+ at: number$1().nullable(),
59444
+ expiresAt: number$1().nullable(),
59445
+ rev: number$1().int(),
59446
+ updatedAt: number$1(),
59447
+ data: RecordBodySchema
59448
+ });
59449
+ /** Declared index fields only. Anything else does not compile, by design. */
59450
+ var RecordWhereSchema = record(RecordFieldPathSchema, union([
59451
+ string(),
59452
+ number$1(),
59453
+ boolean(),
59454
+ object({ in: array(union([
59455
+ string(),
59456
+ number$1(),
59457
+ boolean()
59458
+ ])).min(1).max(RECORD_BATCH_MAX) }),
59459
+ object({
59460
+ gt: union([string(), number$1()]).optional(),
59461
+ gte: union([string(), number$1()]).optional(),
59462
+ lt: union([string(), number$1()]).optional(),
59463
+ lte: union([string(), number$1()]).optional()
59464
+ }).refine((r) => Object.keys(r).length > 0, { message: "name at least one bound" })
59465
+ ]));
59466
+ var KindShape = { kind: RecordKindNameSchema };
59467
+ var TimeWindowShape = {
59468
+ deviceId: number$1().int().optional(),
59469
+ atFrom: number$1().optional(),
59470
+ atTo: number$1().optional()
59471
+ };
59472
+ var RecordQueryInputSchema = object({
59473
+ ...KindShape,
59474
+ ...TimeWindowShape,
59475
+ where: RecordWhereSchema.optional(),
59476
+ /** Without `where`: `at` (default for a kind with a clock) or `id`. With `where` the order is the index's. */
59477
+ order: _enum(["at", "id"]).optional(),
59478
+ desc: boolean().optional(),
59479
+ limit: number$1().int().min(1).max(RECORD_QUERY_MAX).default(100),
59480
+ /** Opaque keyset cursor from a previous answer. There is no offset. */
59481
+ cursor: string().min(1).optional()
59482
+ });
59483
+ var RecordQueryResultSchema = object({
59484
+ records: array(RecordViewSchema),
59485
+ nextCursor: string().nullable()
59486
+ });
59487
+ var RecordKindStateSchema = _enum([
59488
+ "created",
59489
+ "unchanged",
59490
+ "updated"
59491
+ ]);
59492
+ var DeclareKindResultSchema = object({
59493
+ kindId: number$1().int(),
59494
+ state: RecordKindStateSchema,
59495
+ createdIndexes: array(string())
59496
+ });
59497
+ /** Keychain refs a write released (the old marker is gone from the row): the owner deletes the values. */
59498
+ var SecretRefsList = array(string());
59499
+ /**
59500
+ * One op of a `transact`: the single-row writes, each on a kind of the caller.
59501
+ * All ops of one call share ONE database file and ONE `transactFamily`.
59502
+ */
59503
+ var RecordTransactOpSchema = discriminatedUnion("op", [
59504
+ object({
59505
+ op: literal("put"),
59506
+ kind: RecordKindNameSchema,
59507
+ record: RecordInputSchema,
59508
+ ifRev: number$1().int().min(0).optional()
59509
+ }),
59510
+ object({
59511
+ op: literal("patch"),
59512
+ kind: RecordKindNameSchema,
59513
+ id: RecordIdSchema,
59514
+ data: RecordBodySchema,
59515
+ at: number$1().int().nullable().optional(),
59516
+ expiresAt: number$1().int().nullable().optional(),
59517
+ ifRev: number$1().int().min(1).optional()
59518
+ }),
59519
+ object({
59520
+ op: literal("delete"),
59521
+ kind: RecordKindNameSchema,
59522
+ id: RecordIdSchema
59523
+ })
59524
+ ]);
59525
+ var RecordSecretRefsSchema = record(string(), record(string(), string()));
59526
+ method(object({ kinds: record(RecordKindNameSchema, RecordKindDeclarationSchema) }), object({ kinds: record(string(), DeclareKindResultSchema) }), {
59527
+ kind: "mutation",
59528
+ auth: "admin"
59529
+ }), method(object({
59530
+ ...KindShape,
59531
+ id: RecordIdSchema
59532
+ }), RecordViewSchema.nullable(), { auth: "admin" }), method(object({
59533
+ ...KindShape,
59534
+ ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
59535
+ }), array(RecordViewSchema), { auth: "admin" }), method(object({
59536
+ ...KindShape,
59537
+ ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
59538
+ }), RecordSecretRefsSchema, { auth: "admin" }), method(object({
59539
+ ...KindShape,
59540
+ record: RecordInputSchema,
59541
+ /** Compare-and-set. `0` means "the row must not exist". */
59542
+ ifRev: number$1().int().min(0).optional()
59543
+ }), object({
59544
+ rev: number$1().int(),
59545
+ releasedRefs: SecretRefsList
59546
+ }), {
59547
+ kind: "mutation",
59548
+ auth: "admin"
59549
+ }), method(object({
59550
+ ...KindShape,
59551
+ records: array(RecordInputSchema).min(1).max(RECORD_BATCH_MAX)
59552
+ }), object({
59553
+ count: number$1().int(),
59554
+ releasedRefs: SecretRefsList
59555
+ }), {
59556
+ kind: "mutation",
59557
+ auth: "admin"
59558
+ }), method(object({
59559
+ ...KindShape,
59560
+ id: RecordIdSchema,
59561
+ data: RecordBodySchema,
59562
+ at: number$1().int().nullable().optional(),
59563
+ expiresAt: number$1().int().nullable().optional(),
59564
+ ifRev: number$1().int().min(1).optional()
59565
+ }), object({
59566
+ rev: number$1().int(),
59567
+ releasedRefs: SecretRefsList
59568
+ }), {
59569
+ kind: "mutation",
59570
+ auth: "admin"
59571
+ }), method(object({
59572
+ ...KindShape,
59573
+ id: RecordIdSchema
59574
+ }), object({
59575
+ deleted: boolean(),
59576
+ secretRefs: SecretRefsList
59577
+ }), {
59578
+ kind: "mutation",
59579
+ auth: "admin"
59580
+ }), method(object({
59581
+ ...KindShape,
59582
+ ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
59583
+ }), object({
59584
+ deleted: number$1().int(),
59585
+ secretRefs: SecretRefsList
59586
+ }), {
59587
+ kind: "mutation",
59588
+ auth: "admin"
59589
+ }), method(RecordQueryInputSchema, RecordQueryResultSchema, { auth: "admin" }), method(object({
59590
+ ...KindShape,
59591
+ ...TimeWindowShape,
59592
+ where: RecordWhereSchema.optional()
59593
+ }), object({ count: number$1().int() }), { auth: "admin" }), method(object({
59594
+ ...KindShape,
59595
+ deviceId: number$1().int().optional(),
59596
+ from: number$1(),
59597
+ to: number$1(),
59598
+ bucketMs: number$1().int().positive()
59599
+ }), object({ buckets: array(object({
59600
+ bucketStart: number$1(),
59601
+ count: number$1().int()
59602
+ })) }), { auth: "admin" }), method(object({
59603
+ ...KindShape,
59604
+ op: _enum([
59605
+ "sum",
59606
+ "min",
59607
+ "max",
59608
+ "count"
59609
+ ]),
59610
+ field: RecordFieldPathSchema.optional(),
59611
+ groupBy: RecordFieldPathSchema.optional(),
59612
+ where: RecordWhereSchema.optional()
59613
+ }), object({ rows: array(object({
59614
+ group: union([
59615
+ string(),
59616
+ number$1(),
59617
+ _null()
59618
+ ]),
59619
+ value: number$1().nullable()
59620
+ })) }), { auth: "admin" }), method(object({
59621
+ ...KindShape,
59622
+ deviceId: number$1().int().optional(),
59623
+ atBefore: number$1().optional(),
59624
+ where: RecordWhereSchema.optional()
59625
+ }), object({
59626
+ deleted: number$1().int(),
59627
+ secretRefs: SecretRefsList
59628
+ }), {
59629
+ kind: "mutation",
59630
+ auth: "admin"
59631
+ }), method(object({ ops: array(RecordTransactOpSchema).min(1).max(RECORD_BATCH_MAX) }), object({
59632
+ results: array(object({
59633
+ rev: number$1().int().nullable(),
59634
+ deleted: boolean()
59635
+ })),
59636
+ releasedRefs: SecretRefsList,
59637
+ secretRefs: SecretRefsList
59638
+ }), {
59639
+ kind: "mutation",
59640
+ auth: "admin"
59641
+ }), method(object({
59642
+ deviceId: number$1().int(),
59643
+ kinds: array(RecordKindNameSchema).optional()
59644
+ }), object({
59645
+ deleted: record(string(), number$1().int()),
59646
+ secretRefs: SecretRefsList
59647
+ }), {
59648
+ kind: "mutation",
59649
+ auth: "admin"
59650
+ }), method(object(KindShape), object({ id: number$1().int() }), {
59651
+ kind: "mutation",
59652
+ auth: "admin"
59653
+ });
59654
+ /**
59655
+ * The wire shapes of the migration API, version 1 (planning#447).
59656
+ *
59657
+ * A migration is code that outlives the server it was written against: an addon at
59658
+ * version 1.4 that is brought to 1.9 in one jump still runs its 1.4 catalogue, against
59659
+ * the 1.9 kernel. The only thing that code may call is {@link MigrationApiV1}; this file
59660
+ * is the contract of every method of it, one Zod pair per method.
59661
+ *
59662
+ * APPEND-ONLY. A released method is never removed and never changes meaning: an input
59663
+ * field is never removed or made required, an enum never narrows, an output field is
59664
+ * never removed. A change that cannot be expressed that way is `migration-api.v2`, next
59665
+ * to this file. `scripts/check-migration-api-append-only.ts` holds the line against
59666
+ * `scripts/migration-api-contract.json`, and every `behaviours` id below must have an
59667
+ * `it('<id>'` in `migration-api.contract.spec.ts`.
59668
+ */
59669
+ var Rows = record(string(), unknown());
59670
+ var IntegrationId = object({ integrationId: string().min(1) });
59671
+ var RecordId = object({ id: string().min(1) });
59672
+ var RecordRowSchema = object({
59673
+ id: string(),
59674
+ deviceId: number$1().int().nullable(),
59675
+ at: number$1().nullable(),
59676
+ expiresAt: number$1().nullable(),
59677
+ rev: number$1().int(),
59678
+ updatedAt: number$1(),
59679
+ data: Rows
59680
+ });
59681
+ var RecordPutShape = {
59682
+ id: string().min(1),
59683
+ deviceId: number$1().int().optional(),
59684
+ at: number$1().int().optional(),
59685
+ expiresAt: number$1().int().optional(),
59686
+ data: Rows
59687
+ };
59688
+ var MigrationIntegrationSchema = object({
59689
+ id: string(),
59690
+ addonId: string(),
59691
+ name: string(),
59692
+ info: Rows,
59693
+ originStableId: string().optional()
59694
+ });
59695
+ var MigrationDeviceSchema = object({
59696
+ id: number$1().int(),
59697
+ integrationId: string().nullable().optional(),
59698
+ /** The owning device; null or absent for a root. */
59699
+ parentDeviceId: number$1().int().nullable().optional(),
59700
+ type: string().optional(),
59701
+ name: string().optional(),
59702
+ stableId: string().optional()
59703
+ });
59704
+ /** What the engine says about the migration API it serves: lets an addon notice an older server. */
59705
+ var MigrationApiInfoSchema = object({
59706
+ version: number$1().int(),
59707
+ methods: array(string())
59708
+ });
59709
+ object({
59710
+ message: string(),
59711
+ meta: Rows.optional(),
59712
+ /** Grouping tags of the line; a line about a device carries `{ deviceId }`. */
59713
+ tags: object({ deviceId: number$1().int().optional() }).optional()
59714
+ });
59715
+ object({
59716
+ owner: string().min(1),
59717
+ metas: array(MigrationMetaSchema)
59718
+ }), object({
59719
+ bootId: string(),
59720
+ snapshot: _enum([
59721
+ "none",
59722
+ "written",
59723
+ "refused"
59724
+ ]),
59725
+ steps: array(MigrationOwnerStepSchema),
59726
+ /** The migration API the engine serves; absent from a server that predates it. */
59727
+ migrationApi: MigrationApiInfoSchema.optional()
59728
+ }), object({ id: string().min(1) }), object({
59729
+ proceed: boolean(),
59730
+ state: union([literal("pending"), MigrationStatusSchema]),
59731
+ reason: string().nullable()
59732
+ }), object({
59733
+ id: string().min(1),
59734
+ result: MigrationOwnerResultSchema
59735
+ }), object({
59736
+ status: MigrationStatusSchema,
59737
+ error: string().nullable(),
59738
+ rowsChanged: number$1().int().nonnegative()
59739
+ }), object({
59740
+ rows: number$1().int().nonnegative(),
59741
+ secrets: number$1().int().nonnegative()
59742
+ }), IntegrationId.extend({ patch: Rows }), object({ written: boolean() }), IntegrationId.extend({ keys: array(string().min(1)) }), object({
59743
+ present: boolean(),
59744
+ removed: Rows
59745
+ }), RecordRowSchema.nullable(), RecordRowSchema.nullable(), object({
59746
+ ...RecordPutShape,
59747
+ ifRev: number$1().int().optional()
59748
+ }), object({ rev: number$1().int() }), object({ records: array(object(RecordPutShape)) }), object({ count: number$1().int().nonnegative() }), RecordId.extend({
59749
+ partial: Rows,
59750
+ ifRev: number$1().int().optional()
59751
+ }), object({ rev: number$1().int() }), object({ deleted: boolean() }), object({
59752
+ deviceId: number$1().int().optional(),
59753
+ atFrom: number$1().optional(),
59754
+ atTo: number$1().optional(),
59755
+ where: RecordWhereSchema.optional(),
59756
+ limit: number$1().int().min(1).optional(),
59757
+ cursor: string().min(1).optional(),
59758
+ resolveSecrets: boolean().optional()
59759
+ }), object({
59760
+ records: array(RecordRowSchema),
59761
+ nextCursor: string().nullable()
59762
+ }), object({
59763
+ deviceId: number$1().int().optional(),
59764
+ atBefore: number$1().optional(),
59765
+ where: RecordWhereSchema.optional()
59766
+ }), object({ count: number$1().int().nonnegative() }), object({}), object({ store: Rows }), object({ patch: Rows }), object({}), object({ keys: array(string().min(1)) }), object({ removed: array(string()) }), object({ refs: array(string()) }), object({ values: record(string(), string().nullable()) }), object({
59767
+ ref: string().min(1),
59768
+ value: string()
59769
+ }), object({}), object({ refs: array(string()) }), object({ deleted: number$1().int().nonnegative() }), object({}), object({ integrations: array(MigrationIntegrationSchema) }), object({ id: string().min(1) }), MigrationIntegrationSchema.nullable(), object({
59770
+ addonId: string().min(1),
59771
+ integrationId: string().min(1).optional()
59772
+ }), object({ devices: array(MigrationDeviceSchema) }), object({ deviceId: number$1().int() }), object({
59773
+ id: number$1().int(),
59774
+ integrationId: string().nullable(),
59775
+ /** Whether the device carries any stored config; its content is not offered. */
59776
+ hasConfig: boolean()
59777
+ }).nullable(), object({
59778
+ deviceId: number$1().int(),
59779
+ integrationId: string().min(1)
59780
+ }), object({}), object({}), object({ nodeId: string() }), object({}), object({});
59331
59781
  method(object({}), object({ entries: array(MigrationLedgerEntrySchema) }), { auth: "admin" }), method(object({ owner: string().optional() }), object({ pending: array(MigrationPendingEntrySchema) }), { auth: "admin" }), method(object({ id: string().min(1) }), MigrationDryRunResultSchema, { auth: "admin" }), method(object({
59332
59782
  id: string().min(1),
59333
59783
  error: string().min(1)
@@ -59357,7 +59807,9 @@ method(object({}), object({ entries: array(MigrationLedgerEntrySchema) }), { aut
59357
59807
  "written",
59358
59808
  "refused"
59359
59809
  ]),
59360
- steps: array(MigrationOwnerStepSchema)
59810
+ steps: array(MigrationOwnerStepSchema),
59811
+ /** The migration API this engine serves (additive: a server that predates it omits it). */
59812
+ migrationApi: MigrationApiInfoSchema.optional()
59361
59813
  }), {
59362
59814
  kind: "mutation",
59363
59815
  auth: "admin"
@@ -62024,7 +62476,10 @@ DeviceType.Camera, method(object({ deviceId: number$1() }), array(TrackSchema).r
62024
62476
  }), method(RelocateMediaInputSchema, object({ jobId: string() }), {
62025
62477
  kind: "mutation",
62026
62478
  auth: "admin"
62027
- }), method(object({}), UnstampedEventMediaCountSchema, { auth: "admin" }), method(RelocatableMediaCountInputSchema, RelocatableMediaCountSchema, {
62479
+ }), method(object({}), UnstampedEventMediaCountSchema, { auth: "admin" }), method(MediaLocationCensusInputSchema, MediaLocationCensusSchema, {
62480
+ kind: "query",
62481
+ auth: "admin"
62482
+ }), method(RelocatableMediaCountInputSchema, RelocatableMediaCountSchema, {
62028
62483
  kind: "query",
62029
62484
  auth: "admin"
62030
62485
  }), method(object({}), array(RelocateJobSchema).readonly(), {
@@ -64679,7 +65134,7 @@ var BrokerStatusSchema$1 = _enum([
64679
65134
  "unreachable",
64680
65135
  "tls-error"
64681
65136
  ]);
64682
- var BrokerInfoSchema$1 = object({
65137
+ var BrokerInfoSchema = object({
64683
65138
  id: string(),
64684
65139
  name: string(),
64685
65140
  url: string(),
@@ -64742,7 +65197,7 @@ var StatusSchema = object({
64742
65197
  brokerCount: number$1(),
64743
65198
  embeddedRunning: boolean()
64744
65199
  });
64745
- method(_void(), array(BrokerInfoSchema$1)), method(IdInputSchema, BrokerConnectionDetailsSchema, { auth: "admin" }), method(AddBrokerInputSchema, AddBrokerResultSchema, {
65200
+ method(_void(), array(BrokerInfoSchema)), method(IdInputSchema, BrokerConnectionDetailsSchema, { auth: "admin" }), method(AddBrokerInputSchema, AddBrokerResultSchema, {
64746
65201
  kind: "mutation",
64747
65202
  auth: "admin"
64748
65203
  }), method(IdInputSchema, _void(), {
@@ -71924,253 +72379,6 @@ DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, DeviceType.Gateway, met
71924
72379
  auth: "admin"
71925
72380
  });
71926
72381
  /**
71927
- * records - an addon's own facts and documents (D881): ledgers, audit rows,
71928
- * config documents, mirrors. The third storage API next to `ctx.settings`
71929
- * (operator intent, D870) and device-history (numbers over time, D850).
71930
- *
71931
- * Who the OWNER is: `callerAddonId`, stamped by the transport on a call that
71932
- * crossed a process and stated by a hub in-process caller (`ctx.records` does
71933
- * that with the addon id closed over). It is deliberately NOT in any input
71934
- * schema, so a session can never supply it, and a call with no stamp is refused
71935
- * `records/no-caller`. A kind is readable and writable only by its owner; other
71936
- * addons go through the owner's capability.
71937
- *
71938
- * Every read is three-way: a row, `null`, or the call THROWS. A failed read is
71939
- * never `[]`. Secret fields are never on the wire: a read shows the
71940
- * redaction token, `secretRefs` hands the owner's `ctx.secrets` the refs it
71941
- * resolves itself, and a write may carry only a keychain marker bound to the
71942
- * row's own ref.
71943
- *
71944
- * Internal: addons reach it through `ctx.records`, never directly.
71945
- */
71946
- var RecordIdSchema = string().min(1).max(256);
71947
- /** A JSON object body. Size is the engine's check (`maxDataBytes`), not a schema's. */
71948
- var RecordBodySchema = record(string(), unknown());
71949
- var RecordInputSchema = object({
71950
- id: RecordIdSchema,
71951
- /** The device this row belongs to (D865 removal, per-device reads). */
71952
- deviceId: number$1().int().optional(),
71953
- /** The domain clock, ms: observed / created / opened. */
71954
- at: number$1().int().optional(),
71955
- /** Producer-set expiry, ms. Refused on a kind whose retention is an engine TTL. */
71956
- expiresAt: number$1().int().optional(),
71957
- data: RecordBodySchema
71958
- });
71959
- var RecordViewSchema = object({
71960
- id: string(),
71961
- deviceId: number$1().int().nullable(),
71962
- at: number$1().nullable(),
71963
- expiresAt: number$1().nullable(),
71964
- rev: number$1().int(),
71965
- updatedAt: number$1(),
71966
- data: RecordBodySchema
71967
- });
71968
- /** Declared index fields only. Anything else does not compile, by design. */
71969
- var RecordWhereSchema = record(RecordFieldPathSchema, union([
71970
- string(),
71971
- number$1(),
71972
- boolean(),
71973
- object({ in: array(union([
71974
- string(),
71975
- number$1(),
71976
- boolean()
71977
- ])).min(1).max(RECORD_BATCH_MAX) }),
71978
- object({
71979
- gt: union([string(), number$1()]).optional(),
71980
- gte: union([string(), number$1()]).optional(),
71981
- lt: union([string(), number$1()]).optional(),
71982
- lte: union([string(), number$1()]).optional()
71983
- }).refine((r) => Object.keys(r).length > 0, { message: "name at least one bound" })
71984
- ]));
71985
- var KindShape = { kind: RecordKindNameSchema };
71986
- var TimeWindowShape = {
71987
- deviceId: number$1().int().optional(),
71988
- atFrom: number$1().optional(),
71989
- atTo: number$1().optional()
71990
- };
71991
- var RecordQueryInputSchema = object({
71992
- ...KindShape,
71993
- ...TimeWindowShape,
71994
- where: RecordWhereSchema.optional(),
71995
- /** Without `where`: `at` (default for a kind with a clock) or `id`. With `where` the order is the index's. */
71996
- order: _enum(["at", "id"]).optional(),
71997
- desc: boolean().optional(),
71998
- limit: number$1().int().min(1).max(RECORD_QUERY_MAX).default(100),
71999
- /** Opaque keyset cursor from a previous answer. There is no offset. */
72000
- cursor: string().min(1).optional()
72001
- });
72002
- var RecordQueryResultSchema = object({
72003
- records: array(RecordViewSchema),
72004
- nextCursor: string().nullable()
72005
- });
72006
- var RecordKindStateSchema = _enum([
72007
- "created",
72008
- "unchanged",
72009
- "updated"
72010
- ]);
72011
- var DeclareKindResultSchema = object({
72012
- kindId: number$1().int(),
72013
- state: RecordKindStateSchema,
72014
- createdIndexes: array(string())
72015
- });
72016
- /** Keychain refs a write released (the old marker is gone from the row): the owner deletes the values. */
72017
- var SecretRefsList = array(string());
72018
- /**
72019
- * One op of a `transact`: the single-row writes, each on a kind of the caller.
72020
- * All ops of one call share ONE database file and ONE `transactFamily`.
72021
- */
72022
- var RecordTransactOpSchema = discriminatedUnion("op", [
72023
- object({
72024
- op: literal("put"),
72025
- kind: RecordKindNameSchema,
72026
- record: RecordInputSchema,
72027
- ifRev: number$1().int().min(0).optional()
72028
- }),
72029
- object({
72030
- op: literal("patch"),
72031
- kind: RecordKindNameSchema,
72032
- id: RecordIdSchema,
72033
- data: RecordBodySchema,
72034
- at: number$1().int().nullable().optional(),
72035
- expiresAt: number$1().int().nullable().optional(),
72036
- ifRev: number$1().int().min(1).optional()
72037
- }),
72038
- object({
72039
- op: literal("delete"),
72040
- kind: RecordKindNameSchema,
72041
- id: RecordIdSchema
72042
- })
72043
- ]);
72044
- var RecordSecretRefsSchema = record(string(), record(string(), string()));
72045
- method(object({ kinds: record(RecordKindNameSchema, RecordKindDeclarationSchema) }), object({ kinds: record(string(), DeclareKindResultSchema) }), {
72046
- kind: "mutation",
72047
- auth: "admin"
72048
- }), method(object({
72049
- ...KindShape,
72050
- id: RecordIdSchema
72051
- }), RecordViewSchema.nullable(), { auth: "admin" }), method(object({
72052
- ...KindShape,
72053
- ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
72054
- }), array(RecordViewSchema), { auth: "admin" }), method(object({
72055
- ...KindShape,
72056
- ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
72057
- }), RecordSecretRefsSchema, { auth: "admin" }), method(object({
72058
- ...KindShape,
72059
- record: RecordInputSchema,
72060
- /** Compare-and-set. `0` means "the row must not exist". */
72061
- ifRev: number$1().int().min(0).optional()
72062
- }), object({
72063
- rev: number$1().int(),
72064
- releasedRefs: SecretRefsList
72065
- }), {
72066
- kind: "mutation",
72067
- auth: "admin"
72068
- }), method(object({
72069
- ...KindShape,
72070
- records: array(RecordInputSchema).min(1).max(RECORD_BATCH_MAX)
72071
- }), object({
72072
- count: number$1().int(),
72073
- releasedRefs: SecretRefsList
72074
- }), {
72075
- kind: "mutation",
72076
- auth: "admin"
72077
- }), method(object({
72078
- ...KindShape,
72079
- id: RecordIdSchema,
72080
- data: RecordBodySchema,
72081
- at: number$1().int().nullable().optional(),
72082
- expiresAt: number$1().int().nullable().optional(),
72083
- ifRev: number$1().int().min(1).optional()
72084
- }), object({
72085
- rev: number$1().int(),
72086
- releasedRefs: SecretRefsList
72087
- }), {
72088
- kind: "mutation",
72089
- auth: "admin"
72090
- }), method(object({
72091
- ...KindShape,
72092
- id: RecordIdSchema
72093
- }), object({
72094
- deleted: boolean(),
72095
- secretRefs: SecretRefsList
72096
- }), {
72097
- kind: "mutation",
72098
- auth: "admin"
72099
- }), method(object({
72100
- ...KindShape,
72101
- ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
72102
- }), object({
72103
- deleted: number$1().int(),
72104
- secretRefs: SecretRefsList
72105
- }), {
72106
- kind: "mutation",
72107
- auth: "admin"
72108
- }), method(RecordQueryInputSchema, RecordQueryResultSchema, { auth: "admin" }), method(object({
72109
- ...KindShape,
72110
- ...TimeWindowShape,
72111
- where: RecordWhereSchema.optional()
72112
- }), object({ count: number$1().int() }), { auth: "admin" }), method(object({
72113
- ...KindShape,
72114
- deviceId: number$1().int().optional(),
72115
- from: number$1(),
72116
- to: number$1(),
72117
- bucketMs: number$1().int().positive()
72118
- }), object({ buckets: array(object({
72119
- bucketStart: number$1(),
72120
- count: number$1().int()
72121
- })) }), { auth: "admin" }), method(object({
72122
- ...KindShape,
72123
- op: _enum([
72124
- "sum",
72125
- "min",
72126
- "max",
72127
- "count"
72128
- ]),
72129
- field: RecordFieldPathSchema.optional(),
72130
- groupBy: RecordFieldPathSchema.optional(),
72131
- where: RecordWhereSchema.optional()
72132
- }), object({ rows: array(object({
72133
- group: union([
72134
- string(),
72135
- number$1(),
72136
- _null()
72137
- ]),
72138
- value: number$1().nullable()
72139
- })) }), { auth: "admin" }), method(object({
72140
- ...KindShape,
72141
- deviceId: number$1().int().optional(),
72142
- atBefore: number$1().optional(),
72143
- where: RecordWhereSchema.optional()
72144
- }), object({
72145
- deleted: number$1().int(),
72146
- secretRefs: SecretRefsList
72147
- }), {
72148
- kind: "mutation",
72149
- auth: "admin"
72150
- }), method(object({ ops: array(RecordTransactOpSchema).min(1).max(RECORD_BATCH_MAX) }), object({
72151
- results: array(object({
72152
- rev: number$1().int().nullable(),
72153
- deleted: boolean()
72154
- })),
72155
- releasedRefs: SecretRefsList,
72156
- secretRefs: SecretRefsList
72157
- }), {
72158
- kind: "mutation",
72159
- auth: "admin"
72160
- }), method(object({
72161
- deviceId: number$1().int(),
72162
- kinds: array(RecordKindNameSchema).optional()
72163
- }), object({
72164
- deleted: record(string(), number$1().int()),
72165
- secretRefs: SecretRefsList
72166
- }), {
72167
- kind: "mutation",
72168
- auth: "admin"
72169
- }), method(object(KindShape), object({ id: number$1().int() }), {
72170
- kind: "mutation",
72171
- auth: "admin"
72172
- });
72173
- /**
72174
72382
  * record-entries - the ONE admin surface for every list of user-created entries
72175
72383
  * (D881): notification targets, brokers, LLM profiles, anything an addon
72176
72384
  * declares as an ENTRY kind (`entry: { title }` in `defineRecordKinds`).
@@ -81898,7 +82106,7 @@ var BaseDeviceProvider = class extends BaseAddon {
81898
82106
  * `deviceClasses` map (first registered type). `null` when manual creation is
81899
82107
  * unsupported. Lets the Add-Device dialog pick the right type per provider.
81900
82108
  */
81901
- async getManualCreationType() {
82109
+ async getManualCreationType(_input) {
81902
82110
  if (!await this.supportsManualCreation()) return { deviceType: null };
81903
82111
  return { deviceType: Object.values(DeviceType).find((t) => this.deviceClasses[t] !== void 0) ?? null };
81904
82112
  }
@@ -81909,7 +82117,7 @@ var BaseDeviceProvider = class extends BaseAddon {
81909
82117
  return true;
81910
82118
  }
81911
82119
  async getChildCreationSchema(input) {
81912
- return this.onGetCreationSchema(input.type);
82120
+ return this.onGetCreationSchema(input.type, { integrationId: input.integrationId });
81913
82121
  }
81914
82122
  /**
81915
82123
  * Default kernel-orchestrated `createDevice` implementation. The
@@ -81921,7 +82129,7 @@ var BaseDeviceProvider = class extends BaseAddon {
81921
82129
  * `deviceClasses` instead.
81922
82130
  */
81923
82131
  async createDevice(input) {
81924
- const spec = await this.onCreateDevice(input.type, input.config);
82132
+ const spec = await this.onCreateDevice(input.type, input.config, { ...input.integrationId !== void 0 ? { integrationId: input.integrationId } : {} });
81925
82133
  const Class = this.deviceClasses[spec.meta.type];
81926
82134
  if (!Class) throw new Error(`${this.providerName} provider: no device class registered for type "${spec.meta.type}" — add it to the deviceClasses map`);
81927
82135
  const stableId = this.generateStableId(spec.meta.type, spec.config);
@@ -83330,20 +83538,6 @@ Object.freeze({
83330
83538
  access: "create",
83331
83539
  role: "viewer"
83332
83540
  },
83333
- "broker.add": {
83334
- capName: "broker",
83335
- capScope: "system",
83336
- addonId: null,
83337
- access: "create",
83338
- role: "admin"
83339
- },
83340
- "broker.get": {
83341
- capName: "broker",
83342
- capScope: "system",
83343
- addonId: null,
83344
- access: "view",
83345
- role: "admin"
83346
- },
83347
83541
  "broker.getBrokerConfig": {
83348
83542
  capName: "broker",
83349
83543
  capScope: "system",
@@ -83351,20 +83545,6 @@ Object.freeze({
83351
83545
  access: "view",
83352
83546
  role: "admin"
83353
83547
  },
83354
- "broker.getSettings": {
83355
- capName: "broker",
83356
- capScope: "system",
83357
- addonId: null,
83358
- access: "view",
83359
- role: "admin"
83360
- },
83361
- "broker.getSettingsSchema": {
83362
- capName: "broker",
83363
- capScope: "system",
83364
- addonId: null,
83365
- access: "view",
83366
- role: "admin"
83367
- },
83368
83548
  "broker.getState": {
83369
83549
  capName: "broker",
83370
83550
  capScope: "system",
@@ -83379,20 +83559,6 @@ Object.freeze({
83379
83559
  access: "view",
83380
83560
  role: "admin"
83381
83561
  },
83382
- "broker.list": {
83383
- capName: "broker",
83384
- capScope: "system",
83385
- addonId: null,
83386
- access: "view",
83387
- role: "admin"
83388
- },
83389
- "broker.listProviders": {
83390
- capName: "broker",
83391
- capScope: "system",
83392
- addonId: null,
83393
- access: "view",
83394
- role: "admin"
83395
- },
83396
83562
  "broker.publish": {
83397
83563
  capName: "broker",
83398
83564
  capScope: "system",
@@ -83400,20 +83566,6 @@ Object.freeze({
83400
83566
  access: "create",
83401
83567
  role: "admin"
83402
83568
  },
83403
- "broker.remove": {
83404
- capName: "broker",
83405
- capScope: "system",
83406
- addonId: null,
83407
- access: "delete",
83408
- role: "admin"
83409
- },
83410
- "broker.setSettings": {
83411
- capName: "broker",
83412
- capScope: "system",
83413
- addonId: null,
83414
- access: "create",
83415
- role: "admin"
83416
- },
83417
83569
  "broker.subscribe": {
83418
83570
  capName: "broker",
83419
83571
  capScope: "system",
@@ -83421,20 +83573,6 @@ Object.freeze({
83421
83573
  access: "create",
83422
83574
  role: "admin"
83423
83575
  },
83424
- "broker.testConnection": {
83425
- capName: "broker",
83426
- capScope: "system",
83427
- addonId: null,
83428
- access: "create",
83429
- role: "admin"
83430
- },
83431
- "broker.testSettings": {
83432
- capName: "broker",
83433
- capScope: "system",
83434
- addonId: null,
83435
- access: "create",
83436
- role: "admin"
83437
- },
83438
83576
  "broker.unsubscribe": {
83439
83577
  capName: "broker",
83440
83578
  capScope: "system",
@@ -88398,6 +88536,13 @@ Object.freeze({
88398
88536
  access: "view",
88399
88537
  role: "viewer"
88400
88538
  },
88539
+ "pipelineAnalytics.getMediaLocationCensus": {
88540
+ capName: "pipeline-analytics",
88541
+ capScope: "device",
88542
+ addonId: null,
88543
+ access: "view",
88544
+ role: "admin"
88545
+ },
88401
88546
  "pipelineAnalytics.getMediaReclaimStatus": {
88402
88547
  capName: "pipeline-analytics",
88403
88548
  capScope: "device",
@@ -97648,6 +97793,15 @@ function rewriteLocation(location, currentUpstream, route) {
97648
97793
  /** Characters that may precede a URL that STARTS a value (never `=`, `?`, `&`, `%3D`). */
97649
97794
  var VALUE_START = String.raw`(?<=^|["'(\s>,\[{])`;
97650
97795
  /**
97796
+ * Subresource Integrity pins the bytes of a script or stylesheet. The proxy rewrites vendor
97797
+ * URLs inside those bytes (and re-serves them from another origin), so the hash can no longer
97798
+ * match and the browser refuses the file: the page renders with no script at all. The hub
97799
+ * already drops the CSP that would have enforced the same pin; the attribute goes with it.
97800
+ */
97801
+ function stripIntegrity(html) {
97802
+ return html.replace(/\s+integrity\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "");
97803
+ }
97804
+ /**
97651
97805
  * Rewrite vendor URLs in a text body: absolute (`https://host/…`), protocol-relative
97652
97806
  * (`//host/…`) and JSON-escaped (`https:\/\/host\/…`) when they start a value; and, in
97653
97807
  * HTML/CSS, root-relative references (`href="/x"`, `url(/x)`) which would otherwise
@@ -97663,6 +97817,7 @@ function rewriteBody(body, kind, currentUpstream, route) {
97663
97817
  const escaped = new RegExp(`${VALUE_START}(?:https?:)?\\\\/\\\\/${host}(?=\\\\/|["'?#\\s)\\]},<]|$)`, "g");
97664
97818
  out = out.replace(escaped, escapedBase);
97665
97819
  }
97820
+ if (kind === "html") out = stripIntegrity(out);
97666
97821
  const current = `${route.basePath}/${currentUpstream.host}/`;
97667
97822
  const notMounted = `(?!/)(?!${escapeRegex(route.basePath.slice(1))}/)`;
97668
97823
  if (kind === "html") {
@@ -98287,6 +98442,179 @@ object({
98287
98442
  delay: number$1().nonnegative().optional(),
98288
98443
  commands: record(string(), unknown())
98289
98444
  });
98445
+ var BrokerInUseError = class extends Error {
98446
+ brokerId;
98447
+ users;
98448
+ name = "BrokerInUseError";
98449
+ constructor(brokerId, users) {
98450
+ super(`broker '${brokerId}' is still in use by ${users.map((u) => `${u.kind} '${u.name ?? u.id}' (${u.id})`).join(", ")}; unlink them first`);
98451
+ this.brokerId = brokerId;
98452
+ this.users = users;
98453
+ }
98454
+ };
98455
+ /** Throw {@link BrokerInUseError} when any finder names a user of `brokerId`. */
98456
+ async function assertBrokerUnused(brokerId, finders) {
98457
+ const users = [];
98458
+ for (const finder of finders) users.push(...await finder.find(brokerId));
98459
+ if (users.length > 0) throw new BrokerInUseError(brokerId, users);
98460
+ }
98461
+ /**
98462
+ * An account provider whose `deleteInstance` is refused while anything still uses the account
98463
+ * (D1012). The account is an `integration-instances` kind (D1013), so the refusal lives on the
98464
+ * delete the instances page calls, not on a separate broker cap.
98465
+ */
98466
+ function guardBrokerDelete(base, users) {
98467
+ return {
98468
+ ...base,
98469
+ deleteInstance: async (input) => {
98470
+ await assertBrokerUnused(input.id, users());
98471
+ return base.deleteInstance(input);
98472
+ }
98473
+ };
98474
+ }
98475
+ /**
98476
+ * The generic binding of a service to a broker (D1012). A service (an integration today; a
98477
+ * notifier target, a calendar source or an exporter later) names the broker it runs through by id
98478
+ * and keeps only its OWN link settings: never a copy of the broker's host, sign-in or secrets,
98479
+ * which stay with the broker and are edited on the Brokers page.
98480
+ *
98481
+ * One primitive serves every consumer:
98482
+ * - {@link declareBrokerLinkKind}: the record kind, body `{ name, brokerId, ...linkFields }`;
98483
+ * - {@link buildBrokerLinkForm}: the form, a `broker-select` picker plus the service's link
98484
+ * sections, which may be DYNAMIC (a list read from the chosen broker, like HA calendars);
98485
+ * - {@link buildBrokerLinkInstances}: the `integration-instances` provider of an integration;
98486
+ * - {@link resolveBrokerId} / {@link brokerLinkUserFinder}: which broker a record names and which
98487
+ * records name a broker, so a delete of a broker still in use can name its users generically.
98488
+ */
98489
+ /** The record kind an integration is stored under. */
98490
+ var BROKER_LINK_KIND_NAME = "connections";
98491
+ /** What every broker-linked record carries; `brokerId` is always present, `''` is a row whose broker is not picked yet; link fields of the service come on top. */
98492
+ var BrokerLinkBodySchema = entryBody({ brokerId: string() });
98493
+ function declareBrokerLinkKind(spec) {
98494
+ return defineEntryKind(BROKER_LINK_KIND_NAME, {
98495
+ title: spec.title,
98496
+ schema: BrokerLinkBodySchema.extend(spec.linkFields ?? {}),
98497
+ writeTrigger: spec.writeTrigger
98498
+ });
98499
+ }
98500
+ /** The form: a picker over the brokers of `brokerKind`, then the service's link sections. */
98501
+ async function buildBrokerLinkForm(spec, brokerId) {
98502
+ const dynamic = brokerId !== void 0 && spec.dynamicSections !== void 0 ? await spec.dynamicSections(brokerId) : [];
98503
+ return { sections: [
98504
+ {
98505
+ id: "broker-link",
98506
+ title: spec.brokerLabel,
98507
+ description: `Each integration links to a ${spec.brokerLabel} connection (Connectivity → Brokers). The connection and its sign-in are edited there.`,
98508
+ columns: 1,
98509
+ fields: [{
98510
+ type: "broker-select",
98511
+ key: "brokerId",
98512
+ label: spec.brokerLabel,
98513
+ brokerKind: spec.brokerKind
98514
+ }]
98515
+ },
98516
+ ...spec.linkSections ?? [],
98517
+ ...dynamic
98518
+ ] };
98519
+ }
98520
+ /** The broker a record names; undefined while it names none. */
98521
+ function resolveBrokerId(record) {
98522
+ return record.brokerId === void 0 || record.brokerId === "" ? void 0 : record.brokerId;
98523
+ }
98524
+ /** The broker an integration links to, from its record. */
98525
+ async function brokerIdOf(instances, integrationId) {
98526
+ const body = await instances.getResolved(integrationId);
98527
+ return body === null ? void 0 : resolveBrokerId(body);
98528
+ }
98529
+ /** Every record of the kind that names `brokerId`, as users of that broker. */
98530
+ function brokerLinkUserFinder(instances, userKind = "integration") {
98531
+ return { find: async (brokerId) => (await instances.listResolved()).filter((entry) => resolveBrokerId(entry.body) === brokerId).map((entry) => ({
98532
+ kind: userKind,
98533
+ id: entry.id,
98534
+ name: entry.body.name
98535
+ })) };
98536
+ }
98537
+ /** The `integration-instances` provider of an addon whose integrations link to a broker. */
98538
+ function buildBrokerLinkInstances(records, spec) {
98539
+ const instances = new RecordIntegrationInstances(records, spec.kind, {
98540
+ description: {
98541
+ role: "devices",
98542
+ instanceMode: "multiple",
98543
+ kind: "connection",
98544
+ canTest: false
98545
+ },
98546
+ formSchema: async (id) => buildBrokerLinkForm(spec, id === null ? void 0 : await brokerIdOf(instances, id)),
98547
+ initialBody: (name) => spec.kind.schema.parse({
98548
+ name,
98549
+ brokerId: ""
98550
+ }),
98551
+ configured: (body) => resolveBrokerId(body) !== void 0,
98552
+ ...spec.onChanged !== void 0 ? { onChanged: spec.onChanged } : {}
98553
+ });
98554
+ return instances;
98555
+ }
98556
+ var BROKER_ROWS_MAX = 1e3;
98557
+ var STRAY_REGISTRY_ID = /^int_\d+$/;
98558
+ async function moveBrokerLinksToConnections(ports) {
98559
+ const brokers = await ports.listBrokers();
98560
+ const strays = brokers.filter((b) => STRAY_REGISTRY_ID.test(b.id));
98561
+ if (strays.length > 0) ports.warn(`${strays.length} broker row(s) are keyed like a registry integration and are left as they are: ${strays.map((s) => s.id).join(", ")}`);
98562
+ let moved = 0;
98563
+ for (const broker of brokers) {
98564
+ const integrationId = broker.integrationId;
98565
+ if (integrationId === void 0 || integrationId === "") continue;
98566
+ const existing = await ports.readConnection(integrationId);
98567
+ if (existing === null) await ports.putConnection(integrationId, {
98568
+ name: broker.name,
98569
+ brokerId: broker.id
98570
+ });
98571
+ else if (existing.brokerId !== broker.id) {
98572
+ ports.warn(`integration '${integrationId}' already names broker '${existing.brokerId ?? "none"}', not '${broker.id}'; the link stays on the broker`);
98573
+ continue;
98574
+ }
98575
+ if ((await ports.readConnection(integrationId))?.brokerId !== broker.id) throw new Error(`integration '${integrationId}': the link did not read back - broker '${broker.id}' keeps it`);
98576
+ await ports.clearBrokerLink(broker.id);
98577
+ moved += 1;
98578
+ }
98579
+ return {
98580
+ outcome: moved === 0 ? "already-done" : "done",
98581
+ summary: {
98582
+ brokers: brokers.length,
98583
+ moved,
98584
+ strayIntegrationRows: strays.length
98585
+ }
98586
+ };
98587
+ }
98588
+ /** The ports over the two record kinds of the addon. */
98589
+ function brokerLinkPortsOver(deps) {
98590
+ return {
98591
+ warn: deps.warn,
98592
+ listBrokers: async () => {
98593
+ return (await deps.brokers.query({ limit: BROKER_ROWS_MAX })).records.map((row) => {
98594
+ const name = row.data["name"];
98595
+ const link = row.data["integrationId"];
98596
+ return {
98597
+ id: row.id,
98598
+ name: typeof name === "string" && name.length > 0 ? name : row.id,
98599
+ integrationId: typeof link === "string" ? link : void 0
98600
+ };
98601
+ });
98602
+ },
98603
+ readConnection: async (id) => {
98604
+ const row = await deps.connections.get(id);
98605
+ return row === null ? null : { brokerId: row.data.brokerId };
98606
+ },
98607
+ putConnection: async (id, body) => {
98608
+ await deps.connections.put({
98609
+ id,
98610
+ data: body
98611
+ }, { ifRev: 0 });
98612
+ },
98613
+ clearBrokerLink: async (brokerId) => {
98614
+ await deps.brokers.patch(brokerId, { integrationId: null });
98615
+ }
98616
+ };
98617
+ }
98290
98618
  //#endregion
98291
98619
  //#region src/config.ts
98292
98620
  /**
@@ -98332,7 +98660,6 @@ var AlexaBrokerEntrySchema = object({
98332
98660
  id: string().min(1),
98333
98661
  name: string().min(1),
98334
98662
  connection: alexaConnectionSchema,
98335
- integrationId: string().optional(),
98336
98663
  /** Absent means on; the instances page switches a connection off without deleting it. */
98337
98664
  enabled: boolean().optional()
98338
98665
  });
@@ -98407,7 +98734,12 @@ var AMAZON_DOMAIN_BY_COUNTRY = {
98407
98734
  BR: "amazon.com.br",
98408
98735
  MX: "amazon.com.mx"
98409
98736
  };
98410
- /** Hand-written connection form for the broker/integration creation UI. */
98737
+ /**
98738
+ * The connection form: what the operator chooses (region, poll cadence). The cookie blob and the
98739
+ * refresh token are login STATE minted by the in-house sign-in; they live on the record as
98740
+ * keychain secrets, are written by that flow and are never part of this form, so a form save
98741
+ * cannot overwrite or clear them.
98742
+ */
98411
98743
  function buildConnectionFormSchema() {
98412
98744
  return { sections: [{
98413
98745
  id: "alexa-account",
@@ -98416,45 +98748,26 @@ function buildConnectionFormSchema() {
98416
98748
  description: "Sign in with your Amazon account to link it. This is an unofficial integration: Amazon can change or break it without notice, and it may violate Amazon ToS. State is polled, not pushed.",
98417
98749
  descriptionKey: "import-alexa:connection.s_alexa-account.description",
98418
98750
  columns: 1,
98419
- fields: [
98420
- {
98421
- type: "select",
98422
- key: "amazonDomain",
98423
- label: "Amazon domain (region)",
98424
- labelKey: "import-alexa:connection.s_alexa-account.amazonDomain.label",
98425
- options: AMAZON_DOMAINS.map((value) => ({
98426
- value,
98427
- label: value
98428
- })),
98429
- default: "amazon.com",
98430
- defaultByCountry: AMAZON_DOMAIN_BY_COUNTRY
98431
- },
98432
- {
98433
- type: "password",
98434
- key: "cookie",
98435
- label: "Cookie blob (filled by the sign-in)",
98436
- labelKey: "import-alexa:connection.s_alexa-account.cookie.label",
98437
- showToggle: true,
98438
- filledBySignIn: true
98439
- },
98440
- {
98441
- type: "password",
98442
- key: "refreshToken",
98443
- label: "Refresh token (alternative to the sign-in)",
98444
- labelKey: "import-alexa:connection.s_alexa-account.refreshToken.label",
98445
- showToggle: true,
98446
- filledBySignIn: true
98447
- },
98448
- {
98449
- type: "number",
98450
- key: "pollIntervalMs",
98451
- label: "State poll interval (ms)",
98452
- labelKey: "import-alexa:connection.s_alexa-account.pollIntervalMs.label",
98453
- min: 15e3,
98454
- max: 36e5,
98455
- default: 6e4
98456
- }
98457
- ]
98751
+ fields: [{
98752
+ type: "select",
98753
+ key: "amazonDomain",
98754
+ label: "Amazon domain (region)",
98755
+ labelKey: "import-alexa:connection.s_alexa-account.amazonDomain.label",
98756
+ options: AMAZON_DOMAINS.map((value) => ({
98757
+ value,
98758
+ label: value
98759
+ })),
98760
+ default: "amazon.com",
98761
+ defaultByCountry: AMAZON_DOMAIN_BY_COUNTRY
98762
+ }, {
98763
+ type: "number",
98764
+ key: "pollIntervalMs",
98765
+ label: "State poll interval (ms)",
98766
+ labelKey: "import-alexa:connection.s_alexa-account.pollIntervalMs.label",
98767
+ min: 15e3,
98768
+ max: 36e5,
98769
+ default: 6e4
98770
+ }]
98458
98771
  }] };
98459
98772
  }
98460
98773
  //#endregion
@@ -142592,7 +142905,6 @@ var AlexaBrokerRegistry = class {
142592
142905
  #onStatesPolled;
142593
142906
  #makeManager;
142594
142907
  #managers = /* @__PURE__ */ new Map();
142595
- #integrationToBroker = /* @__PURE__ */ new Map();
142596
142908
  #nextId = 1;
142597
142909
  constructor(logger, deps = {}) {
142598
142910
  this.#logger = logger;
@@ -142643,7 +142955,6 @@ var AlexaBrokerRegistry = class {
142643
142955
  }
142644
142956
  }));
142645
142957
  this.#managers.clear();
142646
- this.#integrationToBroker.clear();
142647
142958
  }
142648
142959
  setOnBrokerConnected(cb) {
142649
142960
  this.#onBrokerConnected = cb;
@@ -142666,17 +142977,14 @@ var AlexaBrokerRegistry = class {
142666
142977
  const entry = {
142667
142978
  id,
142668
142979
  name,
142669
- connection,
142670
- ...opts.integrationId !== void 0 ? { integrationId: opts.integrationId } : {}
142980
+ connection
142671
142981
  };
142672
142982
  await this.#startManager(entry);
142673
- if (opts.integrationId !== void 0) this.#integrationToBroker.set(opts.integrationId, id);
142674
142983
  return entry;
142675
142984
  }
142676
142985
  async removeEntry(id) {
142677
142986
  const mgr = this.#managers.get(id);
142678
142987
  if (!mgr) return;
142679
- for (const [integrationId, brokerId] of this.#integrationToBroker.entries()) if (brokerId === id) this.#integrationToBroker.delete(integrationId);
142680
142988
  this.#managers.delete(id);
142681
142989
  try {
142682
142990
  await mgr.stop();
@@ -142702,9 +143010,6 @@ var AlexaBrokerRegistry = class {
142702
143010
  if (!mgr) throw new Error(`AlexaBrokerRegistry: unknown broker id "${brokerId}"`);
142703
143011
  await mgr.sendCommand(command);
142704
143012
  }
142705
- linkIntegration(integrationId, brokerId) {
142706
- this.#integrationToBroker.set(integrationId, brokerId);
142707
- }
142708
143013
  list() {
142709
143014
  return Array.from(this.#managers.values()).map((m) => m.getInfo());
142710
143015
  }
@@ -142723,10 +143028,7 @@ var AlexaBrokerRegistry = class {
142723
143028
  return count;
142724
143029
  }
142725
143030
  async #startManager(entry) {
142726
- if (this.#managers.has(entry.id)) {
142727
- if (entry.integrationId !== void 0) this.#integrationToBroker.set(entry.integrationId, entry.id);
142728
- return;
142729
- }
143031
+ if (this.#managers.has(entry.id)) return;
142730
143032
  const mgr = this.#makeManager({
142731
143033
  id: entry.id,
142732
143034
  name: entry.name,
@@ -142739,7 +143041,6 @@ var AlexaBrokerRegistry = class {
142739
143041
  onStatesPolled: (id, values) => this.#onStatesPolled(id, values)
142740
143042
  });
142741
143043
  this.#managers.set(entry.id, mgr);
142742
- if (entry.integrationId !== void 0) this.#integrationToBroker.set(entry.integrationId, entry.id);
142743
143044
  await mgr.start();
142744
143045
  }
142745
143046
  /** The next free broker id, for a record minted before its manager exists. */
@@ -142803,33 +143104,15 @@ async function removeSessionFile(dataDir, brokerId) {
142803
143104
  await node_fs_promises.rm(sessionPath(dataDir, brokerId), { force: true });
142804
143105
  }
142805
143106
  //#endregion
142806
- //#region src/speak/constants.ts
142807
- /** Echo speech limits and the worker restart bound (same window as CrashSupervisor). */
142808
- var ALEXA_SPEAK_ADDON_ID = "import-alexa";
142809
- var ALEXA_SPEAK_KIND = "alexa-echo";
142810
- /** The kind of the Amazon-account broker the Echoes are read from. */
142811
- var ALEXA_BROKER_KIND = "alexa";
142812
- var DEVICE_REFRESH_MS = 5 * 6e4;
142813
- /** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
142814
- var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
142815
- /** How often an adopted Echo's player state is read (a push channel is a later phase). */
142816
- var ECHO_POLL_MS = 3e4;
142817
- /** One more read after a command, once Amazon has had time to apply it. */
142818
- var ECHO_REREAD_MS = 1500;
142819
- /** After a 429 the account is left alone for this long, doubling up to the cap. */
142820
- var ECHO_RATE_BACKOFF_BASE_MS = 3e4;
142821
- var ECHO_RATE_BACKOFF_MAX_MS = 5 * 6e4;
142822
- //#endregion
142823
143107
  //#region src/alexa-broker-provider.ts
142824
143108
  /**
142825
143109
  * Construct the `broker` cap provider for the Alexa import addon. Pure builder.
142826
- * Mirrors `buildEcowittBrokerProvider`. The cookie + refresh token are secrets:
142827
- * redacted on settings reads, preserved on an empty round-trip. State sync is
142828
- * poll-based (no pub/sub) — `publish`/`subscribe`/`getState` are no-ops.
143110
+ * The bus half only (D1013): creating, listing, editing and removing an account is
143111
+ * `integration-instances`. The cookie + refresh token are secrets, redacted on the config
143112
+ * read. State sync is poll-based (no pub/sub) — `publish`/`subscribe`/`getState` are no-ops.
142829
143113
  */
142830
143114
  function buildAlexaBrokerProvider(deps) {
142831
- const { ownerAddonId, registry, getBrokers, persistBrokers, cascadeRemoveDevices, logger } = deps;
142832
- const owns = (id) => getBrokers().some((b) => b.id === id);
143115
+ const { registry, getBrokers } = deps;
142833
143116
  const redact = (entry) => ({
142834
143117
  ...entry.connection,
142835
143118
  cookie: "",
@@ -142837,108 +143120,10 @@ function buildAlexaBrokerProvider(deps) {
142837
143120
  macDms: ""
142838
143121
  });
142839
143122
  return {
142840
- list: async ({ kind }) => {
142841
- if (kind && kind !== "alexa") return [];
142842
- return registry.list().map((info) => ({
142843
- ...info,
142844
- addonId: ownerAddonId
142845
- }));
142846
- },
142847
- get: async ({ id }) => {
142848
- const info = registry.get(id);
142849
- return info ? {
142850
- ...info,
142851
- addonId: ownerAddonId
142852
- } : null;
142853
- },
142854
- listProviders: async () => [{
142855
- addonId: ownerAddonId,
142856
- kinds: [{
142857
- kind: ALEXA_BROKER_KIND,
142858
- label: "Alexa",
142859
- signIn: {
142860
- basePath: "/addon/import-alexa/login",
142861
- methods: ["device-code", "proxy"]
142862
- }
142863
- }]
142864
- }],
142865
- add: async ({ kind, name, settings }) => {
142866
- if (kind !== "alexa") throw new Error(`import-alexa: only kind '${ALEXA_BROKER_KIND}' is handled here (got '${kind}')`);
142867
- const entry = await registry.createEntry(name, settingsToAlexaConnection(settings));
142868
- await persistBrokers([...getBrokers(), entry]);
142869
- return { id: entry.id };
142870
- },
142871
- remove: async ({ id }) => {
142872
- if (!owns(id)) return;
142873
- await registry.removeEntry(id);
142874
- await cascadeRemoveDevices(id).catch((err) => {
142875
- logger.warn("alexa: broker cascade-remove threw", {
142876
- tags: { brokerId: id },
142877
- meta: { error: errMsg(err) }
142878
- });
142879
- });
142880
- await persistBrokers(getBrokers().filter((b) => b.id !== id));
142881
- },
142882
- testConnection: async ({ id }) => {
142883
- if (!owns(id)) return {
142884
- ok: false,
142885
- error: "unknown broker"
142886
- };
142887
- const info = registry.get(id);
142888
- if (!info) return {
142889
- ok: false,
142890
- error: "unknown broker"
142891
- };
142892
- if (info.status === "connected") return {
142893
- ok: true,
142894
- latencyMs: info.lastCheckedAt ? Math.max(0, Date.now() - info.lastCheckedAt) : 0
142895
- };
142896
- return {
142897
- ok: false,
142898
- error: info.error ?? `status: ${info.status}`
142899
- };
142900
- },
142901
- getSettings: async ({ id }) => {
142902
- const entry = getBrokers().find((b) => b.id === id);
142903
- return entry ? redact(entry) : null;
142904
- },
142905
- setSettings: async ({ id, settings }) => {
142906
- if (!owns(id)) return;
142907
- const existing = getBrokers().find((b) => b.id === id);
142908
- if (!existing) return;
142909
- const parsed = settingsToAlexaConnection(settings);
142910
- const merged = {
142911
- ...parsed,
142912
- cookie: parsed.cookie.length > 0 ? parsed.cookie : existing.connection.cookie,
142913
- refreshToken: parsed.refreshToken.length > 0 ? parsed.refreshToken : existing.connection.refreshToken,
142914
- macDms: parsed.macDms.length > 0 ? parsed.macDms : existing.connection.macDms
142915
- };
142916
- const updated = await registry.updateEntry(id, merged);
142917
- await persistBrokers(getBrokers().map((b) => b.id === id ? updated : b));
142918
- },
142919
143123
  getBrokerConfig: async ({ id }) => {
142920
143124
  const entry = getBrokers().find((b) => b.id === id);
142921
143125
  return entry ? redact(entry) : null;
142922
143126
  },
142923
- getSettingsSchema: async ({ kind }) => {
142924
- if (kind !== "alexa") return null;
142925
- return buildConnectionFormSchema();
142926
- },
142927
- testSettings: async ({ kind, settings }) => {
142928
- if (kind !== "alexa") return {
142929
- ok: false,
142930
- error: `unsupported kind: ${kind}`
142931
- };
142932
- try {
142933
- settingsToAlexaConnection(settings);
142934
- return { ok: true };
142935
- } catch (err) {
142936
- return {
142937
- ok: false,
142938
- error: errMsg(err)
142939
- };
142940
- }
142941
- },
142942
143127
  publish: async () => null,
142943
143128
  subscribe: async () => ({ subscriptionId: "" }),
142944
143129
  unsubscribe: async () => void 0,
@@ -143264,7 +143449,7 @@ function buildAlexaAdoptionProvider(deps) {
143264
143449
  //#region src/alexa-broker-device-cascade.ts
143265
143450
  /**
143266
143451
  * Remove every adopted Alexa device whose persisted config carries
143267
- * `{ system: 'alexa', brokerId }`. Used on broker.remove and integration delete.
143452
+ * `{ system: 'alexa', brokerId }`. Used when an account instance is deleted and when an integration is deleted.
143268
143453
  * Alexa appliances are flat (standalone) devices, so this removes top-level
143269
143454
  * devices directly. Best-effort per device; returns the count removed. Mirrors
143270
143455
  * the Ecowitt cascade helper.
@@ -143852,6 +144037,30 @@ function withListedVolume(status, listed) {
143852
144037
  };
143853
144038
  }
143854
144039
  //#endregion
144040
+ //#region src/speak/constants.ts
144041
+ /** Echo speech limits and the worker restart bound (same window as CrashSupervisor). */
144042
+ var ALEXA_SPEAK_ADDON_ID = "import-alexa";
144043
+ var ALEXA_SPEAK_KIND = "alexa-echo";
144044
+ /** The kind of the Amazon-account broker the Echoes are read from. */
144045
+ var ALEXA_BROKER_KIND = "alexa";
144046
+ var DEVICE_REFRESH_MS = 5 * 6e4;
144047
+ /** Where the addon serves the Alexa mark; `listTargetKinds` stamps it as `iconUrl`. */
144048
+ var ALEXA_SPEAK_ICON_URL = `/addon/${ALEXA_SPEAK_ADDON_ID}/icons/${ALEXA_SPEAK_KIND}`;
144049
+ /** How often an adopted Echo's player state is read (a push channel is a later phase). */
144050
+ var ECHO_POLL_MS = 3e4;
144051
+ /** One more read after a command, once Amazon has had time to apply it. */
144052
+ var ECHO_REREAD_MS = 1500;
144053
+ /** After a 429 the account is left alone for this long, doubling up to the cap. */
144054
+ var ECHO_RATE_BACKOFF_BASE_MS = 3e4;
144055
+ var ECHO_RATE_BACKOFF_MAX_MS = 5 * 6e4;
144056
+ /**
144057
+ * A command (volume, play, Do Not Disturb) that has not started within this, or not
144058
+ * finished, is answered with an error instead of running minutes late.
144059
+ */
144060
+ var ECHO_COMMAND_DEADLINE_MS = 12e3;
144061
+ /** A poll read is stale after this; it is dropped from the queue rather than sent late. */
144062
+ var ECHO_READ_DEADLINE_MS = 25e3;
144063
+ //#endregion
143855
144064
  //#region src/speak/worker-protocol.ts
143856
144065
  /** Line-delimited JSON the long-lived Python worker speaks. One object per line. */
143857
144066
  var EchoDeviceSchema = object({
@@ -144019,6 +144228,10 @@ var AlexaEchoDevice = class extends BaseDevice {
144019
144228
  failureLogged = false;
144020
144229
  volumeFailureLogged = false;
144021
144230
  health = "ok";
144231
+ /** The last level this device asked Amazon for, and when; a step starts from it until a read is newer. */
144232
+ lastSet = null;
144233
+ /** When the read that produced the current media-player slice began. */
144234
+ polledFrom = 0;
144022
144235
  constructor(ctx) {
144023
144236
  const persisted = alexaEchoDeviceSchema.parse(ctx.persistedConfig ?? {});
144024
144237
  super(ctx, alexaEchoDeviceSchema, { type: DeviceType.MediaPlayer });
@@ -144090,7 +144303,10 @@ var AlexaEchoDevice = class extends BaseDevice {
144090
144303
  }
144091
144304
  const deviceType = this.amazonDeviceType !== "" ? this.amazonDeviceType : listed?.deviceType ?? "";
144092
144305
  try {
144093
- const fromPlayer = mapPlayerInfoToStatus(await session.playerState(this.serial, deviceType), {
144306
+ const readStartedAt = Date.now();
144307
+ const info = await session.playerState(this.serial, deviceType, this.id);
144308
+ this.polledFrom = readStartedAt;
144309
+ const fromPlayer = mapPlayerInfoToStatus(info, {
144094
144310
  online: true,
144095
144311
  now: Date.now()
144096
144312
  });
@@ -144158,16 +144374,28 @@ var AlexaEchoDevice = class extends BaseDevice {
144158
144374
  });
144159
144375
  }
144160
144376
  async command(command) {
144161
- await this.session().npCommand(this.serial, command);
144377
+ await this.session().npCommand(this.serial, command, this.id);
144162
144378
  this.rereadSoon();
144163
144379
  }
144164
- /** Amazon has no step command: move from the last known level, refuse when there is none. */
144380
+ async sendVolume(level) {
144381
+ this.lastSet = {
144382
+ level,
144383
+ at: Date.now()
144384
+ };
144385
+ await this.session().setVolume(this.serial, level, this.id);
144386
+ this.rereadSoon();
144387
+ }
144388
+ /**
144389
+ * Amazon has no step command: move from the level we last SET when no read since
144390
+ * is newer (a polled level lags the command), else the polled one; refuse when
144391
+ * there is none.
144392
+ */
144165
144393
  async stepVolume(delta) {
144166
- const level = this.runtimeState.getCapState("media-player")?.volumeLevel;
144394
+ const polled = this.runtimeState.getCapState("media-player")?.volumeLevel;
144395
+ const set = this.lastSet;
144396
+ const level = set !== null && set.at >= this.polledFrom ? set.level : polled;
144167
144397
  if (level === null || level === void 0) throw new Error("the Echo volume is not known yet");
144168
- const next = Math.max(0, Math.min(100, level + delta));
144169
- await this.session().setVolume(this.serial, next);
144170
- this.rereadSoon();
144398
+ await this.sendVolume(Math.max(0, Math.min(100, level + delta)));
144171
144399
  }
144172
144400
  registerMediaPlayer() {
144173
144401
  this.ctx.registerNativeCap(mediaPlayerCapability, {
@@ -144176,10 +144404,7 @@ var AlexaEchoDevice = class extends BaseDevice {
144176
144404
  pause: () => this.command("pause"),
144177
144405
  next: () => this.command("next"),
144178
144406
  previous: () => this.command("previous"),
144179
- setVolume: async ({ volumeLevel }) => {
144180
- await this.session().setVolume(this.serial, Math.round(volumeLevel));
144181
- this.rereadSoon();
144182
- },
144407
+ setVolume: async ({ volumeLevel }) => this.sendVolume(Math.round(volumeLevel)),
144183
144408
  stop: async () => unsupported("stop"),
144184
144409
  turnOn: async () => unsupported("power"),
144185
144410
  turnOff: async () => unsupported("power"),
@@ -144199,7 +144424,7 @@ var AlexaEchoDevice = class extends BaseDevice {
144199
144424
  this.ctx.registerNativeCap(doNotDisturbCapability, {
144200
144425
  getStatus: async () => this.runtimeState.getCapState("do-not-disturb") ?? COLD_DND,
144201
144426
  setEnabled: async ({ enabled }) => {
144202
- await this.session().setDnd(this.serial, enabled);
144427
+ await this.session().setDnd(this.serial, enabled, this.id);
144203
144428
  this.runtimeState.setCapState("do-not-disturb", {
144204
144429
  enabled,
144205
144430
  lastChangedAt: Date.now()
@@ -144212,6 +144437,7 @@ var alexaBrokersEntryKind = defineEntryKind("brokers", {
144212
144437
  title: "Alexa import brokers",
144213
144438
  schema: entryBody({
144214
144439
  ...alexaConnectionSchema.shape,
144440
+ /** Retired (D1012): the link lives on the integration's `connections` record. Only the migration reads it. */
144215
144441
  integrationId: string().optional(),
144216
144442
  enabled: boolean().optional()
144217
144443
  }),
@@ -144223,20 +144449,27 @@ var alexaSpeakSessionsEntryKind = defineEntryKind("speak-sessions", {
144223
144449
  schema: entryBody({ session: string().default("").meta({ secret: true }) }),
144224
144450
  writeTrigger: "an Echo worker signs in as an Alexa account broker"
144225
144451
  });
144452
+ /** An Alexa integration: the link to the Alexa account (broker) it imports devices from. */
144453
+ var alexaConnectionsEntryKind = declareBrokerLinkKind({
144454
+ title: "Alexa integrations",
144455
+ writeTrigger: "an operator links an Alexa integration to an Alexa account"
144456
+ });
144226
144457
  /** Every kind this addon declares, keyed by its name: `records.declare` refuses any other key. */
144227
144458
  var alexaRecordKinds = {
144228
144459
  brokers: alexaBrokersEntryKind,
144460
+ connections: alexaConnectionsEntryKind,
144229
144461
  "speak-sessions": alexaSpeakSessionsEntryKind
144230
144462
  };
144231
144463
  //#endregion
144232
- //#region src/alexa-instances.ts
144464
+ //#region src/alexa-account-instances.ts
144233
144465
  /**
144234
- * The Alexa accounts as `integration-instances` (planning#408 P3): one instance per `brokers`
144235
- * record, over the SAME records and ids (`alexa_NNN`) the `broker` cap serves. The body is flat, so
144466
+ * The Alexa accounts as `integration-instances` (planning#408 P3, D1013): one instance per `brokers`
144467
+ * record, over the SAME records and ids (`alexa_NNN`) the `broker` cap serves; the `account` kind of
144468
+ * the composed provider (the integrations are the `integration` kind, `alexa-instances.ts`). The body is flat, so
144236
144469
  * the form the instances page edits is the stored shape. Signing in (device code or the Amazon
144237
144470
  * login proxy) goes through the routes the addon already serves under `/addon/import-alexa/login`.
144238
144471
  */
144239
- var ALEXA_INSTANCE_KIND = "alexa";
144472
+ var ALEXA_ACCOUNT_KIND = "account";
144240
144473
  var ALEXA_SIGN_IN_PATH = "/addon/import-alexa/login";
144241
144474
  function isAlexaBodyConfigured(body) {
144242
144475
  return body.cookie.length > 0 || body.refreshToken.length > 0;
@@ -144264,21 +144497,13 @@ function statusOf(info, configured) {
144264
144497
  ...info.error !== null ? { detail: info.error } : {}
144265
144498
  };
144266
144499
  }
144267
- function buildAlexaInstances(records, deps) {
144500
+ function buildAlexaAccountInstances(records, deps) {
144268
144501
  return new RecordIntegrationInstances(records, alexaBrokersEntryKind, {
144269
144502
  description: {
144270
- role: "both",
144503
+ role: "services",
144271
144504
  instanceMode: "multiple",
144272
144505
  kind: "account",
144273
144506
  canTest: true,
144274
- kinds: [{
144275
- id: ALEXA_INSTANCE_KIND,
144276
- label: "Alexa",
144277
- signIn: {
144278
- basePath: ALEXA_SIGN_IN_PATH,
144279
- methods: ["device-code", "proxy"]
144280
- }
144281
- }],
144282
144507
  allowRestoreId: true
144283
144508
  },
144284
144509
  formSchema: () => buildConnectionFormSchema(),
@@ -144291,7 +144516,7 @@ function buildAlexaInstances(records, deps) {
144291
144516
  pollIntervalMs: 6e4
144292
144517
  }),
144293
144518
  mintId: () => deps.mintId(),
144294
- kindOf: () => ALEXA_INSTANCE_KIND,
144519
+ kindOf: () => ALEXA_ACCOUNT_KIND,
144295
144520
  health: (body, id) => {
144296
144521
  const info = deps.infoOf(id);
144297
144522
  return {
@@ -144301,15 +144526,7 @@ function buildAlexaInstances(records, deps) {
144301
144526
  },
144302
144527
  configured: isAlexaBodyConfigured,
144303
144528
  onChanged: deps.onChanged,
144304
- test: async ({ id, values }) => {
144305
- if (["cookie", "refreshToken"].some((key) => {
144306
- const v = values[key];
144307
- return typeof v === "string" && v.length > 0 && v !== "__camstack_redacted__";
144308
- })) return {
144309
- success: true,
144310
- status: "inconclusive",
144311
- error: "Save the account to check the sign-in"
144312
- };
144529
+ test: async ({ id }) => {
144313
144530
  const info = id === null ? null : deps.infoOf(id);
144314
144531
  if (info === null) return {
144315
144532
  success: false,
@@ -144328,6 +144545,21 @@ function buildAlexaInstances(records, deps) {
144328
144545
  });
144329
144546
  }
144330
144547
  //#endregion
144548
+ //#region src/alexa-instances.ts
144549
+ /**
144550
+ * The Alexa integrations as `integration-instances` (D1012): one `connections` record per
144551
+ * integration, keyed by the registry id, whose body is the link to an Alexa account (a `brokers`
144552
+ * record, edited on the Brokers page). The account's sign-in and secrets are never copied here.
144553
+ */
144554
+ function buildAlexaInstances(records) {
144555
+ return buildBrokerLinkInstances(records, {
144556
+ kind: alexaConnectionsEntryKind,
144557
+ brokerKind: ALEXA_BROKER_KIND,
144558
+ brokerLabel: "Alexa account",
144559
+ onChanged: () => void 0
144560
+ });
144561
+ }
144562
+ //#endregion
144331
144563
  //#region src/alexa-broker-store.ts
144332
144564
  function rowToEntry(id, data) {
144333
144565
  const parsed = AlexaBrokerEntrySchema.safeParse({
@@ -144340,7 +144572,6 @@ function rowToEntry(id, data) {
144340
144572
  macDms: data.macDms,
144341
144573
  pollIntervalMs: data.pollIntervalMs
144342
144574
  },
144343
- integrationId: data.integrationId,
144344
144575
  enabled: data.enabled
144345
144576
  });
144346
144577
  return parsed.success ? parsed.data : null;
@@ -144377,11 +144608,10 @@ var AlexaBrokerStore = class AlexaBrokerStore {
144377
144608
  const nextIds = new Set(entries.map((e) => e.id));
144378
144609
  for (const row of page.records) if (!nextIds.has(row.id)) await this.kind.delete(row.id);
144379
144610
  for (const entry of entries) {
144380
- const { id, name, connection, integrationId, enabled } = entry;
144611
+ const { id, name, connection, enabled } = entry;
144381
144612
  const body = {
144382
144613
  name,
144383
144614
  ...connection,
144384
- ...integrationId === void 0 ? {} : { integrationId },
144385
144615
  ...enabled === void 0 ? {} : { enabled }
144386
144616
  };
144387
144617
  const parsed = alexaBrokersEntryKind.schema.safeParse(body);
@@ -144609,6 +144839,9 @@ function scrubSecrets(text, secrets) {
144609
144839
  }
144610
144840
  //#endregion
144611
144841
  //#region src/speak/broker-echo.ts
144842
+ function withDevice(deviceId) {
144843
+ return deviceId === void 0 ? {} : { deviceId };
144844
+ }
144612
144845
  var BrokerEcho = class {
144613
144846
  deps;
144614
144847
  currentState = "starting";
@@ -144754,29 +144987,29 @@ var BrokerEcho = class {
144754
144987
  }
144755
144988
  }
144756
144989
  /** The raw player state of one Echo. Throws on any failure, a 429 included: never an empty answer. */
144757
- async playerState(serial, deviceType) {
144990
+ async playerState(serial, deviceType, deviceId) {
144758
144991
  await this.requireReady();
144759
144992
  return this.limited(async () => parsePlayerState(await this.deps.worker.request({
144760
144993
  op: "player_state",
144761
144994
  serial,
144762
144995
  deviceType
144763
- })));
144996
+ }, withDevice(deviceId))));
144764
144997
  }
144765
- async npCommand(serial, command) {
144998
+ async npCommand(serial, command, deviceId) {
144766
144999
  await this.requireReady();
144767
145000
  await this.limited(() => this.deps.worker.request({
144768
145001
  op: "np_command",
144769
145002
  serial,
144770
145003
  command
144771
- }));
145004
+ }, withDevice(deviceId)));
144772
145005
  }
144773
- async setVolume(serial, level) {
145006
+ async setVolume(serial, level, deviceId) {
144774
145007
  await this.requireReady();
144775
145008
  await this.limited(() => this.deps.worker.request({
144776
145009
  op: "set_volume",
144777
145010
  serial,
144778
145011
  level
144779
- }));
145012
+ }, withDevice(deviceId)));
144780
145013
  }
144781
145014
  /** Do Not Disturb of every device of the account, by serial, in one call. */
144782
145015
  async dndState() {
@@ -144788,13 +145021,13 @@ var BrokerEcho = class {
144788
145021
  await this.requireReady();
144789
145022
  return this.limited(async () => parseDeviceVolumes(await this.deps.worker.request({ op: "device_volumes" })));
144790
145023
  }
144791
- async setDnd(serial, enabled) {
145024
+ async setDnd(serial, enabled, deviceId) {
144792
145025
  await this.requireReady();
144793
145026
  await this.limited(() => this.deps.worker.request({
144794
145027
  op: "set_dnd",
144795
145028
  serial,
144796
145029
  enabled
144797
- }));
145030
+ }, withDevice(deviceId)));
144798
145031
  }
144799
145032
  async requireReady() {
144800
145033
  if (!await this.ensureReady()) throw new Error(this.notReadyReason());
@@ -145380,6 +145613,28 @@ function resolveAlexaWorkerScript(fromModuleUrl = require("url").pathToFileURL(_
145380
145613
  * A crash restarts the process until the same bound CrashSupervisor uses,
145381
145614
  * then requests fail instead of looping.
145382
145615
  */
145616
+ var READ_OPS = new Set([
145617
+ "player_state",
145618
+ "device_volumes",
145619
+ "dnd_state",
145620
+ "devices",
145621
+ "session_status"
145622
+ ]);
145623
+ function coalesceKey(request) {
145624
+ switch (request.op) {
145625
+ case "set_volume":
145626
+ case "player_state": return `${request.op}:${request.serial}`;
145627
+ case "device_volumes":
145628
+ case "dnd_state":
145629
+ case "devices":
145630
+ case "session_status": return request.op;
145631
+ default: return null;
145632
+ }
145633
+ }
145634
+ function defaultDeadlineMs(request) {
145635
+ if (request.op === "set_volume" || request.op === "np_command" || request.op === "set_dnd") return ECHO_COMMAND_DEADLINE_MS;
145636
+ if (READ_OPS.has(request.op) && request.op !== "devices" && request.op !== "session_status") return ECHO_READ_DEADLINE_MS;
145637
+ }
145383
145638
  /** Pure: a new timestamp list, never a mutated one. */
145384
145639
  function recordWorkerCrash(times, now, windowMs, maxCrashes) {
145385
145640
  const cutoff = now - windowMs;
@@ -145464,10 +145719,40 @@ function startAlexaWorker(deps) {
145464
145719
  function scrub(text) {
145465
145720
  return scrubSecrets(text, secrets);
145466
145721
  }
145722
+ function clearTimer(entry) {
145723
+ if (entry.timer !== null) clearTimeout(entry.timer);
145724
+ entry.timer = null;
145725
+ }
145726
+ function timing(entry, outcome) {
145727
+ const end = now();
145728
+ const queuedMs = (entry.sentAt ?? end) - entry.enqueuedAt;
145729
+ const workerMs = entry.sentAt === null ? 0 : end - entry.sentAt;
145730
+ const totalMs = end - entry.enqueuedAt;
145731
+ const extras = {
145732
+ ...entry.deviceId === void 0 ? {} : { tags: { deviceId: entry.deviceId } },
145733
+ meta: {
145734
+ op: entry.request.op,
145735
+ outcome,
145736
+ queuedMs,
145737
+ workerMs,
145738
+ totalMs
145739
+ }
145740
+ };
145741
+ if (totalMs > 5e3) logger.warn("alexa worker request was slow", extras);
145742
+ else logger.debug("alexa worker request", extras);
145743
+ }
145744
+ function settle(entry, outcome, error, result) {
145745
+ clearTimer(entry);
145746
+ timing(entry, outcome);
145747
+ const waiters = entry.waiters;
145748
+ entry.waiters = [];
145749
+ for (const waiter of waiters) if (error === null) waiter.resolve(result);
145750
+ else waiter.reject(error);
145751
+ }
145467
145752
  function failPending(err) {
145468
145753
  const current = pending;
145469
145754
  pending = null;
145470
- current?.reject(err);
145755
+ if (current !== null && !current.abandoned) settle(current, "worker-gone", err);
145471
145756
  }
145472
145757
  function attach(spawned) {
145473
145758
  child = spawned;
@@ -145520,9 +145805,16 @@ function startAlexaWorker(deps) {
145520
145805
  }
145521
145806
  const current = pending;
145522
145807
  pending = null;
145523
- if (message.ok) current.resolve(message.result);
145524
- else if (message.code === "rate-limited") current.reject(new RateLimitedError(scrub(message.error)));
145525
- else current.reject(new Error(scrub(message.error)));
145808
+ if (current.abandoned) logger.debug("alexa worker late reply dropped", {
145809
+ ...current.deviceId === void 0 ? {} : { tags: { deviceId: current.deviceId } },
145810
+ meta: {
145811
+ op: current.request.op,
145812
+ lateMs: now() - current.enqueuedAt
145813
+ }
145814
+ });
145815
+ else if (message.ok) settle(current, "ok", null, message.result);
145816
+ else if (message.code === "rate-limited") settle(current, "rate-limited", new RateLimitedError(scrub(message.error)));
145817
+ else settle(current, "error", new Error(scrub(message.error)));
145526
145818
  pump();
145527
145819
  }
145528
145820
  function onExit(code) {
@@ -145538,7 +145830,7 @@ function startAlexaWorker(deps) {
145538
145830
  failed = true;
145539
145831
  logger.error("alexa worker restart bound reached", { meta: { crashes: crashTimes.length } });
145540
145832
  const waiting = queue.splice(0, queue.length);
145541
- for (const item of waiting) item.pending.reject(/* @__PURE__ */ new Error("alexa worker restart bound reached"));
145833
+ for (const item of waiting) settle(item, "worker-gone", /* @__PURE__ */ new Error("alexa worker restart bound reached"));
145542
145834
  return;
145543
145835
  }
145544
145836
  logger.warn("alexa worker exited; restarting", { meta: {
@@ -145559,23 +145851,74 @@ function startAlexaWorker(deps) {
145559
145851
  if (pending !== null || child === null || failed || stopped) return;
145560
145852
  const next = queue.shift();
145561
145853
  if (next === void 0) return;
145562
- pending = next.pending;
145563
- child.stdin.write(requestLine(next.pending.id, next.request));
145854
+ pending = next;
145855
+ next.sentAt = now();
145856
+ child.stdin.write(requestLine(next.id, next.request));
145857
+ }
145858
+ function expire(entry, deadlineMs) {
145859
+ if (pending === entry) {
145860
+ entry.abandoned = true;
145861
+ settle(entry, "timeout-in-flight", /* @__PURE__ */ new Error(`alexa worker call timed out after ${deadlineMs} ms`));
145862
+ return;
145863
+ }
145864
+ const at = queue.indexOf(entry);
145865
+ if (at < 0) return;
145866
+ queue.splice(at, 1);
145867
+ settle(entry, "expired-queued", /* @__PURE__ */ new Error(`alexa worker call not started within ${deadlineMs} ms`));
145868
+ }
145869
+ function enqueue(entry) {
145870
+ if (entry.isRead) {
145871
+ queue.push(entry);
145872
+ return;
145873
+ }
145874
+ const firstRead = queue.findIndex((queued) => queued.isRead);
145875
+ if (firstRead < 0) queue.push(entry);
145876
+ else queue.splice(firstRead, 0, entry);
145564
145877
  }
145565
- function request(body) {
145878
+ function request(body, options) {
145566
145879
  if (failed) return Promise.reject(/* @__PURE__ */ new Error("alexa worker restart bound reached"));
145567
145880
  if (stopped) return Promise.reject(/* @__PURE__ */ new Error("alexa worker stopped"));
145568
- const id = String(nextId);
145569
- nextId += 1;
145881
+ const key = coalesceKey(body);
145882
+ const isRead = READ_OPS.has(body.op);
145570
145883
  return new Promise((resolve, reject) => {
145571
- queue.push({
145884
+ const waiter = {
145885
+ resolve,
145886
+ reject
145887
+ };
145888
+ const twin = key === null ? void 0 : queue.find((queued) => queued.key === key);
145889
+ if (twin !== void 0 && isRead) {
145890
+ twin.waiters.push(waiter);
145891
+ return;
145892
+ }
145893
+ const id = String(nextId);
145894
+ nextId += 1;
145895
+ const entry = {
145896
+ id,
145572
145897
  request: body,
145573
- pending: {
145574
- id,
145575
- resolve,
145576
- reject
145577
- }
145578
- });
145898
+ key,
145899
+ isRead,
145900
+ deviceId: options?.deviceId,
145901
+ enqueuedAt: now(),
145902
+ sentAt: null,
145903
+ waiters: [waiter],
145904
+ abandoned: false,
145905
+ timer: null
145906
+ };
145907
+ if (twin !== void 0) {
145908
+ queue.splice(queue.indexOf(twin), 1);
145909
+ clearTimer(twin);
145910
+ entry.waiters = [...twin.waiters, waiter];
145911
+ logger.debug("alexa worker queued call replaced by a newer one", {
145912
+ ...twin.deviceId === void 0 ? {} : { tags: { deviceId: twin.deviceId } },
145913
+ meta: { op: body.op }
145914
+ });
145915
+ }
145916
+ const deadlineMs = options?.deadlineMs ?? defaultDeadlineMs(body);
145917
+ if (deadlineMs !== void 0) {
145918
+ entry.timer = setTimeout(() => expire(entry, deadlineMs), deadlineMs);
145919
+ entry.timer.unref();
145920
+ }
145921
+ enqueue(entry);
145579
145922
  if (child === null) launch();
145580
145923
  pump();
145581
145924
  });
@@ -145592,7 +145935,7 @@ function startAlexaWorker(deps) {
145592
145935
  current?.kill("SIGTERM");
145593
145936
  failPending(/* @__PURE__ */ new Error("alexa worker stopped"));
145594
145937
  const waiting = queue.splice(0, queue.length);
145595
- for (const item of waiting) item.pending.reject(/* @__PURE__ */ new Error("alexa worker stopped"));
145938
+ for (const item of waiting) settle(item, "worker-gone", /* @__PURE__ */ new Error("alexa worker stopped"));
145596
145939
  },
145597
145940
  get failed() {
145598
145941
  return failed;
@@ -145851,6 +146194,21 @@ async function flattenBrokerRecords(ports) {
145851
146194
  });
145852
146195
  }
145853
146196
  //#endregion
146197
+ //#region src/migrations/broker-links-to-connections.ts
146198
+ var BROKER_LINKS_TO_CONNECTIONS_ID = "import-alexa/2026-11-05-broker-links-to-connections";
146199
+ /** The broker records and the integration (`connections`) records of this addon. */
146200
+ function alexaBrokerLinkPorts(records, warn) {
146201
+ return brokerLinkPortsOver({
146202
+ brokers: records.kind({
146203
+ name: alexaBrokersEntryKind.name,
146204
+ declaration: alexaBrokersEntryKind.declaration,
146205
+ schema: record(string(), unknown())
146206
+ }),
146207
+ connections: records.kind(alexaConnectionsEntryKind),
146208
+ warn
146209
+ });
146210
+ }
146211
+ //#endregion
145854
146212
  //#region src/migrations/strip-echo-target-volume-and-mode.ts
145855
146213
  var RETIRED_KEYS = ["volume", "mode"];
145856
146214
  function isRecord(value) {
@@ -145933,8 +146291,30 @@ var ADDON_MIGRATIONS = [
145933
146291
  fixtureMark: "the name of the broker and the integration it already names"
145934
146292
  },
145935
146293
  procedure: moveAlexaIntegrationRows
146294
+ },
146295
+ {
146296
+ meta: {
146297
+ id: BROKER_LINKS_TO_CONNECTIONS_ID,
146298
+ owner: "import-alexa",
146299
+ level: "addon-state",
146300
+ seq: 4,
146301
+ dependsOn: [INTEGRATION_ROWS_TO_RECORD_ID],
146302
+ store: "settings",
146303
+ scope: "cluster",
146304
+ onFailure: "block-owner",
146305
+ bodyKind: "procedure",
146306
+ description: "Write each integration a connections record naming its Alexa account (keyed by the integration id), then clear the integration link on the account; stray registry-keyed account rows are counted and logged, never deleted",
146307
+ fixtureMark: "the name and the extra field of the account that keeps its record"
146308
+ },
146309
+ procedure: moveBrokerLinksToConnections
145936
146310
  }
145937
146311
  ];
146312
+ /** The migrations that rewrite the broker records: they run BEFORE anything reads or persists one. */
146313
+ var BROKER_RECORD_MIGRATION_IDS = [
146314
+ FLATTEN_BROKER_RECORDS_ID,
146315
+ INTEGRATION_ROWS_TO_RECORD_ID,
146316
+ BROKER_LINKS_TO_CONNECTIONS_ID
146317
+ ];
145938
146318
  //#endregion
145939
146319
  //#region src/speak/icon-routes.ts
145940
146320
  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>";
@@ -146033,6 +146413,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146033
146413
  loginProxy = null;
146034
146414
  brokerStore = null;
146035
146415
  instances = null;
146416
+ accountInstances = null;
146036
146417
  activeBrokers = [];
146037
146418
  speak = null;
146038
146419
  constructor() {
@@ -146044,12 +146425,13 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146044
146425
  if (records === void 0) throw new Error("import-alexa: ctx.records is required (hub-only addon)");
146045
146426
  await records.declare(alexaRecordKinds);
146046
146427
  this.brokerStore = AlexaBrokerStore.of(records);
146047
- this.instances = buildAlexaInstances(records, {
146428
+ this.instances = buildAlexaInstances(records);
146429
+ this.accountInstances = buildAlexaAccountInstances(records, {
146048
146430
  mintId: () => this.requireRegistry().allocateId(),
146049
146431
  infoOf: (id) => this.registry?.get(id) ?? null,
146050
146432
  onChanged: (id) => this.syncBrokerRuntime(id)
146051
146433
  });
146052
- await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, this.migrationPorts(records)), { only: [FLATTEN_BROKER_RECORDS_ID] });
146434
+ await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, this.migrationPorts(records)), { only: BROKER_RECORD_MIGRATION_IDS });
146053
146435
  this.activeBrokers = await this.loadPersistedBrokers();
146054
146436
  await this.adoptSessionFiles();
146055
146437
  this.registry = new AlexaBrokerRegistry(this.ctx.logger, {
@@ -146084,15 +146466,13 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146084
146466
  });
146085
146467
  this.deviceCodeLogin = new AlexaDeviceCodeManager({ logger: this.ctx.logger });
146086
146468
  this.ctx.logger.info("Alexa: import addon initialised", { meta: { brokerCount: this.activeBrokers.length } });
146087
- await this.reconcileIntegrationsToBrokers();
146088
- this.subscribeIntegrationLifecycle();
146089
146469
  const loginRoutes = [...buildAlexaLoginRoutes({
146090
146470
  logger: this.ctx.logger,
146091
146471
  proxyLogin: this.proxyLogin,
146092
146472
  deviceCode: this.deviceCodeLogin,
146093
146473
  commitCookie: (input) => this.commitCapturedCookie(input)
146094
146474
  }), ...speakIconRoutes()];
146095
- await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, this.migrationPorts(records)), { only: ADDON_MIGRATIONS.map((m) => m.meta.id).filter((id) => id !== FLATTEN_BROKER_RECORDS_ID) });
146475
+ await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, this.migrationPorts(records)), { only: ADDON_MIGRATIONS.map((m) => m.meta.id).filter((id) => !BROKER_RECORD_MIGRATION_IDS.includes(id)) });
146096
146476
  const speak = new AlexaSpeakController({
146097
146477
  logger: this.ctx.logger.child("speak"),
146098
146478
  readConfig: () => this.config,
@@ -146124,7 +146504,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146124
146504
  },
146125
146505
  {
146126
146506
  capability: integrationInstancesCapability,
146127
- provider: this.requireInstances().toProvider()
146507
+ provider: this.buildInstancesProvider()
146128
146508
  },
146129
146509
  {
146130
146510
  capability: deviceAdoptionCapability,
@@ -146149,6 +146529,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146149
146529
  await this.updateGlobalSettings({ echoTargets: [...readEchoTargets({ echoTargets: rows })] });
146150
146530
  },
146151
146531
  ...flattenPortsOver(records.kind(alexaBrokersEntryKind)),
146532
+ ...alexaBrokerLinkPorts(records, (message) => this.ctx.logger.warn(message)),
146152
146533
  ...alexaIntegrationRowsPorts({
146153
146534
  api: defaultIntegrationApiOver(this.ctx.api),
146154
146535
  records,
@@ -146157,10 +146538,42 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146157
146538
  })
146158
146539
  };
146159
146540
  }
146541
+ /** One provider over the two record kinds: the account (broker) and the integrations using it. */
146542
+ buildInstancesProvider() {
146543
+ if (this.accountInstances === null) throw new Error("import-alexa: account instances not ready");
146544
+ return composeInstanceKinds([{
146545
+ kind: {
146546
+ id: "account",
146547
+ label: "Alexa account",
146548
+ functionCap: "broker",
146549
+ brokerKind: ALEXA_BROKER_KIND,
146550
+ role: "services",
146551
+ signIn: {
146552
+ basePath: ALEXA_SIGN_IN_PATH,
146553
+ methods: ["device-code", "proxy"]
146554
+ }
146555
+ },
146556
+ provider: guardBrokerDelete(this.accountInstances.toProvider(), () => this.brokerUsers())
146557
+ }, {
146558
+ kind: {
146559
+ id: "integration",
146560
+ label: "Alexa",
146561
+ functionCap: "device-provider",
146562
+ role: "devices"
146563
+ },
146564
+ provider: this.requireInstances().toProvider()
146565
+ }], {
146566
+ role: "devices",
146567
+ instanceMode: "multiple",
146568
+ kind: "account",
146569
+ canTest: true,
146570
+ allowRestoreId: true
146571
+ });
146572
+ }
146160
146573
  /**
146161
- * Bring the runtime in line with one record after the instances page changed it: a removed
146162
- * record stops its manager and cascades its devices, an off record only stops the manager, a
146163
- * live one re-applies its connection or starts under the record's own id.
146574
+ * Bring the runtime in line with one account record after the instances page changed it: a
146575
+ * removed record stops its manager and cascades its devices, an off record only stops the
146576
+ * manager, a live one re-applies its connection or starts under the record's own id.
146164
146577
  */
146165
146578
  async syncBrokerRuntime(id) {
146166
146579
  const reg = this.requireRegistry();
@@ -146181,10 +146594,7 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146181
146594
  try {
146182
146595
  if (reg.get(id) !== null) {
146183
146596
  if (!sameConnection(entry.connection, reg.getConnection(id))) await reg.updateEntry(id, entry.connection);
146184
- } else await reg.createEntry(entry.name, entry.connection, {
146185
- id,
146186
- ...entry.integrationId !== void 0 ? { integrationId: entry.integrationId } : {}
146187
- });
146597
+ } else await reg.createEntry(entry.name, entry.connection, { id });
146188
146598
  } catch (err) {
146189
146599
  this.ctx.logger.warn("alexa instance: could not start the account", { meta: {
146190
146600
  brokerId: id,
@@ -146403,12 +146813,8 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146403
146813
  }
146404
146814
  buildBrokerProvider() {
146405
146815
  return buildAlexaBrokerProvider({
146406
- ownerAddonId: this.ctx.id,
146407
146816
  registry: this.requireRegistry(),
146408
- getBrokers: () => this.activeBrokers,
146409
- persistBrokers: (brokers) => this.persistBrokerEntries(brokers),
146410
- cascadeRemoveDevices: (brokerId) => this.cascadeRemoveDevicesForBroker(brokerId),
146411
- logger: this.ctx.logger
146817
+ getBrokers: () => this.activeBrokers
146412
146818
  });
146413
146819
  }
146414
146820
  /**
@@ -146445,6 +146851,14 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146445
146851
  } });
146446
146852
  return { brokerId: entry.id };
146447
146853
  }
146854
+ /** Who still points at an Alexa account: its integrations and the Echo targets that speak through it. */
146855
+ brokerUsers() {
146856
+ return [brokerLinkUserFinder(this.requireInstances()), { find: async (brokerId) => readEchoTargets(this.config).filter((target) => target.brokerId === brokerId).map((target) => ({
146857
+ kind: "notification-target",
146858
+ id: target.id,
146859
+ name: target.name
146860
+ })) }];
146861
+ }
146448
146862
  async cascadeRemoveDevicesForBroker(brokerId) {
146449
146863
  const reg = this.ctx.kernel.deviceRegistry;
146450
146864
  const devices = this.ctx.kernel.devices;
@@ -146457,42 +146871,6 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146457
146871
  logger: this.ctx.logger
146458
146872
  });
146459
146873
  }
146460
- async reconcileIntegrationsToBrokers() {
146461
- try {
146462
- const mine = (await this.ctx.api.integrations.list.query()).filter((i) => i.addonId === this.ctx.id);
146463
- const surviving = new Set(mine.map((i) => i.id));
146464
- for (const broker of this.activeBrokers) {
146465
- const integrationId = broker.integrationId;
146466
- if (integrationId === void 0 || !surviving.has(integrationId)) continue;
146467
- this.requireRegistry().linkIntegration(integrationId, broker.id);
146468
- }
146469
- const toRemove = this.activeBrokers.filter((b) => b.integrationId !== void 0 && !surviving.has(b.integrationId)).map((b) => b.id);
146470
- if (toRemove.length === 0) return;
146471
- for (const id of toRemove) try {
146472
- await this.requireRegistry().removeEntry(id);
146473
- await this.cascadeRemoveDevicesForBroker(id);
146474
- } catch (err) {
146475
- this.ctx.logger.warn("Alexa integration→broker: broker cleanup failed", { meta: {
146476
- brokerId: id,
146477
- error: errMsg(err)
146478
- } });
146479
- }
146480
- const nextBrokers = this.activeBrokers.filter((b) => !toRemove.includes(b.id));
146481
- await this.persistBrokerEntries(nextBrokers);
146482
- } catch (err) {
146483
- this.ctx.logger.warn("Alexa integration→broker reconcile failed", { meta: { error: errMsg(err) } });
146484
- }
146485
- }
146486
- subscribeIntegrationLifecycle() {
146487
- const handler = (event) => {
146488
- const addonId = event.data["addonId"];
146489
- if (typeof addonId === "string" && addonId !== this.ctx.id) return;
146490
- this.reconcileIntegrationsToBrokers();
146491
- };
146492
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationEnabled }, handler);
146493
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDisabled }, handler);
146494
- this.ctx.eventBus.subscribe({ category: EventCategory.IntegrationDeleted }, handler);
146495
- }
146496
146874
  buildAdoptionProvider() {
146497
146875
  return buildAlexaAdoptionProvider({
146498
146876
  registry: {
@@ -146506,9 +146884,9 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146506
146884
  },
146507
146885
  logger: this.ctx.logger,
146508
146886
  getBrokerIdForIntegration: async (id) => {
146509
- const broker = this.activeBrokers.find((entry) => entry.integrationId === id);
146510
- if (broker === void 0) throw new Error(`integration ${id} has no linked brokerId`);
146511
- return broker.id;
146887
+ const brokerId = await brokerIdOf(this.requireInstances(), id);
146888
+ if (brokerId === void 0) throw new Error(`integration ${id} has no broker`);
146889
+ return brokerId;
146512
146890
  },
146513
146891
  listAdoptedAlexa: async () => {
146514
146892
  const reg = this.ctx.kernel.deviceRegistry;
@@ -146571,22 +146949,9 @@ var AlexaImportAddon = class extends BaseDeviceProvider {
146571
146949
  }
146572
146950
  });
146573
146951
  }
146574
- globalSettingsSchema() {
146575
- return this.schema({ sections: [{
146576
- id: "alexa-account",
146577
- title: "Amazon Alexa account",
146578
- titleKey: "import-alexa:globalSettings.s_alexa-account.title",
146579
- description: "Alexa accounts are managed in the External systems → Brokers tab.",
146580
- descriptionKey: "import-alexa:globalSettings.s_alexa-account.description",
146581
- columns: 1,
146582
- fields: [{
146583
- type: "info",
146584
- key: "accountHelp",
146585
- label: "Accounts are managed separately",
146586
- labelKey: "import-alexa:globalSettings.s_alexa-account.accountHelp.label",
146587
- content: "This integration links to an Amazon Alexa account broker. Add, edit, or remove accounts from External systems → Brokers, then adopt the imported devices. The integration only stores a reference to its broker. NOTE: this is an unofficial integration that Amazon can change or break without notice."
146588
- }]
146589
- }] });
146952
+ /** The connection form a record carries; the values live on the record, never in `addon-settings`. */
146953
+ async integrationFormSchema(_instance) {
146954
+ return buildConnectionFormSchema();
146590
146955
  }
146591
146956
  async supportsManualCreation() {
146592
146957
  return false;