nexo-plugin-manifest 0.1.8

TOML manifest schema + 4-tier validator for native Rust nexo plugins (Phase 81.1).
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
//! Plugin-driven pairing UI descriptor.
//!
//! Plugins that expose a channel which can be linked to an agent
//! declare HOW that link happens via `[plugin.pairing]` in their
//! `nexo-plugin.toml`. The admin reads this via the
//! `nexo/admin/pairing/channels` RPC and renders a modal driven
//! entirely by the descriptor — no per-channel hardcoded logic
//! in the admin frontend.
//!
//! Section is opt-in. Plugins without a channel (e.g. pure tool
//! plugins, MCP servers) omit the block entirely and never appear
//! in the admin's channel selector.

use std::collections::BTreeMap;

use serde::{Deserialize, Serialize};

/// Pairing UI section in `nexo-plugin.toml`. Absent / unset =
/// plugin does not expose a pair-able channel.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PairingSection {
    /// Pairing flow kind. `None` = section present but plugin
    /// declares "no channel here". Treated identically to absent
    /// section by the admin (channel filtered out).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub kind: Option<PairingKind>,

    /// Human-visible label for the channel selector. When `None`,
    /// the admin falls back to `plugin.name`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub label: Option<String>,

    /// Operator-facing instructions per BCP-47 locale tag. The
    /// admin resolves the active locale, falls back to `en`, and
    /// finally to the first entry in iteration order.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub instructions: BTreeMap<String, String>,

    /// Form-flow only (`kind = Form`). Fields the admin renders
    /// inside the modal. Empty for other kinds; if a plugin
    /// declares fields with a non-form kind they are ignored
    /// (logged at warn level when the admin reads the descriptor).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub fields: Vec<PairingFieldDescriptor>,

    /// Custom-flow only (`kind = Custom`). JSON-RPC notification
    /// method namespace the plugin pushes its progress on
    /// (`nexo/notify/<rpc_namespace>/status_changed`). When
    /// `None` and `kind = Custom`, the admin defaults to the
    /// plugin id.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rpc_namespace: Option<String>,

    /// Phase 81.30 follow-up #4 — name of the field (inside
    /// [`Self::fields`]) whose value the admin should treat as
    /// the credential's `instance` discriminator when calling
    /// `credentials/register`. `None` falls back to the literal
    /// `"instance"` for backwards compat with whatsapp + telegram
    /// (both ship a field called `instance`). Only meaningful
    /// when `kind = Form`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub instance_field: Option<String>,

    /// Phase 81.33.b.real — pairing-adapter broker dispatch
    /// descriptor. When present, daemon constructs a
    /// `GenericBrokerPairingAdapter` from these fields and inserts
    /// it into `PairingAdapterRegistry` under
    /// `adapter.channel_id`. Plugins that supply this section
    /// no longer need a hardcoded `XxxPairingAdapter::new(broker)`
    /// block on the daemon side.
    ///
    /// Sub-section of `[plugin.pairing]` because the adapter is
    /// only meaningful for plugins that ALSO ship a pairing UI
    /// descriptor (a plugin without a pair-able channel has no
    /// reason to declare an adapter).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub adapter: Option<PairingAdapterSection>,

    /// Phase 81.20.x Stage 7 Phase 2 — pairing-trigger broker
    /// dispatch descriptor. When present, daemon constructs a
    /// `BrokerPairingTrigger` (in `nexo-pairing`) keyed by
    /// `adapter.channel_id` (or the plugin id if no adapter is
    /// declared) and inserts it into the dispatcher's
    /// `PairingChannelTriggers` map. The trigger forwards
    /// `pairing/start` to `start_method` via [`PluginAdminRouter`]
    /// — the plugin subprocess owns the QR pump.
    ///
    /// Without this section, daemon falls back to a hardcoded
    /// trigger registration (legacy `WhatsappPairingTrigger` import
    /// path). Plugins that adopt the section drop the daemon-side
    /// coupling entirely.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub trigger: Option<PairingTriggerSection>,
}

/// Pairing-trigger broker dispatch descriptor. Daemon-side
/// `BrokerPairingTrigger` reads these to know which
/// [`plugin.admin`] methods to forward `pairing/start` and
/// `pairing/cancel` to. The plugin subprocess owns the QR pump
/// — daemon only orchestrates start/cancel and subscribes to
/// inbound QR/state updates published by the plugin on
/// `plugin.inbound.<channel>.<instance>.pairing.{qr,state}`.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PairingTriggerSection {
    /// Admin RPC method the daemon invokes to start a pairing
    /// pump (e.g. `"nexo/admin/whatsapp/pairing/start"`). Routed
    /// through [`PluginAdminRouter`] so the plugin's existing
    /// admin handler subsystem serves it. MUST live under the
    /// plugin's own `[plugin.admin] method_prefix`.
    pub start_method: String,

    /// Admin RPC method the daemon invokes to cancel an
    /// in-flight pairing (e.g.
    /// `"nexo/admin/whatsapp/pairing/cancel"`). Same routing rules
    /// as [`Self::start_method`].
    pub cancel_method: String,

    /// Per-call timeout in seconds. `None` = inherit
    /// `PAIRING_DEFAULT_TIMEOUT` (180s).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub timeout_seconds: Option<u64>,
}

/// Pairing adapter broker dispatch descriptor. Daemon-side
/// `GenericBrokerPairingAdapter` reads these to translate trait
/// method calls into JSON-RPC requests on the plugin's broker
/// subject prefix.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PairingAdapterSection {
    /// Stable string id matching what the gate stores in
    /// `pairing_pending.channel` and `pairing_allow_from.channel`.
    /// Must equal `PairingSection::kind`-derived channel name in
    /// practice; daemon trusts what the manifest declares here as
    /// the registry key.
    pub channel_id: String,

    /// Broker subject prefix the adapter dispatches under. Daemon
    /// publishes JSON-RPC requests to:
    /// - `<broker_topic_prefix>.pairing.normalize_sender`
    /// - `<broker_topic_prefix>.pairing.send_reply`
    /// - `<broker_topic_prefix>.pairing.send_qr_image`
    /// - `<broker_topic_prefix>.pairing.format_challenge_text`
    ///   (only if `format_challenge_text_kind = "broker"`; default
    ///   uses the trait's built-in formatter).
    pub broker_topic_prefix: String,

    /// Format-challenge dispatch mode. `default` (the value the
    /// trait already supplies) handles the common case; `broker`
    /// asks the plugin per challenge so channels that need custom
    /// formatting (e.g. Telegram MarkdownV2 escape) can override.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub format_challenge_text_kind: Option<String>,

    /// Cache TTL in seconds for `normalize_sender` results. `None`
    /// = unbounded (cache lives the daemon's lifetime). Pairing
    /// flows are low-volume so unbounded is the default; channels
    /// with churn can declare a TTL to evict stale entries.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub normalize_cache_ttl_seconds: Option<u64>,
}

impl PairingSection {
    /// `true` when the manifest writer omitted every field.
    /// Equivalent to "no section present"; used by
    /// `skip_serializing_if` on the parent `PluginSection`.
    pub fn is_unset(&self) -> bool {
        self.kind.is_none()
            && self.label.is_none()
            && self.instructions.is_empty()
            && self.fields.is_empty()
            && self.rpc_namespace.is_none()
            && self.instance_field.is_none()
            && self.adapter.is_none()
            && self.trigger.is_none()
    }
}

/// Pairing flow kind. Closed enum to keep the admin's `switch`
/// statement exhaustive. New flow kinds require a manifest
/// schema bump; `Custom` covers escape-hatch use cases without
/// expanding this enum.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum PairingKind {
    /// QR-code pairing (WhatsApp Web style). Admin renders the
    /// existing QR component which polls `pairing/start` +
    /// `pairing/status`.
    Qr,
    /// Form-based credential entry (Telegram bot token, …). Admin
    /// renders the `fields` list, posts the values to
    /// `credentials/register` on submit.
    Form,
    /// Informational only — channel is already configured
    /// out-of-band (YAML, env vars, …) and the admin just shows
    /// the instructions + a "Continue" button.
    Info,
    /// Plugin-defined flow. Admin opens the modal with
    /// `instructions`, subscribes to
    /// `nexo/notify/<rpc_namespace>/status_changed`, and closes
    /// on a terminal `state` ("linked" / "error" / "cancelled").
    Custom,
}

/// One field rendered inside the `Form`-flow modal.
///
/// Shape mirrors `crate::manifest::UiHint` (same vocabulary —
/// `label/help/sensitive/placeholder`) but adds `name` (the
/// stable key submitted back to `credentials/register`) and
/// `required` (admin blocks submit when missing).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PairingFieldDescriptor {
    /// Stable identifier — submitted to `credentials/register`
    /// verbatim. Snake_case by convention; the parser does not
    /// enforce a regex (consistency comes from review).
    pub name: String,

    /// Operator-visible label.
    pub label: String,

    /// Optional inline help text rendered under the input.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub help: Option<String>,

    /// When `true`, admin renders `<input type="password">` and
    /// never logs the value. Default `false`.
    #[serde(default)]
    pub sensitive: bool,

    /// Optional placeholder shown when the input is empty.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub placeholder: Option<String>,

    /// When `true`, admin disables submit until the operator
    /// provides a non-empty value. Default `false`.
    #[serde(default)]
    pub required: bool,
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_section_is_unset() {
        let s = PairingSection::default();
        assert!(s.is_unset());
    }

    #[test]
    fn qr_kind_round_trips() {
        let toml_src = r#"
kind = "qr"
label = "WhatsApp"

[instructions]
es = "Abrí WhatsApp y escaneá."
en = "Open WhatsApp and scan."
"#;
        let parsed: PairingSection = toml::from_str(toml_src).unwrap();
        assert_eq!(parsed.kind, Some(PairingKind::Qr));
        assert_eq!(parsed.label.as_deref(), Some("WhatsApp"));
        assert_eq!(parsed.instructions.len(), 2);
        assert!(parsed.fields.is_empty());
    }

    #[test]
    fn form_kind_with_fields_round_trips() {
        let toml_src = r#"
kind = "form"
label = "Telegram"

[[fields]]
name = "instance"
label = "Bot username"
placeholder = "mi_bot"
required = true

[[fields]]
name = "token"
label = "Bot token"
sensitive = true
required = true
"#;
        let parsed: PairingSection = toml::from_str(toml_src).unwrap();
        assert_eq!(parsed.kind, Some(PairingKind::Form));
        assert_eq!(parsed.fields.len(), 2);
        assert_eq!(parsed.fields[0].name, "instance");
        assert!(parsed.fields[0].required);
        assert!(!parsed.fields[0].sensitive);
        assert!(parsed.fields[1].sensitive);
    }

    #[test]
    fn info_kind_without_fields_round_trips() {
        let toml_src = r#"
kind = "info"
[instructions]
en = "Configure plugin via YAML and restart."
"#;
        let parsed: PairingSection = toml::from_str(toml_src).unwrap();
        assert_eq!(parsed.kind, Some(PairingKind::Info));
        assert!(parsed.fields.is_empty());
    }

    #[test]
    fn custom_kind_with_namespace_round_trips() {
        let toml_src = r#"
kind = "custom"
rpc_namespace = "myauth"
"#;
        let parsed: PairingSection = toml::from_str(toml_src).unwrap();
        assert_eq!(parsed.kind, Some(PairingKind::Custom));
        assert_eq!(parsed.rpc_namespace.as_deref(), Some("myauth"));
    }

    #[test]
    fn unknown_kind_errors_with_clear_message() {
        let toml_src = r#"kind = "bluetooth""#;
        let err = toml::from_str::<PairingSection>(toml_src).unwrap_err();
        // serde's enum error mentions "unknown variant" — keeps
        // the message stable enough to assert on.
        assert!(
            err.to_string().contains("unknown variant"),
            "expected 'unknown variant' in error, got: {err}"
        );
    }

    #[test]
    fn deny_unknown_fields_rejects_typos() {
        let toml_src = r#"
kind = "qr"
laybel = "WhatsApp"
"#;
        let err = toml::from_str::<PairingSection>(toml_src).unwrap_err();
        assert!(
            err.to_string().contains("unknown field"),
            "expected 'unknown field' in error, got: {err}"
        );
    }

    #[test]
    fn skip_serializing_if_unset_emits_empty_toml() {
        let s = PairingSection::default();
        let out = toml::to_string(&s).unwrap();
        assert!(out.trim().is_empty(), "expected empty TOML, got: {out:?}");
    }

    #[test]
    fn trigger_section_round_trips() {
        let toml_src = r#"
kind = "qr"

[trigger]
start_method = "nexo/admin/whatsapp/pairing/start"
cancel_method = "nexo/admin/whatsapp/pairing/cancel"
timeout_seconds = 120
"#;
        let parsed: PairingSection = toml::from_str(toml_src).unwrap();
        let trigger = parsed.trigger.expect("trigger present");
        assert_eq!(trigger.start_method, "nexo/admin/whatsapp/pairing/start");
        assert_eq!(trigger.cancel_method, "nexo/admin/whatsapp/pairing/cancel");
        assert_eq!(trigger.timeout_seconds, Some(120));
    }

    #[test]
    fn trigger_section_timeout_optional() {
        let toml_src = r#"
kind = "qr"

[trigger]
start_method = "nexo/admin/foo/pair/start"
cancel_method = "nexo/admin/foo/pair/cancel"
"#;
        let parsed: PairingSection = toml::from_str(toml_src).unwrap();
        let trigger = parsed.trigger.expect("trigger present");
        assert!(trigger.timeout_seconds.is_none());
    }

    #[test]
    fn trigger_section_deny_unknown_fields() {
        let toml_src = r#"
[trigger]
start_method = "a"
cancel_method = "b"
woops = true
"#;
        let err = toml::from_str::<PairingSection>(toml_src).unwrap_err();
        assert!(err.to_string().contains("unknown field"));
    }

    #[test]
    fn pairing_section_unset_includes_trigger() {
        let s = PairingSection {
            trigger: Some(PairingTriggerSection {
                start_method: "x".into(),
                cancel_method: "y".into(),
                timeout_seconds: None,
            }),
            ..PairingSection::default()
        };
        assert!(!s.is_unset());
    }
}