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