// mosquitto's provisioner — the adapter that makes mosquitto a provider of the mesh `mqtt-topic` // interface. The reconcile loop, the contributions file, and reading the mesh's minted password are // the sdk harness's; this writes only the per-service half: how mosquitto creates and removes a // per-consumer MQTT client (novox/hq ADR 0039/0040/0048). // // The `mqtt-topic` interface: a consumer connects as `as` with the password the mesh minted, and // publishes and subscribes under `/#`, isolated from every other consumer by a Dynamic Security // role scoped to exactly that subtree — unless it contributed `topics`, the MQTT topic filters its // work needs (a home-automation hub needs the devices' topics); then the role grants exactly those // (topics.ts). A list that is not valid topic filters is refused, and the consumer is not created // or changed until it is fixed. // // What a consumer is told (its binding): `at` — the broker's machine — and `port`, the machine port // of the MQTT listener (the manifest's `serves`); `as` is its login, and its copy of the password is // the pair credential the mesh delivers to it. // // **The login and password are the mesh's, not the provisioner's (ADR 0048).** The mesh derives the // login and hands it to both ends so they agree, and mints the password and delivers a copy to each. // mosquitto creates exactly that client with exactly that password — a name or password the // provisioner invented is one the consumer could never present. import { runProvisioner, type Provision } from "@novox/mesh-sdk/provisioner"; import { emit } from "@novox/mesh-sdk/events"; import { MosquittoClient } from "../client.js"; import { topicFilters } from "../topics.js"; const mosquitto = MosquittoClient.fromEnv(); /** Emit a lifecycle event without letting a broker hiccup fail the provisioning itself. */ async function announce(type: string, body: Record): Promise { try { await emit(type, body); } catch (err) { console.error(`[provisioner:mqtt-topic] emit ${type} failed: ${err}`); } } runProvisioner("mqtt-topic", { async create(p: Provision): Promise { // By default the consumer's own subtree, so one cannot read another's topics; what it // contributed as `topics` otherwise. const granted = topicFilters(p.values, p.as); if ("problem" in granted) { // Thrown, so the harness logs it and retries: the consumer stays as it was (or absent) until // its contribution is valid, rather than being given a grant it did not ask for. throw new Error(`${p.as}: ${granted.problem}`); } await mosquitto.createScopedClient(p.as, p.password, granted.filters); await announce("topic.provisioned", { consumer: p.consumer ?? "", username: p.as, topicPrefix: granted.own ? p.as : "", topics: granted.filters.join(" "), }); }, async remove(p: { as: string }): Promise { await mosquitto.deleteScopedClient(p.as); await announce("topic.deprovisioned", { username: p.as }); }, // Asked every minute by the harness: whether the backend still holds this consumer exactly as // the mesh gave it, so a login lost behind the provisioner's back is made again (novox/hq issue 120). async holds(p: Provision): Promise { const granted = topicFilters(p.values, p.as); // An invalid list was never applied; create refuses it again, loudly, on every pass. if ("problem" in granted) return false; return mosquitto.holdsClient(p.as, p.password, granted.filters); }, });