Skip to main content

fedimint_server_ui/dashboard/modules/
meta.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap};
2use std::sync::LazyLock;
3
4use axum::extract::{Form, FromRequest, Query, State};
5use axum::http::StatusCode;
6use axum::response::{Html, IntoResponse, Response};
7use chrono::NaiveDateTime;
8use fedimint_core::PeerId;
9use fedimint_core::module::serde_json::{self, Value};
10use fedimint_meta_server::Meta;
11use fedimint_server_core::dashboard_ui::{DashboardApiModuleExt, DynDashboardApi};
12use fedimint_ui_common::UiState;
13use fedimint_ui_common::auth::UserAuth;
14use maud::{Markup, html};
15use serde::Serialize;
16use thiserror::Error;
17use tracing::{debug, warn};
18
19use crate::LOG_UI;
20
21// Meta route constants
22pub const META_SUBMIT_ROUTE: &str = "/meta/submit";
23pub const META_SET_ROUTE: &str = "/meta/set";
24pub const META_RESET_ROUTE: &str = "/meta/reset";
25pub const META_DELETE_ROUTE: &str = "/meta/delete";
26pub const META_VALUE_INPUT_ROUTE: &str = "/meta/value-input";
27pub const META_MERGE_ROUTE: &str = "/meta/merge";
28
29/// The type of value expected for a well-known meta key.
30enum KeyType {
31    String,
32    Url,
33    Amount,
34    DateTime,
35    Json,
36}
37
38/// Schema describing a well-known meta key.
39struct KeySchema {
40    description: &'static str,
41    value_type: KeyType,
42}
43
44// <https://fedibtc.github.io/fedi-docs/docs/fedi/meta_fields/federation-metadata-configurations>
45// NOTE: If you're updating this, please update `docs/meta_fields/README.md`
46static WELL_KNOWN_KEYS: LazyLock<BTreeMap<&'static str, KeySchema>> = LazyLock::new(|| {
47    BTreeMap::from([
48        (
49            "welcome_message",
50            KeySchema {
51                description: "A welcome message for new users joining the federation",
52                value_type: KeyType::String,
53            },
54        ),
55        (
56            "federation_expiry_timestamp",
57            KeySchema {
58                description: "The date and time after which the federation will shut down",
59                value_type: KeyType::DateTime,
60            },
61        ),
62        (
63            "federation_name",
64            KeySchema {
65                description: "The human-readable name of the federation",
66                value_type: KeyType::String,
67            },
68        ),
69        (
70            "federation_successor",
71            KeySchema {
72                description: "An invite code to a successor federation for user migration",
73                value_type: KeyType::String,
74            },
75        ),
76        (
77            "meta_override_url",
78            KeySchema {
79                description: "A URL to a file containing overrides for meta fields",
80                value_type: KeyType::Url,
81            },
82        ),
83        (
84            "vetted_gateways",
85            KeySchema {
86                description: "A list of gateway identifiers vetted by the federation",
87                value_type: KeyType::Json,
88            },
89        ),
90        (
91            "recurringd_api",
92            KeySchema {
93                description: "The API URL of a recurringd instance for creating LNURLs",
94                value_type: KeyType::Url,
95            },
96        ),
97        (
98            "lnaddress_api",
99            KeySchema {
100                description: "The API URL of a Lightning Address Server for serving LNURLs",
101                value_type: KeyType::Url,
102            },
103        ),
104        (
105            "fedi:pinned_message",
106            KeySchema {
107                description: "",
108                value_type: KeyType::String,
109            },
110        ),
111        (
112            "fedi:federation_icon_url",
113            KeySchema {
114                description: "",
115                value_type: KeyType::Url,
116            },
117        ),
118        (
119            "fedi:tos_url",
120            KeySchema {
121                description: "",
122                value_type: KeyType::Url,
123            },
124        ),
125        (
126            "fedi:default_currency",
127            KeySchema {
128                description: "",
129                value_type: KeyType::String,
130            },
131        ),
132        (
133            "fedi:invite_codes_disabled",
134            KeySchema {
135                description: "",
136                value_type: KeyType::String,
137            },
138        ),
139        (
140            "fedi:new_members_disabled",
141            KeySchema {
142                description: "",
143                value_type: KeyType::String,
144            },
145        ),
146        (
147            "fedi:max_invoice_msats",
148            KeySchema {
149                description: "",
150                value_type: KeyType::Amount,
151            },
152        ),
153        (
154            "fedi:max_balance_msats",
155            KeySchema {
156                description: "",
157                value_type: KeyType::Amount,
158            },
159        ),
160        (
161            "fedi:max_stable_balance_msats",
162            KeySchema {
163                description: "",
164                value_type: KeyType::Amount,
165            },
166        ),
167        (
168            "fedi:fedimods",
169            KeySchema {
170                description: "",
171                value_type: KeyType::Json,
172            },
173        ),
174        (
175            "fedi:default_group_chats",
176            KeySchema {
177                description: "",
178                value_type: KeyType::Json,
179            },
180        ),
181        (
182            "fedi:offline_wallet_disabled",
183            KeySchema {
184                description: "",
185                value_type: KeyType::String,
186            },
187        ),
188    ])
189});
190
191// Function to render the Meta module UI section
192pub async fn render(meta: &Meta) -> Markup {
193    // Get current consensus value
194    let consensus_value = meta.handle_get_consensus_request_ui().await.ok().flatten();
195    // Get current revision number
196    let revision = meta
197        .handle_get_consensus_revision_request_ui()
198        .await
199        .ok()
200        .unwrap_or(0);
201    // Get current submissions from all peers
202    let submissions = meta
203        .handle_get_submissions_request_ui()
204        .await
205        .ok()
206        .unwrap_or_default();
207
208    let consensus_map = consensus_value
209        .as_ref()
210        .and_then(|v| v.as_object().cloned())
211        .unwrap_or_default();
212
213    let current_meta_keys = if let Some(o) = submissions
214        .get(&meta.our_peer_id)
215        .cloned()
216        .or_else(|| consensus_value.clone())
217        .and_then(|v| v.as_object().cloned())
218    {
219        o
220    } else {
221        serde_json::Map::new()
222    };
223
224    html! {
225        div class="card h-100" {
226            div class="card-header dashboard-header" { "Meta Configuration" }
227            div class="card-body" {
228                div class="mb-4" {
229                    h5 { "Current Consensus (Revision: " (revision) ")" }
230                    @if consensus_value.is_some() {
231                        div class="row mb-2" {
232                            div class="col-md-6" {
233                                strong { "Full document" }
234                                pre class="m-0 p-2 bg-light" style="max-height: 40vh; overflow-y: auto;" {
235                                    code {
236                                        (serde_json::to_string_pretty(&consensus_map).unwrap_or_else(|_| "Invalid JSON".to_string()))
237                                    }
238                                }
239                            }
240                            div class="col-md-6" {
241                                (render_consensus_summary(&consensus_map))
242                            }
243                        }
244                    } @else {
245                        div class="alert alert-secondary" { "No consensus value has been established yet." }
246                    }
247                    div class="mb-4" {
248                        (render_meta_edit_form(&consensus_map, current_meta_keys, false, MetaEditForm::default()))
249                    }
250
251                    (render_submissions_form(meta.our_peer_id, &consensus_map, &submissions))
252                }
253            }
254        }
255    }
256}
257
258/// A single change between the consensus and a proposal.
259enum MetaChange {
260    Set { key: String, value: String },
261    Deleted { key: String },
262}
263
264/// Computes an itemized list of changes between `consensus` and `proposal`.
265fn compute_changes(
266    consensus: &serde_json::Map<String, Value>,
267    proposal: &serde_json::Map<String, Value>,
268) -> Vec<MetaChange> {
269    let mut changes = Vec::new();
270
271    // Keys set or modified in the proposal
272    for (key, new_val) in proposal {
273        let changed = consensus.get(key) != Some(new_val);
274        if changed {
275            changes.push(MetaChange::Set {
276                key: key.clone(),
277                value: format_value_for_display(key, new_val),
278            });
279        }
280    }
281
282    // Keys deleted from consensus
283    for key in consensus.keys() {
284        if !proposal.contains_key(key) {
285            changes.push(MetaChange::Deleted { key: key.clone() });
286        }
287    }
288
289    changes
290}
291
292/// Formats a meta value for human-readable display, using the key schema when
293/// available (e.g. UNIX timestamps become formatted dates).
294fn format_value_for_display(key: &str, value: &Value) -> String {
295    if let Some(schema) = WELL_KNOWN_KEYS.get(key) {
296        match schema.value_type {
297            KeyType::DateTime => {
298                if let Some(ts) = value
299                    .as_str()
300                    .and_then(|s| s.parse::<i64>().ok())
301                    .and_then(|t| chrono::DateTime::from_timestamp(t, 0))
302                {
303                    return ts.format("%Y-%m-%d %H:%M UTC").to_string();
304                }
305            }
306            KeyType::Amount => {
307                if let Some(s) = value.as_str() {
308                    return format!("{s} msats");
309                }
310            }
311            KeyType::Json => {
312                return serde_json::to_string_pretty(value).unwrap_or_else(|_| value.to_string());
313            }
314            KeyType::Url | KeyType::String => {}
315        }
316    }
317
318    match value {
319        Value::String(s) => s.clone(),
320        other => other.to_string(),
321    }
322}
323
324/// Renders the "Proposed changes" summary from a list of [`MetaChange`]s.
325fn render_changes_summary(changes: &[MetaChange]) -> Markup {
326    html! {
327        strong { "Proposed changes" }
328        @if changes.is_empty() {
329            p class="text-muted" { "No changes" }
330        } @else {
331            ul class="mb-0 ps-3" {
332                @for change in changes {
333                    li {
334                        @match change {
335                            MetaChange::Set { key, value } => {
336                                strong { (key) }
337                                " set to "
338                                em { (value) }
339                            },
340                            MetaChange::Deleted { key } => {
341                                strong { (key) }
342                                " deleted"
343                            },
344                        }
345                    }
346                }
347            }
348        }
349    }
350}
351
352/// Renders an itemized summary of all key-value pairs in a meta map,
353/// formatting values using their schema when available.
354fn render_consensus_summary(map: &serde_json::Map<String, Value>) -> Markup {
355    html! {
356        @if map.is_empty() {
357            span class="text-muted" { "No fields set" }
358        } @else {
359            strong { "Summary" }
360            ul class="mb-0 ps-3" {
361                @for (key, value) in map {
362                    li {
363                        strong { (key) }
364                        " = "
365                        em { (format_value_for_display(key, value)) }
366                    }
367                }
368            }
369        }
370    }
371}
372
373fn render_submissions_form(
374    our_id: PeerId,
375    consensus: &serde_json::Map<String, Value>,
376    submissions: &BTreeMap<PeerId, Value>,
377) -> Markup {
378    let mut submissions_by_value: HashMap<
379        String,
380        (BTreeSet<PeerId>, serde_json::Map<String, Value>),
381    > = HashMap::new();
382
383    for (peer_id, value) in submissions {
384        let value_str =
385            serde_json::to_string_pretty(value).unwrap_or_else(|_| "Invalid JSON".to_string());
386        let proposal_map = value.as_object().cloned().unwrap_or_default();
387        let entry = submissions_by_value
388            .entry(value_str)
389            .or_insert_with(|| (BTreeSet::new(), proposal_map));
390        entry.0.insert(*peer_id);
391    }
392
393    html! {
394        div #meta-submissions hx-swap-oob=(true) {
395            @if !submissions.is_empty() {
396                h5 { "Current Proposals" }
397                @for (value_str, (peer_ids, proposal_map)) in &submissions_by_value {
398                    div class="card mb-3" {
399                        div class="card-header py-2" {
400                            strong { "Peers: " }
401                            (peer_ids.iter()
402                                .map(|n| n.to_string())
403                                .collect::<Vec<String>>()
404                                .join(", "))
405                        }
406                        div class="card-body py-2" {
407                            div class="row" {
408                                div class="col-md-6" {
409                                    strong { "Full proposal" }
410                                    pre class="m-0 p-2 bg-light" style="max-height: 40vh; overflow-y: auto;" {
411                                        code { (value_str) }
412                                    }
413                                }
414                                div class="col-md-6" {
415                                    (render_changes_summary(&compute_changes(consensus, proposal_map)))
416                                }
417                            }
418                        }
419                        @if !peer_ids.contains(&our_id) {
420                            div class="card-footer py-2 d-flex gap-2 justify-content-end" {
421                                form method="post"
422                                    hx-post=(META_SUBMIT_ROUTE)
423                                    hx-swap="none"
424                                {
425                                    input type="hidden" name="json_content"
426                                        value=(value_str);
427                                    button type="submit" class="btn btn-sm btn-success" {
428                                        "Accept As-Is"
429                                    }
430                                }
431                                form method="post"
432                                    hx-post=(META_MERGE_ROUTE)
433                                    hx-swap="none"
434                                    hx-include="#meta-edit-form [name='json_content']"
435                                {
436                                    input type="hidden" name="proposal_json"
437                                        value=(value_str);
438                                    button type="submit" class="btn btn-sm btn-primary" {
439                                        "Add Changes To My Proposal"
440                                    }
441                                }
442                            }
443                        }
444                    }
445                }
446            }
447        }
448    }
449}
450
451// Form for meta value submission
452#[derive(serde::Deserialize, Default)]
453pub struct MetaEditForm {
454    pub json_content: String,
455    #[serde(default)]
456    pub add_key: String,
457    #[serde(default)]
458    pub add_value: String,
459    #[serde(default)]
460    pub delete_key: String,
461}
462
463impl MetaEditForm {
464    fn top_level_keys(&self) -> RequestResult<serde_json::Map<String, Value>> {
465        Ok(
466            if let Some(serde_json::Value::Object(o)) =
467                serde_json::from_slice(self.json_content.as_bytes())
468                    .map_err(|x| RequestError::BadRequest { source: x.into() })?
469            {
470                o
471            } else {
472                serde_json::Map::new()
473            },
474        )
475    }
476}
477
478/// Helper to fetch the current consensus as a JSON object map.
479async fn get_consensus_map(meta: &Meta) -> serde_json::Map<String, Value> {
480    meta.handle_get_consensus_request_ui()
481        .await
482        .ok()
483        .flatten()
484        .and_then(|v| v.as_object().cloned())
485        .unwrap_or_default()
486}
487
488pub async fn post_submit(
489    State(state): State<UiState<DynDashboardApi>>,
490    _auth: UserAuth,
491    Form(form): Form<MetaEditForm>,
492) -> RequestResult<Response> {
493    let meta_module = state.api.get_module::<Meta>().unwrap();
494
495    let top_level_keys = form.top_level_keys()?;
496    let top_level_object = Value::Object(top_level_keys.clone());
497
498    meta_module
499        .handle_submit_request_ui(top_level_object.clone())
500        .await
501        .inspect_err(|msg| warn!(target: LOG_UI, msg= %msg.message, "Request error"))
502        .map_err(|_err| RequestError::InternalError)?;
503
504    let consensus_map = get_consensus_map(meta_module).await;
505
506    let mut submissions = meta_module
507        .handle_get_submissions_request_ui()
508        .await
509        .ok()
510        .unwrap_or_default();
511
512    submissions.insert(meta_module.our_peer_id, top_level_object);
513
514    let content = html! {
515        (render_meta_edit_form(&consensus_map, top_level_keys, false, MetaEditForm::default()))
516
517        // Re-render submission with our submission added, as it will take couple of milliseconds
518        // for it to get processed and it's confusing if it doesn't immediately show up.
519        (render_submissions_form(meta_module.our_peer_id, &consensus_map, &submissions))
520    };
521    Ok(Html(content.into_string()).into_response())
522}
523
524pub async fn post_reset(
525    State(state): State<UiState<DynDashboardApi>>,
526    _auth: UserAuth,
527    Form(_form): Form<MetaEditForm>,
528) -> RequestResult<Response> {
529    let meta_module = state.api.get_module::<Meta>().unwrap();
530
531    let consensus_map = get_consensus_map(meta_module).await;
532    let top_level_keys = consensus_map.clone();
533    let top_level_object = Value::Object(top_level_keys.clone());
534
535    meta_module
536        .handle_submit_request_ui(top_level_object.clone())
537        .await
538        .inspect_err(|msg| warn!(target: LOG_UI, msg = %msg.message, "Request error"))
539        .map_err(|_err| RequestError::InternalError)?;
540
541    let mut submissions = meta_module
542        .handle_get_submissions_request_ui()
543        .await
544        .ok()
545        .unwrap_or_default();
546
547    submissions.remove(&meta_module.our_peer_id);
548
549    let content = html! {
550        (render_meta_edit_form(&consensus_map, top_level_keys, false, MetaEditForm::default()))
551
552        // Re-render submission with our submission removed, as it will take couple of milliseconds
553        // for it to get processed and it's confusing if it doesn't immediately show up.
554        (render_submissions_form(meta_module.our_peer_id, &consensus_map, &submissions))
555    };
556    Ok(Html(content.into_string()).into_response())
557}
558
559pub async fn post_set(
560    State(state): State<UiState<DynDashboardApi>>,
561    _auth: UserAuth,
562    Form(mut form): Form<MetaEditForm>,
563) -> RequestResult<Response> {
564    let meta_module = state.api.get_module::<Meta>().unwrap();
565    let consensus_map = get_consensus_map(meta_module).await;
566
567    let mut top_level_object = form.top_level_keys()?;
568
569    let key = form.add_key.trim();
570    let raw_value = form.add_value.trim();
571    let key_type = WELL_KNOWN_KEYS
572        .get(key)
573        .map(|s| &s.value_type)
574        .unwrap_or(&KeyType::String);
575    let value = convert_input_value(raw_value, key_type)?;
576
577    top_level_object.insert(key.to_string(), value);
578
579    form.add_key = "".into();
580    form.add_value = "".into();
581    let content = render_meta_edit_form(
582        &consensus_map,
583        top_level_object,
584        true,
585        MetaEditForm::default(),
586    );
587    Ok(Html(content.into_string()).into_response())
588}
589
590pub async fn post_delete(
591    State(state): State<UiState<DynDashboardApi>>,
592    _auth: UserAuth,
593    Form(mut form): Form<MetaEditForm>,
594) -> RequestResult<Response> {
595    let meta_module = state.api.get_module::<Meta>().unwrap();
596    let consensus_map = get_consensus_map(meta_module).await;
597
598    let mut top_level_json = form.top_level_keys()?;
599
600    let key = form.delete_key.trim();
601
602    top_level_json.remove(key);
603    form.delete_key = "".into();
604
605    let content = render_meta_edit_form(&consensus_map, top_level_json, true, form);
606    Ok(Html(content.into_string()).into_response())
607}
608
609#[derive(serde::Deserialize)]
610pub struct MetaMergeForm {
611    pub json_content: String,
612    pub proposal_json: String,
613}
614
615pub async fn post_merge(
616    State(state): State<UiState<DynDashboardApi>>,
617    _auth: UserAuth,
618    Form(form): Form<MetaMergeForm>,
619) -> RequestResult<Response> {
620    let meta_module = state.api.get_module::<Meta>().unwrap();
621    let consensus_map = get_consensus_map(meta_module).await;
622
623    let mut current: serde_json::Map<String, Value> =
624        if let Ok(Value::Object(o)) = serde_json::from_str(&form.json_content) {
625            o
626        } else {
627            serde_json::Map::new()
628        };
629
630    let proposal: serde_json::Map<String, Value> =
631        if let Ok(Value::Object(o)) = serde_json::from_str(&form.proposal_json) {
632            o
633        } else {
634            serde_json::Map::new()
635        };
636
637    // Compute changes from consensus -> proposal and apply to current
638    for change in &compute_changes(&consensus_map, &proposal) {
639        match change {
640            MetaChange::Set { key, .. } => {
641                if let Some(val) = proposal.get(key) {
642                    current.insert(key.clone(), val.clone());
643                }
644            }
645            MetaChange::Deleted { key } => {
646                current.remove(key);
647            }
648        }
649    }
650
651    let content = render_meta_edit_form(&consensus_map, current, true, MetaEditForm::default());
652    Ok(Html(content.into_string()).into_response())
653}
654
655/// Renders the appropriate HTML input element for the given key type.
656///
657/// Always returns a single element (no nested input-groups) so it can be a
658/// direct child of the main `.input-group` without breaking Bootstrap's
659/// `:first-child`/`:last-child` border-radius selectors.
660fn render_value_input(key_type: &KeyType, current_value: &str) -> Markup {
661    match key_type {
662        KeyType::Url => html! {
663            input #add-value type="url" name="add_value" class="form-control"
664                placeholder="https://..." aria-label="Value"
665                value=(current_value) {}
666        },
667        KeyType::Amount => html! {
668            input #add-value type="number" name="add_value" class="form-control"
669                placeholder="Amount (msats)" aria-label="Value"
670                value=(current_value) {}
671        },
672        KeyType::DateTime => {
673            // Pre-fill with today at 00:00 so the time portion isn't blank
674            let val = if current_value.is_empty() {
675                chrono::Utc::now().format("%Y-%m-%dT00:00").to_string()
676            } else {
677                current_value.to_string()
678            };
679            html! {
680                input #add-value type="datetime-local" name="add_value" class="form-control"
681                    aria-label="Value" value=(val) {}
682            }
683        }
684        KeyType::Json => html! {
685            input #add-value type="text" name="add_value" class="form-control"
686                placeholder="{}" aria-label="Value"
687                value=(current_value) {}
688        },
689        KeyType::String => html! {
690            input #add-value type="text" name="add_value" class="form-control"
691                placeholder="Value" aria-label="Value"
692                value=(current_value) {}
693        },
694    }
695}
696
697/// Renders the description hint for a well-known key (if any), including a
698/// type hint for Amount and DateTime fields whose unit labels are no longer
699/// shown inline in the input-group.
700fn render_value_description(key: &str) -> Markup {
701    let schema = WELL_KNOWN_KEYS.get(key);
702    let description = schema.map(|s| s.description).unwrap_or("");
703    let type_hint = match schema.map(|s| &s.value_type) {
704        Some(KeyType::DateTime) => " (UTC)",
705        Some(KeyType::Amount) => " (msats)",
706        _ => "",
707    };
708
709    html! {
710        @if !description.is_empty() || !type_hint.is_empty() {
711            small class="form-text text-muted" { (description) (type_hint) }
712        }
713    }
714}
715
716/// Renders a dropdown picker for well-known keys (plus any extra keys from
717/// the current proposal). Picking a key copies it into the `#add-key` text
718/// input and triggers its HTMX `change` event, then resets itself to "▾".
719fn render_key_picker(extra_keys: &BTreeSet<String>) -> Markup {
720    let all_keys: BTreeSet<&str> = WELL_KNOWN_KEYS
721        .keys()
722        .copied()
723        .chain(extra_keys.iter().map(|s| s.as_str()))
724        .collect();
725
726    html! {
727        select class="form-select"
728            style="flex: 0 0 auto; width: 2em; padding-left: 0.2em;"
729            aria-label="Pick a well-known key"
730            onchange="if(this.value){var k=document.getElementById('add-key');k.value=this.value;this.value='';htmx.trigger(k,'change')}"
731        {
732            option value="" selected {}
733            @for key in &all_keys {
734                option value=(key) { (key) }
735            }
736        }
737    }
738}
739
740/// Renders the Set button. When there is no Delete button next to it,
741/// `rounded-end` is added so Bootstrap applies right border-radius despite
742/// the hidden placeholder that follows.
743fn render_set_button(key_in_proposal: bool, oob: bool) -> Markup {
744    let class = if key_in_proposal {
745        "btn btn-primary btn-min-width"
746    } else {
747        "btn btn-primary btn-min-width rounded-end"
748    };
749    html! {
750        button #button-set class=(class) type="button"
751            hx-swap-oob=[oob.then_some("outerHTML")]
752            title="Set a value in a meta proposal"
753            hx-post=(META_SET_ROUTE)
754            hx-swap="none"
755            hx-trigger="click, keypress[key=='Enter'] from:#add-value, keypress[key=='Enter'] from:#add-key"
756        { "Set" }
757    }
758}
759
760/// Renders the Delete button (visible) or a hidden placeholder (invisible).
761/// Uses `outerHTML` OOB swap when returned from the HTMX endpoint.
762fn render_delete_button(key: &str, visible: bool, oob: bool) -> Markup {
763    if visible {
764        html! {
765            button #button-delete class="btn btn-danger btn-min-width" type="button"
766                hx-swap-oob=[oob.then_some("outerHTML")]
767                title="Delete this key from the proposal"
768                hx-post=(META_DELETE_ROUTE)
769                hx-swap="none"
770                hx-vals=(format!(r#"{{"delete_key":"{}"}}"#, key))
771            { "Delete" }
772        }
773    } else {
774        html! {
775            span #button-delete style="display:none"
776                hx-swap-oob=[oob.then_some("outerHTML")]
777            {}
778        }
779    }
780}
781
782/// Parses json_content into a JSON object map, returning empty map on failure.
783fn parse_proposal(json_content: &str) -> serde_json::Map<String, Value> {
784    serde_json::from_str(json_content)
785        .ok()
786        .and_then(|v: Value| v.as_object().cloned())
787        .unwrap_or_default()
788}
789
790/// Query params for the value-input HTMX endpoint.
791#[derive(serde::Deserialize)]
792pub struct ValueInputQuery {
793    #[serde(default)]
794    pub add_key: String,
795    #[serde(default)]
796    pub json_content: String,
797}
798
799/// HTMX endpoint: returns a type-appropriate value input for the selected key
800/// (primary `outerHTML` swap on `#add-value`), plus OOB swaps for the
801/// description hint and the Set/Delete buttons.
802pub async fn get_value_input(
803    _auth: UserAuth,
804    Query(query): Query<ValueInputQuery>,
805) -> impl IntoResponse {
806    let key = query.add_key.trim();
807    let proposal = parse_proposal(&query.json_content);
808    let key_in_proposal = !key.is_empty() && proposal.contains_key(key);
809
810    let key_type = WELL_KNOWN_KEYS
811        .get(key)
812        .map(|s| &s.value_type)
813        .unwrap_or(&KeyType::String);
814
815    let content = html! {
816        // Primary swap target: replaces #add-value via outerHTML
817        (render_value_input(key_type, ""))
818        // OOB swaps for description, Set button, and Delete button
819        div #value-description-container hx-swap-oob="innerHTML" {
820            (render_value_description(key))
821        }
822        (render_set_button(key_in_proposal, true))
823        (render_delete_button(key, key_in_proposal, true))
824    };
825    Html(content.into_string())
826}
827
828/// Converts raw form input to a [`serde_json::Value`] appropriate for the
829/// given key type.
830fn convert_input_value(raw: &str, key_type: &KeyType) -> RequestResult<Value> {
831    match key_type {
832        KeyType::DateTime => {
833            // Browsers may send with or without seconds
834            let dt = NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M:%S")
835                .or_else(|_| NaiveDateTime::parse_from_str(raw, "%Y-%m-%dT%H:%M"))
836                .map_err(|e| RequestError::BadRequest {
837                    source: anyhow::anyhow!("Invalid datetime: {e}"),
838                })?;
839            Ok(Value::String(dt.and_utc().timestamp().to_string()))
840        }
841        KeyType::Amount => {
842            let _: u64 = raw.parse().map_err(|e| RequestError::BadRequest {
843                source: anyhow::anyhow!("Invalid amount: {e}"),
844            })?;
845            Ok(Value::String(raw.to_string()))
846        }
847        KeyType::Json => serde_json::from_str(raw).map_err(|e| RequestError::BadRequest {
848            source: anyhow::anyhow!("Invalid JSON: {e}"),
849        }),
850        KeyType::Url | KeyType::String => {
851            // Try JSON parse first (backward compat), fall back to plain string
852            Ok(serde_json::from_str(raw).unwrap_or_else(|_| Value::String(raw.to_string())))
853        }
854    }
855}
856
857pub fn render_meta_edit_form(
858    consensus: &serde_json::Map<String, Value>,
859    mut top_level_json: serde_json::Map<String, Value>,
860    // was the value edited via set/delete
861    pending: bool,
862    form: MetaEditForm,
863) -> Markup {
864    top_level_json.sort_keys();
865
866    let changes = compute_changes(consensus, &top_level_json);
867    let extra_keys: BTreeSet<String> = top_level_json.keys().cloned().collect();
868    let all_keys: BTreeSet<&str> = WELL_KNOWN_KEYS
869        .keys()
870        .copied()
871        .chain(extra_keys.iter().map(|s| s.as_str()))
872        .collect();
873    let default_input = render_value_input(&KeyType::String, &form.add_value);
874
875    html! {
876        form #meta-edit-form hx-swap-oob=(true) {
877            h5 {
878                "Propose Changes"
879                @if pending {
880                    " (Pending - click Submit to Propose)"
881                }
882            }
883            input type="hidden" name="json_content"
884                value=(serde_json::to_string_pretty(&top_level_json).expect("Can't fail")) {}
885            div class="row mb-2" {
886                div class="col-md-6" {
887                    strong { "Full proposal" }
888                    pre class="m-0 p-2 bg-light" style="min-height: 120px; max-height: 40vh; overflow-y: auto;" {
889                        code {
890                            (serde_json::to_string_pretty(&top_level_json).expect("Can't fail"))
891                        }
892                    }
893                }
894                div class="col-md-6" {
895                    (render_changes_summary(&changes))
896                }
897            }
898            // Datalist for autocomplete on the key text input
899            datalist #keyOptions {
900                @for key in &all_keys {
901                    option value=(key) {}
902                }
903            }
904            // All children are direct elements — no wrapper divs — so
905            // Bootstrap's input-group :first-child/:last-child CSS works.
906            div class="input-group mb-1" {
907                (render_key_picker(&extra_keys))
908                input #add-key type="text" list="keyOptions" name="add_key" class="form-control"
909                    style="max-width: 250px;" placeholder="Key"
910                    hx-get=(META_VALUE_INPUT_ROUTE)
911                    hx-trigger="change, input changed delay:300ms"
912                    hx-target="#add-value"
913                    hx-swap="outerHTML"
914                    hx-include="#meta-edit-form [name='json_content']"
915                {}
916                span class="input-group-text" { ":" }
917                (default_input)
918                (render_set_button(false, false))
919                (render_delete_button("", false, false))
920            }
921            div #value-description-container {}
922            div class="d-flex justify-content-between btn-min-width" {
923                button type="button" class="btn btn-outline-warning me-5"
924                    title="Reset to current consensus"
925                    hx-post=(META_RESET_ROUTE)
926                    hx-swap="none"
927                    hx-confirm="This will clear all the changes in your proposal. Are you sure?"
928                { "Reset" }
929                button type="button" class="btn btn-success btn-min-width"
930                    hx-post=(META_SUBMIT_ROUTE)
931                    hx-swap="none"
932                    title="Submit new meta document for approval of other peers"
933                { "Submit" }
934            }
935        }
936    }
937}
938
939/// Wrapper over `T` to make it a json request response
940#[derive(FromRequest)]
941#[from_request(via(axum::Json), rejection(RequestError))]
942struct AppJson<T>(pub T);
943
944impl<T> IntoResponse for AppJson<T>
945where
946    axum::Json<T>: IntoResponse,
947{
948    fn into_response(self) -> Response {
949        axum::Json(self.0).into_response()
950    }
951}
952
953/// Whatever can go wrong with a request
954#[derive(Debug, Error)]
955pub enum RequestError {
956    #[error("Bad request: {source}")]
957    BadRequest { source: anyhow::Error },
958    #[error("Internal Error")]
959    InternalError,
960}
961
962pub type RequestResult<T> = std::result::Result<T, RequestError>;
963
964impl IntoResponse for RequestError {
965    fn into_response(self) -> Response {
966        debug!(target: LOG_UI, err=%self, "Request Error");
967
968        let (status_code, message) = match self {
969            Self::BadRequest { source } => {
970                (StatusCode::BAD_REQUEST, format!("Bad Request: {source}"))
971            }
972            _ => (
973                StatusCode::INTERNAL_SERVER_ERROR,
974                "Internal Service Error".to_owned(),
975            ),
976        };
977
978        (status_code, AppJson(UserErrorResponse { message })).into_response()
979    }
980}
981
982// How we want user errors responses to be serialized
983#[derive(Serialize)]
984pub struct UserErrorResponse {
985    pub message: String,
986}