@camstack/addon-mqtt-broker 1.2.151 → 1.2.153

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.
@@ -6028,7 +6028,7 @@ var ZodIssueCode = {
6028
6028
  var ZodFirstPartyTypeKind;
6029
6029
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
6030
6030
  //#endregion
6031
- //#region ../types/dist/sleep-DEZaLbiu.mjs
6031
+ //#region ../types/dist/sleep-CmjWurK5.mjs
6032
6032
  /**
6033
6033
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6034
6034
  * window to float samples (D455).
@@ -6712,137 +6712,86 @@ async function runAddonMigrations(host, migrations, logger, options = {}) {
6712
6712
  blockOwner
6713
6713
  };
6714
6714
  }
6715
- /**
6716
- * Build a {@link DurableState} over a single store key. Transport-agnostic:
6717
- * `read`/`write` are the addon-store or device-store accessors. The whole
6718
- * validated value round-trips on every read/write — the schema is the
6719
- * single source of truth for what is persisted, so no hand-listed field
6720
- * set can drop a key on save.
6721
- */
6722
- function createDurableState(deps) {
6723
- const get = async () => {
6724
- const raw = (await deps.read())[deps.key];
6725
- if (raw === void 0) return deps.fallback;
6726
- const parsed = deps.schema.safeParse(raw);
6727
- if (!parsed.success) {
6728
- deps.onParseError?.(deps.key, parsed.error);
6729
- return deps.fallback;
6730
- }
6731
- return parsed.data;
6732
- };
6733
- const set = async (next) => {
6734
- const validated = deps.schema.parse(next);
6735
- await deps.write({ [deps.key]: validated });
6736
- };
6737
- const update = async (fn) => {
6738
- await set(fn(await get()));
6739
- };
6715
+ /** The record calls the one-switch migration makes, over the migration API. */
6716
+ function switchRecordsOverMigrationApi(api) {
6717
+ return { kind: (definition) => {
6718
+ const handle = api.records.kind(definition);
6719
+ return {
6720
+ get: (id) => handle.get({ id }),
6721
+ put: (input, opts) => handle.put({
6722
+ ...input,
6723
+ ...opts?.ifRev === void 0 ? {} : { ifRev: opts.ifRev }
6724
+ }),
6725
+ patch: (id, partial) => handle.patch({
6726
+ id,
6727
+ partial
6728
+ })
6729
+ };
6730
+ } };
6731
+ }
6732
+ function integrationRowsApiOver(api) {
6740
6733
  return {
6741
- get,
6742
- set,
6743
- update
6734
+ list: async () => (await api.integrations.list({})).integrations,
6735
+ readLegacyIntegrationRows: (input) => api.legacyRows.read(input),
6736
+ clearLegacyIntegrationRows: (input) => api.legacyRows.clear(input)
6744
6737
  };
6745
6738
  }
6746
- /**
6747
- * Per-node scoping for the shared addon-settings blob.
6748
- *
6749
- * An addon's settings store is hub-central (the `addon-settings` cap is
6750
- * hub-routed — the hub instance answers for every node), so fields whose
6751
- * value is a NODE fact (decoder backend, engine pick, probed hardware
6752
- * capabilities) persist under the node-scoped key `<key>@<nodeId>` inside
6753
- * the single shared blob. The SCHEMA field key stays bare: the write path
6754
- * ({@link scopePatch}) maps the bare field onto the target node's scoped
6755
- * key; the read/hydrate path ({@link projectStore}) maps THIS node's value
6756
- * back, so UI forms and `resolveConfig` only ever see the bare key.
6757
- *
6758
- * Fields opt in via the `perNode: true` schema marker on `ConfigFieldBase`
6759
- * (`interfaces/config-ui.ts`); `BaseAddon` derives the key set from the
6760
- * schema and routes reads/writes through these helpers.
6761
- *
6762
- * ## No bare-key fallback — deliberate
6763
- *
6764
- * {@link readNodeValue} reads the node-scoped key ONLY. A node with no
6765
- * scoped key resolves to `undefined` so the field's schema `default` wins —
6766
- * NEVER `store[base]` and never another node's value. A bare legacy key in
6767
- * the store is invisible to every node, hub included, so one node's
6768
- * selection can never leak onto another. (This generalizes the
6769
- * `decoder-backend-keys.ts` semantics — the no-fallback variant — over an
6770
- * arbitrary set of per-node field keys.)
6771
- *
6772
- * Pure functions only — no I/O, no imports beyond the language. This is a
6773
- * LEAF module: import it via its deep path, never from the root barrel.
6774
- */
6775
- /**
6776
- * Normalize a raw kernel node id to the bare node id used for scoping.
6777
- * `localNodeId` can carry a `<node>/<addon>` suffix on forked child
6778
- * processes; per-node settings are per-NODE, so strip the addon segment.
6779
- * `undefined` / `null` / empty falls back to `'hub'`.
6780
- */
6781
- function normalizeNodeId(raw) {
6782
- if (raw === void 0 || raw === null || raw === "") return "hub";
6783
- const slashIdx = raw.indexOf("/");
6784
- if (slashIdx < 0) return raw;
6785
- const bare = raw.slice(0, slashIdx);
6786
- return bare === "" ? "hub" : bare;
6787
- }
6788
- /** The node-scoped store key for a per-node field: `<base>@<nodeId>`. */
6789
- function nodeScopedKey(base, nodeId) {
6790
- return `${base}@${normalizeNodeId(nodeId)}`;
6791
- }
6792
- /**
6793
- * Read a node's value for a per-node field from the raw shared store:
6794
- * the node-scoped key when present, otherwise `undefined`.
6795
- * Deliberately NO bare-key fallback (see module doc) — the caller lets the
6796
- * schema `default` win on `undefined`.
6797
- */
6798
- function readNodeValue(store, base, nodeId) {
6799
- return store[nodeScopedKey(base, nodeId)];
6800
- }
6801
- /**
6802
- * Re-map a UI/settings patch so every bare perNode field persists under the
6803
- * TARGET node's scoped key; all other keys pass through unchanged. Used on
6804
- * the write path so a save for one node never clobbers another node's value
6805
- * (and the bare key is never written). Returns a new object — the input
6806
- * patch is not mutated.
6807
- */
6808
- function scopePatch(patch, perNodeKeys, nodeId) {
6809
- const out = {};
6810
- for (const [key, value] of Object.entries(patch)) out[perNodeKeys.has(key) ? nodeScopedKey(key, nodeId) : key] = value;
6811
- return out;
6812
- }
6813
- /**
6814
- * Project the raw shared store onto the bare schema keys for ONE node so a
6815
- * UI schema (whose field keys are bare) hydrates from that node's own
6816
- * values:
6817
- *
6818
- * - EVERY `@`-scoped key (any node's) is dropped from the pass-through.
6819
- * - Every bare perNode key is dropped from the pass-through (a stray bare
6820
- * legacy key must never hydrate any node — no bare fallback).
6821
- * - THIS node's effective value ({@link readNodeValue}) is then laid onto
6822
- * each bare perNode key; when the node has no scoped key the bare key is
6823
- * left ABSENT so the field's schema `default` wins.
6824
- *
6825
- * Returns a new object — the input store is not mutated.
6826
- */
6827
- function projectStore(store, perNodeKeys, nodeId) {
6828
- const out = {};
6829
- for (const [key, value] of Object.entries(store)) {
6830
- if (key.includes("@")) continue;
6831
- if (perNodeKeys.has(key)) continue;
6832
- out[key] = value;
6739
+ var PREFIX = "records/";
6740
+ /** The base of every records failure. `detail` is the human part of the message. */
6741
+ var RecordsError = class extends Error {
6742
+ code;
6743
+ detail;
6744
+ constructor(code, detail) {
6745
+ super(`${PREFIX}${code}: ${detail}`);
6746
+ this.name = "RecordsError";
6747
+ this.code = code;
6748
+ this.detail = detail;
6833
6749
  }
6834
- for (const base of perNodeKeys) {
6835
- const value = readNodeValue(store, base, nodeId);
6836
- if (value !== void 0) out[base] = value;
6750
+ };
6751
+ /** The keychain could not store or serve a value: explicit, never an empty string. */
6752
+ var RecordSecretUnavailableError = class extends RecordsError {
6753
+ constructor(detail) {
6754
+ super("secret-unavailable", detail);
6755
+ this.name = "RecordSecretUnavailableError";
6837
6756
  }
6838
- return out;
6757
+ };
6758
+ var InvalidRecordError = class extends RecordsError {
6759
+ constructor(detail) {
6760
+ super("invalid-record", detail);
6761
+ this.name = "InvalidRecordError";
6762
+ }
6763
+ };
6764
+ /** A kind declaration the engine refuses (non-additive change, bad shape, wrong file). */
6765
+ var DeclarationRefusedError = class extends RecordsError {
6766
+ constructor(detail) {
6767
+ super("declaration-refused", detail);
6768
+ this.name = "DeclarationRefusedError";
6769
+ }
6770
+ };
6771
+ function linkTargetOf(meta) {
6772
+ if (typeof meta !== "object" || meta === null) return void 0;
6773
+ const link = Reflect.get(meta, "link");
6774
+ const parsed = LinkTargetSchema.safeParse(link);
6775
+ return parsed.success ? parsed.data : void 0;
6776
+ }
6777
+ var LinkTargetSchema = object({
6778
+ cap: string().min(1),
6779
+ kind: string().min(1).optional()
6780
+ });
6781
+ /** The links a kind declares, read from the meta of its body fields. */
6782
+ function linksOfKind(kind) {
6783
+ const schema = kind.schema;
6784
+ if (!(schema instanceof ZodObject)) return [];
6785
+ const links = [];
6786
+ for (const [field, fieldSchema] of Object.entries(schema.shape)) {
6787
+ const to = linkTargetOf(globalRegistry.get(fieldSchema));
6788
+ if (to !== void 0) links.push({
6789
+ field,
6790
+ to
6791
+ });
6792
+ }
6793
+ return links;
6839
6794
  }
6840
- /** Every scope, in the order the guard and the docs list them. */
6841
- var SCOPED_SETTINGS_SCOPES = [
6842
- "cluster",
6843
- "device-defaults",
6844
- "node"
6845
- ];
6846
6795
  /**
6847
6796
  * The address of one keychain value: `secret://<owner>/<scope>/<fieldPath>`.
6848
6797
  *
@@ -6977,6 +6926,643 @@ function parseSecretRef(ref) {
6977
6926
  fieldPath
6978
6927
  };
6979
6928
  }
6929
+ /** Limits and names of `ctx.records` (D881). One authority; the engine and the facade read it. */
6930
+ /** The files a kind may live in. `history` is device-history's (D850), never records. */
6931
+ var RECORD_DATABASES = ["settings", "events"];
6932
+ /** The classes of a kind: the `@durable` vocabulary, one authority (replaces the annotation). */
6933
+ var RECORD_CLASSES = [
6934
+ "config",
6935
+ "ledger",
6936
+ "audit",
6937
+ "mirror",
6938
+ "ephemeral"
6939
+ ];
6940
+ var RECORD_DEFAULT_MAX_DATA_BYTES = 64 * 1024;
6941
+ var RECORD_MAX_DATA_BYTES = 1024 * 1024;
6942
+ var RECORD_BATCH_MAX = 1e3;
6943
+ var RECORD_QUERY_MAX = 1e3;
6944
+ /** A kind in the `settings` file may not bound itself above this (D447: high volume is `events`). */
6945
+ var RECORD_SETTINGS_MAX_ROWS = 5e4;
6946
+ /** The `Column`-free proof prefix: a ref collection for a kind's secret fields. */
6947
+ var RECORD_SECRET_COLLECTION_PREFIX = "records:";
6948
+ /**
6949
+ * Secret fields of a record, addressed the way a Zod schema declares them
6950
+ * (`collectZodSecretPaths`): dotted keys, `[]` for "each element of this array"
6951
+ * (`auth.accessToken`, `headers[].value`).
6952
+ *
6953
+ * `{}` after a key means "each value of this map" (`applianceSecrets{}`): the map's own key
6954
+ * names the value (`applianceSecrets{fridge}`), so no id key is declared and a body that drops a
6955
+ * key releases exactly that key's secret.
6956
+ *
6957
+ * Array elements are matched by a DECLARED id key (`secretArrayKeys`:
6958
+ * `{ headers: 'name' }`), never by index, so reordering a list keeps every
6959
+ * keychain ref (the same rule as `arrayItemFieldPath`). The concrete path of a
6960
+ * leaf names its element: `headers[X-Api].value`.
6961
+ *
6962
+ * Pure and browser-safe. Nothing here mutates: every transform returns a new value.
6963
+ */
6964
+ /** The array prefixes (declared form, e.g. `headers` or `a.headers`) of a path. */
6965
+ function parseSecretPath(path) {
6966
+ if (path.length === 0) throw new InvalidRecordError("an empty secret path");
6967
+ return path.split(".").map((raw) => {
6968
+ const each = raw.endsWith("[]");
6969
+ const map = raw.endsWith("{}");
6970
+ const key = each || map ? raw.slice(0, -2) : raw;
6971
+ if (key.length === 0) throw new InvalidRecordError(`secret path "${path}" has an empty segment`);
6972
+ return {
6973
+ key,
6974
+ each,
6975
+ map
6976
+ };
6977
+ });
6978
+ }
6979
+ /** The declared array prefixes of `path` that need an id key (`headers`, `a.rows`). */
6980
+ function secretArrayPrefixes(path) {
6981
+ const segments = parseSecretPath(path);
6982
+ const prefixes = [];
6983
+ const walked = [];
6984
+ for (const segment of segments) {
6985
+ walked.push(segment.key);
6986
+ if (segment.each) prefixes.push(walked.join("."));
6987
+ }
6988
+ return prefixes;
6989
+ }
6990
+ function isRecord$6(value) {
6991
+ return typeof value === "object" && value !== null && !Array.isArray(value);
6992
+ }
6993
+ function elementId(element, idKey, declared, prefix) {
6994
+ const id = isRecord$6(element) ? element[idKey] : void 0;
6995
+ if (typeof id !== "string" || id.length === 0) throw new InvalidRecordError(`"${declared}": every element of "${prefix}" needs a non-empty string "${idKey}" to carry a secret`);
6996
+ return id;
6997
+ }
6998
+ /**
6999
+ * Visit every secret leaf PRESENT in `data` (an absent branch has nothing to
7000
+ * visit). `replace` returns the new leaf value, or {@link REMOVE_LEAF}; the
7001
+ * result is a new body, `data` is untouched.
7002
+ */
7003
+ var REMOVE_LEAF = Symbol("remove-leaf");
7004
+ function mapAt(node, segments, index, walkedKeys, concretePrefix, declared, arrayKeys, visit) {
7005
+ if (!isRecord$6(node)) return node;
7006
+ const segment = segments[index];
7007
+ if (segment === void 0) return node;
7008
+ if (!Object.prototype.hasOwnProperty.call(node, segment.key)) return node;
7009
+ const child = node[segment.key];
7010
+ const keys = [...walkedKeys, segment.key];
7011
+ const last = index === segments.length - 1;
7012
+ if (segment.each) {
7013
+ if (!Array.isArray(child)) return node;
7014
+ const prefix = keys.join(".");
7015
+ const idKey = arrayKeys[prefix];
7016
+ if (idKey === void 0) throw new InvalidRecordError(`"${declared}": no id key declared for the array "${prefix}"`);
7017
+ const mapped = child.map((element) => {
7018
+ const id = elementId(element, idKey, declared, prefix);
7019
+ const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}[${encodeURIComponent(id)}]`;
7020
+ return last ? element : mapAt(element, segments, index + 1, keys, concrete, declared, arrayKeys, visit);
7021
+ });
7022
+ return {
7023
+ ...node,
7024
+ [segment.key]: mapped
7025
+ };
7026
+ }
7027
+ if (segment.map) {
7028
+ if (!isRecord$6(child)) return node;
7029
+ const kept = Object.entries(child).map(([mapKey, value]) => {
7030
+ const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}{${encodeURIComponent(mapKey)}}`;
7031
+ if (!last) return [mapKey, mapAt(value, segments, index + 1, keys, concrete, declared, arrayKeys, visit)];
7032
+ return [mapKey, visit({
7033
+ declared,
7034
+ concrete,
7035
+ value
7036
+ })];
7037
+ }).filter(([, value]) => value !== REMOVE_LEAF);
7038
+ return {
7039
+ ...node,
7040
+ [segment.key]: Object.fromEntries(kept)
7041
+ };
7042
+ }
7043
+ const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}`;
7044
+ if (last) {
7045
+ const replacement = visit({
7046
+ declared,
7047
+ concrete,
7048
+ value: child
7049
+ });
7050
+ if (replacement === REMOVE_LEAF) return Object.fromEntries(Object.entries(node).filter(([key]) => key !== segment.key));
7051
+ return {
7052
+ ...node,
7053
+ [segment.key]: replacement
7054
+ };
7055
+ }
7056
+ return {
7057
+ ...node,
7058
+ [segment.key]: mapAt(child, segments, index + 1, keys, concrete, declared, arrayKeys, visit)
7059
+ };
7060
+ }
7061
+ /** Replace/remove every present secret leaf; returns a NEW body. */
7062
+ function mapSecretLeaves(data, paths, arrayKeys, visit) {
7063
+ let current = data;
7064
+ for (const declared of paths) current = mapAt(current, parseSecretPath(declared), 0, [], "", declared, arrayKeys, visit);
7065
+ return current;
7066
+ }
7067
+ /**
7068
+ * The keychain collection name a kind's secrets live under. Prefixed so a
7069
+ * record's refs never collide with the refs a legacy collection of the same name
7070
+ * already holds during an adoption.
7071
+ */
7072
+ function recordSecretCollection(kind) {
7073
+ return `${RECORD_SECRET_COLLECTION_PREFIX}${kind}`;
7074
+ }
7075
+ /** The ONE ref a row's secret field may carry: the engine and the facade both build it here. */
7076
+ function recordSecretRef(owner, kind, rowId, concretePath) {
7077
+ return buildSecretRef({
7078
+ owner,
7079
+ scope: {
7080
+ kind: "row",
7081
+ collection: recordSecretCollection(kind),
7082
+ rowId
7083
+ },
7084
+ fieldPath: concretePath
7085
+ });
7086
+ }
7087
+ /**
7088
+ * A stored secret, as an admin read shows it: a VIEW naming the row's own
7089
+ * keychain ref (`set: true`), never the value and no longer the bare redaction
7090
+ * token. The form hands it to the secret field, which reveals the value by ref
7091
+ * through the audited `keychain-admin.reveal`; sent back untouched it keeps the
7092
+ * stored secret (the records door reads a view as KEEP).
7093
+ *
7094
+ * ONE implementation for the entries service and for every addon that serves
7095
+ * its own instances (`RecordIntegrationInstances`).
7096
+ */
7097
+ function isRecord$5(value) {
7098
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7099
+ }
7100
+ function secretViewsOf(owner, kind, id, decl, data) {
7101
+ if (decl.secretPaths.length === 0) return data;
7102
+ const shown = mapSecretLeaves(data, decl.secretPaths, decl.secretArrayKeys, (leaf) => {
7103
+ if (leaf.value !== "__camstack_redacted__") return leaf.value;
7104
+ return {
7105
+ $secret: recordSecretRef(owner, kind, id, leaf.concrete),
7106
+ v: 1,
7107
+ set: true
7108
+ };
7109
+ });
7110
+ return isRecord$5(shown) ? shown : data;
7111
+ }
7112
+ /** The most instances one addon lists (an integrations page, not a table). */
7113
+ var INSTANCES_LIST_MAX = 1e3;
7114
+ /**
7115
+ * The ONE reader of an instance's switch (D996): an absent field means on, so a row written
7116
+ * before the field existed needs no migration. Every consumer of an instance body asks this.
7117
+ */
7118
+ function isInstanceEnabled(body) {
7119
+ return body.enabled !== false;
7120
+ }
7121
+ /** The body key of the switch; only {@link RecordIntegrationInstances.setInstanceEnabled} writes it. */
7122
+ var SWITCH_KEY = "enabled";
7123
+ /** The keys of the kind's secret fields that sit at the top level of the body. */
7124
+ function topLevelSecretKeys(paths) {
7125
+ return new Set(paths.filter((p) => !p.includes(".") && !p.includes("[") && !p.includes("{")));
7126
+ }
7127
+ /** The keys of the kind's top-level MAPS whose values are secrets (`applianceSecrets{}`). */
7128
+ function secretMapKeys(paths) {
7129
+ return new Set(paths.filter((p) => p.endsWith("{}") && !p.slice(0, -2).match(/[.[{]/)).map((p) => p.slice(0, -2)));
7130
+ }
7131
+ /**
7132
+ * A patch value for a map of secrets: an entry that is omitted, `''`, the redaction token or the
7133
+ * view the form was given KEEPS its stored secret (it is left out of the merge); `{ $secretClear }`
7134
+ * or `null` removes the entry; any other string replaces it. Null when nothing is left to merge.
7135
+ */
7136
+ function secretMapPatch(value) {
7137
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
7138
+ const out = {};
7139
+ for (const [key, entry] of Object.entries(value)) if (entry === null || isSecretClear(entry)) out[key] = null;
7140
+ else if (typeof entry === "string" && entry !== "" && entry !== "__camstack_redacted__" && !isSecretFieldView(entry)) out[key] = entry;
7141
+ return Object.keys(out).length > 0 ? out : null;
7142
+ }
7143
+ var RecordIntegrationInstances = class {
7144
+ records;
7145
+ kind;
7146
+ hooks;
7147
+ handle;
7148
+ secretKeys;
7149
+ secretMaps;
7150
+ nameField;
7151
+ switchListeners = /* @__PURE__ */ new Set();
7152
+ constructor(records, kind, hooks) {
7153
+ this.records = records;
7154
+ this.kind = kind;
7155
+ this.hooks = hooks;
7156
+ this.handle = records.kind(kind);
7157
+ this.secretKeys = topLevelSecretKeys(kind.declaration.secretPaths);
7158
+ this.secretMaps = secretMapKeys(kind.declaration.secretPaths);
7159
+ this.nameField = kind.declaration.entry?.nameField ?? "name";
7160
+ }
7161
+ /**
7162
+ * One instance read for the addon's own runtime; null when absent. When the keychain is
7163
+ * locked the row is served WITHOUT its secrets and says so (`secrets: 'unavailable'`), so the
7164
+ * caller can keep what it had instead of reconfiguring with an empty secret (D49).
7165
+ */
7166
+ async readResolved(id) {
7167
+ try {
7168
+ const record = await this.handle.getResolved(id);
7169
+ return record === null ? null : {
7170
+ id,
7171
+ body: record.data,
7172
+ enabled: isInstanceEnabled(record.data),
7173
+ secrets: "resolved"
7174
+ };
7175
+ } catch (err) {
7176
+ if (!(err instanceof RecordSecretUnavailableError)) throw err;
7177
+ const stored = await this.handle.get(id);
7178
+ if (stored === null) return null;
7179
+ return {
7180
+ id,
7181
+ body: Object.fromEntries(Object.entries(stored.data).filter(([key]) => !this.secretKeys.has(key))),
7182
+ enabled: isInstanceEnabled(stored.data),
7183
+ secrets: "unavailable"
7184
+ };
7185
+ }
7186
+ }
7187
+ /** The body of {@link readResolved} alone; a locked keychain yields the row without secrets. */
7188
+ async getResolved(id) {
7189
+ return (await this.readResolved(id))?.body ?? null;
7190
+ }
7191
+ /** Every instance, secrets resolved where the keychain allows; an absent one is skipped. */
7192
+ async listResolved() {
7193
+ const page = await this.handle.query({ limit: INSTANCES_LIST_MAX });
7194
+ const out = [];
7195
+ for (const record of page.records) {
7196
+ const read = await this.readResolved(record.id);
7197
+ if (read !== null) out.push(read);
7198
+ }
7199
+ return out;
7200
+ }
7201
+ viewOf(record) {
7202
+ const shown = secretViewsOf(this.records.owner, this.kind.name, record.id, this.kind.declaration, record.data);
7203
+ const rawName = record.data[this.nameField];
7204
+ const kind = this.hooks.kindOf?.(record.data);
7205
+ const health = this.hooks.health?.(record.data, record.id);
7206
+ return {
7207
+ id: record.id,
7208
+ name: typeof rawName === "string" ? rawName : record.id,
7209
+ configured: this.hooks.configured(record.data),
7210
+ enabled: isInstanceEnabled(record.data),
7211
+ formValues: shown,
7212
+ ...kind !== void 0 ? { kind } : {},
7213
+ ...health?.status !== void 0 ? { status: health.status } : {},
7214
+ ...health?.lastCheckedAt !== void 0 ? { lastCheckedAt: health.lastCheckedAt } : {}
7215
+ };
7216
+ }
7217
+ describe = async () => {
7218
+ const declared = linksOfKind(this.kind);
7219
+ const links = this.hooks.description.links !== void 0 || declared.length === 0 ? {} : { links: declared.map((link) => ({
7220
+ kind: this.kind.name,
7221
+ field: link.field,
7222
+ to: link.to
7223
+ })) };
7224
+ return {
7225
+ ...this.hooks.description,
7226
+ ...links,
7227
+ ...this.hooks.mintId === void 0 ? {} : { mintsId: true }
7228
+ };
7229
+ };
7230
+ instanceFormSchema = async ({ id }) => this.hooks.formSchema(id);
7231
+ listInstances = async () => {
7232
+ return (await this.handle.query({ limit: INSTANCES_LIST_MAX })).records.map((record) => this.viewOf(record));
7233
+ };
7234
+ getInstance = async ({ id }) => {
7235
+ const record = await this.handle.get(id);
7236
+ return record === null ? null : this.viewOf(record);
7237
+ };
7238
+ /** Insert ONE row (`ifRev: 0`: an existing id is refused, never overwritten). */
7239
+ createInstance = async ({ id, name, kind, values }) => {
7240
+ const minted = id ?? this.hooks.mintId?.({
7241
+ name,
7242
+ ...kind !== void 0 ? { kind } : {}
7243
+ });
7244
+ if (minted === void 0) throw new Error("createInstance needs an id: this provider does not mint one");
7245
+ await this.handle.put({
7246
+ id: minted,
7247
+ data: this.hooks.initialBody(name, kind)
7248
+ }, { ifRev: 0 });
7249
+ const patch = this.patchOf(values ?? {});
7250
+ if (Object.keys(patch).length > 0) await this.handle.patch(minted, patch);
7251
+ await this.hooks.onChanged?.(minted, "created");
7252
+ return { id: minted };
7253
+ };
7254
+ updateInstance = async ({ id, name, values }) => {
7255
+ const patch = this.patchOf(values);
7256
+ if (name !== void 0) patch[this.nameField] = name;
7257
+ await this.handle.patch(id, patch);
7258
+ await this.hooks.onChanged?.(id, "updated");
7259
+ return { ok: true };
7260
+ };
7261
+ /** The record patch a form `values` object means: secrets kept, cleared or replaced. */
7262
+ patchOf(values) {
7263
+ const patch = {};
7264
+ for (const [key, value] of Object.entries(values)) {
7265
+ if (value === void 0) continue;
7266
+ if (key === "enabled") continue;
7267
+ if (this.secretMaps.has(key)) {
7268
+ const entries = secretMapPatch(value);
7269
+ if (entries !== null) patch[key] = entries;
7270
+ } else if (!this.secretKeys.has(key)) patch[key] = value;
7271
+ else if (isSecretClear(value)) patch[key] = null;
7272
+ else if (typeof value === "string" && value !== "" && value !== "__camstack_redacted__" && !isSecretFieldView(value)) patch[key] = value;
7273
+ }
7274
+ return patch;
7275
+ }
7276
+ /**
7277
+ * Listen to the switch of ANY instance of this helper (in the process that serves it). The
7278
+ * durable replacement of the `integration.enabled` / `integration.disabled` events (D8): the
7279
+ * addon reconciles from the record, whichever way it flipped.
7280
+ */
7281
+ onSwitched(listener) {
7282
+ this.switchListeners.add(listener);
7283
+ }
7284
+ /**
7285
+ * The ONE writer of the switch (D996, D1014): the record body's `enabled`, for every role. A
7286
+ * configured thing is an instance, so an id with no record yet (a default integration that was
7287
+ * never configured) gets its record from `initialBody` before the switch is written.
7288
+ */
7289
+ setInstanceEnabled = async ({ id, enabled }) => {
7290
+ if (await this.handle.get(id) === null) await this.handle.put({
7291
+ id,
7292
+ data: this.hooks.initialBody(id)
7293
+ }, { ifRev: 0 });
7294
+ await this.handle.patch(id, { [SWITCH_KEY]: enabled });
7295
+ await this.hooks.onChanged?.(id, "enabled");
7296
+ for (const listener of this.switchListeners) await listener(id, enabled);
7297
+ return { ok: true };
7298
+ };
7299
+ /** The row and its keychain refs go together (the records door). */
7300
+ deleteInstance = async ({ id }) => {
7301
+ await this.handle.delete(id);
7302
+ await this.hooks.onChanged?.(id, "deleted");
7303
+ return { ok: true };
7304
+ };
7305
+ testInstance = async (input) => {
7306
+ if (this.hooks.test === void 0) return {
7307
+ success: false,
7308
+ status: "unsupported",
7309
+ error: "This integration declares no connection test"
7310
+ };
7311
+ return this.hooks.test(input);
7312
+ };
7313
+ /** The flat provider object to hand to `registerProvider`. */
7314
+ toProvider() {
7315
+ return {
7316
+ describe: this.describe,
7317
+ instanceFormSchema: this.instanceFormSchema,
7318
+ listInstances: this.listInstances,
7319
+ getInstance: this.getInstance,
7320
+ createInstance: this.createInstance,
7321
+ updateInstance: this.updateInstance,
7322
+ setInstanceEnabled: this.setInstanceEnabled,
7323
+ deleteInstance: this.deleteInstance,
7324
+ testInstance: this.testInstance
7325
+ };
7326
+ }
7327
+ };
7328
+ /**
7329
+ * The one-switch migration (epic #440, step 3). An integration's on/off state used to live in the
7330
+ * registry's `enabled` column for a device addon; it now lives only in the instance record's
7331
+ * body (`enabled`, D1007 superseded). This moves a registry `enabled = false` into the body, once.
7332
+ *
7333
+ * For every registry row of the addon that is OFF:
7334
+ * - no record under its id: the record is created from `initialBody(name)` with `enabled: false`
7335
+ * (a default integration that never had settings, Reolink's among them);
7336
+ * - a record whose body states no `enabled`: `enabled: false` is written into it;
7337
+ * - a record that already states `enabled` (either way) is the newer truth and is left alone.
7338
+ * A row that is ON writes nothing: an absent field reads as on. A rerun finds every off row with
7339
+ * a stated switch and does nothing, so nothing switched off comes back on and nothing switched on
7340
+ * goes off. The registry column itself is neither read afterwards nor written; its drop is step 5.
7341
+ *
7342
+ * It runs in the addon process, BEFORE the addon registers its `integration-instances` provider,
7343
+ * which is also why `integrations.list` still answers the stored column at that point.
7344
+ */
7345
+ function registrySwitchToBodyId(addonId, date) {
7346
+ return `${addonId}/${date}-registry-switch-to-body`;
7347
+ }
7348
+ async function moveRegistrySwitchToBody(ports, spec) {
7349
+ const rows = await ports.listIntegrations(spec.addonId);
7350
+ let created = 0;
7351
+ let switched = 0;
7352
+ let kept = 0;
7353
+ for (const row of rows) {
7354
+ if (row.enabled) continue;
7355
+ const body = await ports.readBody(row.id);
7356
+ if (body === null) {
7357
+ if (await ports.createRecord(row.id, {
7358
+ ...spec.initialBody(row.name),
7359
+ ["enabled"]: false
7360
+ }) === "created") created += 1;
7361
+ else kept += 1;
7362
+ continue;
7363
+ }
7364
+ if ("enabled" in body) {
7365
+ kept += 1;
7366
+ continue;
7367
+ }
7368
+ await ports.switchOff(row.id);
7369
+ switched += 1;
7370
+ }
7371
+ const summary = {
7372
+ integrations: rows.length,
7373
+ off: rows.filter((r) => !r.enabled).length,
7374
+ created,
7375
+ switched,
7376
+ kept
7377
+ };
7378
+ return created === 0 && switched === 0 ? {
7379
+ outcome: "already-done",
7380
+ summary
7381
+ } : {
7382
+ outcome: "done",
7383
+ summary
7384
+ };
7385
+ }
7386
+ /** A migration entry: an addon states its kind and how a new body starts, nothing else. */
7387
+ function registrySwitchToBodyMigration(spec) {
7388
+ return {
7389
+ meta: {
7390
+ id: registrySwitchToBodyId(spec.addonId, spec.date),
7391
+ owner: spec.addonId,
7392
+ level: "addon-state",
7393
+ seq: spec.seq,
7394
+ dependsOn: [],
7395
+ store: "settings",
7396
+ scope: "cluster",
7397
+ onFailure: "block-owner",
7398
+ bodyKind: "procedure",
7399
+ description: "Move the registry enabled flag of every switched-off integration into its instance record body, creating the record when there is none",
7400
+ fixtureMark: "enabled"
7401
+ },
7402
+ procedure: (ports) => moveRegistrySwitchToBody(ports, {
7403
+ addonId: spec.addonId,
7404
+ initialBody: spec.initialBody
7405
+ })
7406
+ };
7407
+ }
7408
+ /** The hub and records sides of {@link registrySwitchToBodyMigration}. */
7409
+ function registrySwitchPortsOver(deps) {
7410
+ const handle = deps.records.kind({
7411
+ name: deps.kind.name,
7412
+ declaration: deps.kind.declaration,
7413
+ schema: record(string(), unknown())
7414
+ });
7415
+ return {
7416
+ listIntegrations: async (addonId) => (await deps.api.list()).filter((i) => i.addonId === addonId).map((i) => ({
7417
+ id: i.id,
7418
+ name: i.name,
7419
+ enabled: i.enabled !== false
7420
+ })),
7421
+ readBody: async (id) => (await handle.get(id))?.data ?? null,
7422
+ createRecord: async (id, body) => {
7423
+ if (await handle.get(id) !== null) return "exists";
7424
+ await handle.put({
7425
+ id,
7426
+ data: body
7427
+ }, { ifRev: 0 });
7428
+ return "created";
7429
+ },
7430
+ switchOff: async (id) => {
7431
+ await handle.patch(id, { [SWITCH_KEY]: false });
7432
+ }
7433
+ };
7434
+ }
7435
+ /**
7436
+ * Build a {@link DurableState} over a single store key. Transport-agnostic:
7437
+ * `read`/`write` are the addon-store or device-store accessors. The whole
7438
+ * validated value round-trips on every read/write — the schema is the
7439
+ * single source of truth for what is persisted, so no hand-listed field
7440
+ * set can drop a key on save.
7441
+ */
7442
+ function createDurableState(deps) {
7443
+ const get = async () => {
7444
+ const raw = (await deps.read())[deps.key];
7445
+ if (raw === void 0) return deps.fallback;
7446
+ const parsed = deps.schema.safeParse(raw);
7447
+ if (!parsed.success) {
7448
+ deps.onParseError?.(deps.key, parsed.error);
7449
+ return deps.fallback;
7450
+ }
7451
+ return parsed.data;
7452
+ };
7453
+ const set = async (next) => {
7454
+ const validated = deps.schema.parse(next);
7455
+ await deps.write({ [deps.key]: validated });
7456
+ };
7457
+ const update = async (fn) => {
7458
+ await set(fn(await get()));
7459
+ };
7460
+ return {
7461
+ get,
7462
+ set,
7463
+ update
7464
+ };
7465
+ }
7466
+ /**
7467
+ * Per-node scoping for the shared addon-settings blob.
7468
+ *
7469
+ * An addon's settings store is hub-central (the `addon-settings` cap is
7470
+ * hub-routed — the hub instance answers for every node), so fields whose
7471
+ * value is a NODE fact (decoder backend, engine pick, probed hardware
7472
+ * capabilities) persist under the node-scoped key `<key>@<nodeId>` inside
7473
+ * the single shared blob. The SCHEMA field key stays bare: the write path
7474
+ * ({@link scopePatch}) maps the bare field onto the target node's scoped
7475
+ * key; the read/hydrate path ({@link projectStore}) maps THIS node's value
7476
+ * back, so UI forms and `resolveConfig` only ever see the bare key.
7477
+ *
7478
+ * Fields opt in via the `perNode: true` schema marker on `ConfigFieldBase`
7479
+ * (`interfaces/config-ui.ts`); `BaseAddon` derives the key set from the
7480
+ * schema and routes reads/writes through these helpers.
7481
+ *
7482
+ * ## No bare-key fallback — deliberate
7483
+ *
7484
+ * {@link readNodeValue} reads the node-scoped key ONLY. A node with no
7485
+ * scoped key resolves to `undefined` so the field's schema `default` wins —
7486
+ * NEVER `store[base]` and never another node's value. A bare legacy key in
7487
+ * the store is invisible to every node, hub included, so one node's
7488
+ * selection can never leak onto another. (This generalizes the
7489
+ * `decoder-backend-keys.ts` semantics — the no-fallback variant — over an
7490
+ * arbitrary set of per-node field keys.)
7491
+ *
7492
+ * Pure functions only — no I/O, no imports beyond the language. This is a
7493
+ * LEAF module: import it via its deep path, never from the root barrel.
7494
+ */
7495
+ /**
7496
+ * Normalize a raw kernel node id to the bare node id used for scoping.
7497
+ * `localNodeId` can carry a `<node>/<addon>` suffix on forked child
7498
+ * processes; per-node settings are per-NODE, so strip the addon segment.
7499
+ * `undefined` / `null` / empty falls back to `'hub'`.
7500
+ */
7501
+ function normalizeNodeId(raw) {
7502
+ if (raw === void 0 || raw === null || raw === "") return "hub";
7503
+ const slashIdx = raw.indexOf("/");
7504
+ if (slashIdx < 0) return raw;
7505
+ const bare = raw.slice(0, slashIdx);
7506
+ return bare === "" ? "hub" : bare;
7507
+ }
7508
+ /** The node-scoped store key for a per-node field: `<base>@<nodeId>`. */
7509
+ function nodeScopedKey(base, nodeId) {
7510
+ return `${base}@${normalizeNodeId(nodeId)}`;
7511
+ }
7512
+ /**
7513
+ * Read a node's value for a per-node field from the raw shared store:
7514
+ * the node-scoped key when present, otherwise `undefined`.
7515
+ * Deliberately NO bare-key fallback (see module doc) — the caller lets the
7516
+ * schema `default` win on `undefined`.
7517
+ */
7518
+ function readNodeValue(store, base, nodeId) {
7519
+ return store[nodeScopedKey(base, nodeId)];
7520
+ }
7521
+ /**
7522
+ * Re-map a UI/settings patch so every bare perNode field persists under the
7523
+ * TARGET node's scoped key; all other keys pass through unchanged. Used on
7524
+ * the write path so a save for one node never clobbers another node's value
7525
+ * (and the bare key is never written). Returns a new object — the input
7526
+ * patch is not mutated.
7527
+ */
7528
+ function scopePatch(patch, perNodeKeys, nodeId) {
7529
+ const out = {};
7530
+ for (const [key, value] of Object.entries(patch)) out[perNodeKeys.has(key) ? nodeScopedKey(key, nodeId) : key] = value;
7531
+ return out;
7532
+ }
7533
+ /**
7534
+ * Project the raw shared store onto the bare schema keys for ONE node so a
7535
+ * UI schema (whose field keys are bare) hydrates from that node's own
7536
+ * values:
7537
+ *
7538
+ * - EVERY `@`-scoped key (any node's) is dropped from the pass-through.
7539
+ * - Every bare perNode key is dropped from the pass-through (a stray bare
7540
+ * legacy key must never hydrate any node — no bare fallback).
7541
+ * - THIS node's effective value ({@link readNodeValue}) is then laid onto
7542
+ * each bare perNode key; when the node has no scoped key the bare key is
7543
+ * left ABSENT so the field's schema `default` wins.
7544
+ *
7545
+ * Returns a new object — the input store is not mutated.
7546
+ */
7547
+ function projectStore(store, perNodeKeys, nodeId) {
7548
+ const out = {};
7549
+ for (const [key, value] of Object.entries(store)) {
7550
+ if (key.includes("@")) continue;
7551
+ if (perNodeKeys.has(key)) continue;
7552
+ out[key] = value;
7553
+ }
7554
+ for (const base of perNodeKeys) {
7555
+ const value = readNodeValue(store, base, nodeId);
7556
+ if (value !== void 0) out[base] = value;
7557
+ }
7558
+ return out;
7559
+ }
7560
+ /** Every scope, in the order the guard and the docs list them. */
7561
+ var SCOPED_SETTINGS_SCOPES = [
7562
+ "cluster",
7563
+ "device-defaults",
7564
+ "node"
7565
+ ];
6980
7566
  /** A write to a secret path could not be stored in the keychain, so it was refused. */
6981
7567
  var KeychainWriteRefusedError = class extends Error {
6982
7568
  fieldPath;
@@ -6987,7 +7573,7 @@ var KeychainWriteRefusedError = class extends Error {
6987
7573
  }
6988
7574
  };
6989
7575
  var KEEP = { kind: "keep" };
6990
- function isRecord$6(value) {
7576
+ function isRecord$4(value) {
6991
7577
  return typeof value === "object" && value !== null && !Array.isArray(value);
6992
7578
  }
6993
7579
  function defaultMintId() {
@@ -7000,7 +7586,7 @@ function collectSecretMarkerRefs(value, out = []) {
7000
7586
  return out;
7001
7587
  }
7002
7588
  if (Array.isArray(value)) for (const item of value) collectSecretMarkerRefs(item, out);
7003
- else if (isRecord$6(value)) for (const child of Object.values(value)) collectSecretMarkerRefs(child, out);
7589
+ else if (isRecord$4(value)) for (const child of Object.values(value)) collectSecretMarkerRefs(child, out);
7004
7590
  return out;
7005
7591
  }
7006
7592
  async function storedValueEquals(secrets, marker, candidate) {
@@ -7055,7 +7641,7 @@ function rowId(row, idKey) {
7055
7641
  return null;
7056
7642
  }
7057
7643
  async function writeArray(ctx, node, incoming, stored, fieldPath) {
7058
- const storedRows = Array.isArray(stored) ? stored.filter(isRecord$6) : [];
7644
+ const storedRows = Array.isArray(stored) ? stored.filter(isRecord$4) : [];
7059
7645
  if (!Array.isArray(incoming)) {
7060
7646
  ctx.deletes.push(...collectSecretMarkerRefs(storedRows));
7061
7647
  return {
@@ -7071,7 +7657,7 @@ async function writeArray(ctx, node, incoming, stored, fieldPath) {
7071
7657
  const kept = /* @__PURE__ */ new Set();
7072
7658
  const rows = [];
7073
7659
  for (const row of incoming) {
7074
- if (!isRecord$6(row)) {
7660
+ if (!isRecord$4(row)) {
7075
7661
  rows.push(row);
7076
7662
  continue;
7077
7663
  }
@@ -7096,7 +7682,7 @@ async function writeNode(ctx, node, incoming, stored, fieldPath) {
7096
7682
  case "leaf": return writeLeaf(ctx, incoming, stored, fieldPath);
7097
7683
  case "array": return writeArray(ctx, node, incoming, stored, fieldPath);
7098
7684
  case "object": {
7099
- if (!isRecord$6(incoming)) {
7685
+ if (!isRecord$4(incoming)) {
7100
7686
  if (incoming === void 0) return KEEP;
7101
7687
  ctx.deletes.push(...collectSecretMarkerRefs(stored));
7102
7688
  return {
@@ -7104,7 +7690,7 @@ async function writeNode(ctx, node, incoming, stored, fieldPath) {
7104
7690
  value: incoming
7105
7691
  };
7106
7692
  }
7107
- const storedObj = isRecord$6(stored) ? stored : {};
7693
+ const storedObj = isRecord$4(stored) ? stored : {};
7108
7694
  return {
7109
7695
  kind: "value",
7110
7696
  value: await writeMembers(ctx, node.children, incoming, storedObj, (m) => `${fieldPath}.${m}`)
@@ -7227,7 +7813,7 @@ async function resolveSecretMarkers(value, secrets, options = {}) {
7227
7813
  value: items
7228
7814
  };
7229
7815
  }
7230
- if (isRecord$6(v)) {
7816
+ if (isRecord$4(v)) {
7231
7817
  const out = {};
7232
7818
  for (const [k, child] of Object.entries(v)) {
7233
7819
  const s = substitute(child);
@@ -7259,7 +7845,7 @@ var SecretOverwriteRefusedError = class extends Error {
7259
7845
  this.name = "SecretOverwriteRefusedError";
7260
7846
  }
7261
7847
  };
7262
- function isRecord$5(value) {
7848
+ function isRecord$3(value) {
7263
7849
  return typeof value === "object" && value !== null && !Array.isArray(value);
7264
7850
  }
7265
7851
  function resolvedValue(pass, marker) {
@@ -7288,7 +7874,7 @@ function inArray(pass, incoming, stored) {
7288
7874
  return incoming.map((item, index) => {
7289
7875
  const twin = stored[index];
7290
7876
  if (isSecretMarker(twin)) return typeof item === "string" && item === resolvedValue(pass, twin) ? twin : item;
7291
- if (isRecord$5(item) && isRecord$5(twin)) return inArrayRow(pass, item, twin);
7877
+ if (isRecord$3(item) && isRecord$3(twin)) return inArrayRow(pass, item, twin);
7292
7878
  if (Array.isArray(item) && Array.isArray(twin)) return inArray(pass, item, twin);
7293
7879
  return item;
7294
7880
  });
@@ -7299,7 +7885,7 @@ function inArrayRow(pass, incoming, stored) {
7299
7885
  const twin = stored[key];
7300
7886
  if (isSecretMarker(twin)) {
7301
7887
  if (typeof value === "string" && value === resolvedValue(pass, twin)) out[key] = twin;
7302
- } else if (isRecord$5(value) && isRecord$5(twin)) out[key] = inArrayRow(pass, value, twin);
7888
+ } else if (isRecord$3(value) && isRecord$3(twin)) out[key] = inArrayRow(pass, value, twin);
7303
7889
  else if (Array.isArray(value) && Array.isArray(twin)) out[key] = inArray(pass, value, twin);
7304
7890
  }
7305
7891
  return out;
@@ -7307,7 +7893,7 @@ function inArrayRow(pass, incoming, stored) {
7307
7893
  async function walk(pass, incoming, stored) {
7308
7894
  if (isSecretMarker(stored)) return atMarker(pass, incoming, stored);
7309
7895
  if (Array.isArray(incoming) && Array.isArray(stored)) return inArray(pass, incoming, stored);
7310
- if (!isRecord$5(incoming) || !isRecord$5(stored)) return incoming;
7896
+ if (!isRecord$3(incoming) || !isRecord$3(stored)) return incoming;
7311
7897
  const out = { ...incoming };
7312
7898
  for (const [key, value] of Object.entries(incoming)) out[key] = await walk(pass, value, stored[key]);
7313
7899
  return out;
@@ -7338,7 +7924,7 @@ async function restoreUnchangedSecrets(input) {
7338
7924
  function isJsonEqual(a, b) {
7339
7925
  if (Object.is(a, b)) return true;
7340
7926
  if (Array.isArray(a)) return Array.isArray(b) && a.length === b.length && a.every((v, i) => isJsonEqual(v, b[i]));
7341
- if (isRecord$5(a) && isRecord$5(b)) {
7927
+ if (isRecord$3(a) && isRecord$3(b)) {
7342
7928
  const keys = Object.keys(a);
7343
7929
  if (keys.length !== Object.keys(b).length) return false;
7344
7930
  return keys.every((k) => Object.prototype.hasOwnProperty.call(b, k) && isJsonEqual(a[k], b[k]));
@@ -7362,7 +7948,7 @@ function markerKeysOf(blob, owner) {
7362
7948
  }
7363
7949
  function nodeFromStored(value, owner) {
7364
7950
  if (isSecretMarker(value)) return isSecretRefOwnedBy(value.$secret, owner) ? { kind: "leaf" } : null;
7365
- if (!isRecord$5(value)) return null;
7951
+ if (!isRecord$3(value)) return null;
7366
7952
  const children = /* @__PURE__ */ new Map();
7367
7953
  for (const [key, child] of Object.entries(value)) {
7368
7954
  const node = nodeFromStored(child, owner);
@@ -7657,6 +8243,28 @@ var SECRET_RETRY_MAX_MS = 5 * 6e4;
7657
8243
  /** Coalesces a burst of `keychain.changed` events into one re-resolve. */
7658
8244
  var SECRET_CHANGE_DEBOUNCE_MS = 250;
7659
8245
  /**
8246
+ * Pair a catalogue with the function that builds its ports FROM THE MIGRATION API. The one
8247
+ * place a catalogue is bound: a migration reaches nothing a ports binder cannot reach
8248
+ * through `api` (guard `check-migration-ports-surface`).
8249
+ */
8250
+ function migrationSet(catalogue, bindPorts) {
8251
+ return { bind: (api) => defineAddonMigrations(catalogue, bindPorts(api)) };
8252
+ }
8253
+ /** A mixed array is a mistake: the other kind would be dropped without a word. */
8254
+ function refuseMixed(form, kept, dropped) {
8255
+ 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`);
8256
+ }
8257
+ function catalogueOf(list) {
8258
+ const kept = list.filter((entry) => !("execute" in entry));
8259
+ refuseMixed("catalogue, (api) => ports", kept.length, list.length - kept.length);
8260
+ return kept;
8261
+ }
8262
+ function boundOf(list) {
8263
+ const kept = list.filter((entry) => "execute" in entry);
8264
+ refuseMixed("bound migrations", kept.length, list.length - kept.length);
8265
+ return kept;
8266
+ }
8267
+ /**
7660
8268
  * Base class for CamStack addons. Eliminates settings boilerplate:
7661
8269
  *
7662
8270
  * - Typed `config` property with automatic resolution from store + defaults
@@ -7823,29 +8431,62 @@ var BaseAddon = class {
7823
8431
  await this.loadScopedSchemas();
7824
8432
  return normalized;
7825
8433
  }
7826
- /**
7827
- * Run this addon's migration catalogue (D987 M3) through the hub's `migrations`
7828
- * engine. Call it at the point of the boot where the data must already have the new
7829
- * shape — `onInitialize`, or `onRestoreDevices` for a migration that needs the
7830
- * restored devices — with the catalogue bound to the addon's ports
7831
- * (`defineAddonMigrations(ADDON_MIGRATIONS, ports)`).
7832
- *
7833
- * An engine that cannot be reached or refuses the catalogue is logged and runs
7834
- * nothing; the boot continues. A migration that FAILED (or was blocked by one)
7835
- * and declares `onFailure: 'block-owner'` makes this throw, so the addon does not
7836
- * serve data whose shape it cannot trust; `tolerate` migrations never do.
7837
- *
7838
- * ALWAYS pass the whole catalogue; a phase that must run only some of it passes
7839
- * `{ only: [ids] }` — never a filtered catalogue (the engine would judge the
7840
- * omitted applied ids "newer").
7841
- */
7842
- async runMigrations(migrations, options) {
8434
+ async runMigrations(first, second, third) {
8435
+ if (typeof second === "function") return this.runMigrationSets([migrationSet(catalogueOf(first), second)], third);
8436
+ return this.runBound(boundOf(first), second);
8437
+ }
8438
+ /**
8439
+ * Several catalogues of ONE owner in one declaration (the engine replaces an owner's
8440
+ * catalogue on every declare, so two calls would each judge the other's ids "newer").
8441
+ * Build each entry with `migrationSet(catalogue, (api) => ports)`.
8442
+ */
8443
+ async runMigrationSets(sets, options) {
8444
+ const api = this.ctx.migrationApi;
8445
+ if (api === void 0) throw new Error(`${this.ctx.id}: the migration API is not available on this context`);
8446
+ return this.runBound(sets.flatMap((set) => set.bind(api)), options);
8447
+ }
8448
+ /**
8449
+ * A device addon states its instance kind once, before its migrations run, and the one-switch
8450
+ * migration (registry `enabled` to the instance body, epic #440 step 3) joins whatever
8451
+ * catalogue the addon runs, in the SAME declaration (the engine replaces an owner's catalogue
8452
+ * on every declare).
8453
+ */
8454
+ declareRegistrySwitch(spec) {
8455
+ this.registrySwitchSpec = spec;
8456
+ }
8457
+ registrySwitchSpec = null;
8458
+ registrySwitchBound() {
8459
+ const spec = this.registrySwitchSpec;
8460
+ if (spec === null) return [];
8461
+ const api = this.ctx.migrationApi;
8462
+ if (api === void 0) {
8463
+ this.ctx.logger.warn("the registry switch migration is skipped: the migration API is not available on this context");
8464
+ return [];
8465
+ }
8466
+ return migrationSet([registrySwitchToBodyMigration({
8467
+ addonId: this.ctx.id,
8468
+ date: "2026-11-06",
8469
+ seq: 1e3,
8470
+ initialBody: spec.initialBody
8471
+ })], (a) => registrySwitchPortsOver({
8472
+ api: integrationRowsApiOver(a),
8473
+ records: switchRecordsOverMigrationApi(a),
8474
+ kind: spec.kind
8475
+ })).bind(api);
8476
+ }
8477
+ async runBound(declared, options) {
8478
+ const added = this.registrySwitchBound();
8479
+ const migrations = [...declared, ...added];
8480
+ const phased = options?.only === void 0 || added.length === 0 ? options : {
8481
+ ...options,
8482
+ only: [...options.only, ...added.map((m) => m.meta.id)]
8483
+ };
7843
8484
  const api = this.ctx.api.migrations;
7844
8485
  const report = await runAddonMigrations({
7845
8486
  declareOwner: (input) => api.declareOwner.mutate(input),
7846
8487
  startMigration: (input) => api.startMigration.mutate(input),
7847
8488
  finishMigration: (input) => api.finishMigration.mutate(input)
7848
- }, migrations, this.ctx.logger, options);
8489
+ }, migrations, this.ctx.logger, phased);
7849
8490
  if (report.blockOwner.length > 0) throw new AddonMigrationsBlockedError(report.blockOwner);
7850
8491
  return report;
7851
8492
  }
@@ -8918,221 +9559,6 @@ function normalizeAddonInitResult(result) {
8918
9559
  if (Array.isArray(result)) return { providers: result };
8919
9560
  return result;
8920
9561
  }
8921
- var PREFIX = "records/";
8922
- /** The base of every records failure. `detail` is the human part of the message. */
8923
- var RecordsError = class extends Error {
8924
- code;
8925
- detail;
8926
- constructor(code, detail) {
8927
- super(`${PREFIX}${code}: ${detail}`);
8928
- this.name = "RecordsError";
8929
- this.code = code;
8930
- this.detail = detail;
8931
- }
8932
- };
8933
- /** The keychain could not store or serve a value: explicit, never an empty string. */
8934
- var RecordSecretUnavailableError = class extends RecordsError {
8935
- constructor(detail) {
8936
- super("secret-unavailable", detail);
8937
- this.name = "RecordSecretUnavailableError";
8938
- }
8939
- };
8940
- var InvalidRecordError = class extends RecordsError {
8941
- constructor(detail) {
8942
- super("invalid-record", detail);
8943
- this.name = "InvalidRecordError";
8944
- }
8945
- };
8946
- /** A kind declaration the engine refuses (non-additive change, bad shape, wrong file). */
8947
- var DeclarationRefusedError = class extends RecordsError {
8948
- constructor(detail) {
8949
- super("declaration-refused", detail);
8950
- this.name = "DeclarationRefusedError";
8951
- }
8952
- };
8953
- /** Limits and names of `ctx.records` (D881). One authority; the engine and the facade read it. */
8954
- /** The files a kind may live in. `history` is device-history's (D850), never records. */
8955
- var RECORD_DATABASES = ["settings", "events"];
8956
- /** The classes of a kind: the `@durable` vocabulary, one authority (replaces the annotation). */
8957
- var RECORD_CLASSES = [
8958
- "config",
8959
- "ledger",
8960
- "audit",
8961
- "mirror",
8962
- "ephemeral"
8963
- ];
8964
- var RECORD_DEFAULT_MAX_DATA_BYTES = 64 * 1024;
8965
- var RECORD_MAX_DATA_BYTES = 1024 * 1024;
8966
- var RECORD_BATCH_MAX = 1e3;
8967
- var RECORD_QUERY_MAX = 1e3;
8968
- /** A kind in the `settings` file may not bound itself above this (D447: high volume is `events`). */
8969
- var RECORD_SETTINGS_MAX_ROWS = 5e4;
8970
- /** The `Column`-free proof prefix: a ref collection for a kind's secret fields. */
8971
- var RECORD_SECRET_COLLECTION_PREFIX = "records:";
8972
- /**
8973
- * Secret fields of a record, addressed the way a Zod schema declares them
8974
- * (`collectZodSecretPaths`): dotted keys, `[]` for "each element of this array"
8975
- * (`auth.accessToken`, `headers[].value`).
8976
- *
8977
- * `{}` after a key means "each value of this map" (`applianceSecrets{}`): the map's own key
8978
- * names the value (`applianceSecrets{fridge}`), so no id key is declared and a body that drops a
8979
- * key releases exactly that key's secret.
8980
- *
8981
- * Array elements are matched by a DECLARED id key (`secretArrayKeys`:
8982
- * `{ headers: 'name' }`), never by index, so reordering a list keeps every
8983
- * keychain ref (the same rule as `arrayItemFieldPath`). The concrete path of a
8984
- * leaf names its element: `headers[X-Api].value`.
8985
- *
8986
- * Pure and browser-safe. Nothing here mutates: every transform returns a new value.
8987
- */
8988
- /** The array prefixes (declared form, e.g. `headers` or `a.headers`) of a path. */
8989
- function parseSecretPath(path) {
8990
- if (path.length === 0) throw new InvalidRecordError("an empty secret path");
8991
- return path.split(".").map((raw) => {
8992
- const each = raw.endsWith("[]");
8993
- const map = raw.endsWith("{}");
8994
- const key = each || map ? raw.slice(0, -2) : raw;
8995
- if (key.length === 0) throw new InvalidRecordError(`secret path "${path}" has an empty segment`);
8996
- return {
8997
- key,
8998
- each,
8999
- map
9000
- };
9001
- });
9002
- }
9003
- /** The declared array prefixes of `path` that need an id key (`headers`, `a.rows`). */
9004
- function secretArrayPrefixes(path) {
9005
- const segments = parseSecretPath(path);
9006
- const prefixes = [];
9007
- const walked = [];
9008
- for (const segment of segments) {
9009
- walked.push(segment.key);
9010
- if (segment.each) prefixes.push(walked.join("."));
9011
- }
9012
- return prefixes;
9013
- }
9014
- function isRecord$4(value) {
9015
- return typeof value === "object" && value !== null && !Array.isArray(value);
9016
- }
9017
- function elementId(element, idKey, declared, prefix) {
9018
- const id = isRecord$4(element) ? element[idKey] : void 0;
9019
- if (typeof id !== "string" || id.length === 0) throw new InvalidRecordError(`"${declared}": every element of "${prefix}" needs a non-empty string "${idKey}" to carry a secret`);
9020
- return id;
9021
- }
9022
- /**
9023
- * Visit every secret leaf PRESENT in `data` (an absent branch has nothing to
9024
- * visit). `replace` returns the new leaf value, or {@link REMOVE_LEAF}; the
9025
- * result is a new body, `data` is untouched.
9026
- */
9027
- var REMOVE_LEAF = Symbol("remove-leaf");
9028
- function mapAt(node, segments, index, walkedKeys, concretePrefix, declared, arrayKeys, visit) {
9029
- if (!isRecord$4(node)) return node;
9030
- const segment = segments[index];
9031
- if (segment === void 0) return node;
9032
- if (!Object.prototype.hasOwnProperty.call(node, segment.key)) return node;
9033
- const child = node[segment.key];
9034
- const keys = [...walkedKeys, segment.key];
9035
- const last = index === segments.length - 1;
9036
- if (segment.each) {
9037
- if (!Array.isArray(child)) return node;
9038
- const prefix = keys.join(".");
9039
- const idKey = arrayKeys[prefix];
9040
- if (idKey === void 0) throw new InvalidRecordError(`"${declared}": no id key declared for the array "${prefix}"`);
9041
- const mapped = child.map((element) => {
9042
- const id = elementId(element, idKey, declared, prefix);
9043
- const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}[${encodeURIComponent(id)}]`;
9044
- return last ? element : mapAt(element, segments, index + 1, keys, concrete, declared, arrayKeys, visit);
9045
- });
9046
- return {
9047
- ...node,
9048
- [segment.key]: mapped
9049
- };
9050
- }
9051
- if (segment.map) {
9052
- if (!isRecord$4(child)) return node;
9053
- const kept = Object.entries(child).map(([mapKey, value]) => {
9054
- const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}{${encodeURIComponent(mapKey)}}`;
9055
- if (!last) return [mapKey, mapAt(value, segments, index + 1, keys, concrete, declared, arrayKeys, visit)];
9056
- return [mapKey, visit({
9057
- declared,
9058
- concrete,
9059
- value
9060
- })];
9061
- }).filter(([, value]) => value !== REMOVE_LEAF);
9062
- return {
9063
- ...node,
9064
- [segment.key]: Object.fromEntries(kept)
9065
- };
9066
- }
9067
- const concrete = `${concretePrefix}${concretePrefix.length > 0 ? "." : ""}${segment.key}`;
9068
- if (last) {
9069
- const replacement = visit({
9070
- declared,
9071
- concrete,
9072
- value: child
9073
- });
9074
- if (replacement === REMOVE_LEAF) return Object.fromEntries(Object.entries(node).filter(([key]) => key !== segment.key));
9075
- return {
9076
- ...node,
9077
- [segment.key]: replacement
9078
- };
9079
- }
9080
- return {
9081
- ...node,
9082
- [segment.key]: mapAt(child, segments, index + 1, keys, concrete, declared, arrayKeys, visit)
9083
- };
9084
- }
9085
- /** Replace/remove every present secret leaf; returns a NEW body. */
9086
- function mapSecretLeaves(data, paths, arrayKeys, visit) {
9087
- let current = data;
9088
- for (const declared of paths) current = mapAt(current, parseSecretPath(declared), 0, [], "", declared, arrayKeys, visit);
9089
- return current;
9090
- }
9091
- /**
9092
- * The keychain collection name a kind's secrets live under. Prefixed so a
9093
- * record's refs never collide with the refs a legacy collection of the same name
9094
- * already holds during an adoption.
9095
- */
9096
- function recordSecretCollection(kind) {
9097
- return `${RECORD_SECRET_COLLECTION_PREFIX}${kind}`;
9098
- }
9099
- /** The ONE ref a row's secret field may carry: the engine and the facade both build it here. */
9100
- function recordSecretRef(owner, kind, rowId, concretePath) {
9101
- return buildSecretRef({
9102
- owner,
9103
- scope: {
9104
- kind: "row",
9105
- collection: recordSecretCollection(kind),
9106
- rowId
9107
- },
9108
- fieldPath: concretePath
9109
- });
9110
- }
9111
- /**
9112
- * A stored secret, as an admin read shows it: a VIEW naming the row's own
9113
- * keychain ref (`set: true`), never the value and no longer the bare redaction
9114
- * token. The form hands it to the secret field, which reveals the value by ref
9115
- * through the audited `keychain-admin.reveal`; sent back untouched it keeps the
9116
- * stored secret (the records door reads a view as KEEP).
9117
- *
9118
- * ONE implementation for the entries service and for every addon that serves
9119
- * its own instances (`RecordIntegrationInstances`).
9120
- */
9121
- function isRecord$3(value) {
9122
- return typeof value === "object" && value !== null && !Array.isArray(value);
9123
- }
9124
- function secretViewsOf(owner, kind, id, decl, data) {
9125
- if (decl.secretPaths.length === 0) return data;
9126
- const shown = mapSecretLeaves(data, decl.secretPaths, decl.secretArrayKeys, (leaf) => {
9127
- if (leaf.value !== "__camstack_redacted__") return leaf.value;
9128
- return {
9129
- $secret: recordSecretRef(owner, kind, id, leaf.concrete),
9130
- v: 1,
9131
- set: true
9132
- };
9133
- });
9134
- return isRecord$3(shown) ? shown : data;
9135
- }
9136
9562
  /**
9137
9563
  * The share-view scope kinds a share-link token can carry. The hub mints
9138
9564
  * and validates them (`ShareViewGrantSchema` builds its
@@ -9386,6 +9812,7 @@ function defineRecordKinds(specs) {
9386
9812
  function entryBody(shape) {
9387
9813
  return object({
9388
9814
  name: string().min(1),
9815
+ enabled: boolean().optional(),
9389
9816
  ...shape
9390
9817
  });
9391
9818
  }
@@ -11225,6 +11652,10 @@ Object.freeze({
11225
11652
  "all",
11226
11653
  "one"
11227
11654
  ]
11655
+ },
11656
+ {
11657
+ "path": "positionUpdatedAt",
11658
+ "valueType": "number"
11228
11659
  }
11229
11660
  ],
11230
11661
  "meter": [{
@@ -11479,22 +11910,6 @@ Object.freeze({
11479
11910
  "path": "pumpRunning",
11480
11911
  "valueType": "boolean"
11481
11912
  },
11482
- {
11483
- "path": "lastDrink",
11484
- "valueType": "number"
11485
- },
11486
- {
11487
- "path": "currentHourDrink",
11488
- "valueType": "number"
11489
- },
11490
- {
11491
- "path": "last24hDrink",
11492
- "valueType": "number"
11493
- },
11494
- {
11495
- "path": "todayDrink",
11496
- "valueType": "number"
11497
- },
11498
11913
  {
11499
11914
  "path": "error",
11500
11915
  "valueType": "string"
@@ -12060,12 +12475,66 @@ Object.freeze({
12060
12475
  }
12061
12476
  ]
12062
12477
  });
12478
+ Object.freeze({
12479
+ "actions": { "run": 363e4 },
12480
+ "audio-analyzer": { "classify": 3e4 },
12481
+ "custom-model-registry": { "importModelBundle": 18e5 },
12482
+ "database-snapshot": { "writeSnapshot": 18e5 },
12483
+ "device-manager": {
12484
+ "migrateDevice": 72e4,
12485
+ "runDiscoverySweep": 12e4
12486
+ },
12487
+ "device-provider": { "reloadDevice": 18e4 },
12488
+ "llm": {
12489
+ "generate": 6e5,
12490
+ "generateVision": 6e5,
12491
+ "testProfile": 6e5
12492
+ },
12493
+ "llm-runtime": {
12494
+ "complete": 6e5,
12495
+ "completeApple": 6e5,
12496
+ "ensureStarted": 6e5,
12497
+ "installModel": 36e5
12498
+ },
12499
+ "local-network": { "scanNetwork": 9e4 },
12500
+ "model-convert": { "convert": 6e5 },
12501
+ "pipeline-analytics": {
12502
+ "addRetrainFrameFromRecording": 6e4,
12503
+ "offerRetrainArchive": 12e4,
12504
+ "proposeRetrainAnnotationsWithLlm": 18e4,
12505
+ "resolveRetrainTrainingCorpus": 6e4,
12506
+ "writeRetrainArchive": 36e5
12507
+ },
12508
+ "server-management": { "applyServerUpdate": 96e4 },
12509
+ "terminal-session": { "pullOutput": 15e3 },
12510
+ "training": {
12511
+ "canaryResult": 12e4,
12512
+ "cancelJob": 12e4,
12513
+ "planJob": 6e4,
12514
+ "registerResult": 18e5,
12515
+ "rollbackCanary": 12e4,
12516
+ "startJob": 12e4,
12517
+ "startJobBatch": 12e4
12518
+ },
12519
+ "training-provider": {
12520
+ "cancelJob": 6e4,
12521
+ "cleanupJob": 3e5,
12522
+ "getJob": 6e4,
12523
+ "listJobEvents": 6e4,
12524
+ "listJobs": 12e4,
12525
+ "offerArtifact": 3e5,
12526
+ "readResult": 3e5,
12527
+ "submitJob": 18e5,
12528
+ "uploadDataset": 72e5,
12529
+ "validateCredentials": 3e4
12530
+ }
12531
+ });
12063
12532
  object({
12064
12533
  code: string().regex(/^[a-z][a-z0-9-]*(\.[a-z0-9-]+)+$/),
12065
12534
  params: record(string(), union([string(), number()])).optional()
12066
12535
  });
12067
12536
  //#endregion
12068
- //#region ../types/dist/composition-pOHbTzRB.mjs
12537
+ //#region ../types/dist/composition-BmBHuDTQ.mjs
12069
12538
  /**
12070
12539
  * The ffmpeg build every CamStack node downloads, per platform and arch.
12071
12540
  *
@@ -12369,7 +12838,7 @@ var table = {
12369
12838
  },
12370
12839
  dwell: {
12371
12840
  minArgs: 3,
12372
- maxArgs: 3,
12841
+ maxArgs: 4,
12373
12842
  apply: () => {
12374
12843
  throw new ExpressionEvalError("dwell: stateful evaluation context is required");
12375
12844
  }
@@ -13412,205 +13881,6 @@ var IntegrationInstanceRefSchema = object({
13412
13881
  ]),
13413
13882
  candidateStableId: string().optional()
13414
13883
  });
13415
- /**
13416
- * `RecordIntegrationInstances` - the `integration-instances` provider over ONE
13417
- * entry kind, so an addon that holds a connection serves it in ~30 lines.
13418
- *
13419
- * Every instance is one record of the addon's own kind, keyed by the
13420
- * `integrations` registry id. The records door does the secret work: a field the
13421
- * kind marks `.meta({ secret: true })` is a keychain marker bound to the row, so
13422
- * deleting the row deletes the secret, and one row's value is never another's.
13423
- *
13424
- * Patch rules for a secret (`updateInstance`): omitted, `''`, the redaction
13425
- * token or the view the form was given KEEP the stored secret;
13426
- * `{ $secretClear: true }` clears it; any other string replaces it.
13427
- */
13428
- /** The most instances one addon lists (an integrations page, not a table). */
13429
- var INSTANCES_LIST_MAX = 1e3;
13430
- /** The keys of the kind's secret fields that sit at the top level of the body. */
13431
- function topLevelSecretKeys(paths) {
13432
- return new Set(paths.filter((p) => !p.includes(".") && !p.includes("[") && !p.includes("{")));
13433
- }
13434
- /** The keys of the kind's top-level MAPS whose values are secrets (`applianceSecrets{}`). */
13435
- function secretMapKeys(paths) {
13436
- return new Set(paths.filter((p) => p.endsWith("{}") && !p.slice(0, -2).match(/[.[{]/)).map((p) => p.slice(0, -2)));
13437
- }
13438
- /**
13439
- * A patch value for a map of secrets: an entry that is omitted, `''`, the redaction token or the
13440
- * view the form was given KEEPS its stored secret (it is left out of the merge); `{ $secretClear }`
13441
- * or `null` removes the entry; any other string replaces it. Null when nothing is left to merge.
13442
- */
13443
- function secretMapPatch(value) {
13444
- if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
13445
- const out = {};
13446
- for (const [key, entry] of Object.entries(value)) if (entry === null || isSecretClear(entry)) out[key] = null;
13447
- else if (typeof entry === "string" && entry !== "" && entry !== "__camstack_redacted__" && !isSecretFieldView(entry)) out[key] = entry;
13448
- return Object.keys(out).length > 0 ? out : null;
13449
- }
13450
- /** Does the kind's body schema carry an `enabled` field? */
13451
- function declaresEnabled(kind) {
13452
- const json = toJSONSchema(kind.schema, {
13453
- unrepresentable: "any",
13454
- io: "input"
13455
- });
13456
- const properties = Reflect.get(json, "properties");
13457
- return typeof properties === "object" && properties !== null && "enabled" in properties;
13458
- }
13459
- var RecordIntegrationInstances = class {
13460
- records;
13461
- kind;
13462
- hooks;
13463
- handle;
13464
- secretKeys;
13465
- secretMaps;
13466
- nameField;
13467
- /** The `integrations` registry owns the on/off switch (`role: devices`); the body has none. */
13468
- registryOwnsEnabled;
13469
- constructor(records, kind, hooks) {
13470
- this.records = records;
13471
- this.kind = kind;
13472
- this.hooks = hooks;
13473
- this.handle = records.kind(kind);
13474
- this.secretKeys = topLevelSecretKeys(kind.declaration.secretPaths);
13475
- this.secretMaps = secretMapKeys(kind.declaration.secretPaths);
13476
- this.nameField = kind.declaration.entry?.nameField ?? "name";
13477
- this.registryOwnsEnabled = hooks.description.role === "devices";
13478
- if (this.registryOwnsEnabled && declaresEnabled(kind)) throw new Error(`kind "${kind.name}": the integrations registry owns the on/off switch of a role "devices" addon, so the body must not declare "enabled"`);
13479
- }
13480
- /**
13481
- * One instance read for the addon's own runtime; null when absent. When the keychain is
13482
- * locked the row is served WITHOUT its secrets and says so (`secrets: 'unavailable'`), so the
13483
- * caller can keep what it had instead of reconfiguring with an empty secret (D49).
13484
- */
13485
- async readResolved(id) {
13486
- try {
13487
- const record = await this.handle.getResolved(id);
13488
- return record === null ? null : {
13489
- id,
13490
- body: record.data,
13491
- enabled: record.data.enabled !== false,
13492
- secrets: "resolved"
13493
- };
13494
- } catch (err) {
13495
- if (!(err instanceof RecordSecretUnavailableError)) throw err;
13496
- const stored = await this.handle.get(id);
13497
- if (stored === null) return null;
13498
- return {
13499
- id,
13500
- body: Object.fromEntries(Object.entries(stored.data).filter(([key]) => !this.secretKeys.has(key))),
13501
- enabled: stored.data.enabled !== false,
13502
- secrets: "unavailable"
13503
- };
13504
- }
13505
- }
13506
- /** The body of {@link readResolved} alone; a locked keychain yields the row without secrets. */
13507
- async getResolved(id) {
13508
- return (await this.readResolved(id))?.body ?? null;
13509
- }
13510
- /** Every instance, secrets resolved where the keychain allows; an absent one is skipped. */
13511
- async listResolved() {
13512
- const page = await this.handle.query({ limit: INSTANCES_LIST_MAX });
13513
- const out = [];
13514
- for (const record of page.records) {
13515
- const read = await this.readResolved(record.id);
13516
- if (read !== null) out.push(read);
13517
- }
13518
- return out;
13519
- }
13520
- viewOf(record) {
13521
- const shown = secretViewsOf(this.records.owner, this.kind.name, record.id, this.kind.declaration, record.data);
13522
- const rawName = record.data[this.nameField];
13523
- const kind = this.hooks.kindOf?.(record.data);
13524
- const health = this.hooks.health?.(record.data, record.id);
13525
- return {
13526
- id: record.id,
13527
- name: typeof rawName === "string" ? rawName : record.id,
13528
- configured: this.hooks.configured(record.data),
13529
- ...this.registryOwnsEnabled ? {} : { enabled: record.data.enabled !== false },
13530
- formValues: shown,
13531
- ...kind !== void 0 ? { kind } : {},
13532
- ...health?.status !== void 0 ? { status: health.status } : {},
13533
- ...health?.lastCheckedAt !== void 0 ? { lastCheckedAt: health.lastCheckedAt } : {}
13534
- };
13535
- }
13536
- describe = async () => this.hooks.mintId === void 0 ? this.hooks.description : {
13537
- ...this.hooks.description,
13538
- mintsId: true
13539
- };
13540
- instanceFormSchema = async ({ id }) => this.hooks.formSchema(id);
13541
- listInstances = async () => {
13542
- return (await this.handle.query({ limit: INSTANCES_LIST_MAX })).records.map((record) => this.viewOf(record));
13543
- };
13544
- getInstance = async ({ id }) => {
13545
- const record = await this.handle.get(id);
13546
- return record === null ? null : this.viewOf(record);
13547
- };
13548
- /** Insert ONE row (`ifRev: 0`: an existing id is refused, never overwritten). */
13549
- createInstance = async ({ id, name, kind }) => {
13550
- const minted = id ?? this.hooks.mintId?.({
13551
- name,
13552
- ...kind !== void 0 ? { kind } : {}
13553
- });
13554
- if (minted === void 0) throw new Error("createInstance needs an id: this provider does not mint one");
13555
- await this.handle.put({
13556
- id: minted,
13557
- data: this.hooks.initialBody(name, kind)
13558
- }, { ifRev: 0 });
13559
- await this.hooks.onChanged?.(minted, "created");
13560
- return { id: minted };
13561
- };
13562
- updateInstance = async ({ id, name, values }) => {
13563
- const patch = {};
13564
- for (const [key, value] of Object.entries(values)) {
13565
- if (value === void 0) continue;
13566
- if (this.secretMaps.has(key)) {
13567
- const entries = secretMapPatch(value);
13568
- if (entries !== null) patch[key] = entries;
13569
- } else if (!this.secretKeys.has(key)) patch[key] = value;
13570
- else if (isSecretClear(value)) patch[key] = null;
13571
- else if (typeof value === "string" && value !== "" && value !== "__camstack_redacted__" && !isSecretFieldView(value)) patch[key] = value;
13572
- }
13573
- if (name !== void 0) patch[this.nameField] = name;
13574
- await this.handle.patch(id, patch);
13575
- await this.hooks.onChanged?.(id, "updated");
13576
- return { ok: true };
13577
- };
13578
- /** The switch lives in the record body; the addon is told so it applies it (D996). */
13579
- setInstanceEnabled = async ({ id, enabled }) => {
13580
- if (this.registryOwnsEnabled) throw new Error(`instance "${id}": the integrations registry owns the on/off switch of this addon; set it with integrations.update`);
13581
- await this.handle.patch(id, { enabled });
13582
- await this.hooks.onChanged?.(id, "enabled");
13583
- return { ok: true };
13584
- };
13585
- /** The row and its keychain refs go together (the records door). */
13586
- deleteInstance = async ({ id }) => {
13587
- await this.handle.delete(id);
13588
- await this.hooks.onChanged?.(id, "deleted");
13589
- return { ok: true };
13590
- };
13591
- testInstance = async (input) => {
13592
- if (this.hooks.test === void 0) return {
13593
- success: false,
13594
- status: "unsupported",
13595
- error: "This integration declares no connection test"
13596
- };
13597
- return this.hooks.test(input);
13598
- };
13599
- /** The flat provider object to hand to `registerProvider`. */
13600
- toProvider() {
13601
- return {
13602
- describe: this.describe,
13603
- instanceFormSchema: this.instanceFormSchema,
13604
- listInstances: this.listInstances,
13605
- getInstance: this.getInstance,
13606
- createInstance: this.createInstance,
13607
- updateInstance: this.updateInstance,
13608
- setInstanceEnabled: this.setInstanceEnabled,
13609
- deleteInstance: this.deleteInstance,
13610
- testInstance: this.testInstance
13611
- };
13612
- }
13613
- };
13614
13884
  var EncodeProfileSchema = object({
13615
13885
  video: object({
13616
13886
  codec: _enum([
@@ -15783,6 +16053,39 @@ var UnstampedEventMediaCountSchema = object({
15783
16053
  /** Sum across both, or `null` when either lane could not be counted. */
15784
16054
  total: number().int().nonnegative().nullable()
15785
16055
  }).nullable();
16056
+ /**
16057
+ * Where the stored event media sits, per storage location: how many media rows
16058
+ * and how many bytes carry each location id, how many carry none (legacy,
16059
+ * resolved to the default `eventMedia` location), and how many name a location
16060
+ * that was not asked about (a deleted or never-listed one).
16061
+ *
16062
+ * One covering-index aggregate per asked location plus the NULL one, and one
16063
+ * unfiltered total that scans the covering index: O(rows), not a walk of the
16064
+ * rows themselves. `unlisted` is total minus the ASKED locations minus NULL, so
16065
+ * it is a deleted location only when every location id was asked.
16066
+ * `null` is "could not be measured", never zero.
16067
+ */
16068
+ var MediaLocationCensusInputSchema = object({ locationIds: array(string().min(1)).max(50).readonly() });
16069
+ var MediaLocationCensusSchema = object({
16070
+ locations: array(object({
16071
+ locationId: string(),
16072
+ rows: number().int().nonnegative(),
16073
+ bytes: number().nonnegative()
16074
+ })).readonly(),
16075
+ unstamped: object({
16076
+ rows: number().int().nonnegative(),
16077
+ bytes: number().nonnegative()
16078
+ }),
16079
+ /** Rows stamped with a location id that was not in the asked list. */
16080
+ unlisted: object({
16081
+ rows: number().int().nonnegative(),
16082
+ bytes: number().nonnegative()
16083
+ }),
16084
+ total: object({
16085
+ rows: number().int().nonnegative(),
16086
+ bytes: number().nonnegative()
16087
+ })
16088
+ });
15786
16089
  var StorageMigrationMediaMoveInputSchema = RelocateMediaInputSchema.extend({ leaseId: string().min(1) });
15787
16090
  /** The independently selectable logical storage classes — every class
15788
16091
  * `storage.listLocationDeclarations` reports, so an operator never meets a
@@ -19762,31 +20065,21 @@ var APPLIANCE_CAP_VOCABULARY = {
19762
20065
  "pet-fountain": {
19763
20066
  scope: "device",
19764
20067
  noun: t("Pet fountain", "Fontana per animali"),
19765
- actions: [
19766
- {
19767
- id: "petFountain.pumpOn",
19768
- method: "setPump",
19769
- label: t("Pump on", "Pompa accesa"),
19770
- phrase: t("Start the pump of {device}", "Avvia la pompa di {device}"),
19771
- icon: "play",
19772
- args: { on: true }
19773
- },
19774
- {
19775
- id: "petFountain.pumpOff",
19776
- method: "setPump",
19777
- label: t("Pump off", "Pompa spenta"),
19778
- phrase: t("Stop the pump of {device}", "Ferma la pompa di {device}"),
19779
- icon: "square",
19780
- args: { on: false }
19781
- },
19782
- {
19783
- id: "petFountain.resetDailyConsumption",
19784
- method: "resetDailyConsumption",
19785
- label: t("Clear daily consumption", "Azzera il consumo giornaliero"),
19786
- phrase: t("Clear the daily water consumption of {device}", "Azzera il consumo giornaliero di acqua di {device}"),
19787
- icon: "rotate-ccw"
19788
- }
19789
- ],
20068
+ actions: [{
20069
+ id: "petFountain.pumpOn",
20070
+ method: "setPump",
20071
+ label: t("Pump on", "Pompa accesa"),
20072
+ phrase: t("Start the pump of {device}", "Avvia la pompa di {device}"),
20073
+ icon: "play",
20074
+ args: { on: true }
20075
+ }, {
20076
+ id: "petFountain.pumpOff",
20077
+ method: "setPump",
20078
+ label: t("Pump off", "Pompa spenta"),
20079
+ phrase: t("Stop the pump of {device}", "Ferma la pompa di {device}"),
20080
+ icon: "square",
20081
+ args: { on: false }
20082
+ }],
19790
20083
  state: [{
19791
20084
  kind: "boolean",
19792
20085
  field: "empty",
@@ -43680,8 +43973,15 @@ var ActionInputSchema = object({
43680
43973
  var ActionRunOriginSchema = object({
43681
43974
  /** The automation block whose step started it. */
43682
43975
  blockId: string().min(1).optional() });
43683
- /** `started` = the caller did not ask to wait. */
43684
- var ActionRunOutcomeSchema = union([AutomationRunOutcomeSchema, literal("started")]);
43976
+ /**
43977
+ * `started` = the caller did not ask to wait. `timed-out` = the run reached the
43978
+ * ceiling (`ACTION_RUN_MAX_MS`) and was stopped; it is never a `ran`.
43979
+ */
43980
+ var ActionRunOutcomeSchema = union([
43981
+ AutomationRunOutcomeSchema,
43982
+ literal("started"),
43983
+ literal("timed-out")
43984
+ ]);
43685
43985
  var ActionRunResultSchema = object({
43686
43986
  runId: string().min(1),
43687
43987
  outcome: ActionRunOutcomeSchema,
@@ -44203,6 +44503,8 @@ method(object({}), array(ActionEntrySchema), { auth: "admin" }), method(object({
44203
44503
  kind: "mutation",
44204
44504
  auth: "admin"
44205
44505
  });
44506
+ /** `timeoutMs` of `actions.run`: a waited call outlives any legal run. */
44507
+ var ACTION_RUN_TIMEOUT_MS = 363e4;
44206
44508
  /**
44207
44509
  * actions — the ONE surface for runnable actions, for the UI and for
44208
44510
  * automations. Provided by the core-blocks builtin on the hub.
@@ -44221,6 +44523,12 @@ method(object({}), array(ActionEntrySchema), { auth: "admin" }), method(object({
44221
44523
  * An admin, and an in-process caller (an automation block), may run any action.
44222
44524
  * Every refusal is typed (`[not-linked] …`) and logged.
44223
44525
  *
44526
+ * **A run lasts at most `ACTION_RUN_MAX_MS` (60 min).** The provider stops a
44527
+ * run that reaches it and answers `timed-out` (never `ran`); an action whose
44528
+ * `wait` steps alone add up to more is refused at create/update
44529
+ * (`[invalid-steps]`). `run` declares a `timeoutMs` above that ceiling, so a
44530
+ * waited call (an automation step) is never cut by the 60 s transport default.
44531
+ *
44224
44532
  * A second `run` of an action that is already running is REFUSED — traced and
44225
44533
  * logged — never queued.
44226
44534
  */
@@ -44247,7 +44555,8 @@ method(object({}), array(ActionEntrySchema), { auth: "admin" }), method(object({
44247
44555
  role: "viewer",
44248
44556
  auth: "protected",
44249
44557
  kind: "mutation",
44250
- caller: "required"
44558
+ caller: "required",
44559
+ timeoutMs: ACTION_RUN_TIMEOUT_MS
44251
44560
  }), method(ActionIdInput, _void(), {
44252
44561
  kind: "mutation",
44253
44562
  auth: "admin"
@@ -46303,52 +46612,7 @@ DeviceType.Light, method(object({
46303
46612
  level: number().min(0).max(100),
46304
46613
  lastChangedAt: number()
46305
46614
  });
46306
- /**
46307
- * `broker` — unified pub/sub broker registry, system-scoped collection.
46308
- *
46309
- * The cap models any kind-tagged message broker the user wants to
46310
- * register and that other addons might consume. The first two kinds
46311
- * are MQTT (mosquitto / aedes embedded / cloud bridge) and
46312
- * Home Assistant (HA WebSocket — subscribe_entities + call_service).
46313
- * Future kinds (Zigbee2MQTT bridge, ZHA, KNX, Telegram, …) plug in
46314
- * the same surface.
46315
- *
46316
- * Why one cap, not one cap per kind:
46317
- * - The integrations page wants a single table of "every broker"
46318
- * across kinds; a unified cap drives it without join logic.
46319
- * - Generic admin operations (add / remove / test / status) are
46320
- * identical across kinds — duplicating them per cap was busywork.
46321
- * - The `network-access` cap precedent: same interface, many
46322
- * `providerKind: 'ingress'` implementations (Tailscale, ngrok, …).
46323
- *
46324
- * What stays kind-specific:
46325
- * - `getBrokerConfig` payload (kind decides whether it returns
46326
- * mqtt URL+creds, HA baseUrl+token, …) — typed as
46327
- * `Record<string, unknown>` at the wire; consumers narrow.
46328
- * - `publish` / `subscribe` / `getState` arguments (target / filter /
46329
- * key) — also typed as `Record<string, unknown>` so each kind can
46330
- * evolve its surface (MQTT topic+qos vs HA entity_id+domain)
46331
- * without rebreaking the cap signature.
46332
- * - `add` `settings` payload (different fields per kind, validated by
46333
- * the kind-specific provider).
46334
- *
46335
- * Bidirectionality:
46336
- * - `publish` is RPC — synchronous result (provider-defined).
46337
- * - `subscribe` returns a subscription handle; the actual message
46338
- * stream flows over the typed event-bus as `broker.message` events
46339
- * keyed by `(brokerId, subscriptionId)`. Consumers filter in their
46340
- * event handler. Subscriptions persist across reconnects — the
46341
- * provider re-subscribes upstream after a transport drop.
46342
- *
46343
- * Why event-bus push (not a subscription RPC stream):
46344
- * - Matches the rest of CamStack's D8 contract — events for telemetry,
46345
- * RPC for loss-is-a-bug. Broker messages are telemetry (a single
46346
- * drop is recoverable via `getState({key})`).
46347
- * - Frees `broker.subscribe` from holding a long-lived RPC channel.
46348
- * - Cross-process: events already route through `$event-bus`; no
46349
- * per-broker wire-shaping needed.
46350
- */
46351
- var BrokerStatusEnum = _enum([
46615
+ _enum([
46352
46616
  "connected",
46353
46617
  "disconnected",
46354
46618
  "connecting",
@@ -46356,141 +46620,12 @@ var BrokerStatusEnum = _enum([
46356
46620
  "unreachable",
46357
46621
  "error"
46358
46622
  ]);
46359
- var BrokerInfoSchema = object({
46360
- /** Stable broker id. Persisted; survives addon restarts. */
46361
- id: string(),
46362
- /** Addon id of the provider that OWNS this broker.
46363
- *
46364
- * The `broker` cap is a system-scoped collection: several addons
46365
- * register a `broker` provider, each owning a DISJOINT set of
46366
- * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
46367
- * `ha_*`). A broker therefore belongs to exactly one addon — like a
46368
- * device belongs to one integration. The admin UI threads this id
46369
- * back as the `{ addonId }` system-collection selector on every
46370
- * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
46371
- * `testConnection`), so the call routes to the OWNING provider
46372
- * instead of defaulting to the first-registered one. */
46373
- addonId: string(),
46374
- /** Human-readable name (operator-chosen at add-time). */
46375
- name: string(),
46376
- /** Provider-defined kind tag — `mqtt` / `home-assistant` / future. */
46377
- kind: string(),
46378
- status: BrokerStatusEnum,
46379
- /** Free-form provider-specific info (HA version, MQTT broker
46380
- * flavour, latency, mTLS active, …). */
46381
- info: record(string(), unknown()),
46382
- /** Ms epoch of the last connection probe / status update. */
46383
- lastCheckedAt: number().nullable(),
46384
- /** Last error message, when `status` indicates failure. */
46385
- error: string().nullable()
46386
- });
46387
46623
  var RegistryStatusSchema = object({
46388
46624
  brokerCount: number().int().nonnegative(),
46389
46625
  connectedCount: number().int().nonnegative()
46390
46626
  });
46391
- /**
46392
- * One entry per `broker` provider — which addon provides which broker
46393
- * kind(s). The unified create picker reads this to know, for each
46394
- * (addonId, kind) pair the operator can pick, where to route the
46395
- * follow-up `getSettingsSchema` / `testSettings` / `add` calls (via the
46396
- * `{ addonId }` selector). A provider only advertises a kind here when
46397
- * it implements `getSettingsSchema` for that kind (so the picker never
46398
- * offers a kind whose creation form can't be rendered).
46399
- */
46400
- var BrokerProviderInfoSchema = object({
46401
- /** Addon id of the `broker` provider this entry describes. */
46402
- addonId: string(),
46403
- /** Broker kinds this provider can create, with a display label. */
46404
- kinds: array(object({
46405
- kind: string(),
46406
- label: string(),
46407
- /**
46408
- * Relative hub path to start an interactive account link (OAuth popup).
46409
- * May include `{{brokerId}}`; admin-ui substitutes and appends `?popup=1`.
46410
- */
46411
- interactiveConnectPath: string().min(1).optional(),
46412
- /** Relative hub path to disconnect the linked account (same popup pattern). */
46413
- interactiveDisconnectPath: string().min(1).optional(),
46414
- /**
46415
- * The kind signs in through a server-held interactive session instead of
46416
- * (or before) typed credentials. `basePath` is a hub-relative route set
46417
- * served by the owning addon (`/addon/<id>/...`) speaking the sign-in
46418
- * contract: `POST {basePath}/start` (body: the form's settings) answers
46419
- * `{ sessionId, loginUrl }`; `GET {basePath}/status?sessionId=` answers
46420
- * `{ phase: 'starting' | 'awaiting-login' | 'captured' | 'error' |
46421
- * 'cancelled', loginUrl, error }`; `POST {basePath}/commit`
46422
- * (`{ sessionId, name, brokerId? }`) stores the credentials on a new or
46423
- * existing broker and answers `{ brokerId }`; `POST {basePath}/cancel`
46424
- * (`{ sessionId }`) drops the session. The dialog that edits a broker
46425
- * offers it as the default path; the fields the session fills are
46426
- * marked `filledBySignIn` in the kind's settings schema.
46427
- *
46428
- * `methods` lists the ways the session can sign in, recommended first:
46429
- * `'device-code'` adds `POST {basePath}/code/start` (body: the form's
46430
- * settings) answering `{ sessionId, userCode, verificationUrl,
46431
- * expiresAt }` — the operator types `userCode` at `verificationUrl` on
46432
- * any device, and the same `status` / `commit` / `cancel` routes follow
46433
- * (status then carries `method`, `userCode`, `verificationUrl`,
46434
- * `expiresAt` and the phases `'awaiting-code'` / `'expired'`);
46435
- * `'proxy'` is the `{basePath}/start` flow above. Absent means
46436
- * `['proxy']`.
46437
- */
46438
- signIn: object({
46439
- basePath: string().min(1),
46440
- methods: array(_enum(["device-code", "proxy"])).optional()
46441
- }).optional()
46442
- }))
46443
- });
46444
- var ListInputSchema = object({
46445
- /** Optional kind filter — `list({kind:'home-assistant'})` returns
46446
- * only HA brokers. Omit to list every broker. */
46447
- kind: string().optional() });
46448
46627
  var GetInputSchema = object({ id: string() });
46449
- var AddInputSchema = object({
46450
- kind: string().min(1),
46451
- name: string().min(1),
46452
- /**
46453
- * ADOPT an existing id instead of minting a new one.
46454
- *
46455
- * Written the day this cost an outage. A broker's id is not a detail: HA
46456
- * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
46457
- * lost from config and re-added as `ha_001` leaves every one of its devices
46458
- * bound to a broker that no longer exists. Re-entering the password under the
46459
- * ORIGINAL id turns a multi-step device migration back into re-entering a
46460
- * password.
46461
- *
46462
- * A provider MUST refuse an id that is already in use — adopting a live
46463
- * broker's id would silently take it over.
46464
- */
46465
- id: string().min(1).optional(),
46466
- /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
46467
- * `{baseUrl,accessToken}`). Validated by the kind-specific provider
46468
- * branch on receipt — invalid shape rejects the add. */
46469
- settings: record(string(), unknown())
46470
- });
46471
- var AddResultSchema = object({ id: string() });
46472
- var RemoveInputSchema = object({ id: string() });
46473
- var TestConnectionResultSchema = discriminatedUnion("ok", [object({
46474
- ok: literal(true),
46475
- latencyMs: number().nonnegative()
46476
- }), object({
46477
- ok: literal(false),
46478
- error: string()
46479
- })]);
46480
46628
  var SettingsRecordSchema$1 = record(string(), unknown());
46481
- var SettingsSchemaInputSchema = object({ kind: string() });
46482
- var TestSettingsInputSchema = object({
46483
- kind: string(),
46484
- settings: SettingsRecordSchema$1
46485
- });
46486
- var TestSettingsResultSchema = discriminatedUnion("ok", [object({
46487
- ok: literal(true),
46488
- latencyMs: number().nonnegative().optional()
46489
- }).strict(), object({
46490
- ok: literal(false),
46491
- error: string()
46492
- })]);
46493
- var SettingsSchemaResultSchema = unknown().nullable();
46494
46629
  var PublishInputSchema = object({
46495
46630
  brokerId: string(),
46496
46631
  /** Kind-specific routing target.
@@ -46534,51 +46669,12 @@ var brokerCapability = {
46534
46669
  kind: "poll"
46535
46670
  },
46536
46671
  methods: {
46537
- list: method(ListInputSchema, array(BrokerInfoSchema)),
46538
- get: method(GetInputSchema, BrokerInfoSchema.nullable()),
46539
- /** Enumerate which addon provides which broker kind(s) for the
46540
- * unified create picker. The auto-mount fans this array across
46541
- * every registered `broker` provider (array-output method), so the
46542
- * picker sees every kind from every provider in one call. */
46543
- listProviders: method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }),
46544
- add: method(AddInputSchema, AddResultSchema, {
46545
- kind: "mutation",
46546
- auth: "admin"
46547
- }),
46548
- remove: method(RemoveInputSchema, _void(), {
46549
- kind: "mutation",
46550
- auth: "admin"
46551
- }),
46552
- testConnection: method(GetInputSchema, TestConnectionResultSchema, {
46553
- kind: "mutation",
46554
- auth: "admin"
46555
- }),
46556
- /** Read the persisted settings record for a broker (kind-specific
46557
- * shape). Admin-only — settings may contain secrets. Returns `null`
46558
- * when the broker id is unknown to the provider (the collection
46559
- * fallback may route a foreign id to the first provider). */
46560
- getSettings: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
46561
- /** Overwrite the persisted settings record. The kind-specific
46562
- * provider validates the shape and applies the change (reconnects
46563
- * if credentials changed). */
46564
- setSettings: method(object({
46565
- id: string(),
46566
- settings: SettingsRecordSchema$1
46567
- }), _void(), {
46568
- kind: "mutation",
46569
- auth: "admin"
46570
- }),
46571
46672
  /** Returns the kind-specific connection config the consumer needs
46572
46673
  * to open its own client (MQTT pattern: `{url, username, password,
46573
46674
  * clientIdPrefix}`). HA providers MAY return the auth envelope
46574
46675
  * but typical HA consumers use `publish` / `subscribe` instead.
46575
46676
  * Returns `null` when the broker id is unknown to the provider. */
46576
46677
  getBrokerConfig: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
46577
- getSettingsSchema: method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }),
46578
- testSettings: method(TestSettingsInputSchema, TestSettingsResultSchema, {
46579
- kind: "mutation",
46580
- auth: "admin"
46581
- }),
46582
46678
  publish: method(PublishInputSchema, unknown(), {
46583
46679
  kind: "mutation",
46584
46680
  auth: "admin"
@@ -48505,6 +48601,9 @@ var MediaPlayerStatusSchema = object({
48505
48601
  /** Repeat mode. Null when no repeat surface.
48506
48602
  * Pair with `DeviceFeature.MediaPlayerRepeat`. */
48507
48603
  repeat: MediaPlayerRepeatSchema.nullable(),
48604
+ /** Ms epoch when `position` was read from the device. Null when the driver
48605
+ * does not say; a UI that advances the position while playing counts from it. */
48606
+ positionUpdatedAt: number().nullable().default(null),
48508
48607
  /** Ms epoch when the slice was last updated. */
48509
48608
  lastChangedAt: number()
48510
48609
  });
@@ -49084,7 +49183,9 @@ var ThresholdTransformDraftSchema = object({
49084
49183
  comparison: _enum(["gte", "lte"]),
49085
49184
  threshold: number(),
49086
49185
  startDwellMs: number().nonnegative(),
49087
- stopDwellMs: number().nonnegative()
49186
+ stopDwellMs: number().nonnegative(),
49187
+ /** Samples a start needs; absent = the compiler's default for the source (2 for running power, else 1). */
49188
+ minSamples: number().int().min(1).optional()
49088
49189
  });
49089
49190
  var CustomizeTransformDraftSchema = discriminatedUnion("kind", [
49090
49191
  IdentityTransformDraftSchema,
@@ -50651,7 +50752,14 @@ var DiscoveryCandidateSchema = object({
50651
50752
  /** `stableId` of the gateway candidate this device was found BEHIND. */
50652
50753
  parentStableId: string().optional(),
50653
50754
  /** `false` = shown for context but not pickable. Default true. */
50654
- selectable: boolean().optional()
50755
+ selectable: boolean().optional(),
50756
+ /**
50757
+ * Keys of the provider's creation form (`getChildCreationSchema`) this candidate
50758
+ * cannot be adopted without — a key for an encrypted board, a pairing code. The
50759
+ * admin asks for exactly these, on the creation form prefilled with
50760
+ * `prefilledConfig`, before it adopts. Absent or empty = adopt as it is.
50761
+ */
50762
+ needsParams: array(string()).optional()
50655
50763
  });
50656
50764
  /**
50657
50765
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -50710,12 +50818,24 @@ method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind:
50710
50818
  }), array(DiscoveryCandidateSchema), {
50711
50819
  kind: "mutation",
50712
50820
  auth: "admin"
50713
- }), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
50821
+ }), method(object({}), CreationSchemaOutputSchema), method(object({
50822
+ /** The integration asked about; a provider whose integrations differ answers per one. */
50823
+ integrationId: string().optional() }), object({ deviceType: _enum(DeviceType).nullable() })), method(object({
50824
+ candidate: DiscoveryCandidateSchema,
50825
+ /** The integration the operator adopts into; a provider with several instances needs it. */
50826
+ integrationId: string().optional()
50827
+ }), DeviceSummarySchema, {
50714
50828
  kind: "mutation",
50715
50829
  auth: "admin"
50716
- }), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
50830
+ }), method(object({}), boolean()), method(object({
50717
50831
  type: _enum(DeviceType),
50718
- config: record(string(), unknown())
50832
+ /** The integration asked about; a provider whose integrations differ answers per one. */
50833
+ integrationId: string().optional()
50834
+ }), CreationSchemaOutputSchema), method(object({
50835
+ type: _enum(DeviceType),
50836
+ config: record(string(), unknown()),
50837
+ /** The integration the operator adds the device to; a provider with several instances needs it. */
50838
+ integrationId: string().optional()
50719
50839
  }), DeviceSummarySchema, {
50720
50840
  kind: "mutation",
50721
50841
  auth: "admin"
@@ -51425,7 +51545,10 @@ method(object({
51425
51545
  auth: "admin"
51426
51546
  }), method(object({
51427
51547
  addonId: string(),
51428
- type: _enum(DeviceType)
51548
+ type: _enum(DeviceType),
51549
+ /** The integration the form is for; a provider whose integrations differ (a hub
51550
+ * integration takes no manual add) answers per integration. Absent = the addon's own. */
51551
+ integrationId: string().optional()
51429
51552
  }), unknown().nullable()), method(object({
51430
51553
  addonId: string(),
51431
51554
  type: _enum(DeviceType),
@@ -51509,7 +51632,11 @@ method(object({
51509
51632
  kind: "mutation",
51510
51633
  auth: "admin",
51511
51634
  timeoutMs: 12e4
51512
- }), method(object({ addonId: string() }), object({ deviceType: _enum(DeviceType).nullable() }), { auth: "admin" }), method(object({ addonId: string() }), unknown(), { auth: "admin" }), method(object({
51635
+ }), method(object({
51636
+ addonId: string(),
51637
+ /** The integration the Add-Device dialog is open on; absent = the addon's own. */
51638
+ integrationId: string().optional()
51639
+ }), object({ deviceType: _enum(DeviceType).nullable() }), { auth: "admin" }), method(object({ addonId: string() }), unknown(), { auth: "admin" }), method(object({
51513
51640
  deviceId: number(),
51514
51641
  key: string(),
51515
51642
  value: unknown()
@@ -54011,6 +54138,7 @@ var IntegrationWithStateSchema = object({
54011
54138
  id: string(),
54012
54139
  addonId: string(),
54013
54140
  name: string(),
54141
+ /** A projection of the instance body's switch (`setInstanceEnabled` is the only writer). */
54014
54142
  enabled: boolean(),
54015
54143
  info: record(string(), unknown()).readonly(),
54016
54144
  /** The discovery candidate this integration was set up from (D984). */
@@ -54023,6 +54151,7 @@ var IntegrationLiteSchema = object({
54023
54151
  id: string(),
54024
54152
  addonId: string(),
54025
54153
  name: string(),
54154
+ /** A projection of the instance body's switch (`setInstanceEnabled` is the only writer). */
54026
54155
  enabled: boolean(),
54027
54156
  info: record(string(), unknown()).readonly(),
54028
54157
  /** The discovery candidate this integration was set up from (D984). */
@@ -54105,7 +54234,7 @@ var TestConnectionStatusEnum = _enum([
54105
54234
  "inconclusive",
54106
54235
  "unsupported"
54107
54236
  ]);
54108
- var TestConnectionResultSchema$1 = object({
54237
+ var TestConnectionResultSchema = object({
54109
54238
  /** True ONLY for `validated`. Never true for a test that did not run. */
54110
54239
  success: boolean(),
54111
54240
  error: string().optional(),
@@ -54122,7 +54251,6 @@ var TestConnectionResultSchema$1 = object({
54122
54251
  var CreateIntegrationInputSchema = object({
54123
54252
  addonId: string(),
54124
54253
  name: string(),
54125
- enabled: boolean().optional(),
54126
54254
  info: record(string(), unknown()).optional(),
54127
54255
  /** `stableId` of the discovery candidate the form was seeded from (D984). */
54128
54256
  originStableId: string().optional(),
@@ -54131,6 +54259,11 @@ var CreateIntegrationInputSchema = object({
54131
54259
  var UpdateIntegrationInputSchema = object({
54132
54260
  id: string(),
54133
54261
  name: string().optional(),
54262
+ /**
54263
+ * Accepted for a FIXED builtin only (`info.fixed`: alarm panel, camera grid, core blocks,
54264
+ * terminal), which keeps no instance record: the registry flag is its one store. Any other
54265
+ * integration is switched through `integration-instances.setInstanceEnabled`.
54266
+ */
54134
54267
  enabled: boolean().optional(),
54135
54268
  info: record(string(), unknown()).optional()
54136
54269
  });
@@ -54150,7 +54283,7 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
54150
54283
  }), method(_void(), array(AvailableIntegrationTypeSchema), { auth: "admin" }), method(object({
54151
54284
  addonId: string(),
54152
54285
  settings: record(string(), unknown())
54153
- }), TestConnectionResultSchema$1, {
54286
+ }), TestConnectionResultSchema, {
54154
54287
  kind: "mutation",
54155
54288
  auth: "admin"
54156
54289
  });
@@ -54177,6 +54310,14 @@ var ConfigSchemaPassthrough$3 = custom();
54177
54310
  var IntegrationInstanceIdSchema = string().min(1);
54178
54311
  var IntegrationInstanceModeSchema = _enum(["single", "multiple"]);
54179
54312
  /**
54313
+ * What the addon's instances ARE for the Integrations page (planning#416). `devices`: each
54314
+ * instance is an integration that brings devices (its record body owns the on/off switch);
54315
+ * `services`: a connection configured from the addon's own function page, never listed on
54316
+ * Integrations.
54317
+ * It is the same vocabulary as the manifest's `integrationRole`, which stays as an override.
54318
+ */
54319
+ var IntegrationInstanceRoleSchema = _enum(["devices", "services"]);
54320
+ /**
54180
54321
  * One kind of instance a provider can create (an adapter, an account type). The sign-in and
54181
54322
  * connect fields follow the `broker` contract (`broker.cap.ts`): `signIn.basePath` is a
54182
54323
  * hub-relative route set speaking the sign-in contract, `connectPath` / `disconnectPath` open an
@@ -54187,6 +54328,22 @@ var IntegrationInstanceKindSchema = object({
54187
54328
  /** Stable id of the kind, sent back as `createInstance.kind`. */
54188
54329
  id: string().min(1),
54189
54330
  label: string().min(1),
54331
+ /**
54332
+ * The function capability this kind is configured for (`broker`, `smtp-provider`,
54333
+ * `device-provider`, ...). A page section is the kinds whose `functionCap` it names (D1013); a
54334
+ * provider with more than one section must name it on every kind.
54335
+ */
54336
+ functionCap: string().min(1),
54337
+ /**
54338
+ * For `functionCap: 'broker'`: the registry kind of the broker an instance of this kind is
54339
+ * (`matter`, `home-assistant`, ...). A caller that needs a broker of one kind (a link field)
54340
+ * finds the instances whose kind names it; absent takes the kind `id`.
54341
+ */
54342
+ brokerKind: string().min(1).optional(),
54343
+ /** What instances of this kind are for the Integrations page; absent takes the provider's `role`. */
54344
+ role: IntegrationInstanceRoleSchema.optional(),
54345
+ /** `testInstance` really probes the remote for this kind; absent takes the provider's `canTest`. */
54346
+ canTest: boolean().optional(),
54190
54347
  /** The kind signs in through a server-held interactive session. */
54191
54348
  signIn: object({
54192
54349
  basePath: string().min(1),
@@ -54197,22 +54354,38 @@ var IntegrationInstanceKindSchema = object({
54197
54354
  /** Relative hub path to disconnect that account. */
54198
54355
  disconnectPath: string().min(1).optional()
54199
54356
  });
54357
+ /**
54358
+ * How devices of an instance are found. `none`: the operator adds them by hand; `lan`: the addon
54359
+ * scans the network before any instance exists (the wizard offers the candidates); `account`: the
54360
+ * devices appear after the instance signs in. `scopedToInstance`: a discovery run belongs to ONE
54361
+ * instance and its candidates are claimed by it.
54362
+ */
54363
+ var IntegrationInstanceDiscoverySchema = object({
54364
+ mode: _enum([
54365
+ "none",
54366
+ "lan",
54367
+ "account"
54368
+ ]),
54369
+ scopedToInstance: boolean()
54370
+ });
54371
+ /**
54372
+ * One link a kind of this provider declares (D1013 rule 5): the body `field` of an instance of
54373
+ * `kind` holds the id of an instance of the function cap `to.cap` (of kind `to.kind`, when set).
54374
+ * The hub indexes these so the target's delete is refused while a link names it.
54375
+ */
54376
+ var IntegrationInstanceLinkSchema = object({
54377
+ kind: string().min(1),
54378
+ field: string().min(1),
54379
+ to: object({
54380
+ cap: string().min(1),
54381
+ kind: string().min(1).optional()
54382
+ })
54383
+ });
54200
54384
  var IntegrationInstanceDescriptionSchema = object({
54201
54385
  /** What the instances are for the Integrations page; see {@link IntegrationInstanceRoleSchema}. */
54202
- role: _enum([
54203
- "devices",
54204
- "services",
54205
- "both"
54206
- ]),
54386
+ role: IntegrationInstanceRoleSchema,
54207
54387
  /** How the devices of an instance are found; absent means `{ mode: 'none' }`. */
54208
- discovery: object({
54209
- mode: _enum([
54210
- "none",
54211
- "lan",
54212
- "account"
54213
- ]),
54214
- scopedToInstance: boolean()
54215
- }).optional(),
54388
+ discovery: IntegrationInstanceDiscoverySchema.optional(),
54216
54389
  /** `single`: at most one instance can exist (Loki). `multiple`: an account each. */
54217
54390
  instanceMode: IntegrationInstanceModeSchema,
54218
54391
  /** What the instance is, for the UI (`destination`, `relay`, `account`). */
@@ -54233,7 +54406,9 @@ var IntegrationInstanceDescriptionSchema = object({
54233
54406
  * `createInstance` without an `id` works: the provider mints it. Absent means the caller must
54234
54407
  * send an id (the UI then mints an opaque one).
54235
54408
  */
54236
- mintsId: boolean().optional()
54409
+ mintsId: boolean().optional(),
54410
+ /** The links to other instances the provider's kinds declare; absent means none. */
54411
+ links: array(IntegrationInstanceLinkSchema).optional()
54237
54412
  });
54238
54413
  var IntegrationInstanceStatusSchema = object({
54239
54414
  state: _enum([
@@ -54253,8 +54428,8 @@ var IntegrationInstanceSchema = object({
54253
54428
  configured: boolean(),
54254
54429
  /**
54255
54430
  * The instance's own on/off switch (D996), derived from the record body (`enabled !== false`,
54256
- * so a row written before the field existed is on). Absent when the `integrations` registry
54257
- * owns the switch (`describe().role` is `devices`): read it from the registry row.
54431
+ * so a row written before the field existed is on). The same for every role, `devices` included:
54432
+ * the body is the one store, `setInstanceEnabled` the one writer (epic #440, step 3).
54258
54433
  */
54259
54434
  enabled: boolean().optional(),
54260
54435
  /** The kind of this instance (one of `describe().kinds[].id`), when the provider has kinds. */
@@ -54278,7 +54453,11 @@ var integrationInstancesCapability = {
54278
54453
  methods: {
54279
54454
  describe: method(_void(), IntegrationInstanceDescriptionSchema, { auth: "admin" }),
54280
54455
  /** The form; `id: null` is the create form. */
54281
- instanceFormSchema: method(object({ id: IntegrationInstanceIdSchema.nullable() }), ConfigSchemaPassthrough$3, { auth: "admin" }),
54456
+ instanceFormSchema: method(object({
54457
+ id: IntegrationInstanceIdSchema.nullable(),
54458
+ /** The kind of the create form (`id: null`) of a provider with several. */
54459
+ kind: string().min(1).optional()
54460
+ }), ConfigSchemaPassthrough$3, { auth: "admin" }),
54282
54461
  listInstances: method(_void(), array(IntegrationInstanceSchema).readonly(), { auth: "admin" }),
54283
54462
  getInstance: method(object({ id: IntegrationInstanceIdSchema }), IntegrationInstanceSchema.nullable(), { auth: "admin" }),
54284
54463
  /**
@@ -54288,7 +54467,9 @@ var integrationInstancesCapability = {
54288
54467
  createInstance: method(object({
54289
54468
  id: IntegrationInstanceIdSchema.optional(),
54290
54469
  name: string().min(1),
54291
- kind: string().min(1).optional()
54470
+ kind: string().min(1).optional(),
54471
+ /** Form values written with the record (a link picked in the create dialog). */
54472
+ values: record(string(), unknown()).optional()
54292
54473
  }), object({ id: string() }), {
54293
54474
  kind: "mutation",
54294
54475
  auth: "admin"
@@ -54298,7 +54479,8 @@ var integrationInstancesCapability = {
54298
54479
  auth: "admin"
54299
54480
  }),
54300
54481
  /**
54301
- * Switch ONE instance on or off. A row control, not a form field: `updateInstance.values` is the
54482
+ * Switch ONE instance on or off, for every role; a missing record is created from the addon's
54483
+ * initial body. A row control, not a form field: `updateInstance.values` is the
54302
54484
  * form patch (D62, D996). An off instance keeps its record and secrets and does no work.
54303
54485
  */
54304
54486
  setInstanceEnabled: method(object({
@@ -54315,8 +54497,10 @@ var integrationInstancesCapability = {
54315
54497
  }),
54316
54498
  testInstance: optionalMethod(object({
54317
54499
  id: IntegrationInstanceIdSchema.nullable(),
54318
- values: record(string(), unknown())
54319
- }), TestConnectionResultSchema$1, {
54500
+ values: record(string(), unknown()),
54501
+ /** The kind to test when `id` is null and the provider has several. */
54502
+ kind: string().min(1).optional()
54503
+ }), TestConnectionResultSchema, {
54320
54504
  kind: "mutation",
54321
54505
  auth: "admin"
54322
54506
  })
@@ -56821,6 +57005,382 @@ var SystemMetricsSchema = object({
56821
57005
  gpuMemoryPercent: number().optional()
56822
57006
  });
56823
57007
  method(_void(), SystemResourceSnapshotSchema), method(_void(), SystemResourceSnapshotSchema.nullable()), method(_void(), SystemMetricsSchema), method(object({ dirPath: string() }), DiskSpaceInfoSchema), method(_void(), MetricsGpuInfoSchema.nullable()), method(_void(), number().nullable()), method(object({ pids: array(number()) }), array(PidResourceStatsSchema)), method(_void(), array(AddonInstanceSchema).readonly()), method(object({ addonId: string() }), PidResourceStatsSchema.nullable()), method(_void(), array(NodeProcessSchema).readonly()), method(GetLoadSeriesInputSchema, NodeLoadSeriesSchema);
57008
+ /**
57009
+ * records - an addon's own facts and documents (D881): ledgers, audit rows,
57010
+ * config documents, mirrors. The third storage API next to `ctx.settings`
57011
+ * (operator intent, D870) and device-history (numbers over time, D850).
57012
+ *
57013
+ * Who the OWNER is: `callerAddonId`, stamped by the transport on a call that
57014
+ * crossed a process and stated by a hub in-process caller (`ctx.records` does
57015
+ * that with the addon id closed over). It is deliberately NOT in any input
57016
+ * schema, so a session can never supply it, and a call with no stamp is refused
57017
+ * `records/no-caller`. A kind is readable and writable only by its owner; other
57018
+ * addons go through the owner's capability.
57019
+ *
57020
+ * Every read is three-way: a row, `null`, or the call THROWS. A failed read is
57021
+ * never `[]`. Secret fields are never on the wire: a read shows the
57022
+ * redaction token, `secretRefs` hands the owner's `ctx.secrets` the refs it
57023
+ * resolves itself, and a write may carry only a keychain marker bound to the
57024
+ * row's own ref.
57025
+ *
57026
+ * Internal: addons reach it through `ctx.records`, never directly.
57027
+ */
57028
+ var RecordIdSchema = string().min(1).max(256);
57029
+ /** A JSON object body. Size is the engine's check (`maxDataBytes`), not a schema's. */
57030
+ var RecordBodySchema = record(string(), unknown());
57031
+ var RecordInputSchema = object({
57032
+ id: RecordIdSchema,
57033
+ /** The device this row belongs to (D865 removal, per-device reads). */
57034
+ deviceId: number().int().optional(),
57035
+ /** The domain clock, ms: observed / created / opened. */
57036
+ at: number().int().optional(),
57037
+ /** Producer-set expiry, ms. Refused on a kind whose retention is an engine TTL. */
57038
+ expiresAt: number().int().optional(),
57039
+ data: RecordBodySchema
57040
+ });
57041
+ var RecordViewSchema = object({
57042
+ id: string(),
57043
+ deviceId: number().int().nullable(),
57044
+ at: number().nullable(),
57045
+ expiresAt: number().nullable(),
57046
+ rev: number().int(),
57047
+ updatedAt: number(),
57048
+ data: RecordBodySchema
57049
+ });
57050
+ /** Declared index fields only. Anything else does not compile, by design. */
57051
+ var RecordWhereSchema = record(RecordFieldPathSchema, union([
57052
+ string(),
57053
+ number(),
57054
+ boolean(),
57055
+ object({ in: array(union([
57056
+ string(),
57057
+ number(),
57058
+ boolean()
57059
+ ])).min(1).max(RECORD_BATCH_MAX) }),
57060
+ object({
57061
+ gt: union([string(), number()]).optional(),
57062
+ gte: union([string(), number()]).optional(),
57063
+ lt: union([string(), number()]).optional(),
57064
+ lte: union([string(), number()]).optional()
57065
+ }).refine((r) => Object.keys(r).length > 0, { message: "name at least one bound" })
57066
+ ]));
57067
+ var KindShape = { kind: RecordKindNameSchema };
57068
+ var TimeWindowShape = {
57069
+ deviceId: number().int().optional(),
57070
+ atFrom: number().optional(),
57071
+ atTo: number().optional()
57072
+ };
57073
+ var RecordQueryInputSchema = object({
57074
+ ...KindShape,
57075
+ ...TimeWindowShape,
57076
+ where: RecordWhereSchema.optional(),
57077
+ /** Without `where`: `at` (default for a kind with a clock) or `id`. With `where` the order is the index's. */
57078
+ order: _enum(["at", "id"]).optional(),
57079
+ desc: boolean().optional(),
57080
+ limit: number().int().min(1).max(RECORD_QUERY_MAX).default(100),
57081
+ /** Opaque keyset cursor from a previous answer. There is no offset. */
57082
+ cursor: string().min(1).optional()
57083
+ });
57084
+ var RecordQueryResultSchema = object({
57085
+ records: array(RecordViewSchema),
57086
+ nextCursor: string().nullable()
57087
+ });
57088
+ var RecordKindStateSchema = _enum([
57089
+ "created",
57090
+ "unchanged",
57091
+ "updated"
57092
+ ]);
57093
+ var DeclareKindResultSchema = object({
57094
+ kindId: number().int(),
57095
+ state: RecordKindStateSchema,
57096
+ createdIndexes: array(string())
57097
+ });
57098
+ /** Keychain refs a write released (the old marker is gone from the row): the owner deletes the values. */
57099
+ var SecretRefsList = array(string());
57100
+ /**
57101
+ * One op of a `transact`: the single-row writes, each on a kind of the caller.
57102
+ * All ops of one call share ONE database file and ONE `transactFamily`.
57103
+ */
57104
+ var RecordTransactOpSchema = discriminatedUnion("op", [
57105
+ object({
57106
+ op: literal("put"),
57107
+ kind: RecordKindNameSchema,
57108
+ record: RecordInputSchema,
57109
+ ifRev: number().int().min(0).optional()
57110
+ }),
57111
+ object({
57112
+ op: literal("patch"),
57113
+ kind: RecordKindNameSchema,
57114
+ id: RecordIdSchema,
57115
+ data: RecordBodySchema,
57116
+ at: number().int().nullable().optional(),
57117
+ expiresAt: number().int().nullable().optional(),
57118
+ ifRev: number().int().min(1).optional()
57119
+ }),
57120
+ object({
57121
+ op: literal("delete"),
57122
+ kind: RecordKindNameSchema,
57123
+ id: RecordIdSchema
57124
+ })
57125
+ ]);
57126
+ var RecordSecretRefsSchema = record(string(), record(string(), string()));
57127
+ method(object({ kinds: record(RecordKindNameSchema, RecordKindDeclarationSchema) }), object({ kinds: record(string(), DeclareKindResultSchema) }), {
57128
+ kind: "mutation",
57129
+ auth: "admin"
57130
+ }), method(object({
57131
+ ...KindShape,
57132
+ id: RecordIdSchema
57133
+ }), RecordViewSchema.nullable(), { auth: "admin" }), method(object({
57134
+ ...KindShape,
57135
+ ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
57136
+ }), array(RecordViewSchema), { auth: "admin" }), method(object({
57137
+ ...KindShape,
57138
+ ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
57139
+ }), RecordSecretRefsSchema, { auth: "admin" }), method(object({
57140
+ ...KindShape,
57141
+ record: RecordInputSchema,
57142
+ /** Compare-and-set. `0` means "the row must not exist". */
57143
+ ifRev: number().int().min(0).optional()
57144
+ }), object({
57145
+ rev: number().int(),
57146
+ releasedRefs: SecretRefsList
57147
+ }), {
57148
+ kind: "mutation",
57149
+ auth: "admin"
57150
+ }), method(object({
57151
+ ...KindShape,
57152
+ records: array(RecordInputSchema).min(1).max(RECORD_BATCH_MAX)
57153
+ }), object({
57154
+ count: number().int(),
57155
+ releasedRefs: SecretRefsList
57156
+ }), {
57157
+ kind: "mutation",
57158
+ auth: "admin"
57159
+ }), method(object({
57160
+ ...KindShape,
57161
+ id: RecordIdSchema,
57162
+ data: RecordBodySchema,
57163
+ at: number().int().nullable().optional(),
57164
+ expiresAt: number().int().nullable().optional(),
57165
+ ifRev: number().int().min(1).optional()
57166
+ }), object({
57167
+ rev: number().int(),
57168
+ releasedRefs: SecretRefsList
57169
+ }), {
57170
+ kind: "mutation",
57171
+ auth: "admin"
57172
+ }), method(object({
57173
+ ...KindShape,
57174
+ id: RecordIdSchema
57175
+ }), object({
57176
+ deleted: boolean(),
57177
+ secretRefs: SecretRefsList
57178
+ }), {
57179
+ kind: "mutation",
57180
+ auth: "admin"
57181
+ }), method(object({
57182
+ ...KindShape,
57183
+ ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
57184
+ }), object({
57185
+ deleted: number().int(),
57186
+ secretRefs: SecretRefsList
57187
+ }), {
57188
+ kind: "mutation",
57189
+ auth: "admin"
57190
+ }), method(RecordQueryInputSchema, RecordQueryResultSchema, { auth: "admin" }), method(object({
57191
+ ...KindShape,
57192
+ ...TimeWindowShape,
57193
+ where: RecordWhereSchema.optional()
57194
+ }), object({ count: number().int() }), { auth: "admin" }), method(object({
57195
+ ...KindShape,
57196
+ deviceId: number().int().optional(),
57197
+ from: number(),
57198
+ to: number(),
57199
+ bucketMs: number().int().positive()
57200
+ }), object({ buckets: array(object({
57201
+ bucketStart: number(),
57202
+ count: number().int()
57203
+ })) }), { auth: "admin" }), method(object({
57204
+ ...KindShape,
57205
+ op: _enum([
57206
+ "sum",
57207
+ "min",
57208
+ "max",
57209
+ "count"
57210
+ ]),
57211
+ field: RecordFieldPathSchema.optional(),
57212
+ groupBy: RecordFieldPathSchema.optional(),
57213
+ where: RecordWhereSchema.optional()
57214
+ }), object({ rows: array(object({
57215
+ group: union([
57216
+ string(),
57217
+ number(),
57218
+ _null()
57219
+ ]),
57220
+ value: number().nullable()
57221
+ })) }), { auth: "admin" }), method(object({
57222
+ ...KindShape,
57223
+ deviceId: number().int().optional(),
57224
+ atBefore: number().optional(),
57225
+ where: RecordWhereSchema.optional()
57226
+ }), object({
57227
+ deleted: number().int(),
57228
+ secretRefs: SecretRefsList
57229
+ }), {
57230
+ kind: "mutation",
57231
+ auth: "admin"
57232
+ }), method(object({ ops: array(RecordTransactOpSchema).min(1).max(RECORD_BATCH_MAX) }), object({
57233
+ results: array(object({
57234
+ rev: number().int().nullable(),
57235
+ deleted: boolean()
57236
+ })),
57237
+ releasedRefs: SecretRefsList,
57238
+ secretRefs: SecretRefsList
57239
+ }), {
57240
+ kind: "mutation",
57241
+ auth: "admin"
57242
+ }), method(object({
57243
+ deviceId: number().int(),
57244
+ kinds: array(RecordKindNameSchema).optional()
57245
+ }), object({
57246
+ deleted: record(string(), number().int()),
57247
+ secretRefs: SecretRefsList
57248
+ }), {
57249
+ kind: "mutation",
57250
+ auth: "admin"
57251
+ }), method(object(KindShape), object({ id: number().int() }), {
57252
+ kind: "mutation",
57253
+ auth: "admin"
57254
+ });
57255
+ /**
57256
+ * The wire shapes of the migration API, version 1 (planning#447).
57257
+ *
57258
+ * A migration is code that outlives the server it was written against: an addon at
57259
+ * version 1.4 that is brought to 1.9 in one jump still runs its 1.4 catalogue, against
57260
+ * the 1.9 kernel. The only thing that code may call is {@link MigrationApiV1}; this file
57261
+ * is the contract of every method of it, one Zod pair per method.
57262
+ *
57263
+ * APPEND-ONLY. A released method is never removed and never changes meaning: an input
57264
+ * field is never removed or made required, an enum never narrows, an output field is
57265
+ * never removed. A change that cannot be expressed that way is `migration-api.v2`, next
57266
+ * to this file. `scripts/check-migration-api-append-only.ts` holds the line against
57267
+ * `scripts/migration-api-contract.json`, and every `behaviours` id below must have an
57268
+ * `it('<id>'` in `migration-api.contract.spec.ts`.
57269
+ */
57270
+ var Rows = record(string(), unknown());
57271
+ var IntegrationId = object({ integrationId: string().min(1) });
57272
+ var RecordId = object({ id: string().min(1) });
57273
+ var RecordRowSchema = object({
57274
+ id: string(),
57275
+ deviceId: number().int().nullable(),
57276
+ at: number().nullable(),
57277
+ expiresAt: number().nullable(),
57278
+ rev: number().int(),
57279
+ updatedAt: number(),
57280
+ data: Rows
57281
+ });
57282
+ var RecordPutShape = {
57283
+ id: string().min(1),
57284
+ deviceId: number().int().optional(),
57285
+ at: number().int().optional(),
57286
+ expiresAt: number().int().optional(),
57287
+ data: Rows
57288
+ };
57289
+ var MigrationIntegrationSchema = object({
57290
+ id: string(),
57291
+ addonId: string(),
57292
+ name: string(),
57293
+ info: Rows,
57294
+ /** The registry's stored switch: read by the one-switch migration, nothing else. */
57295
+ enabled: boolean().optional(),
57296
+ originStableId: string().optional()
57297
+ });
57298
+ var MigrationDeviceSchema = object({
57299
+ id: number().int(),
57300
+ integrationId: string().nullable().optional(),
57301
+ /** The owning device; null or absent for a root. */
57302
+ parentDeviceId: number().int().nullable().optional(),
57303
+ type: string().optional(),
57304
+ name: string().optional(),
57305
+ stableId: string().optional()
57306
+ });
57307
+ /** What the engine says about the migration API it serves: lets an addon notice an older server. */
57308
+ var MigrationApiInfoSchema = object({
57309
+ version: number().int(),
57310
+ methods: array(string())
57311
+ });
57312
+ object({
57313
+ message: string(),
57314
+ meta: Rows.optional(),
57315
+ /** Grouping tags of the line; a line about a device carries `{ deviceId }`. */
57316
+ tags: object({ deviceId: number().int().optional() }).optional()
57317
+ });
57318
+ object({
57319
+ owner: string().min(1),
57320
+ metas: array(MigrationMetaSchema)
57321
+ }), object({
57322
+ bootId: string(),
57323
+ snapshot: _enum([
57324
+ "none",
57325
+ "written",
57326
+ "refused"
57327
+ ]),
57328
+ steps: array(MigrationOwnerStepSchema),
57329
+ /** The migration API the engine serves; absent from a server that predates it. */
57330
+ migrationApi: MigrationApiInfoSchema.optional()
57331
+ }), object({ id: string().min(1) }), object({
57332
+ proceed: boolean(),
57333
+ state: union([literal("pending"), MigrationStatusSchema]),
57334
+ reason: string().nullable()
57335
+ }), object({
57336
+ id: string().min(1),
57337
+ result: MigrationOwnerResultSchema
57338
+ }), object({
57339
+ status: MigrationStatusSchema,
57340
+ error: string().nullable(),
57341
+ rowsChanged: number().int().nonnegative()
57342
+ }), object({
57343
+ rows: number().int().nonnegative(),
57344
+ secrets: number().int().nonnegative()
57345
+ }), IntegrationId.extend({ patch: Rows }), object({ written: boolean() }), IntegrationId.extend({ keys: array(string().min(1)) }), object({
57346
+ present: boolean(),
57347
+ removed: Rows
57348
+ }), RecordRowSchema.nullable(), RecordRowSchema.nullable(), object({
57349
+ ...RecordPutShape,
57350
+ ifRev: number().int().optional()
57351
+ }), object({ rev: number().int() }), object({ records: array(object(RecordPutShape)) }), object({ count: number().int().nonnegative() }), RecordId.extend({
57352
+ partial: Rows,
57353
+ ifRev: number().int().optional()
57354
+ }), object({ rev: number().int() }), object({ deleted: boolean() }), object({
57355
+ deviceId: number().int().optional(),
57356
+ atFrom: number().optional(),
57357
+ atTo: number().optional(),
57358
+ where: RecordWhereSchema.optional(),
57359
+ limit: number().int().min(1).optional(),
57360
+ cursor: string().min(1).optional(),
57361
+ resolveSecrets: boolean().optional()
57362
+ }), object({
57363
+ records: array(RecordRowSchema),
57364
+ nextCursor: string().nullable()
57365
+ }), object({
57366
+ deviceId: number().int().optional(),
57367
+ atBefore: number().optional(),
57368
+ where: RecordWhereSchema.optional()
57369
+ }), object({ count: number().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({
57370
+ ref: string().min(1),
57371
+ value: string()
57372
+ }), object({}), object({ refs: array(string()) }), object({ deleted: number().int().nonnegative() }), object({}), object({ integrations: array(MigrationIntegrationSchema) }), object({ id: string().min(1) }), MigrationIntegrationSchema.nullable(), object({
57373
+ addonId: string().min(1),
57374
+ integrationId: string().min(1).optional()
57375
+ }), object({ devices: array(MigrationDeviceSchema) }), object({ deviceId: number().int() }), object({
57376
+ id: number().int(),
57377
+ integrationId: string().nullable(),
57378
+ /** Whether the device carries any stored config; its content is not offered. */
57379
+ hasConfig: boolean()
57380
+ }).nullable(), object({
57381
+ deviceId: number().int(),
57382
+ integrationId: string().min(1)
57383
+ }), object({}), object({}), object({ nodeId: string() }), object({}), object({});
56824
57384
  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({
56825
57385
  id: string().min(1),
56826
57386
  error: string().min(1)
@@ -56850,7 +57410,9 @@ method(object({}), object({ entries: array(MigrationLedgerEntrySchema) }), { aut
56850
57410
  "written",
56851
57411
  "refused"
56852
57412
  ]),
56853
- steps: array(MigrationOwnerStepSchema)
57413
+ steps: array(MigrationOwnerStepSchema),
57414
+ /** The migration API this engine serves (additive: a server that predates it omits it). */
57415
+ migrationApi: MigrationApiInfoSchema.optional()
56854
57416
  }), {
56855
57417
  kind: "mutation",
56856
57418
  auth: "admin"
@@ -59517,7 +60079,10 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
59517
60079
  }), method(RelocateMediaInputSchema, object({ jobId: string() }), {
59518
60080
  kind: "mutation",
59519
60081
  auth: "admin"
59520
- }), method(object({}), UnstampedEventMediaCountSchema, { auth: "admin" }), method(RelocatableMediaCountInputSchema, RelocatableMediaCountSchema, {
60082
+ }), method(object({}), UnstampedEventMediaCountSchema, { auth: "admin" }), method(MediaLocationCensusInputSchema, MediaLocationCensusSchema, {
60083
+ kind: "query",
60084
+ auth: "admin"
60085
+ }), method(RelocatableMediaCountInputSchema, RelocatableMediaCountSchema, {
59521
60086
  kind: "query",
59522
60087
  auth: "admin"
59523
60088
  }), method(object({}), array(RelocateJobSchema).readonly(), {
@@ -61924,7 +62489,7 @@ var BrokerStatusSchema$1 = _enum([
61924
62489
  "unreachable",
61925
62490
  "tls-error"
61926
62491
  ]);
61927
- var BrokerInfoSchema$1 = object({
62492
+ var BrokerInfoSchema = object({
61928
62493
  id: string(),
61929
62494
  name: string(),
61930
62495
  url: string(),
@@ -61997,7 +62562,7 @@ var mqttBrokerCapability = {
61997
62562
  kind: "poll"
61998
62563
  },
61999
62564
  methods: {
62000
- listBrokers: method(_void(), array(BrokerInfoSchema$1)),
62565
+ listBrokers: method(_void(), array(BrokerInfoSchema)),
62001
62566
  getBrokerConfig: method(IdInputSchema, BrokerConnectionDetailsSchema, { auth: "admin" }),
62002
62567
  addBroker: method(AddBrokerInputSchema, AddBrokerResultSchema, {
62003
62568
  kind: "mutation",
@@ -66265,14 +66830,6 @@ object({
66265
66830
  empty: boolean().nullable(),
66266
66831
  /** True while the pump is running. Null while unknown. */
66267
66832
  pumpRunning: boolean().nullable(),
66268
- /** Water drunk in the last drink, in litres. */
66269
- lastDrink: number().min(0).nullable().meta(readingMeta("volume")),
66270
- /** Water drunk in the current hour, in litres. */
66271
- currentHourDrink: number().min(0).nullable().meta(readingMeta("volume")),
66272
- /** Water drunk over the last 24 hours (rolling), in litres. */
66273
- last24hDrink: number().min(0).nullable().meta(readingMeta("volume")),
66274
- /** Water drunk since local midnight, in litres. Null when the fountain has no such sensor. */
66275
- todayDrink: number().min(0).nullable().meta(readingMeta("volume")),
66276
66833
  /** Decoded fault message. Null means healthy; a non-null string is an active fault. */
66277
66834
  error: string().nullable(),
66278
66835
  /** Ms epoch when the slice was last refreshed. */
@@ -66285,9 +66842,6 @@ DeviceType.PetFountain, method(object({
66285
66842
  }), _void(), {
66286
66843
  kind: "mutation",
66287
66844
  auth: "protected"
66288
- }), method(object({ deviceId: DeviceIdSchema$1 }), _void(), {
66289
- kind: "mutation",
66290
- auth: "protected"
66291
66845
  });
66292
66846
  /**
66293
66847
  * The wire shapes of the Auto tune plan and apply (D745), used by the
@@ -68324,253 +68878,6 @@ DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, DeviceType.Gateway, met
68324
68878
  auth: "admin"
68325
68879
  });
68326
68880
  /**
68327
- * records - an addon's own facts and documents (D881): ledgers, audit rows,
68328
- * config documents, mirrors. The third storage API next to `ctx.settings`
68329
- * (operator intent, D870) and device-history (numbers over time, D850).
68330
- *
68331
- * Who the OWNER is: `callerAddonId`, stamped by the transport on a call that
68332
- * crossed a process and stated by a hub in-process caller (`ctx.records` does
68333
- * that with the addon id closed over). It is deliberately NOT in any input
68334
- * schema, so a session can never supply it, and a call with no stamp is refused
68335
- * `records/no-caller`. A kind is readable and writable only by its owner; other
68336
- * addons go through the owner's capability.
68337
- *
68338
- * Every read is three-way: a row, `null`, or the call THROWS. A failed read is
68339
- * never `[]`. Secret fields are never on the wire: a read shows the
68340
- * redaction token, `secretRefs` hands the owner's `ctx.secrets` the refs it
68341
- * resolves itself, and a write may carry only a keychain marker bound to the
68342
- * row's own ref.
68343
- *
68344
- * Internal: addons reach it through `ctx.records`, never directly.
68345
- */
68346
- var RecordIdSchema = string().min(1).max(256);
68347
- /** A JSON object body. Size is the engine's check (`maxDataBytes`), not a schema's. */
68348
- var RecordBodySchema = record(string(), unknown());
68349
- var RecordInputSchema = object({
68350
- id: RecordIdSchema,
68351
- /** The device this row belongs to (D865 removal, per-device reads). */
68352
- deviceId: number().int().optional(),
68353
- /** The domain clock, ms: observed / created / opened. */
68354
- at: number().int().optional(),
68355
- /** Producer-set expiry, ms. Refused on a kind whose retention is an engine TTL. */
68356
- expiresAt: number().int().optional(),
68357
- data: RecordBodySchema
68358
- });
68359
- var RecordViewSchema = object({
68360
- id: string(),
68361
- deviceId: number().int().nullable(),
68362
- at: number().nullable(),
68363
- expiresAt: number().nullable(),
68364
- rev: number().int(),
68365
- updatedAt: number(),
68366
- data: RecordBodySchema
68367
- });
68368
- /** Declared index fields only. Anything else does not compile, by design. */
68369
- var RecordWhereSchema = record(RecordFieldPathSchema, union([
68370
- string(),
68371
- number(),
68372
- boolean(),
68373
- object({ in: array(union([
68374
- string(),
68375
- number(),
68376
- boolean()
68377
- ])).min(1).max(RECORD_BATCH_MAX) }),
68378
- object({
68379
- gt: union([string(), number()]).optional(),
68380
- gte: union([string(), number()]).optional(),
68381
- lt: union([string(), number()]).optional(),
68382
- lte: union([string(), number()]).optional()
68383
- }).refine((r) => Object.keys(r).length > 0, { message: "name at least one bound" })
68384
- ]));
68385
- var KindShape = { kind: RecordKindNameSchema };
68386
- var TimeWindowShape = {
68387
- deviceId: number().int().optional(),
68388
- atFrom: number().optional(),
68389
- atTo: number().optional()
68390
- };
68391
- var RecordQueryInputSchema = object({
68392
- ...KindShape,
68393
- ...TimeWindowShape,
68394
- where: RecordWhereSchema.optional(),
68395
- /** Without `where`: `at` (default for a kind with a clock) or `id`. With `where` the order is the index's. */
68396
- order: _enum(["at", "id"]).optional(),
68397
- desc: boolean().optional(),
68398
- limit: number().int().min(1).max(RECORD_QUERY_MAX).default(100),
68399
- /** Opaque keyset cursor from a previous answer. There is no offset. */
68400
- cursor: string().min(1).optional()
68401
- });
68402
- var RecordQueryResultSchema = object({
68403
- records: array(RecordViewSchema),
68404
- nextCursor: string().nullable()
68405
- });
68406
- var RecordKindStateSchema = _enum([
68407
- "created",
68408
- "unchanged",
68409
- "updated"
68410
- ]);
68411
- var DeclareKindResultSchema = object({
68412
- kindId: number().int(),
68413
- state: RecordKindStateSchema,
68414
- createdIndexes: array(string())
68415
- });
68416
- /** Keychain refs a write released (the old marker is gone from the row): the owner deletes the values. */
68417
- var SecretRefsList = array(string());
68418
- /**
68419
- * One op of a `transact`: the single-row writes, each on a kind of the caller.
68420
- * All ops of one call share ONE database file and ONE `transactFamily`.
68421
- */
68422
- var RecordTransactOpSchema = discriminatedUnion("op", [
68423
- object({
68424
- op: literal("put"),
68425
- kind: RecordKindNameSchema,
68426
- record: RecordInputSchema,
68427
- ifRev: number().int().min(0).optional()
68428
- }),
68429
- object({
68430
- op: literal("patch"),
68431
- kind: RecordKindNameSchema,
68432
- id: RecordIdSchema,
68433
- data: RecordBodySchema,
68434
- at: number().int().nullable().optional(),
68435
- expiresAt: number().int().nullable().optional(),
68436
- ifRev: number().int().min(1).optional()
68437
- }),
68438
- object({
68439
- op: literal("delete"),
68440
- kind: RecordKindNameSchema,
68441
- id: RecordIdSchema
68442
- })
68443
- ]);
68444
- var RecordSecretRefsSchema = record(string(), record(string(), string()));
68445
- method(object({ kinds: record(RecordKindNameSchema, RecordKindDeclarationSchema) }), object({ kinds: record(string(), DeclareKindResultSchema) }), {
68446
- kind: "mutation",
68447
- auth: "admin"
68448
- }), method(object({
68449
- ...KindShape,
68450
- id: RecordIdSchema
68451
- }), RecordViewSchema.nullable(), { auth: "admin" }), method(object({
68452
- ...KindShape,
68453
- ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
68454
- }), array(RecordViewSchema), { auth: "admin" }), method(object({
68455
- ...KindShape,
68456
- ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
68457
- }), RecordSecretRefsSchema, { auth: "admin" }), method(object({
68458
- ...KindShape,
68459
- record: RecordInputSchema,
68460
- /** Compare-and-set. `0` means "the row must not exist". */
68461
- ifRev: number().int().min(0).optional()
68462
- }), object({
68463
- rev: number().int(),
68464
- releasedRefs: SecretRefsList
68465
- }), {
68466
- kind: "mutation",
68467
- auth: "admin"
68468
- }), method(object({
68469
- ...KindShape,
68470
- records: array(RecordInputSchema).min(1).max(RECORD_BATCH_MAX)
68471
- }), object({
68472
- count: number().int(),
68473
- releasedRefs: SecretRefsList
68474
- }), {
68475
- kind: "mutation",
68476
- auth: "admin"
68477
- }), method(object({
68478
- ...KindShape,
68479
- id: RecordIdSchema,
68480
- data: RecordBodySchema,
68481
- at: number().int().nullable().optional(),
68482
- expiresAt: number().int().nullable().optional(),
68483
- ifRev: number().int().min(1).optional()
68484
- }), object({
68485
- rev: number().int(),
68486
- releasedRefs: SecretRefsList
68487
- }), {
68488
- kind: "mutation",
68489
- auth: "admin"
68490
- }), method(object({
68491
- ...KindShape,
68492
- id: RecordIdSchema
68493
- }), object({
68494
- deleted: boolean(),
68495
- secretRefs: SecretRefsList
68496
- }), {
68497
- kind: "mutation",
68498
- auth: "admin"
68499
- }), method(object({
68500
- ...KindShape,
68501
- ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
68502
- }), object({
68503
- deleted: number().int(),
68504
- secretRefs: SecretRefsList
68505
- }), {
68506
- kind: "mutation",
68507
- auth: "admin"
68508
- }), method(RecordQueryInputSchema, RecordQueryResultSchema, { auth: "admin" }), method(object({
68509
- ...KindShape,
68510
- ...TimeWindowShape,
68511
- where: RecordWhereSchema.optional()
68512
- }), object({ count: number().int() }), { auth: "admin" }), method(object({
68513
- ...KindShape,
68514
- deviceId: number().int().optional(),
68515
- from: number(),
68516
- to: number(),
68517
- bucketMs: number().int().positive()
68518
- }), object({ buckets: array(object({
68519
- bucketStart: number(),
68520
- count: number().int()
68521
- })) }), { auth: "admin" }), method(object({
68522
- ...KindShape,
68523
- op: _enum([
68524
- "sum",
68525
- "min",
68526
- "max",
68527
- "count"
68528
- ]),
68529
- field: RecordFieldPathSchema.optional(),
68530
- groupBy: RecordFieldPathSchema.optional(),
68531
- where: RecordWhereSchema.optional()
68532
- }), object({ rows: array(object({
68533
- group: union([
68534
- string(),
68535
- number(),
68536
- _null()
68537
- ]),
68538
- value: number().nullable()
68539
- })) }), { auth: "admin" }), method(object({
68540
- ...KindShape,
68541
- deviceId: number().int().optional(),
68542
- atBefore: number().optional(),
68543
- where: RecordWhereSchema.optional()
68544
- }), object({
68545
- deleted: number().int(),
68546
- secretRefs: SecretRefsList
68547
- }), {
68548
- kind: "mutation",
68549
- auth: "admin"
68550
- }), method(object({ ops: array(RecordTransactOpSchema).min(1).max(RECORD_BATCH_MAX) }), object({
68551
- results: array(object({
68552
- rev: number().int().nullable(),
68553
- deleted: boolean()
68554
- })),
68555
- releasedRefs: SecretRefsList,
68556
- secretRefs: SecretRefsList
68557
- }), {
68558
- kind: "mutation",
68559
- auth: "admin"
68560
- }), method(object({
68561
- deviceId: number().int(),
68562
- kinds: array(RecordKindNameSchema).optional()
68563
- }), object({
68564
- deleted: record(string(), number().int()),
68565
- secretRefs: SecretRefsList
68566
- }), {
68567
- kind: "mutation",
68568
- auth: "admin"
68569
- }), method(object(KindShape), object({ id: number().int() }), {
68570
- kind: "mutation",
68571
- auth: "admin"
68572
- });
68573
- /**
68574
68881
  * record-entries - the ONE admin surface for every list of user-created entries
68575
68882
  * (D881): notification targets, brokers, LLM profiles, anything an addon
68576
68883
  * declares as an ENTRY kind (`entry: { title }` in `defineRecordKinds`).
@@ -77113,20 +77420,6 @@ Object.freeze({
77113
77420
  access: "create",
77114
77421
  role: "viewer"
77115
77422
  },
77116
- "broker.add": {
77117
- capName: "broker",
77118
- capScope: "system",
77119
- addonId: null,
77120
- access: "create",
77121
- role: "admin"
77122
- },
77123
- "broker.get": {
77124
- capName: "broker",
77125
- capScope: "system",
77126
- addonId: null,
77127
- access: "view",
77128
- role: "admin"
77129
- },
77130
77423
  "broker.getBrokerConfig": {
77131
77424
  capName: "broker",
77132
77425
  capScope: "system",
@@ -77134,20 +77427,6 @@ Object.freeze({
77134
77427
  access: "view",
77135
77428
  role: "admin"
77136
77429
  },
77137
- "broker.getSettings": {
77138
- capName: "broker",
77139
- capScope: "system",
77140
- addonId: null,
77141
- access: "view",
77142
- role: "admin"
77143
- },
77144
- "broker.getSettingsSchema": {
77145
- capName: "broker",
77146
- capScope: "system",
77147
- addonId: null,
77148
- access: "view",
77149
- role: "admin"
77150
- },
77151
77430
  "broker.getState": {
77152
77431
  capName: "broker",
77153
77432
  capScope: "system",
@@ -77162,20 +77441,6 @@ Object.freeze({
77162
77441
  access: "view",
77163
77442
  role: "admin"
77164
77443
  },
77165
- "broker.list": {
77166
- capName: "broker",
77167
- capScope: "system",
77168
- addonId: null,
77169
- access: "view",
77170
- role: "admin"
77171
- },
77172
- "broker.listProviders": {
77173
- capName: "broker",
77174
- capScope: "system",
77175
- addonId: null,
77176
- access: "view",
77177
- role: "admin"
77178
- },
77179
77444
  "broker.publish": {
77180
77445
  capName: "broker",
77181
77446
  capScope: "system",
@@ -77183,20 +77448,6 @@ Object.freeze({
77183
77448
  access: "create",
77184
77449
  role: "admin"
77185
77450
  },
77186
- "broker.remove": {
77187
- capName: "broker",
77188
- capScope: "system",
77189
- addonId: null,
77190
- access: "delete",
77191
- role: "admin"
77192
- },
77193
- "broker.setSettings": {
77194
- capName: "broker",
77195
- capScope: "system",
77196
- addonId: null,
77197
- access: "create",
77198
- role: "admin"
77199
- },
77200
77451
  "broker.subscribe": {
77201
77452
  capName: "broker",
77202
77453
  capScope: "system",
@@ -77204,20 +77455,6 @@ Object.freeze({
77204
77455
  access: "create",
77205
77456
  role: "admin"
77206
77457
  },
77207
- "broker.testConnection": {
77208
- capName: "broker",
77209
- capScope: "system",
77210
- addonId: null,
77211
- access: "create",
77212
- role: "admin"
77213
- },
77214
- "broker.testSettings": {
77215
- capName: "broker",
77216
- capScope: "system",
77217
- addonId: null,
77218
- access: "create",
77219
- role: "admin"
77220
- },
77221
77458
  "broker.unsubscribe": {
77222
77459
  capName: "broker",
77223
77460
  capScope: "system",
@@ -81978,13 +82215,6 @@ Object.freeze({
81978
82215
  access: "view",
81979
82216
  role: "viewer"
81980
82217
  },
81981
- "petFountain.resetDailyConsumption": {
81982
- capName: "pet-fountain",
81983
- capScope: "device",
81984
- addonId: null,
81985
- access: "delete",
81986
- role: "viewer"
81987
- },
81988
82218
  "petFountain.setPump": {
81989
82219
  capName: "pet-fountain",
81990
82220
  capScope: "device",
@@ -82181,6 +82411,13 @@ Object.freeze({
82181
82411
  access: "view",
82182
82412
  role: "viewer"
82183
82413
  },
82414
+ "pipelineAnalytics.getMediaLocationCensus": {
82415
+ capName: "pipeline-analytics",
82416
+ capScope: "device",
82417
+ addonId: null,
82418
+ access: "view",
82419
+ role: "admin"
82420
+ },
82184
82421
  "pipelineAnalytics.getMediaReclaimStatus": {
82185
82422
  capName: "pipeline-analytics",
82186
82423
  capScope: "device",
@@ -88374,11 +88611,6 @@ Object.freeze({
88374
88611
  form: "single",
88375
88612
  optional: false
88376
88613
  }],
88377
- "petFountain.resetDailyConsumption": [{
88378
- name: "deviceId",
88379
- form: "single",
88380
- optional: false
88381
- }],
88382
88614
  "petFountain.setPump": [{
88383
88615
  name: "deviceId",
88384
88616
  form: "single",
@@ -91122,6 +91354,53 @@ object({
91122
91354
  delay: number().nonnegative().optional(),
91123
91355
  commands: record(string(), unknown())
91124
91356
  });
91357
+ var InstanceInUseError = class extends Error {
91358
+ instanceId;
91359
+ users;
91360
+ name = "InstanceInUseError";
91361
+ constructor(instanceId, users) {
91362
+ super(`instance '${instanceId}' is still in use by ${users.map((u) => `${u.kind} '${u.name ?? u.id}' (${u.id})`).join(", ")}; unlink them first`);
91363
+ this.instanceId = instanceId;
91364
+ this.users = users;
91365
+ }
91366
+ };
91367
+ /** Throw {@link InstanceInUseError} when any finder names a user of `instanceId`. */
91368
+ async function assertInstanceUnused(instanceId, finders) {
91369
+ const users = [];
91370
+ for (const finder of finders) users.push(...await finder.find(instanceId));
91371
+ if (users.length > 0) throw new InstanceInUseError(instanceId, users);
91372
+ }
91373
+ function namesInstance(value, instanceId) {
91374
+ if (Array.isArray(value)) return value.includes(instanceId);
91375
+ return value === instanceId;
91376
+ }
91377
+ /**
91378
+ * Users reached through another addon's listed rows (an addon cannot read a record kind it does
91379
+ * not own): every row whose `field` holds the instance id, as a string or in a string list.
91380
+ * `list` is a cap call the owner serves.
91381
+ */
91382
+ function fieldUserFinder(spec) {
91383
+ const fields = typeof spec.field === "string" ? [spec.field] : spec.field;
91384
+ return { find: async (instanceId) => (await spec.list()).filter((row) => fields.some((f) => namesInstance(row.formValues[f], instanceId))).map((row) => ({
91385
+ kind: spec.kind,
91386
+ id: row.id,
91387
+ ...row.name !== void 0 ? { name: row.name } : {}
91388
+ })) };
91389
+ }
91390
+ /**
91391
+ * A provider whose `deleteInstance` is refused while anything still uses the instance. The
91392
+ * account is an `integration-instances` kind (D1013), so the refusal lives on the delete the
91393
+ * instances page calls.
91394
+ */
91395
+ function guardInstanceDelete(base, users) {
91396
+ return {
91397
+ ...base,
91398
+ deleteInstance: async (input) => {
91399
+ await assertInstanceUnused(input.id, users());
91400
+ return base.deleteInstance(input);
91401
+ }
91402
+ };
91403
+ }
91125
91404
  //#endregion
91126
91405
  //#region ../../node_modules/xtend/immutable.js
91127
91406
  var require_immutable = /* @__PURE__ */ __commonJSMin(((exports, module) => {
@@ -123166,7 +123445,7 @@ function readOnboardPort(settings) {
123166
123445
  return raw;
123167
123446
  }
123168
123447
  /**
123169
- * Build an onboard {@link BuiltOnboardEntry} from `broker.add` settings.
123448
+ * Build an onboard {@link BuiltOnboardEntry} from the create form's values.
123170
123449
  * Pure — the port is pre-resolved by the caller (auto-allocation is a
123171
123450
  * side-effecting concern kept out of this helper), so the helper performs no
123172
123451
  * I/O of its own.
@@ -123252,7 +123531,7 @@ function resolveStoredPassword(value, legacy) {
123252
123531
  function retiredCiphertextPaths(config) {
123253
123532
  const out = [];
123254
123533
  for (const b of config.brokers) if (b.password?.startsWith("enc:v1:") === true) out.push(`brokers[${b.id}].password`);
123255
- if (config.embeddedPassword.startsWith("enc:v1:")) out.push("embeddedPassword");
123534
+ if (config.embeddedPassword?.startsWith("enc:v1:") === true) out.push("embeddedPassword");
123256
123535
  return out;
123257
123536
  }
123258
123537
  //#endregion
@@ -123286,6 +123565,229 @@ var mqttBrokersEntryKind = defineEntryKind("brokers", {
123286
123565
  writeTrigger: "an operator adds or removes an external MQTT broker entry"
123287
123566
  });
123288
123567
  //#endregion
123568
+ //#region src/mqtt-broker-users.ts
123569
+ /** The addon whose integrations name an MQTT broker, and the field they keep that id in. */
123570
+ var MQTT_HA_ADDON_ID = "provider-mqtt-ha";
123571
+ var MQTT_HA_BROKER_FIELD = "mqttBrokerId";
123572
+ /** The finders for everything that still points at an MQTT broker. */
123573
+ function mqttBrokerUsers(listMqttHaInstances) {
123574
+ return [fieldUserFinder({
123575
+ kind: "integration",
123576
+ field: MQTT_HA_BROKER_FIELD,
123577
+ list: listMqttHaInstances
123578
+ })];
123579
+ }
123580
+ /** MQTT Discovery is installed but has not registered as a provider: not known, NOT absent (D315). */
123581
+ var MqttDiscoveryNotReadyError = class extends Error {
123582
+ constructor() {
123583
+ super("MQTT Discovery is installed but not answering yet (still starting, or switched off), so its integrations cannot be checked: try again once it is running");
123584
+ this.name = "MqttDiscoveryNotReadyError";
123585
+ }
123586
+ };
123587
+ /**
123588
+ * The integrations of the MQTT Discovery addon. Nothing can name a broker only when the registry
123589
+ * ANSWERED that the addon supplies no `integration-instances` AND the addon roster ANSWERED that
123590
+ * it is not installed: then the answer is none. An addon that is installed but has not registered
123591
+ * (booting, switched off) is not-yet-known, and a registry or roster that does not answer, or a
123592
+ * read that fails, is unknown too: all of those throw, so the delete is refused (D49, D315).
123593
+ */
123594
+ async function listMqttHaInstancesOver(api) {
123595
+ const info = await api.capabilities.getCapability.query({ name: "integration-instances" });
123596
+ if (info === null) throw new Error("the capability registry did not answer");
123597
+ if (!info.providers.includes("provider-mqtt-ha")) {
123598
+ if ((await api.addons.list.query()).some((row) => row.manifest.id === "provider-mqtt-ha")) throw new MqttDiscoveryNotReadyError();
123599
+ return [];
123600
+ }
123601
+ return api.integrationInstances.listInstances.query({ addonId: MQTT_HA_ADDON_ID });
123602
+ }
123603
+ //#endregion
123604
+ //#region src/mqtt-broker-instances.ts
123605
+ /**
123606
+ * The MQTT brokers as `integration-instances` (D1013): one instance per `brokers` record. An
123607
+ * external broker is a URL; an ONBOARD broker (`onboard: true` on the create form) is a CamStack
123608
+ * `aedes` server on this hub: the create allocates its port (or honours the operator's) and the
123609
+ * addon spawns it from the record, the delete tears it down. Removing a broker an MQTT Discovery
123610
+ * integration still names is refused (D1012).
123611
+ */
123612
+ var MQTT_INSTANCE_KIND = "mqtt";
123613
+ /** The create-form keys that only mean "run an onboard broker"; the record keeps a descriptor. */
123614
+ var ONBOARD_FORM_KEYS = new Set([
123615
+ "onboard",
123616
+ "port",
123617
+ "allowAnonymous"
123618
+ ]);
123619
+ var PROBE_LABELS = {
123620
+ connected: "Connected",
123621
+ disconnected: "Disconnected",
123622
+ "auth-failed": "Authentication failed",
123623
+ unreachable: "Unreachable",
123624
+ "tls-error": "TLS error"
123625
+ };
123626
+ /** The instance's health word: connected / auth-failed / unreachable / tls-error, latency, clients. */
123627
+ function mqttHealthOf(body, live, now) {
123628
+ if (!isInstanceEnabled(body)) return { status: {
123629
+ state: "off",
123630
+ label: "Switched off"
123631
+ } };
123632
+ if (body.onboard !== void 0) return live.onboard === null ? {
123633
+ status: {
123634
+ state: "error",
123635
+ label: "Not listening"
123636
+ },
123637
+ lastCheckedAt: now
123638
+ } : {
123639
+ status: {
123640
+ state: "ok",
123641
+ label: "Listening",
123642
+ detail: `${live.onboard.connectedClients} connected clients`
123643
+ },
123644
+ lastCheckedAt: now
123645
+ };
123646
+ const probe = live.probe;
123647
+ if (probe === void 0) return { status: {
123648
+ state: "unknown",
123649
+ label: "Not checked yet"
123650
+ } };
123651
+ const detail = probe.status === "connected" ? probe.latencyMs !== null ? `${probe.latencyMs} ms` : void 0 : probe.error;
123652
+ return {
123653
+ status: {
123654
+ state: probe.status === "connected" ? "ok" : "error",
123655
+ label: PROBE_LABELS[probe.status],
123656
+ ...detail !== void 0 ? { detail } : {}
123657
+ },
123658
+ lastCheckedAt: probe.lastCheckedAt
123659
+ };
123660
+ }
123661
+ function withoutOnboardKeys(values) {
123662
+ return Object.fromEntries(Object.entries(values).filter(([key]) => !ONBOARD_FORM_KEYS.has(key)));
123663
+ }
123664
+ function typedText(value) {
123665
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
123666
+ }
123667
+ /** A password the form really typed: not blank, not the redaction token, not the hidden view. */
123668
+ function typedSecret(value) {
123669
+ const text = typedText(value);
123670
+ return text === void 0 || text === "__camstack_redacted__" || isSecretFieldView(text) ? void 0 : text;
123671
+ }
123672
+ function resultOf(probe) {
123673
+ return probe.ok ? {
123674
+ success: true,
123675
+ status: "validated",
123676
+ ...probe.latencyMs !== void 0 ? { latencyMs: probe.latencyMs } : {}
123677
+ } : {
123678
+ success: false,
123679
+ status: "rejected",
123680
+ error: probe.error ?? "The broker did not answer"
123681
+ };
123682
+ }
123683
+ function buildMqttBrokerInstances(records, deps) {
123684
+ const instances = new RecordIntegrationInstances(records, mqttBrokersEntryKind, {
123685
+ description: {
123686
+ role: "services",
123687
+ instanceMode: "multiple",
123688
+ kind: "brokers",
123689
+ canTest: true,
123690
+ kinds: [{
123691
+ id: MQTT_INSTANCE_KIND,
123692
+ label: "MQTT broker",
123693
+ functionCap: "broker",
123694
+ brokerKind: "mqtt",
123695
+ canTest: true
123696
+ }]
123697
+ },
123698
+ formSchema: (id) => deps.formSchema(id, false),
123699
+ kindOf: () => MQTT_INSTANCE_KIND,
123700
+ initialBody: (name) => ({
123701
+ name,
123702
+ url: "mqtt://"
123703
+ }),
123704
+ configured: (body) => body.url.trim().length > 0 && body.url.trim() !== "mqtt://",
123705
+ health: (body, id) => mqttHealthOf(body, deps.liveState(id), Date.now()),
123706
+ onChanged: deps.onChanged,
123707
+ test: async ({ id, values }) => {
123708
+ if (id === null && isOnboardRequested(values)) return {
123709
+ success: true,
123710
+ status: "validated",
123711
+ detail: "A free port is picked when the broker is created"
123712
+ };
123713
+ const saved = id === null ? null : await instances.readResolved(id);
123714
+ if (saved?.body.onboard !== void 0) return deps.onboardListening(saved.id) ? {
123715
+ success: true,
123716
+ status: "validated",
123717
+ detail: `Listening on port ${saved.body.onboard.port}`
123718
+ } : {
123719
+ success: false,
123720
+ status: "rejected",
123721
+ error: "The onboard broker is not listening"
123722
+ };
123723
+ const url = typedText(values["url"]) ?? saved?.body.url;
123724
+ if (url === void 0 || url === "mqtt://") return {
123725
+ success: false,
123726
+ status: "rejected",
123727
+ error: "Enter the broker URL first"
123728
+ };
123729
+ const username = typedText(values["username"]) ?? saved?.body.username;
123730
+ const password = typedSecret(values["password"]) ?? saved?.body.password;
123731
+ return resultOf(await deps.probe({
123732
+ url,
123733
+ ...username !== void 0 ? { username } : {},
123734
+ ...password !== void 0 ? { password } : {}
123735
+ }));
123736
+ }
123737
+ });
123738
+ const base = instances.toProvider();
123739
+ /** Port, descriptor and URL of an onboard broker the form asked for. */
123740
+ const onboardValues = async (id, name, values) => {
123741
+ const built = buildOnboardEntry({
123742
+ id,
123743
+ name,
123744
+ port: readOnboardPort(values) ?? await deps.freePort(),
123745
+ settings: values
123746
+ });
123747
+ return {
123748
+ ...withoutOnboardKeys(values),
123749
+ url: built.url,
123750
+ onboard: built.onboard
123751
+ };
123752
+ };
123753
+ return guardInstanceDelete({
123754
+ ...base,
123755
+ instanceFormSchema: async ({ id }) => {
123756
+ const saved = id === null ? null : await instances.getResolved(id);
123757
+ return deps.formSchema(id, saved?.onboard !== void 0);
123758
+ },
123759
+ createInstance: async (input) => {
123760
+ const values = input.values ?? {};
123761
+ if (!isOnboardRequested(values)) return base.createInstance({
123762
+ ...input,
123763
+ values: withoutOnboardKeys(values)
123764
+ });
123765
+ const id = input.id ?? (0, node_crypto.randomUUID)();
123766
+ return base.createInstance({
123767
+ ...input,
123768
+ id,
123769
+ values: await onboardValues(id, input.name, values)
123770
+ });
123771
+ },
123772
+ updateInstance: async (input) => {
123773
+ if (!isOnboardRequested(input.values)) return base.updateInstance({
123774
+ ...input,
123775
+ values: withoutOnboardKeys(input.values)
123776
+ });
123777
+ const current = await instances.getResolved(input.id);
123778
+ if (current === null) throw new Error(`mqtt-broker: broker '${input.id}' not found`);
123779
+ if (current.onboard !== void 0) return base.updateInstance({
123780
+ ...input,
123781
+ values: withoutOnboardKeys(input.values)
123782
+ });
123783
+ return base.updateInstance({
123784
+ ...input,
123785
+ values: await onboardValues(input.id, input.name ?? current.name, input.values)
123786
+ });
123787
+ }
123788
+ }, () => mqttBrokerUsers(deps.listMqttHaInstances));
123789
+ }
123790
+ //#endregion
123289
123791
  //#region src/migrations/brokers-to-records.ts
123290
123792
  /**
123291
123793
  * The external MQTT brokers live in the `brokers` records (D985, D996); the old addon-settings
@@ -123375,46 +123877,163 @@ async function planDropBrokersKey(ports) {
123375
123877
  }] };
123376
123878
  }
123377
123879
  //#endregion
123880
+ //#region src/migrations/embedded-to-onboard.ts
123881
+ /**
123882
+ * The legacy single embedded broker lived in FLAT addon-settings keys (`embeddedEnabled`,
123883
+ * `embeddedPort`, ...). It is an ONBOARD broker record now, so the instance list, the pickers,
123884
+ * the test and the delete all see it (D1013). Two migrations of the catalogue, in this order:
123885
+ *
123886
+ * 1. `embeddedToOnboardRecord` (procedure): a legacy broker (a stopped one as a switched-off
123887
+ * record) becomes a `brokers` record
123888
+ * under the SAME id (`embeddedId`, else the `'embedded'` id the old code derived), so a
123889
+ * consumer that stored that id still resolves. It never overwrites a record of that id.
123890
+ * 2. `planDropEmbeddedKeys` (plan): the flat keys are deleted from the canonical
123891
+ * `addon-settings` rows, and only while the record that replaces them exists and carries the
123892
+ * password the legacy key held. `embeddedClients` / `embeddedAcl` are not flat broker keys
123893
+ * and stay.
123894
+ */
123895
+ /** The flat keys that described the legacy embedded broker. */
123896
+ var LEGACY_EMBEDDED_KEYS = [
123897
+ "embeddedEnabled",
123898
+ "embeddedId",
123899
+ "embeddedPort",
123900
+ "embeddedAllowAnonymous",
123901
+ "embeddedUsername",
123902
+ "embeddedPassword"
123903
+ ];
123904
+ var LEGACY_EMBEDDED_NAME = "Embedded broker";
123905
+ function onboardBodyOf(legacy) {
123906
+ return {
123907
+ name: LEGACY_EMBEDDED_NAME,
123908
+ url: `mqtt://127.0.0.1:${legacy.port}`,
123909
+ ...legacy.username !== void 0 ? { username: legacy.username } : {},
123910
+ ...legacy.password !== void 0 ? { password: legacy.password } : {},
123911
+ onboard: {
123912
+ port: legacy.port,
123913
+ allowAnonymous: legacy.allowAnonymous
123914
+ },
123915
+ ...legacy.enabled ? {} : { enabled: false }
123916
+ };
123917
+ }
123918
+ async function embeddedToOnboardRecord(ports) {
123919
+ const legacy = await ports.readLegacyEmbedded();
123920
+ if (legacy === null) return {
123921
+ outcome: "already-done",
123922
+ summary: { migrated: 0 }
123923
+ };
123924
+ if ((await ports.readRecords()).has(legacy.id)) return {
123925
+ outcome: "already-done",
123926
+ summary: { migrated: 0 }
123927
+ };
123928
+ await ports.putOnboardRecord(legacy.id, onboardBodyOf(legacy));
123929
+ return {
123930
+ outcome: "done",
123931
+ summary: { migrated: 1 }
123932
+ };
123933
+ }
123934
+ async function planDropEmbeddedKeys(ports) {
123935
+ const raw = await ports.readRawEmbeddedKeys();
123936
+ const present = LEGACY_EMBEDDED_KEYS.filter((key) => key in raw);
123937
+ if (present.length === 0) return { edits: [] };
123938
+ const refuse = (reason) => {
123939
+ ports.warn(`mqtt-broker migration: the legacy embedded keys are kept: ${reason}`);
123940
+ throw new Error(`the legacy embedded broker keys are not deleted: ${reason}`);
123941
+ };
123942
+ const legacy = await ports.readLegacyEmbedded();
123943
+ if (legacy !== null) {
123944
+ const record = (await ports.readRecords()).get(legacy.id);
123945
+ if (record === void 0) return refuse(`the record '${legacy.id}' does not exist`);
123946
+ if ((legacy.password ?? "").length > 0 && !record.hasPassword) return refuse(`the record '${legacy.id}' holds no password`);
123947
+ }
123948
+ return { edits: [{
123949
+ op: "deleteKeys",
123950
+ scope: ADDON_SETTINGS_TABLE,
123951
+ keys: present.map((key) => `${MQTT_BROKER_ADDON_ID}.${key}`),
123952
+ precondition: preconditionOf(Object.fromEntries(present.map((key) => [`${MQTT_BROKER_ADDON_ID}.${key}`, {
123953
+ addonId: MQTT_BROKER_ADDON_ID,
123954
+ key,
123955
+ value: raw[key]
123956
+ }])))
123957
+ }] };
123958
+ }
123959
+ //#endregion
123378
123960
  //#region src/migrations/index.ts
123379
123961
  var BROKERS_TO_RECORDS_ID = "mqtt-broker/2026-11-03-brokers-settings-to-records";
123380
123962
  var DROP_BROKERS_KEY_ID = "mqtt-broker/2026-11-03-drop-brokers-key";
123963
+ var EMBEDDED_TO_ONBOARD_ID = "mqtt-broker/2026-11-05-embedded-broker-to-onboard-record";
123964
+ var DROP_EMBEDDED_KEYS_ID = "mqtt-broker/2026-11-05-drop-embedded-keys";
123381
123965
  var FIXTURE_MARK = "a setting key the migration does not know";
123382
123966
  /**
123383
123967
  * The `mqtt-broker` catalogue (D987), offered to the engine when the addon initialises. Both
123384
123968
  * entries are `tolerate`: the embedded broker and every record keep working while a legacy key
123385
123969
  * stays where it is.
123386
123970
  */
123387
- var ADDON_MIGRATIONS = [{
123388
- meta: {
123389
- id: BROKERS_TO_RECORDS_ID,
123390
- owner: "mqtt-broker",
123391
- level: "addon-state",
123392
- seq: 1,
123393
- dependsOn: [],
123394
- store: "settings",
123395
- scope: "cluster",
123396
- onFailure: "tolerate",
123397
- bodyKind: "procedure",
123398
- description: "Check that every external broker of the old settings list is a brokers record; never deletes",
123399
- fixtureMark: FIXTURE_MARK
123400
- },
123401
- procedure: verifyBrokersAreRecords
123402
- }, {
123403
- meta: {
123404
- id: DROP_BROKERS_KEY_ID,
123405
- owner: "mqtt-broker",
123406
- level: "addon-state",
123407
- seq: 2,
123408
- dependsOn: [BROKERS_TO_RECORDS_ID],
123409
- store: "settings",
123410
- scope: "cluster",
123411
- onFailure: "tolerate",
123412
- bodyKind: "plan",
123413
- description: "Delete the legacy brokers settings key once every broker is a record",
123414
- fixtureMark: FIXTURE_MARK
123415
- },
123416
- plan: planDropBrokersKey
123417
- }];
123971
+ var ADDON_MIGRATIONS = [
123972
+ {
123973
+ meta: {
123974
+ id: BROKERS_TO_RECORDS_ID,
123975
+ owner: "mqtt-broker",
123976
+ level: "addon-state",
123977
+ seq: 1,
123978
+ dependsOn: [],
123979
+ store: "settings",
123980
+ scope: "cluster",
123981
+ onFailure: "tolerate",
123982
+ bodyKind: "procedure",
123983
+ description: "Check that every external broker of the old settings list is a brokers record; never deletes",
123984
+ fixtureMark: FIXTURE_MARK
123985
+ },
123986
+ procedure: verifyBrokersAreRecords
123987
+ },
123988
+ {
123989
+ meta: {
123990
+ id: DROP_BROKERS_KEY_ID,
123991
+ owner: "mqtt-broker",
123992
+ level: "addon-state",
123993
+ seq: 2,
123994
+ dependsOn: [BROKERS_TO_RECORDS_ID],
123995
+ store: "settings",
123996
+ scope: "cluster",
123997
+ onFailure: "tolerate",
123998
+ bodyKind: "plan",
123999
+ description: "Delete the legacy brokers settings key once every broker is a record",
124000
+ fixtureMark: FIXTURE_MARK
124001
+ },
124002
+ plan: planDropBrokersKey
124003
+ },
124004
+ {
124005
+ meta: {
124006
+ id: EMBEDDED_TO_ONBOARD_ID,
124007
+ owner: "mqtt-broker",
124008
+ level: "addon-state",
124009
+ seq: 3,
124010
+ dependsOn: [DROP_BROKERS_KEY_ID],
124011
+ store: "settings",
124012
+ scope: "cluster",
124013
+ onFailure: "tolerate",
124014
+ bodyKind: "procedure",
124015
+ description: "The legacy embedded broker (flat embedded* keys) becomes an onboard brokers record under the same id; never overwrites a record",
124016
+ fixtureMark: FIXTURE_MARK
124017
+ },
124018
+ procedure: embeddedToOnboardRecord
124019
+ },
124020
+ {
124021
+ meta: {
124022
+ id: DROP_EMBEDDED_KEYS_ID,
124023
+ owner: "mqtt-broker",
124024
+ level: "addon-state",
124025
+ seq: 4,
124026
+ dependsOn: [EMBEDDED_TO_ONBOARD_ID],
124027
+ store: "settings",
124028
+ scope: "cluster",
124029
+ onFailure: "tolerate",
124030
+ bodyKind: "plan",
124031
+ description: "Delete the flat embedded broker keys once the onboard record holds them",
124032
+ fixtureMark: FIXTURE_MARK
124033
+ },
124034
+ plan: planDropEmbeddedKeys
124035
+ }
124036
+ ];
123418
124037
  //#endregion
123419
124038
  //#region src/mqtt-entry-record-kinds.ts
123420
124039
  var aclAccessSchema = _enum([
@@ -123622,123 +124241,25 @@ var MqttAclStore = class MqttAclStore {
123622
124241
  *
123623
124242
  * Lifecycle:
123624
124243
  * - External broker entries are persisted in `this.activeExternalBrokers`.
123625
- * - The embedded broker is persisted as flat `embedded*` config keys
123626
- * (`embeddedEnabled`, `embeddedPort`, …) so the standard settings
123627
- * form can drive it; {@link MqttBrokerAddon.deriveEmbeddedEntry}
123628
- * reassembles them into an `EmbeddedBrokerEntry`.
123629
- * - On boot we start the embedded broker when `embeddedEnabled`.
123630
- * - `addBroker` / `removeBroker` / `startEmbeddedBroker` /
123631
- * `stopEmbeddedBroker` mutate the config and the runtime state.
124244
+ * - The legacy single embedded broker is an onboard `brokers` record (migrated from the flat
124245
+ * `embedded*` keys, same id); `startEmbeddedBroker` / `stopEmbeddedBroker` act on that record.
124246
+ * - `addBroker` / `removeBroker` mutate the records and the runtime state.
123632
124247
  * - `testConnection` does a one-shot `mqtt.connect` race against a 3s
123633
124248
  * timeout AND refreshes the probe cache for the broker.
123634
124249
  */
123635
124250
  /** bcrypt cost factor — exported for tests. */
123636
124251
  var MQTT_EMBEDDED_BCRYPT_ROUNDS = 10;
123637
124252
  var PROBE_INTERVAL_MS = 3e4;
123638
- var DEFAULT_EMBEDDED_PORT = 1883;
123639
- /**
123640
- * Per-kind broker creation form for kind='mqtt', rendered by the unified
123641
- * Broker manager / create picker (via `broker.getSettingsSchema`). PURE
123642
- * DATA — emits the flat `{ url, username, password, clientIdPrefix }`
123643
- * shape `broker.add`'s `settings` payload consumes. Mirrors the external
123644
- * broker fields `addBroker` accepts.
123645
- */
123646
- var mqttBrokerSettingsSchema = { sections: [{
123647
- id: "mqtt-broker-connection",
123648
- title: "MQTT connection",
123649
- titleKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.title",
123650
- columns: 1,
123651
- fields: [
123652
- {
123653
- type: "boolean",
123654
- key: "onboard",
123655
- label: "Run an onboard broker",
123656
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.onboard.label",
123657
- description: "Spin up a CamStack-hosted aedes broker on this hub instead of connecting to an external one. Each onboard broker gets its own dedicated server + port.",
123658
- descriptionKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.onboard.description",
123659
- default: false
123660
- },
123661
- {
123662
- type: "number",
123663
- key: "port",
123664
- label: "Port",
123665
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.port.label",
123666
- placeholder: "(auto)",
123667
- description: "TCP port for the onboard broker. Leave blank to auto-allocate a free port.",
123668
- descriptionKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.port.description",
123669
- showWhen: {
123670
- field: "onboard",
123671
- equals: true
123672
- }
123673
- },
123674
- {
123675
- type: "boolean",
123676
- key: "allowAnonymous",
123677
- label: "Allow anonymous",
123678
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.allowAnonymous.label",
123679
- description: "Permit clients to connect without a username/password.",
123680
- descriptionKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.allowAnonymous.description",
123681
- default: false,
123682
- showWhen: {
123683
- field: "onboard",
123684
- equals: true
123685
- }
123686
- },
123687
- {
123688
- type: "text",
123689
- key: "url",
123690
- label: "Broker URL",
123691
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.url.label",
123692
- placeholder: "mqtt://localhost:1883",
123693
- showWhen: {
123694
- field: "onboard",
123695
- equals: false
123696
- }
123697
- },
123698
- {
123699
- type: "text",
123700
- key: "username",
123701
- label: "Username",
123702
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.username.label",
123703
- placeholder: "(optional)"
123704
- },
123705
- {
123706
- type: "password",
123707
- key: "password",
123708
- label: "Password",
123709
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.password.label",
123710
- showToggle: true
123711
- },
123712
- {
123713
- type: "text",
123714
- key: "clientIdPrefix",
123715
- label: "Client ID prefix",
123716
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.clientIdPrefix.label",
123717
- placeholder: "(optional)",
123718
- showWhen: {
123719
- field: "onboard",
123720
- equals: false
123721
- }
123722
- }
123723
- ]
123724
- }] };
123725
124253
  var DEFAULT_CONFIG = {
123726
- embeddedEnabled: false,
123727
- embeddedId: "",
123728
- embeddedPort: DEFAULT_EMBEDDED_PORT,
123729
- embeddedAllowAnonymous: false,
123730
- embeddedUsername: "",
123731
- embeddedPassword: "",
123732
124254
  embeddedClients: [],
123733
124255
  embeddedAcl: []
123734
124256
  };
123735
124257
  var MqttBrokerAddon = class extends BaseAddon {
123736
- embeddedRuntime = null;
123737
- /** Per-entry onboard `aedes` runtimes, keyed by `ExternalBrokerEntry.id`. */
124258
+ /** Per-record onboard `aedes` runtimes, keyed by `ExternalBrokerEntry.id`. */
123738
124259
  onboardRuntimes = /* @__PURE__ */ new Map();
123739
124260
  probeCache = null;
123740
124261
  brokerStore = null;
123741
- instances = null;
124262
+ brokerInstances = null;
123742
124263
  clientStore = null;
123743
124264
  aclStore = null;
123744
124265
  /** The external broker rows: the `brokers` records, secrets resolved. */
@@ -123752,6 +124273,20 @@ var MqttBrokerAddon = class extends BaseAddon {
123752
124273
  if (this.brokerStore === null) throw new Error("mqtt-broker: the broker records are not open");
123753
124274
  return this.brokerStore;
123754
124275
  }
124276
+ /** What the addon knows of a broker right now: its onboard server or its last probe. */
124277
+ liveState(id) {
124278
+ const running = this.onboardRuntimes.get(id);
124279
+ const probe = this.probeCache?.get(id);
124280
+ return {
124281
+ onboard: running === void 0 ? null : { connectedClients: running.aedes.connectedClients },
124282
+ ...probe !== void 0 ? { probe: {
124283
+ status: probe.status,
124284
+ latencyMs: probe.latencyMs,
124285
+ ...probe.error !== void 0 ? { error: probe.error } : {},
124286
+ lastCheckedAt: probe.lastCheckedAt
124287
+ } } : {}
124288
+ };
124289
+ }
123755
124290
  async persistExternalBrokers(entries) {
123756
124291
  this.activeExternalBrokers = [...entries];
123757
124292
  await this.requireBrokerStore().replaceAll(entries);
@@ -123801,19 +124336,13 @@ var MqttBrokerAddon = class extends BaseAddon {
123801
124336
  this.brokerStore = MqttBrokerStore.of(records);
123802
124337
  this.clientStore = MqttClientStore.of(records);
123803
124338
  this.aclStore = MqttAclStore.of(records);
123804
- this.instances = new RecordIntegrationInstances(records, mqttBrokersEntryKind, {
123805
- description: {
123806
- role: "services",
123807
- instanceMode: "multiple",
123808
- kind: "brokers",
123809
- canTest: false
123810
- },
123811
- formSchema: () => this.brokerForm(),
123812
- initialBody: (name) => ({
123813
- name,
123814
- url: "mqtt://"
123815
- }),
123816
- configured: (body) => body.url.trim().length > 0 && body.url.trim() !== "mqtt://",
124339
+ this.brokerInstances = buildMqttBrokerInstances(records, {
124340
+ formSchema: (id, onboard) => this.brokerForm(id, onboard),
124341
+ listMqttHaInstances: () => listMqttHaInstancesOver(this.ctx.api),
124342
+ freePort: () => findFreePort(),
124343
+ probe: (cfg) => this.probeMqtt(cfg),
124344
+ onboardListening: (id) => this.onboardRuntimes.has(id),
124345
+ liveState: (id) => this.liveState(id),
123817
124346
  onChanged: async () => {
123818
124347
  await this.refreshExternalBrokers();
123819
124348
  }
@@ -123842,29 +124371,9 @@ var MqttBrokerAddon = class extends BaseAddon {
123842
124371
  });
123843
124372
  this.probeCache.setBrokersSource(() => this.collectExternalProbeInputs());
123844
124373
  this.probeCache.start();
123845
- const persistedEmbedded = this.deriveEmbeddedEntry();
123846
- if (persistedEmbedded) try {
123847
- await this.spawnEmbedded(persistedEmbedded);
123848
- this.ctx.logger.info("embedded mqtt broker restarted", {
123849
- meta: { port: persistedEmbedded.port },
123850
- tags: {
123851
- topic: "mqtt-broker",
123852
- phase: "embedded-restarted"
123853
- }
123854
- });
123855
- } catch (err) {
123856
- this.ctx.logger.warn("embedded mqtt broker failed to restart", {
123857
- meta: { error: errMsg(err) },
123858
- tags: {
123859
- topic: "mqtt-broker",
123860
- phase: "embedded-restart-failed"
123861
- }
123862
- });
123863
- }
123864
124374
  await this.reconcileOnboardRuntimes();
123865
124375
  this.ctx.addDisposer(async () => {
123866
124376
  await this.probeCache?.stop();
123867
- await this.shutdownEmbedded().catch(() => void 0);
123868
124377
  await this.shutdownAllOnboard();
123869
124378
  });
123870
124379
  const provider = {
@@ -123876,11 +124385,13 @@ var MqttBrokerAddon = class extends BaseAddon {
123876
124385
  startEmbeddedBroker: async (input) => this.startEmbeddedBroker(input),
123877
124386
  stopEmbeddedBroker: async ({ id }) => this.stopEmbeddedBroker(id),
123878
124387
  getStatus: async () => ({
123879
- brokerCount: this.activeExternalBrokers.length + (this.config.embeddedEnabled ? 1 : 0),
123880
- embeddedRunning: this.embeddedRuntime !== null
124388
+ brokerCount: this.activeExternalBrokers.length,
124389
+ embeddedRunning: this.onboardRuntimes.size > 0
123881
124390
  })
123882
124391
  };
123883
124392
  const unifiedProvider = this.buildUnifiedBrokerProvider();
124393
+ const brokerInstances = this.brokerInstances;
124394
+ if (brokerInstances === null) throw new Error("mqtt-broker: the broker instances are not built");
123884
124395
  return [
123885
124396
  {
123886
124397
  capability: mqttBrokerCapability,
@@ -123892,111 +124403,21 @@ var MqttBrokerAddon = class extends BaseAddon {
123892
124403
  },
123893
124404
  {
123894
124405
  capability: integrationInstancesCapability,
123895
- provider: this.instances.toProvider()
124406
+ provider: brokerInstances
123896
124407
  }
123897
124408
  ];
123898
124409
  }
124410
+ /**
124411
+ * The bus-only `broker` cap (D1013). The brokers themselves are the `integration-instances`
124412
+ * provider; this one only answers the config read and the status.
124413
+ */
123899
124414
  buildUnifiedBrokerProvider() {
123900
- const KIND = "mqtt";
123901
- const ownerAddonId = this.ctx.id;
123902
- const adapt = (info) => ({
123903
- id: info.id,
123904
- addonId: ownerAddonId,
123905
- name: info.name,
123906
- kind: KIND,
123907
- status: mapMqttStatus(info.status),
123908
- info: {
123909
- ...info.latencyMs !== null ? { latencyMs: info.latencyMs } : {},
123910
- ...info.kind === "embedded" ? {
123911
- embedded: true,
123912
- connectedClients: info.connectedClients ?? 0
123913
- } : {}
123914
- },
123915
- lastCheckedAt: info.lastCheckedAt ?? null,
123916
- error: info.error ?? null
123917
- });
123918
- const owns = (id) => {
123919
- if (this.activeExternalBrokers.some((b) => b.id === id)) return true;
123920
- const embedded = this.deriveEmbeddedEntry();
123921
- return embedded !== null && embedded.id === id;
123922
- };
124415
+ const owns = (id) => this.activeExternalBrokers.some((b) => b.id === id);
123923
124416
  return {
123924
- list: async ({ kind }) => {
123925
- if (kind && kind !== KIND) return [];
123926
- return (await this.listBrokers()).map(adapt);
123927
- },
123928
- get: async ({ id }) => {
123929
- const match = (await this.listBrokers()).find((b) => b.id === id);
123930
- return match ? adapt(match) : null;
123931
- },
123932
- listProviders: async () => [{
123933
- addonId: ownerAddonId,
123934
- kinds: [{
123935
- kind: KIND,
123936
- label: "MQTT"
123937
- }]
123938
- }],
123939
- add: async ({ kind, name, settings, id }) => {
123940
- if (kind !== KIND) throw new Error(`mqtt-broker: only kind '${KIND}' is handled here (got '${kind}')`);
123941
- if (id !== void 0) throw new Error("mqtt-broker: adopting an existing broker id is not supported by this provider");
123942
- if (isOnboardRequested(settings)) return this.addOnboardBroker(name, settings);
123943
- const url = typeof settings["url"] === "string" ? settings["url"] : "";
123944
- if (!url) throw new Error("mqtt-broker.add: settings.url is required");
123945
- const input = {
123946
- name,
123947
- url,
123948
- ...typeof settings["username"] === "string" ? { username: settings["username"] } : {},
123949
- ...typeof settings["password"] === "string" ? { password: settings["password"] } : {},
123950
- ...typeof settings["clientIdPrefix"] === "string" ? { clientIdPrefix: settings["clientIdPrefix"] } : {}
123951
- };
123952
- return this.addBroker(input);
123953
- },
123954
- remove: async ({ id }) => {
123955
- if (!owns(id)) return;
123956
- await this.removeBroker(id);
123957
- },
123958
- testConnection: async ({ id }) => {
123959
- if (!owns(id)) return {
123960
- ok: false,
123961
- error: "unknown broker"
123962
- };
123963
- return this.testConnection(id);
123964
- },
123965
- getSettings: async ({ id }) => {
123966
- if (!owns(id)) return null;
123967
- return this.getBrokerConfig(id);
123968
- },
123969
- setSettings: async () => {
123970
- throw new Error("mqtt-broker.setSettings: not supported — use remove + add to replace a broker");
123971
- },
123972
124417
  getBrokerConfig: async ({ id }) => {
123973
124418
  if (!owns(id)) return null;
123974
124419
  return this.getBrokerConfig(id);
123975
124420
  },
123976
- getSettingsSchema: async ({ kind }) => {
123977
- if (kind !== KIND) return null;
123978
- return mqttBrokerSettingsSchema;
123979
- },
123980
- testSettings: async ({ kind, settings }) => {
123981
- if (kind !== KIND) return {
123982
- ok: false,
123983
- error: `unsupported kind: ${kind}`
123984
- };
123985
- if (isOnboardRequested(settings)) return {
123986
- ok: true,
123987
- latencyMs: 0
123988
- };
123989
- const url = typeof settings["url"] === "string" ? settings["url"] : "";
123990
- if (!url) return {
123991
- ok: false,
123992
- error: "settings.url is required"
123993
- };
123994
- return this.probeMqtt({
123995
- url,
123996
- ...typeof settings["username"] === "string" ? { username: settings["username"] } : {},
123997
- ...typeof settings["password"] === "string" ? { password: settings["password"] } : {}
123998
- });
123999
- },
124000
124421
  publish: async () => {
124001
124422
  throw new Error("mqtt-broker.publish: not supported — fetch getBrokerConfig + open your own mqtt.js client (see \"NOT a pub/sub proxy\" in mqtt-broker.cap.ts)");
124002
124423
  },
@@ -124010,7 +124431,7 @@ var MqttBrokerAddon = class extends BaseAddon {
124010
124431
  getStatus: async () => {
124011
124432
  const all = await this.listBrokers();
124012
124433
  return {
124013
- brokerCount: this.activeExternalBrokers.length + (this.config.embeddedEnabled ? 1 : 0),
124434
+ brokerCount: this.activeExternalBrokers.length,
124014
124435
  connectedCount: all.filter((b) => b.status === "connected").length
124015
124436
  };
124016
124437
  }
@@ -124018,35 +124439,13 @@ var MqttBrokerAddon = class extends BaseAddon {
124018
124439
  }
124019
124440
  async onShutdown() {
124020
124441
  await this.probeCache?.stop();
124021
- await this.shutdownEmbedded().catch(() => void 0);
124022
124442
  await this.shutdownAllOnboard();
124023
124443
  }
124024
124444
  async onConfigChanged() {
124025
124445
  this.activeExternalBrokers = await this.requireBrokerStore().listResolved();
124026
124446
  await this.syncEmbeddedEntriesFromConfig();
124027
124447
  await this.reconcileOnboardRuntimes();
124028
- const desired = this.deriveEmbeddedEntry();
124029
- const running = this.embeddedRuntime?.entry ?? null;
124030
- if (!desired && running) {
124031
- await this.shutdownEmbedded();
124032
- return;
124033
- }
124034
- if (desired && !running) {
124035
- await this.spawnEmbedded(desired).catch((err) => {
124036
- this.ctx.logger.warn("embedded mqtt broker spawn (on config change) failed", { meta: { error: errMsg(err) } });
124037
- });
124038
- return;
124039
- }
124040
- if (desired && running) {
124041
- if (requiresEmbeddedRespawn(desired, running)) {
124042
- await this.shutdownEmbedded();
124043
- await this.spawnEmbedded(desired).catch((err) => {
124044
- this.ctx.logger.warn("embedded mqtt broker respawn failed", { meta: { error: errMsg(err) } });
124045
- });
124046
- return;
124047
- }
124048
- this.reconcileEmbeddedClients();
124049
- }
124448
+ this.reconcileEmbeddedClients();
124050
124449
  }
124051
124450
  async listBrokers() {
124052
124451
  const out = [];
@@ -124080,28 +124479,12 @@ var MqttBrokerAddon = class extends BaseAddon {
124080
124479
  };
124081
124480
  out.push(info);
124082
124481
  }
124083
- const embedded = this.deriveEmbeddedEntry();
124084
- if (embedded) {
124085
- const running = this.embeddedRuntime;
124086
- const status = running !== null ? "connected" : "disconnected";
124087
- const info = {
124088
- id: embedded.id,
124089
- name: embedded.name,
124090
- url: `mqtt://127.0.0.1:${embedded.port}`,
124091
- kind: "embedded",
124092
- status,
124093
- latencyMs: null,
124094
- ...running ? { connectedClients: running.aedes.connectedClients } : {},
124095
- lastCheckedAt: Date.now()
124096
- };
124097
- out.push(info);
124098
- }
124099
124482
  return out;
124100
124483
  }
124101
124484
  getBrokerConfig(id) {
124102
124485
  const external = this.activeExternalBrokers.find((b) => b.id === id);
124103
124486
  if (external) {
124104
- if (external.enabled === false) throw new Error(`mqtt-broker: broker '${id}' is switched off`);
124487
+ if (!isInstanceEnabled(external)) throw new Error(`mqtt-broker: broker '${id}' is switched off`);
124105
124488
  const password = this.storedPassword(external.password);
124106
124489
  return {
124107
124490
  url: external.url,
@@ -124110,15 +124493,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124110
124493
  ...external.clientIdPrefix !== void 0 ? { clientIdPrefix: external.clientIdPrefix } : {}
124111
124494
  };
124112
124495
  }
124113
- const embedded = this.deriveEmbeddedEntry();
124114
- if (embedded && embedded.id === id) {
124115
- const password = this.storedPassword(embedded.password);
124116
- return {
124117
- url: `mqtt://127.0.0.1:${embedded.port}`,
124118
- ...embedded.username !== void 0 ? { username: embedded.username } : {},
124119
- ...password !== void 0 ? { password } : {}
124120
- };
124121
- }
124122
124496
  throw new Error(`mqtt-broker: broker '${id}' not found`);
124123
124497
  }
124124
124498
  async addBroker(input) {
@@ -124139,44 +124513,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124139
124513
  } });
124140
124514
  return { id };
124141
124515
  }
124142
- /**
124143
- * Create a per-entry ONBOARD broker from `broker.add` settings: allocate
124144
- * (or honour) a port, persist an `ExternalBrokerEntry` carrying an
124145
- * `onboard` descriptor, then spawn its dedicated `aedes` server.
124146
- */
124147
- async addOnboardBroker(name, settings) {
124148
- const id = (0, node_crypto.randomUUID)();
124149
- const port = readOnboardPort(settings) ?? await findFreePort();
124150
- const built = buildOnboardEntry({
124151
- id,
124152
- name,
124153
- port,
124154
- settings
124155
- });
124156
- const entry = {
124157
- id: built.id,
124158
- name: built.name,
124159
- url: built.url,
124160
- ...built.username !== void 0 ? { username: built.username } : {},
124161
- ...built.password !== void 0 ? { password: built.password } : {},
124162
- onboard: built.onboard
124163
- };
124164
- const next = [...this.activeExternalBrokers, entry];
124165
- await this.persistExternalBrokers(next);
124166
- const spawn = this.onboardSpawnEntry(entry);
124167
- if (spawn) await this.spawnOnboard(spawn);
124168
- this.ctx.logger.info("onboard mqtt broker added", {
124169
- meta: {
124170
- id,
124171
- port
124172
- },
124173
- tags: {
124174
- topic: "mqtt-broker",
124175
- phase: "onboard-started"
124176
- }
124177
- });
124178
- return { id };
124179
- }
124180
124516
  async removeBroker(id) {
124181
124517
  const removed = this.activeExternalBrokers.find((b) => b.id === id);
124182
124518
  if (removed) {
@@ -124187,13 +124523,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124187
124523
  this.ctx.logger.info("mqtt broker removed", { meta: { id } });
124188
124524
  return;
124189
124525
  }
124190
- const embedded = this.deriveEmbeddedEntry();
124191
- if (embedded && embedded.id === id) {
124192
- await this.shutdownEmbedded();
124193
- await this.updateGlobalSettings({ embeddedEnabled: false });
124194
- this.ctx.logger.info("embedded mqtt broker removed", { meta: { id } });
124195
- return;
124196
- }
124197
124526
  }
124198
124527
  async testConnection(id) {
124199
124528
  let cfg;
@@ -124212,7 +124541,7 @@ var MqttBrokerAddon = class extends BaseAddon {
124212
124541
  * One-shot connectivity probe against an MQTT endpoint built from a
124213
124542
  * flat config (`{url, username?, password?}`). Shared by the
124214
124543
  * id-keyed `testConnection` (existing broker) and the pre-creation
124215
- * `testSettings` (throwaway settings the operator hasn't saved yet).
124544
+ * the instance test (`testInstance`) of a broker not saved yet (the typed values).
124216
124545
  * Never throws — every failure resolves to `{ ok: false, error }`.
124217
124546
  */
124218
124547
  async probeMqtt(cfg) {
@@ -124257,30 +124586,46 @@ var MqttBrokerAddon = class extends BaseAddon {
124257
124586
  });
124258
124587
  });
124259
124588
  }
124589
+ /** The `brokers` records, for the two legacy cap methods that create or switch off one. */
124590
+ brokerRecordHandle() {
124591
+ const handle = this.ctx.records?.kind(mqttBrokersEntryKind);
124592
+ if (handle === void 0) throw new Error("mqtt-broker: the broker records are not open");
124593
+ return handle;
124594
+ }
124595
+ /** Create an ONBOARD broker record; the reconcile then spawns its `aedes` server. */
124260
124596
  async startEmbeddedBroker(input) {
124261
- if (this.embeddedRuntime) throw new Error("embedded broker already running — call stopEmbeddedBroker first");
124262
- const id = this.config.embeddedId || (0, node_crypto.randomUUID)();
124263
- const entry = {
124597
+ const id = (0, node_crypto.randomUUID)();
124598
+ const built = buildOnboardEntry({
124264
124599
  id,
124265
- name: "Embedded broker",
124600
+ name: LEGACY_EMBEDDED_NAME,
124266
124601
  port: input.port,
124267
- allowAnonymous: input.allowAnonymous,
124268
- ...input.username !== void 0 ? { username: input.username } : {},
124269
- ...input.password !== void 0 ? { password: input.password } : {}
124270
- };
124271
- await this.spawnEmbedded(entry);
124272
- await this.updateGlobalSettings({
124273
- embeddedEnabled: true,
124274
- embeddedId: id,
124275
- embeddedPort: input.port,
124276
- embeddedAllowAnonymous: input.allowAnonymous,
124277
- embeddedUsername: input.username ?? "",
124278
- embeddedPassword: input.password ?? ""
124602
+ settings: {
124603
+ allowAnonymous: input.allowAnonymous,
124604
+ ...input.username !== void 0 ? { username: input.username } : {},
124605
+ ...input.password !== void 0 ? { password: input.password } : {}
124606
+ }
124279
124607
  });
124608
+ const handle = this.brokerRecordHandle();
124609
+ await handle.put({
124610
+ id,
124611
+ data: mqttBrokersEntryKind.schema.parse({
124612
+ name: built.name,
124613
+ url: built.url,
124614
+ ...built.username !== void 0 ? { username: built.username } : {},
124615
+ ...built.password !== void 0 ? { password: built.password } : {},
124616
+ onboard: built.onboard
124617
+ })
124618
+ }, { ifRev: 0 });
124619
+ await this.refreshExternalBrokers();
124620
+ if (!this.onboardRuntimes.has(id)) {
124621
+ await handle.delete(id);
124622
+ await this.refreshExternalBrokers();
124623
+ throw new Error(`embedded broker could not listen on port ${input.port}`);
124624
+ }
124280
124625
  this.ctx.logger.info("embedded mqtt broker started", {
124281
124626
  meta: {
124282
124627
  id,
124283
- port: entry.port
124628
+ port: input.port
124284
124629
  },
124285
124630
  tags: {
124286
124631
  topic: "mqtt-broker",
@@ -124302,20 +124647,22 @@ var MqttBrokerAddon = class extends BaseAddon {
124302
124647
  });
124303
124648
  return {
124304
124649
  id,
124305
- url: `mqtt://127.0.0.1:${entry.port}`
124650
+ url: built.url
124306
124651
  };
124307
124652
  }
124653
+ /** Switch an onboard broker off (its record stays, so it can be switched on again). */
124308
124654
  async stopEmbeddedBroker(id) {
124309
- if (!this.embeddedRuntime || this.embeddedRuntime.entry.id !== id) return;
124310
- await this.shutdownEmbedded();
124311
- await this.updateGlobalSettings({ embeddedEnabled: false });
124655
+ const handle = this.brokerRecordHandle();
124656
+ const row = await handle.get(id);
124657
+ if (row === null || row.data.onboard === void 0) return;
124658
+ await handle.patch(id, { enabled: false });
124659
+ await this.refreshExternalBrokers();
124312
124660
  this.ctx.logger.info("embedded mqtt broker stopped", { meta: { id } });
124313
124661
  }
124314
124662
  /**
124315
124663
  * Build + listen an `aedes` server for `entry` and RETURN the runtime
124316
- * without assigning it to any field. Shared by the legacy single
124317
- * embedded broker ({@link spawnEmbedded}) and the per-entry onboard
124318
- * brokers ({@link spawnOnboard}).
124664
+ * without assigning it to any field (the onboard brokers keep theirs in
124665
+ * {@link spawnOnboard}).
124319
124666
  */
124320
124667
  async spawnAedes(entry) {
124321
124668
  const aedes = new import_aedes.default();
@@ -124354,15 +124701,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124354
124701
  aedes.close(() => resolve());
124355
124702
  });
124356
124703
  }
124357
- async spawnEmbedded(entry) {
124358
- this.embeddedRuntime = await this.spawnAedes(entry);
124359
- }
124360
- async shutdownEmbedded() {
124361
- if (!this.embeddedRuntime) return;
124362
- const runtime = this.embeddedRuntime;
124363
- this.embeddedRuntime = null;
124364
- await this.closeRuntime(runtime);
124365
- }
124366
124704
  /** Spawn a dedicated onboard `aedes` server for an onboard entry. */
124367
124705
  async spawnOnboard(entry) {
124368
124706
  const runtime = await this.spawnAedes(entry);
@@ -124402,7 +124740,7 @@ var MqttBrokerAddon = class extends BaseAddon {
124402
124740
  * entry disappeared, and respawn runtimes whose port changed.
124403
124741
  */
124404
124742
  async reconcileOnboardRuntimes() {
124405
- const desiredEntries = this.activeExternalBrokers.filter(hasOnboard).filter((b) => b.enabled !== false);
124743
+ const desiredEntries = this.activeExternalBrokers.filter(hasOnboard).filter((b) => isInstanceEnabled(b));
124406
124744
  const desired = desiredEntries.map((b) => ({
124407
124745
  id: b.id,
124408
124746
  port: b.onboard.port
@@ -124499,8 +124837,9 @@ var MqttBrokerAddon = class extends BaseAddon {
124499
124837
  * (or whose username was removed entirely) get force-closed.
124500
124838
  */
124501
124839
  reconcileEmbeddedClients() {
124502
- const runtime = this.embeddedRuntime;
124503
- if (!runtime) return;
124840
+ for (const runtime of this.onboardRuntimes.values()) this.reconcileRuntimeClients(runtime);
124841
+ }
124842
+ reconcileRuntimeClients(runtime) {
124504
124843
  const prevSnapshot = runtime.credentialsSnapshot;
124505
124844
  const nextSnapshot = this.buildCredentialsSnapshot();
124506
124845
  const affectedUsers = [];
@@ -124539,30 +124878,10 @@ var MqttBrokerAddon = class extends BaseAddon {
124539
124878
  for (const c of this.activeEmbeddedClients) m.set(c.username, c.passwordHash);
124540
124879
  return m;
124541
124880
  }
124542
- /**
124543
- * Reassemble the internal {@link EmbeddedBrokerEntry} from the flat
124544
- * `embedded*` config keys the settings form writes. Returns `null`
124545
- * when the embedded broker is disabled — that's the signal
124546
- * `onConfigChanged` / `onInitialize` use to decide whether to spawn
124547
- * or shut down the `aedes` instance.
124548
- */
124549
- deriveEmbeddedEntry() {
124550
- if (!this.config.embeddedEnabled) return null;
124551
- const username = this.config.embeddedUsername;
124552
- const password = this.config.embeddedPassword;
124553
- return {
124554
- id: this.config.embeddedId || "embedded",
124555
- name: "Embedded broker",
124556
- port: this.config.embeddedPort,
124557
- allowAnonymous: this.config.embeddedAllowAnonymous,
124558
- ...username.length > 0 ? { username } : {},
124559
- ...password.length > 0 ? { password } : {}
124560
- };
124561
- }
124562
124881
  collectExternalProbeInputs() {
124563
124882
  const out = [];
124564
124883
  for (const b of this.activeExternalBrokers) {
124565
- if (b.onboard !== void 0 || b.enabled === false) continue;
124884
+ if (b.onboard !== void 0 || !isInstanceEnabled(b)) continue;
124566
124885
  let password;
124567
124886
  try {
124568
124887
  password = this.storedPassword(b.password);
@@ -124595,19 +124914,13 @@ var MqttBrokerAddon = class extends BaseAddon {
124595
124914
  * here (no migration code ships): the operator's one-off `mqtt` step does.
124596
124915
  */
124597
124916
  reportRetiredCiphertext() {
124598
- const paths = retiredCiphertextPaths({
124599
- brokers: this.activeExternalBrokers,
124600
- embeddedPassword: this.config.embeddedPassword
124601
- });
124917
+ const paths = retiredCiphertextPaths({ brokers: this.activeExternalBrokers });
124602
124918
  if (paths.length === 0) return;
124603
124919
  const unreadable = [];
124604
124920
  const values = [...this.activeExternalBrokers.map((b) => ({
124605
124921
  id: `brokers[${b.id}].password`,
124606
124922
  v: b.password
124607
- })), {
124608
- id: "embeddedPassword",
124609
- v: this.config.embeddedPassword
124610
- }];
124923
+ }))];
124611
124924
  for (const { id, v } of values) try {
124612
124925
  this.storedPassword(v);
124613
124926
  } catch (err) {
@@ -124631,13 +124944,79 @@ var MqttBrokerAddon = class extends BaseAddon {
124631
124944
  }
124632
124945
  });
124633
124946
  }
124634
- brokerForm() {
124947
+ /**
124948
+ * The broker form. The create form (`id` null) offers "run an onboard broker" (CamStack hosts an
124949
+ * `aedes` server on this hub: a port, anonymous access) or an external URL; an existing onboard
124950
+ * broker edits only its credentials, since its address is the one it was given.
124951
+ */
124952
+ brokerForm(id, onboard) {
124953
+ const credentials = [{
124954
+ type: "text",
124955
+ key: "username",
124956
+ label: "Username",
124957
+ labelKey: "mqtt-broker:fullSettings.brokers.username.label"
124958
+ }, {
124959
+ type: "password",
124960
+ key: "password",
124961
+ label: "Password",
124962
+ labelKey: "mqtt-broker:fullSettings.brokers.password.label",
124963
+ showToggle: true
124964
+ }];
124965
+ if (onboard) return this.schema({ sections: [{
124966
+ id: "broker",
124967
+ title: "Onboard broker",
124968
+ description: "A CamStack-hosted broker on this hub. Its port was chosen when it was created.",
124969
+ columns: 1,
124970
+ fields: credentials
124971
+ }] });
124972
+ const creating = id === null;
124973
+ const external = {
124974
+ field: "onboard",
124975
+ equals: false
124976
+ };
124635
124977
  return this.schema({ sections: [{
124636
124978
  id: "broker",
124637
- title: "External broker",
124638
- description: "A broker running outside CamStack (mosquitto, EMQX, HiveMQ, cloud bridges, …). The password is kept in the keychain.",
124979
+ title: creating ? "MQTT broker" : "External broker",
124980
+ description: creating ? "Run a broker on this hub, or connect to one running outside CamStack (mosquitto, EMQX, HiveMQ, cloud bridges, …). The password is kept in the keychain." : "A broker running outside CamStack (mosquitto, EMQX, HiveMQ, cloud bridges, …). The password is kept in the keychain.",
124639
124981
  columns: 1,
124640
124982
  fields: [
124983
+ ...creating ? [
124984
+ {
124985
+ type: "boolean",
124986
+ key: "onboard",
124987
+ label: "Run an onboard broker",
124988
+ labelKey: "mqtt-broker:fullSettings.brokers.onboard.label",
124989
+ description: "Spin up a CamStack-hosted aedes broker on this hub instead of connecting to an external one. Each onboard broker gets its own dedicated server + port.",
124990
+ descriptionKey: "mqtt-broker:fullSettings.brokers.onboard.description",
124991
+ default: false
124992
+ },
124993
+ {
124994
+ type: "number",
124995
+ key: "port",
124996
+ label: "Port",
124997
+ labelKey: "mqtt-broker:fullSettings.brokers.port.label",
124998
+ placeholder: "(auto)",
124999
+ description: "TCP port for the onboard broker. Leave blank to auto-allocate a free port.",
125000
+ descriptionKey: "mqtt-broker:fullSettings.brokers.port.description",
125001
+ showWhen: {
125002
+ field: "onboard",
125003
+ equals: true
125004
+ }
125005
+ },
125006
+ {
125007
+ type: "boolean",
125008
+ key: "allowAnonymous",
125009
+ label: "Allow anonymous",
125010
+ labelKey: "mqtt-broker:fullSettings.brokers.allowAnonymous.label",
125011
+ description: "Permit clients to connect without a username/password.",
125012
+ descriptionKey: "mqtt-broker:fullSettings.brokers.allowAnonymous.description",
125013
+ default: false,
125014
+ showWhen: {
125015
+ field: "onboard",
125016
+ equals: true
125017
+ }
125018
+ }
125019
+ ] : [],
124641
125020
  {
124642
125021
  type: "text",
124643
125022
  key: "url",
@@ -124645,26 +125024,15 @@ var MqttBrokerAddon = class extends BaseAddon {
124645
125024
  labelKey: "mqtt-broker:fullSettings.brokers.url.label",
124646
125025
  description: "mqtt://, mqtts://, ws://, wss://",
124647
125026
  descriptionKey: "mqtt-broker:fullSettings.brokers.url.description",
124648
- required: true
124649
- },
124650
- {
124651
- type: "text",
124652
- key: "username",
124653
- label: "Username",
124654
- labelKey: "mqtt-broker:fullSettings.brokers.username.label"
124655
- },
124656
- {
124657
- type: "password",
124658
- key: "password",
124659
- label: "Password",
124660
- labelKey: "mqtt-broker:fullSettings.brokers.password.label",
124661
- showToggle: true
125027
+ ...creating ? { showWhen: external } : { required: true }
124662
125028
  },
125029
+ ...credentials,
124663
125030
  {
124664
125031
  type: "text",
124665
125032
  key: "clientIdPrefix",
124666
125033
  label: "Client id prefix",
124667
- labelKey: "mqtt-broker:fullSettings.brokers.clientIdPrefix.label"
125034
+ labelKey: "mqtt-broker:fullSettings.brokers.clientIdPrefix.label",
125035
+ ...creating ? { showWhen: external } : {}
124668
125036
  }
124669
125037
  ]
124670
125038
  }] });
@@ -124675,6 +125043,41 @@ var MqttBrokerAddon = class extends BaseAddon {
124675
125043
  if (handle === void 0) throw new Error("records are not available");
124676
125044
  return {
124677
125045
  readRawBrokers: async () => (await this.ctx.settings?.readAddonStore())?.["brokers"],
125046
+ readLegacyEmbedded: async () => {
125047
+ const raw = await this.ctx.settings?.readAddonStore() ?? {};
125048
+ const secrets = this.ctx.secrets;
125049
+ const { value } = secrets === void 0 ? { value: raw } : await resolveSecretMarkers(raw, secrets, {
125050
+ unresolved: "keep-marker",
125051
+ ownedBy: secrets.owner
125052
+ });
125053
+ const text = (key) => {
125054
+ const v = value[key];
125055
+ return typeof v === "string" && v.length > 0 ? v : void 0;
125056
+ };
125057
+ const port = value["embeddedPort"];
125058
+ const username = text("embeddedUsername");
125059
+ const password = this.storedPassword(text("embeddedPassword"));
125060
+ const enabled = value["embeddedEnabled"] === true;
125061
+ if (!(enabled || text("embeddedId") !== void 0 || username !== void 0 || password !== void 0)) return null;
125062
+ return {
125063
+ enabled,
125064
+ id: text("embeddedId") ?? "embedded",
125065
+ port: typeof port === "number" && Number.isInteger(port) ? port : 1883,
125066
+ allowAnonymous: value["embeddedAllowAnonymous"] === true,
125067
+ ...username !== void 0 ? { username } : {},
125068
+ ...password !== void 0 ? { password } : {}
125069
+ };
125070
+ },
125071
+ readRawEmbeddedKeys: async () => {
125072
+ const raw = await this.ctx.settings?.readAddonStore() ?? {};
125073
+ return Object.fromEntries(LEGACY_EMBEDDED_KEYS.filter((key) => key in raw).map((key) => [key, raw[key]]));
125074
+ },
125075
+ putOnboardRecord: async (id, body) => {
125076
+ await handle.put({
125077
+ id,
125078
+ data: mqttBrokersEntryKind.schema.parse(body)
125079
+ }, { ifRev: 0 });
125080
+ },
124678
125081
  readRecords: async () => {
124679
125082
  const page = await handle.query({ limit: 1e3 });
124680
125083
  return new Map(page.records.map((r) => [r.id, { hasPassword: (r.data.password ?? "").length > 0 }]));
@@ -124691,184 +125094,105 @@ var MqttBrokerAddon = class extends BaseAddon {
124691
125094
  return ["mqtt-broker", "broker"];
124692
125095
  }
124693
125096
  /**
124694
- * The embedded broker, its credentials and the ACL. The external brokers are records, listed
125097
+ * The credentials and the ACL of the onboard brokers. The brokers are records, listed
124695
125098
  * on the Brokers page and edited as integration instances (`brokerForm`).
124696
125099
  */
124697
125100
  async clusterSettingsSchema(cap) {
124698
125101
  if (cap !== "mqtt-broker" && cap !== "broker") return null;
124699
- return this.schema({ sections: [
124700
- {
124701
- id: "mqtt-broker-embedded",
124702
- title: "Embedded broker",
124703
- titleKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.title",
124704
- description: "Optional CamStack-hosted aedes broker. Enable it to run a broker on this hub without standing up an external one — its connection details are exposed through the `mqtt-broker` cap like any external entry.",
124705
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.description",
124706
- columns: 2,
124707
- fields: [
124708
- this.field({
124709
- type: "boolean",
124710
- key: "embeddedEnabled",
124711
- label: "Run embedded broker",
124712
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedEnabled.label",
124713
- description: "Starts an aedes broker on this hub. Disabling it shuts the broker down and disconnects clients.",
124714
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedEnabled.description",
124715
- style: "switch",
124716
- default: false
124717
- }),
124718
- this.field({
124719
- type: "number",
124720
- key: "embeddedPort",
124721
- label: "Port",
124722
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedPort.label",
124723
- description: "TCP port the embedded broker listens on.",
124724
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedPort.description",
124725
- min: 1,
124726
- max: 65535,
124727
- default: DEFAULT_EMBEDDED_PORT,
124728
- showWhen: {
124729
- field: "embeddedEnabled",
124730
- equals: true
124731
- }
124732
- }),
124733
- this.field({
124734
- type: "boolean",
124735
- key: "embeddedAllowAnonymous",
124736
- label: "Allow anonymous",
124737
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedAllowAnonymous.label",
124738
- description: "Permit clients to connect without a username/password.",
124739
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedAllowAnonymous.description",
124740
- style: "switch",
124741
- default: false,
124742
- showWhen: {
124743
- field: "embeddedEnabled",
124744
- equals: true
124745
- }
124746
- }),
124747
- this.field({
124748
- type: "text",
124749
- key: "embeddedUsername",
124750
- label: "Shared username",
124751
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedUsername.label",
124752
- description: "Legacy single-user fallback — used only when no per-client credentials are configured below.",
124753
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedUsername.description",
124754
- default: "",
124755
- showWhen: {
124756
- field: "embeddedEnabled",
124757
- equals: true
124758
- }
124759
- }),
124760
- this.field({
124761
- type: "password",
124762
- key: "embeddedPassword",
124763
- label: "Shared password",
124764
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedPassword.label",
124765
- description: "Kept in the keychain. Paired with the shared username above.",
124766
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedPassword.description",
124767
- showToggle: true,
124768
- default: "",
124769
- showWhen: {
124770
- field: "embeddedEnabled",
124771
- equals: true
124772
- }
124773
- })
124774
- ]
124775
- },
124776
- {
124777
- id: "mqtt-broker-embedded-clients",
124778
- title: "Embedded broker — credentials",
124779
- titleKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.title",
124780
- description: "Per-client username + bcrypt password hash for the embedded aedes broker. Generate the hash with `npx bcrypt-cli` (or any bcrypt tool, cost factor 10) and paste it here.",
124781
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.description",
124782
- columns: 1,
124783
- fields: [{
124784
- type: "editable-array",
124785
- key: "embeddedClients",
124786
- label: "Clients",
124787
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.label",
124788
- emptyMessage: "No clients configured — embedded broker is in shared-creds / anonymous mode.",
124789
- addLabel: "Add client",
124790
- rowTitleTemplate: "{username}",
124791
- defaultItem: {
124792
- username: "",
124793
- passwordHash: ""
124794
- },
124795
- itemFields: [{
125102
+ return this.schema({ sections: [{
125103
+ id: "mqtt-broker-embedded-clients",
125104
+ title: "Embedded broker — credentials",
125105
+ titleKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.title",
125106
+ description: "Per-client username + bcrypt password hash for the embedded aedes broker. Generate the hash with `npx bcrypt-cli` (or any bcrypt tool, cost factor 10) and paste it here.",
125107
+ descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.description",
125108
+ columns: 1,
125109
+ fields: [{
125110
+ type: "editable-array",
125111
+ key: "embeddedClients",
125112
+ label: "Clients",
125113
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.label",
125114
+ emptyMessage: "No clients configured — embedded broker is in shared-creds / anonymous mode.",
125115
+ addLabel: "Add client",
125116
+ rowTitleTemplate: "{username}",
125117
+ defaultItem: {
125118
+ username: "",
125119
+ passwordHash: ""
125120
+ },
125121
+ itemFields: [{
125122
+ type: "text",
125123
+ key: "username",
125124
+ label: "Username",
125125
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.username.label"
125126
+ }, {
125127
+ type: "text",
125128
+ key: "passwordHash",
125129
+ label: "bcrypt hash",
125130
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.passwordHash.label",
125131
+ description: "bcrypt hash of the password (cost factor 10).",
125132
+ descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.passwordHash.description"
125133
+ }]
125134
+ }]
125135
+ }, {
125136
+ id: "mqtt-broker-embedded-acl",
125137
+ title: "Embedded broker — topic ACL",
125138
+ titleKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.title",
125139
+ description: "Per-user pub/sub allow-list. With zero rules the broker is fully open to authenticated clients. With at least one rule, every user needs at least one rule that matches the topic.",
125140
+ descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.description",
125141
+ columns: 1,
125142
+ fields: [{
125143
+ type: "editable-array",
125144
+ key: "embeddedAcl",
125145
+ label: "Rules",
125146
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.label",
125147
+ emptyMessage: "No ACL rules — embedded broker is wide open to authenticated users.",
125148
+ addLabel: "Add rule",
125149
+ rowTitleTemplate: "{username} → {topicPattern} ({access})",
125150
+ defaultItem: {
125151
+ username: "",
125152
+ topicPattern: "#",
125153
+ access: "both"
125154
+ },
125155
+ itemFields: [
125156
+ {
124796
125157
  type: "text",
124797
125158
  key: "username",
124798
125159
  label: "Username",
124799
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.username.label"
124800
- }, {
125160
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.username.label"
125161
+ },
125162
+ {
124801
125163
  type: "text",
124802
- key: "passwordHash",
124803
- label: "bcrypt hash",
124804
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.passwordHash.label",
124805
- description: "bcrypt hash of the password (cost factor 10).",
124806
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.passwordHash.description"
124807
- }]
124808
- }]
124809
- },
124810
- {
124811
- id: "mqtt-broker-embedded-acl",
124812
- title: "Embedded broker — topic ACL",
124813
- titleKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.title",
124814
- description: "Per-user pub/sub allow-list. With zero rules the broker is fully open to authenticated clients. With at least one rule, every user needs at least one rule that matches the topic.",
124815
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.description",
124816
- columns: 1,
124817
- fields: [{
124818
- type: "editable-array",
124819
- key: "embeddedAcl",
124820
- label: "Rules",
124821
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.label",
124822
- emptyMessage: "No ACL rules — embedded broker is wide open to authenticated users.",
124823
- addLabel: "Add rule",
124824
- rowTitleTemplate: "{username} → {topicPattern} ({access})",
124825
- defaultItem: {
124826
- username: "",
124827
- topicPattern: "#",
124828
- access: "both"
125164
+ key: "topicPattern",
125165
+ label: "Topic pattern",
125166
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.topicPattern.label",
125167
+ description: "MQTT wildcards: `+` = single level, `#` = multi-level.",
125168
+ descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.topicPattern.description"
124829
125169
  },
124830
- itemFields: [
124831
- {
124832
- type: "text",
124833
- key: "username",
124834
- label: "Username",
124835
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.username.label"
124836
- },
124837
- {
124838
- type: "text",
124839
- key: "topicPattern",
124840
- label: "Topic pattern",
124841
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.topicPattern.label",
124842
- description: "MQTT wildcards: `+` = single level, `#` = multi-level.",
124843
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.topicPattern.description"
124844
- },
124845
- {
124846
- type: "select",
124847
- key: "access",
124848
- label: "Access",
124849
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.label",
124850
- options: [
124851
- {
124852
- value: "sub",
124853
- label: "Subscribe only",
124854
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_sub.label"
124855
- },
124856
- {
124857
- value: "pub",
124858
- label: "Publish only",
124859
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_pub.label"
124860
- },
124861
- {
124862
- value: "both",
124863
- label: "Subscribe + publish",
124864
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_both.label"
124865
- }
124866
- ]
124867
- }
124868
- ]
124869
- }]
124870
- }
124871
- ] });
125170
+ {
125171
+ type: "select",
125172
+ key: "access",
125173
+ label: "Access",
125174
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.label",
125175
+ options: [
125176
+ {
125177
+ value: "sub",
125178
+ label: "Subscribe only",
125179
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_sub.label"
125180
+ },
125181
+ {
125182
+ value: "pub",
125183
+ label: "Publish only",
125184
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_pub.label"
125185
+ },
125186
+ {
125187
+ value: "both",
125188
+ label: "Subscribe + publish",
125189
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_both.label"
125190
+ }
125191
+ ]
125192
+ }
125193
+ ]
125194
+ }]
125195
+ }] });
124872
125196
  }
124873
125197
  };
124874
125198
  /**
@@ -124898,13 +125222,6 @@ function bcryptCompareSafe(plain, hash) {
124898
125222
  }
124899
125223
  }
124900
125224
  /**
124901
- * Decide whether a config update to the embedded broker requires a full
124902
- * restart (port change) or can be hot-reloaded (credential / ACL change).
124903
- */
124904
- function requiresEmbeddedRespawn(desired, running) {
124905
- return desired.id !== running.id || desired.port !== running.port || desired.allowAnonymous !== running.allowAnonymous || desired.username !== running.username || desired.password !== running.password;
124906
- }
124907
- /**
124908
125225
  * Read the `username` field off an aedes Client without leaking a cast.
124909
125226
  * aedes attaches the username at runtime but its `.d.ts` doesn't carry
124910
125227
  * it — we narrow with a small structural check.
@@ -124957,16 +125274,6 @@ async function findFreePort() {
124957
125274
  });
124958
125275
  });
124959
125276
  }
124960
- function mapMqttStatus(s) {
124961
- switch (s) {
124962
- case "connected": return "connected";
124963
- case "disconnected": return "disconnected";
124964
- case "auth-failed": return "auth-failed";
124965
- case "unreachable": return "unreachable";
124966
- case "tls-error": return "error";
124967
- default: return "error";
124968
- }
124969
- }
124970
125277
  //#endregion
124971
125278
  exports.MQTT_EMBEDDED_BCRYPT_ROUNDS = MQTT_EMBEDDED_BCRYPT_ROUNDS;
124972
125279
  exports.MqttBrokerAddon = MqttBrokerAddon;