@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.
@@ -6021,7 +6021,7 @@ var ZodIssueCode = {
6021
6021
  var ZodFirstPartyTypeKind;
6022
6022
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
6023
6023
  //#endregion
6024
- //#region ../types/dist/sleep-DEZaLbiu.mjs
6024
+ //#region ../types/dist/sleep-BX_MqzUe.mjs
6025
6025
  /**
6026
6026
  * The audio chunk plane's byte format, and the ONE expansion from a coded
6027
6027
  * window to float samples (D455).
@@ -7650,6 +7650,28 @@ var SECRET_RETRY_MAX_MS = 5 * 6e4;
7650
7650
  /** Coalesces a burst of `keychain.changed` events into one re-resolve. */
7651
7651
  var SECRET_CHANGE_DEBOUNCE_MS = 250;
7652
7652
  /**
7653
+ * Pair a catalogue with the function that builds its ports FROM THE MIGRATION API. The one
7654
+ * place a catalogue is bound: a migration reaches nothing a ports binder cannot reach
7655
+ * through `api` (guard `check-migration-ports-surface`).
7656
+ */
7657
+ function migrationSet(catalogue, bindPorts) {
7658
+ return { bind: (api) => defineAddonMigrations(catalogue, bindPorts(api)) };
7659
+ }
7660
+ /** A mixed array is a mistake: the other kind would be dropped without a word. */
7661
+ function refuseMixed(form, kept, dropped) {
7662
+ 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`);
7663
+ }
7664
+ function catalogueOf(list) {
7665
+ const kept = list.filter((entry) => !("execute" in entry));
7666
+ refuseMixed("catalogue, (api) => ports", kept.length, list.length - kept.length);
7667
+ return kept;
7668
+ }
7669
+ function boundOf(list) {
7670
+ const kept = list.filter((entry) => "execute" in entry);
7671
+ refuseMixed("bound migrations", kept.length, list.length - kept.length);
7672
+ return kept;
7673
+ }
7674
+ /**
7653
7675
  * Base class for CamStack addons. Eliminates settings boilerplate:
7654
7676
  *
7655
7677
  * - Typed `config` property with automatic resolution from store + defaults
@@ -7816,23 +7838,21 @@ var BaseAddon = class {
7816
7838
  await this.loadScopedSchemas();
7817
7839
  return normalized;
7818
7840
  }
7841
+ async runMigrations(first, second, third) {
7842
+ if (typeof second === "function") return this.runMigrationSets([migrationSet(catalogueOf(first), second)], third);
7843
+ return this.runBound(boundOf(first), second);
7844
+ }
7819
7845
  /**
7820
- * Run this addon's migration catalogue (D987 M3) through the hub's `migrations`
7821
- * engine. Call it at the point of the boot where the data must already have the new
7822
- * shape — `onInitialize`, or `onRestoreDevices` for a migration that needs the
7823
- * restored devices — with the catalogue bound to the addon's ports
7824
- * (`defineAddonMigrations(ADDON_MIGRATIONS, ports)`).
7825
- *
7826
- * An engine that cannot be reached or refuses the catalogue is logged and runs
7827
- * nothing; the boot continues. A migration that FAILED (or was blocked by one)
7828
- * and declares `onFailure: 'block-owner'` makes this throw, so the addon does not
7829
- * serve data whose shape it cannot trust; `tolerate` migrations never do.
7830
- *
7831
- * ALWAYS pass the whole catalogue; a phase that must run only some of it passes
7832
- * `{ only: [ids] }` — never a filtered catalogue (the engine would judge the
7833
- * omitted applied ids "newer").
7846
+ * Several catalogues of ONE owner in one declaration (the engine replaces an owner's
7847
+ * catalogue on every declare, so two calls would each judge the other's ids "newer").
7848
+ * Build each entry with `migrationSet(catalogue, (api) => ports)`.
7834
7849
  */
7835
- async runMigrations(migrations, options) {
7850
+ async runMigrationSets(sets, options) {
7851
+ const api = this.ctx.migrationApi;
7852
+ if (api === void 0) throw new Error(`${this.ctx.id}: the migration API is not available on this context`);
7853
+ return this.runBound(sets.flatMap((set) => set.bind(api)), options);
7854
+ }
7855
+ async runBound(migrations, options) {
7836
7856
  const api = this.ctx.api.migrations;
7837
7857
  const report = await runAddonMigrations({
7838
7858
  declareOwner: (input) => api.declareOwner.mutate(input),
@@ -13539,7 +13559,7 @@ var RecordIntegrationInstances = class {
13539
13559
  return record === null ? null : this.viewOf(record);
13540
13560
  };
13541
13561
  /** Insert ONE row (`ifRev: 0`: an existing id is refused, never overwritten). */
13542
- createInstance = async ({ id, name, kind }) => {
13562
+ createInstance = async ({ id, name, kind, values }) => {
13543
13563
  const minted = id ?? this.hooks.mintId?.({
13544
13564
  name,
13545
13565
  ...kind !== void 0 ? { kind } : {}
@@ -13549,10 +13569,20 @@ var RecordIntegrationInstances = class {
13549
13569
  id: minted,
13550
13570
  data: this.hooks.initialBody(name, kind)
13551
13571
  }, { ifRev: 0 });
13572
+ const patch = this.patchOf(values ?? {});
13573
+ if (Object.keys(patch).length > 0) await this.handle.patch(minted, patch);
13552
13574
  await this.hooks.onChanged?.(minted, "created");
13553
13575
  return { id: minted };
13554
13576
  };
13555
13577
  updateInstance = async ({ id, name, values }) => {
13578
+ const patch = this.patchOf(values);
13579
+ if (name !== void 0) patch[this.nameField] = name;
13580
+ await this.handle.patch(id, patch);
13581
+ await this.hooks.onChanged?.(id, "updated");
13582
+ return { ok: true };
13583
+ };
13584
+ /** The record patch a form `values` object means: secrets kept, cleared or replaced. */
13585
+ patchOf(values) {
13556
13586
  const patch = {};
13557
13587
  for (const [key, value] of Object.entries(values)) {
13558
13588
  if (value === void 0) continue;
@@ -13563,11 +13593,8 @@ var RecordIntegrationInstances = class {
13563
13593
  else if (isSecretClear(value)) patch[key] = null;
13564
13594
  else if (typeof value === "string" && value !== "" && value !== "__camstack_redacted__" && !isSecretFieldView(value)) patch[key] = value;
13565
13595
  }
13566
- if (name !== void 0) patch[this.nameField] = name;
13567
- await this.handle.patch(id, patch);
13568
- await this.hooks.onChanged?.(id, "updated");
13569
- return { ok: true };
13570
- };
13596
+ return patch;
13597
+ }
13571
13598
  /** The switch lives in the record body; the addon is told so it applies it (D996). */
13572
13599
  setInstanceEnabled = async ({ id, enabled }) => {
13573
13600
  if (this.registryOwnsEnabled) throw new Error(`instance "${id}": the integrations registry owns the on/off switch of this addon; set it with integrations.update`);
@@ -15776,6 +15803,39 @@ var UnstampedEventMediaCountSchema = object({
15776
15803
  /** Sum across both, or `null` when either lane could not be counted. */
15777
15804
  total: number().int().nonnegative().nullable()
15778
15805
  }).nullable();
15806
+ /**
15807
+ * Where the stored event media sits, per storage location: how many media rows
15808
+ * and how many bytes carry each location id, how many carry none (legacy,
15809
+ * resolved to the default `eventMedia` location), and how many name a location
15810
+ * that was not asked about (a deleted or never-listed one).
15811
+ *
15812
+ * One covering-index aggregate per asked location plus the NULL one, and one
15813
+ * unfiltered total that scans the covering index: O(rows), not a walk of the
15814
+ * rows themselves. `unlisted` is total minus the ASKED locations minus NULL, so
15815
+ * it is a deleted location only when every location id was asked.
15816
+ * `null` is "could not be measured", never zero.
15817
+ */
15818
+ var MediaLocationCensusInputSchema = object({ locationIds: array(string().min(1)).max(50).readonly() });
15819
+ var MediaLocationCensusSchema = object({
15820
+ locations: array(object({
15821
+ locationId: string(),
15822
+ rows: number().int().nonnegative(),
15823
+ bytes: number().nonnegative()
15824
+ })).readonly(),
15825
+ unstamped: object({
15826
+ rows: number().int().nonnegative(),
15827
+ bytes: number().nonnegative()
15828
+ }),
15829
+ /** Rows stamped with a location id that was not in the asked list. */
15830
+ unlisted: object({
15831
+ rows: number().int().nonnegative(),
15832
+ bytes: number().nonnegative()
15833
+ }),
15834
+ total: object({
15835
+ rows: number().int().nonnegative(),
15836
+ bytes: number().nonnegative()
15837
+ })
15838
+ });
15779
15839
  var StorageMigrationMediaMoveInputSchema = RelocateMediaInputSchema.extend({ leaseId: string().min(1) });
15780
15840
  /** The independently selectable logical storage classes — every class
15781
15841
  * `storage.listLocationDeclarations` reports, so an operator never meets a
@@ -46296,52 +46356,7 @@ DeviceType.Light, method(object({
46296
46356
  level: number().min(0).max(100),
46297
46357
  lastChangedAt: number()
46298
46358
  });
46299
- /**
46300
- * `broker` — unified pub/sub broker registry, system-scoped collection.
46301
- *
46302
- * The cap models any kind-tagged message broker the user wants to
46303
- * register and that other addons might consume. The first two kinds
46304
- * are MQTT (mosquitto / aedes embedded / cloud bridge) and
46305
- * Home Assistant (HA WebSocket — subscribe_entities + call_service).
46306
- * Future kinds (Zigbee2MQTT bridge, ZHA, KNX, Telegram, …) plug in
46307
- * the same surface.
46308
- *
46309
- * Why one cap, not one cap per kind:
46310
- * - The integrations page wants a single table of "every broker"
46311
- * across kinds; a unified cap drives it without join logic.
46312
- * - Generic admin operations (add / remove / test / status) are
46313
- * identical across kinds — duplicating them per cap was busywork.
46314
- * - The `network-access` cap precedent: same interface, many
46315
- * `providerKind: 'ingress'` implementations (Tailscale, ngrok, …).
46316
- *
46317
- * What stays kind-specific:
46318
- * - `getBrokerConfig` payload (kind decides whether it returns
46319
- * mqtt URL+creds, HA baseUrl+token, …) — typed as
46320
- * `Record<string, unknown>` at the wire; consumers narrow.
46321
- * - `publish` / `subscribe` / `getState` arguments (target / filter /
46322
- * key) — also typed as `Record<string, unknown>` so each kind can
46323
- * evolve its surface (MQTT topic+qos vs HA entity_id+domain)
46324
- * without rebreaking the cap signature.
46325
- * - `add` `settings` payload (different fields per kind, validated by
46326
- * the kind-specific provider).
46327
- *
46328
- * Bidirectionality:
46329
- * - `publish` is RPC — synchronous result (provider-defined).
46330
- * - `subscribe` returns a subscription handle; the actual message
46331
- * stream flows over the typed event-bus as `broker.message` events
46332
- * keyed by `(brokerId, subscriptionId)`. Consumers filter in their
46333
- * event handler. Subscriptions persist across reconnects — the
46334
- * provider re-subscribes upstream after a transport drop.
46335
- *
46336
- * Why event-bus push (not a subscription RPC stream):
46337
- * - Matches the rest of CamStack's D8 contract — events for telemetry,
46338
- * RPC for loss-is-a-bug. Broker messages are telemetry (a single
46339
- * drop is recoverable via `getState({key})`).
46340
- * - Frees `broker.subscribe` from holding a long-lived RPC channel.
46341
- * - Cross-process: events already route through `$event-bus`; no
46342
- * per-broker wire-shaping needed.
46343
- */
46344
- var BrokerStatusEnum = _enum([
46359
+ _enum([
46345
46360
  "connected",
46346
46361
  "disconnected",
46347
46362
  "connecting",
@@ -46349,141 +46364,12 @@ var BrokerStatusEnum = _enum([
46349
46364
  "unreachable",
46350
46365
  "error"
46351
46366
  ]);
46352
- var BrokerInfoSchema = object({
46353
- /** Stable broker id. Persisted; survives addon restarts. */
46354
- id: string(),
46355
- /** Addon id of the provider that OWNS this broker.
46356
- *
46357
- * The `broker` cap is a system-scoped collection: several addons
46358
- * register a `broker` provider, each owning a DISJOINT set of
46359
- * brokers (mqtt-broker owns `mqtt_*`, provider-homeassistant owns
46360
- * `ha_*`). A broker therefore belongs to exactly one addon — like a
46361
- * device belongs to one integration. The admin UI threads this id
46362
- * back as the `{ addonId }` system-collection selector on every
46363
- * id-keyed call (`get` / `getSettings` / `setSettings` / `remove` /
46364
- * `testConnection`), so the call routes to the OWNING provider
46365
- * instead of defaulting to the first-registered one. */
46366
- addonId: string(),
46367
- /** Human-readable name (operator-chosen at add-time). */
46368
- name: string(),
46369
- /** Provider-defined kind tag — `mqtt` / `home-assistant` / future. */
46370
- kind: string(),
46371
- status: BrokerStatusEnum,
46372
- /** Free-form provider-specific info (HA version, MQTT broker
46373
- * flavour, latency, mTLS active, …). */
46374
- info: record(string(), unknown()),
46375
- /** Ms epoch of the last connection probe / status update. */
46376
- lastCheckedAt: number().nullable(),
46377
- /** Last error message, when `status` indicates failure. */
46378
- error: string().nullable()
46379
- });
46380
46367
  var RegistryStatusSchema = object({
46381
46368
  brokerCount: number().int().nonnegative(),
46382
46369
  connectedCount: number().int().nonnegative()
46383
46370
  });
46384
- /**
46385
- * One entry per `broker` provider — which addon provides which broker
46386
- * kind(s). The unified create picker reads this to know, for each
46387
- * (addonId, kind) pair the operator can pick, where to route the
46388
- * follow-up `getSettingsSchema` / `testSettings` / `add` calls (via the
46389
- * `{ addonId }` selector). A provider only advertises a kind here when
46390
- * it implements `getSettingsSchema` for that kind (so the picker never
46391
- * offers a kind whose creation form can't be rendered).
46392
- */
46393
- var BrokerProviderInfoSchema = object({
46394
- /** Addon id of the `broker` provider this entry describes. */
46395
- addonId: string(),
46396
- /** Broker kinds this provider can create, with a display label. */
46397
- kinds: array(object({
46398
- kind: string(),
46399
- label: string(),
46400
- /**
46401
- * Relative hub path to start an interactive account link (OAuth popup).
46402
- * May include `{{brokerId}}`; admin-ui substitutes and appends `?popup=1`.
46403
- */
46404
- interactiveConnectPath: string().min(1).optional(),
46405
- /** Relative hub path to disconnect the linked account (same popup pattern). */
46406
- interactiveDisconnectPath: string().min(1).optional(),
46407
- /**
46408
- * The kind signs in through a server-held interactive session instead of
46409
- * (or before) typed credentials. `basePath` is a hub-relative route set
46410
- * served by the owning addon (`/addon/<id>/...`) speaking the sign-in
46411
- * contract: `POST {basePath}/start` (body: the form's settings) answers
46412
- * `{ sessionId, loginUrl }`; `GET {basePath}/status?sessionId=` answers
46413
- * `{ phase: 'starting' | 'awaiting-login' | 'captured' | 'error' |
46414
- * 'cancelled', loginUrl, error }`; `POST {basePath}/commit`
46415
- * (`{ sessionId, name, brokerId? }`) stores the credentials on a new or
46416
- * existing broker and answers `{ brokerId }`; `POST {basePath}/cancel`
46417
- * (`{ sessionId }`) drops the session. The dialog that edits a broker
46418
- * offers it as the default path; the fields the session fills are
46419
- * marked `filledBySignIn` in the kind's settings schema.
46420
- *
46421
- * `methods` lists the ways the session can sign in, recommended first:
46422
- * `'device-code'` adds `POST {basePath}/code/start` (body: the form's
46423
- * settings) answering `{ sessionId, userCode, verificationUrl,
46424
- * expiresAt }` — the operator types `userCode` at `verificationUrl` on
46425
- * any device, and the same `status` / `commit` / `cancel` routes follow
46426
- * (status then carries `method`, `userCode`, `verificationUrl`,
46427
- * `expiresAt` and the phases `'awaiting-code'` / `'expired'`);
46428
- * `'proxy'` is the `{basePath}/start` flow above. Absent means
46429
- * `['proxy']`.
46430
- */
46431
- signIn: object({
46432
- basePath: string().min(1),
46433
- methods: array(_enum(["device-code", "proxy"])).optional()
46434
- }).optional()
46435
- }))
46436
- });
46437
- var ListInputSchema = object({
46438
- /** Optional kind filter — `list({kind:'home-assistant'})` returns
46439
- * only HA brokers. Omit to list every broker. */
46440
- kind: string().optional() });
46441
46371
  var GetInputSchema = object({ id: string() });
46442
- var AddInputSchema = object({
46443
- kind: string().min(1),
46444
- name: string().min(1),
46445
- /**
46446
- * ADOPT an existing id instead of minting a new one.
46447
- *
46448
- * Written the day this cost an outage. A broker's id is not a detail: HA
46449
- * devices carry it inside their `stableId` (`ha:ha_004:dev:…`), so a broker
46450
- * lost from config and re-added as `ha_001` leaves every one of its devices
46451
- * bound to a broker that no longer exists. Re-entering the password under the
46452
- * ORIGINAL id turns a multi-step device migration back into re-entering a
46453
- * password.
46454
- *
46455
- * A provider MUST refuse an id that is already in use — adopting a live
46456
- * broker's id would silently take it over.
46457
- */
46458
- id: string().min(1).optional(),
46459
- /** Kind-specific settings (e.g. MQTT `{url,username,password}` or HA
46460
- * `{baseUrl,accessToken}`). Validated by the kind-specific provider
46461
- * branch on receipt — invalid shape rejects the add. */
46462
- settings: record(string(), unknown())
46463
- });
46464
- var AddResultSchema = object({ id: string() });
46465
- var RemoveInputSchema = object({ id: string() });
46466
- var TestConnectionResultSchema = discriminatedUnion("ok", [object({
46467
- ok: literal(true),
46468
- latencyMs: number().nonnegative()
46469
- }), object({
46470
- ok: literal(false),
46471
- error: string()
46472
- })]);
46473
46372
  var SettingsRecordSchema$1 = record(string(), unknown());
46474
- var SettingsSchemaInputSchema = object({ kind: string() });
46475
- var TestSettingsInputSchema = object({
46476
- kind: string(),
46477
- settings: SettingsRecordSchema$1
46478
- });
46479
- var TestSettingsResultSchema = discriminatedUnion("ok", [object({
46480
- ok: literal(true),
46481
- latencyMs: number().nonnegative().optional()
46482
- }).strict(), object({
46483
- ok: literal(false),
46484
- error: string()
46485
- })]);
46486
- var SettingsSchemaResultSchema = unknown().nullable();
46487
46373
  var PublishInputSchema = object({
46488
46374
  brokerId: string(),
46489
46375
  /** Kind-specific routing target.
@@ -46527,51 +46413,12 @@ var brokerCapability = {
46527
46413
  kind: "poll"
46528
46414
  },
46529
46415
  methods: {
46530
- list: method(ListInputSchema, array(BrokerInfoSchema)),
46531
- get: method(GetInputSchema, BrokerInfoSchema.nullable()),
46532
- /** Enumerate which addon provides which broker kind(s) for the
46533
- * unified create picker. The auto-mount fans this array across
46534
- * every registered `broker` provider (array-output method), so the
46535
- * picker sees every kind from every provider in one call. */
46536
- listProviders: method(_void(), array(BrokerProviderInfoSchema), { auth: "admin" }),
46537
- add: method(AddInputSchema, AddResultSchema, {
46538
- kind: "mutation",
46539
- auth: "admin"
46540
- }),
46541
- remove: method(RemoveInputSchema, _void(), {
46542
- kind: "mutation",
46543
- auth: "admin"
46544
- }),
46545
- testConnection: method(GetInputSchema, TestConnectionResultSchema, {
46546
- kind: "mutation",
46547
- auth: "admin"
46548
- }),
46549
- /** Read the persisted settings record for a broker (kind-specific
46550
- * shape). Admin-only — settings may contain secrets. Returns `null`
46551
- * when the broker id is unknown to the provider (the collection
46552
- * fallback may route a foreign id to the first provider). */
46553
- getSettings: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
46554
- /** Overwrite the persisted settings record. The kind-specific
46555
- * provider validates the shape and applies the change (reconnects
46556
- * if credentials changed). */
46557
- setSettings: method(object({
46558
- id: string(),
46559
- settings: SettingsRecordSchema$1
46560
- }), _void(), {
46561
- kind: "mutation",
46562
- auth: "admin"
46563
- }),
46564
46416
  /** Returns the kind-specific connection config the consumer needs
46565
46417
  * to open its own client (MQTT pattern: `{url, username, password,
46566
46418
  * clientIdPrefix}`). HA providers MAY return the auth envelope
46567
46419
  * but typical HA consumers use `publish` / `subscribe` instead.
46568
46420
  * Returns `null` when the broker id is unknown to the provider. */
46569
46421
  getBrokerConfig: method(GetInputSchema, SettingsRecordSchema$1.nullable(), { auth: "admin" }),
46570
- getSettingsSchema: method(SettingsSchemaInputSchema, SettingsSchemaResultSchema, { auth: "admin" }),
46571
- testSettings: method(TestSettingsInputSchema, TestSettingsResultSchema, {
46572
- kind: "mutation",
46573
- auth: "admin"
46574
- }),
46575
46422
  publish: method(PublishInputSchema, unknown(), {
46576
46423
  kind: "mutation",
46577
46424
  auth: "admin"
@@ -50644,7 +50491,14 @@ var DiscoveryCandidateSchema = object({
50644
50491
  /** `stableId` of the gateway candidate this device was found BEHIND. */
50645
50492
  parentStableId: string().optional(),
50646
50493
  /** `false` = shown for context but not pickable. Default true. */
50647
- selectable: boolean().optional()
50494
+ selectable: boolean().optional(),
50495
+ /**
50496
+ * Keys of the provider's creation form (`getChildCreationSchema`) this candidate
50497
+ * cannot be adopted without — a key for an encrypted board, a pairing code. The
50498
+ * admin asks for exactly these, on the creation form prefilled with
50499
+ * `prefilledConfig`, before it adopts. Absent or empty = adopt as it is.
50500
+ */
50501
+ needsParams: array(string()).optional()
50648
50502
  });
50649
50503
  /**
50650
50504
  * Flat device summary returned by `createDevice` / `adoptDiscoveredDevice`.
@@ -50703,12 +50557,24 @@ method(_void(), _void(), { kind: "mutation" }), method(_void(), _void(), { kind:
50703
50557
  }), array(DiscoveryCandidateSchema), {
50704
50558
  kind: "mutation",
50705
50559
  auth: "admin"
50706
- }), method(object({}), CreationSchemaOutputSchema), method(object({}), object({ deviceType: _enum(DeviceType).nullable() })), method(object({ candidate: DiscoveryCandidateSchema }), DeviceSummarySchema, {
50560
+ }), method(object({}), CreationSchemaOutputSchema), method(object({
50561
+ /** The integration asked about; a provider whose integrations differ answers per one. */
50562
+ integrationId: string().optional() }), object({ deviceType: _enum(DeviceType).nullable() })), method(object({
50563
+ candidate: DiscoveryCandidateSchema,
50564
+ /** The integration the operator adopts into; a provider with several instances needs it. */
50565
+ integrationId: string().optional()
50566
+ }), DeviceSummarySchema, {
50707
50567
  kind: "mutation",
50708
50568
  auth: "admin"
50709
- }), method(object({}), boolean()), method(object({ type: _enum(DeviceType) }), CreationSchemaOutputSchema), method(object({
50569
+ }), method(object({}), boolean()), method(object({
50710
50570
  type: _enum(DeviceType),
50711
- config: record(string(), unknown())
50571
+ /** The integration asked about; a provider whose integrations differ answers per one. */
50572
+ integrationId: string().optional()
50573
+ }), CreationSchemaOutputSchema), method(object({
50574
+ type: _enum(DeviceType),
50575
+ config: record(string(), unknown()),
50576
+ /** The integration the operator adds the device to; a provider with several instances needs it. */
50577
+ integrationId: string().optional()
50712
50578
  }), DeviceSummarySchema, {
50713
50579
  kind: "mutation",
50714
50580
  auth: "admin"
@@ -51418,7 +51284,10 @@ method(object({
51418
51284
  auth: "admin"
51419
51285
  }), method(object({
51420
51286
  addonId: string(),
51421
- type: _enum(DeviceType)
51287
+ type: _enum(DeviceType),
51288
+ /** The integration the form is for; a provider whose integrations differ (a hub
51289
+ * integration takes no manual add) answers per integration. Absent = the addon's own. */
51290
+ integrationId: string().optional()
51422
51291
  }), unknown().nullable()), method(object({
51423
51292
  addonId: string(),
51424
51293
  type: _enum(DeviceType),
@@ -51502,7 +51371,11 @@ method(object({
51502
51371
  kind: "mutation",
51503
51372
  auth: "admin",
51504
51373
  timeoutMs: 12e4
51505
- }), method(object({ addonId: string() }), object({ deviceType: _enum(DeviceType).nullable() }), { auth: "admin" }), method(object({ addonId: string() }), unknown(), { auth: "admin" }), method(object({
51374
+ }), method(object({
51375
+ addonId: string(),
51376
+ /** The integration the Add-Device dialog is open on; absent = the addon's own. */
51377
+ integrationId: string().optional()
51378
+ }), object({ deviceType: _enum(DeviceType).nullable() }), { auth: "admin" }), method(object({ addonId: string() }), unknown(), { auth: "admin" }), method(object({
51506
51379
  deviceId: number(),
51507
51380
  key: string(),
51508
51381
  value: unknown()
@@ -54098,7 +53971,7 @@ var TestConnectionStatusEnum = _enum([
54098
53971
  "inconclusive",
54099
53972
  "unsupported"
54100
53973
  ]);
54101
- var TestConnectionResultSchema$1 = object({
53974
+ var TestConnectionResultSchema = object({
54102
53975
  /** True ONLY for `validated`. Never true for a test that did not run. */
54103
53976
  success: boolean(),
54104
53977
  error: string().optional(),
@@ -54143,7 +54016,7 @@ method(_void(), array(IntegrationWithStateSchema)), method(object({ id: string()
54143
54016
  }), method(_void(), array(AvailableIntegrationTypeSchema), { auth: "admin" }), method(object({
54144
54017
  addonId: string(),
54145
54018
  settings: record(string(), unknown())
54146
- }), TestConnectionResultSchema$1, {
54019
+ }), TestConnectionResultSchema, {
54147
54020
  kind: "mutation",
54148
54021
  auth: "admin"
54149
54022
  });
@@ -54170,6 +54043,14 @@ var ConfigSchemaPassthrough$3 = custom();
54170
54043
  var IntegrationInstanceIdSchema = string().min(1);
54171
54044
  var IntegrationInstanceModeSchema = _enum(["single", "multiple"]);
54172
54045
  /**
54046
+ * What the addon's instances ARE for the Integrations page (planning#416). `devices`: each
54047
+ * instance is an integration that brings devices (the registry owns its on/off switch);
54048
+ * `services`: a connection configured from the addon's own function page, never listed on
54049
+ * Integrations.
54050
+ * It is the same vocabulary as the manifest's `integrationRole`, which stays as an override.
54051
+ */
54052
+ var IntegrationInstanceRoleSchema = _enum(["devices", "services"]);
54053
+ /**
54173
54054
  * One kind of instance a provider can create (an adapter, an account type). The sign-in and
54174
54055
  * connect fields follow the `broker` contract (`broker.cap.ts`): `signIn.basePath` is a
54175
54056
  * hub-relative route set speaking the sign-in contract, `connectPath` / `disconnectPath` open an
@@ -54180,6 +54061,22 @@ var IntegrationInstanceKindSchema = object({
54180
54061
  /** Stable id of the kind, sent back as `createInstance.kind`. */
54181
54062
  id: string().min(1),
54182
54063
  label: string().min(1),
54064
+ /**
54065
+ * The function capability this kind is configured for (`broker`, `smtp-provider`,
54066
+ * `device-provider`, ...). A page section is the kinds whose `functionCap` it names (D1013); a
54067
+ * provider with more than one section must name it on every kind.
54068
+ */
54069
+ functionCap: string().min(1),
54070
+ /**
54071
+ * For `functionCap: 'broker'`: the registry kind of the broker an instance of this kind is
54072
+ * (`matter`, `home-assistant`, ...). A caller that needs a broker of one kind (a link field)
54073
+ * finds the instances whose kind names it; absent takes the kind `id`.
54074
+ */
54075
+ brokerKind: string().min(1).optional(),
54076
+ /** What instances of this kind are for the Integrations page; absent takes the provider's `role`. */
54077
+ role: IntegrationInstanceRoleSchema.optional(),
54078
+ /** `testInstance` really probes the remote for this kind; absent takes the provider's `canTest`. */
54079
+ canTest: boolean().optional(),
54183
54080
  /** The kind signs in through a server-held interactive session. */
54184
54081
  signIn: object({
54185
54082
  basePath: string().min(1),
@@ -54192,11 +54089,7 @@ var IntegrationInstanceKindSchema = object({
54192
54089
  });
54193
54090
  var IntegrationInstanceDescriptionSchema = object({
54194
54091
  /** What the instances are for the Integrations page; see {@link IntegrationInstanceRoleSchema}. */
54195
- role: _enum([
54196
- "devices",
54197
- "services",
54198
- "both"
54199
- ]),
54092
+ role: IntegrationInstanceRoleSchema,
54200
54093
  /** How the devices of an instance are found; absent means `{ mode: 'none' }`. */
54201
54094
  discovery: object({
54202
54095
  mode: _enum([
@@ -54271,7 +54164,11 @@ var integrationInstancesCapability = {
54271
54164
  methods: {
54272
54165
  describe: method(_void(), IntegrationInstanceDescriptionSchema, { auth: "admin" }),
54273
54166
  /** The form; `id: null` is the create form. */
54274
- instanceFormSchema: method(object({ id: IntegrationInstanceIdSchema.nullable() }), ConfigSchemaPassthrough$3, { auth: "admin" }),
54167
+ instanceFormSchema: method(object({
54168
+ id: IntegrationInstanceIdSchema.nullable(),
54169
+ /** The kind of the create form (`id: null`) of a provider with several. */
54170
+ kind: string().min(1).optional()
54171
+ }), ConfigSchemaPassthrough$3, { auth: "admin" }),
54275
54172
  listInstances: method(_void(), array(IntegrationInstanceSchema).readonly(), { auth: "admin" }),
54276
54173
  getInstance: method(object({ id: IntegrationInstanceIdSchema }), IntegrationInstanceSchema.nullable(), { auth: "admin" }),
54277
54174
  /**
@@ -54281,7 +54178,9 @@ var integrationInstancesCapability = {
54281
54178
  createInstance: method(object({
54282
54179
  id: IntegrationInstanceIdSchema.optional(),
54283
54180
  name: string().min(1),
54284
- kind: string().min(1).optional()
54181
+ kind: string().min(1).optional(),
54182
+ /** Form values written with the record (a link picked in the create dialog). */
54183
+ values: record(string(), unknown()).optional()
54285
54184
  }), object({ id: string() }), {
54286
54185
  kind: "mutation",
54287
54186
  auth: "admin"
@@ -54308,8 +54207,10 @@ var integrationInstancesCapability = {
54308
54207
  }),
54309
54208
  testInstance: optionalMethod(object({
54310
54209
  id: IntegrationInstanceIdSchema.nullable(),
54311
- values: record(string(), unknown())
54312
- }), TestConnectionResultSchema$1, {
54210
+ values: record(string(), unknown()),
54211
+ /** The kind to test when `id` is null and the provider has several. */
54212
+ kind: string().min(1).optional()
54213
+ }), TestConnectionResultSchema, {
54313
54214
  kind: "mutation",
54314
54215
  auth: "admin"
54315
54216
  })
@@ -56814,6 +56715,380 @@ var SystemMetricsSchema = object({
56814
56715
  gpuMemoryPercent: number().optional()
56815
56716
  });
56816
56717
  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);
56718
+ /**
56719
+ * records - an addon's own facts and documents (D881): ledgers, audit rows,
56720
+ * config documents, mirrors. The third storage API next to `ctx.settings`
56721
+ * (operator intent, D870) and device-history (numbers over time, D850).
56722
+ *
56723
+ * Who the OWNER is: `callerAddonId`, stamped by the transport on a call that
56724
+ * crossed a process and stated by a hub in-process caller (`ctx.records` does
56725
+ * that with the addon id closed over). It is deliberately NOT in any input
56726
+ * schema, so a session can never supply it, and a call with no stamp is refused
56727
+ * `records/no-caller`. A kind is readable and writable only by its owner; other
56728
+ * addons go through the owner's capability.
56729
+ *
56730
+ * Every read is three-way: a row, `null`, or the call THROWS. A failed read is
56731
+ * never `[]`. Secret fields are never on the wire: a read shows the
56732
+ * redaction token, `secretRefs` hands the owner's `ctx.secrets` the refs it
56733
+ * resolves itself, and a write may carry only a keychain marker bound to the
56734
+ * row's own ref.
56735
+ *
56736
+ * Internal: addons reach it through `ctx.records`, never directly.
56737
+ */
56738
+ var RecordIdSchema = string().min(1).max(256);
56739
+ /** A JSON object body. Size is the engine's check (`maxDataBytes`), not a schema's. */
56740
+ var RecordBodySchema = record(string(), unknown());
56741
+ var RecordInputSchema = object({
56742
+ id: RecordIdSchema,
56743
+ /** The device this row belongs to (D865 removal, per-device reads). */
56744
+ deviceId: number().int().optional(),
56745
+ /** The domain clock, ms: observed / created / opened. */
56746
+ at: number().int().optional(),
56747
+ /** Producer-set expiry, ms. Refused on a kind whose retention is an engine TTL. */
56748
+ expiresAt: number().int().optional(),
56749
+ data: RecordBodySchema
56750
+ });
56751
+ var RecordViewSchema = object({
56752
+ id: string(),
56753
+ deviceId: number().int().nullable(),
56754
+ at: number().nullable(),
56755
+ expiresAt: number().nullable(),
56756
+ rev: number().int(),
56757
+ updatedAt: number(),
56758
+ data: RecordBodySchema
56759
+ });
56760
+ /** Declared index fields only. Anything else does not compile, by design. */
56761
+ var RecordWhereSchema = record(RecordFieldPathSchema, union([
56762
+ string(),
56763
+ number(),
56764
+ boolean(),
56765
+ object({ in: array(union([
56766
+ string(),
56767
+ number(),
56768
+ boolean()
56769
+ ])).min(1).max(RECORD_BATCH_MAX) }),
56770
+ object({
56771
+ gt: union([string(), number()]).optional(),
56772
+ gte: union([string(), number()]).optional(),
56773
+ lt: union([string(), number()]).optional(),
56774
+ lte: union([string(), number()]).optional()
56775
+ }).refine((r) => Object.keys(r).length > 0, { message: "name at least one bound" })
56776
+ ]));
56777
+ var KindShape = { kind: RecordKindNameSchema };
56778
+ var TimeWindowShape = {
56779
+ deviceId: number().int().optional(),
56780
+ atFrom: number().optional(),
56781
+ atTo: number().optional()
56782
+ };
56783
+ var RecordQueryInputSchema = object({
56784
+ ...KindShape,
56785
+ ...TimeWindowShape,
56786
+ where: RecordWhereSchema.optional(),
56787
+ /** Without `where`: `at` (default for a kind with a clock) or `id`. With `where` the order is the index's. */
56788
+ order: _enum(["at", "id"]).optional(),
56789
+ desc: boolean().optional(),
56790
+ limit: number().int().min(1).max(RECORD_QUERY_MAX).default(100),
56791
+ /** Opaque keyset cursor from a previous answer. There is no offset. */
56792
+ cursor: string().min(1).optional()
56793
+ });
56794
+ var RecordQueryResultSchema = object({
56795
+ records: array(RecordViewSchema),
56796
+ nextCursor: string().nullable()
56797
+ });
56798
+ var RecordKindStateSchema = _enum([
56799
+ "created",
56800
+ "unchanged",
56801
+ "updated"
56802
+ ]);
56803
+ var DeclareKindResultSchema = object({
56804
+ kindId: number().int(),
56805
+ state: RecordKindStateSchema,
56806
+ createdIndexes: array(string())
56807
+ });
56808
+ /** Keychain refs a write released (the old marker is gone from the row): the owner deletes the values. */
56809
+ var SecretRefsList = array(string());
56810
+ /**
56811
+ * One op of a `transact`: the single-row writes, each on a kind of the caller.
56812
+ * All ops of one call share ONE database file and ONE `transactFamily`.
56813
+ */
56814
+ var RecordTransactOpSchema = discriminatedUnion("op", [
56815
+ object({
56816
+ op: literal("put"),
56817
+ kind: RecordKindNameSchema,
56818
+ record: RecordInputSchema,
56819
+ ifRev: number().int().min(0).optional()
56820
+ }),
56821
+ object({
56822
+ op: literal("patch"),
56823
+ kind: RecordKindNameSchema,
56824
+ id: RecordIdSchema,
56825
+ data: RecordBodySchema,
56826
+ at: number().int().nullable().optional(),
56827
+ expiresAt: number().int().nullable().optional(),
56828
+ ifRev: number().int().min(1).optional()
56829
+ }),
56830
+ object({
56831
+ op: literal("delete"),
56832
+ kind: RecordKindNameSchema,
56833
+ id: RecordIdSchema
56834
+ })
56835
+ ]);
56836
+ var RecordSecretRefsSchema = record(string(), record(string(), string()));
56837
+ method(object({ kinds: record(RecordKindNameSchema, RecordKindDeclarationSchema) }), object({ kinds: record(string(), DeclareKindResultSchema) }), {
56838
+ kind: "mutation",
56839
+ auth: "admin"
56840
+ }), method(object({
56841
+ ...KindShape,
56842
+ id: RecordIdSchema
56843
+ }), RecordViewSchema.nullable(), { auth: "admin" }), method(object({
56844
+ ...KindShape,
56845
+ ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
56846
+ }), array(RecordViewSchema), { auth: "admin" }), method(object({
56847
+ ...KindShape,
56848
+ ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
56849
+ }), RecordSecretRefsSchema, { auth: "admin" }), method(object({
56850
+ ...KindShape,
56851
+ record: RecordInputSchema,
56852
+ /** Compare-and-set. `0` means "the row must not exist". */
56853
+ ifRev: number().int().min(0).optional()
56854
+ }), object({
56855
+ rev: number().int(),
56856
+ releasedRefs: SecretRefsList
56857
+ }), {
56858
+ kind: "mutation",
56859
+ auth: "admin"
56860
+ }), method(object({
56861
+ ...KindShape,
56862
+ records: array(RecordInputSchema).min(1).max(RECORD_BATCH_MAX)
56863
+ }), object({
56864
+ count: number().int(),
56865
+ releasedRefs: SecretRefsList
56866
+ }), {
56867
+ kind: "mutation",
56868
+ auth: "admin"
56869
+ }), method(object({
56870
+ ...KindShape,
56871
+ id: RecordIdSchema,
56872
+ data: RecordBodySchema,
56873
+ at: number().int().nullable().optional(),
56874
+ expiresAt: number().int().nullable().optional(),
56875
+ ifRev: number().int().min(1).optional()
56876
+ }), object({
56877
+ rev: number().int(),
56878
+ releasedRefs: SecretRefsList
56879
+ }), {
56880
+ kind: "mutation",
56881
+ auth: "admin"
56882
+ }), method(object({
56883
+ ...KindShape,
56884
+ id: RecordIdSchema
56885
+ }), object({
56886
+ deleted: boolean(),
56887
+ secretRefs: SecretRefsList
56888
+ }), {
56889
+ kind: "mutation",
56890
+ auth: "admin"
56891
+ }), method(object({
56892
+ ...KindShape,
56893
+ ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
56894
+ }), object({
56895
+ deleted: number().int(),
56896
+ secretRefs: SecretRefsList
56897
+ }), {
56898
+ kind: "mutation",
56899
+ auth: "admin"
56900
+ }), method(RecordQueryInputSchema, RecordQueryResultSchema, { auth: "admin" }), method(object({
56901
+ ...KindShape,
56902
+ ...TimeWindowShape,
56903
+ where: RecordWhereSchema.optional()
56904
+ }), object({ count: number().int() }), { auth: "admin" }), method(object({
56905
+ ...KindShape,
56906
+ deviceId: number().int().optional(),
56907
+ from: number(),
56908
+ to: number(),
56909
+ bucketMs: number().int().positive()
56910
+ }), object({ buckets: array(object({
56911
+ bucketStart: number(),
56912
+ count: number().int()
56913
+ })) }), { auth: "admin" }), method(object({
56914
+ ...KindShape,
56915
+ op: _enum([
56916
+ "sum",
56917
+ "min",
56918
+ "max",
56919
+ "count"
56920
+ ]),
56921
+ field: RecordFieldPathSchema.optional(),
56922
+ groupBy: RecordFieldPathSchema.optional(),
56923
+ where: RecordWhereSchema.optional()
56924
+ }), object({ rows: array(object({
56925
+ group: union([
56926
+ string(),
56927
+ number(),
56928
+ _null()
56929
+ ]),
56930
+ value: number().nullable()
56931
+ })) }), { auth: "admin" }), method(object({
56932
+ ...KindShape,
56933
+ deviceId: number().int().optional(),
56934
+ atBefore: number().optional(),
56935
+ where: RecordWhereSchema.optional()
56936
+ }), object({
56937
+ deleted: number().int(),
56938
+ secretRefs: SecretRefsList
56939
+ }), {
56940
+ kind: "mutation",
56941
+ auth: "admin"
56942
+ }), method(object({ ops: array(RecordTransactOpSchema).min(1).max(RECORD_BATCH_MAX) }), object({
56943
+ results: array(object({
56944
+ rev: number().int().nullable(),
56945
+ deleted: boolean()
56946
+ })),
56947
+ releasedRefs: SecretRefsList,
56948
+ secretRefs: SecretRefsList
56949
+ }), {
56950
+ kind: "mutation",
56951
+ auth: "admin"
56952
+ }), method(object({
56953
+ deviceId: number().int(),
56954
+ kinds: array(RecordKindNameSchema).optional()
56955
+ }), object({
56956
+ deleted: record(string(), number().int()),
56957
+ secretRefs: SecretRefsList
56958
+ }), {
56959
+ kind: "mutation",
56960
+ auth: "admin"
56961
+ }), method(object(KindShape), object({ id: number().int() }), {
56962
+ kind: "mutation",
56963
+ auth: "admin"
56964
+ });
56965
+ /**
56966
+ * The wire shapes of the migration API, version 1 (planning#447).
56967
+ *
56968
+ * A migration is code that outlives the server it was written against: an addon at
56969
+ * version 1.4 that is brought to 1.9 in one jump still runs its 1.4 catalogue, against
56970
+ * the 1.9 kernel. The only thing that code may call is {@link MigrationApiV1}; this file
56971
+ * is the contract of every method of it, one Zod pair per method.
56972
+ *
56973
+ * APPEND-ONLY. A released method is never removed and never changes meaning: an input
56974
+ * field is never removed or made required, an enum never narrows, an output field is
56975
+ * never removed. A change that cannot be expressed that way is `migration-api.v2`, next
56976
+ * to this file. `scripts/check-migration-api-append-only.ts` holds the line against
56977
+ * `scripts/migration-api-contract.json`, and every `behaviours` id below must have an
56978
+ * `it('<id>'` in `migration-api.contract.spec.ts`.
56979
+ */
56980
+ var Rows = record(string(), unknown());
56981
+ var IntegrationId = object({ integrationId: string().min(1) });
56982
+ var RecordId = object({ id: string().min(1) });
56983
+ var RecordRowSchema = object({
56984
+ id: string(),
56985
+ deviceId: number().int().nullable(),
56986
+ at: number().nullable(),
56987
+ expiresAt: number().nullable(),
56988
+ rev: number().int(),
56989
+ updatedAt: number(),
56990
+ data: Rows
56991
+ });
56992
+ var RecordPutShape = {
56993
+ id: string().min(1),
56994
+ deviceId: number().int().optional(),
56995
+ at: number().int().optional(),
56996
+ expiresAt: number().int().optional(),
56997
+ data: Rows
56998
+ };
56999
+ var MigrationIntegrationSchema = object({
57000
+ id: string(),
57001
+ addonId: string(),
57002
+ name: string(),
57003
+ info: Rows,
57004
+ originStableId: string().optional()
57005
+ });
57006
+ var MigrationDeviceSchema = object({
57007
+ id: number().int(),
57008
+ integrationId: string().nullable().optional(),
57009
+ /** The owning device; null or absent for a root. */
57010
+ parentDeviceId: number().int().nullable().optional(),
57011
+ type: string().optional(),
57012
+ name: string().optional(),
57013
+ stableId: string().optional()
57014
+ });
57015
+ /** What the engine says about the migration API it serves: lets an addon notice an older server. */
57016
+ var MigrationApiInfoSchema = object({
57017
+ version: number().int(),
57018
+ methods: array(string())
57019
+ });
57020
+ object({
57021
+ message: string(),
57022
+ meta: Rows.optional(),
57023
+ /** Grouping tags of the line; a line about a device carries `{ deviceId }`. */
57024
+ tags: object({ deviceId: number().int().optional() }).optional()
57025
+ });
57026
+ object({
57027
+ owner: string().min(1),
57028
+ metas: array(MigrationMetaSchema)
57029
+ }), object({
57030
+ bootId: string(),
57031
+ snapshot: _enum([
57032
+ "none",
57033
+ "written",
57034
+ "refused"
57035
+ ]),
57036
+ steps: array(MigrationOwnerStepSchema),
57037
+ /** The migration API the engine serves; absent from a server that predates it. */
57038
+ migrationApi: MigrationApiInfoSchema.optional()
57039
+ }), object({ id: string().min(1) }), object({
57040
+ proceed: boolean(),
57041
+ state: union([literal("pending"), MigrationStatusSchema]),
57042
+ reason: string().nullable()
57043
+ }), object({
57044
+ id: string().min(1),
57045
+ result: MigrationOwnerResultSchema
57046
+ }), object({
57047
+ status: MigrationStatusSchema,
57048
+ error: string().nullable(),
57049
+ rowsChanged: number().int().nonnegative()
57050
+ }), object({
57051
+ rows: number().int().nonnegative(),
57052
+ secrets: number().int().nonnegative()
57053
+ }), IntegrationId.extend({ patch: Rows }), object({ written: boolean() }), IntegrationId.extend({ keys: array(string().min(1)) }), object({
57054
+ present: boolean(),
57055
+ removed: Rows
57056
+ }), RecordRowSchema.nullable(), RecordRowSchema.nullable(), object({
57057
+ ...RecordPutShape,
57058
+ ifRev: number().int().optional()
57059
+ }), object({ rev: number().int() }), object({ records: array(object(RecordPutShape)) }), object({ count: number().int().nonnegative() }), RecordId.extend({
57060
+ partial: Rows,
57061
+ ifRev: number().int().optional()
57062
+ }), object({ rev: number().int() }), object({ deleted: boolean() }), object({
57063
+ deviceId: number().int().optional(),
57064
+ atFrom: number().optional(),
57065
+ atTo: number().optional(),
57066
+ where: RecordWhereSchema.optional(),
57067
+ limit: number().int().min(1).optional(),
57068
+ cursor: string().min(1).optional(),
57069
+ resolveSecrets: boolean().optional()
57070
+ }), object({
57071
+ records: array(RecordRowSchema),
57072
+ nextCursor: string().nullable()
57073
+ }), object({
57074
+ deviceId: number().int().optional(),
57075
+ atBefore: number().optional(),
57076
+ where: RecordWhereSchema.optional()
57077
+ }), 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({
57078
+ ref: string().min(1),
57079
+ value: string()
57080
+ }), object({}), object({ refs: array(string()) }), object({ deleted: number().int().nonnegative() }), object({}), object({ integrations: array(MigrationIntegrationSchema) }), object({ id: string().min(1) }), MigrationIntegrationSchema.nullable(), object({
57081
+ addonId: string().min(1),
57082
+ integrationId: string().min(1).optional()
57083
+ }), object({ devices: array(MigrationDeviceSchema) }), object({ deviceId: number().int() }), object({
57084
+ id: number().int(),
57085
+ integrationId: string().nullable(),
57086
+ /** Whether the device carries any stored config; its content is not offered. */
57087
+ hasConfig: boolean()
57088
+ }).nullable(), object({
57089
+ deviceId: number().int(),
57090
+ integrationId: string().min(1)
57091
+ }), object({}), object({}), object({ nodeId: string() }), object({}), object({});
56817
57092
  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({
56818
57093
  id: string().min(1),
56819
57094
  error: string().min(1)
@@ -56843,7 +57118,9 @@ method(object({}), object({ entries: array(MigrationLedgerEntrySchema) }), { aut
56843
57118
  "written",
56844
57119
  "refused"
56845
57120
  ]),
56846
- steps: array(MigrationOwnerStepSchema)
57121
+ steps: array(MigrationOwnerStepSchema),
57122
+ /** The migration API this engine serves (additive: a server that predates it omits it). */
57123
+ migrationApi: MigrationApiInfoSchema.optional()
56847
57124
  }), {
56848
57125
  kind: "mutation",
56849
57126
  auth: "admin"
@@ -59510,7 +59787,10 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
59510
59787
  }), method(RelocateMediaInputSchema, object({ jobId: string() }), {
59511
59788
  kind: "mutation",
59512
59789
  auth: "admin"
59513
- }), method(object({}), UnstampedEventMediaCountSchema, { auth: "admin" }), method(RelocatableMediaCountInputSchema, RelocatableMediaCountSchema, {
59790
+ }), method(object({}), UnstampedEventMediaCountSchema, { auth: "admin" }), method(MediaLocationCensusInputSchema, MediaLocationCensusSchema, {
59791
+ kind: "query",
59792
+ auth: "admin"
59793
+ }), method(RelocatableMediaCountInputSchema, RelocatableMediaCountSchema, {
59514
59794
  kind: "query",
59515
59795
  auth: "admin"
59516
59796
  }), method(object({}), array(RelocateJobSchema).readonly(), {
@@ -61917,7 +62197,7 @@ var BrokerStatusSchema$1 = _enum([
61917
62197
  "unreachable",
61918
62198
  "tls-error"
61919
62199
  ]);
61920
- var BrokerInfoSchema$1 = object({
62200
+ var BrokerInfoSchema = object({
61921
62201
  id: string(),
61922
62202
  name: string(),
61923
62203
  url: string(),
@@ -61990,7 +62270,7 @@ var mqttBrokerCapability = {
61990
62270
  kind: "poll"
61991
62271
  },
61992
62272
  methods: {
61993
- listBrokers: method(_void(), array(BrokerInfoSchema$1)),
62273
+ listBrokers: method(_void(), array(BrokerInfoSchema)),
61994
62274
  getBrokerConfig: method(IdInputSchema, BrokerConnectionDetailsSchema, { auth: "admin" }),
61995
62275
  addBroker: method(AddBrokerInputSchema, AddBrokerResultSchema, {
61996
62276
  kind: "mutation",
@@ -68317,253 +68597,6 @@ DeviceType.Camera, DeviceType.Sensor, DeviceType.Switch, DeviceType.Gateway, met
68317
68597
  auth: "admin"
68318
68598
  });
68319
68599
  /**
68320
- * records - an addon's own facts and documents (D881): ledgers, audit rows,
68321
- * config documents, mirrors. The third storage API next to `ctx.settings`
68322
- * (operator intent, D870) and device-history (numbers over time, D850).
68323
- *
68324
- * Who the OWNER is: `callerAddonId`, stamped by the transport on a call that
68325
- * crossed a process and stated by a hub in-process caller (`ctx.records` does
68326
- * that with the addon id closed over). It is deliberately NOT in any input
68327
- * schema, so a session can never supply it, and a call with no stamp is refused
68328
- * `records/no-caller`. A kind is readable and writable only by its owner; other
68329
- * addons go through the owner's capability.
68330
- *
68331
- * Every read is three-way: a row, `null`, or the call THROWS. A failed read is
68332
- * never `[]`. Secret fields are never on the wire: a read shows the
68333
- * redaction token, `secretRefs` hands the owner's `ctx.secrets` the refs it
68334
- * resolves itself, and a write may carry only a keychain marker bound to the
68335
- * row's own ref.
68336
- *
68337
- * Internal: addons reach it through `ctx.records`, never directly.
68338
- */
68339
- var RecordIdSchema = string().min(1).max(256);
68340
- /** A JSON object body. Size is the engine's check (`maxDataBytes`), not a schema's. */
68341
- var RecordBodySchema = record(string(), unknown());
68342
- var RecordInputSchema = object({
68343
- id: RecordIdSchema,
68344
- /** The device this row belongs to (D865 removal, per-device reads). */
68345
- deviceId: number().int().optional(),
68346
- /** The domain clock, ms: observed / created / opened. */
68347
- at: number().int().optional(),
68348
- /** Producer-set expiry, ms. Refused on a kind whose retention is an engine TTL. */
68349
- expiresAt: number().int().optional(),
68350
- data: RecordBodySchema
68351
- });
68352
- var RecordViewSchema = object({
68353
- id: string(),
68354
- deviceId: number().int().nullable(),
68355
- at: number().nullable(),
68356
- expiresAt: number().nullable(),
68357
- rev: number().int(),
68358
- updatedAt: number(),
68359
- data: RecordBodySchema
68360
- });
68361
- /** Declared index fields only. Anything else does not compile, by design. */
68362
- var RecordWhereSchema = record(RecordFieldPathSchema, union([
68363
- string(),
68364
- number(),
68365
- boolean(),
68366
- object({ in: array(union([
68367
- string(),
68368
- number(),
68369
- boolean()
68370
- ])).min(1).max(RECORD_BATCH_MAX) }),
68371
- object({
68372
- gt: union([string(), number()]).optional(),
68373
- gte: union([string(), number()]).optional(),
68374
- lt: union([string(), number()]).optional(),
68375
- lte: union([string(), number()]).optional()
68376
- }).refine((r) => Object.keys(r).length > 0, { message: "name at least one bound" })
68377
- ]));
68378
- var KindShape = { kind: RecordKindNameSchema };
68379
- var TimeWindowShape = {
68380
- deviceId: number().int().optional(),
68381
- atFrom: number().optional(),
68382
- atTo: number().optional()
68383
- };
68384
- var RecordQueryInputSchema = object({
68385
- ...KindShape,
68386
- ...TimeWindowShape,
68387
- where: RecordWhereSchema.optional(),
68388
- /** Without `where`: `at` (default for a kind with a clock) or `id`. With `where` the order is the index's. */
68389
- order: _enum(["at", "id"]).optional(),
68390
- desc: boolean().optional(),
68391
- limit: number().int().min(1).max(RECORD_QUERY_MAX).default(100),
68392
- /** Opaque keyset cursor from a previous answer. There is no offset. */
68393
- cursor: string().min(1).optional()
68394
- });
68395
- var RecordQueryResultSchema = object({
68396
- records: array(RecordViewSchema),
68397
- nextCursor: string().nullable()
68398
- });
68399
- var RecordKindStateSchema = _enum([
68400
- "created",
68401
- "unchanged",
68402
- "updated"
68403
- ]);
68404
- var DeclareKindResultSchema = object({
68405
- kindId: number().int(),
68406
- state: RecordKindStateSchema,
68407
- createdIndexes: array(string())
68408
- });
68409
- /** Keychain refs a write released (the old marker is gone from the row): the owner deletes the values. */
68410
- var SecretRefsList = array(string());
68411
- /**
68412
- * One op of a `transact`: the single-row writes, each on a kind of the caller.
68413
- * All ops of one call share ONE database file and ONE `transactFamily`.
68414
- */
68415
- var RecordTransactOpSchema = discriminatedUnion("op", [
68416
- object({
68417
- op: literal("put"),
68418
- kind: RecordKindNameSchema,
68419
- record: RecordInputSchema,
68420
- ifRev: number().int().min(0).optional()
68421
- }),
68422
- object({
68423
- op: literal("patch"),
68424
- kind: RecordKindNameSchema,
68425
- id: RecordIdSchema,
68426
- data: RecordBodySchema,
68427
- at: number().int().nullable().optional(),
68428
- expiresAt: number().int().nullable().optional(),
68429
- ifRev: number().int().min(1).optional()
68430
- }),
68431
- object({
68432
- op: literal("delete"),
68433
- kind: RecordKindNameSchema,
68434
- id: RecordIdSchema
68435
- })
68436
- ]);
68437
- var RecordSecretRefsSchema = record(string(), record(string(), string()));
68438
- method(object({ kinds: record(RecordKindNameSchema, RecordKindDeclarationSchema) }), object({ kinds: record(string(), DeclareKindResultSchema) }), {
68439
- kind: "mutation",
68440
- auth: "admin"
68441
- }), method(object({
68442
- ...KindShape,
68443
- id: RecordIdSchema
68444
- }), RecordViewSchema.nullable(), { auth: "admin" }), method(object({
68445
- ...KindShape,
68446
- ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
68447
- }), array(RecordViewSchema), { auth: "admin" }), method(object({
68448
- ...KindShape,
68449
- ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
68450
- }), RecordSecretRefsSchema, { auth: "admin" }), method(object({
68451
- ...KindShape,
68452
- record: RecordInputSchema,
68453
- /** Compare-and-set. `0` means "the row must not exist". */
68454
- ifRev: number().int().min(0).optional()
68455
- }), object({
68456
- rev: number().int(),
68457
- releasedRefs: SecretRefsList
68458
- }), {
68459
- kind: "mutation",
68460
- auth: "admin"
68461
- }), method(object({
68462
- ...KindShape,
68463
- records: array(RecordInputSchema).min(1).max(RECORD_BATCH_MAX)
68464
- }), object({
68465
- count: number().int(),
68466
- releasedRefs: SecretRefsList
68467
- }), {
68468
- kind: "mutation",
68469
- auth: "admin"
68470
- }), method(object({
68471
- ...KindShape,
68472
- id: RecordIdSchema,
68473
- data: RecordBodySchema,
68474
- at: number().int().nullable().optional(),
68475
- expiresAt: number().int().nullable().optional(),
68476
- ifRev: number().int().min(1).optional()
68477
- }), object({
68478
- rev: number().int(),
68479
- releasedRefs: SecretRefsList
68480
- }), {
68481
- kind: "mutation",
68482
- auth: "admin"
68483
- }), method(object({
68484
- ...KindShape,
68485
- id: RecordIdSchema
68486
- }), object({
68487
- deleted: boolean(),
68488
- secretRefs: SecretRefsList
68489
- }), {
68490
- kind: "mutation",
68491
- auth: "admin"
68492
- }), method(object({
68493
- ...KindShape,
68494
- ids: array(RecordIdSchema).min(1).max(RECORD_BATCH_MAX)
68495
- }), object({
68496
- deleted: number().int(),
68497
- secretRefs: SecretRefsList
68498
- }), {
68499
- kind: "mutation",
68500
- auth: "admin"
68501
- }), method(RecordQueryInputSchema, RecordQueryResultSchema, { auth: "admin" }), method(object({
68502
- ...KindShape,
68503
- ...TimeWindowShape,
68504
- where: RecordWhereSchema.optional()
68505
- }), object({ count: number().int() }), { auth: "admin" }), method(object({
68506
- ...KindShape,
68507
- deviceId: number().int().optional(),
68508
- from: number(),
68509
- to: number(),
68510
- bucketMs: number().int().positive()
68511
- }), object({ buckets: array(object({
68512
- bucketStart: number(),
68513
- count: number().int()
68514
- })) }), { auth: "admin" }), method(object({
68515
- ...KindShape,
68516
- op: _enum([
68517
- "sum",
68518
- "min",
68519
- "max",
68520
- "count"
68521
- ]),
68522
- field: RecordFieldPathSchema.optional(),
68523
- groupBy: RecordFieldPathSchema.optional(),
68524
- where: RecordWhereSchema.optional()
68525
- }), object({ rows: array(object({
68526
- group: union([
68527
- string(),
68528
- number(),
68529
- _null()
68530
- ]),
68531
- value: number().nullable()
68532
- })) }), { auth: "admin" }), method(object({
68533
- ...KindShape,
68534
- deviceId: number().int().optional(),
68535
- atBefore: number().optional(),
68536
- where: RecordWhereSchema.optional()
68537
- }), object({
68538
- deleted: number().int(),
68539
- secretRefs: SecretRefsList
68540
- }), {
68541
- kind: "mutation",
68542
- auth: "admin"
68543
- }), method(object({ ops: array(RecordTransactOpSchema).min(1).max(RECORD_BATCH_MAX) }), object({
68544
- results: array(object({
68545
- rev: number().int().nullable(),
68546
- deleted: boolean()
68547
- })),
68548
- releasedRefs: SecretRefsList,
68549
- secretRefs: SecretRefsList
68550
- }), {
68551
- kind: "mutation",
68552
- auth: "admin"
68553
- }), method(object({
68554
- deviceId: number().int(),
68555
- kinds: array(RecordKindNameSchema).optional()
68556
- }), object({
68557
- deleted: record(string(), number().int()),
68558
- secretRefs: SecretRefsList
68559
- }), {
68560
- kind: "mutation",
68561
- auth: "admin"
68562
- }), method(object(KindShape), object({ id: number().int() }), {
68563
- kind: "mutation",
68564
- auth: "admin"
68565
- });
68566
- /**
68567
68600
  * record-entries - the ONE admin surface for every list of user-created entries
68568
68601
  * (D881): notification targets, brokers, LLM profiles, anything an addon
68569
68602
  * declares as an ENTRY kind (`entry: { title }` in `defineRecordKinds`).
@@ -77106,20 +77139,6 @@ Object.freeze({
77106
77139
  access: "create",
77107
77140
  role: "viewer"
77108
77141
  },
77109
- "broker.add": {
77110
- capName: "broker",
77111
- capScope: "system",
77112
- addonId: null,
77113
- access: "create",
77114
- role: "admin"
77115
- },
77116
- "broker.get": {
77117
- capName: "broker",
77118
- capScope: "system",
77119
- addonId: null,
77120
- access: "view",
77121
- role: "admin"
77122
- },
77123
77142
  "broker.getBrokerConfig": {
77124
77143
  capName: "broker",
77125
77144
  capScope: "system",
@@ -77127,20 +77146,6 @@ Object.freeze({
77127
77146
  access: "view",
77128
77147
  role: "admin"
77129
77148
  },
77130
- "broker.getSettings": {
77131
- capName: "broker",
77132
- capScope: "system",
77133
- addonId: null,
77134
- access: "view",
77135
- role: "admin"
77136
- },
77137
- "broker.getSettingsSchema": {
77138
- capName: "broker",
77139
- capScope: "system",
77140
- addonId: null,
77141
- access: "view",
77142
- role: "admin"
77143
- },
77144
77149
  "broker.getState": {
77145
77150
  capName: "broker",
77146
77151
  capScope: "system",
@@ -77155,20 +77160,6 @@ Object.freeze({
77155
77160
  access: "view",
77156
77161
  role: "admin"
77157
77162
  },
77158
- "broker.list": {
77159
- capName: "broker",
77160
- capScope: "system",
77161
- addonId: null,
77162
- access: "view",
77163
- role: "admin"
77164
- },
77165
- "broker.listProviders": {
77166
- capName: "broker",
77167
- capScope: "system",
77168
- addonId: null,
77169
- access: "view",
77170
- role: "admin"
77171
- },
77172
77163
  "broker.publish": {
77173
77164
  capName: "broker",
77174
77165
  capScope: "system",
@@ -77176,20 +77167,6 @@ Object.freeze({
77176
77167
  access: "create",
77177
77168
  role: "admin"
77178
77169
  },
77179
- "broker.remove": {
77180
- capName: "broker",
77181
- capScope: "system",
77182
- addonId: null,
77183
- access: "delete",
77184
- role: "admin"
77185
- },
77186
- "broker.setSettings": {
77187
- capName: "broker",
77188
- capScope: "system",
77189
- addonId: null,
77190
- access: "create",
77191
- role: "admin"
77192
- },
77193
77170
  "broker.subscribe": {
77194
77171
  capName: "broker",
77195
77172
  capScope: "system",
@@ -77197,20 +77174,6 @@ Object.freeze({
77197
77174
  access: "create",
77198
77175
  role: "admin"
77199
77176
  },
77200
- "broker.testConnection": {
77201
- capName: "broker",
77202
- capScope: "system",
77203
- addonId: null,
77204
- access: "create",
77205
- role: "admin"
77206
- },
77207
- "broker.testSettings": {
77208
- capName: "broker",
77209
- capScope: "system",
77210
- addonId: null,
77211
- access: "create",
77212
- role: "admin"
77213
- },
77214
77177
  "broker.unsubscribe": {
77215
77178
  capName: "broker",
77216
77179
  capScope: "system",
@@ -82174,6 +82137,13 @@ Object.freeze({
82174
82137
  access: "view",
82175
82138
  role: "viewer"
82176
82139
  },
82140
+ "pipelineAnalytics.getMediaLocationCensus": {
82141
+ capName: "pipeline-analytics",
82142
+ capScope: "device",
82143
+ addonId: null,
82144
+ access: "view",
82145
+ role: "admin"
82146
+ },
82177
82147
  "pipelineAnalytics.getMediaReclaimStatus": {
82178
82148
  capName: "pipeline-analytics",
82179
82149
  capScope: "device",
@@ -91115,6 +91085,48 @@ object({
91115
91085
  delay: number().nonnegative().optional(),
91116
91086
  commands: record(string(), unknown())
91117
91087
  });
91088
+ var BrokerInUseError = class extends Error {
91089
+ brokerId;
91090
+ users;
91091
+ name = "BrokerInUseError";
91092
+ constructor(brokerId, users) {
91093
+ super(`broker '${brokerId}' is still in use by ${users.map((u) => `${u.kind} '${u.name ?? u.id}' (${u.id})`).join(", ")}; unlink them first`);
91094
+ this.brokerId = brokerId;
91095
+ this.users = users;
91096
+ }
91097
+ };
91098
+ /** Throw {@link BrokerInUseError} when any finder names a user of `brokerId`. */
91099
+ async function assertBrokerUnused(brokerId, finders) {
91100
+ const users = [];
91101
+ for (const finder of finders) users.push(...await finder.find(brokerId));
91102
+ if (users.length > 0) throw new BrokerInUseError(brokerId, users);
91103
+ }
91104
+ /**
91105
+ * Users reached through another addon's listed rows (an addon cannot read a record kind it does
91106
+ * not own): every row whose `field` holds the broker id. `list` is a cap call the owner serves.
91107
+ */
91108
+ function fieldUserFinder(spec) {
91109
+ return { find: async (brokerId) => (await spec.list()).filter((row) => row.formValues[spec.field] === brokerId).map((row) => ({
91110
+ kind: spec.kind,
91111
+ id: row.id,
91112
+ ...row.name !== void 0 ? { name: row.name } : {}
91113
+ })) };
91114
+ }
91115
+ /**
91116
+ * An account provider whose `deleteInstance` is refused while anything still uses the account
91117
+ * (D1012). The account is an `integration-instances` kind (D1013), so the refusal lives on the
91118
+ * delete the instances page calls, not on a separate broker cap.
91119
+ */
91120
+ function guardBrokerDelete(base, users) {
91121
+ return {
91122
+ ...base,
91123
+ deleteInstance: async (input) => {
91124
+ await assertBrokerUnused(input.id, users());
91125
+ return base.deleteInstance(input);
91126
+ }
91127
+ };
91128
+ }
91129
+ entryBody({ brokerId: string() });
91118
91130
  //#endregion
91119
91131
  //#region ../../node_modules/xtend/immutable.js
91120
91132
  var require_immutable = /* @__PURE__ */ __commonJSMin(((exports, module) => {
@@ -123159,7 +123171,7 @@ function readOnboardPort(settings) {
123159
123171
  return raw;
123160
123172
  }
123161
123173
  /**
123162
- * Build an onboard {@link BuiltOnboardEntry} from `broker.add` settings.
123174
+ * Build an onboard {@link BuiltOnboardEntry} from the create form's values.
123163
123175
  * Pure — the port is pre-resolved by the caller (auto-allocation is a
123164
123176
  * side-effecting concern kept out of this helper), so the helper performs no
123165
123177
  * I/O of its own.
@@ -123245,7 +123257,7 @@ function resolveStoredPassword(value, legacy) {
123245
123257
  function retiredCiphertextPaths(config) {
123246
123258
  const out = [];
123247
123259
  for (const b of config.brokers) if (b.password?.startsWith("enc:v1:") === true) out.push(`brokers[${b.id}].password`);
123248
- if (config.embeddedPassword.startsWith("enc:v1:")) out.push("embeddedPassword");
123260
+ if (config.embeddedPassword?.startsWith("enc:v1:") === true) out.push("embeddedPassword");
123249
123261
  return out;
123250
123262
  }
123251
123263
  //#endregion
@@ -123279,6 +123291,229 @@ var mqttBrokersEntryKind = defineEntryKind("brokers", {
123279
123291
  writeTrigger: "an operator adds or removes an external MQTT broker entry"
123280
123292
  });
123281
123293
  //#endregion
123294
+ //#region src/mqtt-broker-users.ts
123295
+ /** The addon whose integrations name an MQTT broker, and the field they keep that id in. */
123296
+ var MQTT_HA_ADDON_ID = "provider-mqtt-ha";
123297
+ var MQTT_HA_BROKER_FIELD = "mqttBrokerId";
123298
+ /** The finders for everything that still points at an MQTT broker. */
123299
+ function mqttBrokerUsers(listMqttHaInstances) {
123300
+ return [fieldUserFinder({
123301
+ kind: "integration",
123302
+ field: MQTT_HA_BROKER_FIELD,
123303
+ list: listMqttHaInstances
123304
+ })];
123305
+ }
123306
+ /** MQTT Discovery is installed but has not registered as a provider: not known, NOT absent (D315). */
123307
+ var MqttDiscoveryNotReadyError = class extends Error {
123308
+ constructor() {
123309
+ 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");
123310
+ this.name = "MqttDiscoveryNotReadyError";
123311
+ }
123312
+ };
123313
+ /**
123314
+ * The integrations of the MQTT Discovery addon. Nothing can name a broker only when the registry
123315
+ * ANSWERED that the addon supplies no `integration-instances` AND the addon roster ANSWERED that
123316
+ * it is not installed: then the answer is none. An addon that is installed but has not registered
123317
+ * (booting, switched off) is not-yet-known, and a registry or roster that does not answer, or a
123318
+ * read that fails, is unknown too: all of those throw, so the delete is refused (D49, D315).
123319
+ */
123320
+ async function listMqttHaInstancesOver(api) {
123321
+ const info = await api.capabilities.getCapability.query({ name: "integration-instances" });
123322
+ if (info === null) throw new Error("the capability registry did not answer");
123323
+ if (!info.providers.includes("provider-mqtt-ha")) {
123324
+ if ((await api.addons.list.query()).some((row) => row.manifest.id === "provider-mqtt-ha")) throw new MqttDiscoveryNotReadyError();
123325
+ return [];
123326
+ }
123327
+ return api.integrationInstances.listInstances.query({ addonId: MQTT_HA_ADDON_ID });
123328
+ }
123329
+ //#endregion
123330
+ //#region src/mqtt-broker-instances.ts
123331
+ /**
123332
+ * The MQTT brokers as `integration-instances` (D1013): one instance per `brokers` record. An
123333
+ * external broker is a URL; an ONBOARD broker (`onboard: true` on the create form) is a CamStack
123334
+ * `aedes` server on this hub: the create allocates its port (or honours the operator's) and the
123335
+ * addon spawns it from the record, the delete tears it down. Removing a broker an MQTT Discovery
123336
+ * integration still names is refused (D1012).
123337
+ */
123338
+ var MQTT_INSTANCE_KIND = "mqtt";
123339
+ /** The create-form keys that only mean "run an onboard broker"; the record keeps a descriptor. */
123340
+ var ONBOARD_FORM_KEYS = new Set([
123341
+ "onboard",
123342
+ "port",
123343
+ "allowAnonymous"
123344
+ ]);
123345
+ var PROBE_LABELS = {
123346
+ connected: "Connected",
123347
+ disconnected: "Disconnected",
123348
+ "auth-failed": "Authentication failed",
123349
+ unreachable: "Unreachable",
123350
+ "tls-error": "TLS error"
123351
+ };
123352
+ /** The instance's health word: connected / auth-failed / unreachable / tls-error, latency, clients. */
123353
+ function mqttHealthOf(body, live, now) {
123354
+ if (body.enabled === false) return { status: {
123355
+ state: "off",
123356
+ label: "Switched off"
123357
+ } };
123358
+ if (body.onboard !== void 0) return live.onboard === null ? {
123359
+ status: {
123360
+ state: "error",
123361
+ label: "Not listening"
123362
+ },
123363
+ lastCheckedAt: now
123364
+ } : {
123365
+ status: {
123366
+ state: "ok",
123367
+ label: "Listening",
123368
+ detail: `${live.onboard.connectedClients} connected clients`
123369
+ },
123370
+ lastCheckedAt: now
123371
+ };
123372
+ const probe = live.probe;
123373
+ if (probe === void 0) return { status: {
123374
+ state: "unknown",
123375
+ label: "Not checked yet"
123376
+ } };
123377
+ const detail = probe.status === "connected" ? probe.latencyMs !== null ? `${probe.latencyMs} ms` : void 0 : probe.error;
123378
+ return {
123379
+ status: {
123380
+ state: probe.status === "connected" ? "ok" : "error",
123381
+ label: PROBE_LABELS[probe.status],
123382
+ ...detail !== void 0 ? { detail } : {}
123383
+ },
123384
+ lastCheckedAt: probe.lastCheckedAt
123385
+ };
123386
+ }
123387
+ function withoutOnboardKeys(values) {
123388
+ return Object.fromEntries(Object.entries(values).filter(([key]) => !ONBOARD_FORM_KEYS.has(key)));
123389
+ }
123390
+ function typedText(value) {
123391
+ return typeof value === "string" && value.trim().length > 0 ? value : void 0;
123392
+ }
123393
+ /** A password the form really typed: not blank, not the redaction token, not the hidden view. */
123394
+ function typedSecret(value) {
123395
+ const text = typedText(value);
123396
+ return text === void 0 || text === "__camstack_redacted__" || isSecretFieldView(text) ? void 0 : text;
123397
+ }
123398
+ function resultOf(probe) {
123399
+ return probe.ok ? {
123400
+ success: true,
123401
+ status: "validated",
123402
+ ...probe.latencyMs !== void 0 ? { latencyMs: probe.latencyMs } : {}
123403
+ } : {
123404
+ success: false,
123405
+ status: "rejected",
123406
+ error: probe.error ?? "The broker did not answer"
123407
+ };
123408
+ }
123409
+ function buildMqttBrokerInstances(records, deps) {
123410
+ const instances = new RecordIntegrationInstances(records, mqttBrokersEntryKind, {
123411
+ description: {
123412
+ role: "services",
123413
+ instanceMode: "multiple",
123414
+ kind: "brokers",
123415
+ canTest: true,
123416
+ kinds: [{
123417
+ id: MQTT_INSTANCE_KIND,
123418
+ label: "MQTT broker",
123419
+ functionCap: "broker",
123420
+ brokerKind: "mqtt",
123421
+ canTest: true
123422
+ }]
123423
+ },
123424
+ formSchema: (id) => deps.formSchema(id, false),
123425
+ kindOf: () => MQTT_INSTANCE_KIND,
123426
+ initialBody: (name) => ({
123427
+ name,
123428
+ url: "mqtt://"
123429
+ }),
123430
+ configured: (body) => body.url.trim().length > 0 && body.url.trim() !== "mqtt://",
123431
+ health: (body, id) => mqttHealthOf(body, deps.liveState(id), Date.now()),
123432
+ onChanged: deps.onChanged,
123433
+ test: async ({ id, values }) => {
123434
+ if (id === null && isOnboardRequested(values)) return {
123435
+ success: true,
123436
+ status: "validated",
123437
+ detail: "A free port is picked when the broker is created"
123438
+ };
123439
+ const saved = id === null ? null : await instances.readResolved(id);
123440
+ if (saved?.body.onboard !== void 0) return deps.onboardListening(saved.id) ? {
123441
+ success: true,
123442
+ status: "validated",
123443
+ detail: `Listening on port ${saved.body.onboard.port}`
123444
+ } : {
123445
+ success: false,
123446
+ status: "rejected",
123447
+ error: "The onboard broker is not listening"
123448
+ };
123449
+ const url = typedText(values["url"]) ?? saved?.body.url;
123450
+ if (url === void 0 || url === "mqtt://") return {
123451
+ success: false,
123452
+ status: "rejected",
123453
+ error: "Enter the broker URL first"
123454
+ };
123455
+ const username = typedText(values["username"]) ?? saved?.body.username;
123456
+ const password = typedSecret(values["password"]) ?? saved?.body.password;
123457
+ return resultOf(await deps.probe({
123458
+ url,
123459
+ ...username !== void 0 ? { username } : {},
123460
+ ...password !== void 0 ? { password } : {}
123461
+ }));
123462
+ }
123463
+ });
123464
+ const base = instances.toProvider();
123465
+ /** Port, descriptor and URL of an onboard broker the form asked for. */
123466
+ const onboardValues = async (id, name, values) => {
123467
+ const built = buildOnboardEntry({
123468
+ id,
123469
+ name,
123470
+ port: readOnboardPort(values) ?? await deps.freePort(),
123471
+ settings: values
123472
+ });
123473
+ return {
123474
+ ...withoutOnboardKeys(values),
123475
+ url: built.url,
123476
+ onboard: built.onboard
123477
+ };
123478
+ };
123479
+ return guardBrokerDelete({
123480
+ ...base,
123481
+ instanceFormSchema: async ({ id }) => {
123482
+ const saved = id === null ? null : await instances.getResolved(id);
123483
+ return deps.formSchema(id, saved?.onboard !== void 0);
123484
+ },
123485
+ createInstance: async (input) => {
123486
+ const values = input.values ?? {};
123487
+ if (!isOnboardRequested(values)) return base.createInstance({
123488
+ ...input,
123489
+ values: withoutOnboardKeys(values)
123490
+ });
123491
+ const id = input.id ?? randomUUID();
123492
+ return base.createInstance({
123493
+ ...input,
123494
+ id,
123495
+ values: await onboardValues(id, input.name, values)
123496
+ });
123497
+ },
123498
+ updateInstance: async (input) => {
123499
+ if (!isOnboardRequested(input.values)) return base.updateInstance({
123500
+ ...input,
123501
+ values: withoutOnboardKeys(input.values)
123502
+ });
123503
+ const current = await instances.getResolved(input.id);
123504
+ if (current === null) throw new Error(`mqtt-broker: broker '${input.id}' not found`);
123505
+ if (current.onboard !== void 0) return base.updateInstance({
123506
+ ...input,
123507
+ values: withoutOnboardKeys(input.values)
123508
+ });
123509
+ return base.updateInstance({
123510
+ ...input,
123511
+ values: await onboardValues(input.id, input.name ?? current.name, input.values)
123512
+ });
123513
+ }
123514
+ }, () => mqttBrokerUsers(deps.listMqttHaInstances));
123515
+ }
123516
+ //#endregion
123282
123517
  //#region src/migrations/brokers-to-records.ts
123283
123518
  /**
123284
123519
  * The external MQTT brokers live in the `brokers` records (D985, D996); the old addon-settings
@@ -123368,46 +123603,163 @@ async function planDropBrokersKey(ports) {
123368
123603
  }] };
123369
123604
  }
123370
123605
  //#endregion
123606
+ //#region src/migrations/embedded-to-onboard.ts
123607
+ /**
123608
+ * The legacy single embedded broker lived in FLAT addon-settings keys (`embeddedEnabled`,
123609
+ * `embeddedPort`, ...). It is an ONBOARD broker record now, so the instance list, the pickers,
123610
+ * the test and the delete all see it (D1013). Two migrations of the catalogue, in this order:
123611
+ *
123612
+ * 1. `embeddedToOnboardRecord` (procedure): a legacy broker (a stopped one as a switched-off
123613
+ * record) becomes a `brokers` record
123614
+ * under the SAME id (`embeddedId`, else the `'embedded'` id the old code derived), so a
123615
+ * consumer that stored that id still resolves. It never overwrites a record of that id.
123616
+ * 2. `planDropEmbeddedKeys` (plan): the flat keys are deleted from the canonical
123617
+ * `addon-settings` rows, and only while the record that replaces them exists and carries the
123618
+ * password the legacy key held. `embeddedClients` / `embeddedAcl` are not flat broker keys
123619
+ * and stay.
123620
+ */
123621
+ /** The flat keys that described the legacy embedded broker. */
123622
+ var LEGACY_EMBEDDED_KEYS = [
123623
+ "embeddedEnabled",
123624
+ "embeddedId",
123625
+ "embeddedPort",
123626
+ "embeddedAllowAnonymous",
123627
+ "embeddedUsername",
123628
+ "embeddedPassword"
123629
+ ];
123630
+ var LEGACY_EMBEDDED_NAME = "Embedded broker";
123631
+ function onboardBodyOf(legacy) {
123632
+ return {
123633
+ name: LEGACY_EMBEDDED_NAME,
123634
+ url: `mqtt://127.0.0.1:${legacy.port}`,
123635
+ ...legacy.username !== void 0 ? { username: legacy.username } : {},
123636
+ ...legacy.password !== void 0 ? { password: legacy.password } : {},
123637
+ onboard: {
123638
+ port: legacy.port,
123639
+ allowAnonymous: legacy.allowAnonymous
123640
+ },
123641
+ ...legacy.enabled ? {} : { enabled: false }
123642
+ };
123643
+ }
123644
+ async function embeddedToOnboardRecord(ports) {
123645
+ const legacy = await ports.readLegacyEmbedded();
123646
+ if (legacy === null) return {
123647
+ outcome: "already-done",
123648
+ summary: { migrated: 0 }
123649
+ };
123650
+ if ((await ports.readRecords()).has(legacy.id)) return {
123651
+ outcome: "already-done",
123652
+ summary: { migrated: 0 }
123653
+ };
123654
+ await ports.putOnboardRecord(legacy.id, onboardBodyOf(legacy));
123655
+ return {
123656
+ outcome: "done",
123657
+ summary: { migrated: 1 }
123658
+ };
123659
+ }
123660
+ async function planDropEmbeddedKeys(ports) {
123661
+ const raw = await ports.readRawEmbeddedKeys();
123662
+ const present = LEGACY_EMBEDDED_KEYS.filter((key) => key in raw);
123663
+ if (present.length === 0) return { edits: [] };
123664
+ const refuse = (reason) => {
123665
+ ports.warn(`mqtt-broker migration: the legacy embedded keys are kept: ${reason}`);
123666
+ throw new Error(`the legacy embedded broker keys are not deleted: ${reason}`);
123667
+ };
123668
+ const legacy = await ports.readLegacyEmbedded();
123669
+ if (legacy !== null) {
123670
+ const record = (await ports.readRecords()).get(legacy.id);
123671
+ if (record === void 0) return refuse(`the record '${legacy.id}' does not exist`);
123672
+ if ((legacy.password ?? "").length > 0 && !record.hasPassword) return refuse(`the record '${legacy.id}' holds no password`);
123673
+ }
123674
+ return { edits: [{
123675
+ op: "deleteKeys",
123676
+ scope: ADDON_SETTINGS_TABLE,
123677
+ keys: present.map((key) => `${MQTT_BROKER_ADDON_ID}.${key}`),
123678
+ precondition: preconditionOf(Object.fromEntries(present.map((key) => [`${MQTT_BROKER_ADDON_ID}.${key}`, {
123679
+ addonId: MQTT_BROKER_ADDON_ID,
123680
+ key,
123681
+ value: raw[key]
123682
+ }])))
123683
+ }] };
123684
+ }
123685
+ //#endregion
123371
123686
  //#region src/migrations/index.ts
123372
123687
  var BROKERS_TO_RECORDS_ID = "mqtt-broker/2026-11-03-brokers-settings-to-records";
123373
123688
  var DROP_BROKERS_KEY_ID = "mqtt-broker/2026-11-03-drop-brokers-key";
123689
+ var EMBEDDED_TO_ONBOARD_ID = "mqtt-broker/2026-11-05-embedded-broker-to-onboard-record";
123690
+ var DROP_EMBEDDED_KEYS_ID = "mqtt-broker/2026-11-05-drop-embedded-keys";
123374
123691
  var FIXTURE_MARK = "a setting key the migration does not know";
123375
123692
  /**
123376
123693
  * The `mqtt-broker` catalogue (D987), offered to the engine when the addon initialises. Both
123377
123694
  * entries are `tolerate`: the embedded broker and every record keep working while a legacy key
123378
123695
  * stays where it is.
123379
123696
  */
123380
- var ADDON_MIGRATIONS = [{
123381
- meta: {
123382
- id: BROKERS_TO_RECORDS_ID,
123383
- owner: "mqtt-broker",
123384
- level: "addon-state",
123385
- seq: 1,
123386
- dependsOn: [],
123387
- store: "settings",
123388
- scope: "cluster",
123389
- onFailure: "tolerate",
123390
- bodyKind: "procedure",
123391
- description: "Check that every external broker of the old settings list is a brokers record; never deletes",
123392
- fixtureMark: FIXTURE_MARK
123393
- },
123394
- procedure: verifyBrokersAreRecords
123395
- }, {
123396
- meta: {
123397
- id: DROP_BROKERS_KEY_ID,
123398
- owner: "mqtt-broker",
123399
- level: "addon-state",
123400
- seq: 2,
123401
- dependsOn: [BROKERS_TO_RECORDS_ID],
123402
- store: "settings",
123403
- scope: "cluster",
123404
- onFailure: "tolerate",
123405
- bodyKind: "plan",
123406
- description: "Delete the legacy brokers settings key once every broker is a record",
123407
- fixtureMark: FIXTURE_MARK
123408
- },
123409
- plan: planDropBrokersKey
123410
- }];
123697
+ var ADDON_MIGRATIONS = [
123698
+ {
123699
+ meta: {
123700
+ id: BROKERS_TO_RECORDS_ID,
123701
+ owner: "mqtt-broker",
123702
+ level: "addon-state",
123703
+ seq: 1,
123704
+ dependsOn: [],
123705
+ store: "settings",
123706
+ scope: "cluster",
123707
+ onFailure: "tolerate",
123708
+ bodyKind: "procedure",
123709
+ description: "Check that every external broker of the old settings list is a brokers record; never deletes",
123710
+ fixtureMark: FIXTURE_MARK
123711
+ },
123712
+ procedure: verifyBrokersAreRecords
123713
+ },
123714
+ {
123715
+ meta: {
123716
+ id: DROP_BROKERS_KEY_ID,
123717
+ owner: "mqtt-broker",
123718
+ level: "addon-state",
123719
+ seq: 2,
123720
+ dependsOn: [BROKERS_TO_RECORDS_ID],
123721
+ store: "settings",
123722
+ scope: "cluster",
123723
+ onFailure: "tolerate",
123724
+ bodyKind: "plan",
123725
+ description: "Delete the legacy brokers settings key once every broker is a record",
123726
+ fixtureMark: FIXTURE_MARK
123727
+ },
123728
+ plan: planDropBrokersKey
123729
+ },
123730
+ {
123731
+ meta: {
123732
+ id: EMBEDDED_TO_ONBOARD_ID,
123733
+ owner: "mqtt-broker",
123734
+ level: "addon-state",
123735
+ seq: 3,
123736
+ dependsOn: [DROP_BROKERS_KEY_ID],
123737
+ store: "settings",
123738
+ scope: "cluster",
123739
+ onFailure: "tolerate",
123740
+ bodyKind: "procedure",
123741
+ description: "The legacy embedded broker (flat embedded* keys) becomes an onboard brokers record under the same id; never overwrites a record",
123742
+ fixtureMark: FIXTURE_MARK
123743
+ },
123744
+ procedure: embeddedToOnboardRecord
123745
+ },
123746
+ {
123747
+ meta: {
123748
+ id: DROP_EMBEDDED_KEYS_ID,
123749
+ owner: "mqtt-broker",
123750
+ level: "addon-state",
123751
+ seq: 4,
123752
+ dependsOn: [EMBEDDED_TO_ONBOARD_ID],
123753
+ store: "settings",
123754
+ scope: "cluster",
123755
+ onFailure: "tolerate",
123756
+ bodyKind: "plan",
123757
+ description: "Delete the flat embedded broker keys once the onboard record holds them",
123758
+ fixtureMark: FIXTURE_MARK
123759
+ },
123760
+ plan: planDropEmbeddedKeys
123761
+ }
123762
+ ];
123411
123763
  //#endregion
123412
123764
  //#region src/mqtt-entry-record-kinds.ts
123413
123765
  var aclAccessSchema = _enum([
@@ -123615,123 +123967,25 @@ var MqttAclStore = class MqttAclStore {
123615
123967
  *
123616
123968
  * Lifecycle:
123617
123969
  * - External broker entries are persisted in `this.activeExternalBrokers`.
123618
- * - The embedded broker is persisted as flat `embedded*` config keys
123619
- * (`embeddedEnabled`, `embeddedPort`, …) so the standard settings
123620
- * form can drive it; {@link MqttBrokerAddon.deriveEmbeddedEntry}
123621
- * reassembles them into an `EmbeddedBrokerEntry`.
123622
- * - On boot we start the embedded broker when `embeddedEnabled`.
123623
- * - `addBroker` / `removeBroker` / `startEmbeddedBroker` /
123624
- * `stopEmbeddedBroker` mutate the config and the runtime state.
123970
+ * - The legacy single embedded broker is an onboard `brokers` record (migrated from the flat
123971
+ * `embedded*` keys, same id); `startEmbeddedBroker` / `stopEmbeddedBroker` act on that record.
123972
+ * - `addBroker` / `removeBroker` mutate the records and the runtime state.
123625
123973
  * - `testConnection` does a one-shot `mqtt.connect` race against a 3s
123626
123974
  * timeout AND refreshes the probe cache for the broker.
123627
123975
  */
123628
123976
  /** bcrypt cost factor — exported for tests. */
123629
123977
  var MQTT_EMBEDDED_BCRYPT_ROUNDS = 10;
123630
123978
  var PROBE_INTERVAL_MS = 3e4;
123631
- var DEFAULT_EMBEDDED_PORT = 1883;
123632
- /**
123633
- * Per-kind broker creation form for kind='mqtt', rendered by the unified
123634
- * Broker manager / create picker (via `broker.getSettingsSchema`). PURE
123635
- * DATA — emits the flat `{ url, username, password, clientIdPrefix }`
123636
- * shape `broker.add`'s `settings` payload consumes. Mirrors the external
123637
- * broker fields `addBroker` accepts.
123638
- */
123639
- var mqttBrokerSettingsSchema = { sections: [{
123640
- id: "mqtt-broker-connection",
123641
- title: "MQTT connection",
123642
- titleKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.title",
123643
- columns: 1,
123644
- fields: [
123645
- {
123646
- type: "boolean",
123647
- key: "onboard",
123648
- label: "Run an onboard broker",
123649
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.onboard.label",
123650
- 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.",
123651
- descriptionKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.onboard.description",
123652
- default: false
123653
- },
123654
- {
123655
- type: "number",
123656
- key: "port",
123657
- label: "Port",
123658
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.port.label",
123659
- placeholder: "(auto)",
123660
- description: "TCP port for the onboard broker. Leave blank to auto-allocate a free port.",
123661
- descriptionKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.port.description",
123662
- showWhen: {
123663
- field: "onboard",
123664
- equals: true
123665
- }
123666
- },
123667
- {
123668
- type: "boolean",
123669
- key: "allowAnonymous",
123670
- label: "Allow anonymous",
123671
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.allowAnonymous.label",
123672
- description: "Permit clients to connect without a username/password.",
123673
- descriptionKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.allowAnonymous.description",
123674
- default: false,
123675
- showWhen: {
123676
- field: "onboard",
123677
- equals: true
123678
- }
123679
- },
123680
- {
123681
- type: "text",
123682
- key: "url",
123683
- label: "Broker URL",
123684
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.url.label",
123685
- placeholder: "mqtt://localhost:1883",
123686
- showWhen: {
123687
- field: "onboard",
123688
- equals: false
123689
- }
123690
- },
123691
- {
123692
- type: "text",
123693
- key: "username",
123694
- label: "Username",
123695
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.username.label",
123696
- placeholder: "(optional)"
123697
- },
123698
- {
123699
- type: "password",
123700
- key: "password",
123701
- label: "Password",
123702
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.password.label",
123703
- showToggle: true
123704
- },
123705
- {
123706
- type: "text",
123707
- key: "clientIdPrefix",
123708
- label: "Client ID prefix",
123709
- labelKey: "mqtt-broker:mqttBrokerSettings.s_mqtt-broker-connection.clientIdPrefix.label",
123710
- placeholder: "(optional)",
123711
- showWhen: {
123712
- field: "onboard",
123713
- equals: false
123714
- }
123715
- }
123716
- ]
123717
- }] };
123718
123979
  var DEFAULT_CONFIG = {
123719
- embeddedEnabled: false,
123720
- embeddedId: "",
123721
- embeddedPort: DEFAULT_EMBEDDED_PORT,
123722
- embeddedAllowAnonymous: false,
123723
- embeddedUsername: "",
123724
- embeddedPassword: "",
123725
123980
  embeddedClients: [],
123726
123981
  embeddedAcl: []
123727
123982
  };
123728
123983
  var MqttBrokerAddon = class extends BaseAddon {
123729
- embeddedRuntime = null;
123730
- /** Per-entry onboard `aedes` runtimes, keyed by `ExternalBrokerEntry.id`. */
123984
+ /** Per-record onboard `aedes` runtimes, keyed by `ExternalBrokerEntry.id`. */
123731
123985
  onboardRuntimes = /* @__PURE__ */ new Map();
123732
123986
  probeCache = null;
123733
123987
  brokerStore = null;
123734
- instances = null;
123988
+ brokerInstances = null;
123735
123989
  clientStore = null;
123736
123990
  aclStore = null;
123737
123991
  /** The external broker rows: the `brokers` records, secrets resolved. */
@@ -123745,6 +123999,20 @@ var MqttBrokerAddon = class extends BaseAddon {
123745
123999
  if (this.brokerStore === null) throw new Error("mqtt-broker: the broker records are not open");
123746
124000
  return this.brokerStore;
123747
124001
  }
124002
+ /** What the addon knows of a broker right now: its onboard server or its last probe. */
124003
+ liveState(id) {
124004
+ const running = this.onboardRuntimes.get(id);
124005
+ const probe = this.probeCache?.get(id);
124006
+ return {
124007
+ onboard: running === void 0 ? null : { connectedClients: running.aedes.connectedClients },
124008
+ ...probe !== void 0 ? { probe: {
124009
+ status: probe.status,
124010
+ latencyMs: probe.latencyMs,
124011
+ ...probe.error !== void 0 ? { error: probe.error } : {},
124012
+ lastCheckedAt: probe.lastCheckedAt
124013
+ } } : {}
124014
+ };
124015
+ }
123748
124016
  async persistExternalBrokers(entries) {
123749
124017
  this.activeExternalBrokers = [...entries];
123750
124018
  await this.requireBrokerStore().replaceAll(entries);
@@ -123794,19 +124062,13 @@ var MqttBrokerAddon = class extends BaseAddon {
123794
124062
  this.brokerStore = MqttBrokerStore.of(records);
123795
124063
  this.clientStore = MqttClientStore.of(records);
123796
124064
  this.aclStore = MqttAclStore.of(records);
123797
- this.instances = new RecordIntegrationInstances(records, mqttBrokersEntryKind, {
123798
- description: {
123799
- role: "services",
123800
- instanceMode: "multiple",
123801
- kind: "brokers",
123802
- canTest: false
123803
- },
123804
- formSchema: () => this.brokerForm(),
123805
- initialBody: (name) => ({
123806
- name,
123807
- url: "mqtt://"
123808
- }),
123809
- configured: (body) => body.url.trim().length > 0 && body.url.trim() !== "mqtt://",
124065
+ this.brokerInstances = buildMqttBrokerInstances(records, {
124066
+ formSchema: (id, onboard) => this.brokerForm(id, onboard),
124067
+ listMqttHaInstances: () => listMqttHaInstancesOver(this.ctx.api),
124068
+ freePort: () => findFreePort(),
124069
+ probe: (cfg) => this.probeMqtt(cfg),
124070
+ onboardListening: (id) => this.onboardRuntimes.has(id),
124071
+ liveState: (id) => this.liveState(id),
123810
124072
  onChanged: async () => {
123811
124073
  await this.refreshExternalBrokers();
123812
124074
  }
@@ -123835,29 +124097,9 @@ var MqttBrokerAddon = class extends BaseAddon {
123835
124097
  });
123836
124098
  this.probeCache.setBrokersSource(() => this.collectExternalProbeInputs());
123837
124099
  this.probeCache.start();
123838
- const persistedEmbedded = this.deriveEmbeddedEntry();
123839
- if (persistedEmbedded) try {
123840
- await this.spawnEmbedded(persistedEmbedded);
123841
- this.ctx.logger.info("embedded mqtt broker restarted", {
123842
- meta: { port: persistedEmbedded.port },
123843
- tags: {
123844
- topic: "mqtt-broker",
123845
- phase: "embedded-restarted"
123846
- }
123847
- });
123848
- } catch (err) {
123849
- this.ctx.logger.warn("embedded mqtt broker failed to restart", {
123850
- meta: { error: errMsg(err) },
123851
- tags: {
123852
- topic: "mqtt-broker",
123853
- phase: "embedded-restart-failed"
123854
- }
123855
- });
123856
- }
123857
124100
  await this.reconcileOnboardRuntimes();
123858
124101
  this.ctx.addDisposer(async () => {
123859
124102
  await this.probeCache?.stop();
123860
- await this.shutdownEmbedded().catch(() => void 0);
123861
124103
  await this.shutdownAllOnboard();
123862
124104
  });
123863
124105
  const provider = {
@@ -123869,11 +124111,13 @@ var MqttBrokerAddon = class extends BaseAddon {
123869
124111
  startEmbeddedBroker: async (input) => this.startEmbeddedBroker(input),
123870
124112
  stopEmbeddedBroker: async ({ id }) => this.stopEmbeddedBroker(id),
123871
124113
  getStatus: async () => ({
123872
- brokerCount: this.activeExternalBrokers.length + (this.config.embeddedEnabled ? 1 : 0),
123873
- embeddedRunning: this.embeddedRuntime !== null
124114
+ brokerCount: this.activeExternalBrokers.length,
124115
+ embeddedRunning: this.onboardRuntimes.size > 0
123874
124116
  })
123875
124117
  };
123876
124118
  const unifiedProvider = this.buildUnifiedBrokerProvider();
124119
+ const brokerInstances = this.brokerInstances;
124120
+ if (brokerInstances === null) throw new Error("mqtt-broker: the broker instances are not built");
123877
124121
  return [
123878
124122
  {
123879
124123
  capability: mqttBrokerCapability,
@@ -123885,111 +124129,21 @@ var MqttBrokerAddon = class extends BaseAddon {
123885
124129
  },
123886
124130
  {
123887
124131
  capability: integrationInstancesCapability,
123888
- provider: this.instances.toProvider()
124132
+ provider: brokerInstances
123889
124133
  }
123890
124134
  ];
123891
124135
  }
124136
+ /**
124137
+ * The bus-only `broker` cap (D1013). The brokers themselves are the `integration-instances`
124138
+ * provider; this one only answers the config read and the status.
124139
+ */
123892
124140
  buildUnifiedBrokerProvider() {
123893
- const KIND = "mqtt";
123894
- const ownerAddonId = this.ctx.id;
123895
- const adapt = (info) => ({
123896
- id: info.id,
123897
- addonId: ownerAddonId,
123898
- name: info.name,
123899
- kind: KIND,
123900
- status: mapMqttStatus(info.status),
123901
- info: {
123902
- ...info.latencyMs !== null ? { latencyMs: info.latencyMs } : {},
123903
- ...info.kind === "embedded" ? {
123904
- embedded: true,
123905
- connectedClients: info.connectedClients ?? 0
123906
- } : {}
123907
- },
123908
- lastCheckedAt: info.lastCheckedAt ?? null,
123909
- error: info.error ?? null
123910
- });
123911
- const owns = (id) => {
123912
- if (this.activeExternalBrokers.some((b) => b.id === id)) return true;
123913
- const embedded = this.deriveEmbeddedEntry();
123914
- return embedded !== null && embedded.id === id;
123915
- };
124141
+ const owns = (id) => this.activeExternalBrokers.some((b) => b.id === id);
123916
124142
  return {
123917
- list: async ({ kind }) => {
123918
- if (kind && kind !== KIND) return [];
123919
- return (await this.listBrokers()).map(adapt);
123920
- },
123921
- get: async ({ id }) => {
123922
- const match = (await this.listBrokers()).find((b) => b.id === id);
123923
- return match ? adapt(match) : null;
123924
- },
123925
- listProviders: async () => [{
123926
- addonId: ownerAddonId,
123927
- kinds: [{
123928
- kind: KIND,
123929
- label: "MQTT"
123930
- }]
123931
- }],
123932
- add: async ({ kind, name, settings, id }) => {
123933
- if (kind !== KIND) throw new Error(`mqtt-broker: only kind '${KIND}' is handled here (got '${kind}')`);
123934
- if (id !== void 0) throw new Error("mqtt-broker: adopting an existing broker id is not supported by this provider");
123935
- if (isOnboardRequested(settings)) return this.addOnboardBroker(name, settings);
123936
- const url = typeof settings["url"] === "string" ? settings["url"] : "";
123937
- if (!url) throw new Error("mqtt-broker.add: settings.url is required");
123938
- const input = {
123939
- name,
123940
- url,
123941
- ...typeof settings["username"] === "string" ? { username: settings["username"] } : {},
123942
- ...typeof settings["password"] === "string" ? { password: settings["password"] } : {},
123943
- ...typeof settings["clientIdPrefix"] === "string" ? { clientIdPrefix: settings["clientIdPrefix"] } : {}
123944
- };
123945
- return this.addBroker(input);
123946
- },
123947
- remove: async ({ id }) => {
123948
- if (!owns(id)) return;
123949
- await this.removeBroker(id);
123950
- },
123951
- testConnection: async ({ id }) => {
123952
- if (!owns(id)) return {
123953
- ok: false,
123954
- error: "unknown broker"
123955
- };
123956
- return this.testConnection(id);
123957
- },
123958
- getSettings: async ({ id }) => {
123959
- if (!owns(id)) return null;
123960
- return this.getBrokerConfig(id);
123961
- },
123962
- setSettings: async () => {
123963
- throw new Error("mqtt-broker.setSettings: not supported — use remove + add to replace a broker");
123964
- },
123965
124143
  getBrokerConfig: async ({ id }) => {
123966
124144
  if (!owns(id)) return null;
123967
124145
  return this.getBrokerConfig(id);
123968
124146
  },
123969
- getSettingsSchema: async ({ kind }) => {
123970
- if (kind !== KIND) return null;
123971
- return mqttBrokerSettingsSchema;
123972
- },
123973
- testSettings: async ({ kind, settings }) => {
123974
- if (kind !== KIND) return {
123975
- ok: false,
123976
- error: `unsupported kind: ${kind}`
123977
- };
123978
- if (isOnboardRequested(settings)) return {
123979
- ok: true,
123980
- latencyMs: 0
123981
- };
123982
- const url = typeof settings["url"] === "string" ? settings["url"] : "";
123983
- if (!url) return {
123984
- ok: false,
123985
- error: "settings.url is required"
123986
- };
123987
- return this.probeMqtt({
123988
- url,
123989
- ...typeof settings["username"] === "string" ? { username: settings["username"] } : {},
123990
- ...typeof settings["password"] === "string" ? { password: settings["password"] } : {}
123991
- });
123992
- },
123993
124147
  publish: async () => {
123994
124148
  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)");
123995
124149
  },
@@ -124003,7 +124157,7 @@ var MqttBrokerAddon = class extends BaseAddon {
124003
124157
  getStatus: async () => {
124004
124158
  const all = await this.listBrokers();
124005
124159
  return {
124006
- brokerCount: this.activeExternalBrokers.length + (this.config.embeddedEnabled ? 1 : 0),
124160
+ brokerCount: this.activeExternalBrokers.length,
124007
124161
  connectedCount: all.filter((b) => b.status === "connected").length
124008
124162
  };
124009
124163
  }
@@ -124011,35 +124165,13 @@ var MqttBrokerAddon = class extends BaseAddon {
124011
124165
  }
124012
124166
  async onShutdown() {
124013
124167
  await this.probeCache?.stop();
124014
- await this.shutdownEmbedded().catch(() => void 0);
124015
124168
  await this.shutdownAllOnboard();
124016
124169
  }
124017
124170
  async onConfigChanged() {
124018
124171
  this.activeExternalBrokers = await this.requireBrokerStore().listResolved();
124019
124172
  await this.syncEmbeddedEntriesFromConfig();
124020
124173
  await this.reconcileOnboardRuntimes();
124021
- const desired = this.deriveEmbeddedEntry();
124022
- const running = this.embeddedRuntime?.entry ?? null;
124023
- if (!desired && running) {
124024
- await this.shutdownEmbedded();
124025
- return;
124026
- }
124027
- if (desired && !running) {
124028
- await this.spawnEmbedded(desired).catch((err) => {
124029
- this.ctx.logger.warn("embedded mqtt broker spawn (on config change) failed", { meta: { error: errMsg(err) } });
124030
- });
124031
- return;
124032
- }
124033
- if (desired && running) {
124034
- if (requiresEmbeddedRespawn(desired, running)) {
124035
- await this.shutdownEmbedded();
124036
- await this.spawnEmbedded(desired).catch((err) => {
124037
- this.ctx.logger.warn("embedded mqtt broker respawn failed", { meta: { error: errMsg(err) } });
124038
- });
124039
- return;
124040
- }
124041
- this.reconcileEmbeddedClients();
124042
- }
124174
+ this.reconcileEmbeddedClients();
124043
124175
  }
124044
124176
  async listBrokers() {
124045
124177
  const out = [];
@@ -124073,22 +124205,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124073
124205
  };
124074
124206
  out.push(info);
124075
124207
  }
124076
- const embedded = this.deriveEmbeddedEntry();
124077
- if (embedded) {
124078
- const running = this.embeddedRuntime;
124079
- const status = running !== null ? "connected" : "disconnected";
124080
- const info = {
124081
- id: embedded.id,
124082
- name: embedded.name,
124083
- url: `mqtt://127.0.0.1:${embedded.port}`,
124084
- kind: "embedded",
124085
- status,
124086
- latencyMs: null,
124087
- ...running ? { connectedClients: running.aedes.connectedClients } : {},
124088
- lastCheckedAt: Date.now()
124089
- };
124090
- out.push(info);
124091
- }
124092
124208
  return out;
124093
124209
  }
124094
124210
  getBrokerConfig(id) {
@@ -124103,15 +124219,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124103
124219
  ...external.clientIdPrefix !== void 0 ? { clientIdPrefix: external.clientIdPrefix } : {}
124104
124220
  };
124105
124221
  }
124106
- const embedded = this.deriveEmbeddedEntry();
124107
- if (embedded && embedded.id === id) {
124108
- const password = this.storedPassword(embedded.password);
124109
- return {
124110
- url: `mqtt://127.0.0.1:${embedded.port}`,
124111
- ...embedded.username !== void 0 ? { username: embedded.username } : {},
124112
- ...password !== void 0 ? { password } : {}
124113
- };
124114
- }
124115
124222
  throw new Error(`mqtt-broker: broker '${id}' not found`);
124116
124223
  }
124117
124224
  async addBroker(input) {
@@ -124132,44 +124239,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124132
124239
  } });
124133
124240
  return { id };
124134
124241
  }
124135
- /**
124136
- * Create a per-entry ONBOARD broker from `broker.add` settings: allocate
124137
- * (or honour) a port, persist an `ExternalBrokerEntry` carrying an
124138
- * `onboard` descriptor, then spawn its dedicated `aedes` server.
124139
- */
124140
- async addOnboardBroker(name, settings) {
124141
- const id = randomUUID();
124142
- const port = readOnboardPort(settings) ?? await findFreePort();
124143
- const built = buildOnboardEntry({
124144
- id,
124145
- name,
124146
- port,
124147
- settings
124148
- });
124149
- const entry = {
124150
- id: built.id,
124151
- name: built.name,
124152
- url: built.url,
124153
- ...built.username !== void 0 ? { username: built.username } : {},
124154
- ...built.password !== void 0 ? { password: built.password } : {},
124155
- onboard: built.onboard
124156
- };
124157
- const next = [...this.activeExternalBrokers, entry];
124158
- await this.persistExternalBrokers(next);
124159
- const spawn = this.onboardSpawnEntry(entry);
124160
- if (spawn) await this.spawnOnboard(spawn);
124161
- this.ctx.logger.info("onboard mqtt broker added", {
124162
- meta: {
124163
- id,
124164
- port
124165
- },
124166
- tags: {
124167
- topic: "mqtt-broker",
124168
- phase: "onboard-started"
124169
- }
124170
- });
124171
- return { id };
124172
- }
124173
124242
  async removeBroker(id) {
124174
124243
  const removed = this.activeExternalBrokers.find((b) => b.id === id);
124175
124244
  if (removed) {
@@ -124180,13 +124249,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124180
124249
  this.ctx.logger.info("mqtt broker removed", { meta: { id } });
124181
124250
  return;
124182
124251
  }
124183
- const embedded = this.deriveEmbeddedEntry();
124184
- if (embedded && embedded.id === id) {
124185
- await this.shutdownEmbedded();
124186
- await this.updateGlobalSettings({ embeddedEnabled: false });
124187
- this.ctx.logger.info("embedded mqtt broker removed", { meta: { id } });
124188
- return;
124189
- }
124190
124252
  }
124191
124253
  async testConnection(id) {
124192
124254
  let cfg;
@@ -124205,7 +124267,7 @@ var MqttBrokerAddon = class extends BaseAddon {
124205
124267
  * One-shot connectivity probe against an MQTT endpoint built from a
124206
124268
  * flat config (`{url, username?, password?}`). Shared by the
124207
124269
  * id-keyed `testConnection` (existing broker) and the pre-creation
124208
- * `testSettings` (throwaway settings the operator hasn't saved yet).
124270
+ * the instance test (`testInstance`) of a broker not saved yet (the typed values).
124209
124271
  * Never throws — every failure resolves to `{ ok: false, error }`.
124210
124272
  */
124211
124273
  async probeMqtt(cfg) {
@@ -124250,30 +124312,46 @@ var MqttBrokerAddon = class extends BaseAddon {
124250
124312
  });
124251
124313
  });
124252
124314
  }
124315
+ /** The `brokers` records, for the two legacy cap methods that create or switch off one. */
124316
+ brokerRecordHandle() {
124317
+ const handle = this.ctx.records?.kind(mqttBrokersEntryKind);
124318
+ if (handle === void 0) throw new Error("mqtt-broker: the broker records are not open");
124319
+ return handle;
124320
+ }
124321
+ /** Create an ONBOARD broker record; the reconcile then spawns its `aedes` server. */
124253
124322
  async startEmbeddedBroker(input) {
124254
- if (this.embeddedRuntime) throw new Error("embedded broker already running — call stopEmbeddedBroker first");
124255
- const id = this.config.embeddedId || randomUUID();
124256
- const entry = {
124323
+ const id = randomUUID();
124324
+ const built = buildOnboardEntry({
124257
124325
  id,
124258
- name: "Embedded broker",
124326
+ name: LEGACY_EMBEDDED_NAME,
124259
124327
  port: input.port,
124260
- allowAnonymous: input.allowAnonymous,
124261
- ...input.username !== void 0 ? { username: input.username } : {},
124262
- ...input.password !== void 0 ? { password: input.password } : {}
124263
- };
124264
- await this.spawnEmbedded(entry);
124265
- await this.updateGlobalSettings({
124266
- embeddedEnabled: true,
124267
- embeddedId: id,
124268
- embeddedPort: input.port,
124269
- embeddedAllowAnonymous: input.allowAnonymous,
124270
- embeddedUsername: input.username ?? "",
124271
- embeddedPassword: input.password ?? ""
124328
+ settings: {
124329
+ allowAnonymous: input.allowAnonymous,
124330
+ ...input.username !== void 0 ? { username: input.username } : {},
124331
+ ...input.password !== void 0 ? { password: input.password } : {}
124332
+ }
124272
124333
  });
124334
+ const handle = this.brokerRecordHandle();
124335
+ await handle.put({
124336
+ id,
124337
+ data: mqttBrokersEntryKind.schema.parse({
124338
+ name: built.name,
124339
+ url: built.url,
124340
+ ...built.username !== void 0 ? { username: built.username } : {},
124341
+ ...built.password !== void 0 ? { password: built.password } : {},
124342
+ onboard: built.onboard
124343
+ })
124344
+ }, { ifRev: 0 });
124345
+ await this.refreshExternalBrokers();
124346
+ if (!this.onboardRuntimes.has(id)) {
124347
+ await handle.delete(id);
124348
+ await this.refreshExternalBrokers();
124349
+ throw new Error(`embedded broker could not listen on port ${input.port}`);
124350
+ }
124273
124351
  this.ctx.logger.info("embedded mqtt broker started", {
124274
124352
  meta: {
124275
124353
  id,
124276
- port: entry.port
124354
+ port: input.port
124277
124355
  },
124278
124356
  tags: {
124279
124357
  topic: "mqtt-broker",
@@ -124295,20 +124373,22 @@ var MqttBrokerAddon = class extends BaseAddon {
124295
124373
  });
124296
124374
  return {
124297
124375
  id,
124298
- url: `mqtt://127.0.0.1:${entry.port}`
124376
+ url: built.url
124299
124377
  };
124300
124378
  }
124379
+ /** Switch an onboard broker off (its record stays, so it can be switched on again). */
124301
124380
  async stopEmbeddedBroker(id) {
124302
- if (!this.embeddedRuntime || this.embeddedRuntime.entry.id !== id) return;
124303
- await this.shutdownEmbedded();
124304
- await this.updateGlobalSettings({ embeddedEnabled: false });
124381
+ const handle = this.brokerRecordHandle();
124382
+ const row = await handle.get(id);
124383
+ if (row === null || row.data.onboard === void 0) return;
124384
+ await handle.patch(id, { enabled: false });
124385
+ await this.refreshExternalBrokers();
124305
124386
  this.ctx.logger.info("embedded mqtt broker stopped", { meta: { id } });
124306
124387
  }
124307
124388
  /**
124308
124389
  * Build + listen an `aedes` server for `entry` and RETURN the runtime
124309
- * without assigning it to any field. Shared by the legacy single
124310
- * embedded broker ({@link spawnEmbedded}) and the per-entry onboard
124311
- * brokers ({@link spawnOnboard}).
124390
+ * without assigning it to any field (the onboard brokers keep theirs in
124391
+ * {@link spawnOnboard}).
124312
124392
  */
124313
124393
  async spawnAedes(entry) {
124314
124394
  const aedes = new import_aedes.default();
@@ -124347,15 +124427,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124347
124427
  aedes.close(() => resolve());
124348
124428
  });
124349
124429
  }
124350
- async spawnEmbedded(entry) {
124351
- this.embeddedRuntime = await this.spawnAedes(entry);
124352
- }
124353
- async shutdownEmbedded() {
124354
- if (!this.embeddedRuntime) return;
124355
- const runtime = this.embeddedRuntime;
124356
- this.embeddedRuntime = null;
124357
- await this.closeRuntime(runtime);
124358
- }
124359
124430
  /** Spawn a dedicated onboard `aedes` server for an onboard entry. */
124360
124431
  async spawnOnboard(entry) {
124361
124432
  const runtime = await this.spawnAedes(entry);
@@ -124492,8 +124563,9 @@ var MqttBrokerAddon = class extends BaseAddon {
124492
124563
  * (or whose username was removed entirely) get force-closed.
124493
124564
  */
124494
124565
  reconcileEmbeddedClients() {
124495
- const runtime = this.embeddedRuntime;
124496
- if (!runtime) return;
124566
+ for (const runtime of this.onboardRuntimes.values()) this.reconcileRuntimeClients(runtime);
124567
+ }
124568
+ reconcileRuntimeClients(runtime) {
124497
124569
  const prevSnapshot = runtime.credentialsSnapshot;
124498
124570
  const nextSnapshot = this.buildCredentialsSnapshot();
124499
124571
  const affectedUsers = [];
@@ -124532,26 +124604,6 @@ var MqttBrokerAddon = class extends BaseAddon {
124532
124604
  for (const c of this.activeEmbeddedClients) m.set(c.username, c.passwordHash);
124533
124605
  return m;
124534
124606
  }
124535
- /**
124536
- * Reassemble the internal {@link EmbeddedBrokerEntry} from the flat
124537
- * `embedded*` config keys the settings form writes. Returns `null`
124538
- * when the embedded broker is disabled — that's the signal
124539
- * `onConfigChanged` / `onInitialize` use to decide whether to spawn
124540
- * or shut down the `aedes` instance.
124541
- */
124542
- deriveEmbeddedEntry() {
124543
- if (!this.config.embeddedEnabled) return null;
124544
- const username = this.config.embeddedUsername;
124545
- const password = this.config.embeddedPassword;
124546
- return {
124547
- id: this.config.embeddedId || "embedded",
124548
- name: "Embedded broker",
124549
- port: this.config.embeddedPort,
124550
- allowAnonymous: this.config.embeddedAllowAnonymous,
124551
- ...username.length > 0 ? { username } : {},
124552
- ...password.length > 0 ? { password } : {}
124553
- };
124554
- }
124555
124607
  collectExternalProbeInputs() {
124556
124608
  const out = [];
124557
124609
  for (const b of this.activeExternalBrokers) {
@@ -124588,19 +124640,13 @@ var MqttBrokerAddon = class extends BaseAddon {
124588
124640
  * here (no migration code ships): the operator's one-off `mqtt` step does.
124589
124641
  */
124590
124642
  reportRetiredCiphertext() {
124591
- const paths = retiredCiphertextPaths({
124592
- brokers: this.activeExternalBrokers,
124593
- embeddedPassword: this.config.embeddedPassword
124594
- });
124643
+ const paths = retiredCiphertextPaths({ brokers: this.activeExternalBrokers });
124595
124644
  if (paths.length === 0) return;
124596
124645
  const unreadable = [];
124597
124646
  const values = [...this.activeExternalBrokers.map((b) => ({
124598
124647
  id: `brokers[${b.id}].password`,
124599
124648
  v: b.password
124600
- })), {
124601
- id: "embeddedPassword",
124602
- v: this.config.embeddedPassword
124603
- }];
124649
+ }))];
124604
124650
  for (const { id, v } of values) try {
124605
124651
  this.storedPassword(v);
124606
124652
  } catch (err) {
@@ -124624,13 +124670,79 @@ var MqttBrokerAddon = class extends BaseAddon {
124624
124670
  }
124625
124671
  });
124626
124672
  }
124627
- brokerForm() {
124673
+ /**
124674
+ * The broker form. The create form (`id` null) offers "run an onboard broker" (CamStack hosts an
124675
+ * `aedes` server on this hub: a port, anonymous access) or an external URL; an existing onboard
124676
+ * broker edits only its credentials, since its address is the one it was given.
124677
+ */
124678
+ brokerForm(id, onboard) {
124679
+ const credentials = [{
124680
+ type: "text",
124681
+ key: "username",
124682
+ label: "Username",
124683
+ labelKey: "mqtt-broker:fullSettings.brokers.username.label"
124684
+ }, {
124685
+ type: "password",
124686
+ key: "password",
124687
+ label: "Password",
124688
+ labelKey: "mqtt-broker:fullSettings.brokers.password.label",
124689
+ showToggle: true
124690
+ }];
124691
+ if (onboard) return this.schema({ sections: [{
124692
+ id: "broker",
124693
+ title: "Onboard broker",
124694
+ description: "A CamStack-hosted broker on this hub. Its port was chosen when it was created.",
124695
+ columns: 1,
124696
+ fields: credentials
124697
+ }] });
124698
+ const creating = id === null;
124699
+ const external = {
124700
+ field: "onboard",
124701
+ equals: false
124702
+ };
124628
124703
  return this.schema({ sections: [{
124629
124704
  id: "broker",
124630
- title: "External broker",
124631
- description: "A broker running outside CamStack (mosquitto, EMQX, HiveMQ, cloud bridges, …). The password is kept in the keychain.",
124705
+ title: creating ? "MQTT broker" : "External broker",
124706
+ 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.",
124632
124707
  columns: 1,
124633
124708
  fields: [
124709
+ ...creating ? [
124710
+ {
124711
+ type: "boolean",
124712
+ key: "onboard",
124713
+ label: "Run an onboard broker",
124714
+ labelKey: "mqtt-broker:fullSettings.brokers.onboard.label",
124715
+ 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.",
124716
+ descriptionKey: "mqtt-broker:fullSettings.brokers.onboard.description",
124717
+ default: false
124718
+ },
124719
+ {
124720
+ type: "number",
124721
+ key: "port",
124722
+ label: "Port",
124723
+ labelKey: "mqtt-broker:fullSettings.brokers.port.label",
124724
+ placeholder: "(auto)",
124725
+ description: "TCP port for the onboard broker. Leave blank to auto-allocate a free port.",
124726
+ descriptionKey: "mqtt-broker:fullSettings.brokers.port.description",
124727
+ showWhen: {
124728
+ field: "onboard",
124729
+ equals: true
124730
+ }
124731
+ },
124732
+ {
124733
+ type: "boolean",
124734
+ key: "allowAnonymous",
124735
+ label: "Allow anonymous",
124736
+ labelKey: "mqtt-broker:fullSettings.brokers.allowAnonymous.label",
124737
+ description: "Permit clients to connect without a username/password.",
124738
+ descriptionKey: "mqtt-broker:fullSettings.brokers.allowAnonymous.description",
124739
+ default: false,
124740
+ showWhen: {
124741
+ field: "onboard",
124742
+ equals: true
124743
+ }
124744
+ }
124745
+ ] : [],
124634
124746
  {
124635
124747
  type: "text",
124636
124748
  key: "url",
@@ -124638,26 +124750,15 @@ var MqttBrokerAddon = class extends BaseAddon {
124638
124750
  labelKey: "mqtt-broker:fullSettings.brokers.url.label",
124639
124751
  description: "mqtt://, mqtts://, ws://, wss://",
124640
124752
  descriptionKey: "mqtt-broker:fullSettings.brokers.url.description",
124641
- required: true
124642
- },
124643
- {
124644
- type: "text",
124645
- key: "username",
124646
- label: "Username",
124647
- labelKey: "mqtt-broker:fullSettings.brokers.username.label"
124648
- },
124649
- {
124650
- type: "password",
124651
- key: "password",
124652
- label: "Password",
124653
- labelKey: "mqtt-broker:fullSettings.brokers.password.label",
124654
- showToggle: true
124753
+ ...creating ? { showWhen: external } : { required: true }
124655
124754
  },
124755
+ ...credentials,
124656
124756
  {
124657
124757
  type: "text",
124658
124758
  key: "clientIdPrefix",
124659
124759
  label: "Client id prefix",
124660
- labelKey: "mqtt-broker:fullSettings.brokers.clientIdPrefix.label"
124760
+ labelKey: "mqtt-broker:fullSettings.brokers.clientIdPrefix.label",
124761
+ ...creating ? { showWhen: external } : {}
124661
124762
  }
124662
124763
  ]
124663
124764
  }] });
@@ -124668,6 +124769,41 @@ var MqttBrokerAddon = class extends BaseAddon {
124668
124769
  if (handle === void 0) throw new Error("records are not available");
124669
124770
  return {
124670
124771
  readRawBrokers: async () => (await this.ctx.settings?.readAddonStore())?.["brokers"],
124772
+ readLegacyEmbedded: async () => {
124773
+ const raw = await this.ctx.settings?.readAddonStore() ?? {};
124774
+ const secrets = this.ctx.secrets;
124775
+ const { value } = secrets === void 0 ? { value: raw } : await resolveSecretMarkers(raw, secrets, {
124776
+ unresolved: "keep-marker",
124777
+ ownedBy: secrets.owner
124778
+ });
124779
+ const text = (key) => {
124780
+ const v = value[key];
124781
+ return typeof v === "string" && v.length > 0 ? v : void 0;
124782
+ };
124783
+ const port = value["embeddedPort"];
124784
+ const username = text("embeddedUsername");
124785
+ const password = this.storedPassword(text("embeddedPassword"));
124786
+ const enabled = value["embeddedEnabled"] === true;
124787
+ if (!(enabled || text("embeddedId") !== void 0 || username !== void 0 || password !== void 0)) return null;
124788
+ return {
124789
+ enabled,
124790
+ id: text("embeddedId") ?? "embedded",
124791
+ port: typeof port === "number" && Number.isInteger(port) ? port : 1883,
124792
+ allowAnonymous: value["embeddedAllowAnonymous"] === true,
124793
+ ...username !== void 0 ? { username } : {},
124794
+ ...password !== void 0 ? { password } : {}
124795
+ };
124796
+ },
124797
+ readRawEmbeddedKeys: async () => {
124798
+ const raw = await this.ctx.settings?.readAddonStore() ?? {};
124799
+ return Object.fromEntries(LEGACY_EMBEDDED_KEYS.filter((key) => key in raw).map((key) => [key, raw[key]]));
124800
+ },
124801
+ putOnboardRecord: async (id, body) => {
124802
+ await handle.put({
124803
+ id,
124804
+ data: mqttBrokersEntryKind.schema.parse(body)
124805
+ }, { ifRev: 0 });
124806
+ },
124671
124807
  readRecords: async () => {
124672
124808
  const page = await handle.query({ limit: 1e3 });
124673
124809
  return new Map(page.records.map((r) => [r.id, { hasPassword: (r.data.password ?? "").length > 0 }]));
@@ -124684,184 +124820,105 @@ var MqttBrokerAddon = class extends BaseAddon {
124684
124820
  return ["mqtt-broker", "broker"];
124685
124821
  }
124686
124822
  /**
124687
- * The embedded broker, its credentials and the ACL. The external brokers are records, listed
124823
+ * The credentials and the ACL of the onboard brokers. The brokers are records, listed
124688
124824
  * on the Brokers page and edited as integration instances (`brokerForm`).
124689
124825
  */
124690
124826
  async clusterSettingsSchema(cap) {
124691
124827
  if (cap !== "mqtt-broker" && cap !== "broker") return null;
124692
- return this.schema({ sections: [
124693
- {
124694
- id: "mqtt-broker-embedded",
124695
- title: "Embedded broker",
124696
- titleKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.title",
124697
- 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.",
124698
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.description",
124699
- columns: 2,
124700
- fields: [
124701
- this.field({
124702
- type: "boolean",
124703
- key: "embeddedEnabled",
124704
- label: "Run embedded broker",
124705
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedEnabled.label",
124706
- description: "Starts an aedes broker on this hub. Disabling it shuts the broker down and disconnects clients.",
124707
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedEnabled.description",
124708
- style: "switch",
124709
- default: false
124710
- }),
124711
- this.field({
124712
- type: "number",
124713
- key: "embeddedPort",
124714
- label: "Port",
124715
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedPort.label",
124716
- description: "TCP port the embedded broker listens on.",
124717
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedPort.description",
124718
- min: 1,
124719
- max: 65535,
124720
- default: DEFAULT_EMBEDDED_PORT,
124721
- showWhen: {
124722
- field: "embeddedEnabled",
124723
- equals: true
124724
- }
124725
- }),
124726
- this.field({
124727
- type: "boolean",
124728
- key: "embeddedAllowAnonymous",
124729
- label: "Allow anonymous",
124730
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedAllowAnonymous.label",
124731
- description: "Permit clients to connect without a username/password.",
124732
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedAllowAnonymous.description",
124733
- style: "switch",
124734
- default: false,
124735
- showWhen: {
124736
- field: "embeddedEnabled",
124737
- equals: true
124738
- }
124739
- }),
124740
- this.field({
124741
- type: "text",
124742
- key: "embeddedUsername",
124743
- label: "Shared username",
124744
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedUsername.label",
124745
- description: "Legacy single-user fallback — used only when no per-client credentials are configured below.",
124746
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedUsername.description",
124747
- default: "",
124748
- showWhen: {
124749
- field: "embeddedEnabled",
124750
- equals: true
124751
- }
124752
- }),
124753
- this.field({
124754
- type: "password",
124755
- key: "embeddedPassword",
124756
- label: "Shared password",
124757
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedPassword.label",
124758
- description: "Kept in the keychain. Paired with the shared username above.",
124759
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded.embeddedPassword.description",
124760
- showToggle: true,
124761
- default: "",
124762
- showWhen: {
124763
- field: "embeddedEnabled",
124764
- equals: true
124765
- }
124766
- })
124767
- ]
124768
- },
124769
- {
124770
- id: "mqtt-broker-embedded-clients",
124771
- title: "Embedded broker — credentials",
124772
- titleKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.title",
124773
- 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.",
124774
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.description",
124775
- columns: 1,
124776
- fields: [{
124777
- type: "editable-array",
124778
- key: "embeddedClients",
124779
- label: "Clients",
124780
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.label",
124781
- emptyMessage: "No clients configured — embedded broker is in shared-creds / anonymous mode.",
124782
- addLabel: "Add client",
124783
- rowTitleTemplate: "{username}",
124784
- defaultItem: {
124785
- username: "",
124786
- passwordHash: ""
124787
- },
124788
- itemFields: [{
124828
+ return this.schema({ sections: [{
124829
+ id: "mqtt-broker-embedded-clients",
124830
+ title: "Embedded broker — credentials",
124831
+ titleKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.title",
124832
+ 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.",
124833
+ descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.description",
124834
+ columns: 1,
124835
+ fields: [{
124836
+ type: "editable-array",
124837
+ key: "embeddedClients",
124838
+ label: "Clients",
124839
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.label",
124840
+ emptyMessage: "No clients configured — embedded broker is in shared-creds / anonymous mode.",
124841
+ addLabel: "Add client",
124842
+ rowTitleTemplate: "{username}",
124843
+ defaultItem: {
124844
+ username: "",
124845
+ passwordHash: ""
124846
+ },
124847
+ itemFields: [{
124848
+ type: "text",
124849
+ key: "username",
124850
+ label: "Username",
124851
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.username.label"
124852
+ }, {
124853
+ type: "text",
124854
+ key: "passwordHash",
124855
+ label: "bcrypt hash",
124856
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.passwordHash.label",
124857
+ description: "bcrypt hash of the password (cost factor 10).",
124858
+ descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.passwordHash.description"
124859
+ }]
124860
+ }]
124861
+ }, {
124862
+ id: "mqtt-broker-embedded-acl",
124863
+ title: "Embedded broker — topic ACL",
124864
+ titleKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.title",
124865
+ 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.",
124866
+ descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.description",
124867
+ columns: 1,
124868
+ fields: [{
124869
+ type: "editable-array",
124870
+ key: "embeddedAcl",
124871
+ label: "Rules",
124872
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.label",
124873
+ emptyMessage: "No ACL rules — embedded broker is wide open to authenticated users.",
124874
+ addLabel: "Add rule",
124875
+ rowTitleTemplate: "{username} → {topicPattern} ({access})",
124876
+ defaultItem: {
124877
+ username: "",
124878
+ topicPattern: "#",
124879
+ access: "both"
124880
+ },
124881
+ itemFields: [
124882
+ {
124789
124883
  type: "text",
124790
124884
  key: "username",
124791
124885
  label: "Username",
124792
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.username.label"
124793
- }, {
124886
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.username.label"
124887
+ },
124888
+ {
124794
124889
  type: "text",
124795
- key: "passwordHash",
124796
- label: "bcrypt hash",
124797
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.passwordHash.label",
124798
- description: "bcrypt hash of the password (cost factor 10).",
124799
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-clients.embeddedClients.passwordHash.description"
124800
- }]
124801
- }]
124802
- },
124803
- {
124804
- id: "mqtt-broker-embedded-acl",
124805
- title: "Embedded broker — topic ACL",
124806
- titleKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.title",
124807
- 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.",
124808
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.description",
124809
- columns: 1,
124810
- fields: [{
124811
- type: "editable-array",
124812
- key: "embeddedAcl",
124813
- label: "Rules",
124814
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.label",
124815
- emptyMessage: "No ACL rules — embedded broker is wide open to authenticated users.",
124816
- addLabel: "Add rule",
124817
- rowTitleTemplate: "{username} → {topicPattern} ({access})",
124818
- defaultItem: {
124819
- username: "",
124820
- topicPattern: "#",
124821
- access: "both"
124890
+ key: "topicPattern",
124891
+ label: "Topic pattern",
124892
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.topicPattern.label",
124893
+ description: "MQTT wildcards: `+` = single level, `#` = multi-level.",
124894
+ descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.topicPattern.description"
124822
124895
  },
124823
- itemFields: [
124824
- {
124825
- type: "text",
124826
- key: "username",
124827
- label: "Username",
124828
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.username.label"
124829
- },
124830
- {
124831
- type: "text",
124832
- key: "topicPattern",
124833
- label: "Topic pattern",
124834
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.topicPattern.label",
124835
- description: "MQTT wildcards: `+` = single level, `#` = multi-level.",
124836
- descriptionKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.topicPattern.description"
124837
- },
124838
- {
124839
- type: "select",
124840
- key: "access",
124841
- label: "Access",
124842
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.label",
124843
- options: [
124844
- {
124845
- value: "sub",
124846
- label: "Subscribe only",
124847
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_sub.label"
124848
- },
124849
- {
124850
- value: "pub",
124851
- label: "Publish only",
124852
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_pub.label"
124853
- },
124854
- {
124855
- value: "both",
124856
- label: "Subscribe + publish",
124857
- labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_both.label"
124858
- }
124859
- ]
124860
- }
124861
- ]
124862
- }]
124863
- }
124864
- ] });
124896
+ {
124897
+ type: "select",
124898
+ key: "access",
124899
+ label: "Access",
124900
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.label",
124901
+ options: [
124902
+ {
124903
+ value: "sub",
124904
+ label: "Subscribe only",
124905
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_sub.label"
124906
+ },
124907
+ {
124908
+ value: "pub",
124909
+ label: "Publish only",
124910
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_pub.label"
124911
+ },
124912
+ {
124913
+ value: "both",
124914
+ label: "Subscribe + publish",
124915
+ labelKey: "mqtt-broker:fullSettings.s_mqtt-broker-embedded-acl.embeddedAcl.access.o_both.label"
124916
+ }
124917
+ ]
124918
+ }
124919
+ ]
124920
+ }]
124921
+ }] });
124865
124922
  }
124866
124923
  };
124867
124924
  /**
@@ -124891,13 +124948,6 @@ function bcryptCompareSafe(plain, hash) {
124891
124948
  }
124892
124949
  }
124893
124950
  /**
124894
- * Decide whether a config update to the embedded broker requires a full
124895
- * restart (port change) or can be hot-reloaded (credential / ACL change).
124896
- */
124897
- function requiresEmbeddedRespawn(desired, running) {
124898
- return desired.id !== running.id || desired.port !== running.port || desired.allowAnonymous !== running.allowAnonymous || desired.username !== running.username || desired.password !== running.password;
124899
- }
124900
- /**
124901
124951
  * Read the `username` field off an aedes Client without leaking a cast.
124902
124952
  * aedes attaches the username at runtime but its `.d.ts` doesn't carry
124903
124953
  * it — we narrow with a small structural check.
@@ -124950,15 +125000,5 @@ async function findFreePort() {
124950
125000
  });
124951
125001
  });
124952
125002
  }
124953
- function mapMqttStatus(s) {
124954
- switch (s) {
124955
- case "connected": return "connected";
124956
- case "disconnected": return "disconnected";
124957
- case "auth-failed": return "auth-failed";
124958
- case "unreachable": return "unreachable";
124959
- case "tls-error": return "error";
124960
- default: return "error";
124961
- }
124962
- }
124963
125003
  //#endregion
124964
125004
  export { MQTT_EMBEDDED_BCRYPT_ROUNDS, MqttBrokerAddon, MqttBrokerAddon as default };