@camstack/addon-mqtt-broker 1.2.151 → 1.2.152

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-BX_MqzUe.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).
@@ -7657,6 +7657,28 @@ var SECRET_RETRY_MAX_MS = 5 * 6e4;
7657
7657
  /** Coalesces a burst of `keychain.changed` events into one re-resolve. */
7658
7658
  var SECRET_CHANGE_DEBOUNCE_MS = 250;
7659
7659
  /**
7660
+ * Pair a catalogue with the function that builds its ports FROM THE MIGRATION API. The one
7661
+ * place a catalogue is bound: a migration reaches nothing a ports binder cannot reach
7662
+ * through `api` (guard `check-migration-ports-surface`).
7663
+ */
7664
+ function migrationSet(catalogue, bindPorts) {
7665
+ return { bind: (api) => defineAddonMigrations(catalogue, bindPorts(api)) };
7666
+ }
7667
+ /** A mixed array is a mistake: the other kind would be dropped without a word. */
7668
+ function refuseMixed(form, kept, dropped) {
7669
+ 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`);
7670
+ }
7671
+ function catalogueOf(list) {
7672
+ const kept = list.filter((entry) => !("execute" in entry));
7673
+ refuseMixed("catalogue, (api) => ports", kept.length, list.length - kept.length);
7674
+ return kept;
7675
+ }
7676
+ function boundOf(list) {
7677
+ const kept = list.filter((entry) => "execute" in entry);
7678
+ refuseMixed("bound migrations", kept.length, list.length - kept.length);
7679
+ return kept;
7680
+ }
7681
+ /**
7660
7682
  * Base class for CamStack addons. Eliminates settings boilerplate:
7661
7683
  *
7662
7684
  * - Typed `config` property with automatic resolution from store + defaults
@@ -7823,23 +7845,21 @@ var BaseAddon = class {
7823
7845
  await this.loadScopedSchemas();
7824
7846
  return normalized;
7825
7847
  }
7848
+ async runMigrations(first, second, third) {
7849
+ if (typeof second === "function") return this.runMigrationSets([migrationSet(catalogueOf(first), second)], third);
7850
+ return this.runBound(boundOf(first), second);
7851
+ }
7826
7852
  /**
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").
7853
+ * Several catalogues of ONE owner in one declaration (the engine replaces an owner's
7854
+ * catalogue on every declare, so two calls would each judge the other's ids "newer").
7855
+ * Build each entry with `migrationSet(catalogue, (api) => ports)`.
7841
7856
  */
7842
- async runMigrations(migrations, options) {
7857
+ async runMigrationSets(sets, options) {
7858
+ const api = this.ctx.migrationApi;
7859
+ if (api === void 0) throw new Error(`${this.ctx.id}: the migration API is not available on this context`);
7860
+ return this.runBound(sets.flatMap((set) => set.bind(api)), options);
7861
+ }
7862
+ async runBound(migrations, options) {
7843
7863
  const api = this.ctx.api.migrations;
7844
7864
  const report = await runAddonMigrations({
7845
7865
  declareOwner: (input) => api.declareOwner.mutate(input),
@@ -13546,7 +13566,7 @@ var RecordIntegrationInstances = class {
13546
13566
  return record === null ? null : this.viewOf(record);
13547
13567
  };
13548
13568
  /** Insert ONE row (`ifRev: 0`: an existing id is refused, never overwritten). */
13549
- createInstance = async ({ id, name, kind }) => {
13569
+ createInstance = async ({ id, name, kind, values }) => {
13550
13570
  const minted = id ?? this.hooks.mintId?.({
13551
13571
  name,
13552
13572
  ...kind !== void 0 ? { kind } : {}
@@ -13556,10 +13576,20 @@ var RecordIntegrationInstances = class {
13556
13576
  id: minted,
13557
13577
  data: this.hooks.initialBody(name, kind)
13558
13578
  }, { ifRev: 0 });
13579
+ const patch = this.patchOf(values ?? {});
13580
+ if (Object.keys(patch).length > 0) await this.handle.patch(minted, patch);
13559
13581
  await this.hooks.onChanged?.(minted, "created");
13560
13582
  return { id: minted };
13561
13583
  };
13562
13584
  updateInstance = async ({ id, name, values }) => {
13585
+ const patch = this.patchOf(values);
13586
+ if (name !== void 0) patch[this.nameField] = name;
13587
+ await this.handle.patch(id, patch);
13588
+ await this.hooks.onChanged?.(id, "updated");
13589
+ return { ok: true };
13590
+ };
13591
+ /** The record patch a form `values` object means: secrets kept, cleared or replaced. */
13592
+ patchOf(values) {
13563
13593
  const patch = {};
13564
13594
  for (const [key, value] of Object.entries(values)) {
13565
13595
  if (value === void 0) continue;
@@ -13570,11 +13600,8 @@ var RecordIntegrationInstances = class {
13570
13600
  else if (isSecretClear(value)) patch[key] = null;
13571
13601
  else if (typeof value === "string" && value !== "" && value !== "__camstack_redacted__" && !isSecretFieldView(value)) patch[key] = value;
13572
13602
  }
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
- };
13603
+ return patch;
13604
+ }
13578
13605
  /** The switch lives in the record body; the addon is told so it applies it (D996). */
13579
13606
  setInstanceEnabled = async ({ id, enabled }) => {
13580
13607
  if (this.registryOwnsEnabled) throw new Error(`instance "${id}": the integrations registry owns the on/off switch of this addon; set it with integrations.update`);
@@ -15783,6 +15810,39 @@ var UnstampedEventMediaCountSchema = object({
15783
15810
  /** Sum across both, or `null` when either lane could not be counted. */
15784
15811
  total: number().int().nonnegative().nullable()
15785
15812
  }).nullable();
15813
+ /**
15814
+ * Where the stored event media sits, per storage location: how many media rows
15815
+ * and how many bytes carry each location id, how many carry none (legacy,
15816
+ * resolved to the default `eventMedia` location), and how many name a location
15817
+ * that was not asked about (a deleted or never-listed one).
15818
+ *
15819
+ * One covering-index aggregate per asked location plus the NULL one, and one
15820
+ * unfiltered total that scans the covering index: O(rows), not a walk of the
15821
+ * rows themselves. `unlisted` is total minus the ASKED locations minus NULL, so
15822
+ * it is a deleted location only when every location id was asked.
15823
+ * `null` is "could not be measured", never zero.
15824
+ */
15825
+ var MediaLocationCensusInputSchema = object({ locationIds: array(string().min(1)).max(50).readonly() });
15826
+ var MediaLocationCensusSchema = object({
15827
+ locations: array(object({
15828
+ locationId: string(),
15829
+ rows: number().int().nonnegative(),
15830
+ bytes: number().nonnegative()
15831
+ })).readonly(),
15832
+ unstamped: object({
15833
+ rows: number().int().nonnegative(),
15834
+ bytes: number().nonnegative()
15835
+ }),
15836
+ /** Rows stamped with a location id that was not in the asked list. */
15837
+ unlisted: object({
15838
+ rows: number().int().nonnegative(),
15839
+ bytes: number().nonnegative()
15840
+ }),
15841
+ total: object({
15842
+ rows: number().int().nonnegative(),
15843
+ bytes: number().nonnegative()
15844
+ })
15845
+ });
15786
15846
  var StorageMigrationMediaMoveInputSchema = RelocateMediaInputSchema.extend({ leaseId: string().min(1) });
15787
15847
  /** The independently selectable logical storage classes — every class
15788
15848
  * `storage.listLocationDeclarations` reports, so an operator never meets a
@@ -46303,52 +46363,7 @@ DeviceType.Light, method(object({
46303
46363
  level: number().min(0).max(100),
46304
46364
  lastChangedAt: number()
46305
46365
  });
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([
46366
+ _enum([
46352
46367
  "connected",
46353
46368
  "disconnected",
46354
46369
  "connecting",
@@ -46356,141 +46371,12 @@ var BrokerStatusEnum = _enum([
46356
46371
  "unreachable",
46357
46372
  "error"
46358
46373
  ]);
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
46374
  var RegistryStatusSchema = object({
46388
46375
  brokerCount: number().int().nonnegative(),
46389
46376
  connectedCount: number().int().nonnegative()
46390
46377
  });
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
46378
  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
46379
  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
46380
  var PublishInputSchema = object({
46495
46381
  brokerId: string(),
46496
46382
  /** Kind-specific routing target.
@@ -46534,51 +46420,12 @@ var brokerCapability = {
46534
46420
  kind: "poll"
46535
46421
  },
46536
46422
  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
46423
  /** Returns the kind-specific connection config the consumer needs
46572
46424
  * to open its own client (MQTT pattern: `{url, username, password,
46573
46425
  * clientIdPrefix}`). HA providers MAY return the auth envelope
46574
46426
  * but typical HA consumers use `publish` / `subscribe` instead.
46575
46427
  * Returns `null` when the broker id is unknown to the provider. */
46576
46428
  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
46429
  publish: method(PublishInputSchema, unknown(), {
46583
46430
  kind: "mutation",
46584
46431
  auth: "admin"
@@ -50651,7 +50498,14 @@ var DiscoveryCandidateSchema = object({
50651
50498
  /** `stableId` of the gateway candidate this device was found BEHIND. */
50652
50499
  parentStableId: string().optional(),
50653
50500
  /** `false` = shown for context but not pickable. Default true. */
50654
- selectable: boolean().optional()
50501
+ selectable: boolean().optional(),
50502
+ /**
50503
+ * Keys of the provider's creation form (`getChildCreationSchema`) this candidate
50504
+ * cannot be adopted without — a key for an encrypted board, a pairing code. The
50505
+ * admin asks for exactly these, on the creation form prefilled with
50506
+ * `prefilledConfig`, before it adopts. Absent or empty = adopt as it is.
50507
+ */
50508
+ needsParams: array(string()).optional()
50655
50509
  });
50656
50510
  /**
50657
50511
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -50710,12 +50564,24 @@ method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind:
50710
50564
  }), array(DiscoveryCandidateSchema), {
50711
50565
  kind: "mutation",
50712
50566
  auth: "admin"
50713
- }), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
50567
+ }), method(object({}), CreationSchemaOutputSchema), method(object({
50568
+ /** The integration asked about; a provider whose integrations differ answers per one. */
50569
+ integrationId: string().optional() }), object({ deviceType: _enum(DeviceType).nullable() })), method(object({
50570
+ candidate: DiscoveryCandidateSchema,
50571
+ /** The integration the operator adopts into; a provider with several instances needs it. */
50572
+ integrationId: string().optional()
50573
+ }), DeviceSummarySchema, {
50714
50574
  kind: "mutation",
50715
50575
  auth: "admin"
50716
- }), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
50576
+ }), method(object({}), boolean()), method(object({
50717
50577
  type: _enum(DeviceType),
50718
- config: record(string(), unknown())
50578
+ /** The integration asked about; a provider whose integrations differ answers per one. */
50579
+ integrationId: string().optional()
50580
+ }), CreationSchemaOutputSchema), method(object({
50581
+ type: _enum(DeviceType),
50582
+ config: record(string(), unknown()),
50583
+ /** The integration the operator adds the device to; a provider with several instances needs it. */
50584
+ integrationId: string().optional()
50719
50585
  }), DeviceSummarySchema, {
50720
50586
  kind: "mutation",
50721
50587
  auth: "admin"
@@ -51425,7 +51291,10 @@ method(object({
51425
51291
  auth: "admin"
51426
51292
  }), method(object({
51427
51293
  addonId: string(),
51428
- type: _enum(DeviceType)
51294
+ type: _enum(DeviceType),
51295
+ /** The integration the form is for; a provider whose integrations differ (a hub
51296
+ * integration takes no manual add) answers per integration. Absent = the addon's own. */
51297
+ integrationId: string().optional()
51429
51298
  }), unknown().nullable()), method(object({
51430
51299
  addonId: string(),
51431
51300
  type: _enum(DeviceType),
@@ -51509,7 +51378,11 @@ method(object({
51509
51378
  kind: "mutation",
51510
51379
  auth: "admin",
51511
51380
  timeoutMs: 12e4
51512
- }), method(object({ addonId: string() }), object({ deviceType: _enum(DeviceType).nullable() }), { auth: "admin" }), method(object({ addonId: string() }), unknown(), { auth: "admin" }), method(object({
51381
+ }), method(object({
51382
+ addonId: string(),
51383
+ /** The integration the Add-Device dialog is open on; absent = the addon's own. */
51384
+ integrationId: string().optional()
51385
+ }), object({ deviceType: _enum(DeviceType).nullable() }), { auth: "admin" }), method(object({ addonId: string() }), unknown(), { auth: "admin" }), method(object({
51513
51386
  deviceId: number(),
51514
51387
  key: string(),
51515
51388
  value: unknown()
@@ -54105,7 +53978,7 @@ var TestConnectionStatusEnum = _enum([
54105
53978
  "inconclusive",
54106
53979
  "unsupported"
54107
53980
  ]);
54108
- var TestConnectionResultSchema$1 = object({
53981
+ var TestConnectionResultSchema = object({
54109
53982
  /** True ONLY for `validated`. Never true for a test that did not run. */
54110
53983
  success: boolean(),
54111
53984
  error: string().optional(),
@@ -54150,7 +54023,7 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
54150
54023
  }), method(_void(), array(AvailableIntegrationTypeSchema), { auth: "admin" }), method(object({
54151
54024
  addonId: string(),
54152
54025
  settings: record(string(), unknown())
54153
- }), TestConnectionResultSchema$1, {
54026
+ }), TestConnectionResultSchema, {
54154
54027
  kind: "mutation",
54155
54028
  auth: "admin"
54156
54029
  });
@@ -54177,6 +54050,14 @@ var ConfigSchemaPassthrough$3 = custom();
54177
54050
  var IntegrationInstanceIdSchema = string().min(1);
54178
54051
  var IntegrationInstanceModeSchema = _enum(["single", "multiple"]);
54179
54052
  /**
54053
+ * What the addon's instances ARE for the Integrations page (planning#416). `devices`: each
54054
+ * instance is an integration that brings devices (the registry owns its on/off switch);
54055
+ * `services`: a connection configured from the addon's own function page, never listed on
54056
+ * Integrations.
54057
+ * It is the same vocabulary as the manifest's `integrationRole`, which stays as an override.
54058
+ */
54059
+ var IntegrationInstanceRoleSchema = _enum(["devices", "services"]);
54060
+ /**
54180
54061
  * One kind of instance a provider can create (an adapter, an account type). The sign-in and
54181
54062
  * connect fields follow the `broker` contract (`broker.cap.ts`): `signIn.basePath` is a
54182
54063
  * hub-relative route set speaking the sign-in contract, `connectPath` / `disconnectPath` open an
@@ -54187,6 +54068,22 @@ var IntegrationInstanceKindSchema = object({
54187
54068
  /** Stable id of the kind, sent back as `createInstance.kind`. */
54188
54069
  id: string().min(1),
54189
54070
  label: string().min(1),
54071
+ /**
54072
+ * The function capability this kind is configured for (`broker`, `smtp-provider`,
54073
+ * `device-provider`, ...). A page section is the kinds whose `functionCap` it names (D1013); a
54074
+ * provider with more than one section must name it on every kind.
54075
+ */
54076
+ functionCap: string().min(1),
54077
+ /**
54078
+ * For `functionCap: 'broker'`: the registry kind of the broker an instance of this kind is
54079
+ * (`matter`, `home-assistant`, ...). A caller that needs a broker of one kind (a link field)
54080
+ * finds the instances whose kind names it; absent takes the kind `id`.
54081
+ */
54082
+ brokerKind: string().min(1).optional(),
54083
+ /** What instances of this kind are for the Integrations page; absent takes the provider's `role`. */
54084
+ role: IntegrationInstanceRoleSchema.optional(),
54085
+ /** `testInstance` really probes the remote for this kind; absent takes the provider's `canTest`. */
54086
+ canTest: boolean().optional(),
54190
54087
  /** The kind signs in through a server-held interactive session. */
54191
54088
  signIn: object({
54192
54089
  basePath: string().min(1),
@@ -54199,11 +54096,7 @@ var IntegrationInstanceKindSchema = object({
54199
54096
  });
54200
54097
  var IntegrationInstanceDescriptionSchema = object({
54201
54098
  /** What the instances are for the Integrations page; see {@link IntegrationInstanceRoleSchema}. */
54202
- role: _enum([
54203
- "devices",
54204
- "services",
54205
- "both"
54206
- ]),
54099
+ role: IntegrationInstanceRoleSchema,
54207
54100
  /** How the devices of an instance are found; absent means `{ mode: 'none' }`. */
54208
54101
  discovery: object({
54209
54102
  mode: _enum([
@@ -54278,7 +54171,11 @@ var integrationInstancesCapability = {
54278
54171
  methods: {
54279
54172
  describe: method(_void(), IntegrationInstanceDescriptionSchema, { auth: "admin" }),
54280
54173
  /** The form; `id: null` is the create form. */
54281
- instanceFormSchema: method(object({ id: IntegrationInstanceIdSchema.nullable() }), ConfigSchemaPassthrough$3, { auth: "admin" }),
54174
+ instanceFormSchema: method(object({
54175
+ id: IntegrationInstanceIdSchema.nullable(),
54176
+ /** The kind of the create form (`id: null`) of a provider with several. */
54177
+ kind: string().min(1).optional()
54178
+ }), ConfigSchemaPassthrough$3, { auth: "admin" }),
54282
54179
  listInstances: method(_void(), array(IntegrationInstanceSchema).readonly(), { auth: "admin" }),
54283
54180
  getInstance: method(object({ id: IntegrationInstanceIdSchema }), IntegrationInstanceSchema.nullable(), { auth: "admin" }),
54284
54181
  /**
@@ -54288,7 +54185,9 @@ var integrationInstancesCapability = {
54288
54185
  createInstance: method(object({
54289
54186
  id: IntegrationInstanceIdSchema.optional(),
54290
54187
  name: string().min(1),
54291
- kind: string().min(1).optional()
54188
+ kind: string().min(1).optional(),
54189
+ /** Form values written with the record (a link picked in the create dialog). */
54190
+ values: record(string(), unknown()).optional()
54292
54191
  }), object({ id: string() }), {
54293
54192
  kind: "mutation",
54294
54193
  auth: "admin"
@@ -54315,8 +54214,10 @@ var integrationInstancesCapability = {
54315
54214
  }),
54316
54215
  testInstance: optionalMethod(object({
54317
54216
  id: IntegrationInstanceIdSchema.nullable(),
54318
- values: record(string(), unknown())
54319
- }), TestConnectionResultSchema$1, {
54217
+ values: record(string(), unknown()),
54218
+ /** The kind to test when `id` is null and the provider has several. */
54219
+ kind: string().min(1).optional()
54220
+ }), TestConnectionResultSchema, {
54320
54221
  kind: "mutation",
54321
54222
  auth: "admin"
54322
54223
  })
@@ -56821,6 +56722,380 @@ var SystemMetricsSchema = object({
56821
56722
  gpuMemoryPercent: number().optional()
56822
56723
  });
56823
56724
  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);
56725
+ /**
56726
+ * records - an addon's own facts and documents (D881): ledgers, audit rows,
56727
+ * config documents, mirrors. The third storage API next to `ctx.settings`
56728
+ * (operator intent, D870) and device-history (numbers over time, D850).
56729
+ *
56730
+ * Who the OWNER is: `callerAddonId`, stamped by the transport on a call that
56731
+ * crossed a process and stated by a hub in-process caller (`ctx.records` does
56732
+ * that with the addon id closed over). It is deliberately NOT in any input
56733
+ * schema, so a session can never supply it, and a call with no stamp is refused
56734
+ * `records/no-caller`. A kind is readable and writable only by its owner; other
56735
+ * addons go through the owner's capability.
56736
+ *
56737
+ * Every read is three-way: a row, `null`, or the call THROWS. A failed read is
56738
+ * never `[]`. Secret fields are never on the wire: a read shows the
56739
+ * redaction token, `secretRefs` hands the owner's `ctx.secrets` the refs it
56740
+ * resolves itself, and a write may carry only a keychain marker bound to the
56741
+ * row's own ref.
56742
+ *
56743
+ * Internal: addons reach it through `ctx.records`, never directly.
56744
+ */
56745
+ var RecordIdSchema = string().min(1).max(256);
56746
+ /** A JSON object body. Size is the engine's check (`maxDataBytes`), not a schema's. */
56747
+ var RecordBodySchema = record(string(), unknown());
56748
+ var RecordInputSchema = object({
56749
+ id: RecordIdSchema,
56750
+ /** The device this row belongs to (D865 removal, per-device reads). */
56751
+ deviceId: number().int().optional(),
56752
+ /** The domain clock, ms: observed / created / opened. */
56753
+ at: number().int().optional(),
56754
+ /** Producer-set expiry, ms. Refused on a kind whose retention is an engine TTL. */
56755
+ expiresAt: number().int().optional(),
56756
+ data: RecordBodySchema
56757
+ });
56758
+ var RecordViewSchema = object({
56759
+ id: string(),
56760
+ deviceId: number().int().nullable(),
56761
+ at: number().nullable(),
56762
+ expiresAt: number().nullable(),
56763
+ rev: number().int(),
56764
+ updatedAt: number(),
56765
+ data: RecordBodySchema
56766
+ });
56767
+ /** Declared index fields only. Anything else does not compile, by design. */
56768
+ var RecordWhereSchema = record(RecordFieldPathSchema, union([
56769
+ string(),
56770
+ number(),
56771
+ boolean(),
56772
+ object({ in: array(union([
56773
+ string(),
56774
+ number(),
56775
+ boolean()
56776
+ ])).min(1).max(RECORD_BATCH_MAX) }),
56777
+ object({
56778
+ gt: union([string(), number()]).optional(),
56779
+ gte: union([string(), number()]).optional(),
56780
+ lt: union([string(), number()]).optional(),
56781
+ lte: union([string(), number()]).optional()
56782
+ }).refine((r) => Object.keys(r).length > 0, { message: "name at least one bound" })
56783
+ ]));
56784
+ var KindShape = { kind: RecordKindNameSchema };
56785
+ var TimeWindowShape = {
56786
+ deviceId: number().int().optional(),
56787
+ atFrom: number().optional(),
56788
+ atTo: number().optional()
56789
+ };
56790
+ var RecordQueryInputSchema = object({
56791
+ ...KindShape,
56792
+ ...TimeWindowShape,
56793
+ where: RecordWhereSchema.optional(),
56794
+ /** Without `where`: `at` (default for a kind with a clock) or `id`. With `where` the order is the index's. */
56795
+ order: _enum(["at", "id"]).optional(),
56796
+ desc: boolean().optional(),
56797
+ limit: number().int().min(1).max(RECORD_QUERY_MAX).default(100),
56798
+ /** Opaque keyset cursor from a previous answer. There is no offset. */
56799
+ cursor: string().min(1).optional()
56800
+ });
56801
+ var RecordQueryResultSchema = object({
56802
+ records: array(RecordViewSchema),
56803
+ nextCursor: string().nullable()
56804
+ });
56805
+ var RecordKindStateSchema = _enum([
56806
+ "created",
56807
+ "unchanged",
56808
+ "updated"
56809
+ ]);
56810
+ var DeclareKindResultSchema = object({
56811
+ kindId: number().int(),
56812
+ state: RecordKindStateSchema,
56813
+ createdIndexes: array(string())
56814
+ });
56815
+ /** Keychain refs a write released (the old marker is gone from the row): the owner deletes the values. */
56816
+ var SecretRefsList = array(string());
56817
+ /**
56818
+ * One op of a `transact`: the single-row writes, each on a kind of the caller.
56819
+ * All ops of one call share ONE database file and ONE `transactFamily`.
56820
+ */
56821
+ var RecordTransactOpSchema = discriminatedUnion("op", [
56822
+ object({
56823
+ op: literal("put"),
56824
+ kind: RecordKindNameSchema,
56825
+ record: RecordInputSchema,
56826
+ ifRev: number().int().min(0).optional()
56827
+ }),
56828
+ object({
56829
+ op: literal("patch"),
56830
+ kind: RecordKindNameSchema,
56831
+ id: RecordIdSchema,
56832
+ data: RecordBodySchema,
56833
+ at: number().int().nullable().optional(),
56834
+ expiresAt: number().int().nullable().optional(),
56835
+ ifRev: number().int().min(1).optional()
56836
+ }),
56837
+ object({
56838
+ op: literal("delete"),
56839
+ kind: RecordKindNameSchema,
56840
+ id: RecordIdSchema
56841
+ })
56842
+ ]);
56843
+ var RecordSecretRefsSchema = record(string(), record(string(), string()));
56844
+ method(object({ kinds: record(RecordKindNameSchema, RecordKindDeclarationSchema) }), object({ kinds: record(string(), DeclareKindResultSchema) }), {
56845
+ kind: "mutation",
56846
+ auth: "admin"
56847
+ }), method(object({
56848
+ ...KindShape,
56849
+ id: RecordIdSchema
56850
+ }), RecordViewSchema.nullable(), { auth: "admin" }), method(object({
56851
+ ...KindShape,
56852
+ ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
56853
+ }), array(RecordViewSchema), { auth: "admin" }), method(object({
56854
+ ...KindShape,
56855
+ ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
56856
+ }), RecordSecretRefsSchema, { auth: "admin" }), method(object({
56857
+ ...KindShape,
56858
+ record: RecordInputSchema,
56859
+ /** Compare-and-set. `0` means "the row must not exist". */
56860
+ ifRev: number().int().min(0).optional()
56861
+ }), object({
56862
+ rev: number().int(),
56863
+ releasedRefs: SecretRefsList
56864
+ }), {
56865
+ kind: "mutation",
56866
+ auth: "admin"
56867
+ }), method(object({
56868
+ ...KindShape,
56869
+ records: array(RecordInputSchema).min(1).max(RECORD_BATCH_MAX)
56870
+ }), object({
56871
+ count: number().int(),
56872
+ releasedRefs: SecretRefsList
56873
+ }), {
56874
+ kind: "mutation",
56875
+ auth: "admin"
56876
+ }), method(object({
56877
+ ...KindShape,
56878
+ id: RecordIdSchema,
56879
+ data: RecordBodySchema,
56880
+ at: number().int().nullable().optional(),
56881
+ expiresAt: number().int().nullable().optional(),
56882
+ ifRev: number().int().min(1).optional()
56883
+ }), object({
56884
+ rev: number().int(),
56885
+ releasedRefs: SecretRefsList
56886
+ }), {
56887
+ kind: "mutation",
56888
+ auth: "admin"
56889
+ }), method(object({
56890
+ ...KindShape,
56891
+ id: RecordIdSchema
56892
+ }), object({
56893
+ deleted: boolean(),
56894
+ secretRefs: SecretRefsList
56895
+ }), {
56896
+ kind: "mutation",
56897
+ auth: "admin"
56898
+ }), method(object({
56899
+ ...KindShape,
56900
+ ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
56901
+ }), object({
56902
+ deleted: number().int(),
56903
+ secretRefs: SecretRefsList
56904
+ }), {
56905
+ kind: "mutation",
56906
+ auth: "admin"
56907
+ }), method(RecordQueryInputSchema, RecordQueryResultSchema, { auth: "admin" }), method(object({
56908
+ ...KindShape,
56909
+ ...TimeWindowShape,
56910
+ where: RecordWhereSchema.optional()
56911
+ }), object({ count: number().int() }), { auth: "admin" }), method(object({
56912
+ ...KindShape,
56913
+ deviceId: number().int().optional(),
56914
+ from: number(),
56915
+ to: number(),
56916
+ bucketMs: number().int().positive()
56917
+ }), object({ buckets: array(object({
56918
+ bucketStart: number(),
56919
+ count: number().int()
56920
+ })) }), { auth: "admin" }), method(object({
56921
+ ...KindShape,
56922
+ op: _enum([
56923
+ "sum",
56924
+ "min",
56925
+ "max",
56926
+ "count"
56927
+ ]),
56928
+ field: RecordFieldPathSchema.optional(),
56929
+ groupBy: RecordFieldPathSchema.optional(),
56930
+ where: RecordWhereSchema.optional()
56931
+ }), object({ rows: array(object({
56932
+ group: union([
56933
+ string(),
56934
+ number(),
56935
+ _null()
56936
+ ]),
56937
+ value: number().nullable()
56938
+ })) }), { auth: "admin" }), method(object({
56939
+ ...KindShape,
56940
+ deviceId: number().int().optional(),
56941
+ atBefore: number().optional(),
56942
+ where: RecordWhereSchema.optional()
56943
+ }), object({
56944
+ deleted: number().int(),
56945
+ secretRefs: SecretRefsList
56946
+ }), {
56947
+ kind: "mutation",
56948
+ auth: "admin"
56949
+ }), method(object({ ops: array(RecordTransactOpSchema).min(1).max(RECORD_BATCH_MAX) }), object({
56950
+ results: array(object({
56951
+ rev: number().int().nullable(),
56952
+ deleted: boolean()
56953
+ })),
56954
+ releasedRefs: SecretRefsList,
56955
+ secretRefs: SecretRefsList
56956
+ }), {
56957
+ kind: "mutation",
56958
+ auth: "admin"
56959
+ }), method(object({
56960
+ deviceId: number().int(),
56961
+ kinds: array(RecordKindNameSchema).optional()
56962
+ }), object({
56963
+ deleted: record(string(), number().int()),
56964
+ secretRefs: SecretRefsList
56965
+ }), {
56966
+ kind: "mutation",
56967
+ auth: "admin"
56968
+ }), method(object(KindShape), object({ id: number().int() }), {
56969
+ kind: "mutation",
56970
+ auth: "admin"
56971
+ });
56972
+ /**
56973
+ * The wire shapes of the migration API, version 1 (planning#447).
56974
+ *
56975
+ * A migration is code that outlives the server it was written against: an addon at
56976
+ * version 1.4 that is brought to 1.9 in one jump still runs its 1.4 catalogue, against
56977
+ * the 1.9 kernel. The only thing that code may call is {@link MigrationApiV1}; this file
56978
+ * is the contract of every method of it, one Zod pair per method.
56979
+ *
56980
+ * APPEND-ONLY. A released method is never removed and never changes meaning: an input
56981
+ * field is never removed or made required, an enum never narrows, an output field is
56982
+ * never removed. A change that cannot be expressed that way is `migration-api.v2`, next
56983
+ * to this file. `scripts/check-migration-api-append-only.ts` holds the line against
56984
+ * `scripts/migration-api-contract.json`, and every `behaviours` id below must have an
56985
+ * `it('<id>'` in `migration-api.contract.spec.ts`.
56986
+ */
56987
+ var Rows = record(string(), unknown());
56988
+ var IntegrationId = object({ integrationId: string().min(1) });
56989
+ var RecordId = object({ id: string().min(1) });
56990
+ var RecordRowSchema = object({
56991
+ id: string(),
56992
+ deviceId: number().int().nullable(),
56993
+ at: number().nullable(),
56994
+ expiresAt: number().nullable(),
56995
+ rev: number().int(),
56996
+ updatedAt: number(),
56997
+ data: Rows
56998
+ });
56999
+ var RecordPutShape = {
57000
+ id: string().min(1),
57001
+ deviceId: number().int().optional(),
57002
+ at: number().int().optional(),
57003
+ expiresAt: number().int().optional(),
57004
+ data: Rows
57005
+ };
57006
+ var MigrationIntegrationSchema = object({
57007
+ id: string(),
57008
+ addonId: string(),
57009
+ name: string(),
57010
+ info: Rows,
57011
+ originStableId: string().optional()
57012
+ });
57013
+ var MigrationDeviceSchema = object({
57014
+ id: number().int(),
57015
+ integrationId: string().nullable().optional(),
57016
+ /** The owning device; null or absent for a root. */
57017
+ parentDeviceId: number().int().nullable().optional(),
57018
+ type: string().optional(),
57019
+ name: string().optional(),
57020
+ stableId: string().optional()
57021
+ });
57022
+ /** What the engine says about the migration API it serves: lets an addon notice an older server. */
57023
+ var MigrationApiInfoSchema = object({
57024
+ version: number().int(),
57025
+ methods: array(string())
57026
+ });
57027
+ object({
57028
+ message: string(),
57029
+ meta: Rows.optional(),
57030
+ /** Grouping tags of the line; a line about a device carries `{ deviceId }`. */
57031
+ tags: object({ deviceId: number().int().optional() }).optional()
57032
+ });
57033
+ object({
57034
+ owner: string().min(1),
57035
+ metas: array(MigrationMetaSchema)
57036
+ }), object({
57037
+ bootId: string(),
57038
+ snapshot: _enum([
57039
+ "none",
57040
+ "written",
57041
+ "refused"
57042
+ ]),
57043
+ steps: array(MigrationOwnerStepSchema),
57044
+ /** The migration API the engine serves; absent from a server that predates it. */
57045
+ migrationApi: MigrationApiInfoSchema.optional()
57046
+ }), object({ id: string().min(1) }), object({
57047
+ proceed: boolean(),
57048
+ state: union([literal("pending"), MigrationStatusSchema]),
57049
+ reason: string().nullable()
57050
+ }), object({
57051
+ id: string().min(1),
57052
+ result: MigrationOwnerResultSchema
57053
+ }), object({
57054
+ status: MigrationStatusSchema,
57055
+ error: string().nullable(),
57056
+ rowsChanged: number().int().nonnegative()
57057
+ }), object({
57058
+ rows: number().int().nonnegative(),
57059
+ secrets: number().int().nonnegative()
57060
+ }), IntegrationId.extend({ patch: Rows }), object({ written: boolean() }), IntegrationId.extend({ keys: array(string().min(1)) }), object({
57061
+ present: boolean(),
57062
+ removed: Rows
57063
+ }), RecordRowSchema.nullable(), RecordRowSchema.nullable(), object({
57064
+ ...RecordPutShape,
57065
+ ifRev: number().int().optional()
57066
+ }), object({ rev: number().int() }), object({ records: array(object(RecordPutShape)) }), object({ count: number().int().nonnegative() }), RecordId.extend({
57067
+ partial: Rows,
57068
+ ifRev: number().int().optional()
57069
+ }), object({ rev: number().int() }), object({ deleted: boolean() }), object({
57070
+ deviceId: number().int().optional(),
57071
+ atFrom: number().optional(),
57072
+ atTo: number().optional(),
57073
+ where: RecordWhereSchema.optional(),
57074
+ limit: number().int().min(1).optional(),
57075
+ cursor: string().min(1).optional(),
57076
+ resolveSecrets: boolean().optional()
57077
+ }), object({
57078
+ records: array(RecordRowSchema),
57079
+ nextCursor: string().nullable()
57080
+ }), object({
57081
+ deviceId: number().int().optional(),
57082
+ atBefore: number().optional(),
57083
+ where: RecordWhereSchema.optional()
57084
+ }), 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({
57085
+ ref: string().min(1),
57086
+ value: string()
57087
+ }), object({}), object({ refs: array(string()) }), object({ deleted: number().int().nonnegative() }), object({}), object({ integrations: array(MigrationIntegrationSchema) }), object({ id: string().min(1) }), MigrationIntegrationSchema.nullable(), object({
57088
+ addonId: string().min(1),
57089
+ integrationId: string().min(1).optional()
57090
+ }), object({ devices: array(MigrationDeviceSchema) }), object({ deviceId: number().int() }), object({
57091
+ id: number().int(),
57092
+ integrationId: string().nullable(),
57093
+ /** Whether the device carries any stored config; its content is not offered. */
57094
+ hasConfig: boolean()
57095
+ }).nullable(), object({
57096
+ deviceId: number().int(),
57097
+ integrationId: string().min(1)
57098
+ }), object({}), object({}), object({ nodeId: string() }), object({}), object({});
56824
57099
  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
57100
  id: string().min(1),
56826
57101
  error: string().min(1)
@@ -56850,7 +57125,9 @@ method(object({}), object({ entries: array(MigrationLedgerEntrySchema) }), { aut
56850
57125
  "written",
56851
57126
  "refused"
56852
57127
  ]),
56853
- steps: array(MigrationOwnerStepSchema)
57128
+ steps: array(MigrationOwnerStepSchema),
57129
+ /** The migration API this engine serves (additive: a server that predates it omits it). */
57130
+ migrationApi: MigrationApiInfoSchema.optional()
56854
57131
  }), {
56855
57132
  kind: "mutation",
56856
57133
  auth: "admin"
@@ -59517,7 +59794,10 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
59517
59794
  }), method(RelocateMediaInputSchema, object({ jobId: string() }), {
59518
59795
  kind: "mutation",
59519
59796
  auth: "admin"
59520
- }), method(object({}), UnstampedEventMediaCountSchema, { auth: "admin" }), method(RelocatableMediaCountInputSchema, RelocatableMediaCountSchema, {
59797
+ }), method(object({}), UnstampedEventMediaCountSchema, { auth: "admin" }), method(MediaLocationCensusInputSchema, MediaLocationCensusSchema, {
59798
+ kind: "query",
59799
+ auth: "admin"
59800
+ }), method(RelocatableMediaCountInputSchema, RelocatableMediaCountSchema, {
59521
59801
  kind: "query",
59522
59802
  auth: "admin"
59523
59803
  }), method(object({}), array(RelocateJobSchema).readonly(), {
@@ -61924,7 +62204,7 @@ var BrokerStatusSchema$1 = _enum([
61924
62204
  "unreachable",
61925
62205
  "tls-error"
61926
62206
  ]);
61927
- var BrokerInfoSchema$1 = object({
62207
+ var BrokerInfoSchema = object({
61928
62208
  id: string(),
61929
62209
  name: string(),
61930
62210
  url: string(),
@@ -61997,7 +62277,7 @@ var mqttBrokerCapability = {
61997
62277
  kind: "poll"
61998
62278
  },
61999
62279
  methods: {
62000
- listBrokers: method(_void(), array(BrokerInfoSchema$1)),
62280
+ listBrokers: method(_void(), array(BrokerInfoSchema)),
62001
62281
  getBrokerConfig: method(IdInputSchema, BrokerConnectionDetailsSchema, { auth: "admin" }),
62002
62282
  addBroker: method(AddBrokerInputSchema, AddBrokerResultSchema, {
62003
62283
  kind: "mutation",
@@ -68324,253 +68604,6 @@ DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, DeviceType.Gateway, met
68324
68604
  auth: "admin"
68325
68605
  });
68326
68606
  /**
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
68607
  * record-entries - the ONE admin surface for every list of user-created entries
68575
68608
  * (D881): notification targets, brokers, LLM profiles, anything an addon
68576
68609
  * declares as an ENTRY kind (`entry: { title }` in `defineRecordKinds`).
@@ -77113,20 +77146,6 @@ Object.freeze({
77113
77146
  access: "create",
77114
77147
  role: "viewer"
77115
77148
  },
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
77149
  "broker.getBrokerConfig": {
77131
77150
  capName: "broker",
77132
77151
  capScope: "system",
@@ -77134,20 +77153,6 @@ Object.freeze({
77134
77153
  access: "view",
77135
77154
  role: "admin"
77136
77155
  },
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
77156
  "broker.getState": {
77152
77157
  capName: "broker",
77153
77158
  capScope: "system",
@@ -77162,20 +77167,6 @@ Object.freeze({
77162
77167
  access: "view",
77163
77168
  role: "admin"
77164
77169
  },
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
77170
  "broker.publish": {
77180
77171
  capName: "broker",
77181
77172
  capScope: "system",
@@ -77183,20 +77174,6 @@ Object.freeze({
77183
77174
  access: "create",
77184
77175
  role: "admin"
77185
77176
  },
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
77177
  "broker.subscribe": {
77201
77178
  capName: "broker",
77202
77179
  capScope: "system",
@@ -77204,20 +77181,6 @@ Object.freeze({
77204
77181
  access: "create",
77205
77182
  role: "admin"
77206
77183
  },
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
77184
  "broker.unsubscribe": {
77222
77185
  capName: "broker",
77223
77186
  capScope: "system",
@@ -82181,6 +82144,13 @@ Object.freeze({
82181
82144
  access: "view",
82182
82145
  role: "viewer"
82183
82146
  },
82147
+ "pipelineAnalytics.getMediaLocationCensus": {
82148
+ capName: "pipeline-analytics",
82149
+ capScope: "device",
82150
+ addonId: null,
82151
+ access: "view",
82152
+ role: "admin"
82153
+ },
82184
82154
  "pipelineAnalytics.getMediaReclaimStatus": {
82185
82155
  capName: "pipeline-analytics",
82186
82156
  capScope: "device",
@@ -91122,6 +91092,48 @@ object({
91122
91092
  delay: number().nonnegative().optional(),
91123
91093
  commands: record(string(), unknown())
91124
91094
  });
91095
+ var BrokerInUseError = class extends Error {
91096
+ brokerId;
91097
+ users;
91098
+ name = "BrokerInUseError";
91099
+ constructor(brokerId, users) {
91100
+ super(`broker '${brokerId}' is still in use by ${users.map((u) => `${u.kind} '${u.name ?? u.id}' (${u.id})`).join(", ")}; unlink them first`);
91101
+ this.brokerId = brokerId;
91102
+ this.users = users;
91103
+ }
91104
+ };
91105
+ /** Throw {@link BrokerInUseError} when any finder names a user of `brokerId`. */
91106
+ async function assertBrokerUnused(brokerId, finders) {
91107
+ const users = [];
91108
+ for (const finder of finders) users.push(...await finder.find(brokerId));
91109
+ if (users.length > 0) throw new BrokerInUseError(brokerId, users);
91110
+ }
91111
+ /**
91112
+ * Users reached through another addon's listed rows (an addon cannot read a record kind it does
91113
+ * not own): every row whose `field` holds the broker id. `list` is a cap call the owner serves.
91114
+ */
91115
+ function fieldUserFinder(spec) {
91116
+ return { find: async (brokerId) => (await spec.list()).filter((row) => row.formValues[spec.field] === brokerId).map((row) => ({
91117
+ kind: spec.kind,
91118
+ id: row.id,
91119
+ ...row.name !== void 0 ? { name: row.name } : {}
91120
+ })) };
91121
+ }
91122
+ /**
91123
+ * An account provider whose `deleteInstance` is refused while anything still uses the account
91124
+ * (D1012). The account is an `integration-instances` kind (D1013), so the refusal lives on the
91125
+ * delete the instances page calls, not on a separate broker cap.
91126
+ */
91127
+ function guardBrokerDelete(base, users) {
91128
+ return {
91129
+ ...base,
91130
+ deleteInstance: async (input) => {
91131
+ await assertBrokerUnused(input.id, users());
91132
+ return base.deleteInstance(input);
91133
+ }
91134
+ };
91135
+ }
91136
+ entryBody({ brokerId: string() });
91125
91137
  //#endregion
91126
91138
  //#region ../../node_modules/xtend/immutable.js
91127
91139
  var require_immutable = /* @__PURE__ */ __commonJSMin(((exports, module) => {
@@ -123166,7 +123178,7 @@ function readOnboardPort(settings) {
123166
123178
  return raw;
123167
123179
  }
123168
123180
  /**
123169
- * Build an onboard {@link BuiltOnboardEntry} from `broker.add` settings.
123181
+ * Build an onboard {@link BuiltOnboardEntry} from the create form's values.
123170
123182
  * Pure — the port is pre-resolved by the caller (auto-allocation is a
123171
123183
  * side-effecting concern kept out of this helper), so the helper performs no
123172
123184
  * I/O of its own.
@@ -123252,7 +123264,7 @@ function resolveStoredPassword(value, legacy) {
123252
123264
  function retiredCiphertextPaths(config) {
123253
123265
  const out = [];
123254
123266
  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");
123267
+ if (config.embeddedPassword?.startsWith("enc:v1:") === true) out.push("embeddedPassword");
123256
123268
  return out;
123257
123269
  }
123258
123270
  //#endregion
@@ -123286,6 +123298,229 @@ var mqttBrokersEntryKind = defineEntryKind("brokers", {
123286
123298
  writeTrigger: "an operator adds or removes an external MQTT broker entry"
123287
123299
  });
123288
123300
  //#endregion
123301
+ //#region src/mqtt-broker-users.ts
123302
+ /** The addon whose integrations name an MQTT broker, and the field they keep that id in. */
123303
+ var MQTT_HA_ADDON_ID = "provider-mqtt-ha";
123304
+ var MQTT_HA_BROKER_FIELD = "mqttBrokerId";
123305
+ /** The finders for everything that still points at an MQTT broker. */
123306
+ function mqttBrokerUsers(listMqttHaInstances) {
123307
+ return [fieldUserFinder({
123308
+ kind: "integration",
123309
+ field: MQTT_HA_BROKER_FIELD,
123310
+ list: listMqttHaInstances
123311
+ })];
123312
+ }
123313
+ /** MQTT Discovery is installed but has not registered as a provider: not known, NOT absent (D315). */
123314
+ var MqttDiscoveryNotReadyError = class extends Error {
123315
+ constructor() {
123316
+ 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");
123317
+ this.name = "MqttDiscoveryNotReadyError";
123318
+ }
123319
+ };
123320
+ /**
123321
+ * The integrations of the MQTT Discovery addon. Nothing can name a broker only when the registry
123322
+ * ANSWERED that the addon supplies no `integration-instances` AND the addon roster ANSWERED that
123323
+ * it is not installed: then the answer is none. An addon that is installed but has not registered
123324
+ * (booting, switched off) is not-yet-known, and a registry or roster that does not answer, or a
123325
+ * read that fails, is unknown too: all of those throw, so the delete is refused (D49, D315).
123326
+ */
123327
+ async function listMqttHaInstancesOver(api) {
123328
+ const info = await api.capabilities.getCapability.query({ name: "integration-instances" });
123329
+ if (info === null) throw new Error("the capability registry did not answer");
123330
+ if (!info.providers.includes("provider-mqtt-ha")) {
123331
+ if ((await api.addons.list.query()).some((row) => row.manifest.id === "provider-mqtt-ha")) throw new MqttDiscoveryNotReadyError();
123332
+ return [];
123333
+ }
123334
+ return api.integrationInstances.listInstances.query({ addonId: MQTT_HA_ADDON_ID });
123335
+ }
123336
+ //#endregion
123337
+ //#region src/mqtt-broker-instances.ts
123338
+ /**
123339
+ * The MQTT brokers as `integration-instances` (D1013): one instance per `brokers` record. An
123340
+ * external broker is a URL; an ONBOARD broker (`onboard: true` on the create form) is a CamStack
123341
+ * `aedes` server on this hub: the create allocates its port (or honours the operator's) and the
123342
+ * addon spawns it from the record, the delete tears it down. Removing a broker an MQTT Discovery
123343
+ * integration still names is refused (D1012).
123344
+ */
123345
+ var MQTT_INSTANCE_KIND = "mqtt";
123346
+ /** The create-form keys that only mean "run an onboard broker"; the record keeps a descriptor. */
123347
+ var ONBOARD_FORM_KEYS = new Set([
123348
+ "onboard",
123349
+ "port",
123350
+ "allowAnonymous"
123351
+ ]);
123352
+ var PROBE_LABELS = {
123353
+ connected: "Connected",
123354
+ disconnected: "Disconnected",
123355
+ "auth-failed": "Authentication failed",
123356
+ unreachable: "Unreachable",
123357
+ "tls-error": "TLS error"
123358
+ };
123359
+ /** The instance's health word: connected / auth-failed / unreachable / tls-error, latency, clients. */
123360
+ function mqttHealthOf(body, live, now) {
123361
+ if (body.enabled === false) return { status: {
123362
+ state: "off",
123363
+ label: "Switched off"
123364
+ } };
123365
+ if (body.onboard !== void 0) return live.onboard === null ? {
123366
+ status: {
123367
+ state: "error",
123368
+ label: "Not listening"
123369
+ },
123370
+ lastCheckedAt: now
123371
+ } : {
123372
+ status: {
123373
+ state: "ok",
123374
+ label: "Listening",
123375
+ detail: `${live.onboard.connectedClients} connected clients`
123376
+ },
123377
+ lastCheckedAt: now
123378
+ };
123379
+ const probe = live.probe;
123380
+ if (probe === void 0) return { status: {
123381
+ state: "unknown",
123382
+ label: "Not checked yet"
123383
+ } };
123384
+ const detail = probe.status === "connected" ? probe.latencyMs !== null ? `${probe.latencyMs} ms` : void 0 : probe.error;
123385
+ return {
123386
+ status: {
123387
+ state: probe.status === "connected" ? "ok" : "error",
123388
+ label: PROBE_LABELS[probe.status],
123389
+ ...detail !== void 0 ? { detail } : {}
123390
+ },
123391
+ lastCheckedAt: probe.lastCheckedAt
123392
+ };
123393
+ }
123394
+ function withoutOnboardKeys(values) {
123395
+ return Object.fromEntries(Object.entries(values).filter(([key]) => !ONBOARD_FORM_KEYS.has(key)));
123396
+ }
123397
+ function typedText(value) {
123398
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
123399
+ }
123400
+ /** A password the form really typed: not blank, not the redaction token, not the hidden view. */
123401
+ function typedSecret(value) {
123402
+ const text = typedText(value);
123403
+ return text === void 0 || text === "__camstack_redacted__" || isSecretFieldView(text) ? void 0 : text;
123404
+ }
123405
+ function resultOf(probe) {
123406
+ return probe.ok ? {
123407
+ success: true,
123408
+ status: "validated",
123409
+ ...probe.latencyMs !== void 0 ? { latencyMs: probe.latencyMs } : {}
123410
+ } : {
123411
+ success: false,
123412
+ status: "rejected",
123413
+ error: probe.error ?? "The broker did not answer"
123414
+ };
123415
+ }
123416
+ function buildMqttBrokerInstances(records, deps) {
123417
+ const instances = new RecordIntegrationInstances(records, mqttBrokersEntryKind, {
123418
+ description: {
123419
+ role: "services",
123420
+ instanceMode: "multiple",
123421
+ kind: "brokers",
123422
+ canTest: true,
123423
+ kinds: [{
123424
+ id: MQTT_INSTANCE_KIND,
123425
+ label: "MQTT broker",
123426
+ functionCap: "broker",
123427
+ brokerKind: "mqtt",
123428
+ canTest: true
123429
+ }]
123430
+ },
123431
+ formSchema: (id) => deps.formSchema(id, false),
123432
+ kindOf: () => MQTT_INSTANCE_KIND,
123433
+ initialBody: (name) => ({
123434
+ name,
123435
+ url: "mqtt://"
123436
+ }),
123437
+ configured: (body) => body.url.trim().length > 0 && body.url.trim() !== "mqtt://",
123438
+ health: (body, id) => mqttHealthOf(body, deps.liveState(id), Date.now()),
123439
+ onChanged: deps.onChanged,
123440
+ test: async ({ id, values }) => {
123441
+ if (id === null && isOnboardRequested(values)) return {
123442
+ success: true,
123443
+ status: "validated",
123444
+ detail: "A free port is picked when the broker is created"
123445
+ };
123446
+ const saved = id === null ? null : await instances.readResolved(id);
123447
+ if (saved?.body.onboard !== void 0) return deps.onboardListening(saved.id) ? {
123448
+ success: true,
123449
+ status: "validated",
123450
+ detail: `Listening on port ${saved.body.onboard.port}`
123451
+ } : {
123452
+ success: false,
123453
+ status: "rejected",
123454
+ error: "The onboard broker is not listening"
123455
+ };
123456
+ const url = typedText(values["url"]) ?? saved?.body.url;
123457
+ if (url === void 0 || url === "mqtt://") return {
123458
+ success: false,
123459
+ status: "rejected",
123460
+ error: "Enter the broker URL first"
123461
+ };
123462
+ const username = typedText(values["username"]) ?? saved?.body.username;
123463
+ const password = typedSecret(values["password"]) ?? saved?.body.password;
123464
+ return resultOf(await deps.probe({
123465
+ url,
123466
+ ...username !== void 0 ? { username } : {},
123467
+ ...password !== void 0 ? { password } : {}
123468
+ }));
123469
+ }
123470
+ });
123471
+ const base = instances.toProvider();
123472
+ /** Port, descriptor and URL of an onboard broker the form asked for. */
123473
+ const onboardValues = async (id, name, values) => {
123474
+ const built = buildOnboardEntry({
123475
+ id,
123476
+ name,
123477
+ port: readOnboardPort(values) ?? await deps.freePort(),
123478
+ settings: values
123479
+ });
123480
+ return {
123481
+ ...withoutOnboardKeys(values),
123482
+ url: built.url,
123483
+ onboard: built.onboard
123484
+ };
123485
+ };
123486
+ return guardBrokerDelete({
123487
+ ...base,
123488
+ instanceFormSchema: async ({ id }) => {
123489
+ const saved = id === null ? null : await instances.getResolved(id);
123490
+ return deps.formSchema(id, saved?.onboard !== void 0);
123491
+ },
123492
+ createInstance: async (input) => {
123493
+ const values = input.values ?? {};
123494
+ if (!isOnboardRequested(values)) return base.createInstance({
123495
+ ...input,
123496
+ values: withoutOnboardKeys(values)
123497
+ });
123498
+ const id = input.id ?? (0, node_crypto.randomUUID)();
123499
+ return base.createInstance({
123500
+ ...input,
123501
+ id,
123502
+ values: await onboardValues(id, input.name, values)
123503
+ });
123504
+ },
123505
+ updateInstance: async (input) => {
123506
+ if (!isOnboardRequested(input.values)) return base.updateInstance({
123507
+ ...input,
123508
+ values: withoutOnboardKeys(input.values)
123509
+ });
123510
+ const current = await instances.getResolved(input.id);
123511
+ if (current === null) throw new Error(`mqtt-broker: broker '${input.id}' not found`);
123512
+ if (current.onboard !== void 0) return base.updateInstance({
123513
+ ...input,
123514
+ values: withoutOnboardKeys(input.values)
123515
+ });
123516
+ return base.updateInstance({
123517
+ ...input,
123518
+ values: await onboardValues(input.id, input.name ?? current.name, input.values)
123519
+ });
123520
+ }
123521
+ }, () => mqttBrokerUsers(deps.listMqttHaInstances));
123522
+ }
123523
+ //#endregion
123289
123524
  //#region src/migrations/brokers-to-records.ts
123290
123525
  /**
123291
123526
  * The external MQTT brokers live in the `brokers` records (D985, D996); the old addon-settings
@@ -123375,46 +123610,163 @@ async function planDropBrokersKey(ports) {
123375
123610
  }] };
123376
123611
  }
123377
123612
  //#endregion
123613
+ //#region src/migrations/embedded-to-onboard.ts
123614
+ /**
123615
+ * The legacy single embedded broker lived in FLAT addon-settings keys (`embeddedEnabled`,
123616
+ * `embeddedPort`, ...). It is an ONBOARD broker record now, so the instance list, the pickers,
123617
+ * the test and the delete all see it (D1013). Two migrations of the catalogue, in this order:
123618
+ *
123619
+ * 1. `embeddedToOnboardRecord` (procedure): a legacy broker (a stopped one as a switched-off
123620
+ * record) becomes a `brokers` record
123621
+ * under the SAME id (`embeddedId`, else the `'embedded'` id the old code derived), so a
123622
+ * consumer that stored that id still resolves. It never overwrites a record of that id.
123623
+ * 2. `planDropEmbeddedKeys` (plan): the flat keys are deleted from the canonical
123624
+ * `addon-settings` rows, and only while the record that replaces them exists and carries the
123625
+ * password the legacy key held. `embeddedClients` / `embeddedAcl` are not flat broker keys
123626
+ * and stay.
123627
+ */
123628
+ /** The flat keys that described the legacy embedded broker. */
123629
+ var LEGACY_EMBEDDED_KEYS = [
123630
+ "embeddedEnabled",
123631
+ "embeddedId",
123632
+ "embeddedPort",
123633
+ "embeddedAllowAnonymous",
123634
+ "embeddedUsername",
123635
+ "embeddedPassword"
123636
+ ];
123637
+ var LEGACY_EMBEDDED_NAME = "Embedded broker";
123638
+ function onboardBodyOf(legacy) {
123639
+ return {
123640
+ name: LEGACY_EMBEDDED_NAME,
123641
+ url: `mqtt://127.0.0.1:${legacy.port}`,
123642
+ ...legacy.username !== void 0 ? { username: legacy.username } : {},
123643
+ ...legacy.password !== void 0 ? { password: legacy.password } : {},
123644
+ onboard: {
123645
+ port: legacy.port,
123646
+ allowAnonymous: legacy.allowAnonymous
123647
+ },
123648
+ ...legacy.enabled ? {} : { enabled: false }
123649
+ };
123650
+ }
123651
+ async function embeddedToOnboardRecord(ports) {
123652
+ const legacy = await ports.readLegacyEmbedded();
123653
+ if (legacy === null) return {
123654
+ outcome: "already-done",
123655
+ summary: { migrated: 0 }
123656
+ };
123657
+ if ((await ports.readRecords()).has(legacy.id)) return {
123658
+ outcome: "already-done",
123659
+ summary: { migrated: 0 }
123660
+ };
123661
+ await ports.putOnboardRecord(legacy.id, onboardBodyOf(legacy));
123662
+ return {
123663
+ outcome: "done",
123664
+ summary: { migrated: 1 }
123665
+ };
123666
+ }
123667
+ async function planDropEmbeddedKeys(ports) {
123668
+ const raw = await ports.readRawEmbeddedKeys();
123669
+ const present = LEGACY_EMBEDDED_KEYS.filter((key) => key in raw);
123670
+ if (present.length === 0) return { edits: [] };
123671
+ const refuse = (reason) => {
123672
+ ports.warn(`mqtt-broker migration: the legacy embedded keys are kept: ${reason}`);
123673
+ throw new Error(`the legacy embedded broker keys are not deleted: ${reason}`);
123674
+ };
123675
+ const legacy = await ports.readLegacyEmbedded();
123676
+ if (legacy !== null) {
123677
+ const record = (await ports.readRecords()).get(legacy.id);
123678
+ if (record === void 0) return refuse(`the record '${legacy.id}' does not exist`);
123679
+ if ((legacy.password ?? "").length > 0 && !record.hasPassword) return refuse(`the record '${legacy.id}' holds no password`);
123680
+ }
123681
+ return { edits: [{
123682
+ op: "deleteKeys",
123683
+ scope: ADDON_SETTINGS_TABLE,
123684
+ keys: present.map((key) => `${MQTT_BROKER_ADDON_ID}.${key}`),
123685
+ precondition: preconditionOf(Object.fromEntries(present.map((key) => [`${MQTT_BROKER_ADDON_ID}.${key}`, {
123686
+ addonId: MQTT_BROKER_ADDON_ID,
123687
+ key,
123688
+ value: raw[key]
123689
+ }])))
123690
+ }] };
123691
+ }
123692
+ //#endregion
123378
123693
  //#region src/migrations/index.ts
123379
123694
  var BROKERS_TO_RECORDS_ID = "mqtt-broker/2026-11-03-brokers-settings-to-records";
123380
123695
  var DROP_BROKERS_KEY_ID = "mqtt-broker/2026-11-03-drop-brokers-key";
123696
+ var EMBEDDED_TO_ONBOARD_ID = "mqtt-broker/2026-11-05-embedded-broker-to-onboard-record";
123697
+ var DROP_EMBEDDED_KEYS_ID = "mqtt-broker/2026-11-05-drop-embedded-keys";
123381
123698
  var FIXTURE_MARK = "a setting key the migration does not know";
123382
123699
  /**
123383
123700
  * The `mqtt-broker` catalogue (D987), offered to the engine when the addon initialises. Both
123384
123701
  * entries are `tolerate`: the embedded broker and every record keep working while a legacy key
123385
123702
  * stays where it is.
123386
123703
  */
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
- }];
123704
+ var ADDON_MIGRATIONS = [
123705
+ {
123706
+ meta: {
123707
+ id: BROKERS_TO_RECORDS_ID,
123708
+ owner: "mqtt-broker",
123709
+ level: "addon-state",
123710
+ seq: 1,
123711
+ dependsOn: [],
123712
+ store: "settings",
123713
+ scope: "cluster",
123714
+ onFailure: "tolerate",
123715
+ bodyKind: "procedure",
123716
+ description: "Check that every external broker of the old settings list is a brokers record; never deletes",
123717
+ fixtureMark: FIXTURE_MARK
123718
+ },
123719
+ procedure: verifyBrokersAreRecords
123720
+ },
123721
+ {
123722
+ meta: {
123723
+ id: DROP_BROKERS_KEY_ID,
123724
+ owner: "mqtt-broker",
123725
+ level: "addon-state",
123726
+ seq: 2,
123727
+ dependsOn: [BROKERS_TO_RECORDS_ID],
123728
+ store: "settings",
123729
+ scope: "cluster",
123730
+ onFailure: "tolerate",
123731
+ bodyKind: "plan",
123732
+ description: "Delete the legacy brokers settings key once every broker is a record",
123733
+ fixtureMark: FIXTURE_MARK
123734
+ },
123735
+ plan: planDropBrokersKey
123736
+ },
123737
+ {
123738
+ meta: {
123739
+ id: EMBEDDED_TO_ONBOARD_ID,
123740
+ owner: "mqtt-broker",
123741
+ level: "addon-state",
123742
+ seq: 3,
123743
+ dependsOn: [DROP_BROKERS_KEY_ID],
123744
+ store: "settings",
123745
+ scope: "cluster",
123746
+ onFailure: "tolerate",
123747
+ bodyKind: "procedure",
123748
+ description: "The legacy embedded broker (flat embedded* keys) becomes an onboard brokers record under the same id; never overwrites a record",
123749
+ fixtureMark: FIXTURE_MARK
123750
+ },
123751
+ procedure: embeddedToOnboardRecord
123752
+ },
123753
+ {
123754
+ meta: {
123755
+ id: DROP_EMBEDDED_KEYS_ID,
123756
+ owner: "mqtt-broker",
123757
+ level: "addon-state",
123758
+ seq: 4,
123759
+ dependsOn: [EMBEDDED_TO_ONBOARD_ID],
123760
+ store: "settings",
123761
+ scope: "cluster",
123762
+ onFailure: "tolerate",
123763
+ bodyKind: "plan",
123764
+ description: "Delete the flat embedded broker keys once the onboard record holds them",
123765
+ fixtureMark: FIXTURE_MARK
123766
+ },
123767
+ plan: planDropEmbeddedKeys
123768
+ }
123769
+ ];
123418
123770
  //#endregion
123419
123771
  //#region src/mqtt-entry-record-kinds.ts
123420
123772
  var aclAccessSchema = _enum([
@@ -123622,123 +123974,25 @@ var MqttAclStore = class MqttAclStore {
123622
123974
  *
123623
123975
  * Lifecycle:
123624
123976
  * - 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.
123977
+ * - The legacy single embedded broker is an onboard `brokers` record (migrated from the flat
123978
+ * `embedded*` keys, same id); `startEmbeddedBroker` / `stopEmbeddedBroker` act on that record.
123979
+ * - `addBroker` / `removeBroker` mutate the records and the runtime state.
123632
123980
  * - `testConnection` does a one-shot `mqtt.connect` race against a 3s
123633
123981
  * timeout AND refreshes the probe cache for the broker.
123634
123982
  */
123635
123983
  /** bcrypt cost factor — exported for tests. */
123636
123984
  var MQTT_EMBEDDED_BCRYPT_ROUNDS = 10;
123637
123985
  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
123986
  var DEFAULT_CONFIG = {
123726
- embeddedEnabled: false,
123727
- embeddedId: "",
123728
- embeddedPort: DEFAULT_EMBEDDED_PORT,
123729
- embeddedAllowAnonymous: false,
123730
- embeddedUsername: "",
123731
- embeddedPassword: "",
123732
123987
  embeddedClients: [],
123733
123988
  embeddedAcl: []
123734
123989
  };
123735
123990
  var MqttBrokerAddon = class extends BaseAddon {
123736
- embeddedRuntime = null;
123737
- /** Per-entry onboard `aedes` runtimes, keyed by `ExternalBrokerEntry.id`. */
123991
+ /** Per-record onboard `aedes` runtimes, keyed by `ExternalBrokerEntry.id`. */
123738
123992
  onboardRuntimes = /* @__PURE__ */ new Map();
123739
123993
  probeCache = null;
123740
123994
  brokerStore = null;
123741
- instances = null;
123995
+ brokerInstances = null;
123742
123996
  clientStore = null;
123743
123997
  aclStore = null;
123744
123998
  /** The external broker rows: the `brokers` records, secrets resolved. */
@@ -123752,6 +124006,20 @@ var MqttBrokerAddon = class extends BaseAddon {
123752
124006
  if (this.brokerStore === null) throw new Error("mqtt-broker: the broker records are not open");
123753
124007
  return this.brokerStore;
123754
124008
  }
124009
+ /** What the addon knows of a broker right now: its onboard server or its last probe. */
124010
+ liveState(id) {
124011
+ const running = this.onboardRuntimes.get(id);
124012
+ const probe = this.probeCache?.get(id);
124013
+ return {
124014
+ onboard: running === void 0 ? null : { connectedClients: running.aedes.connectedClients },
124015
+ ...probe !== void 0 ? { probe: {
124016
+ status: probe.status,
124017
+ latencyMs: probe.latencyMs,
124018
+ ...probe.error !== void 0 ? { error: probe.error } : {},
124019
+ lastCheckedAt: probe.lastCheckedAt
124020
+ } } : {}
124021
+ };
124022
+ }
123755
124023
  async persistExternalBrokers(entries) {
123756
124024
  this.activeExternalBrokers = [...entries];
123757
124025
  await this.requireBrokerStore().replaceAll(entries);
@@ -123801,19 +124069,13 @@ var MqttBrokerAddon = class extends BaseAddon {
123801
124069
  this.brokerStore = MqttBrokerStore.of(records);
123802
124070
  this.clientStore = MqttClientStore.of(records);
123803
124071
  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://",
124072
+ this.brokerInstances = buildMqttBrokerInstances(records, {
124073
+ formSchema: (id, onboard) => this.brokerForm(id, onboard),
124074
+ listMqttHaInstances: () => listMqttHaInstancesOver(this.ctx.api),
124075
+ freePort: () => findFreePort(),
124076
+ probe: (cfg) => this.probeMqtt(cfg),
124077
+ onboardListening: (id) => this.onboardRuntimes.has(id),
124078
+ liveState: (id) => this.liveState(id),
123817
124079
  onChanged: async () => {
123818
124080
  await this.refreshExternalBrokers();
123819
124081
  }
@@ -123842,29 +124104,9 @@ var MqttBrokerAddon = class extends BaseAddon {
123842
124104
  });
123843
124105
  this.probeCache.setBrokersSource(() => this.collectExternalProbeInputs());
123844
124106
  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
124107
  await this.reconcileOnboardRuntimes();
123865
124108
  this.ctx.addDisposer(async () => {
123866
124109
  await this.probeCache?.stop();
123867
- await this.shutdownEmbedded().catch(() => void 0);
123868
124110
  await this.shutdownAllOnboard();
123869
124111
  });
123870
124112
  const provider = {
@@ -123876,11 +124118,13 @@ var MqttBrokerAddon = class extends BaseAddon {
123876
124118
  startEmbeddedBroker: async (input) => this.startEmbeddedBroker(input),
123877
124119
  stopEmbeddedBroker: async ({ id }) => this.stopEmbeddedBroker(id),
123878
124120
  getStatus: async () => ({
123879
- brokerCount: this.activeExternalBrokers.length + (this.config.embeddedEnabled ? 1 : 0),
123880
- embeddedRunning: this.embeddedRuntime !== null
124121
+ brokerCount: this.activeExternalBrokers.length,
124122
+ embeddedRunning: this.onboardRuntimes.size > 0
123881
124123
  })
123882
124124
  };
123883
124125
  const unifiedProvider = this.buildUnifiedBrokerProvider();
124126
+ const brokerInstances = this.brokerInstances;
124127
+ if (brokerInstances === null) throw new Error("mqtt-broker: the broker instances are not built");
123884
124128
  return [
123885
124129
  {
123886
124130
  capability: mqttBrokerCapability,
@@ -123892,111 +124136,21 @@ var MqttBrokerAddon = class extends BaseAddon {
123892
124136
  },
123893
124137
  {
123894
124138
  capability: integrationInstancesCapability,
123895
- provider: this.instances.toProvider()
124139
+ provider: brokerInstances
123896
124140
  }
123897
124141
  ];
123898
124142
  }
124143
+ /**
124144
+ * The bus-only `broker` cap (D1013). The brokers themselves are the `integration-instances`
124145
+ * provider; this one only answers the config read and the status.
124146
+ */
123899
124147
  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
- };
124148
+ const owns = (id) => this.activeExternalBrokers.some((b) => b.id === id);
123923
124149
  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
124150
  getBrokerConfig: async ({ id }) => {
123973
124151
  if (!owns(id)) return null;
123974
124152
  return this.getBrokerConfig(id);
123975
124153
  },
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
124154
  publish: async () => {
124001
124155
  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
124156
  },
@@ -124010,7 +124164,7 @@ var MqttBrokerAddon = class extends BaseAddon {
124010
124164
  getStatus: async () => {
124011
124165
  const all = await this.listBrokers();
124012
124166
  return {
124013
- brokerCount: this.activeExternalBrokers.length + (this.config.embeddedEnabled ? 1 : 0),
124167
+ brokerCount: this.activeExternalBrokers.length,
124014
124168
  connectedCount: all.filter((b) => b.status === "connected").length
124015
124169
  };
124016
124170
  }
@@ -124018,35 +124172,13 @@ var MqttBrokerAddon = class extends BaseAddon {
124018
124172
  }
124019
124173
  async onShutdown() {
124020
124174
  await this.probeCache?.stop();
124021
- await this.shutdownEmbedded().catch(() => void 0);
124022
124175
  await this.shutdownAllOnboard();
124023
124176
  }
124024
124177
  async onConfigChanged() {
124025
124178
  this.activeExternalBrokers = await this.requireBrokerStore().listResolved();
124026
124179
  await this.syncEmbeddedEntriesFromConfig();
124027
124180
  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
- }
124181
+ this.reconcileEmbeddedClients();
124050
124182
  }
124051
124183
  async listBrokers() {
124052
124184
  const out = [];
@@ -124080,22 +124212,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124080
124212
  };
124081
124213
  out.push(info);
124082
124214
  }
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
124215
  return out;
124100
124216
  }
124101
124217
  getBrokerConfig(id) {
@@ -124110,15 +124226,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124110
124226
  ...external.clientIdPrefix !== void 0 ? { clientIdPrefix: external.clientIdPrefix } : {}
124111
124227
  };
124112
124228
  }
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
124229
  throw new Error(`mqtt-broker: broker '${id}' not found`);
124123
124230
  }
124124
124231
  async addBroker(input) {
@@ -124139,44 +124246,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124139
124246
  } });
124140
124247
  return { id };
124141
124248
  }
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
124249
  async removeBroker(id) {
124181
124250
  const removed = this.activeExternalBrokers.find((b) => b.id === id);
124182
124251
  if (removed) {
@@ -124187,13 +124256,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124187
124256
  this.ctx.logger.info("mqtt broker removed", { meta: { id } });
124188
124257
  return;
124189
124258
  }
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
124259
  }
124198
124260
  async testConnection(id) {
124199
124261
  let cfg;
@@ -124212,7 +124274,7 @@ var MqttBrokerAddon = class extends BaseAddon {
124212
124274
  * One-shot connectivity probe against an MQTT endpoint built from a
124213
124275
  * flat config (`{url, username?, password?}`). Shared by the
124214
124276
  * id-keyed `testConnection` (existing broker) and the pre-creation
124215
- * `testSettings` (throwaway settings the operator hasn't saved yet).
124277
+ * the instance test (`testInstance`) of a broker not saved yet (the typed values).
124216
124278
  * Never throws — every failure resolves to `{ ok: false, error }`.
124217
124279
  */
124218
124280
  async probeMqtt(cfg) {
@@ -124257,30 +124319,46 @@ var MqttBrokerAddon = class extends BaseAddon {
124257
124319
  });
124258
124320
  });
124259
124321
  }
124322
+ /** The `brokers` records, for the two legacy cap methods that create or switch off one. */
124323
+ brokerRecordHandle() {
124324
+ const handle = this.ctx.records?.kind(mqttBrokersEntryKind);
124325
+ if (handle === void 0) throw new Error("mqtt-broker: the broker records are not open");
124326
+ return handle;
124327
+ }
124328
+ /** Create an ONBOARD broker record; the reconcile then spawns its `aedes` server. */
124260
124329
  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 = {
124330
+ const id = (0, node_crypto.randomUUID)();
124331
+ const built = buildOnboardEntry({
124264
124332
  id,
124265
- name: "Embedded broker",
124333
+ name: LEGACY_EMBEDDED_NAME,
124266
124334
  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 ?? ""
124335
+ settings: {
124336
+ allowAnonymous: input.allowAnonymous,
124337
+ ...input.username !== void 0 ? { username: input.username } : {},
124338
+ ...input.password !== void 0 ? { password: input.password } : {}
124339
+ }
124279
124340
  });
124341
+ const handle = this.brokerRecordHandle();
124342
+ await handle.put({
124343
+ id,
124344
+ data: mqttBrokersEntryKind.schema.parse({
124345
+ name: built.name,
124346
+ url: built.url,
124347
+ ...built.username !== void 0 ? { username: built.username } : {},
124348
+ ...built.password !== void 0 ? { password: built.password } : {},
124349
+ onboard: built.onboard
124350
+ })
124351
+ }, { ifRev: 0 });
124352
+ await this.refreshExternalBrokers();
124353
+ if (!this.onboardRuntimes.has(id)) {
124354
+ await handle.delete(id);
124355
+ await this.refreshExternalBrokers();
124356
+ throw new Error(`embedded broker could not listen on port ${input.port}`);
124357
+ }
124280
124358
  this.ctx.logger.info("embedded mqtt broker started", {
124281
124359
  meta: {
124282
124360
  id,
124283
- port: entry.port
124361
+ port: input.port
124284
124362
  },
124285
124363
  tags: {
124286
124364
  topic: "mqtt-broker",
@@ -124302,20 +124380,22 @@ var MqttBrokerAddon = class extends BaseAddon {
124302
124380
  });
124303
124381
  return {
124304
124382
  id,
124305
- url: `mqtt://127.0.0.1:${entry.port}`
124383
+ url: built.url
124306
124384
  };
124307
124385
  }
124386
+ /** Switch an onboard broker off (its record stays, so it can be switched on again). */
124308
124387
  async stopEmbeddedBroker(id) {
124309
- if (!this.embeddedRuntime || this.embeddedRuntime.entry.id !== id) return;
124310
- await this.shutdownEmbedded();
124311
- await this.updateGlobalSettings({ embeddedEnabled: false });
124388
+ const handle = this.brokerRecordHandle();
124389
+ const row = await handle.get(id);
124390
+ if (row === null || row.data.onboard === void 0) return;
124391
+ await handle.patch(id, { enabled: false });
124392
+ await this.refreshExternalBrokers();
124312
124393
  this.ctx.logger.info("embedded mqtt broker stopped", { meta: { id } });
124313
124394
  }
124314
124395
  /**
124315
124396
  * 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}).
124397
+ * without assigning it to any field (the onboard brokers keep theirs in
124398
+ * {@link spawnOnboard}).
124319
124399
  */
124320
124400
  async spawnAedes(entry) {
124321
124401
  const aedes = new import_aedes.default();
@@ -124354,15 +124434,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124354
124434
  aedes.close(() => resolve());
124355
124435
  });
124356
124436
  }
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
124437
  /** Spawn a dedicated onboard `aedes` server for an onboard entry. */
124367
124438
  async spawnOnboard(entry) {
124368
124439
  const runtime = await this.spawnAedes(entry);
@@ -124499,8 +124570,9 @@ var MqttBrokerAddon = class extends BaseAddon {
124499
124570
  * (or whose username was removed entirely) get force-closed.
124500
124571
  */
124501
124572
  reconcileEmbeddedClients() {
124502
- const runtime = this.embeddedRuntime;
124503
- if (!runtime) return;
124573
+ for (const runtime of this.onboardRuntimes.values()) this.reconcileRuntimeClients(runtime);
124574
+ }
124575
+ reconcileRuntimeClients(runtime) {
124504
124576
  const prevSnapshot = runtime.credentialsSnapshot;
124505
124577
  const nextSnapshot = this.buildCredentialsSnapshot();
124506
124578
  const affectedUsers = [];
@@ -124539,26 +124611,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124539
124611
  for (const c of this.activeEmbeddedClients) m.set(c.username, c.passwordHash);
124540
124612
  return m;
124541
124613
  }
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
124614
  collectExternalProbeInputs() {
124563
124615
  const out = [];
124564
124616
  for (const b of this.activeExternalBrokers) {
@@ -124595,19 +124647,13 @@ var MqttBrokerAddon = class extends BaseAddon {
124595
124647
  * here (no migration code ships): the operator's one-off `mqtt` step does.
124596
124648
  */
124597
124649
  reportRetiredCiphertext() {
124598
- const paths = retiredCiphertextPaths({
124599
- brokers: this.activeExternalBrokers,
124600
- embeddedPassword: this.config.embeddedPassword
124601
- });
124650
+ const paths = retiredCiphertextPaths({ brokers: this.activeExternalBrokers });
124602
124651
  if (paths.length === 0) return;
124603
124652
  const unreadable = [];
124604
124653
  const values = [...this.activeExternalBrokers.map((b) => ({
124605
124654
  id: `brokers[${b.id}].password`,
124606
124655
  v: b.password
124607
- })), {
124608
- id: "embeddedPassword",
124609
- v: this.config.embeddedPassword
124610
- }];
124656
+ }))];
124611
124657
  for (const { id, v } of values) try {
124612
124658
  this.storedPassword(v);
124613
124659
  } catch (err) {
@@ -124631,13 +124677,79 @@ var MqttBrokerAddon = class extends BaseAddon {
124631
124677
  }
124632
124678
  });
124633
124679
  }
124634
- brokerForm() {
124680
+ /**
124681
+ * The broker form. The create form (`id` null) offers "run an onboard broker" (CamStack hosts an
124682
+ * `aedes` server on this hub: a port, anonymous access) or an external URL; an existing onboard
124683
+ * broker edits only its credentials, since its address is the one it was given.
124684
+ */
124685
+ brokerForm(id, onboard) {
124686
+ const credentials = [{
124687
+ type: "text",
124688
+ key: "username",
124689
+ label: "Username",
124690
+ labelKey: "mqtt-broker:fullSettings.brokers.username.label"
124691
+ }, {
124692
+ type: "password",
124693
+ key: "password",
124694
+ label: "Password",
124695
+ labelKey: "mqtt-broker:fullSettings.brokers.password.label",
124696
+ showToggle: true
124697
+ }];
124698
+ if (onboard) return this.schema({ sections: [{
124699
+ id: "broker",
124700
+ title: "Onboard broker",
124701
+ description: "A CamStack-hosted broker on this hub. Its port was chosen when it was created.",
124702
+ columns: 1,
124703
+ fields: credentials
124704
+ }] });
124705
+ const creating = id === null;
124706
+ const external = {
124707
+ field: "onboard",
124708
+ equals: false
124709
+ };
124635
124710
  return this.schema({ sections: [{
124636
124711
  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.",
124712
+ title: creating ? "MQTT broker" : "External broker",
124713
+ 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
124714
  columns: 1,
124640
124715
  fields: [
124716
+ ...creating ? [
124717
+ {
124718
+ type: "boolean",
124719
+ key: "onboard",
124720
+ label: "Run an onboard broker",
124721
+ labelKey: "mqtt-broker:fullSettings.brokers.onboard.label",
124722
+ 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.",
124723
+ descriptionKey: "mqtt-broker:fullSettings.brokers.onboard.description",
124724
+ default: false
124725
+ },
124726
+ {
124727
+ type: "number",
124728
+ key: "port",
124729
+ label: "Port",
124730
+ labelKey: "mqtt-broker:fullSettings.brokers.port.label",
124731
+ placeholder: "(auto)",
124732
+ description: "TCP port for the onboard broker. Leave blank to auto-allocate a free port.",
124733
+ descriptionKey: "mqtt-broker:fullSettings.brokers.port.description",
124734
+ showWhen: {
124735
+ field: "onboard",
124736
+ equals: true
124737
+ }
124738
+ },
124739
+ {
124740
+ type: "boolean",
124741
+ key: "allowAnonymous",
124742
+ label: "Allow anonymous",
124743
+ labelKey: "mqtt-broker:fullSettings.brokers.allowAnonymous.label",
124744
+ description: "Permit clients to connect without a username/password.",
124745
+ descriptionKey: "mqtt-broker:fullSettings.brokers.allowAnonymous.description",
124746
+ default: false,
124747
+ showWhen: {
124748
+ field: "onboard",
124749
+ equals: true
124750
+ }
124751
+ }
124752
+ ] : [],
124641
124753
  {
124642
124754
  type: "text",
124643
124755
  key: "url",
@@ -124645,26 +124757,15 @@ var MqttBrokerAddon = class extends BaseAddon {
124645
124757
  labelKey: "mqtt-broker:fullSettings.brokers.url.label",
124646
124758
  description: "mqtt://, mqtts://, ws://, wss://",
124647
124759
  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
124760
+ ...creating ? { showWhen: external } : { required: true }
124662
124761
  },
124762
+ ...credentials,
124663
124763
  {
124664
124764
  type: "text",
124665
124765
  key: "clientIdPrefix",
124666
124766
  label: "Client id prefix",
124667
- labelKey: "mqtt-broker:fullSettings.brokers.clientIdPrefix.label"
124767
+ labelKey: "mqtt-broker:fullSettings.brokers.clientIdPrefix.label",
124768
+ ...creating ? { showWhen: external } : {}
124668
124769
  }
124669
124770
  ]
124670
124771
  }] });
@@ -124675,6 +124776,41 @@ var MqttBrokerAddon = class extends BaseAddon {
124675
124776
  if (handle === void 0) throw new Error("records are not available");
124676
124777
  return {
124677
124778
  readRawBrokers: async () => (await this.ctx.settings?.readAddonStore())?.["brokers"],
124779
+ readLegacyEmbedded: async () => {
124780
+ const raw = await this.ctx.settings?.readAddonStore() ?? {};
124781
+ const secrets = this.ctx.secrets;
124782
+ const { value } = secrets === void 0 ? { value: raw } : await resolveSecretMarkers(raw, secrets, {
124783
+ unresolved: "keep-marker",
124784
+ ownedBy: secrets.owner
124785
+ });
124786
+ const text = (key) => {
124787
+ const v = value[key];
124788
+ return typeof v === "string" && v.length > 0 ? v : void 0;
124789
+ };
124790
+ const port = value["embeddedPort"];
124791
+ const username = text("embeddedUsername");
124792
+ const password = this.storedPassword(text("embeddedPassword"));
124793
+ const enabled = value["embeddedEnabled"] === true;
124794
+ if (!(enabled || text("embeddedId") !== void 0 || username !== void 0 || password !== void 0)) return null;
124795
+ return {
124796
+ enabled,
124797
+ id: text("embeddedId") ?? "embedded",
124798
+ port: typeof port === "number" && Number.isInteger(port) ? port : 1883,
124799
+ allowAnonymous: value["embeddedAllowAnonymous"] === true,
124800
+ ...username !== void 0 ? { username } : {},
124801
+ ...password !== void 0 ? { password } : {}
124802
+ };
124803
+ },
124804
+ readRawEmbeddedKeys: async () => {
124805
+ const raw = await this.ctx.settings?.readAddonStore() ?? {};
124806
+ return Object.fromEntries(LEGACY_EMBEDDED_KEYS.filter((key) => key in raw).map((key) => [key, raw[key]]));
124807
+ },
124808
+ putOnboardRecord: async (id, body) => {
124809
+ await handle.put({
124810
+ id,
124811
+ data: mqttBrokersEntryKind.schema.parse(body)
124812
+ }, { ifRev: 0 });
124813
+ },
124678
124814
  readRecords: async () => {
124679
124815
  const page = await handle.query({ limit: 1e3 });
124680
124816
  return new Map(page.records.map((r) => [r.id, { hasPassword: (r.data.password ?? "").length > 0 }]));
@@ -124691,184 +124827,105 @@ var MqttBrokerAddon = class extends BaseAddon {
124691
124827
  return ["mqtt-broker", "broker"];
124692
124828
  }
124693
124829
  /**
124694
- * The embedded broker, its credentials and the ACL. The external brokers are records, listed
124830
+ * The credentials and the ACL of the onboard brokers. The brokers are records, listed
124695
124831
  * on the Brokers page and edited as integration instances (`brokerForm`).
124696
124832
  */
124697
124833
  async clusterSettingsSchema(cap) {
124698
124834
  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: [{
124835
+ return this.schema({ sections: [{
124836
+ id: "mqtt-broker-embedded-clients",
124837
+ title: "Embedded broker — credentials",
124838
+ titleKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.title",
124839
+ 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.",
124840
+ descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.description",
124841
+ columns: 1,
124842
+ fields: [{
124843
+ type: "editable-array",
124844
+ key: "embeddedClients",
124845
+ label: "Clients",
124846
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.label",
124847
+ emptyMessage: "No clients configured — embedded broker is in shared-creds / anonymous mode.",
124848
+ addLabel: "Add client",
124849
+ rowTitleTemplate: "{username}",
124850
+ defaultItem: {
124851
+ username: "",
124852
+ passwordHash: ""
124853
+ },
124854
+ itemFields: [{
124855
+ type: "text",
124856
+ key: "username",
124857
+ label: "Username",
124858
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.username.label"
124859
+ }, {
124860
+ type: "text",
124861
+ key: "passwordHash",
124862
+ label: "bcrypt hash",
124863
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.passwordHash.label",
124864
+ description: "bcrypt hash of the password (cost factor 10).",
124865
+ descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.passwordHash.description"
124866
+ }]
124867
+ }]
124868
+ }, {
124869
+ id: "mqtt-broker-embedded-acl",
124870
+ title: "Embedded broker — topic ACL",
124871
+ titleKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.title",
124872
+ 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.",
124873
+ descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.description",
124874
+ columns: 1,
124875
+ fields: [{
124876
+ type: "editable-array",
124877
+ key: "embeddedAcl",
124878
+ label: "Rules",
124879
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.label",
124880
+ emptyMessage: "No ACL rules — embedded broker is wide open to authenticated users.",
124881
+ addLabel: "Add rule",
124882
+ rowTitleTemplate: "{username} → {topicPattern} ({access})",
124883
+ defaultItem: {
124884
+ username: "",
124885
+ topicPattern: "#",
124886
+ access: "both"
124887
+ },
124888
+ itemFields: [
124889
+ {
124796
124890
  type: "text",
124797
124891
  key: "username",
124798
124892
  label: "Username",
124799
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.username.label"
124800
- }, {
124893
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.username.label"
124894
+ },
124895
+ {
124801
124896
  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"
124897
+ key: "topicPattern",
124898
+ label: "Topic pattern",
124899
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.topicPattern.label",
124900
+ description: "MQTT wildcards: `+` = single level, `#` = multi-level.",
124901
+ descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.topicPattern.description"
124829
124902
  },
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
- ] });
124903
+ {
124904
+ type: "select",
124905
+ key: "access",
124906
+ label: "Access",
124907
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.label",
124908
+ options: [
124909
+ {
124910
+ value: "sub",
124911
+ label: "Subscribe only",
124912
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_sub.label"
124913
+ },
124914
+ {
124915
+ value: "pub",
124916
+ label: "Publish only",
124917
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_pub.label"
124918
+ },
124919
+ {
124920
+ value: "both",
124921
+ label: "Subscribe + publish",
124922
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_both.label"
124923
+ }
124924
+ ]
124925
+ }
124926
+ ]
124927
+ }]
124928
+ }] });
124872
124929
  }
124873
124930
  };
124874
124931
  /**
@@ -124898,13 +124955,6 @@ function bcryptCompareSafe(plain, hash) {
124898
124955
  }
124899
124956
  }
124900
124957
  /**
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
124958
  * Read the `username` field off an aedes Client without leaking a cast.
124909
124959
  * aedes attaches the username at runtime but its `.d.ts` doesn't carry
124910
124960
  * it — we narrow with a small structural check.
@@ -124957,16 +125007,6 @@ async function findFreePort() {
124957
125007
  });
124958
125008
  });
124959
125009
  }
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
125010
  //#endregion
124971
125011
  exports.MQTT_EMBEDDED_BCRYPT_ROUNDS = MQTT_EMBEDDED_BCRYPT_ROUNDS;
124972
125012
  exports.MqttBrokerAddon = MqttBrokerAddon;