Changeset

5856:75dee6127829 draft default tip

Merge upstream
author Trần H. Trung <xmpp:trần.h.trung@trung.fun>
date Tue, 06 Feb 2024 18:32:01 +0700
parents 5664:52db2da66680 (current diff) 5855:5afc8273c5ef (diff)
children
files
diffstat 86 files changed, 4800 insertions(+), 423 deletions(-) [+]
line wrap: on
line diff
--- a/.luacheckrc	Tue Aug 29 23:51:17 2023 +0700
+++ b/.luacheckrc	Tue Feb 06 18:32:01 2024 +0700
@@ -41,9 +41,12 @@
 	"module.get_option",
 	"module.get_option_array",
 	"module.get_option_boolean",
+	"module.get_option_enum",
 	"module.get_option_inherited_set",
+	"module.get_option_integer",
 	"module.get_option_number",
 	"module.get_option_path",
+	"module.get_option_period",
 	"module.get_option_scalar",
 	"module.get_option_set",
 	"module.get_option_string",
@@ -59,7 +62,7 @@
 	"module.may",
 	"module.measure",
 	"module.metric",
-	"module.once",
+	"module.on_ready",
 	"module.open_store",
 	"module.provides",
 	"module.remove_item",
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/misc/grafana/prosody-dashboard.json	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,1261 @@
+{
+   "description" : "",
+   "editable" : true,
+   "fiscalYearStartMonth" : 0,
+   "graphTooltip" : 1,
+   "id" : 1,
+   "links" : [],
+   "liveNow" : false,
+   "panels" : [
+      {
+         "collapsed" : false,
+         "gridPos" : {
+            "h" : 1,
+            "w" : 24,
+            "x" : 0,
+            "y" : 0
+         },
+         "id" : 26,
+         "panels" : [],
+         "title" : "Core",
+         "type" : "row"
+      },
+      {
+         "fieldConfig" : {
+            "defaults" : {
+               "color" : {
+                  "mode" : "continuous-GrYlRd",
+                  "seriesBy" : "last"
+               },
+               "custom" : {
+                  "axisBorderShow" : false,
+                  "axisCenteredZero" : false,
+                  "axisColorMode" : "text",
+                  "axisLabel" : "",
+                  "axisPlacement" : "right",
+                  "barAlignment" : 0,
+                  "drawStyle" : "line",
+                  "fillOpacity" : 10,
+                  "gradientMode" : "scheme",
+                  "hideFrom" : {
+                     "legend" : false,
+                     "tooltip" : false,
+                     "viz" : false
+                  },
+                  "insertNulls" : false,
+                  "lineInterpolation" : "smooth",
+                  "lineStyle" : {
+                     "fill" : "solid"
+                  },
+                  "lineWidth" : 1,
+                  "pointSize" : 5,
+                  "scaleDistribution" : {
+                     "type" : "linear"
+                  },
+                  "showPoints" : "auto",
+                  "spanNulls" : 300000,
+                  "stacking" : {
+                     "group" : "A",
+                     "mode" : "none"
+                  },
+                  "thresholdsStyle" : {
+                     "mode" : "off"
+                  }
+               },
+               "mappings" : [],
+               "min" : 0,
+               "thresholds" : {
+                  "mode" : "absolute",
+                  "steps" : [
+                     {
+                        "color" : "green",
+                        "value" : null
+                     },
+                     {
+                        "color" : "red",
+                        "value" : 80
+                     }
+                  ]
+               },
+               "unit" : "percentunit"
+            },
+            "overrides" : []
+         },
+         "gridPos" : {
+            "h" : 8,
+            "w" : 12,
+            "x" : 0,
+            "y" : 1
+         },
+         "id" : 6,
+         "options" : {
+            "legend" : {
+               "calcs" : [],
+               "displayMode" : "list",
+               "placement" : "bottom",
+               "showLegend" : true
+            },
+            "tooltip" : {
+               "mode" : "single",
+               "sort" : "none"
+            }
+         },
+         "pluginVersion" : "8.2.5",
+         "targets" : [
+            {
+               "exemplar" : true,
+               "expr" : "rate(process_cpu_seconds_total{job=\"prosody\"}[$__interval])",
+               "instant" : false,
+               "interval" : "",
+               "intervalFactor" : 2,
+               "legendFormat" : "usage",
+               "refId" : "A"
+            }
+         ],
+         "title" : "CPU",
+         "type" : "timeseries"
+      },
+      {
+         "fieldConfig" : {
+            "defaults" : {
+               "color" : {
+                  "mode" : "palette-classic"
+               },
+               "custom" : {
+                  "axisBorderShow" : false,
+                  "axisCenteredZero" : false,
+                  "axisColorMode" : "text",
+                  "axisLabel" : "",
+                  "axisPlacement" : "right",
+                  "barAlignment" : 0,
+                  "drawStyle" : "line",
+                  "fillOpacity" : 10,
+                  "gradientMode" : "none",
+                  "hideFrom" : {
+                     "legend" : false,
+                     "tooltip" : false,
+                     "viz" : false
+                  },
+                  "insertNulls" : false,
+                  "lineInterpolation" : "smooth",
+                  "lineStyle" : {
+                     "fill" : "solid"
+                  },
+                  "lineWidth" : 1,
+                  "pointSize" : 5,
+                  "scaleDistribution" : {
+                     "log" : 2,
+                     "type" : "log"
+                  },
+                  "showPoints" : "auto",
+                  "spanNulls" : 300000,
+                  "stacking" : {
+                     "group" : "A",
+                     "mode" : "none"
+                  },
+                  "thresholdsStyle" : {
+                     "mode" : "off"
+                  }
+               },
+               "mappings" : [],
+               "thresholds" : {
+                  "mode" : "percentage",
+                  "steps" : [
+                     {
+                        "color" : "green",
+                        "value" : null
+                     }
+                  ]
+               },
+               "unit" : "bytes"
+            },
+            "overrides" : [
+               {
+                  "__systemRef" : "hideSeriesFrom",
+                  "matcher" : {
+                     "id" : "byNames",
+                     "options" : {
+                        "mode" : "exclude",
+                        "names" : [
+                           "RSS",
+                           "Used",
+                           "Lua"
+                        ],
+                        "prefix" : "All except:",
+                        "readOnly" : true
+                     }
+                  },
+                  "properties" : [
+                     {
+                        "id" : "custom.hideFrom",
+                        "value" : {
+                           "legend" : false,
+                           "tooltip" : false,
+                           "viz" : true
+                        }
+                     }
+                  ]
+               }
+            ]
+         },
+         "gridPos" : {
+            "h" : 8,
+            "w" : 12,
+            "x" : 12,
+            "y" : 1
+         },
+         "id" : 4,
+         "options" : {
+            "legend" : {
+               "calcs" : [],
+               "displayMode" : "list",
+               "placement" : "bottom",
+               "showLegend" : true
+            },
+            "tooltip" : {
+               "mode" : "single",
+               "sort" : "none"
+            }
+         },
+         "pluginVersion" : "8.2.5",
+         "targets" : [
+            {
+               "exemplar" : true,
+               "expr" : "max_over_time(process_virtual_memory_bytes{job=\"prosody\"}[$__interval])",
+               "fullMetaSearch" : false,
+               "hide" : false,
+               "interval" : "",
+               "intervalFactor" : 1,
+               "legendFormat" : "Virtual",
+               "refId" : "VIRT"
+            },
+            {
+               "exemplar" : false,
+               "expr" : "max_over_time(process_resident_memory_bytes{job=\"prosody\"}[$__interval])",
+               "interval" : "",
+               "legendFormat" : "RSS",
+               "refId" : "RSS"
+            },
+            {
+               "exemplar" : false,
+               "expr" : "max_over_time(malloc_heap_allocated_bytes{job=\"prosody\"}[$__interval])",
+               "hide" : false,
+               "interval" : "",
+               "legendFormat" : "Allocated ({{mode}})",
+               "refId" : "Malloc allocated"
+            },
+            {
+               "exemplar" : false,
+               "expr" : "max_over_time(malloc_heap_used_bytes{job=\"prosody\"}[$__interval])",
+               "hide" : false,
+               "interval" : "",
+               "legendFormat" : "Used",
+               "refId" : "Malloc Used"
+            },
+            {
+               "exemplar" : false,
+               "expr" : "max_over_time(lua_heap_bytes{job=\"prosody\"}[$__interval])",
+               "hide" : false,
+               "interval" : "",
+               "legendFormat" : "Lua",
+               "refId" : "Lua"
+            },
+            {
+               "exemplar" : false,
+               "expr" : "sum(lua_heap_bytes{job=\"prosody\"}) / (sum(prosody_mod_c2s__connections{job=\"prosody\"})+sum(prosody_mod_s2s__connections_inbound{job=\"prosody\"})+sum(prosody_mod_s2s__connections_outbound{job=\"prosody\"}))",
+               "hide" : false,
+               "interval" : "",
+               "legendFormat" : "Lua (per connection)",
+               "refId" : "LuaPerConn"
+            }
+         ],
+         "title" : "Memory",
+         "type" : "timeseries"
+      },
+      {
+         "collapsed" : false,
+         "gridPos" : {
+            "h" : 1,
+            "w" : 24,
+            "x" : 0,
+            "y" : 9
+         },
+         "id" : 31,
+         "panels" : [],
+         "title" : "Connections",
+         "type" : "row"
+      },
+      {
+         "fieldConfig" : {
+            "defaults" : {
+               "color" : {
+                  "mode" : "palette-classic"
+               },
+               "custom" : {
+                  "axisBorderShow" : false,
+                  "axisCenteredZero" : false,
+                  "axisColorMode" : "text",
+                  "axisLabel" : "",
+                  "axisPlacement" : "right",
+                  "barAlignment" : 0,
+                  "drawStyle" : "line",
+                  "fillOpacity" : 100,
+                  "gradientMode" : "none",
+                  "hideFrom" : {
+                     "legend" : false,
+                     "tooltip" : false,
+                     "viz" : false
+                  },
+                  "insertNulls" : false,
+                  "lineInterpolation" : "stepAfter",
+                  "lineWidth" : 0,
+                  "pointSize" : 5,
+                  "scaleDistribution" : {
+                     "type" : "linear"
+                  },
+                  "showPoints" : "auto",
+                  "spanNulls" : false,
+                  "stacking" : {
+                     "group" : "A",
+                     "mode" : "normal"
+                  },
+                  "thresholdsStyle" : {
+                     "mode" : "off"
+                  }
+               },
+               "mappings" : [],
+               "min" : 0,
+               "thresholds" : {
+                  "mode" : "absolute",
+                  "steps" : [
+                     {
+                        "color" : "green",
+                        "value" : null
+                     },
+                     {
+                        "color" : "red",
+                        "value" : 80
+                     }
+                  ]
+               },
+               "unit" : "none"
+            },
+            "overrides" : []
+         },
+         "gridPos" : {
+            "h" : 8,
+            "w" : 12,
+            "x" : 0,
+            "y" : 10
+         },
+         "id" : 13,
+         "options" : {
+            "legend" : {
+               "calcs" : [],
+               "displayMode" : "list",
+               "placement" : "bottom",
+               "showLegend" : true
+            },
+            "tooltip" : {
+               "mode" : "single",
+               "sort" : "none"
+            }
+         },
+         "targets" : [
+            {
+               "exemplar" : true,
+               "expr" : "prosody_mod_c2s__connections{type=\"c2s\"}",
+               "interval" : "",
+               "legendFormat" : "{{ip_family}} {{type}}",
+               "refId" : "c2s"
+            }
+         ],
+         "title" : "Client-to-Server Connections",
+         "type" : "timeseries"
+      },
+      {
+         "fieldConfig" : {
+            "defaults" : {
+               "color" : {
+                  "mode" : "palette-classic"
+               },
+               "custom" : {
+                  "axisBorderShow" : false,
+                  "axisCenteredZero" : false,
+                  "axisColorMode" : "text",
+                  "axisLabel" : "",
+                  "axisPlacement" : "right",
+                  "barAlignment" : 0,
+                  "drawStyle" : "line",
+                  "fillOpacity" : 100,
+                  "gradientMode" : "none",
+                  "hideFrom" : {
+                     "legend" : false,
+                     "tooltip" : false,
+                     "viz" : false
+                  },
+                  "insertNulls" : false,
+                  "lineInterpolation" : "stepAfter",
+                  "lineWidth" : 0,
+                  "pointSize" : 5,
+                  "scaleDistribution" : {
+                     "type" : "linear"
+                  },
+                  "showPoints" : "auto",
+                  "spanNulls" : false,
+                  "stacking" : {
+                     "group" : "A",
+                     "mode" : "normal"
+                  },
+                  "thresholdsStyle" : {
+                     "mode" : "off"
+                  }
+               },
+               "mappings" : [],
+               "min" : 0,
+               "thresholds" : {
+                  "mode" : "absolute",
+                  "steps" : [
+                     {
+                        "color" : "green",
+                        "value" : null
+                     },
+                     {
+                        "color" : "red",
+                        "value" : 80
+                     }
+                  ]
+               },
+               "unit" : "none"
+            },
+            "overrides" : []
+         },
+         "gridPos" : {
+            "h" : 8,
+            "w" : 12,
+            "x" : 12,
+            "y" : 10
+         },
+         "id" : 12,
+         "options" : {
+            "legend" : {
+               "calcs" : [],
+               "displayMode" : "list",
+               "placement" : "bottom",
+               "showLegend" : true
+            },
+            "tooltip" : {
+               "mode" : "single",
+               "sort" : "none"
+            }
+         },
+         "targets" : [
+            {
+               "exemplar" : true,
+               "expr" : "sum(prosody_mod_s2s__connections_inbound{type=\"s2sin\"}) by (ip_family)",
+               "interval" : "",
+               "legendFormat" : "{{ip_family}} s2sin",
+               "refId" : "s2sin"
+            },
+            {
+               "exemplar" : true,
+               "expr" : "sum(prosody_mod_s2s__connections_outbound{type=\"s2sout\"}) by (ip_family)",
+               "hide" : false,
+               "interval" : "",
+               "legendFormat" : "{{ip_family}} s2sout",
+               "refId" : "s2sout"
+            }
+         ],
+         "title" : "Server-to-Server Connections",
+         "type" : "timeseries"
+      },
+      {
+         "fieldConfig" : {
+            "defaults" : {
+               "custom" : {
+                  "hideFrom" : {
+                     "legend" : false,
+                     "tooltip" : false,
+                     "viz" : false
+                  },
+                  "scaleDistribution" : {
+                     "type" : "linear"
+                  }
+               }
+            },
+            "overrides" : []
+         },
+         "gridPos" : {
+            "h" : 8,
+            "w" : 12,
+            "x" : 0,
+            "y" : 18
+         },
+         "id" : 29,
+         "options" : {
+            "calculate" : false,
+            "cellGap" : 1,
+            "color" : {
+               "exponent" : 0.5,
+               "fill" : "dark-orange",
+               "mode" : "scheme",
+               "reverse" : false,
+               "scale" : "exponential",
+               "scheme" : "Spectral",
+               "steps" : 64
+            },
+            "exemplars" : {
+               "color" : "rgba(255,0,255,0.7)"
+            },
+            "filterValues" : {
+               "le" : 1e-09
+            },
+            "legend" : {
+               "show" : true
+            },
+            "rowsFrame" : {
+               "layout" : "auto"
+            },
+            "tooltip" : {
+               "show" : true,
+               "yHistogram" : false
+            },
+            "yAxis" : {
+               "axisPlacement" : "left",
+               "reverse" : false
+            }
+         },
+         "pluginVersion" : "10.2.2",
+         "targets" : [
+            {
+               "disableTextWrap" : false,
+               "editorMode" : "builder",
+               "exemplar" : false,
+               "expr" : "changes(prosody_mod_c2s__encrypted_total[$__interval])",
+               "fullMetaSearch" : false,
+               "includeNullMetadata" : true,
+               "interval" : "10m",
+               "legendFormat" : "{{protocol}} {{cipher}}",
+               "range" : true,
+               "refId" : "c2s",
+               "useBackend" : false
+            }
+         ],
+         "title" : "Client-to-Server Connections Encrypted",
+         "type" : "heatmap"
+      },
+      {
+         "fieldConfig" : {
+            "defaults" : {
+               "custom" : {
+                  "hideFrom" : {
+                     "legend" : false,
+                     "tooltip" : false,
+                     "viz" : false
+                  },
+                  "scaleDistribution" : {
+                     "type" : "linear"
+                  }
+               }
+            },
+            "overrides" : []
+         },
+         "gridPos" : {
+            "h" : 8,
+            "w" : 12,
+            "x" : 12,
+            "y" : 18
+         },
+         "id" : 30,
+         "options" : {
+            "calculate" : false,
+            "cellGap" : 1,
+            "color" : {
+               "exponent" : 0.5,
+               "fill" : "dark-orange",
+               "mode" : "scheme",
+               "reverse" : false,
+               "scale" : "exponential",
+               "scheme" : "Spectral",
+               "steps" : 64
+            },
+            "exemplars" : {
+               "color" : "rgba(255,0,255,0.7)"
+            },
+            "filterValues" : {
+               "le" : 1e-09
+            },
+            "legend" : {
+               "show" : true
+            },
+            "rowsFrame" : {
+               "layout" : "auto"
+            },
+            "tooltip" : {
+               "show" : true,
+               "yHistogram" : false
+            },
+            "yAxis" : {
+               "axisPlacement" : "left",
+               "reverse" : false
+            }
+         },
+         "pluginVersion" : "10.2.2",
+         "targets" : [
+            {
+               "disableTextWrap" : false,
+               "editorMode" : "builder",
+               "exemplar" : false,
+               "expr" : "changes(prosody_mod_s2s__encrypted_total[$__interval])",
+               "format" : "time_series",
+               "fullMetaSearch" : false,
+               "includeNullMetadata" : true,
+               "instant" : false,
+               "interval" : "10m",
+               "legendFormat" : "{{protocol}} {{cipher}}",
+               "range" : true,
+               "refId" : "s2sin",
+               "useBackend" : false
+            }
+         ],
+         "title" : "Server-to-Server Connections Encrypted",
+         "type" : "heatmap"
+      },
+      {
+         "collapsed" : false,
+         "gridPos" : {
+            "h" : 1,
+            "w" : 24,
+            "x" : 0,
+            "y" : 26
+         },
+         "id" : 24,
+         "panels" : [],
+         "title" : "Stream Management",
+         "type" : "row"
+      },
+      {
+         "cards" : {},
+         "color" : {
+            "cardColor" : "#b4ff00",
+            "colorScale" : "sqrt",
+            "colorScheme" : "interpolateRdYlGn",
+            "exponent" : 0.5,
+            "mode" : "spectrum"
+         },
+         "dataFormat" : "tsbuckets",
+         "description" : "How long a session has been hibernating when a client resumes it",
+         "fieldConfig" : {
+            "defaults" : {
+               "custom" : {
+                  "hideFrom" : {
+                     "legend" : false,
+                     "tooltip" : false,
+                     "viz" : false
+                  },
+                  "scaleDistribution" : {
+                     "type" : "linear"
+                  }
+               }
+            },
+            "overrides" : []
+         },
+         "gridPos" : {
+            "h" : 8,
+            "w" : 12,
+            "x" : 0,
+            "y" : 27
+         },
+         "heatmap" : {},
+         "hideZeroBuckets" : true,
+         "highlightCards" : true,
+         "id" : 14,
+         "legend" : {
+            "show" : true
+         },
+         "options" : {
+            "calculate" : false,
+            "calculation" : {},
+            "cellGap" : 2,
+            "cellValues" : {},
+            "color" : {
+               "exponent" : 0.5,
+               "fill" : "#b4ff00",
+               "mode" : "scheme",
+               "reverse" : false,
+               "scale" : "exponential",
+               "scheme" : "RdYlGn",
+               "steps" : 128
+            },
+            "exemplars" : {
+               "color" : "rgba(255,0,255,0.7)"
+            },
+            "filterValues" : {
+               "le" : 1e-09
+            },
+            "legend" : {
+               "show" : true
+            },
+            "rowsFrame" : {
+               "layout" : "ge"
+            },
+            "showValue" : "never",
+            "tooltip" : {
+               "show" : true,
+               "yHistogram" : false
+            },
+            "yAxis" : {
+               "axisPlacement" : "left",
+               "decimals" : 0,
+               "reverse" : false,
+               "unit" : "clocks"
+            }
+         },
+         "pluginVersion" : "10.2.2",
+         "reverseYBuckets" : false,
+         "targets" : [
+            {
+               "disableTextWrap" : false,
+               "editorMode" : "builder",
+               "exemplar" : true,
+               "expr" : "sum by(le) (changes(prosody_mod_smacks__resumption_age_seconds_bucket{host=\"$virtualhost\"}[$__interval]))",
+               "format" : "heatmap",
+               "fullMetaSearch" : false,
+               "includeNullMetadata" : true,
+               "interval" : "600",
+               "legendFormat" : "{{le}}",
+               "range" : true,
+               "refId" : "A",
+               "useBackend" : false
+            }
+         ],
+         "title" : "Resumption Age",
+         "tooltip" : {
+            "show" : true,
+            "showHistogram" : false
+         },
+         "type" : "heatmap",
+         "xAxis" : {
+            "show" : true
+         },
+         "yAxis" : {
+            "format" : "clocks",
+            "logBase" : 1,
+            "show" : true
+         },
+         "yBucketBound" : "upper"
+      },
+      {
+         "fieldConfig" : {
+            "defaults" : {
+               "color" : {
+                  "mode" : "palette-classic"
+               },
+               "custom" : {
+                  "axisBorderShow" : false,
+                  "axisCenteredZero" : false,
+                  "axisColorMode" : "text",
+                  "axisLabel" : "",
+                  "axisPlacement" : "auto",
+                  "barAlignment" : 0,
+                  "drawStyle" : "line",
+                  "fillOpacity" : 0,
+                  "gradientMode" : "none",
+                  "hideFrom" : {
+                     "legend" : false,
+                     "tooltip" : false,
+                     "viz" : false
+                  },
+                  "insertNulls" : false,
+                  "lineInterpolation" : "linear",
+                  "lineWidth" : 1,
+                  "pointSize" : 5,
+                  "scaleDistribution" : {
+                     "type" : "linear"
+                  },
+                  "showPoints" : "auto",
+                  "spanNulls" : false,
+                  "stacking" : {
+                     "group" : "A",
+                     "mode" : "none"
+                  },
+                  "thresholdsStyle" : {
+                     "mode" : "off"
+                  }
+               },
+               "mappings" : [],
+               "thresholds" : {
+                  "mode" : "absolute",
+                  "steps" : [
+                     {
+                        "color" : "green",
+                        "value" : null
+                     },
+                     {
+                        "color" : "red",
+                        "value" : 80
+                     }
+                  ]
+               },
+               "unit" : "pps"
+            },
+            "overrides" : []
+         },
+         "gridPos" : {
+            "h" : 8,
+            "w" : 12,
+            "x" : 12,
+            "y" : 27
+         },
+         "id" : 16,
+         "options" : {
+            "legend" : {
+               "calcs" : [],
+               "displayMode" : "list",
+               "placement" : "bottom",
+               "showLegend" : true
+            },
+            "tooltip" : {
+               "mode" : "single",
+               "sort" : "none"
+            }
+         },
+         "targets" : [
+            {
+               "editorMode" : "code",
+               "exemplar" : true,
+               "expr" : "rate(prosody_mod_smacks__tx_queued_stanzas_total{host=\"$virtualhost\"}[$__interval])",
+               "interval" : "600",
+               "legendFormat" : "queued on {{host}}",
+               "range" : true,
+               "refId" : "A"
+            },
+            {
+               "editorMode" : "builder",
+               "exemplar" : true,
+               "expr" : "rate(prosody_mod_smacks__tx_acked_stanzas_count{host=\"$virtualhost\"}[$__interval])",
+               "hide" : false,
+               "interval" : "600",
+               "legendFormat" : "acked on {{host}}",
+               "range" : true,
+               "refId" : "B"
+            }
+         ],
+         "title" : "Stanzas",
+         "type" : "timeseries"
+      },
+      {
+         "collapsed" : false,
+         "gridPos" : {
+            "h" : 1,
+            "w" : 24,
+            "x" : 0,
+            "y" : 35
+         },
+         "id" : 28,
+         "panels" : [],
+         "title" : "Mobile optimizations",
+         "type" : "row"
+      },
+      {
+         "fieldConfig" : {
+            "defaults" : {
+               "color" : {
+                  "mode" : "palette-classic"
+               },
+               "custom" : {
+                  "axisBorderShow" : false,
+                  "axisCenteredZero" : false,
+                  "axisColorMode" : "text",
+                  "axisLabel" : "",
+                  "axisPlacement" : "auto",
+                  "barAlignment" : 0,
+                  "drawStyle" : "points",
+                  "fillOpacity" : 0,
+                  "gradientMode" : "none",
+                  "hideFrom" : {
+                     "legend" : false,
+                     "tooltip" : false,
+                     "viz" : false
+                  },
+                  "insertNulls" : false,
+                  "lineInterpolation" : "linear",
+                  "lineWidth" : 1,
+                  "pointSize" : 5,
+                  "scaleDistribution" : {
+                     "type" : "linear"
+                  },
+                  "showPoints" : "auto",
+                  "spanNulls" : false,
+                  "stacking" : {
+                     "group" : "A",
+                     "mode" : "none"
+                  },
+                  "thresholdsStyle" : {
+                     "mode" : "off"
+                  }
+               },
+               "mappings" : [],
+               "thresholds" : {
+                  "mode" : "absolute",
+                  "steps" : [
+                     {
+                        "color" : "green"
+                     },
+                     {
+                        "color" : "red",
+                        "value" : 80
+                     }
+                  ]
+               },
+               "unit" : "s"
+            },
+            "overrides" : []
+         },
+         "gridPos" : {
+            "h" : 8,
+            "w" : 12,
+            "x" : 0,
+            "y" : 36
+         },
+         "id" : 18,
+         "options" : {
+            "legend" : {
+               "calcs" : [],
+               "displayMode" : "list",
+               "placement" : "bottom",
+               "showLegend" : true
+            },
+            "tooltip" : {
+               "mode" : "single",
+               "sort" : "none"
+            }
+         },
+         "targets" : [
+            {
+               "editorMode" : "builder",
+               "exemplar" : false,
+               "expr" : "histogram_quantile(0.95, sum by(le) (rate(prosody_mod_csi_simple__buffer_hold_seconds_bucket{host=\"$virtualhost\"}[$__rate_interval])))",
+               "format" : "time_series",
+               "instant" : false,
+               "interval" : "",
+               "legendFormat" : "CSI hold seconds",
+               "range" : true,
+               "refId" : "A"
+            }
+         ],
+         "title" : "Hold time",
+         "type" : "timeseries"
+      },
+      {
+         "fieldConfig" : {
+            "defaults" : {
+               "color" : {
+                  "mode" : "palette-classic"
+               },
+               "custom" : {
+                  "axisBorderShow" : false,
+                  "axisCenteredZero" : false,
+                  "axisColorMode" : "text",
+                  "axisLabel" : "",
+                  "axisPlacement" : "auto",
+                  "barAlignment" : 0,
+                  "drawStyle" : "points",
+                  "fillOpacity" : 0,
+                  "gradientMode" : "none",
+                  "hideFrom" : {
+                     "legend" : false,
+                     "tooltip" : false,
+                     "viz" : false
+                  },
+                  "insertNulls" : false,
+                  "lineInterpolation" : "linear",
+                  "lineWidth" : 1,
+                  "pointSize" : 5,
+                  "scaleDistribution" : {
+                     "type" : "linear"
+                  },
+                  "showPoints" : "auto",
+                  "spanNulls" : false,
+                  "stacking" : {
+                     "group" : "A",
+                     "mode" : "none"
+                  },
+                  "thresholdsStyle" : {
+                     "mode" : "off"
+                  }
+               },
+               "mappings" : [],
+               "thresholds" : {
+                  "mode" : "absolute",
+                  "steps" : [
+                     {
+                        "color" : "green"
+                     },
+                     {
+                        "color" : "red",
+                        "value" : 80
+                     }
+                  ]
+               },
+               "unit" : "none"
+            },
+            "overrides" : []
+         },
+         "gridPos" : {
+            "h" : 8,
+            "w" : 12,
+            "x" : 12,
+            "y" : 36
+         },
+         "id" : 20,
+         "options" : {
+            "legend" : {
+               "calcs" : [],
+               "displayMode" : "list",
+               "placement" : "bottom",
+               "showLegend" : true
+            },
+            "tooltip" : {
+               "mode" : "single",
+               "sort" : "none"
+            }
+         },
+         "targets" : [
+            {
+               "editorMode" : "builder",
+               "expr" : "histogram_quantile(0.95, sum by(le) (rate(prosody_mod_csi_simple__flush_stanza_count_bucket{host=\"$virtualhost\"}[$__rate_interval])))",
+               "format" : "time_series",
+               "legendFormat" : "Stanzas flushed",
+               "range" : true,
+               "refId" : "A"
+            }
+         ],
+         "title" : "Flush sizes",
+         "type" : "timeseries"
+      },
+      {
+         "collapsed" : false,
+         "gridPos" : {
+            "h" : 1,
+            "w" : 24,
+            "x" : 0,
+            "y" : 44
+         },
+         "id" : 22,
+         "panels" : [],
+         "title" : "HTTP Upload",
+         "type" : "row"
+      },
+      {
+         "cards" : {},
+         "color" : {
+            "cardColor" : "#b4ff00",
+            "colorScale" : "sqrt",
+            "colorScheme" : "interpolateRdYlGn",
+            "exponent" : 0.5,
+            "max" : 5,
+            "min" : 0,
+            "mode" : "opacity"
+         },
+         "dataFormat" : "tsbuckets",
+         "fieldConfig" : {
+            "defaults" : {
+               "custom" : {
+                  "hideFrom" : {
+                     "legend" : false,
+                     "tooltip" : false,
+                     "viz" : false
+                  },
+                  "scaleDistribution" : {
+                     "type" : "linear"
+                  }
+               }
+            },
+            "overrides" : []
+         },
+         "gridPos" : {
+            "h" : 8,
+            "w" : 18,
+            "x" : 0,
+            "y" : 45
+         },
+         "heatmap" : {},
+         "hideZeroBuckets" : false,
+         "highlightCards" : true,
+         "id" : 8,
+         "legend" : {
+            "show" : false
+         },
+         "options" : {
+            "calculate" : false,
+            "calculation" : {},
+            "cellGap" : 2,
+            "cellValues" : {},
+            "color" : {
+               "exponent" : 0.5,
+               "fill" : "#b4ff00",
+               "max" : 5,
+               "min" : 0,
+               "mode" : "opacity",
+               "reverse" : false,
+               "scale" : "exponential",
+               "scheme" : "Oranges",
+               "steps" : 128
+            },
+            "exemplars" : {
+               "color" : "rgba(255,0,255,0.7)"
+            },
+            "filterValues" : {
+               "le" : 1e-09
+            },
+            "legend" : {
+               "show" : false
+            },
+            "rowsFrame" : {
+               "layout" : "ge"
+            },
+            "showValue" : "never",
+            "tooltip" : {
+               "show" : true,
+               "yHistogram" : false
+            },
+            "yAxis" : {
+               "axisPlacement" : "left",
+               "reverse" : false,
+               "unit" : "bytes"
+            }
+         },
+         "pluginVersion" : "10.2.0",
+         "reverseYBuckets" : false,
+         "targets" : [
+            {
+               "disableTextWrap" : false,
+               "editorMode" : "builder",
+               "exemplar" : true,
+               "expr" : "sum by(le) (changes(prosody_mod_http_file_share__upload_bytes_bucket{host=\"$virtualhost\"}[$__interval]))",
+               "format" : "heatmap",
+               "fullMetaSearch" : false,
+               "includeNullMetadata" : true,
+               "interval" : "3600s",
+               "intervalFactor" : 1,
+               "legendFormat" : "{{le}}",
+               "range" : true,
+               "refId" : "A",
+               "useBackend" : false
+            }
+         ],
+         "title" : "Upload sizes",
+         "tooltip" : {
+            "show" : true,
+            "showHistogram" : false
+         },
+         "type" : "heatmap",
+         "xAxis" : {
+            "show" : true
+         },
+         "yAxis" : {
+            "format" : "bytes",
+            "logBase" : 1,
+            "show" : true
+         },
+         "yBucketBound" : "upper"
+      },
+      {
+         "fieldConfig" : {
+            "defaults" : {
+               "color" : {
+                  "mode" : "continuous-GrYlRd"
+               },
+               "mappings" : [],
+               "max" : 10737418240,
+               "min" : 0,
+               "thresholds" : {
+                  "mode" : "absolute",
+                  "steps" : [
+                     {
+                        "color" : "green"
+                     }
+                  ]
+               },
+               "unit" : "bytes"
+            },
+            "overrides" : []
+         },
+         "gridPos" : {
+            "h" : 8,
+            "w" : 6,
+            "x" : 18,
+            "y" : 45
+         },
+         "id" : 10,
+         "options" : {
+            "minVizHeight" : 75,
+            "minVizWidth" : 75,
+            "orientation" : "auto",
+            "reduceOptions" : {
+               "calcs" : [
+                  "lastNotNull"
+               ],
+               "fields" : "",
+               "values" : false
+            },
+            "showThresholdLabels" : false,
+            "showThresholdMarkers" : false,
+            "text" : {}
+         },
+         "pluginVersion" : "10.2.0",
+         "targets" : [
+            {
+               "editorMode" : "builder",
+               "exemplar" : true,
+               "expr" : "prosody_mod_http_file_share__total_storage_bytes{host=\"$virtualhost\"}",
+               "instant" : false,
+               "interval" : "",
+               "legendFormat" : "",
+               "refId" : "A"
+            }
+         ],
+         "title" : "Total uploads",
+         "type" : "gauge"
+      }
+   ],
+   "refresh" : "5m",
+   "schemaVersion" : 38,
+   "tags" : [
+      "prosody",
+      "xmpp"
+   ],
+   "templating" : {
+      "list" : [
+         {
+            "definition" : "label_values(host)",
+            "hide" : 0,
+            "includeAll" : false,
+            "label" : "VirtualHost",
+            "multi" : false,
+            "name" : "virtualhost",
+            "options" : [],
+            "query" : {
+               "query" : "label_values(host)",
+               "refId" : "PrometheusVariableQueryEditor-VariableQuery"
+            },
+            "refresh" : 1,
+            "regex" : "",
+            "skipUrlSync" : false,
+            "sort" : 0,
+            "type" : "query"
+         }
+      ]
+   },
+   "time" : {
+      "from" : "now-2d",
+      "to" : "now"
+   },
+   "timepicker" : {},
+   "timezone" : "",
+   "title" : "Prosody",
+   "uid" : "y1Onovt7z",
+   "version" : 91,
+   "weekStart" : ""
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/misc/grafterm/dashboard.json	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,121 @@
+{
+  "version": "v1",
+  "datasources": {
+    "prometheus": {
+      "prometheus": {
+        "address": "http://127.0.0.1:9090"
+      }
+    }
+  },
+  "dashboard": {
+    "variables": {
+      "job": {
+        "constant": {
+          "value": "prosody"
+        }
+      }
+    },
+    "widgets": [
+      {
+        "title": "CPU",
+        "gridPos": {
+          "w": 50
+        },
+        "graph": {
+          "visualization": {
+            "yAxis": {
+              "unit": "percent",
+              "decimals": 2
+            }
+          },
+          "queries": [
+            {
+              "datasourceID": "prometheus",
+              "expr": "rate(process_cpu_seconds_total{job=\"{{.job}}\"}[5m])",
+              "legend": "CPU"
+            }
+          ]
+        }
+      },
+      {
+        "title": "Memory",
+        "gridPos": {
+          "w": 50
+        },
+        "graph": {
+          "visualization": {
+            "yAxis": {
+              "unit": "bytes"
+            }
+          },
+          "queries": [
+            {
+              "datasourceID": "prometheus",
+              "expr": "max_over_time(process_resident_memory_bytes{job=\"{{.job}}\"}[5m])",
+              "legend": "RSS"
+            },
+            {
+              "datasourceID": "prometheus",
+              "expr": "max_over_time(malloc_heap_used_bytes{job=\"{{.job}}\"}[5m])",
+              "legend": "Malloc"
+            },
+            {
+              "datasourceID": "prometheus",
+              "expr": "max_over_time(lua_heap_bytes{job=\"{{.job}}\"}[5m])",
+              "legend": "Lua"
+            }
+          ]
+        }
+      },
+      {
+        "title": "C2S",
+        "gridPos": {
+          "x": 50,
+          "w": 50
+        },
+        "graph": {
+          "visualization": {
+            "yAxis": {
+              "unit": "none",
+              "decimals": 0
+            }
+          },
+          "queries": [
+            {
+              "datasourceID": "prometheus",
+              "expr": "sum(prosody_mod_c2s__connections{type=\"c2s\",job=\"{{.job}}\"})",
+              "legend": "c2s"
+            }
+          ]
+        }
+      },
+      {
+        "title": "S2S",
+        "gridPos": {
+          "x": 50,
+          "w": 50
+        },
+        "graph": {
+          "visualization": {
+            "yAxis": {
+              "unit": "none",
+              "decimals": 0
+            }
+          },
+          "queries": [
+            {
+              "datasourceID": "prometheus",
+              "expr": "sum(prosody_mod_s2s__connections_inbound{type=\"s2sin\",job=\"{{.job}}\"})",
+              "legend": "s2sin"
+            },
+            {
+              "datasourceID": "prometheus",
+              "expr": "sum(prosody_mod_s2s__connections_outbound{type=\"s2sout\",job=\"{{.job}}\"})",
+              "legend": "s2sout"
+            }
+          ]
+        }
+      }
+    ]
+  }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/misc/mtail/prosody.mtail	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,13 @@
+counter prosody_log_messages by level
+
+/^(?P<date>(?P<legacy_date>\w+\s+\d+\s+\d+:\d+:\d+)|(?P<rfc3339_date>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+[+-]\d{2}:\d{2})) (?P<sink>\S+)\s(?P<loglevel>\w+)\s(?P<message>.*)/ {
+	len($legacy_date) > 0 {
+		strptime($2, "Jan _2 15:04:05")
+	}
+	len($rfc3339_date) > 0 {
+		strptime($rfc3339_date, "2006-01-02T03:04:05-0700")
+	}
+	$loglevel != "" {
+		prosody_log_messages[$loglevel]++
+	}
+}
--- a/mod_audit/README.md	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_audit/README.md	Tue Feb 06 18:32:01 2024 +0700
@@ -12,7 +12,7 @@
 
 This module, however, only provides the infrastructure for audit logging. It
 does not, by itself, generate such logs. For that, other modules, such as
-`mod_audit_auth` or `mod_audit_register` need to be loaded.
+`mod_audit_auth` or `mod_audit_user_accounts` need to be loaded.
 
 ## A note on privacy
 
--- a/mod_audit/mod_audit.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_audit/mod_audit.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -1,19 +1,13 @@
 module:set_global();
 
 local time_now = os.time;
-local parse_duration = require "util.human.io".parse_duration;
 local ip = require "util.ip";
 local st = require "util.stanza";
 local moduleapi = require "core.moduleapi";
 
 local host_wide_user = "@";
 
-local cleanup_after = module:get_option_string("audit_log_expires_after", "28d");
-if cleanup_after == "never" then
-	cleanup_after = nil;
-else
-	cleanup_after = parse_duration(cleanup_after);
-end
+local cleanup_after = module:get_option_period("audit_log_expires_after", "28d");
 
 local attach_ips = module:get_option_boolean("audit_log_ips", true);
 local attach_ipv4_prefix = module:get_option_number("audit_log_ipv4_prefix", nil);
@@ -61,13 +55,12 @@
 end
 
 local function get_ip_network(ip_addr)
-	local _ip = ip.new_ip(ip_addr);
-	local proto = _ip.proto;
+	local proto = ip_addr.proto;
 	local network;
 	if proto == "IPv4" and attach_ipv4_prefix then
-		network = ip.truncate(_ip, attach_ipv4_prefix).normal.."/"..attach_ipv4_prefix;
+		network = ip.truncate(ip_addr, attach_ipv4_prefix).normal.."/"..attach_ipv4_prefix;
 	elseif proto == "IPv6" and attach_ipv6_prefix then
-		network = ip.truncate(_ip, attach_ipv6_prefix).normal.."/"..attach_ipv6_prefix;
+		network = ip.truncate(ip_addr, attach_ipv6_prefix).normal.."/"..attach_ipv6_prefix;
 	end
 	return network;
 end
@@ -83,18 +76,19 @@
 		attr.type = session.type;
 	end
 	local stanza = st.stanza("session", attr);
-	if attach_ips and session.ip then
-		local remote_ip, network = session.ip;
+	local remote_ip = session.ip and ip.new_ip(session.ip);
+	if attach_ips and remote_ip then
+		local network;
 		if attach_ipv4_prefix or attach_ipv6_prefix then
 			network = get_ip_network(remote_ip);
 		end
-		stanza:text_tag("remote-ip", network or remote_ip);
+		stanza:text_tag("remote-ip", network or remote_ip.normal);
 	end
-	if attach_location and session.ip then
-		local remote_ip = ip.new(session.ip);
-		local geoip_country = ip.proto == "IPv6" and geoip6_country or geoip4_country;
-		stanza:tag("location", {
-			country = geoip_country:query_by_addr(remote_ip.normal);
+	if attach_location and remote_ip then
+		local geoip_info = remote_ip.proto == "IPv6" and geoip6_country:query_by_addr6(remote_ip.normal) or geoip4_country:query_by_addr(remote_ip.normal);
+		stanza:text_tag("location", geoip_info.name, {
+			country = geoip_info.code;
+			continent = geoip_info.continent;
 		}):up();
 	end
 	if session.client_id then
@@ -140,7 +134,7 @@
 		if err == "quota-limit" then
 			local limit = store.caps and store.caps.quota or 1000;
 			local truncate_to = math.floor(limit * 0.99);
-			if type(cleanup_after) == "number" then
+			if cleanup_after ~= math.huge then
 				module:log("debug", "Audit log has reached quota - forcing prune");
 				if prune_audit_log(host) then
 					-- Retry append
@@ -177,8 +171,9 @@
 		value_params = { "limit" };
 	 });
 
-	for k, v in pairs(arg) do print("U", k, v) end
-	local query_user, host = jid.prepped_split(arg[1]);
+	module:log("debug", "arg = %q", arg);
+	local query_jid = jid.prep(arg[1]);
+	local host = jid.host(query_jid);
 
 	if arg.prune then
 		local sm = require "core.storagemanager";
@@ -207,14 +202,16 @@
 	local c = 0;
 
 	if arg.global then
-		if query_user then
+		if jid.node(query_jid) then
 			print("WW: Specifying a user account is incompatible with --global. Showing only global events.");
 		end
-		query_user = "@";
+		query_jid = "@";
+	elseif host == query_jid then
+		query_jid = nil;
 	end
 
 	local results, err = store:find(nil, {
-		with = query_user;
+		with = query_jid;
 		limit = arg.limit and tonumber(arg.limit) or nil;
 		reverse = true;
 	})
@@ -224,12 +221,12 @@
 	end
 
 	local colspec = {
-		{ title = "Date", key = "when", width = 19, mapper = function (when) return os.date("%Y-%m-%d %R:%S", when); end };
+		{ title = "Date", key = "when", width = 19, mapper = function (when) return os.date("%Y-%m-%d %R:%S", math.floor(when)); end };
 		{ title = "Source", key = "source", width = "2p" };
 		{ title = "Event", key = "event_type", width = "2p" };
 	};
 
-	if arg.show_user ~= false and (not arg.global and not query_user) or arg.show_user then
+	if arg.show_user ~= false and (not arg.global and not query_jid) or arg.show_user then
 		table.insert(colspec, {
 			title = "User", key = "username", width = "2p",
 			mapper = function (user)
@@ -270,8 +267,8 @@
 				source = entry.attr.source;
 				event_type = entry.attr.type:gsub("%-", " ");
 				username = user;
-				ip = entry:get_child_text("remote-ip");
-				location = entry:find("location@country");
+				ip = entry:find("{xmpp:prosody.im/audit}session/remote-ip#");
+				country = entry:find("{xmpp:prosody.im/audit}session/location@country");
 				note = entry:get_child_text("note");
 			}));
 		end
--- a/mod_audit_auth/README.md	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_audit_auth/README.md	Tue Feb 06 18:32:01 2024 +0700
@@ -7,3 +7,7 @@
 
 This module stores authentication failures and authentication successes in the
 audit log provided by `mod_audit`.
+
+If mod_client_management is loaded, it will also record entries when a new
+client is connected to the user's account for the first time. For non-SASL2
+clients, this may have false positives.
--- a/mod_audit_auth/mod_audit_auth.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_audit_auth/mod_audit_auth.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -1,16 +1,54 @@
+local jid = require"util.jid";
+local st = require "util.stanza";
+
 module:depends("audit");
 -- luacheck: read globals module.audit
 
+local only_passwords = module:get_option_boolean("audit_auth_passwords_only", true);
+
 module:hook("authentication-failure", function(event)
 	local session = event.session;
-	module:audit(session.sasl_handler.username, "authentication-failure", {
+	module:audit(jid.join(session.sasl_handler.username, module.host), "authentication-failure", {
 		session = session,
 	});
 end)
 
 module:hook("authentication-success", function(event)
 	local session = event.session;
-	module:audit(session.sasl_handler.username, "authentication-success", {
+	if only_passwords and session.sasl_handler.fast then
+		return;
+	end
+	module:audit(jid.join(session.sasl_handler.username, module.host), "authentication-success", {
 		session = session,
 	});
 end)
+
+module:hook("client_management/new-client", function (event)
+	local session, client = event.session, event.client;
+
+	local client_info = st.stanza("client", { id = client.id });
+
+	if client.user_agent then
+		local user_agent = st.stanza("user-agent", { xmlns = "urn:xmpp:sasl:2" })
+		if client.user_agent.software then
+			user_agent:text_tag("software", client.user_agent.software, { id = client.user_agent.software_id; version = client.user_agent.software_version });
+		end
+		if client.user_agent.device then
+			user_agent:text_tag("device", client.user_agent.device);
+		end
+		if client.user_agent.uri then
+			user_agent:text_tag("uri", client.user_agent.uri);
+		end
+		client_info:add_child(user_agent);
+	end
+
+	if client.legacy then
+		client_info:text_tag("legacy");
+	end
+
+	module:audit(jid.join(session.username, module.host), "new-client", {
+		session = session;
+		custom = {
+		};
+	});
+end);
--- a/mod_audit_register/README.md	Tue Aug 29 23:51:17 2023 +0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,9 +0,0 @@
----
-summary: Store registration events in the audit log
-rockspec:
-  dependencies:
-  - mod_audit
-...
-
-This module stores successful user registrations in the audit log provided by
-`mod_audit`.
--- a/mod_audit_register/mod_audit_register.lua	Tue Aug 29 23:51:17 2023 +0700
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,23 +0,0 @@
-module:depends("audit");
--- luacheck: read globals module.audit
-
-local st = require "util.stanza";
-
-module:hook("user-registered", function(event)
-	local session = event.session;
-	local custom = {};
-	local invite = event.validated_invite or (event.session and event.session.validated_invite);
-	if invite then
-		table.insert(custom, st.stanza(
-			"invite-used",
-			{
-				xmlns = "xmpp:prosody.im/audit",
-				token = invite.token,
-			}
-		))
-	end
-	module:audit(event.username, "user-registered", {
-		session = session,
-		custom = custom,
-	});
-end);
--- a/mod_audit_status/mod_audit_status.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_audit_status/mod_audit_status.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -28,8 +28,13 @@
 end);
 
 if heartbeat_interval then
+	local async = require "util.async";
+	local heartbeat_writer = async.runner(function (timestamp)
+		store:set_key(nil, "heartbeat", timestamp);
+	end);
+
 	module:add_timer(0, function ()
-		store:set_key(nil, "heartbeat", os.time());
+		heartbeat_writer:run(os.time());
 		return heartbeat_interval;
 	end);
 end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_audit_tokens/README.md	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,8 @@
+---
+summary: Store token events in the audit log
+rockspec:
+  dependencies:
+  - mod_audit
+...
+
+This module stores events relating to auth tokens, e.g. grant creations and revokations, in the audit log provided by `mod_audit`.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_audit_tokens/mod_audit_tokens.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,19 @@
+local jid = require"util.jid";
+
+module:depends("audit");
+-- luacheck: read globals module.audit
+
+module:hook("token-grant-created", function(event)
+	module:audit(jid.join(event.username, event.host), "token-grant-created", {
+	});
+end)
+
+module:hook("token-grant-revoked", function(event)
+	module:audit(jid.join(event.username, event.host), "token-grant-revoked", {
+	});
+end)
+
+module:hook("token-revoked", function(event)
+	module:audit(jid.join(event.username, event.host), "token-revoked", {
+	});
+end)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_audit_user_accounts/README.md	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,17 @@
+---
+summary: Store user account lifecycle events in the audit log
+rockspec:
+  dependencies:
+  - mod_audit
+...
+
+This module stores events related to user accounts in the audit log. Events
+include:
+
+- New user registered via IBR (user-registered)
+- User deleted their account via IBR (user-deregistered)
+- User requested deletion of their account (i.e. when a grace period is set) (user-deregistered-pending)
+- User account disabled
+- User account enabled
+
+There are no configuration options for this module. It depends on mod_audit.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_audit_user_accounts/mod_audit_user_accounts.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,53 @@
+module:depends("audit");
+-- luacheck: read globals module.audit
+
+local dt = require "util.datetime";
+local jid = require "util.jid";
+local st = require "util.stanza";
+
+local function audit_basic_event(name, custom_handler)
+	module:hook(name, function (event)
+		local custom;
+		if custom_handler then
+			custom = custom_handler(event);
+		end
+		module:audit(jid.join(event.username, module.host), name, {
+			session = event.session;
+			custom = custom;
+		});
+	end);
+end
+
+audit_basic_event("user-registered", function (event)
+	local invite = event.validated_invite or (event.session and event.session.validated_invite);
+	if not invite then return; end
+	return {
+		st.stanza(
+			"invite-used",
+			{
+				xmlns = "xmpp:prosody.im/audit",
+				token = invite.token,
+			}
+		);
+	};
+end);
+
+audit_basic_event("user-deregistered-pending");
+audit_basic_event("user-deregistered");
+
+audit_basic_event("user-enabled");
+audit_basic_event("user-disabled", function (event)
+	local meta = event.meta;
+	if not meta then return end
+
+	local meta_st = st.stanza("disabled", {
+		xmlns = "xmpp:prosody.im/audit";
+		reason = meta.reason;
+		when = meta.when and dt.datetime(meta.when) or nil;
+	});
+	if meta.comment then
+		meta_st:text_tag("comment", meta.comment);
+	end
+
+	return { meta_st };
+end);
--- a/mod_auth_oauth_external/mod_auth_oauth_external.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_auth_oauth_external/mod_auth_oauth_external.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -34,7 +34,7 @@
 	return nil, "method not implemented"
 end
 
--- With proper OAuth 2, most of these should be handled at the atuhorization
+-- With proper OAuth 2, most of these should be handled at the authorization
 -- server, no there.
 provider.test_password = not_implemented;
 provider.get_password = not_implemented;
@@ -58,7 +58,7 @@
 
 function provider.get_sasl_handler()
 	local profile = {};
-	profile.http_client = http.default; -- TODO configurable
+	profile.http_client = http.new({ connection_pooling = true }); -- TODO configurable
 	local extra = { oidc_discovery_url = oidc_discovery_url };
 	if token_endpoint and allow_plain then
 		local map_username = function (username, _realm) return username; end; --jid.join; -- TODO configurable
--- a/mod_aws_profile/mod_aws_profile.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_aws_profile/mod_aws_profile.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -49,7 +49,7 @@
 		current_credentials.access_key = credentials.access_key;
 		current_credentials.secret_key = credentials.secret_key;
 		current_credentials.expiry = credentials.expiry;
-		module:timer(credentials.ttl or 240, refresh_credentials);
+		module:add_timer(credentials.ttl or 240, refresh_credentials);
 		module:fire_event("aws_profile/credentials-refreshed", current_credentials);
 	end);
 end
--- a/mod_c2s_conn_throttle/mod_c2s_conn_throttle.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_c2s_conn_throttle/mod_c2s_conn_throttle.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -15,7 +15,7 @@
 		if in_count[session.ip].starttls_c then in_count[session.ip].c = in_count[session.ip].starttls_c else in_count[session.ip].c = in_count[session.ip].c + 1 end
 
 		if in_count[session.ip].c > logins_count and time() - in_count[session.ip].t < throttle_time then
-			module:log("error", "Exceeded login count for %s, closing connection", session.ip)
+			module:log("info", "Exceeded login count for %s, closing connection", session.ip)
 			session:close{ condition = "policy-violation", text = "You exceeded the number of connections/logins allowed in "..throttle_time.." seconds, good bye." }
 			return true
 		elseif time() - in_count[session.ip].t > throttle_time then
--- a/mod_client_management/mod_client_management.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_client_management/mod_client_management.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -79,7 +79,7 @@
 		client_store:set_key(username, client_id, client_state);
 
 		if is_new_client then
-			module:fire_event("client_management/new-client", { client = client_state });
+			module:fire_event("client_management/new-client", { client = client_state; session = session });
 		end
 	end
 end);
@@ -133,7 +133,7 @@
 	client_store:set_key(session.username, client_state.id, client_state);
 
 	if is_new_client then
-		module:fire_event("client_management/new-client", { client = client_state });
+		module:fire_event("client_management/new-client", { client = client_state; session = session });
 	end
 end);
 
@@ -185,6 +185,9 @@
 end
 
 local function is_client_active(client)
+	if not client.full_jid then
+		return nil;
+	end
 	local username, host = jid.split(client.full_jid);
 	local account_info = usermanager.get_account_info(username, host);
 	local last_password_change = account_info and account_info.password_updated;
@@ -252,6 +255,7 @@
 				type = "access";
 				first_seen = grant.created;
 				last_seen = grant.accessed;
+				expires = grant.expires;
 				active = {
 					grant = grant;
 				};
@@ -421,7 +425,7 @@
 
 -- Command
 
-module:once(function ()
+module:on_ready(function ()
 	local console_env = module:shared("/*/admin_shell/env");
 	if not console_env.user then return; end -- admin_shell probably not loaded
 
@@ -438,9 +442,11 @@
 		end
 
 		local function date_or_time(last_seen)
-			return last_seen and os.date(os.difftime(os.time(), last_seen) >= 86400 and "%Y-%m-%d" or "%H:%M:%S", last_seen);
+			return last_seen and os.date(math.abs(os.difftime(os.time(), last_seen)) >= 86400 and "%Y-%m-%d" or "%H:%M:%S", last_seen);
 		end
 
+		local date_or_time_width = math.max(#os.date("%Y-%m-%d"), #os.date("%H:%M:%S"));
+
 		local colspec = {
 			{ title = "ID"; key = "id"; width = "1p" };
 			{
@@ -452,18 +458,39 @@
 			{
 				title = "First seen";
 				key = "first_seen";
-				width = math.max(#os.date("%Y-%m-%d"), #os.date("%H:%M:%S"));
+				width = date_or_time_width;
 				align = "right";
 				mapper = date_or_time;
 			};
 			{
 				title = "Last seen";
 				key = "last_seen";
-				width = math.max(#os.date("%Y-%m-%d"), #os.date("%H:%M:%S"));
+				width = date_or_time_width;
 				align = "right";
 				mapper = date_or_time;
 			};
 			{
+				title = "Expires";
+				key = "active";
+				width = date_or_time_width;
+				align = "right";
+				mapper = function(active, client)
+					local grant = active and active.grant;
+					local expires = client and client.expires;
+					local tokens = grant and grant.tokens;
+					if expires or not tokens then
+						return date_or_time(expires);
+					end
+
+					for _, token in pairs(tokens) do
+						if token.expires and (not expires or token.expires > expires) then
+							expires = token.expires;
+						end
+					end
+					return date_or_time(expires);
+				end;
+			};
+			{
 				title = "Authentication";
 				key = "active";
 				width = "2p";
--- a/mod_csi_battery_saver/mod_csi_battery_saver.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_csi_battery_saver/mod_csi_battery_saver.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -88,7 +88,13 @@
 	local st_name = stanza and stanza.name or nil;
 	if not st_name then return true; end	-- nonzas are always important
 	if st_name == "presence" then
-		-- TODO check for MUC status codes?
+		local st_type = stanza.attr.type;
+		-- subscription requests are important
+		if st_type == "subscribe" then return true; end
+		-- muc status codes are important, too
+		local muc_x = stanza:get_child("x", "http://jabber.org/protocol/muc#user")
+		local muc_status = muc_x and muc_x:get_child("status") or nil
+		if muc_status and muc_status.attr.code then return true; end
 		return false;
 	elseif st_name == "message" then
 		-- unpack carbon copies
@@ -122,7 +128,7 @@
 
 		-- check xep373 pgp (OX) https://xmpp.org/extensions/xep-0373.html
 		if stanza:get_child("openpgp", "urn:xmpp:openpgp:0") then return true; end
-		
+
 		-- check eme
 		if stanza:get_child("encryption", "urn:xmpp:eme:0") then return true; end
 
--- a/mod_dnsupdate/mod_dnsupdate.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_dnsupdate/mod_dnsupdate.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -121,6 +121,7 @@
 		if not opts.remove then
 			for port in ports do print(("add _%s._tcp.%s IN SRV 1 1 %d %s"):format(service, ihost, port, target)); end
 		end
+		if ports:empty() then print(("add _%s._tcp.%s IN SRV 0 0 0 ."):format(service, ihost)); end
 	end
 
 	print("show");
--- a/mod_firewall/README.markdown	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_firewall/README.markdown	Tue Feb 06 18:32:01 2024 +0700
@@ -253,7 +253,7 @@
 Similarly, a message stanza with no type is equivalent to one of type
 'normal'. mod\_firewall handles these cases for you automatically.
 
-### Sender/recipient matching
+#### Sender/recipient matching
 
   Condition       Matches
   --------------- -------------------------------------------------------
@@ -301,6 +301,31 @@
 stanza. It is not advisable to perform access control or similar rules
 on JIDs in these chains (see the [chain documentation](#chains) for more info).
 
+#### GeoIP matching
+
+  Condition        Matches
+  ---------------- --------------------------------------------------------------
+  `FROM COUNTRY`   Two or three letter country code looked up in GeoIP database
+
+This condition uses a GeoIP database to look up the origin country of
+the IP attached to the current session.
+
+For example:
+
+    # 3 letter country code
+    FROM COUNTRY: SWE
+
+    # or 2 letter
+    FROM COUNTRY: SE
+
+    # Explicit
+    FROM COUNTRY: code=SE
+    FROM COUNTRY: code3=SWE
+
+**Note:** This requires that the `lua-geoip` and `geoip-database`
+packages are installed (on Debian, package names may differ on other
+operating systems).
+
 #### INSPECT
 
 INSPECT takes a 'path' through the stanza to get a string (an attribute
--- a/mod_firewall/conditions.lib.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_firewall/conditions.lib.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -205,11 +205,11 @@
 end
 
 function condition_handlers.TO_ROLE(role_name)
-	return ("get_jid_role(bare_to, current_host) == %q"):format(role_name), { "get_jid_role", "current_host", "bare_to" };
+	return ("recipient_role and recipient_role.name == %q"):format(role_name), { "recipient_role" };
 end
 
 function condition_handlers.FROM_ROLE(role_name)
-	return ("get_jid_role(bare_from, current_host) == %q"):format(role_name), { "get_jid_role", "current_host", "bare_from" };
+	return ("sender_role and sender_role.name == %q"):format(role_name), { "sender_role" };
 end
 
 local day_numbers = { sun = 0, mon = 2, tue = 3, wed = 4, thu = 5, fri = 6, sat = 7 };
@@ -381,4 +381,30 @@
 	};
 end
 
+-- FROM COUNTRY: SE
+-- FROM COUNTRY: code=SE
+-- FROM COUNTRY: SWE
+-- FROM COUNTRY: code3=SWE
+-- FROM COUNTRY: continent=EU
+-- FROM COUNTRY? --> NOT FROM COUNTRY: -- (for unknown/invalid)
+-- TODO list support?
+function condition_handlers.FROM_COUNTRY(geoip_spec)
+	local condition = "==";
+	if not geoip_spec then
+		geoip_spec = "--";
+		condition = "~=";
+	end
+	local field, country = geoip_spec:match("(%w+)=(%w+)");
+	if not field then
+		if #geoip_spec == 3 then
+			field, country = "code3", geoip_spec;
+		elseif #geoip_spec == 2 then
+			field, country = "code", geoip_spec;
+		else
+			error("Unknown country code type");
+		end
+	end
+	return ("get_geoip(session.ip, %q) %s %q"):format(field:lower(), condition, country:upper()), { "geoip_country" };
+end
+
 return condition_handlers;
--- a/mod_firewall/mod_firewall.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_firewall/mod_firewall.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -261,9 +261,51 @@
 			return code, { "search:"..search_name, "pattern:"..pattern_name };
 		end;
 	};
+	sender_role = {
+		local_code = [[local sender_role = get_jid_role(bare_from, current_host)]];
+		depends = { "bare_from", "current_host", "get_jid_role" };
+	};
+	recipient_role = {
+		local_code = [[local recipient_role = get_jid_role(bare_to, current_host)]];
+		depends = { "bare_to", "current_host", "get_jid_role" };
+	};
 	scan_list = {
 		global_code = [[local function scan_list(list, items) for item in pairs(items) do if list:contains(item) then return true; end end end]];
-	}
+	};
+	iplib = {
+		global_code = [[local iplib = require "util.ip";]];
+	};
+	geoip_country = {
+		global_code = [[
+local geoip_country = require "geoip.country";
+local geov4 = geoip_country.open(module:get_option_string("geoip_ipv4_country", "/usr/share/GeoIP/GeoIP.dat"));
+local geov6 = geoip_country.open(module:get_option_string("geoip_ipv6_country", "/usr/share/GeoIP/GeoIPv6.dat"));
+local function get_geoip(ips, what)
+	if not ips then
+		return "--";
+	end
+	local ip = iplib.new_ip(ips);
+	if not ip then
+		return "--";
+	end
+	if ip.proto == "IPv6" and geov6 then
+		local geoinfo = geoinfo:query_by_addr6(ip.addr);
+		if geoinfo then
+			return geoinfo[what or "code"];
+		end
+	elseif ip.proto == "IPv4" and geov4 then
+		local geoinfo = geoinfo:query_by_addr(ip.addr);
+		if geoinfo then
+			return geoinfo[what or "code"];
+		end
+	end
+	return "--";
+end
+		]];
+		depends = {
+			"iplib"
+		}
+	};
 };
 
 local function include_dep(dependency, code)
--- a/mod_groups_internal/mod_groups_internal.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_groups_internal/mod_groups_internal.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -1,12 +1,13 @@
 local rostermanager = require"core.rostermanager";
 local modulemanager = require"core.modulemanager";
+local array = require "util.array";
 local id = require "util.id";
 local jid = require "util.jid";
 local st = require "util.stanza";
 local jid_join = jid.join;
 local host = module.host;
 
-local group_info_store = module:open_store("group_info");
+local group_info_store = module:open_store("group_info", "keyval+");
 local group_members_store = module:open_store("groups");
 local group_memberships = module:open_store("groups", "map");
 
@@ -17,7 +18,7 @@
 
 -- Make a *one-way* subscription. User will see when contact is online,
 -- contact will not see when user is online.
-local function subscribe(user, user_jid, contact, contact_jid)
+local function subscribe(user, user_jid, contact, contact_jid, group_name)
 	-- Update user's roster to say subscription request is pending...
 	rostermanager.set_contact_pending_out(user, host, contact_jid);
 	-- Update contact's roster to say subscription request is pending...
@@ -27,6 +28,11 @@
 	-- Update user's roster to say subscription request approved...
 	rostermanager.process_inbound_subscription_approval(user, host, contact_jid);
 
+	if group_name then
+		local user_roster = rostermanager.load_roster(user, host);
+		user_roster[contact_jid].groups[group_name] = true;
+	end
+
 	-- Push updates to both rosters
 	rostermanager.roster_push(user, host, contact_jid);
 	rostermanager.roster_push(contact, host, user_jid);
@@ -39,17 +45,18 @@
 local function do_single_group_subscriptions(username, group_id)
 	local members = group_members_store:get(group_id);
 	if not members then return; end
+	local group_name = group_info_store:get_key(group_id, "name");
 	local user_jid = jid_join(username, host);
 	for membername in pairs(members) do
 		if membername ~= username then
 			local member_jid = jid_join(membername, host);
 			if not is_contact_subscribed(username, host, member_jid) then
 				module:log("debug", "[group %s] Subscribing %s to %s", member_jid, user_jid);
-				subscribe(membername, member_jid, username, user_jid);
+				subscribe(membername, member_jid, username, user_jid, group_name);
 			end
 			if not is_contact_subscribed(membername, host, user_jid) then
 				module:log("debug", "[group %s] Subscribing %s to %s", user_jid, member_jid);
-				subscribe(username, user_jid, membername, member_jid);
+				subscribe(username, user_jid, membername, member_jid, group_name);
 			end
 		end
 	end
@@ -76,8 +83,43 @@
 	do_all_group_subscriptions_by_user(event.session.username);
 end);
 
+local function _create_muc_room(name)
+	if not muc_host_name then
+		module:log("error", "cannot create group MUC: no MUC host configured")
+		return nil, "service-unavailable"
+	end
+	if not muc_host then
+		module:log("error", "cannot create group MUC: MUC host %s not configured properly", muc_host_name)
+		return nil, "internal-server-error"
+	end
+
+	local muc_jid = jid.prep(id.short() .. "@" .. muc_host_name);
+	local room = muc_host.create_room(muc_jid)
+	if not room then
+		return nil, "internal-server-error"
+	end
+
+	local ok = pcall(function ()
+		room:set_public(false);
+		room:set_persistent(true);
+		room:set_members_only(true);
+		room:set_allow_member_invites(false);
+		room:set_moderated(false);
+		room:set_whois("anyone");
+		room:set_name(name);
+	end);
+
+	if not ok then
+		module:log("error", "Failed to configure group MUC %s", muc_jid);
+		room:destroy();
+		return nil, "internal-server-error";
+	end
+
+	return muc_jid, room;
+end
+
 --luacheck: ignore 131
-function create(group_info, create_muc, group_id)
+function create(group_info, create_default_muc, group_id)
 	if not group_info.name then
 		return nil, "group-name-required";
 	end
@@ -91,29 +133,13 @@
 
 	local muc_jid = nil
 	local room = nil
-	if create_muc then
-		if not muc_host_name then
-			module:log("error", "cannot create group with MUC: no MUC host configured")
-			return nil, "service-unavailable"
-		end
-		if not muc_host then
-			module:log("error", "cannot create group with MUC: MUC host %s not configured properly", muc_host_name)
-			return nil, "internal-server-error"
+	if create_default_muc then
+		muc_jid, room = _create_muc_room(group_info.name);
+		if not muc_jid then
+			-- MUC creation failed, fail to create group
+			delete(group_id)
+			return nil, room;
 		end
-
-		muc_jid = jid.prep(id.short() .. "@" .. muc_host_name);
-		room = muc_host.create_room(muc_jid)
-		if not room then
-			delete(group_id)
-			return nil, "internal-server-error"
-		end
-		room:set_public(false)
-		room:set_persistent(true)
-		room:set_members_only(true)
-		room:set_allow_member_invites(false)
-		room:set_moderated(false)
-		room:set_whois("anyone")
-		room:set_name(group_info.name)
 	end
 
 	local ok = group_info_store:set(group_id, {
@@ -158,7 +184,7 @@
 end
 
 function get_members(group_id)
-	return group_members_store:get(group_id);
+	return group_members_store:get(group_id) or {};
 end
 
 function exists(group_id)
@@ -200,6 +226,7 @@
 	if not group_memberships:set(group_id, username, {}) then
 		return nil, "internal-server-error";
 	end
+
 	if group_info.muc_jid then
 		local room = muc_host.get_room_from_jid(group_info.muc_jid);
 		if room then
@@ -215,7 +242,29 @@
 		else
 			module:log("warn", "failed to update affiliation for %s in %s", username, group_info.muc_jid);
 		end
+	elseif group_info.mucs then
+		local user_jid = username .. "@" .. host;
+		for i = #group_info.mucs, 1, -1 do
+			local muc_jid = group_info.mucs[i];
+			local room = muc_host.get_room_from_jid(muc_jid);
+			if not room or room._data.destroyed then
+				-- MUC no longer available, for some reason
+				-- Let's remove it from the circle metadata...
+				table.remove(group_info.mucs, i);
+				group_info_store:set_key(group_id, "mucs", group_info.mucs);
+			else
+				room:set_affiliation(true, user_jid, "member");
+				module:send(st.message(
+					{ from = muc_jid, to = user_jid }
+				):tag("x", {
+					xmlns = "jabber:x:conference",
+					jid = muc_jid
+				}):up());
+				module:log("debug", "set user %s to be member in %s and sent invite", username, muc_jid);
+			end
+		end
 	end
+
 	module:fire_event(
 		"group-user-added",
 		{
@@ -247,7 +296,18 @@
 		else
 			module:log("warn", "failed to update affiliation for %s in %s", username, group_info.muc_jid);
 		end
+	elseif group_info.mucs then
+		local user_jid = username .. "@" .. host;
+		for _, muc_jid in ipairs(group_info.mucs) do
+			local room = muc_host.get_room_from_jid(muc_jid);
+			if room then
+				room:set_affiliation(true, user_jid, nil);
+			else
+				module:log("warn", "failed to update affiliation for %s in %s", username, muc_jid);
+			end
+		end
 	end
+
 	module:fire_event(
 		"group-user-removed",
 		{
@@ -264,6 +324,151 @@
 	do_all_group_subscriptions_by_group(group_id);
 end
 
+function add_group_chat(group_id, name)
+	local group_info = group_info_store:get(group_id);
+	local mucs = group_info.mucs or {};
+
+	-- Create the MUC
+	local muc_jid, room = _create_muc_room(name);
+	if not muc_jid then return nil, room; end
+	room:save(); -- This ensures the room is committed to storage
+
+	table.insert(mucs, muc_jid);
+
+	if group_info.muc_jid then -- COMPAT include old muc_jid into array
+		table.insert(mucs, group_info.muc_jid);
+	end
+	local store_ok, store_err = group_info_store:set_key(group_id, "mucs", mucs);
+	if not store_ok then
+		module:log("error", "Failed to store new MUC association: %s", store_err);
+		room:destroy();
+		return nil, "internal-server-error";
+	end
+
+	-- COMPAT: clear old muc_jid (it's now in mucs array)
+	if group_info.muc_jid then
+		module:log("debug", "Clearing old single-MUC JID");
+		group_info.muc_jid = nil;
+		group_info_store:set_key(group_id, "muc_jid", nil);
+	end
+
+	-- Make existing group members, members of the MUC
+	for username in pairs(get_members(group_id)) do
+		local user_jid = username .. "@" ..module.host;
+		room:set_affiliation(true, user_jid, "member");
+		module:send(st.message(
+			{ from = muc_jid, to = user_jid }
+		):tag("x", {
+			xmlns = "jabber:x:conference",
+			jid = muc_jid
+		}):up());
+		module:log("debug", "set user %s to be member in %s and sent invite", user_jid, muc_jid);
+	end
+
+	-- Notify other modules (such as mod_groups_muc_bookmarks)
+	local muc = {
+		jid = muc_jid;
+		name = name;
+	};
+
+	module:fire_event("group-chat-added", {
+		group_id = group_id;
+		group_info = group_info;
+		muc = muc;
+	});
+
+	return muc;
+end
+
+function remove_group_chat(group_id, muc_id)
+	local group_info = group_info_store:get(group_id);
+	if not group_info then
+		return nil, "group-not-found";
+	end
+
+	local mucs = group_info.mucs;
+	if not mucs then
+		if not group_info.muc_jid then
+			return true;
+		end
+		-- COMPAT with old single-MUC groups - upgrade to new format
+		mucs = {};
+	end
+	if group_info.muc_jid then
+		table.insert(mucs, group_info.muc_jid);
+	end
+
+	local removed;
+	for i, muc_jid in ipairs(mucs) do
+		if muc_id == jid.node(muc_jid) then
+			removed = table.remove(mucs, i);
+			break;
+		end
+	end
+
+	if removed then
+		if not group_info_store:set_key(group_id, "mucs", mucs) then
+			return nil, "internal-server-error";
+		end
+
+		if group_info.muc_jid then
+			-- COMPAT: Now we've set the array, clean up muc_jid
+			group_info.muc_jid = nil;
+			group_info_store:set_key(group_id, "muc_jid", nil);
+		end
+
+		module:log("debug", "Updated group MUC list");
+
+		local room = muc_host.get_room_from_jid(removed);
+		if room then
+			room:destroy();
+		else
+			module:log("warn", "Removing a group chat, but associated MUC not found (%s)", removed);
+		end
+
+		module:fire_event(
+			"group-chat-removed",
+			{
+				group_id = group_id;
+				group_info = group_info;
+				muc = {
+					id = muc_id;
+					jid = removed;
+				};
+			}
+		);
+	else
+		module:log("warn", "Removal of a group chat that can't be found - %s", muc_id);
+	end
+
+	return true;
+end
+
+function get_group_chats(group_id)
+	local group_info, err = group_info_store:get(group_id);
+	if not group_info then
+		module:log("debug", "Unable to load group info: %s - %s", group_id, err);
+		return nil;
+	end
+
+	local mucs = group_info.mucs or {};
+
+	-- COMPAT with single-MUC groups
+	if group_info.muc_jid then
+		table.insert(mucs, group_info.muc_jid);
+	end
+
+	return array.map(mucs, function (muc_jid)
+		local room = muc_host.get_room_from_jid(muc_jid);
+		return {
+			id = jid.node(muc_jid);
+			jid = muc_jid;
+			name = room and room:get_name() or group_info.name;
+			deleted = not room or room._data.destroyed;
+		};
+	end);
+end
+
 function emit_member_events(group_id)
 	local group_info, err = get_info(group_id)
 	if group_info == nil then
@@ -287,7 +492,7 @@
 
 -- Returns iterator over group ids
 function groups()
-	return group_info_store:users();
+	return group_info_store:items();
 end
 
 local function setup()
--- a/mod_groups_muc_bookmarks/mod_groups_muc_bookmarks.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_groups_muc_bookmarks/mod_groups_muc_bookmarks.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -41,6 +41,7 @@
 end
 
 local function inject_bookmark(jid, room, autojoin, name)
+	module:log("debug", "Injecting bookmark for %s into %s", room, jid);
 	local pep_service = mod_pep.get_pep_service(jid_split(jid))
 
 	local current, err = get_current_bookmarks(jid, pep_service);
@@ -69,31 +70,69 @@
 	update_bookmark(jid, pep_service, room, found)
 end
 
-local function remove_bookmark(jid, room, autojoin, name)
+local function remove_bookmark(jid, room)
 	local pep_service = mod_pep.get_pep_service(jid_split(jid))
 
 	return pep_service:retract(XMLNS_BM2, jid, room, st.stanza("retract", { id = room }));
 end
 
 local function handle_user_added(event)
-	if not event.group_info.muc_jid then
-		module:log("debug", "ignoring user added event on group %s because it has no MUC", event.id)
-		return
+	local group_info = event.group_info;
+
+	local jid = event.user .. "@" .. event.host
+
+	if group_info.muc_jid then
+		inject_bookmark(jid, group_info.muc_jid, true, group_info.name);
+	elseif group_info.mucs then
+		for _, chat in ipairs(mod_groups.get_group_chats(event.id)) do
+			if not chat.deleted then
+				inject_bookmark(jid, chat.jid, true, chat.name);
+			end
+		end
+	else
+		module:log("debug", "ignoring user added event on group %s because it has no MUCs", event.id)
 	end
-	local jid = event.user .. "@" .. event.host
-	inject_bookmark(jid, event.group_info.muc_jid, true, event.group_info.name)
 end
 
 local function handle_user_removed(event)
-	if not event.group_info.muc_jid then
-		module:log("debug", "ignoring user removed event on group %s because it has no MUC", event.id)
-		return
-	end
 	-- Removing the bookmark is fine as the user just lost any privilege to
 	-- be in the MUC (as group MUCs are members-only).
+	local group_info = event.group_info;
 	local jid = event.user .. "@" .. event.host
-	remove_bookmark(jid, event.group_info.muc_jid, true, event.group_info.name)
+
+	if group_info.muc_jid then
+		remove_bookmark(jid, event.group_info.muc_jid);
+	elseif group_info.mucs then
+		for _, muc_jid in ipairs(group_info.mucs) do
+			remove_bookmark(jid, muc_jid);
+		end
+	else
+		module:log("debug", "ignoring user removed event on group %s because it has no MUC", event.id)
+	end
 end
 
 module:hook("group-user-added", handle_user_added)
 module:hook("group-user-removed", handle_user_removed)
+
+
+local function handle_muc_added(event)
+	-- Add MUC to all members' bookmarks
+	module:log("info", "Adding new group chat to all member bookmarks...");
+	local muc_jid, muc_name = event.muc.jid, event.muc.name;
+	for member_username in pairs(mod_groups.get_members(event.group_id)) do
+		local member_jid = member_username .. "@" .. module.host;
+		inject_bookmark(member_jid, muc_jid, true, muc_name);
+	end
+end
+
+local function handle_muc_removed(event)
+	-- Remove MUC from all members' bookmarks
+	local muc_jid = event.muc.jid;
+	for member_username in ipairs(mod_groups.get_members(event.group_id)) do
+		local member_jid = member_username .. "@" .. module.host;
+		remove_bookmark(member_jid, muc_jid);
+	end
+end
+
+module:hook("group-chat-added", handle_muc_added)
+module:hook("group-chat-removed", handle_muc_removed)
--- a/mod_http_admin_api/mod_http_admin_api.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_http_admin_api/mod_http_admin_api.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -1,10 +1,11 @@
 local usermanager = require "core.usermanager";
 
+local array = require "util.array";
+local it = require "util.iterators";
 local jid = require "util.jid";
-local it = require "util.iterators";
 local json = require "util.json";
+local set = require "util.set";
 local st = require "util.stanza";
-local array = require "util.array";
 local statsmanager = require "core.statsmanager";
 
 module:depends("http");
@@ -14,6 +15,7 @@
 local tokens = module:depends("tokenauth");
 local mod_pep = module:depends("pep");
 local mod_groups = module:depends("groups_internal");
+local mod_lastlog2 = module:depends("lastlog2");
 
 local push_errors = module:shared("cloud_notify/push_errors");
 
@@ -28,6 +30,10 @@
 local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
 local xmlns_nick = "http://jabber.org/protocol/nick";
 
+assert(mod_lastlog2.get_last_active, "Newer version of mod_lastlog2 is required to use this module");
+
+local deleted_users = module:open_store("accounts_cleanup");
+
 local function check_credentials(request)
 	local auth_type, auth_data = string.match(request.headers.authorization or "", "^(%S+)%s(.+)$");
 	if not (auth_type and auth_data) then
@@ -170,6 +176,27 @@
 	return 200;
 end
 
+local function get_user_avatar_info(username)
+	local pep_service = mod_pep.get_pep_service(username);
+	local ok, _, avatar_item = pep_service:get_last_item("urn:xmpp:avatar:metadata", true);
+	avatar_item = avatar_item and avatar_item:get_child("metadata", "urn:xmpp:avatar:metadata");
+	if not ok or not avatar_item then return; end
+
+	local avatar_info = {};
+
+	for avatar in avatar_item:childtags("info") do
+		table.insert(avatar_info, {
+			bytes = tonumber(avatar.attr.bytes);
+			hash = avatar.attr.id;
+			type = avatar.attr.type;
+			width = tonumber(avatar.attr.width);
+			height = tonumber(avatar.attr.height);
+		});
+	end
+
+	return avatar_info;
+end
+
 local function get_user_info(username)
 	if not usermanager.user_exists(username, module.host) then
 		return nil;
@@ -195,12 +222,21 @@
 		end
 	end
 
+	local enabled = true; -- Assume all enabled if on a version without is_enabled
+	if usermanager.user_is_enabled then
+		enabled = usermanager.user_is_enabled(username, module.host);
+	end
+
 	return {
 		username = username;
 		display_name = display_name;
 		role = primary_role and primary_role.name or nil;
 		secondary_roles = secondary_roles;
 		roles = legacy_roles; -- COMPAT w/0.12
+		enabled = enabled;
+		last_active = mod_lastlog2.get_last_active(username);
+		deletion_request = not enabled and deleted_users:get(username) or nil;
+		avatar_info = get_user_avatar_info(username);
 	};
 end
 
@@ -390,8 +426,60 @@
 	return json.encode(user_info);
 end
 
+local user_attribute_writers = {
+	enabled = function (username, enabled)
+		local ok, err;
+		if enabled == true then
+			ok, err = usermanager.enable_user(username, module.host);
+		elseif enabled == false then
+			ok, err = usermanager.disable_user(username, module.host);
+		else
+			ok, err = nil, "Invalid value provided for 'enabled'";
+		end
+		if not ok then
+			module:log("error", "Unable to %s user '%s': %s", enabled and "enable" or "disable", username, err);
+			return nil, err;
+		end
+		return true;
+	end;
+};
+local writable_user_attributes = set.new(array.collect(it.keys(user_attribute_writers)));
+
+function patch_user(event, username)
+	if not username then return; end
+
+	local current_user = get_user_info(username);
+	if not current_user then return 404; end
+
+	local request = event.request;
+	if request.headers.content_type ~= json_content_type
+	or (not request.body or #request.body == 0) then
+		return 400;
+	end
+	local new_user = json.decode(event.request.body);
+	if not new_user then
+		return 400;
+	end
+
+	local updated_attributes = set.new(array.collect(it.keys(new_user)));
+	if not (updated_attributes - writable_user_attributes):empty() then
+		module:log("warn", "Unable to service PATCH user request, unsupported attributes: %s", (updated_attributes - writable_user_attributes));
+		return 400;
+	end
+
+	if new_user.enabled ~= nil and new_user.enabled ~= current_user.enabled then
+		if not user_attribute_writers.enabled(username, new_user.enabled) then
+			return 500;
+		end
+	end
+
+	return 200;
+end
+
 function update_user(event, username)
-	local current_user = get_user_info(username);
+	if not username then
+		return 400;
+	end
 
 	local request = event.request;
 	if request.headers.content_type ~= json_content_type
@@ -407,19 +495,15 @@
 		return 400;
 	end
 
-	local final_user = {};
-
 	if new_user.display_name then
 		local pep_service = mod_pep.get_pep_service(username);
 		-- TODO: publish
 		local nick_item = st.stanza("item", { xmlns = xmlns_pubsub, id = "current" })
 			:text_tag("nick", new_user.display_name, { xmlns = xmlns_nick });
-		if pep_service:publish(xmlns_nick, true, "current", nick_item, {
+		pep_service:publish(xmlns_nick, true, "current", nick_item, {
 			access_model = "open";
 			_defaults_only = true;
-		}) then
-			final_user.display_name = new_user.display_name;
-		end
+		});
 	end
 
 	if new_user.role then
@@ -441,13 +525,19 @@
 		for _, role in ipairs(new_user.roles) do
 			backend_roles[role] = true;
 		end
-		local jid = username.."@"..module.host;
+		local user_jid = username.."@"..module.host;
 		if not usermanager.set_user_roles(username, module.host, backend_roles) then
-			module:log("error", "failed to set roles %q for %s", backend_roles, jid)
+			module:log("error", "failed to set roles %q for %s", backend_roles, user_jid)
 			return 500
 		end
 	end
 
+	if new_user.enabled ~= nil then
+		if not user_attribute_writers.enabled(username, new_user.enabled) then
+			return 500;
+		end
+	end
+
 	return 200;
 end
 
@@ -465,8 +555,8 @@
 		table.insert(group_list, {
 			id = group_id;
 			name = group_info.name;
-			muc_jid = group_info.muc_jid;
 			members = mod_groups.get_members(group_id);
+			chats = mod_groups.get_group_chats(group_id);
 		});
 	end
 
@@ -485,8 +575,8 @@
 	return json.encode({
 		id = group_id;
 		name = group.name;
-		muc_jid = group.muc_jid;
 		members = mod_groups.get_members(group_id);
+		chats = mod_groups.get_group_chats(group_id);
 	});
 end
 
@@ -524,58 +614,87 @@
 	return json.encode({
 		id = group_id;
 		name = info.name;
-		muc_jid = info.muc_jid or nil;
 		members = {};
+		chats = {};
 	});
 end
 
 function update_group(event, group) --luacheck: ignore 212/event
 	-- Add member
-	local group_id, member_name = group:match("^([^/]+)/members/([^/]+)$");
-	if group_id and member_name then
-		if not mod_groups.add_member(group_id, member_name) then
-			return 500;
+	do
+		local group_id, member_name = group:match("^([^/]+)/members/([^/]+)$");
+		if group_id and member_name then
+			if not mod_groups.add_member(group_id, member_name) then
+				return 500;
+			end
+			return 204;
 		end
-		return 204;
 	end
 
 	local group_id = group:match("^([^/]+)$")
+	if not group_id then return 404; end
+
+	local request = event.request;
+	if request.headers.content_type ~= json_content_type or (not request.body or #request.body == 0) then
+		return 400;
+	end
+
+	local update = json.decode(event.request.body);
+	if not update then
+		return 400;
+	end
+
+	local group_info = mod_groups.get_info(group_id);
+	if not group_info then
+		return 404;
+	end
+
+	if update.name then
+		group_info["name"] = update.name;
+	end
+	if not mod_groups.set_info(group_id, group_info) then
+		return 500;
+	end
+	return 204;
+end
+
+function extend_group(event, subpath)
+	-- Add group chat
+	local group_id = subpath:match("^([^/]+)/chats$");
 	if group_id then
-		local request = event.request;
-		if request.headers.content_type ~= json_content_type or (not request.body or #request.body == 0) then
-			return 400;
-		end
-
-		local update = json.decode(event.request.body);
-		if not update then
+		local muc_params = json.decode(event.request.body);
+		if not muc_params then
 			return 400;
 		end
-
-		local group_info = mod_groups.get_info(group_id);
-		if not group_info then
-			return 404;
-		end
-
-		if update.name then
-			group_info["name"] = update.name;
-		end
-		if mod_groups.set_info(group_id, group_info) then
-			return 204;
-		else
+		local muc = mod_groups.add_group_chat(group_id, muc_params.name);
+		if not muc then
 			return 500;
 		end
+		return json.encode(muc);
 	end
+
 	return 404;
 end
 
 function delete_group(event, subpath) --luacheck: ignore 212/event
 	-- Check if this is a membership deletion and handle it
-	local group_id, member_name = subpath:match("^([^/]+)/members/([^/]+)$");
-	if group_id and member_name then
-		if mod_groups.remove_member(group_id, member_name) then
-			return 204;
+	local group_id, sub_resource_type, sub_resource_id = subpath:match("^([^/]+)/([^/]+)/([^/]+)$");
+	if group_id then
+		-- Operation is on a sub-resource
+		if sub_resource_type == "members" then
+			if mod_groups.remove_member(group_id, sub_resource_id) then
+				return 204;
+			else
+				return 500;
+			end
+		elseif sub_resource_type == "chats" then
+			if mod_groups.remove_group_chat(group_id, sub_resource_id) then
+				return 204;
+			else
+				return 500;
+			end
 		else
-			return 500;
+			return 404;
 		end
 	else
 		-- Action refers to the group
@@ -629,7 +748,7 @@
 	for _, metric in mf:iter_metrics() do
 		sum = sum + metric.value;
 	end
-	return sum;
+	return (sum == sum) and sum or nil; -- Filter out nan
 end
 
 local function get_server_metrics(event)
@@ -695,11 +814,13 @@
 		["GET /users"] = list_users;
 		["GET /users/*"] = get_user_by_name;
 		["PUT /users/*"] = update_user;
+		["PATCH /users/*"] = patch_user;
 		["DELETE /users/*"] = delete_user;
 
 		["GET /groups"] = list_groups;
 		["GET /groups/*"] = get_group_by_id;
 		["POST /groups"] = create_group;
+		["POST /groups/*"] = extend_group;
 		["PUT /groups/*"] = update_group;
 		["DELETE /groups/*"] = delete_group;
 
--- a/mod_http_avatar/mod_http_avatar.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_http_avatar/mod_http_avatar.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -29,7 +29,8 @@
 		end
 	end
 	if not photo_type or not binval then
-		response.status_code = 404;
+		-- FIXME: should be a 404, but Firefox won’t display it in that case…
+		--response.status_code = 404;
 		response.headers.content_type = "image/svg+xml";
 		return default_avatar;
 	end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_health/README.md	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,39 @@
+Simple module adding an endpoint meant to be used for health checks.
+
+# Configuration
+
+After installing, enable by adding to [`modules_enabled`][doc:modules_enabled] like many other modules:
+
+``` lua
+-- in the global section
+modules_enabled = {
+    -- Other globally enabled modules here...
+    "http_health"; -- add
+}
+```
+
+## Access control
+
+By default only access via localhost is allowed. This can be adjusted with `http_health_allow_ips`. The following example shows the default:
+
+```
+http_health_allow_ips = { "::1"; "127.0.0.1" }
+```
+
+Access can also be granted to one IP range via CIDR notation:
+
+```
+http_health_allow_cidr = "172.17.2.0/24"
+```
+
+The default for `http_health_allow_cidr` is empty.
+
+# Details
+
+Adds a `http://your.prosody.example:5280/health` endpoint that returns either HTTP status code 200 when all appears to be good or 500 when any module
+[status][doc:developers:moduleapi#logging-and-status] has been set to `error`.
+
+# See also
+
+- [mod_measure_modules] provides module statues via OpenMetrics
+- [mod_http_status] provides all module status details as JSON via HTTP
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_health/mod_http_health.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,39 @@
+module:set_global();
+
+local ip = require "util.ip";
+
+local modulemanager = require "core.modulemanager";
+
+local permitted_ips = module:get_option_set("http_health_allow_ips", { "::1", "127.0.0.1" });
+local permitted_cidr = module:get_option_string("http_health_allow_cidr");
+
+local function is_permitted(request)
+	local ip_raw = request.ip;
+	if permitted_ips:contains(ip_raw) or
+	   (permitted_cidr and ip.match(ip.new_ip(ip_raw), ip.parse_cidr(permitted_cidr))) then
+		return true;
+	end
+	return false;
+end
+
+module:provides("http", {
+	route = {
+		GET = function(event)
+			local request = event.request;
+			if not is_permitted(request) then
+				return 403; -- Forbidden
+			end
+
+			for host in pairs(prosody.hosts) do
+				local mods = modulemanager.get_modules(host);
+				for _, mod in pairs(mods) do
+					if mod.module.status_type == "error" then
+						return { status_code = 500; headers = { content_type = "text/plain" }; body = "HAS ERRORS\n" };
+					end
+				end
+			end
+
+			return { status_code = 200; headers = { content_type = "text/plain" }; body = "OK\n" };
+		end;
+	};
+});
--- a/mod_http_muc_log/mod_http_muc_log.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_http_muc_log/mod_http_muc_log.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -407,17 +407,17 @@
 			local target_id = reactions.attr.id or reactions.attr.to;
 			for n = i - 1, 1, -1 do
 				if logs[n].archive_id == target_id then
-					local react_map = logs[n].reactions; -- { string : integer }
+					local react_map = logs[n].reactions; -- [occupant_id][emoji]boolean
 					if not react_map then
 						react_map = {};
 						logs[n].reactions = react_map;
 					end
+					local reacts = {};
 					for reaction_tag in reactions:childtags("reaction") do
-						-- FIXME This doesn't replace previous reactions by the same user
-						-- on the same message.
 						local reaction_text = reaction_tag:get_text() or "�";
-						react_map[reaction_text] = (react_map[reaction_text] or 0) + 1;
+						reacts[reaction_text] = true;
 					end
+					react_map[occupant_id] = reacts;
 					break
 				end
 			end
@@ -458,6 +458,20 @@
 	end
 	if i == 1 and not lazy then return end -- No items
 
+	-- collapse reactions[occupant-id][reaction]boolean into reactions[reaction]integer
+	for n = 1, #logs do
+		local reactions = logs[n].reactions;
+		if reactions then
+			local collated = {};
+			for _, reacts in pairs(reactions) do
+				for reaction_text in pairs(reacts) do
+					collated[reaction_text] = (collated[reaction_text] or 0) + 1;
+				end
+			end
+			logs[n].reactions = collated;
+		end
+	end
+
 	local next_when, prev_when = "", "";
 	local date_list = archive.dates and archive:dates(room);
 	if date_list then
@@ -528,7 +542,7 @@
 	local request, response = event.request, event.response;
 	local room_list, i = {}, 1;
 	for room in each_room() do
-		if not (room.get_hidden or room.is_hidden)(room) then
+		if room:get_public() then
 			local localpart = jid_split(room.jid);
 			room_list[i], i = {
 				jid = room.jid;
--- a/mod_http_oauth2/README.markdown	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_http_oauth2/README.markdown	Tue Feb 06 18:32:01 2024 +0700
@@ -11,7 +11,7 @@
 ## Introduction
 
 This module implements an [OAuth2](https://oauth.net/2/)/[OpenID Connect
-(OIDC)](https://openid.net/connect/) provider HTTP frontend on top of
+(OIDC)](https://openid.net/connect/) Authorization Server on top of
 Prosody's usual internal authentication backend.
 
 OAuth and OIDC are web standards that allow you to provide clients and
@@ -51,6 +51,7 @@
 - [RFC 7591: OAuth 2.0 Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591.html)
 - [RFC 7628: A Set of Simple Authentication and Security Layer (SASL) Mechanisms for OAuth](https://www.rfc-editor.org/rfc/rfc7628)
 - [RFC 7636: Proof Key for Code Exchange by OAuth Public Clients](https://www.rfc-editor.org/rfc/rfc7636)
+- [RFC 7662: OAuth 2.0 Token Introspection](https://www.rfc-editor.org/rfc/rfc7662)
 - [RFC 8628: OAuth 2.0 Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628)
 - [RFC 9207: OAuth 2.0 Authorization Server Issuer Identification](https://www.rfc-editor.org/rfc/rfc9207.html)
 - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
@@ -78,15 +79,6 @@
 oauth2_template_path = "/etc/prosody/custom-oauth2-templates"
 ```
 
-Some templates support additional variables, that can be provided by the
-`oauth2_template_style` option:
-
-```lua
-oauth2_template_style = {
-  background_colour = "#ffffff";
-}
-```
-
 If you know what features your templates use use you can adjust the
 `Content-Security-Policy` header to only allow what is needed:
 
@@ -232,10 +224,10 @@
 ```
 
 The [Proof Key for Code Exchange][RFC 7636] mitigation method is
-optional by default but can be made required:
+required by default but can be made optional:
 
 ```lua
-oauth2_require_code_challenge = true -- default is false
+oauth2_require_code_challenge = false -- default is true
 ```
 
 Further, individual challenge methods can be enabled or disabled:
@@ -243,7 +235,7 @@
 ```lua
 -- These reflects the default
 allowed_oauth2_code_challenge_methods = {
-    "plain"; -- the insecure one
+    -- "plain"; -- insecure but backwards-compatible
     "S256";
 }
 ```
--- a/mod_http_oauth2/html/style.css	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_http_oauth2/html/style.css	Tue Feb 06 18:32:01 2024 +0700
@@ -1,7 +1,10 @@
+:root
+{
+	color-scheme:light dark;
+}
 body
 {
 	text-align:center;
-	background-color:#f8f8f8;
 	font-family:sans-serif
 }
 
@@ -76,12 +79,6 @@
 
 @media(prefers-color-scheme:dark)
 {
-	body
-	{
-		background-color:#161616;
-		color:#eee;
-	}
-
 	.error {
 		color: #f8d7da;
 		background-color: #842029;
@@ -90,17 +87,6 @@
 		color: #d7daf8;
 		background-color: #202984;
 	}
-
-
-	:link
-	{
-		color: #6197df;
-	}
-
-	:visited
-	{
-		color: #9a61df;
-	}
 }
 
 @media(min-width: 768px)
--- a/mod_http_oauth2/mod_http_oauth2.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -111,7 +111,8 @@
 local registration_options = module:get_option("oauth2_registration_options",
 	{ default_ttl = registration_ttl; accept_expired = not registration_ttl });
 
-local pkce_required = module:get_option_boolean("oauth2_require_code_challenge", false);
+local pkce_required = module:get_option_boolean("oauth2_require_code_challenge", true);
+local respect_prompt = module:get_option_boolean("oauth2_respect_oidc_prompt", false);
 
 local verification_key;
 local sign_client, verify_client;
@@ -138,6 +139,8 @@
 	return client;
 end
 
+local purpose_map = { ["oauth2-refresh"] = "refresh_token"; ["oauth"] = "access_token" };
+
 -- scope : string | array | set
 --
 -- at each step, allow the same or a subset of scopes
@@ -212,12 +215,19 @@
 	return code_expires_in(code) < 0;
 end
 
+-- LRU cache for short-term storage of authorization codes and device codes
 local codes = cache.new(10000, function (_, code)
+	-- If the cache is full and the oldest item hasn't expired yet then we
+	-- might be under some kind of DoS attack, so might as well reject further
+	-- entries for a bit.
 	return code_expired(code)
 end);
 
 -- Clear out unredeemed codes so they don't linger in memory.
 module:daily("Clear expired authorization codes", function()
+	-- The tail should be the least recently touched item, and most likely to
+	-- have expired already, so check and remove that one until encountering
+	-- one that has not expired.
 	local k, code = codes:tail();
 	while code and code_expired(code) do
 		codes:set(k, nil);
@@ -242,7 +252,7 @@
 		type = "modify";
 		condition = "bad-request";
 		code = err_name == "invalid_client" and 401 or 400;
-		text = err_desc and (err_name..": "..err_desc) or err_name;
+		text = err_desc or err_name:gsub("^.", string.upper):gsub("_", " ");
 		extra = { oauth2_response = { error = err_name, error_description = err_desc } };
 	});
 end
@@ -272,7 +282,7 @@
 	local grant = refresh_token_info and refresh_token_info.grant;
 	if not grant then
 		-- No existing grant, create one
-		grant = tokens.create_grant(token_jid, token_jid, default_refresh_ttl, token_data);
+		grant = tokens.create_grant(token_jid, token_jid, nil, token_data);
 	end
 
 	if refresh_token_info then
@@ -284,7 +294,7 @@
 		end
 	end
 	-- in with the new refresh token
-	local refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant.id, nil, nil, "oauth2-refresh");
+	local refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant.id, nil, default_refresh_ttl, "oauth2-refresh");
 
 	if role == "xmpp" then
 		-- Special scope meaning the users default role.
@@ -390,13 +400,15 @@
 	end
 	local granted_scopes, granted_role = filter_scopes(request_username, params.scope);
 
-	if pkce_required and not params.code_challenge then
+	local redirect_uri = get_redirect_uri(client, params.redirect_uri);
+
+	if pkce_required and not params.code_challenge and redirect_uri ~= device_uri and redirect_uri ~= oob_uri then
 		return oauth_error("invalid_request", "PKCE required");
 	end
 
 	local prefix = "authorization_code:";
 	local code = id.medium();
-	if params.redirect_uri == device_uri then
+	if redirect_uri == device_uri then
 		local is_device, device_state = verify_device_token(params.state);
 		if is_device then
 			-- reconstruct the device_code
@@ -419,7 +431,6 @@
 		return oauth_error("temporarily_unavailable");
 	end
 
-	local redirect_uri = get_redirect_uri(client, params.redirect_uri);
 	if redirect_uri == oob_uri then
 		return render_page(templates.oob, { client = client; authorization_code = code }, true);
 	elseif redirect_uri == device_uri then
@@ -630,16 +641,32 @@
 		-- First step: login
 		local username = encodings.stringprep.nodeprep(form.username);
 		local password = encodings.stringprep.saslprep(form.password);
+		-- Many things hooked to authentication-{success,failure} don't expect
+		-- non-XMPP sessions so here's something close enough...
+		local auth_event = {
+			session = {
+				type = "http";
+				ip = request.ip;
+				conn = request.conn;
+				username = username;
+				host = module.host;
+				log = request.log;
+				sasl_handler = { username = username; selected = "x-www-form" };
+				client_id = request.headers.user_agent;
+			};
+		};
 		if not (username and password) or not usermanager.test_password(username, module.host, password) then
+			module:fire_event("authentication-failure", auth_event);
 			return {
 				error = "Invalid username/password";
 			};
 		end
+		module:fire_event("authentication-success", auth_event);
 		return {
 			user = {
 				username = username;
 				host = module.host;
-				token = new_user_token({ username = username; host = module.host; auth_time = os.time() });
+				token = new_user_token({ username = username; host = module.host; amr = { "pwd" } });
 			};
 		};
 	elseif form.user_token and form.consent then
@@ -729,7 +756,7 @@
 -- the redirect_uri is missing or invalid. In those cases, we render an
 -- error directly to the user-agent.
 local function error_response(request, redirect_uri, err)
-	if not redirect_uri or redirect_uri == oob_uri then
+	if not redirect_uri or redirect_uri == oob_uri or redirect_uri == device_uri then
 		return render_error(err);
 	end
 	local q = strict_formdecode(request.url.query);
@@ -738,7 +765,7 @@
 	redirect_uri = redirect_uri
 		.. sep .. http.formencode(err.extra.oauth2_response)
 		.. "&" .. http.formencode({ state = q.state, iss = get_issuer() });
-	module:log("warn", "Sending error response to client via redirect to %s", redirect_uri);
+	module:log("debug", "Sending error response to client via redirect to %s", redirect_uri);
 	return {
 		status_code = 303;
 		headers = {
@@ -751,7 +778,6 @@
 
 local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {
 	"authorization_code";
-	"password"; -- TODO Disable. The resource owner password credentials grant [RFC6749] MUST NOT be used.
 	"refresh_token";
 	device_uri;
 })
@@ -781,7 +807,7 @@
 	end
 end
 
-local allowed_challenge_methods = module:get_option_set("allowed_oauth2_code_challenge_methods", { "plain"; "S256" })
+local allowed_challenge_methods = module:get_option_set("allowed_oauth2_code_challenge_methods", { "S256" })
 for handler_type in pairs(verifier_transforms) do
 	if not allowed_challenge_methods:contains(handler_type) then
 		module:log("debug", "Challenge method %q disabled", handler_type);
@@ -859,37 +885,56 @@
 	end
 
 	-- The 'prompt' parameter from OpenID Core
-	local prompt = set.new(parse_scopes(params.prompt or "select_account login consent"));
-	if prompt:contains("none") then
-		-- Client wants no interaction, only confirmation of prior login and
-		-- consent, but this is not implemented.
-		return error_response(request, redirect_uri, oauth_error("interaction_required"));
-	elseif not prompt:contains("select_account") then
-		-- TODO If the login page is split into account selection followed by login
-		-- (e.g. password), and then the account selection could be skipped iff the
-		-- 'login_hint' parameter is present.
-		return error_response(request, redirect_uri, oauth_error("account_selection_required"));
-	elseif not prompt:contains("login") then
-		-- Currently no cookies or such are used, so login is required every time.
-		return error_response(request, redirect_uri, oauth_error("login_required"));
-	elseif not prompt:contains("consent") then
-		-- Are there any circumstances when consent would be implied or assumed?
-		return error_response(request, redirect_uri, oauth_error("consent_required"));
-	end
+	local prompt = set.new(parse_scopes(respect_prompt and params.prompt or "select_account login consent"));
 
 	local auth_state = get_auth_state(request);
 	if not auth_state.user then
+		if not prompt:contains("login") then
+			-- Currently no cookies or such are used, so login is required every time.
+			return error_response(request, redirect_uri, oauth_error("login_required"));
+		end
+
 		-- Render login page
 		local extra = {};
 		if params.login_hint then
-			extra.username_hint = (jid.prepped_split(params.login_hint));
+			extra.username_hint = (jid.prepped_split(params.login_hint) or encodings.stringprep.nodeprep(params.login_hint));
+		elseif not prompt:contains("select_account") then
+			-- TODO If the login page is split into account selection followed by login
+			-- (e.g. password), and then the account selection could be skipped iff the
+			-- 'login_hint' parameter is present.
+			return error_response(request, redirect_uri, oauth_error("account_selection_required"));
 		end
 		return render_page(templates.login, { state = auth_state; client = client; extra = extra });
 	elseif auth_state.consent == nil then
-		-- Render consent page
 		local scopes, roles = split_scopes(requested_scopes);
 		roles = user_assumable_roles(auth_state.user.username, roles);
-		return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true);
+
+		if not prompt:contains("consent") then
+			local grants = tokens.get_user_grants(auth_state.user.username);
+			local matching_grant;
+			if grants then
+				for grant_id, grant in pairs(grants) do
+					if grant.data and grant.data.oauth2_client and grant.data.oauth2_client.hash == client.client_hash then
+						if set.new(parse_scopes(grant.data.oauth2_scopes)) == set.new(scopes+roles) then
+							matching_grant = grant_id;
+							break
+						end
+					end
+				end
+			end
+
+			if not matching_grant then
+				return error_response(request, redirect_uri, oauth_error("consent_required"));
+			else
+				-- Consent for these scopes already granted to this exact client, continue
+				auth_state.scopes = scopes + roles;
+				auth_state.consent = "granted";
+			end
+
+		else
+			-- Render consent page
+			return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true);
+		end
 	elseif not auth_state.consent then
 		-- Notify client of rejection
 		if redirect_uri == device_uri then
@@ -923,8 +968,9 @@
 		iss = get_issuer();
 		sub = url.build({ scheme = "xmpp"; path = user_jid });
 		aud = params.client_id;
-		auth_time = auth_state.user.auth_time;
+		auth_time = auth_state.user.iat;
 		nonce = params.nonce;
+		amr = auth_state.user.amr;
 	});
 	local response_type = params.response_type;
 	local response_handler = response_type_handlers[response_type];
@@ -1046,15 +1092,67 @@
 	}
 end
 
+local function handle_introspection_request(event)
+	local request = event.request;
+	local credentials = get_request_credentials(request);
+	if not credentials or credentials.type ~= "basic" then
+		event.response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name);
+		return 401;
+	end
+	-- OAuth "client" credentials
+	if not verify_client_secret(credentials.username, credentials.password) then
+		return 401;
+	end
+
+	local client = check_client(credentials.username);
+	if not client then
+		return 401;
+	end
+
+	local form_data = http.formdecode(request.body or "=");
+	local token = form_data.token;
+	if not token then
+		return 400;
+	end
+
+	local token_info = tokens.get_token_info(form_data.token);
+	if not token_info then
+		return { headers = { content_type = "application/json" }; body = json.encode { active = false } };
+	end
+	local token_client = token_info.grant.data.oauth2_client;
+	if not token_client or token_client.hash ~= client.client_hash then
+		return 403;
+	end
+
+	return {
+		headers = { content_type = "application/json" };
+		body = json.encode {
+			active = true;
+			client_id = credentials.username; -- We don't really know for sure
+			username = jid.node(token_info.jid);
+			scope = token_info.grant.data.oauth2_scopes;
+			token_type = purpose_map[token_info.purpose];
+			exp = token.expires;
+			iat = token.created;
+			sub = url.build({ scheme = "xmpp"; path = token_info.jid });
+			aud = credentials.username;
+			iss = get_issuer();
+			jti = token_info.id;
+		};
+	};
+end
+
+-- RFC 7009 says that the authorization server should validate that only the client that a token was issued to should be able to revoke it. However
+-- this would prevent someone who comes across a leaked token from doing the responsible thing and revoking it, so this is not enforced by default.
 local strict_auth_revoke = module:get_option_boolean("oauth2_require_auth_revoke", false);
 
 local function handle_revocation_request(event)
 	local request, response = event.request, event.response;
 	response.headers.cache_control = "no-store";
 	response.headers.pragma = "no-cache";
-	if request.headers.authorization then
-		local credentials = get_request_credentials(request);
-		if not credentials or credentials.type ~= "basic" then
+	local credentials = get_request_credentials(request);
+	if credentials then
+		if credentials.type ~= "basic" then
 			response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name);
 			return 401;
 		end
@@ -1074,6 +1172,22 @@
 		response.headers.accept = "application/x-www-form-urlencoded";
 		return 415;
 	end
+
+	if credentials then
+		local client = check_client(credentials.username);
+		if not client then
+			return 401;
+		end
+		local token_info = tokens.get_token_info(form_data.token);
+		if not token_info then
+			return 404;
+		end
+		local token_client = token_info.grant.data.oauth2_client;
+		if not token_client or token_client.hash ~= client.client_hash then
+			return 403;
+		end
+	end
+
 	local ok, err = tokens.revoke_token(form_data.token);
 	if not ok then
 		module:log("warn", "Unable to revoke token: %s", tostring(err));
@@ -1084,6 +1198,7 @@
 
 local registration_schema = {
 	title = "OAuth 2.0 Dynamic Client Registration Protocol";
+	description = "This endpoint allows dynamically registering an OAuth 2.0 client.";
 	type = "object";
 	required = {
 		-- These are shown to users in the template
@@ -1098,16 +1213,30 @@
 			type = "array";
 			minItems = 1;
 			uniqueItems = true;
-			items = { title = "Redirect URI"; type = "string"; format = "uri" };
+			items = {
+				title = "Redirect URI";
+				type = "string";
+				format = "uri";
+				examples = {
+					"https://app.example.com/redirect";
+					"http://localhost:8080/redirect";
+					"com.example.app:/redirect";
+					oob_uri;
+					device_uri;
+				};
+			};
 		};
 		token_endpoint_auth_method = {
 			title = "Token Endpoint Authentication Method";
+			description = "Authentication method the client intends to use. Recommended is `client_secret_basic`. \z
+			`none` is only allowed for use with the insecure Implicit flow.";
 			type = "string";
 			enum = { "none"; "client_secret_post"; "client_secret_basic" };
 			default = "client_secret_basic";
 		};
 		grant_types = {
 			title = "Grant Types";
+			description = "List of grant types the client intends to use.";
 			type = "array";
 			minItems = 1;
 			uniqueItems = true;
@@ -1129,8 +1258,9 @@
 		application_type = {
 			title = "Application Type";
 			description = "Determines which kinds of redirect URIs the client may register. \z
-			The value 'web' limits the client to https:// URLs with the same hostname as in 'client_uri' \z
-			while the value 'native' allows either loopback http:// URLs or application specific URIs.";
+			The value `web` limits the client to `https://` URLs with the same hostname as \z
+			in `client_uri` while the value `native` allows either loopback URLs like \z
+			`http://localhost:8080/` or application specific URIs like `com.example.app:/redirect`.";
 			type = "string";
 			enum = { "native"; "web" };
 			default = "web";
@@ -1150,10 +1280,12 @@
 		};
 		client_uri = {
 			title = "Client URL";
-			description = "Should be an link to a page with information about the client.";
+			description = "Should be an link to a page with information about the client. \z
+			The hostname in this URL must be the same as in every other '_uri' property.";
 			type = "string";
 			format = "uri";
 			pattern = "^https:";
+			examples = { "https://app.example.com/" };
 		};
 		logo_uri = {
 			title = "Logo URL";
@@ -1161,11 +1293,13 @@
 			type = "string";
 			format = "uri";
 			pattern = "^https:";
+			examples = { "https://app.example.com/appicon.png" };
 		};
 		scope = {
 			title = "Scopes";
 			description = "Space-separated list of scopes the client promises to restrict itself to.";
 			type = "string";
+			examples = { "openid xmpp" };
 		};
 		contacts = {
 			title = "Contact Addresses";
@@ -1177,17 +1311,19 @@
 		tos_uri = {
 			title = "Terms of Service URL";
 			description = "Link to Terms of Service for the client, presented to the user in the consent dialog. \z
-			MUST be a https:// URL with hostname matching that of 'client_uri'.";
+			MUST be a `https://` URL with hostname matching that of `client_uri`.";
 			type = "string";
 			format = "uri";
 			pattern = "^https:";
+			examples = { "https://app.example.com/tos.html" };
 		};
 		policy_uri = {
 			title = "Privacy Policy URL";
-			description = "Link to a Privacy Policy for the client. MUST be a https:// URL with hostname matching that of 'client_uri'.";
+			description = "Link to a Privacy Policy for the client. MUST be a `https://` URL with hostname matching that of `client_uri`.";
 			type = "string";
 			format = "uri";
 			pattern = "^https:";
+			examples = { "https://app.example.com/policy.pdf" };
 		};
 		software_id = {
 			title = "Software ID";
@@ -1200,7 +1336,7 @@
 			description = "Version of the client software being registered. \z
 			E.g. to allow revoking all related tokens in the event of a security incident.";
 			type = "string";
-			example = "2.3.1";
+			examples = { "2.3.1" };
 		};
 	};
 }
@@ -1221,6 +1357,9 @@
 
 local function redirect_uri_allowed(redirect_uri, client_uri, app_type)
 	local uri = url.parse(redirect_uri);
+	if not uri then
+		return false;
+	end
 	if not uri.scheme then
 		return false; -- no relative URLs
 	end
@@ -1232,8 +1371,24 @@
 end
 
 function create_client(client_metadata)
-	if not schema.validate(registration_schema, client_metadata) then
-		return nil, oauth_error("invalid_request", "Failed schema validation.");
+	local valid, validation_errors = schema.validate(registration_schema, client_metadata);
+	if not valid then
+		return nil, errors.new({
+			type = "modify";
+			condition = "bad-request";
+			code = 400;
+			text = "Failed schema validation.";
+			extra = {
+				oauth2_response = {
+					error = "invalid_request";
+					error_description = "Client registration data failed schema validation."; -- TODO Generate from validation_errors?
+					-- JSON Schema Output Format
+					-- https://json-schema.org/draft/2020-12/draft-bhutton-json-schema-01#name-basic
+					valid = false;
+					errors = validation_errors;
+				};
+			};
+		});
 	end
 
 	local client_uri = url.parse(client_metadata.client_uri);
@@ -1289,6 +1444,15 @@
 		return nil, oauth_error("invalid_client_metadata", "No allowed 'response_types' specified");
 	end
 
+	if client_metadata.token_endpoint_auth_method ~= "none" then
+		-- Ensure that each client_id JWT with a client_secret is unique.
+		-- A short ID along with the issued at timestamp should be sufficient to
+		-- rule out brute force attacks.
+		-- Not needed for public clients without a secret, but those are expected
+		-- to be uncommon since they can only do the insecure implicit flow.
+		client_metadata.nonce = id.short();
+	end
+
 	-- Do we want to keep everything?
 	local client_id = sign_client(client_metadata);
 
@@ -1296,14 +1460,7 @@
 	client_metadata.client_id_issued_at = os.time();
 
 	if client_metadata.token_endpoint_auth_method ~= "none" then
-		-- Ensure that each client_id JWT with a client_secret is unique.
-		-- A short ID along with the issued at timestamp should be sufficient to
-		-- rule out brute force attacks.
-		-- Not needed for public clients without a secret, but those are expected
-		-- to be uncommon since they can only do the insecure implicit flow.
-		client_metadata.nonce = id.short();
-
-		local client_secret = make_client_secret(client_id, client_metadata);
+		local client_secret = make_client_secret(client_id);
 		client_metadata.client_secret = client_secret;
 		client_metadata.client_secret_expires_at = 0;
 
@@ -1424,6 +1581,9 @@
 		-- Step 5. Revoke token (access or refresh)
 		["POST /revoke"] = handle_revocation_request;
 
+		-- Get info about a token
+		["POST /introspect"] = handle_introspection_request;
+
 		-- OpenID
 		["GET /userinfo"] = handle_userinfo_request;
 
@@ -1432,7 +1592,7 @@
 			headers = {
 				["Content-Type"] = "text/css";
 			};
-			body = render_html(templates.css, module:get_option("oauth2_template_style"));
+			body = templates.css;
 		} or nil;
 		["GET /script.js"] = templates.js and {
 			headers = {
@@ -1445,6 +1605,7 @@
 		["GET /register"] = { headers = { content_type = "application/schema+json" }; body = json.encode(registration_schema) };
 		["GET /token"] = function() return 405; end;
 		["GET /revoke"] = function() return 405; end;
+		["GET /introspect"] = function() return 405; end;
 	};
 });
 
@@ -1481,6 +1642,8 @@
 		revocation_endpoint = handle_revocation_request and module:http_url() .. "/revoke" or nil;
 		revocation_endpoint_auth_methods_supported = array({ "client_secret_basic" });
 		device_authorization_endpoint = handle_device_authorization_request and module:http_url() .. "/device";
+		introspection_endpoint = handle_introspection_request and module:http_url() .. "/introspect";
+		introspection_endpoint_auth_methods_supported = nil;
 		code_challenge_methods_supported = array(it.keys(verifier_transforms));
 		grant_types_supported = array(it.keys(grant_type_handlers));
 		response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" });
--- a/mod_http_status/README.md	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_http_status/README.md	Tue Feb 06 18:32:01 2024 +0700
@@ -13,3 +13,19 @@
 }
 ```
 
+# Configuration
+
+
+By default only access via localhost is allowed. This can be adjusted with `http_status_allow_ips`. The following example shows the default:
+
+```
+http_status_allow_ips = { "::1"; "127.0.0.1" }
+```
+
+Access can also be granted to one IP range via CIDR notation:
+
+```
+http_status_allow_cidr = "172.17.2.0/24"
+```
+
+The default for `http_status_allow_cidr` is empty.
--- a/mod_http_status/mod_http_status.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_http_status/mod_http_status.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -2,13 +2,29 @@
 
 local json = require "util.json";
 local datetime = require "util.datetime".datetime;
+local ip = require "util.ip";
 
 local modulemanager = require "core.modulemanager";
 
+local permitted_ips = module:get_option_set("http_status_allow_ips", { "::1", "127.0.0.1" });
+local permitted_cidr = module:get_option_string("http_status_allow_cidr");
+
+local function is_permitted(request)
+	local ip_raw = request.ip;
+	if permitted_ips:contains(ip_raw) or
+	   (permitted_cidr and ip.match(ip.new_ip(ip_raw), ip.parse_cidr(permitted_cidr))) then
+		return true;
+	end
+	return false;
+end
+
 module:provides("http", {
 	route = {
 		GET = function(event)
 			local request, response = event.request, event.response;
+			if not is_permitted(request) then
+				return 403; -- Forbidden
+			end
 			response.headers.content_type = "application/json";
 
 			local resp = { ["*"] = true };
--- a/mod_invites_page/html/client.html	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_invites_page/html/client.html	Tue Feb 06 18:32:01 2024 +0700
@@ -27,7 +27,7 @@
 					your camera.
 					<button id="qr-modal-show" class="mt-2 d-block btn btn-secondary" title="Send this invite to your device"
 						data-toggle="modal" data-target="#qr-modal">
-							<img src="{static}/qr-logo.png" alt="QR code icon" class="align-middle h-50 mt-1" style="display:inline" >
+							<img src="{static}/qr-logo.png" alt="QR code icon" class="align-middle h-50 mt-1">
 							Scan with mobile device
 					</button>
 				</div>
@@ -116,15 +116,13 @@
 	<script src="/share/bootstrap4/js/bootstrap.min.js"></script>
 	<script src="{static}/qrcode.min.js"></script>
 	<script>
-		$(function () {
+		(function () {
 			// If QR lib loaded ok, show QR button on desktop devices
 			if(window.QRCode) {
-				$('#qr-modal').one('show.bs.modal', function (e) {
-					new QRCode(document.getElementById("qr-invite-page"), document.location.href);
-				});
-				$('#qr-button-container').addClass("d-md-block");
+				new QRCode(document.getElementById("qr-invite-page"), document.location.href);
+				document.getElementById('qr-button-container').classList.add("d-md-block");
 			}
-		});
+		})();
 	</script>
 </body>
 </html>
--- a/mod_invites_page/html/invite.html	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_invites_page/html/invite.html	Tue Feb 06 18:32:01 2024 +0700
@@ -105,69 +105,6 @@
 	<script src="/share/bootstrap4/js/bootstrap.min.js"></script>
 	<script src="{static}/qrcode.min.js"></script>
 	<script src="{static}/platform.min.js"></script>
-	<script>
-		$(function () {
-			// If QR lib loaded ok, show QR button on desktop devices
-			if(window.QRCode) {
-				$('#qr-modal').one('show.bs.modal', function (e) {
-					new QRCode(document.getElementById("qr-invite-page"), document.location.href);
-				});
-				$('#qr-button-container').addClass("d-md-block");
-			}
-
-			// Detect current platform and show/hide appropriate clients
-			if(window.platform) {
-				var platform_friendly = null;
-				var platform_classname = null;
-				 
-				switch(platform.os.family) {
-				case "Ubuntu":
-				case "Linux":
-				case "Fedora":
-				case "Red Hat":
-				case "SuSE":
-					platform_friendly = platform.os.family + " (Linux)";
-					platform_classname = "linux";
-					break;
-				case "Windows Phone":
-					platform_friendly = "Windows Phone";
-					platform_classname = "windows-phone";
-					break;
-				default:
-					if(platform.os.family.startsWith("Windows")) {
-						platform_friendly = "Windows";
-						platform_classname = "windows";
-					} else {
-						platform_friendly = platform.os.family;
-						platform_classname = platform_friendly.toLowerCase();
-					}
-				}
-
-				if(platform_friendly && platform_classname) {
-					if($('.client-card .client-platform-badge-'+platform_classname).length == 0) {
-						// No clients recognised for this platform, do nothing
-						return;
-					}
-					// Hide clients not for this platform
-					$('.client-card.app-platform-'+platform_classname).addClass("supported-platform");
-					$('.client-card').not(".supported-platform, .app-platform-web").hide();
-					$('.client-card .client-platform-badge')
-						.not(".client-platform-badge-"+platform_classname)
-							.addClass("badge-secondary")
-							.removeClass("badge-info");
-					$('.client-card .client-platform-badge-'+platform_classname)
-						.addClass("badge-success")
-						.removeClass("badge-info");
-					$('#show-all-clients-button-container .platform-name').text(platform_friendly);
-					$('#show-all-clients-button-container').removeClass("d-none");
-					$('#show-all-clients-button').click(function () {
-						$('.client-card').show();
-						$('#show-all-clients-button-container').hide();
-					});
-				}
-			}
-		});
-			
-	</script>
+	<script src="{static}/invite.js"></script>
 </body>
 </html>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_invites_page/static/invite.js	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,78 @@
+(function () {
+	// If QR lib loaded ok, show QR button on desktop devices
+	if(window.QRCode) {
+		new QRCode(document.getElementById("qr-invite-page"), document.location.href);
+		document.getElementById('qr-button-container').classList.add("d-md-block");
+	}
+
+	// Detect current platform and show/hide appropriate clients
+	if(window.platform) {
+		let platform_friendly = null;
+		let platform_classname = null;
+
+		switch(platform.os.family) {
+		case "Ubuntu":
+		case "Linux":
+		case "Fedora":
+		case "Red Hat":
+		case "SuSE":
+			platform_friendly = platform.os.family + " (Linux)";
+			platform_classname = "linux";
+			break;
+		case "Linux aarch64":
+			platform_friendly = "Linux mobile";
+			platform_classname = "linux";
+			break;
+		case "Haiku R1":
+			platform_friendly = "Haiku";
+			platform_classname = "haiku";
+			break;
+		case "Windows Phone":
+			platform_friendly = "Windows Phone";
+			platform_classname = "windows-phone";
+			break;
+		default:
+			if(platform.os.family.startsWith("Windows")) {
+				platform_friendly = "Windows";
+				platform_classname = "windows";
+			} else {
+				platform_friendly = platform.os.family;
+				platform_classname = platform_friendly.toLowerCase();
+			}
+		}
+
+		if(platform_friendly && platform_classname) {
+			if(document.querySelectorAll('.client-card .client-platform-badge-'+platform_classname).length == 0) {
+				// No clients recognised for this platform, do nothing
+				return;
+			}
+			// Hide clients not for this platform
+			const client_cards = document.getElementsByClassName('client-card');
+			for (let card of client_cards) {
+				if (card.classList.contains('app-platform-'+platform_classname))
+					card.classList.add('supported-platform');
+				else if (!card.classList.contains('app-platform-web'))
+					card.hidden = true;
+				const badges = card.querySelectorAll('.client-platform-badge');
+				for (let badge of badges) {
+					if (badge.classList.contains('client-platform-badge-'+platform_classname)) {
+						badge.classList.add("badge-success");
+						badge.classList.remove("badge-info");
+					} else {
+						badge.classList.add("badge-secondary");
+						badge.classList.remove("badge-info");
+					}
+				}
+			}
+			const show_all_clients_button_container = document.getElementById('show-all-clients-button-container');
+			show_all_clients_button_container.querySelector('.platform-name').innerHTML = platform_friendly;
+			show_all_clients_button_container.classList.remove("d-none");
+			document.getElementById('show-all-clients-button').addEventListener('click', function (e) {
+				for (let card of client_cards)
+					card.hidden = false;
+				show_all_clients_button_container.hidden = true;
+				e.preventDefault();
+			});
+		}
+	}
+})();
--- a/mod_isolate_host/mod_isolate_host.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_isolate_host/mod_isolate_host.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -58,7 +58,7 @@
 	if not is_jid_isolated(bare_jid) then
 		session.no_host_isolation = true;
 	end
-	module:log("debug", "%s is %sisolated", session.full_jid or "[?]", session.no_host_isolation and "" or "not ");
+	module:log("debug", "%s is %sisolated", session.full_jid or "[?]", session.no_host_isolation and "not " or "");
 end
 
 module:hook("resource-bind", set_session_isolation_flag);
--- a/mod_lastlog2/mod_lastlog2.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_lastlog2/mod_lastlog2.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -47,6 +47,26 @@
 	end);
 end
 
+do
+	local user_sessions = prosody.hosts[module.host].sessions;
+	local kv_store = module:open_store();
+	function get_last_active(username) --luacheck: ignore 131/get_last_active
+		if user_sessions[username] then
+			return os.time(); -- Currently connected
+		else
+			local last_activity = kv_store:get(username);
+			if not last_activity then return nil; end
+			local last_login = last_activity.login;
+			local last_logout = last_activity.logout;
+			local latest = math.max(last_login and last_login.timestamp or 0, last_logout and last_logout.timestamp or 0);
+			if latest == 0 then
+				return nil; -- Never logged in
+			end
+			return latest;
+		end
+	end
+end
+
 function module.command(arg)
 	if not arg[1] or arg[1] == "--help" then
 		require"util.prosodyctl".show_usage([[mod_lastlog <user@host>]], [[Show when user last logged in or out]]);
--- a/mod_log_sasl_mech/mod_log_sasl_mech.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_log_sasl_mech/mod_log_sasl_mech.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -1,6 +1,7 @@
 
 module:hook("authentication-success", function (event)
 	local session = event.session;
-	local sasl_handler = session.sasl_handler;
-	session.log("info", "Authenticated with %s", sasl_handler and sasl_handler.selected or "legacy auth");
+	local sasl_handler = session and session.sasl_handler;
+	local log = session and session.log or module._log
+	log("info", "Authenticated with %s", sasl_handler and sasl_handler.selected or "legacy auth");
 end);
--- a/mod_measure_active_users/mod_measure_active_users.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_measure_active_users/mod_measure_active_users.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -4,9 +4,17 @@
 local measure_d7 = module:measure("active_users_7d", "amount");
 local measure_d30 = module:measure("active_users_30d", "amount");
 
+local is_enabled = require "core.usermanager".user_is_enabled;
+
+-- Exclude disabled user accounts from the counts if usermanager supports that API
+local count_disabled = module:get_option_boolean("measure_active_users_count_disabled", is_enabled == nil);
+
+local get_last_active = module:depends("lastlog2").get_last_active;
+
 function update_calculations()
 	module:log("debug", "Calculating active users");
-	local host_user_sessions = prosody.hosts[module.host].sessions;
+	local host = module.host;
+	local host_user_sessions = prosody.hosts[host].sessions;
 	local active_d1, active_d7, active_d30 = 0, 0, 0;
 	local now = os.time();
 	for username in store:users() do
@@ -14,16 +22,9 @@
 			-- Active now
 			active_d1, active_d7, active_d30 =
 				active_d1 + 1, active_d7 + 1, active_d30 + 1;
-		else
-			local lastlog_data = store:get(username);
-			if lastlog_data then
-				-- Due to server restarts/crashes/etc. some events
-				-- may not always get recorded, so we'll just take the
-				-- latest as a sign of last activity
-				local last_active = math.max(
-					lastlog_data.login and lastlog_data.login.timestamp or 0,
-					lastlog_data.logout and lastlog_data.logout.timestamp or 0
-				);
+		elseif count_disabled or is_enabled(username, host) then
+			local last_active = get_last_active(username);
+			if last_active then
 				if now - last_active < 86400 then
 					active_d1 = active_d1 + 1;
 				end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_measure_modules/README.md	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,56 @@
+# Introduction
+
+This module reports [module status priorities][doc:developers:moduleapi#logging-and-status] as metrics, which are a kind of persistent log messages
+indicating whether the module is functioning properly.
+
+This concept was introduced in [Prosody 0.12.0][doc:release:0.12.0#api] and is not used extensively yet, primarily for reporting failure to load
+modules or e.g. [mod_component] not being connected to its external component yet.
+
+Besides using this to report problems, this metric could also be used to count how many modules are loaded or monitor for when critical modules aren't
+loaded at all.
+
+# Configuration
+
+After installing, enable by adding to [`modules_enabled`][doc:modules_enabled] like many other modules:
+
+``` lua
+-- in the global section
+modules_enabled = {
+    -- Other globally enabled modules here...
+    "http_openmetrics";
+    "measure_modules"; -- add
+}
+```
+
+# Example OpenMetrics
+
+``` openmetrics
+# HELP prosody_module_status Prosody module status
+# UNIT prosody_module_status
+# TYPE prosody_module_status gauge
+prosody_module_status{host="example.org",module="message"} 0
+prosody_module_status{host="example.org",module="presence"} 0
+prosody_module_status{host="groups.example.org",module="muc"} 0
+```
+
+# Details
+
+The priorities are reported as the following values:
+
+0
+:   `core` - no problem, nothing to report
+
+1
+:   `info` - no problem, but a module had something important to say
+
+2
+:   `warn` - something is not right
+
+3
+:   `error` - something has gone wrong
+
+Status changes are generally also reported in Prosodys logs, so look there for details.
+
+# See also
+
+- [mod_http_status] provides all module status details as JSON via HTTP
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_measure_modules/mod_measure_modules.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,33 @@
+module:set_global();
+
+local mm = require "core.modulemanager";
+local sm = require "core.statsmanager";
+
+local measure_status = sm.metric("gauge", "prosody_module_status", "", "Prosody module status", { "host"; "module" });
+
+local status_priorities = { error = 3; warn = 2; info = 1; core = 0 };
+
+function module.add_host(module)
+	local measure = measure_status:with_partial_label(module.host);
+
+	if module.global then
+		measure = measure_status:with_partial_label(":global");
+	end
+
+	-- Already loaded modules
+	local modules = mm.get_modules(module.host);
+	for name, mod in pairs(modules) do
+		measure:with_labels(name):set(status_priorities[mod.module.status_type] or 0);
+	end
+
+	-- TODO hook module load and unload
+
+	-- Future changes
+	module:hook("module-status/updated", function(event)
+		local mod = mm.get_module(event.name);
+		measure:with_labels(event.name):set(status_priorities[mod and mod.module.status_type] or 0);
+	end);
+
+end
+
+module:add_host(); -- Initialize global context
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_muc_adhoc_bots/README.markdown	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,22 @@
+---
+labels:
+- Stage-Alpha
+summary: Install adhoc command bots in MUCs
+---
+
+# Introduction
+
+This module allows you to "install" bots on a MUC service (via config for
+now, via adhoc command and on just one MUC to follow). All the adhoc commands
+defined on the bot become adhoc commands on the service's MUCs, and the bots
+can send XEP-0356 messages to the MUC to send messages as any participant.
+
+# Configuration
+
+List all bots to install. You must specify full JID.
+
+    adhoc_bots = { "some@bot.example.com/bot" }
+	
+And enable the module on the MUC service as usual
+
+    modules_enabled = { "muc_adhoc_bots" }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_muc_adhoc_bots/mod_muc_adhoc_bots.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,122 @@
+local jid = require "util.jid";
+local json = require "util.json";
+local promise = require "util.promise";
+local st = require "util.stanza";
+local uuid = require "util.uuid";
+
+local xmlns_cmd = "http://jabber.org/protocol/commands";
+
+module:hook("muc-disco#info", function(event)
+	event.reply:tag("feature", {var = xmlns_cmd}):up();
+end);
+
+module:hook("iq-get/bare/http://jabber.org/protocol/disco#items:query", function (event)
+	local room = prosody.hosts[module:get_host()].modules.muc.get_room_from_jid(event.stanza.attr.to);
+	local occupant = room:get_occupant_by_real_jid(event.stanza.attr.from)
+	if event.stanza.tags[1].attr.node ~= xmlns_cmd or not occupant then
+		return
+	end
+
+	local bots = module:get_option_array("adhoc_bots", {})
+	bots:map(function(bot)
+		return module:send_iq(
+			st.iq({ type = "get", id = uuid.generate(), to = bot, from = room:get_occupant_jid(event.stanza.attr.from) })
+				:tag("query", { xmlns = "http://jabber.org/protocol/disco#items", node = xmlns_cmd }):up(),
+			nil,
+			5
+		)
+	end)
+
+	promise.all_settled(bots):next(function (bot_commands)
+		local reply = st.reply(event.stanza):query("http://jabber.org/protocol/disco#items")
+		for i, one_bot_reply in ipairs(bot_commands) do
+			if one_bot_reply.status == "fulfilled"	then
+			local query = one_bot_reply.value.stanza:get_child("query", "http://jabber.org/protocol/disco#items")
+				if query then
+					-- Should use query:childtags("item") but it doesn't work
+					for j,item in ipairs(query.tags) do
+						item.attr.node = json.encode({ jid = item.attr.jid, node = item.attr.node })
+						item.attr.jid = event.stanza.attr.to
+						reply:add_child(item):up()
+					end
+				end
+			end
+		end
+		event.origin.send(reply:up())
+	end):catch(function (e)
+		module:log("error", e)
+	end)
+
+	return true;
+end, 500);
+
+local function is_adhoc_bot(jid)
+	for i, bot_jid in ipairs(module:get_option_array("adhoc_bots", {})) do
+		if jid == bot_jid then
+			return true
+		end
+	end
+
+	return false
+end
+
+module:hook("iq-set/bare/"..xmlns_cmd..":command", function (event)
+	local origin, stanza = event.origin, event.stanza;
+	local node = stanza.tags[1].attr.node
+	local meta = json.decode(node)
+	local room = prosody.hosts[module:get_host()].modules.muc.get_room_from_jid(stanza.attr.to);
+	local occupant = room:get_occupant_by_real_jid(event.stanza.attr.from)
+	if meta and occupant and is_adhoc_bot(meta.jid) then
+		local fwd = st.clone(stanza)
+		fwd.attr.to = meta.jid
+		fwd.attr.from = room:get_occupant_jid(event.stanza.attr.from)
+		local command = fwd:get_child("command", "http://jabber.org/protocol/commands")
+		command.attr.node = meta.node
+		module:send_iq(fwd):next(function(response)
+			local response_command = response.stanza:get_child("command", "http://jabber.org/protocol/commands")
+			response.stanza.attr.from = stanza.attr.to
+			response.stanza.attr.to = stanza.attr.from
+			response_command.attr.node = node
+			origin.send(response.stanza)
+		end):catch(function (e)
+			module:log("error", e)
+		end)
+
+		return true
+	end
+
+	return
+end, 500);
+
+local function clean_xmlns(node)
+		-- Recursively remove "jabber:client" attribute from node.
+		-- In Prosody internal routing, xmlns should not be set.
+		-- Keeping xmlns would lead to issues like mod_smacks ignoring the outgoing stanza,
+		-- so we remove all xmlns attributes with a value of "jabber:client"
+		if node.attr.xmlns == 'jabber:client' then
+				for childnode in node:childtags() do
+						clean_xmlns(childnode)
+				end
+				node.attr.xmlns = nil
+		end
+end
+
+module:hook("message/bare", function (event)
+	local origin, stanza = event.origin, event.stanza;
+	if not is_adhoc_bot(stanza.attr.from) then return; end
+	local room = prosody.hosts[module:get_host()].modules.muc.get_room_from_jid(stanza.attr.to);
+	if room == nil then return; end
+	local privilege = stanza:get_child("privilege", "urn:xmpp:privilege:2")
+	if privilege == nil then return; end
+	local fwd = privilege:get_child("forwarded", "urn:xmpp:forward:0")
+	if fwd == nil then return; end
+	local message = fwd:get_child("message", "jabber:client")
+	if message == nil then return; end
+	if message.attr.to ~= stanza.attr.to or jid.bare(message.attr.from) ~= stanza.attr.to then
+		return
+	end
+
+	clean_xmlns(message)
+	room:broadcast_message(message)
+	return true
+end)
--- a/mod_muc_members_json/README.md	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_muc_members_json/README.md	Tue Feb 06 18:32:01 2024 +0700
@@ -38,13 +38,26 @@
 The `muc_members_json_mucs` setting determines which rooms will be managed by
 the plugin, and how to map roles to hats (if desired).
 
-```
+``` lua
 muc_members_json_mucs = {
+	-- This configures hats for the myroom@<this MUC host> MUC
 	myroom = {
+		-- The optional field 'member_hat' defines a hat that will be
+		-- added to any user that is listed in the members JSON
+		-- (regardless of what roles they have, if any)
 		member_hat = {
 			id = "urn:uuid:6a1b143a-1c5c-11ee-80aa-4ff1ce4867dc";
 			title = "Cool Member";
 		};
+		-- The optional field 'team_hats' defines one or more hats
+		-- that will be assigned to users that have the specified
+		-- roles in the JSON.
+		team_hats = {
+			janitor = {
+				id = "urn:uuid:ec32f550-7d5f-11ee-81ee-6b139cac3bf6";
+				title = "Janitor";
+			}
+		}
 	};
 }
 ```
@@ -52,24 +65,27 @@
 JSON format
 ===========
 
-```
+``` json
 {
   "members": [
     {
-      "jids": ["user@example.com"]
-    },
-    {
-      "jids": ["user2@example.com"]
+      "jids": [
+        "user@example.com",
+        "user2@example.com"
+      ]
     },
     {
       "jids": ["user3@example.com"],
-      roles: ["janitor"]
+      "roles": ["janitor"]
     }
   ]
 }
 ```
 
-Each member must have a `jids` field, and optionally a `roles` field.
+The JSON format must be an object with a `members` array.
+
+Each member must have a `jids` field, and optionally a `roles` field (both are
+arrays of strings).
 
 Compatibility
 =============
--- a/mod_muc_members_json/mod_muc_members_json.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_muc_members_json/mod_muc_members_json.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -80,7 +80,7 @@
 					end
 					-- Remove affiliation from folk who weren't in the source data but previously were
 					for jid, aff, data in muc:each_affiliation() do
-						if not jids[jid] and data.source == module.name then
+						if not jids[jid] and data and data.source == module.name then
 							muc:set_affiliation(true, jid, "none", "imported membership lost");
 						end
 					end
--- a/mod_muc_moderation/README.markdown	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_muc_moderation/README.markdown	Tue Feb 06 18:32:01 2024 +0700
@@ -28,6 +28,7 @@
 -   Basic functionality with Prosody 0.11.x and later
 -   Full functionality with Prosody 0.12.x and `internal` or `sql`
     storage^[Replacing moderated messages with tombstones requires new storage API methods.]
+-   Works with [mod_storage_xmlarchive]
 
 ## Clients
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_muc_restrict_avatars/mod_muc_restrict_avatars.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,19 @@
+local bare_jid = require"util.jid".bare;
+local mod_muc = module:depends("muc");
+
+local function filter_avatar_advertisement(tag)
+	if tag.attr.xmlns == "vcard-temp:x:update" then
+		return nil;
+	end
+
+	return tag;
+end
+
+module:hook("presence/full", function(event)
+	local stanza = event.stanza;
+	local room = mod_muc.get_room_from_jid(bare_jid(stanza.attr.to));
+
+	if not room:get_affiliation(stanza.attr.from) then
+		stanza:maptags(filter_avatar_advertisement);
+	end
+end, 1);
--- a/mod_password_policy/mod_password_policy.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_password_policy/mod_password_policy.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -100,7 +100,7 @@
 				local pw_ok, pw_err, pw_failed_policy = check_password(password, additional_info);
 				if not pw_ok then
 					module:log("debug", "Password failed check against '%s' policy", pw_failed_policy);
-					origin.send(st.error_reply(stanza, "cancel", "not-acceptable", pw_err));
+					origin.send(st.error_reply(stanza, "modify", "not-acceptable", pw_err));
 					return true;
 				end
 			end
--- a/mod_poke_strangers/mod_poke_strangers.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_poke_strangers/mod_poke_strangers.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -11,12 +11,12 @@
 local disco_id = uuid_generate();
 
 module:hook("iq-result/host/" .. version_id, function (event)
-	module:log("info", "Stranger " .. event.stanza.attr.from .. " version: " .. tostring(event.stanza));
+	module:log("info", "Stranger <%s> version: %s", event.stanza.attr.from, event.stanza);
 	return true;
 end);
 
 module:hook("iq-result/host/" .. disco_id, function (event)
-	module:log("info", "Stranger " .. event.stanza.attr.from .. " disco: " .. tostring(event.stanza));
+	module:log("info", "Stranger <%s> disco: %s", event.stanza.attr.from, event.stanza);
 	return true;
 end);
 
@@ -27,7 +27,7 @@
 	local stranger_jid = stanza.attr.from;
 
 	if recently_queried:contains(stranger_jid) then
-		module:log("debug", "Not re-poking " .. stranger_jid);
+		module:log("debug", "Not re-poking <%s>", stranger_jid);
 		return nil;
 	end
 
--- a/mod_pubsub_mqtt/mod_pubsub_mqtt.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_pubsub_mqtt/mod_pubsub_mqtt.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -166,6 +166,13 @@
 	listener = mqtt_listener;
 });
 
+module:provides("net", {
+	name = "pubsub_mqtt_tls";
+	encryption = "ssl";
+	default_port = 8883;
+	listener = mqtt_listener;
+});
+
 function module.add_host(module)
 	local pubsub_module = hosts[module.host].modules.pubsub
 	if pubsub_module then
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_pubsub_serverinfo/README.markdown	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,59 @@
+---
+labels:
+- 'Statistics'
+...
+
+Exposes server information over Pub/Sub per ProtoXEP: PubSub Server Information.
+
+The module announces support (used to 'opt-in', per the XEP) and publishes the name of the local domain via a Pub/Sub node. The published data
+will contain a 'remote-domain' element for inbound and outgoing s2s connections. These elements will be named only when the remote domain announces
+support ('opts in') too.
+
+Installation
+============
+
+Enable this module in the global or a virtual host.
+
+The default configuration requires the existence of a Pub/Sub component that uses the 'pubsub' subdomain of the host in which the module is enabled:
+
+    Component "pubsub.example.org" "pubsub"
+
+The module will create a node and publish data, using a JID that matches the XMPP domain name of the host. Ensure that this actor is an admin of the
+Pub/Sub service:
+
+    admins = { "example.org" }
+
+Configuration
+=============
+
+The Pub/Sub service on which data is published, by default, is a component addressed as the `pubsub` subdomain of the domain of the virtual host that
+the module is loaded under. To change this, apply this configuration setting:
+
+    pubsub_serverinfo_service = "anotherpubsub.example.org"
+
+The Pub/Sub node on which data is published is, by default, a leaf-node named `serverinfo`. To change this, apply this configuration setting:
+
+    pubsub_serverinfo_node = "foobar"
+
+To prevent a surplus of event notifications, this module will only publish new data after a certain period of time has expired. The default duration
+is 300 seconds (5 minutes). To change this simply put in the config:
+
+    pubsub_serverinfo_publication_interval = 180 -- or any other number of seconds
+
+To detect if remote domains allow their domain name to be included in the data that this module publishes, this module will perform a service
+discovery request to each remote domain. To prevent a continuous flood of disco/info requests, the response to these requests is cached. By default,
+a cached value will remain in cache for one hour. This duration can be modified by adding this configuration option:
+
+    pubsub_serverinfo_cache_ttl = 1800 -- or any other number of seconds
+
+Compatibility
+=============
+
+Incompatible with 0.11 or lower.
+
+Known Issues / TODOs
+====================
+
+The reported data does not contain the optional 'connection' child elements. These can be used to describe the direction of a connection.
+
+More generic server information (eg: user counts, software version) should be included in the data that is published.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_pubsub_serverinfo/mod_pubsub_serverinfo.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,325 @@
+local http = require "net.http";
+local json = require "util.json";
+local st = require "util.stanza";
+local new_id = require"util.id".medium;
+local dataform = require "util.dataforms".new;
+
+local local_domain = module:get_host();
+local service = module:get_option(module.name .. "_service") or "pubsub." .. local_domain;
+local node = module:get_option(module.name .. "_node") or "serverinfo";
+local actor = module.host .. "/modules/" .. module.name;
+local publication_interval = module:get_option(module.name .. "_publication_interval") or 300;
+local cache_ttl = module:get_option(module.name .. "_cache_ttl") or 3600;
+local public_providers_url = module:get_option_string(module.name.."_public_providers_url", "https://data.xmpp.net/providers/v2/providers-Ds.json");
+local delete_node_on_unload = module:get_option_boolean(module.name.."_delete_node_on_unload", false);
+local persist_items = module:get_option_boolean(module.name.."_persist_items", true);
+
+local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
+
+function module.load()
+	discover_node():next(
+		function(exists)
+			if not exists then create_node() end
+		end
+	):catch(
+		function(error)
+			module:log("warn", "Error prevented discovery or creation of pub/sub node at %s: %s", service, error)
+		end
+	)
+
+	module:add_feature("urn:xmpp:serverinfo:0");
+
+	module:add_extension(dataform {
+		{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/network/serverinfo" },
+		{ name = "serverinfo-pubsub-node", type = "text-single" },
+	}:form({ ["serverinfo-pubsub-node"] = ("xmpp:%s?;node=%s"):format(service, node) }, "result"));
+
+	if cache_ttl < publication_interval then
+		module:log("warn", "It is recommended to have a cache interval higher than the publication interval");
+	end
+
+	cache_warm_up()
+	module:add_timer(10, publish_serverinfo);
+end
+
+function module.unload()
+	-- This removes all subscribers, which may or may not be desirable, depending on the reason for the unload.
+	if delete_node_on_unload then
+		delete_node();
+	end
+end
+
+-- Returns a promise of a boolean
+function discover_node()
+	local request = st.iq({ type = "get", to = service, from = actor, id = new_id() })
+		:tag("query", { xmlns = "http://jabber.org/protocol/disco#items" })
+
+	module:log("debug", "Sending request to discover existence of pub/sub node '%s' at %s", node, service)
+	return module:send_iq(request):next(
+		function(response)
+			if response.stanza == nil or response.stanza.attr.type ~= "result" then
+				module:log("warn", "Unexpected response to service discovery items request at %s: %s", service, response.stanza)
+				return false
+			end
+
+			local query = response.stanza:get_child("query", "http://jabber.org/protocol/disco#items")
+			if query ~= nil then
+				for item in query:childtags("item") do
+					if item.attr.jid == service and item.attr.node == node then
+						module:log("debug", "pub/sub node '%s' at %s does exist.", node, service)
+						return true
+					end
+				end
+			end
+			module:log("debug", "pub/sub node '%s' at %s does not exist.", node, service)
+			return false;
+		end
+	);
+end
+
+-- Returns a promise of a boolean
+function create_node()
+	local request = st.iq({ type = "set", to = service, from = actor, id = new_id() })
+		:tag("pubsub", { xmlns = xmlns_pubsub })
+			:tag("create", { node = node, xmlns = xmlns_pubsub }):up()
+			:tag("configure", { xmlns = xmlns_pubsub })
+				:tag("x", { xmlns = "jabber:x:data", type = "submit" })
+					:tag("field", { var = "FORM_TYPE", type = "hidden"})
+						:text_tag("value", "http://jabber.org/protocol/pubsub#node_config")
+						:up()
+					:tag("field", { var = "pubsub#max_items" })
+						:text_tag("value", "1")
+						:up()
+					:tag("field", { var = "pubsub#persist_items" })
+						:text_tag("value", persist_items and "1" or "0")
+
+	module:log("debug", "Sending request to create pub/sub node '%s' at %s", node, service)
+	return module:send_iq(request):next(
+		function(response)
+			if response.stanza == nil or response.stanza.attr.type ~= "result" then
+				module:log("warn", "Unexpected response to pub/sub node '%s' creation request at %s: %s", node, service, response.stanza)
+				return false
+			else
+				module:log("debug", "Successfully created pub/sub node '%s' at %s", node, service)
+				return true
+			end
+		end
+	)
+end
+
+-- Returns a promise of a boolean
+function delete_node()
+	local request = st.iq({ type = "set", to = service, from = actor, id = new_id() })
+		:tag("pubsub", { xmlns = xmlns_pubsub })
+			:tag("delete", { node = node, xmlns = xmlns_pubsub });
+
+	module:log("debug", "Sending request to delete pub/sub node '%s' at %s", node, service)
+	return module:send_iq(request):next(
+		function(response)
+			if response.stanza == nil or response.stanza.attr.type ~= "result" then
+				module:log("warn", "Unexpected response to pub/sub node '%s' deletion request at %s: %s", node, service, response.stanza)
+				return false
+			else
+				module:log("debug", "Successfully deleted pub/sub node '%s' at %s", node, service)
+				return true
+			end
+		end
+	)
+end
+
+function get_remote_domain_names()
+	-- Iterate over s2s sessions, adding them to a multimap, where the key is the local domain name,
+	-- mapped to a collection of remote domain names. De-duplicate all remote domain names by using
+	-- them as an index in a table.
+	local domains_by_host = {}
+	for session, _ in pairs(prosody.incoming_s2s) do
+		if session ~= nil and session.from_host ~= nil and local_domain == session.to_host then
+			module:log("debug", "Local host '%s' has remote '%s' (inbound)", session.to_host, session.from_host);
+			local sessions = domains_by_host[session.to_host]
+			if sessions == nil then sessions = {} end; -- instantiate a new entry if none existed
+			sessions[session.from_host] = true
+			domains_by_host[session.to_host] = sessions
+		end
+	end
+
+	-- At an earlier stage, the code iterated over all prosody.hosts, trying to generate one pubsub item for all local hosts. That turned out to be
+	-- to noisy. Instead, this code now creates an item that includes the local vhost only. It is assumed that this module will also be loaded for
+	-- other vhosts. Their data should then be published to distinct pub/sub services and nodes.
+
+	-- for host, data in pairs(prosody.hosts) do
+	local host = local_domain
+	local data = prosody.hosts[host]
+	if data ~= nil then
+		local sessions = domains_by_host[host]
+		if sessions == nil then sessions = {} end; -- instantiate a new entry if none existed
+		if data.s2sout ~= nil then
+			for _, session in pairs(data.s2sout) do
+				if session.to_host ~= nil then
+					module:log("debug", "Local host '%s' has remote '%s' (outbound)", host, session.to_host);
+					sessions[session.to_host] = true
+					domains_by_host[host] = sessions
+				end
+			end
+		end
+
+		-- When the instance of Prosody hosts more than one host, the other hosts can be thought of as having a 'permanent' s2s connection.
+		for host_name, host_info in pairs(prosody.hosts) do
+			if host ~= host_name and host_info.type ~= "component" then
+				module:log("debug", "Local host '%s' has remote '%s' (vhost)", host, host_name);
+				sessions[host_name] = true;
+				domains_by_host[host] = sessions
+			end
+		end
+	end
+
+	return domains_by_host
+end
+
+function publish_serverinfo()
+	module:log("debug", "Publishing server info...");
+	local domains_by_host = get_remote_domain_names()
+
+	-- Build the publication stanza.
+	local request = st.iq({ type = "set", to = service, from = actor, id = new_id() })
+		:tag("pubsub", { xmlns = xmlns_pubsub })
+			:tag("publish", { node = node, xmlns = xmlns_pubsub })
+				:tag("item", { id = "current", xmlns = xmlns_pubsub })
+					:tag("serverinfo", { xmlns = "urn:xmpp:serverinfo:0" })
+
+	request:tag("domain", { name = local_domain })
+		:tag("federation")
+
+	local remotes = domains_by_host[local_domain]
+
+	if remotes ~= nil then
+		for remote, _ in pairs(remotes) do
+			-- include a domain name for remote domains, but only if they advertise support.
+			if does_opt_in(remote) then
+				request:tag("remote-domain", { name = remote }):up()
+			else
+				request:tag("remote-domain"):up()
+			end
+		end
+	end
+
+	request:up():up()
+
+	module:send_iq(request):next(
+		function(response)
+			if response.stanza == nil or response.stanza.attr.type ~= "result" then
+				module:log("warn", "Unexpected response to item publication at pub/sub node '%s' on %s: %s", node, service, response.stanza)
+				return false
+			else
+				module:log("debug", "Successfully published item on pub/sub node '%s' at %s", node, service)
+				return true
+			end
+		end,
+		function(error)
+			module:log("warn", "Error prevented publication of item on pub/sub node at %s: %s", service, error)
+		end
+	)
+
+	return publication_interval;
+end
+
+local opt_in_cache = {}
+
+-- Public providers are already public, so we fetch the list of providers
+-- registered on providers.xmpp.net so we don't have to disco them individually
+local function update_public_providers()
+	return http.request(public_providers_url)
+		:next(function (response)
+			assert(
+				response.headers["content-type"] == "application/json",
+				"invalid mimetype: "..tostring(response.headers["content-type"])
+			);
+			return json.decode(response.body);
+		end)
+		:next(function (public_server_domains)
+			module:log("debug", "Retrieved list of %d public providers", #public_server_domains);
+			for _, domain in ipairs(public_server_domains) do
+				opt_in_cache[domain] = {
+					opt_in = true;
+					expires = os.time() + (86400 * 1.5);
+				};
+			end
+		end, function (err)
+			module:log("warn", "Failed to fetch/decode provider list: %s", err);
+		end);
+end
+
+module:daily("update public provider list", update_public_providers);
+
+function cache_warm_up()
+	module:log("debug", "Warming up opt-in cache")
+
+	update_public_providers():finally(function ()
+		module:log("debug", "Querying known domains for opt-in cache...");
+		local domains_by_host = get_remote_domain_names()
+		local remotes = domains_by_host[local_domain]
+		if remotes ~= nil then
+			for remote in pairs(remotes) do
+				does_opt_in(remote)
+			end
+		end
+	end);
+end
+
+function does_opt_in(remoteDomain)
+
+	-- try to read answer from cache.
+	local cached_value = opt_in_cache[remoteDomain]
+	local ttl = cached_value and os.difftime(cached_value.expires, os.time());
+	if cached_value and ttl > (publication_interval + 60) then
+		module:log("debug", "Opt-in status (from cache) for '%s': %s", remoteDomain, cached_value.opt_in)
+		return cached_value.opt_in;
+	end
+
+	-- We don't have a cached value, or it is nearing expiration - refresh it now
+	-- TODO worry about not having multiple requests in flight to the same domain.cached_value
+
+	module:log("debug", "%s: performing disco/info to determine opt-in", remoteDomain)
+	local discoRequest = st.iq({ type = "get", to = remoteDomain, from = actor, id = new_id() })
+		:tag("query", { xmlns = "http://jabber.org/protocol/disco#info" })
+
+	module:send_iq(discoRequest):next(
+		function(response)
+			if response.stanza ~= nil and response.stanza.attr.type == "result" then
+				local query = response.stanza:get_child("query", "http://jabber.org/protocol/disco#info")
+				if query ~= nil then
+					for feature in query:childtags("feature") do
+						--module:log("debug", "Disco/info feature for '%s': %s", remoteDomain, feature)
+						if feature.attr.var == 'urn:xmpp:serverinfo:0' then
+							module:log("debug", "Disco/info response included opt-in for '%s'", remoteDomain)
+							opt_in_cache[remoteDomain] = {
+								opt_in = true;
+								expires = os.time() + cache_ttl;
+							}
+							return; -- prevent 'false' to be cached, down below.
+						end
+					end
+				end
+			end
+			module:log("debug", "Disco/info response did not include opt-in for '%s'", remoteDomain)
+			opt_in_cache[remoteDomain] = {
+				opt_in = false;
+				expires = os.time() + cache_ttl;
+			}
+		end,
+		function(response)
+			module:log("debug", "An error occurred while performing a disco/info request to determine opt-in for '%s'", remoteDomain, response)
+			opt_in_cache[remoteDomain] = {
+				opt_in = false;
+				expires = os.time() + cache_ttl;
+			}
+		end
+	);
+
+	if ttl and ttl <= 0 then
+		-- Cache entry expired, remove it and assume not opted in
+		opt_in_cache[remoteDomain] = nil;
+		return false;
+	end
+
+	return cached_value and cached_value.opt_in;
+end
--- a/mod_pubsub_subscription/mod_pubsub_subscription.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_pubsub_subscription/mod_pubsub_subscription.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -12,7 +12,7 @@
 
 local pending_subscription = cache.new(256); -- uuid → node
 local pending_unsubscription = cache.new(256); -- uuid → node
-local active_subscriptions = mt.new() -- service | node | uuid | { item }
+local active_subscriptions = mt.new() -- service | node | subscriber | uuid | { item }
 function module.save()
 	return { active_subscriptions = active_subscriptions.data }
 end
@@ -28,9 +28,10 @@
 	local item = item_event.item;
 	assert(item.service, "pubsub subscription item MUST have a 'service' field.");
 	assert(item.node, "pubsub subscription item MUST have a 'node' field.");
+	item.from = item.from or module.host;
 
 	local already_subscibed = false;
-	for _ in active_subscriptions:iter(item.service, item.node, nil) do -- luacheck: ignore 512
+	for _ in active_subscriptions:iter(item.service, item.node, item.from, nil) do -- luacheck: ignore 512
 		already_subscibed = true;
 		break
 	end
@@ -38,24 +39,30 @@
 	item._id = uuid.generate();
 	local iq_id = uuid.generate();
 	pending_subscription:set(iq_id, item._id);
-	active_subscriptions:set(item.service, item.node, item._id, item);
+	active_subscriptions:set(item.service, item.node, item.from, item._id, item);
 
 	if not already_subscibed then
-		module:send(st.iq({ type = "set", id = iq_id, from = module.host, to = item.service })
+		module:send(st.iq({ type = "set", id = iq_id, from = item.from, to = item.service })
 			:tag("pubsub", { xmlns = xmlns_pubsub })
-				:tag("subscribe", { jid = module.host, node = item.node }));
+				:tag("subscribe", { jid = item.from, node = item.node }));
 	end
 end
 
 for _, event_name in ipairs(valid_events) do
 	module:hook("pubsub-event/host/"..event_name, function (event)
-		for _, _, _, _, cb in active_subscriptions:iter(event.service, event.node, nil, "on_"..event_name) do
+		for _, _, _, _, _, cb in active_subscriptions:iter(event.service, event.node, event.stanza.attr.to, nil, "on_"..event_name) do
+			pcall(cb, event);
+		end
+	end);
+
+	module:hook("pubsub-event/bare/"..event_name, function (event)
+		for _, _, _, _, _, cb in active_subscriptions:iter(event.service, event.node, event.stanza.attr.to, nil, "on_"..event_name) do
 			pcall(cb, event);
 		end
 	end);
 end
 
-module:hook("iq/host", function (event)
+function handle_iq(context, event)
 	local stanza = event.stanza;
 	local service = stanza.attr.from;
 
@@ -79,7 +86,7 @@
 			what = "on_unsubscribed";
 		end
 		if not what then return end -- there are other states but we don't handle them
-		for _, _, _, _, cb in active_subscriptions:iter(service, node, nil, what) do
+		for _, _, _, _, _, cb in active_subscriptions:iter(service, node, stanza.attr.to, nil, what) do
 			cb(event);
 		end
 		return true;
@@ -89,40 +96,48 @@
 		local error_type, error_condition, reason, pubsub_error = stanza:get_error();
 		local err = { type = error_type, condition = error_condition, text = reason, extra = pubsub_error };
 		if active_subscriptions:get(service) then
-			for _, _, _, _, cb in active_subscriptions:iter(service, node, nil, "on_error") do
+			for _, _, _, _, _, cb in active_subscriptions:iter(service, node, stanza.attr.to, nil, "on_error") do
 				cb(err);
 			end
 			return true;
 		end
 	end
+end
+
+module:hook("iq/host", function (event)
+	handle_iq("host", event);
+end, 1);
+
+module:hook("iq/bare", function (event)
+	handle_iq("bare", event);
 end, 1);
 
 local function subscription_removed(item_event)
 	local item = item_event.item;
-	active_subscriptions:set(item.service, item.node, item._id, nil);
-	local node_subs = active_subscriptions:get(item.service, item.node);
+	active_subscriptions:set(item.service, item.node, item.from, item._id, nil);
+	local node_subs = active_subscriptions:get(item.service, item.node, item.from);
 	if node_subs and next(node_subs) then return end
 
 	local iq_id = uuid.generate();
 	pending_unsubscription:set(iq_id, item._id);
 
-	module:send(st.iq({ type = "set", id = iq_id, from = module.host, to = item.service })
+	module:send(st.iq({ type = "set", id = iq_id, from = item.from, to = item.service })
 		:tag("pubsub", { xmlns = xmlns_pubsub })
-			:tag("unsubscribe", { jid = module.host, node = item.node }))
+			:tag("unsubscribe", { jid = item.from, node = item.node }))
 end
 
 module:handle_items("pubsub-subscription", subscription_added, subscription_removed, true);
 
-module:hook("message/host", function(event)
+function handle_message(context, event)
 	local origin, stanza = event.origin, event.stanza;
 	local ret = nil;
 	local service = stanza.attr.from;
-	module:log("debug", "Got message/host: %s", stanza:top_tag());
+	module:log("debug", "Got message/%s: %s", context, stanza:top_tag());
 	for event_container in stanza:childtags("event", xmlns_pubsub_event) do
 		for pubsub_event in event_container:childtags() do
 			module:log("debug", "Got pubsub event %s", pubsub_event:top_tag());
 			local node = pubsub_event.attr.node;
-			module:fire_event("pubsub-event/host/"..pubsub_event.name, {
+			module:fire_event("pubsub-event/" .. context .. "/"..pubsub_event.name, {
 					stanza = stanza;
 					origin = origin;
 					event = pubsub_event;
@@ -133,13 +148,30 @@
 		end
 	end
 	return ret;
+end
+
+module:hook("message/host", function(event)
+	return handle_message("host", event);
 end);
 
-module:hook("pubsub-event/host/items", function (event)
+module:hook("message/bare", function(event)
+	return handle_message("bare", event);
+end);
+
+
+function handle_items(context, event)
 	for item in event.event:childtags() do
 		module:log("debug", "Got pubsub item event %s", item:top_tag());
 		event.item = item;
 		event.payload = item.tags[1];
-		module:fire_event("pubsub-event/host/"..item.name, event);
+		module:fire_event("pubsub-event/" .. context .. "/"..item.name, event);
 	end
+end
+
+module:hook("pubsub-event/host/items", function (event)
+	handle_items("host", event);
 end);
+
+module:hook("pubsub-event/bare/items", function (event)
+	handle_items("bare", event);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_push2/README.markdown	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,48 @@
+---
+labels:
+- Stage-Alpha
+summary: 'Push 2.0'
+---
+
+The way forward for push notifications?  You are probably looking for
+`mod_cloud_notify` for now though
+
+See also https://hg.prosody.im/prosody-modules/file/tip/mod_push2/push2.markdown
+
+Configuration
+=============
+
+  Option                               Default           Description
+  ------------------------------------ ----------------- -------------------------------------------------------------------------------------------------------------------
+  `contact_uri`                        xmpp:server.tld   Contact information for the server operator (usually as a `mailto:` URI is preferred)
+  `push_max_hibernation_timeout`       `259200` (72h)    Number of seconds to extend the smacks timeout if no push was triggered yet (default: 72 hours)
+
+Internal design notes
+=====================
+
+App servers are notified about offline messages, messages stored by [mod_mam]
+or messages waiting in the smacks queue.
+
+To cooperate with [mod_smacks] this module consumes some events:
+`smacks-ack-delayed`, `smacks-hibernation-start` and `smacks-hibernation-end`.
+These events allow this module to send out notifications for messages received
+while the session is hibernated by [mod_smacks] or even when smacks
+acknowledgements for messages are delayed by a certain amount of seconds
+configurable with the [mod_smacks] setting `smacks_max_ack_delay`.
+
+The `smacks_max_ack_delay` setting allows to send out notifications to clients
+which aren't already in smacks hibernation state (because the read timeout or
+connection close didn't already happen) but also aren't responding to acknowledgement
+request in a timely manner. This setting thus allows conversations to be smoother
+under such circumstances.
+
+Compatibility
+=============
+
+**Note:** This module should be used with Lua 5.3 and higher.
+
+Requires a slightly patches luaossl right now: https://github.com/wahern/luaossl/pull/214
+
+------ -----------------------------------------------------------------------------
+  trunk  Works
+------ -----------------------------------------------------------------------------
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_push2/mod_push2.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,598 @@
+local os_time = os.time;
+local st = require"util.stanza";
+local jid = require"util.jid";
+local hashes = require"util.hashes";
+local random = require"util.random";
+local watchdog = require "util.watchdog";
+local uuid = require "util.uuid";
+local base64 = require "util.encodings".base64;
+local ciphers = require "openssl.cipher";
+local pkey = require "openssl.pkey";
+local kdf = require "openssl.kdf";
+local jwt = require "util.jwt";
+
+local xmlns_push = "urn:xmpp:push2:0";
+
+-- configuration
+local contact_uri = module:get_option_string("contact_uri", "xmpp:" .. module.host)
+local extended_hibernation_timeout = module:get_option_number("push_max_hibernation_timeout", 72*3600)  -- use same timeout like ejabberd
+
+local host_sessions = prosody.hosts[module.host].sessions
+local push2_registrations = module:open_store("push2_registrations", "keyval")
+
+if _VERSION:match("5%.1") or _VERSION:match("5%.2") then
+	module:log("warn", "This module may behave incorrectly on Lua before 5.3. It is recommended to upgrade to a newer Lua version.")
+end
+
+local function account_dico_info(event)
+	(event.reply or event.stanza):tag("feature", {var=xmlns_push}):up()
+end
+module:hook("account-disco-info", account_dico_info);
+
+local function parse_match(matchel)
+		local match = { match = matchel.attr.profile }
+		local send = matchel:get_child("send", "urn:xmpp:push2:send:notify-only:0")
+		if send then
+			match.send = send.attr.xmlns
+			return match
+		end
+
+		send = matchel:get_child("send", "urn:xmpp:push2:send:sce+rfc8291+rfc8292:0")
+		if send then
+			match.send = send.attr.xmlns
+			match.ua_public = send:get_child_text("ua-public")
+			match.auth_secret = send:get_child_text("auth-secret")
+			match.jwt_alg = send:get_child_text("jwt-alg")
+			match.jwt_key = send:get_child_text("jwt-key")
+			match.jwt_claims = {}
+			for claim in send:childtags("jwt-claim") do
+				match.jwt_claims[claim.attr.name] = claim:get_text()
+			end
+			return match
+		end
+
+		return nil
+end
+
+local function push_enable(event)
+	local origin, stanza = event.origin, event.stanza;
+	local enable = stanza.tags[1];
+	origin.log("debug", "Attempting to enable push notifications")
+	-- MUST contain a jid of the push service being enabled
+	local service_jid = enable:get_child_text("service")
+	-- MUST contain a string to identify the client fo the push service
+	local client = enable:get_child_text("client")
+	if not service_jid then
+		origin.log("debug", "Push notification enable request missing service")
+		origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing service"))
+		return true
+	end
+	if not client then
+		origin.log("debug", "Push notification enable request missing client")
+		origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing client"))
+		return true
+	end
+	if service_jid == stanza.attr.from then
+		origin.log("debug", "Push notification enable request service JID identical to our own")
+		origin.send(st.error_reply(stanza, "modify", "bad-request", "JID must be different from ours"))
+		return true
+	end
+	local matches = {}
+	for matchel in enable:childtags("match") do
+		local match = parse_match(matchel)
+		if match then
+			matches[#matches + 1] = match
+		end
+	end
+	-- Tie registration to client, via client_id with sasl2 or else fallback to resource
+	local registration_id = origin.client_id or origin.resource
+	local push_registration = {
+		service = service_jid;
+		client = client;
+		timestamp = os_time();
+		matches = matches;
+	};
+	-- TODO: can we move to keyval+ on trunk?
+	local registrations = push2_registrations:get(origin.username) or {}
+	registrations[registration_id] = push_registration
+	if not push2_registrations:set(origin.username, registrations) then
+		origin.send(st.error_reply(stanza, "wait", "internal-server-error"));
+	else
+		origin.push_registration_id = registration_id
+		origin.push_registration = push_registration
+		origin.first_hibernated_push = nil
+		origin.log("info", "Push notifications enabled for %s (%s)", tostring(stanza.attr.from), tostring(service_jid))
+		origin.send(st.reply(stanza))
+	end
+	return true
+end
+module:hook("iq-set/self/"..xmlns_push..":enable", push_enable)
+
+-- urgent stanzas should be delivered without delay
+local function is_voip(stanza)
+	if stanza.name == "message" then
+		if stanza:get_child("propose", "urn:xmpp:jingle-message:0") then
+			return true, "jingle call"
+		end
+	end
+end
+
+local function has_body(stanza)
+	-- We can't check for body contents in encrypted messages, so let's treat them as important
+	-- Some clients don't even set a body or an empty body for encrypted messages
+
+	-- check omemo https://xmpp.org/extensions/inbox/omemo.html
+	if stanza:get_child("encrypted", "eu.siacs.conversations.axolotl") or stanza:get_child("encrypted", "urn:xmpp:omemo:0") then return true; end
+
+	-- check xep27 pgp https://xmpp.org/extensions/xep-0027.html
+	if stanza:get_child("x", "jabber:x:encrypted") then return true; end
+
+	-- check xep373 pgp (OX) https://xmpp.org/extensions/xep-0373.html
+	if stanza:get_child("openpgp", "urn:xmpp:openpgp:0") then return true; end
+
+	local body = stanza:get_child_text("body");
+
+	return body ~= nil and body ~= ""
+end
+
+-- is this push a high priority one
+local function is_important(stanza)
+	local is_voip_stanza, urgent_reason = is_voip(stanza)
+	if is_voip_stanza then return true; end
+
+	local st_name = stanza and stanza.name or nil
+	if not st_name then return false; end -- nonzas are never important here
+	if st_name == "presence" then
+		return false; -- same for presences
+	elseif st_name == "message" then
+		-- unpack carbon copied message stanzas
+		local carbon = stanza:find("{urn:xmpp:carbons:2}/{urn:xmpp:forward:0}/{jabber:client}message")
+		local stanza_direction = carbon and stanza:child_with_name("sent") and "out" or "in"
+		if carbon then stanza = carbon; end
+		local st_type = stanza.attr.type
+
+		-- headline message are always not important
+		if st_type == "headline" then return false; end
+
+		-- carbon copied outgoing messages are not important
+		if carbon and stanza_direction == "out" then return false; end
+
+		-- groupchat subjects are not important here
+		if st_type == "groupchat" and stanza:get_child_text("subject") then
+			return false
+		end
+
+		-- empty bodies are not important
+		return has_body(stanza)
+	end
+	return false;		-- this stanza wasn't one of the above cases --> it is not important, too
+end
+
+local function add_sce_rfc8291(match, stanza, push_notification_payload)
+	local max_data_size = 2847 -- https://github.com/web-push-libs/web-push-php/issues/108
+	local stanza_clone = st.clone(stanza)
+	stanza_clone.attr.xmlns = "jabber:client"
+	local envelope = st.stanza("envelope", { xmlns = "urn:xmpp:sce:1" })
+		:tag("content")
+		:tag("forwarded", { xmlns = "urn:xmpp:forward:0" })
+		:add_child(stanza_clone)
+		:up():up():up()
+	local envelope_bytes = tostring(envelope)
+	if string.len(envelope_bytes) > max_data_size then
+		-- If stanza is too big, remove extra elements
+		stanza_clone:maptags(function(el)
+			if el.attr.xmlns == nil or
+				el.attr.xmlns == "jabber:client" or
+				el.attr.xmlns == "jabber:x:oob" or
+				(el.attr.xmlns == "urn:xmpp:sid:0" and el.name == "stanza-id") or
+				el.attr.xmlns == "eu.siacs.conversations.axolotl" or
+				el.attr.xmlns == "urn:xmpp:omemo:0" or
+				el.attr.xmlns == "jabber:x:encrypted" or
+				el.attr.xmlns == "urn:xmpp:openpgp:0" or
+				el.attr.xmlns == "urn:xmpp:sce:1" or
+				el.attr.xmlns == "urn:xmpp:jingle-message:0" or
+				el.attr.xmlns == "jabber:x:conference"
+			then
+				return el
+			else
+				return nil
+			end
+		end)
+		envelope_bytes = tostring(envelope)
+	end
+	if string.len(envelope_bytes) > max_data_size then
+		local body = stanza:get_child_text("body")
+		if string.len(body) > 50 then
+			stanza_clone:maptags(function(el)
+				if el.name == "body" then
+					return nil
+				else
+					return el
+				end
+			end)
+
+			body = string.gsub(string.gsub("\n" .. body, "\n>[^\n]*", ""), "^%s", "")
+			stanza_clone:body(body:sub(1, utf8.offset(body, 50)) .. "…")
+			envelope_bytes = tostring(envelope)
+		end
+	end
+	if string.len(envelope_bytes) > max_data_size then
+		-- If still too big, get aggressive
+		stanza_clone:maptags(function(el)
+			if el.name == "body" or
+				(el.attr.xmlns == "urn:xmpp:sid:0" and el.name == "stanza-id") or
+				el.attr.xmlns == "urn:xmpp:jingle-message:0" or
+				el.attr.xmlns == "jabber:x:conference"
+			then
+				return el
+			else
+				return nil
+			end
+		end)
+		envelope_bytes = tostring(envelope)
+	end
+	if string.len(envelope_bytes) < max_data_size/2 then
+		envelope:text_tag("rpad", base64.encode(random.bytes(math.min(150, max_data_size/3 - string.len(envelope_bytes)))))
+		envelope_bytes = tostring(envelope)
+	end
+
+	local p256dh_raw = base64.decode(match.ua_public .. "==")
+	local p256dh = pkey.new(p256dh_raw, "*", "public", "prime256v1")
+	local one_time_key = pkey.new({ type = "EC", curve = "prime256v1" })
+	local one_time_key_public = one_time_key:getParameters().pub_key:toBinary()
+	local info = "WebPush: info\0" .. p256dh_raw .. one_time_key_public
+	local auth_secret = base64.decode(match.auth_secret .. "==")
+	local salt = random.bytes(16)
+	local shared_secret = one_time_key:derive(p256dh)
+	local ikm = kdf.derive({
+		type = "HKDF",
+		outlen = 32,
+		salt = auth_secret,
+		key = shared_secret,
+		info = info,
+		md = "sha256"
+	})
+	local key = kdf.derive({
+		type = "HKDF",
+		outlen = 16,
+		salt = salt,
+		key = ikm,
+		info = "Content-Encoding: aes128gcm\0",
+		md = "sha256"
+	})
+	local nonce = kdf.derive({
+		type = "HKDF",
+		outlen = 12,
+		salt = salt,
+		key = ikm,
+		info = "Content-Encoding: nonce\0",
+		md = "sha256"
+	})
+	local header = salt .. "\0\0\16\0" .. string.char(string.len(one_time_key_public)) .. one_time_key_public
+	local encryptor = ciphers.new("AES-128-GCM"):encrypt(key, nonce)
+
+	push_notification_payload
+		:tag("encrypted", { xmlns = "urn:xmpp:sce:rfc8291:0" })
+		:text_tag("payload", base64.encode(header .. encryptor:final(envelope_bytes .. "\2") .. encryptor:getTag(16)))
+		:up()
+end
+
+local function add_rfc8292(match, stanza, push_notification_payload)
+	if not match.jwt_alg then return; end
+	local key = match.jwt_key
+	if match.jwt_alg ~= "HS256" then
+		-- keypairs are in PKCS#8 PEM format without header/footer
+		key = "-----BEGIN PRIVATE KEY-----\n"..key.."\n-----END PRIVATE KEY-----"
+	end
+
+	local public_key = pkey.new(key):getParameters().pub_key:toBinary()
+	local signer = jwt.new_signer(match.jwt_alg, key)
+	local payload = {}
+	for k, v in pairs(match.jwt_claims or {}) do
+		payload[k] = v
+	end
+	payload.sub = contact_uri
+	push_notification_payload:text_tag("jwt", signer(payload), { key = base64.encode(public_key) })
+end
+
+local function handle_notify_request(stanza, node, user_push_services, log_push_decline)
+	local pushes = 0;
+	if not #user_push_services then return pushes end
+
+	local notify_push_services = {};
+	if is_important(stanza) then
+		notify_push_services = user_push_services
+	else
+		for identifier, push_info in pairs(user_push_services) do
+			for _, match in ipairs(push_info.matches) do
+				if match.match == "urn:xmpp:push2:match:important" then
+					identifier_found.log("debug", "Not pushing because not important")
+				else
+					notify_push_services[identifier] = push_info;
+				end
+			end
+		end
+	end
+
+	for push_registration_id, push_info in pairs(notify_push_services) do
+		local send_push = true;		-- only send push to this node when not already done for this stanza or if no stanza is given at all
+		if stanza then
+			if not stanza._push_notify2 then stanza._push_notify2 = {}; end
+			if stanza._push_notify2[push_registration_id] then
+				if log_push_decline then
+					module:log("debug", "Already sent push notification for %s@%s to %s (%s)", node, module.host, push_info.jid, tostring(push_info.node));
+				end
+				send_push = false;
+			end
+			stanza._push_notify2[push_registration_id] = true;
+		end
+
+		if send_push then
+			local push_notification_payload = st.stanza("notification", { xmlns = xmlns_push })
+			push_notification_payload:text_tag("client", push_info.client)
+			push_notification_payload:text_tag("priority", is_voip(stanza) and "high" or (is_important(stanza) and "normal" or "low"))
+			if is_voip(stanza) then
+				push_notification_payload:tag("voip"):up()
+			end
+
+			local sends_added = {};
+			for _, match in ipairs(push_info.matches) do
+				local does_match = false;
+				if match.match == "urn:xmpp:push2:match:all" then
+					does_match = true
+				elseif match.match == "urn:xmpp:push2:match:important" then
+					does_match = is_important(stanza)
+				elseif match.match == "urn:xmpp:push2:match:archived" then
+					does_match = stanza:get_child("stana-id", "urn:xmpp:sid:0")
+				elseif match.match == "urn:xmpp:push2:match:archived-with-body" then
+					does_match = stanza:get_child("stana-id", "urn:xmpp:sid:0") and has_body(stanza)
+				end
+
+				if does_match and not sends_added[match.send] then
+					sends_added[match.send] = true
+					if match.send == "urn:xmpp:push2:send:notify-only" then
+						-- Nothing more to add
+					elseif match.send == "urn:xmpp:push2:send:sce+rfc8291+rfc8292:0" then
+						add_sce_rfc8291(match, stanza, push_notification_payload)
+						add_rfc8292(match, stanza, push_notification_payload)
+					else
+						module:log("debug", "Unkonwn send profile: " .. push_info.send)
+					end
+				end
+			end
+
+			local push_publish = st.message({ to = push_info.service, from = module.host, id = uuid.generate() })
+				:add_child(push_notification_payload):up()
+
+			-- TODO: watch for message error replies and count or something
+			module:send(push_publish)
+			pushes = pushes + 1
+		end
+	end
+
+	return pushes
+end
+
+-- small helper function to extract relevant push settings
+local function get_push_settings(stanza, session)
+	local to = stanza.attr.to
+	local node = to and jid.split(to) or session.username
+	local user_push_services = push2_registrations:get(node)
+	return node, (user_push_services or {})
+end
+
+-- publish on offline message
+module:hook("message/offline/handle", function(event)
+	local node, user_push_services = get_push_settings(event.stanza, event.origin);
+	module:log("debug", "Invoking handle_notify_request() for offline stanza");
+	handle_notify_request(event.stanza, node, user_push_services, true);
+end, 1);
+
+-- publish on bare groupchat
+-- this picks up MUC messages when there are no devices connected
+module:hook("message/bare/groupchat", function(event)
+	local node, user_push_services = get_push_settings(event.stanza, event.origin);
+	local notify_push_services = {};
+	for identifier, push_info in pairs(user_push_services) do
+		for _, match in ipairs(push_info.matches) do
+			if match.match == "urn:xmpp:push2:match:archived-with-body" or match.match == "urn:xmpp:push2:match:archived" then
+				identifier_found.log("debug", "Not pushing because we are not archiving this stanza")
+			else
+				notify_push_services[identifier] = push_info;
+			end
+		end
+	end
+
+	handle_notify_request(event.stanza, node, notify_push_services, true);
+end, 1);
+
+local function process_stanza_queue(queue, session, queue_type)
+	if not session.push_registration_id then return; end
+	local user_push_services = {[session.push_registration_id] = session.push_settings};
+	local notified = { unimportant = false; important = false }
+	for i=1, #queue do
+		local stanza = queue[i];
+		-- fast ignore of already pushed stanzas
+		if stanza and not (stanza._push_notify2 and stanza._push_notify2[session.push_registration_id]) then
+			local node = get_push_settings(stanza, session);
+			local stanza_type = "unimportant";
+			if is_important(stanza) then stanza_type = "important"; end
+			if not notified[stanza_type] then		-- only notify if we didn't try to push for this stanza type already
+				if handle_notify_request(stanza, node, user_push_services, false) ~= 0 then
+					if session.hibernating and not session.first_hibernated_push then
+						-- if the message was important
+						-- then record the time of first push in the session for the smack module which will extend its hibernation
+						-- timeout based on the value of session.first_hibernated_push
+						if is_important(stanza) then
+							session.first_hibernated_push = os_time();
+							-- check for prosody 0.12 mod_smacks
+							if session.hibernating_watchdog and session.original_smacks_callback and session.original_smacks_timeout then
+								-- restore old smacks watchdog (--> the start of our original timeout will be delayed until first push)
+								session.hibernating_watchdog:cancel();
+								session.hibernating_watchdog = watchdog.new(session.original_smacks_timeout, session.original_smacks_callback);
+							end
+						end
+					end
+					notified[stanza_type] = true
+				end
+			end
+		end
+		if notified.unimportant and notified.important then break; end		-- stop processing the queue if all push types are exhausted
+	end
+end
+
+-- publish on unacked smacks message (use timer to send out push for all stanzas submitted in a row only once)
+local function process_stanza(session, stanza)
+	if session.push_registration_id then
+		session.log("debug", "adding new stanza to push_queue");
+		if not session.push_queue then session.push_queue = {}; end
+		local queue = session.push_queue;
+		queue[#queue+1] = st.clone(stanza);
+		if not session.awaiting_push_timer then		-- timer not already running --> start new timer
+			session.awaiting_push_timer = module:add_timer(1.0, function ()
+				process_stanza_queue(session.push_queue, session, "push");
+				session.push_queue = {};		-- clean up queue after push
+				session.awaiting_push_timer = nil;
+			end);
+		end
+	end
+	return stanza;
+end
+
+local function process_smacks_stanza(event)
+	local session = event.origin;
+	local stanza = event.stanza;
+	if not session.push_registration_id then
+		session.log("debug", "NOT invoking handle_notify_request() for newly smacks queued stanza (session.push_registration_id is not set: %s)",
+			session.push_registration_id
+		);
+	else
+		process_stanza(session, stanza)
+	end
+end
+
+-- smacks hibernation is started
+local function hibernate_session(event)
+	local session = event.origin;
+	local queue = event.queue;
+	session.first_hibernated_push = nil;
+	if session.push_registration_id and session.hibernating_watchdog then -- check for prosody 0.12 mod_smacks
+		-- save old watchdog callback and timeout
+		session.original_smacks_callback = session.hibernating_watchdog.callback;
+		session.original_smacks_timeout = session.hibernating_watchdog.timeout;
+		-- cancel old watchdog and create a new watchdog with extended timeout
+		session.hibernating_watchdog:cancel();
+		session.hibernating_watchdog = watchdog.new(extended_hibernation_timeout, function()
+			session.log("debug", "Push-extended smacks watchdog triggered");
+			if session.original_smacks_callback then
+				session.log("debug", "Calling original smacks watchdog handler");
+				session.original_smacks_callback();
+			end
+		end);
+	end
+	-- process unacked stanzas
+	process_stanza_queue(queue, session, "smacks");
+end
+
+-- smacks hibernation is ended
+local function restore_session(event)
+	local session = event.resumed;
+	if session then		-- older smacks module versions send only the "intermediate" session in event.session and no session.resumed one
+		if session.awaiting_push_timer then
+			session.awaiting_push_timer:stop();
+			session.awaiting_push_timer = nil;
+		end
+		session.first_hibernated_push = nil;
+		-- the extended smacks watchdog will be canceled by the smacks module, no need to anything here
+	end
+end
+
+-- smacks ack is delayed
+local function ack_delayed(event)
+	local session = event.origin;
+	local queue = event.queue;
+	local stanza = event.stanza;
+	if not session.push_registration_id then return; end
+	if stanza then process_stanza(session, stanza); return; end		-- don't iterate through smacks queue if we know which stanza triggered this
+	for i=1, #queue do
+		local queued_stanza = queue[i];
+		-- process unacked stanzas (handle_notify_request() will only send push requests for new stanzas)
+		process_stanza(session, queued_stanza);
+	end
+end
+
+-- archive message added
+local function archive_message_added(event)
+	-- event is: { origin = origin, stanza = stanza, for_user = store_user, id = id }
+	-- only notify for new mam messages when at least one device is online
+	if not event.for_user or not host_sessions[event.for_user] then return; end
+	-- Note that the stanza in the event is a clone not the same as other hooks, so dedupe doesn't work
+	-- This is a problem if you wan to to also hook offline message storage for example
+	local stanza = st.clone(event.stanza)
+	stanza:tag("stanza-id", { xmlns = "urn:xmpp:sid:0", by = event.for_user.."@"..module.host, id = event.id }):up()
+	local user_session = host_sessions[event.for_user] and host_sessions[event.for_user].sessions or {}
+	local to = stanza.attr.to
+	to = to and jid.split(to) or event.origin.username
+
+	-- only notify if the stanza destination is the mam user we store it for
+	if event.for_user == to then
+		local user_push_services = push2_registrations:get(to)
+
+		-- Urgent stanzas are time-sensitive (e.g. calls) and should
+		-- be pushed immediately to avoid getting stuck in the smacks
+		-- queue in case of dead connections, for example
+		local is_voip_stanza, urgent_reason = is_voip(stanza);
+
+		local notify_push_services;
+		if is_voip_stanza then
+			module:log("debug", "Urgent push for %s (%s)", to, urgent_reason);
+			notify_push_services = user_push_services;
+		else
+			-- only notify nodes with no active sessions (smacks is counted as active and handled separate)
+			notify_push_services = {};
+			for identifier, push_info in pairs(user_push_services) do
+				local identifier_found = nil;
+				for _, session in pairs(user_session) do
+					if session.push_registration_id == identifier then
+						identifier_found = session;
+						break;
+					end
+				end
+				if identifier_found then
+					identifier_found.log("debug", "Not pushing '%s' of new MAM stanza (session still alive)", identifier)
+				else
+					notify_push_services[identifier] = push_info
+				end
+			end
+		end
+
+		handle_notify_request(stanza, to, notify_push_services, true);
+	end
+end
+
+module:hook("smacks-hibernation-start", hibernate_session);
+module:hook("smacks-hibernation-end", restore_session);
+module:hook("smacks-ack-delayed", ack_delayed);
+module:hook("smacks-hibernation-stanza-queued", process_smacks_stanza);
+module:hook("archive-message-added", archive_message_added);
+
+module:log("info", "Module loaded");
+function module.unload()
+	module:log("info", "Unloading module");
+	-- cleanup some settings, reloading this module can cause process_smacks_stanza() to stop working otherwise
+	for user, _ in pairs(host_sessions) do
+		for _, session in pairs(host_sessions[user].sessions) do
+			if session.awaiting_push_timer then session.awaiting_push_timer:stop(); end
+			session.awaiting_push_timer = nil;
+			session.push_queue = nil;
+			session.first_hibernated_push = nil;
+			-- check for prosody 0.12 mod_smacks
+			if session.hibernating_watchdog and session.original_smacks_callback and session.original_smacks_timeout then
+				-- restore old smacks watchdog
+				session.hibernating_watchdog:cancel();
+				session.hibernating_watchdog = watchdog.new(session.original_smacks_timeout, session.original_smacks_callback);
+			end
+		end
+	end
+	module:log("info", "Module unloaded");
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_push2/push2.markdown	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,140 @@
+# Push 2.0
+
+Adapted from notes made at [XMPP Summit 25](https://pad.nixnet.services/oy6MKVbESSycLeMJIOh6zw).
+
+Requirements:
+
+- Support for SASL2 inlining
+- Extensible stanza matching rules and notification payload rules
+- Simpler syntax and concept model than original specification
+
+## Client registers to receive push notifications
+
+```xml
+<enable xmlns='urn:xmpp:push2:0'>
+    <service>pusher@push.example.com</service>
+    <client>https://push.example.com/adlfkjadafdasf</client>
+    <match profile="urn:xmpp:push2:match:archived-with-body">
+        <send xmlns="urn:xmpp:push2:send:notify-only:0"/>
+    </match>
+</enable>
+```
+
+The `<service/>` element contains a JID which push notifications for this client will be sent to. It may be a host, bare or full JID.
+
+The `<client/>` element contains an opaque string that will be included in all communication with the push service. It may be used to convey client identifiers used by the push notification service to route notifications.
+
+The `<match/>` and `<send/>` elements define what profiles to use for matching stanzas and sending notifications. These are described later in this document.
+
+## Match and send profiles
+
+Different clients and push services have different requirements for push notifications, often due to the differing capabilities of target platforms.
+
+A "profile" in the context of this specification is a set of rules for matching the kinds of stanzas that should be pushed, and how to transform them before sending the notification to the push service.
+
+### Match profiles
+
+Match profiles define which incoming stanzas will trigger a push notification. More than one match may be specified.
+
+Some match profiles are defined in this XEP. Other XEPs may define additional profiles with the reserved `urn:xmpp:push2:match:` prefix, following the registrar considerations explained later in this document. Custom profiles not defined in a XEP should use their own appropriate URI.
+
+#### `urn:xmpp:push2:match:all`
+
+Using this profile, all stanzas will trigger a push notification to be sent to the push service when the client is unavailable.
+
+#### `urn:xmpp:push2:match:important`
+
+Stanzas that are considered to be "important" are pushed. At the time of writing, there is no standard definition of "important", however most servers already contain such logic for traffic optimization when combined with [XEP-0352: Client State Indication](https://xmpp.org/extensions/xep-0352.html).
+
+#### `urn:xmpp:push2:match:archived`
+
+Push notifications will be sent for any stanza that is stored in the user's archive. This is a good indication that the stanza is important, and is desired on all of a user's devices.
+
+#### `urn:xmpp:push2:match:archived-with-body`
+
+Matches only archived messages that contain a body. This can be used to exclude certain message types, such as typing notifications, receipts and error replies.
+
+### Send profiles
+
+When a server has determined that a stanza should trigger a push notification (according to the client's selected 'match' profile), it proceeds to create a notification stanza following the send profiles specified in the match profiles which match this stanza.
+
+After constructing the notification stanza, it will then be sent to the push service JID selected by the client.
+
+Some send profiles are defined in this XEP. Other XEPs may define additional profiles with the `urn:xmpp:push2:send:` prefix, following the registrar considerations explained later in this document. Custom profiles not defined in a XEP should use their own appropriate URI.
+
+#### `urn:xmpp:push2:send:notify-only:0`
+
+Send an empty notification to the push service. Such notifications are useful if a push notification can trigger the client to "wake up" and connect to the server to receive the message over XMPP.
+
+Example:
+
+```xml
+<message to="pusher@push.example.net">
+    <notification xmlns="urn:xmpp:push2:0">
+        <client>https://push.example.com/adlfkjadafdasf</client>
+        <priority>normal</priority>
+    </notification>
+</message>
+```
+
+#### `urn:xmpp:push2:send:sce+rfc8291+rfc8292:0`
+
+Delivers content encrypted according to RFC8291 and with a JWT auth following RFC8292
+
+```xml
+<send xmlns="urn:xmpp:push2:send:sce+rfc8291+rfc8292:0">
+    <ua-public>Base64 encoded P-256 ECDH public key (raw, uncompressed)</ua-public>
+    <auth-secret>Base64 encoded randomly generated 16 octets</auth-secret>
+    <jwt-alg>ES256</jwt-alg>
+    <jwt-key>PKCS#8 PEM encoded ECDSA keypair, without the header or footer lines</jwt-key>
+    <jwt-claim name="aud">https://push.example.com</jwt-claim>
+</send>
+```
+
+The full stanza is wrapped in XEP-0297 forwarded and then that is wrapped in XEP-0420 envelope/content with optional rpad. The raw bytes of the resulting XML are encrypted according to RFC8291 using the provided `ua-public` and `auth-secret`.
+
+If `jwt-alg` is specified, then a JWT is computed over any provided claims plus a suitable `exp` and `sub` claim and signed using the provided key.
+
+Then a notification is sent:
+
+```xml
+<message to="pusher@push.example.net">
+    <notification xmlns="urn:xmpp:push2:0">
+        <client>https://push.example.com/adlfkjadafdasf</client>
+        <priority>normal</priority>
+        <encrypted xmlns="urn:xmpp:sce:rfc8291:0">
+            <payload>Base64 encoded ciphertext</payload>
+        </encrypted>
+        <jwt key="base64 encoded raw public key">the signed JWT, if present</jwt>
+    </notification>
+</message>
+```
+
+NOTE: if the stanza exceeds the maximum size of 4096 bytes (and some implementations may wish to restrict this even more) the stanza may have some elements removed, body truncated, etc before it is delivered. Servers SHOULD ensure that at least the MAM id (if there is one) is still present after any minimization.
+
+## Discovering support
+
+A server that supports this protocol MUST advertise the `urn:xmpp:push2:0` feature in an account's service discovery information, along with the supported match and send profiles.
+
+```xml
+<iq from='juliet@capulet.lit'
+    to='juliet@capulet.lit/balcony'
+    id='disco1'
+    type='result'>
+  <query xmlns='http://jabber.org/protocol/disco#info'>
+    <identity category='account' type='registered'/>
+    <feature var='urn:xmpp:push2:0'/>
+    <feature var='urn:xmpp:push2:send:'/>
+  </query>
+</iq>
+```
+
+## Push service interactions
+
+### Transient delivery errors
+
+The user's server might receive delivery errors while sending notifications to the user's push service. The error 'type' attribute SHOULD be honoured - errors of type 'wait' SHOULD be retried in an appropriate manner (e.g. using exponential back-off algorithm, up to a limit), discarding the notification after an appropriate length of time or number of attempts.
+
+Other error types MUST NOT be automatically retried.
+
+A user's server MAY automatically disable a push configuration for a service that has consistently failed to relay notifications for an extended period of time. This period is a matter of deployment configuration, but a default no less than 72 hours is recommended.
--- a/mod_register_apps/assets/logos/conversations.svg	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_register_apps/assets/logos/conversations.svg	Tue Feb 06 18:32:01 2024 +0700
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
 <!-- Created with Inkscape (http://www.inkscape.org/) -->
-<svg xmlns:osb="http://www.openswatchbook.org/uri/2009/osb" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="57mm" height="57mm" viewBox="0 0 201.96849 201.96849" id="svg4211" version="1.1" inkscape:version="0.91 r13725" sodipodi:docname="conversations_baloon.svg">
+<svg xmlns:osb="http://www.openswatchbook.org/uri/2009/osb" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" viewBox="0 0 201.96849 201.96849" id="svg4211" version="1.1" inkscape:version="0.91 r13725" sodipodi:docname="conversations_baloon.svg">
   <sodipodi:namedview id="base" pagecolor="#ffffff" bordercolor="#666666" borderopacity="1.0" showgrid="false" fit-margin-top="0" fit-margin-left="0" fit-margin-right="0" fit-margin-bottom="0" showguides="false" inkscape:zoom="2.2196812" inkscape:cx="39.109276" inkscape:cy="132.27753" inkscape:window-width="1600" inkscape:window-height="836" inkscape:window-x="0" inkscape:window-y="27" inkscape:window-maximized="1" inkscape:current-layer="layer8"/>
   <defs id="defs4213">
     <linearGradient osb:paint="solid" id="linearGradient5393">
@@ -102,4 +102,4 @@
   <g inkscape:groupmode="layer" id="layer1" inkscape:label="light" style="display:inline" transform="translate(-4,2.6816164)">
     <path style="display:inline;opacity:0.19211821;fill:url(#radialGradient3883);fill-opacity:1;stroke:none" d="m 192.44891,47.715674 c -61.69765,0 -111.704333,49.103472 -111.704333,109.668976 0,12.77573 2.228815,25.0414 6.321575,36.4393 5.069139,0.70557 10.251828,1.06876 15.514978,1.06876 18.80489,0 30.91434,7.28449 47.46533,1.26909 l 54.00234,6.06606 c 5.24363,2.11897 11.63381,1.37954 10.27166,-4.11162 l -14.23663,-57.56735 c 9.15073,-16.06873 12.27539,-34.36633 12.27539,-53.240271 0,-13.72556 -2.63167,-26.842322 -7.42478,-38.909717 -4.09925,-0.447474 -8.2658,-0.683228 -12.48553,-0.683228 z" id="path3878" inkscape:connector-curvature="0" clip-path="url(#clipPath5745)" transform="translate(4.9800894,-1.9374999)" sodipodi:nodetypes="sscsccccscs"/>
   </g>
-</svg>
\ No newline at end of file
+</svg>
--- a/mod_register_apps/assets/logos/dino.svg	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_register_apps/assets/logos/dino.svg	Tue Feb 06 18:32:01 2024 +0700
@@ -1,1 +1,1 @@
-<svg width="348.643" height="355.287" viewBox="0 0 92.246 94.004" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="d" x1="479.79" x2="529.53" y1="233.23" y2="276.31" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><clipPath id="c"><path d="M503.51 222.01c-20.04 0-36.286 16.246-36.286 36.286 0 1.925.151 3.814.44 5.657 1.402 6.762 6.687 9.389 6.687 9.389 1.108.908 2.484 1.836 4.073 2.752l-.13-.004s3.331 18.563 9.444 18.41c4.622-.116 8.25-9.453 14.128-9.265 5.872.188 8.256 9.151 14.1 9.213 3.374.036 6.307-5.744 8.16-10.528.586-.12 1.17-.25 1.749-.392 5.556 5.254 18.398 10.599 23.87 12.412a.533.533 0 0 0 .177-.14c1.396-1.82-7.954-16.01-12.983-23.372a36.17 36.17 0 0 0 2.856-14.132c0-20.04-16.246-36.286-36.286-36.286z" fill-rule="evenodd" fill="#009688"/></clipPath><filter id="b" x="-.057" y="-.064" width="1.113" height="1.127" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="7.406"/></filter><filter id="a" x="-.036" y="-.036" width="1.073" height="1.071" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.279"/></filter></defs><path transform="translate(-463.58 -207.17)" d="M505.07 210.24c-2.584 0-7.363 8.69-9.387 12.604-.943.203-1.873.442-2.787.716-3.364-2.184-10.387-6.5-12.017-5.56-1.423.821-1.425 8.862-1.333 12.923-7.653 6.652-12.492 16.459-12.492 27.395 0 .048 0 .097.002.145l-.002.127c0 1.924.151 3.814.44 5.657.05.242.106.477.166.709 1.642 8.88 6.518 16.625 13.36 21.97 1.577 4.154 3.775 7.937 6.547 7.867 1.405-.035 2.717-.923 4.029-2.134a36.026 36.026 0 0 0 5.7 1.443 36.53 36.53 0 0 0 6.044.502 36.06 36.06 0 0 0 8.897-1.1c1.06.737 2.218 1.223 3.56 1.238 2.067.022 3.966-2.142 5.555-4.919.374-.214.744-.436 1.11-.663 9.543 3.143 23.481 7.454 26.688 6.928.14.048.295.103.426.146a.534.534 0 0 0 .176-.14c.116-.15.156-.387.133-.695.043-.563-.239-1.495-.744-2.671 1.157-2.273 2.989-6.227 2.326-7.376-.602-1.042-4.756-1.14-7-1.117-.259-.42-.515-.836-.78-1.26 1.135-2.282 3.259-6.915 2.498-8.232-.852-1.476-7.172-1.756-9.086-1.808l-.159-.233c1.955-1.002 10.336-5.393 10.64-7.154.27-1.572-5.236-6.234-7.977-8.444-.032-.98-.104-1.95-.212-2.91 2.74-3.67 8.98-12.269 8.376-13.724-.783-1.886-11.23-3.218-15.204-3.661a36.411 36.411 0 0 0-1.467-1.859c.365-4.318 1.196-16.086-.544-17.205-1.496-.961-11.73 4.52-15.087 6.365a35.98 35.98 0 0 0-1.651-.54c-1.454-3.362-5.99-13.33-8.744-13.33z" fill-rule="evenodd" filter="url(#a)" opacity=".2"/><path d="M84.862 87.192s4.14-7.395 3.197-9.03c-.83-1.436-8.444-1.084-8.444-1.084s4.183-7.757 3.162-9.525c-1.005-1.741-9.675-1.823-9.675-1.823s10.726-5.38 11.068-7.372c.359-2.089-9.482-9.636-9.482-9.636s10.44-13.588 9.67-15.442c-.966-2.327-16.818-3.835-16.818-3.835s1.733-17.52-.397-18.89c-1.79-1.15-16.236 6.999-16.236 6.999S44.967 3.05 41.66 3.05c-3.328 0-10.359 14.525-10.359 14.525S19.631 9.562 17.47 10.81c-1.91 1.103-1.259 15.252-1.259 15.252z" fill-rule="evenodd" fill="#455a64"/><g fill="#80cbc4" fill-rule="evenodd"><path d="M70.31 60.85s17.935 25.313 16.04 27.783c-1.84 2.398-36.52-9.785-36.52-9.785z"/><path d="M3.65 51.13c0 17.981 13.079 32.907 30.242 35.785 1.966.33 3.985.501 6.044.501 2.13 0 4.219-.184 6.25-.536C63.246 83.918 76.22 69.039 76.22 51.13c0-20.04-16.246-36.286-36.286-36.286-20.04 0-36.286 16.246-36.286 36.286z"/></g><path transform="scale(.26458)" d="M154.92 58.094c-75.742 0-137.14 61.401-137.14 137.14 0 7.274.571 14.415 1.662 21.383 5.3 25.557 25.275 35.482 25.275 35.482 4.185 3.435 9.388 6.94 15.395 10.402l-.494-.016s12.592 70.158 35.693 69.58c17.472-.438 31.183-35.725 53.398-35.016 22.195.709 31.205 34.586 53.293 34.82 12.755.135 23.838-21.709 30.84-39.789 2.216-.453 4.422-.945 6.611-1.483 21 19.858 69.537 40.06 90.221 46.912.283-.15.51-.325.668-.53 5.275-6.875-30.062-60.51-49.07-88.333 6.949-16.416 10.793-34.466 10.793-53.414 0-75.742-61.4-137.14-137.14-137.14z" fill-rule="evenodd" filter="url(#b)" opacity=".15"/><path d="M39.931 15.378c-20.04 0-36.286 16.246-36.286 36.286 0 1.925.151 3.814.44 5.658 1.402 6.762 6.687 9.388 6.687 9.388 1.108.908 2.484 1.836 4.073 2.752l-.13-.004s3.331 18.563 9.444 18.41c4.622-.116 8.25-9.452 14.128-9.265 5.872.188 8.256 9.151 14.1 9.213 3.375.036 6.308-5.744 8.16-10.528.587-.12 1.17-.25 1.75-.392 5.556 5.254 18.398 10.599 23.87 12.412a.534.534 0 0 0 .177-.14c1.396-1.82-7.954-16.01-12.983-23.372a36.171 36.171 0 0 0 2.856-14.133c0-20.04-16.246-36.285-36.286-36.285z" fill-rule="evenodd" fill="#00796b"/><path d="M39.931 14.312c-20.04 0-36.286 16.246-36.286 36.286 0 1.925.151 3.814.44 5.658 1.402 6.762 6.687 9.388 6.687 9.388 1.108.908 2.484 1.836 4.073 2.752l-.13-.004s3.331 18.563 9.444 18.41c4.622-.116 8.25-9.452 14.128-9.265 5.872.188 8.256 9.151 14.1 9.213 3.375.036 6.308-5.744 8.16-10.528.587-.12 1.17-.25 1.75-.392 5.556 5.254 18.398 10.6 23.87 12.412a.534.534 0 0 0 .177-.14c1.396-1.82-7.954-16.01-12.983-23.371a36.171 36.171 0 0 0 2.856-14.133c0-20.04-16.246-36.286-36.286-36.286z" fill-rule="evenodd" fill="#4db6ac"/><path d="M39.931 14.841c-20.04 0-36.286 16.246-36.286 36.286 0 1.925.151 3.814.44 5.658 1.402 6.762 6.687 9.388 6.687 9.388 1.108.908 2.484 1.836 4.073 2.752l-.13-.004s3.331 18.563 9.444 18.41c4.622-.116 8.25-9.452 14.128-9.265 5.872.188 8.256 9.151 14.1 9.213 3.374.036 6.307-5.743 8.16-10.528.586-.12 1.17-.25 1.749-.392 5.556 5.254 18.398 10.6 23.87 12.412a.534.534 0 0 0 .177-.14C87.74 86.81 78.39 72.62 73.36 65.26a36.171 36.171 0 0 0 2.856-14.133c0-20.04-16.246-36.286-36.286-36.286z" fill-rule="evenodd" fill="#009688"/><circle cx="21.58" cy="41.319" r="2.91" fill-rule="evenodd" fill="#eee"/><path transform="translate(-463.58 -207.17)" clip-path="url(#c)" fill-rule="evenodd" fill="url(#d)" opacity=".05" d="M461.55 203.9h101.96v98.655H461.55z"/></svg>
\ No newline at end of file
+<svg viewBox="0 0 92.246 94.004" xmlns="http://www.w3.org/2000/svg"><defs><linearGradient id="d" x1="479.79" x2="529.53" y1="233.23" y2="276.31" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fff"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient><clipPath id="c"><path d="M503.51 222.01c-20.04 0-36.286 16.246-36.286 36.286 0 1.925.151 3.814.44 5.657 1.402 6.762 6.687 9.389 6.687 9.389 1.108.908 2.484 1.836 4.073 2.752l-.13-.004s3.331 18.563 9.444 18.41c4.622-.116 8.25-9.453 14.128-9.265 5.872.188 8.256 9.151 14.1 9.213 3.374.036 6.307-5.744 8.16-10.528.586-.12 1.17-.25 1.749-.392 5.556 5.254 18.398 10.599 23.87 12.412a.533.533 0 0 0 .177-.14c1.396-1.82-7.954-16.01-12.983-23.372a36.17 36.17 0 0 0 2.856-14.132c0-20.04-16.246-36.286-36.286-36.286z" fill-rule="evenodd" fill="#009688"/></clipPath><filter id="b" x="-.057" y="-.064" width="1.113" height="1.127" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="7.406"/></filter><filter id="a" x="-.036" y="-.036" width="1.073" height="1.071" color-interpolation-filters="sRGB"><feGaussianBlur stdDeviation="1.279"/></filter></defs><path transform="translate(-463.58 -207.17)" d="M505.07 210.24c-2.584 0-7.363 8.69-9.387 12.604-.943.203-1.873.442-2.787.716-3.364-2.184-10.387-6.5-12.017-5.56-1.423.821-1.425 8.862-1.333 12.923-7.653 6.652-12.492 16.459-12.492 27.395 0 .048 0 .097.002.145l-.002.127c0 1.924.151 3.814.44 5.657.05.242.106.477.166.709 1.642 8.88 6.518 16.625 13.36 21.97 1.577 4.154 3.775 7.937 6.547 7.867 1.405-.035 2.717-.923 4.029-2.134a36.026 36.026 0 0 0 5.7 1.443 36.53 36.53 0 0 0 6.044.502 36.06 36.06 0 0 0 8.897-1.1c1.06.737 2.218 1.223 3.56 1.238 2.067.022 3.966-2.142 5.555-4.919.374-.214.744-.436 1.11-.663 9.543 3.143 23.481 7.454 26.688 6.928.14.048.295.103.426.146a.534.534 0 0 0 .176-.14c.116-.15.156-.387.133-.695.043-.563-.239-1.495-.744-2.671 1.157-2.273 2.989-6.227 2.326-7.376-.602-1.042-4.756-1.14-7-1.117-.259-.42-.515-.836-.78-1.26 1.135-2.282 3.259-6.915 2.498-8.232-.852-1.476-7.172-1.756-9.086-1.808l-.159-.233c1.955-1.002 10.336-5.393 10.64-7.154.27-1.572-5.236-6.234-7.977-8.444-.032-.98-.104-1.95-.212-2.91 2.74-3.67 8.98-12.269 8.376-13.724-.783-1.886-11.23-3.218-15.204-3.661a36.411 36.411 0 0 0-1.467-1.859c.365-4.318 1.196-16.086-.544-17.205-1.496-.961-11.73 4.52-15.087 6.365a35.98 35.98 0 0 0-1.651-.54c-1.454-3.362-5.99-13.33-8.744-13.33z" fill-rule="evenodd" filter="url(#a)" opacity=".2"/><path d="M84.862 87.192s4.14-7.395 3.197-9.03c-.83-1.436-8.444-1.084-8.444-1.084s4.183-7.757 3.162-9.525c-1.005-1.741-9.675-1.823-9.675-1.823s10.726-5.38 11.068-7.372c.359-2.089-9.482-9.636-9.482-9.636s10.44-13.588 9.67-15.442c-.966-2.327-16.818-3.835-16.818-3.835s1.733-17.52-.397-18.89c-1.79-1.15-16.236 6.999-16.236 6.999S44.967 3.05 41.66 3.05c-3.328 0-10.359 14.525-10.359 14.525S19.631 9.562 17.47 10.81c-1.91 1.103-1.259 15.252-1.259 15.252z" fill-rule="evenodd" fill="#455a64"/><g fill="#80cbc4" fill-rule="evenodd"><path d="M70.31 60.85s17.935 25.313 16.04 27.783c-1.84 2.398-36.52-9.785-36.52-9.785z"/><path d="M3.65 51.13c0 17.981 13.079 32.907 30.242 35.785 1.966.33 3.985.501 6.044.501 2.13 0 4.219-.184 6.25-.536C63.246 83.918 76.22 69.039 76.22 51.13c0-20.04-16.246-36.286-36.286-36.286-20.04 0-36.286 16.246-36.286 36.286z"/></g><path transform="scale(.26458)" d="M154.92 58.094c-75.742 0-137.14 61.401-137.14 137.14 0 7.274.571 14.415 1.662 21.383 5.3 25.557 25.275 35.482 25.275 35.482 4.185 3.435 9.388 6.94 15.395 10.402l-.494-.016s12.592 70.158 35.693 69.58c17.472-.438 31.183-35.725 53.398-35.016 22.195.709 31.205 34.586 53.293 34.82 12.755.135 23.838-21.709 30.84-39.789 2.216-.453 4.422-.945 6.611-1.483 21 19.858 69.537 40.06 90.221 46.912.283-.15.51-.325.668-.53 5.275-6.875-30.062-60.51-49.07-88.333 6.949-16.416 10.793-34.466 10.793-53.414 0-75.742-61.4-137.14-137.14-137.14z" fill-rule="evenodd" filter="url(#b)" opacity=".15"/><path d="M39.931 15.378c-20.04 0-36.286 16.246-36.286 36.286 0 1.925.151 3.814.44 5.658 1.402 6.762 6.687 9.388 6.687 9.388 1.108.908 2.484 1.836 4.073 2.752l-.13-.004s3.331 18.563 9.444 18.41c4.622-.116 8.25-9.452 14.128-9.265 5.872.188 8.256 9.151 14.1 9.213 3.375.036 6.308-5.744 8.16-10.528.587-.12 1.17-.25 1.75-.392 5.556 5.254 18.398 10.599 23.87 12.412a.534.534 0 0 0 .177-.14c1.396-1.82-7.954-16.01-12.983-23.372a36.171 36.171 0 0 0 2.856-14.133c0-20.04-16.246-36.285-36.286-36.285z" fill-rule="evenodd" fill="#00796b"/><path d="M39.931 14.312c-20.04 0-36.286 16.246-36.286 36.286 0 1.925.151 3.814.44 5.658 1.402 6.762 6.687 9.388 6.687 9.388 1.108.908 2.484 1.836 4.073 2.752l-.13-.004s3.331 18.563 9.444 18.41c4.622-.116 8.25-9.452 14.128-9.265 5.872.188 8.256 9.151 14.1 9.213 3.375.036 6.308-5.744 8.16-10.528.587-.12 1.17-.25 1.75-.392 5.556 5.254 18.398 10.6 23.87 12.412a.534.534 0 0 0 .177-.14c1.396-1.82-7.954-16.01-12.983-23.371a36.171 36.171 0 0 0 2.856-14.133c0-20.04-16.246-36.286-36.286-36.286z" fill-rule="evenodd" fill="#4db6ac"/><path d="M39.931 14.841c-20.04 0-36.286 16.246-36.286 36.286 0 1.925.151 3.814.44 5.658 1.402 6.762 6.687 9.388 6.687 9.388 1.108.908 2.484 1.836 4.073 2.752l-.13-.004s3.331 18.563 9.444 18.41c4.622-.116 8.25-9.452 14.128-9.265 5.872.188 8.256 9.151 14.1 9.213 3.374.036 6.307-5.743 8.16-10.528.586-.12 1.17-.25 1.749-.392 5.556 5.254 18.398 10.6 23.87 12.412a.534.534 0 0 0 .177-.14C87.74 86.81 78.39 72.62 73.36 65.26a36.171 36.171 0 0 0 2.856-14.133c0-20.04-16.246-36.286-36.286-36.286z" fill-rule="evenodd" fill="#009688"/><circle cx="21.58" cy="41.319" r="2.91" fill-rule="evenodd" fill="#eee"/><path transform="translate(-463.58 -207.17)" clip-path="url(#c)" fill-rule="evenodd" fill="url(#d)" opacity=".05" d="M461.55 203.9h101.96v98.655H461.55z"/></svg>
--- a/mod_register_apps/assets/logos/gajim.svg	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_register_apps/assets/logos/gajim.svg	Tue Feb 06 18:32:01 2024 +0700
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8"?>
-<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="128px" height="128px" viewBox="0 0 128 128" version="1.1">
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 128 128" version="1.1">
 <defs>
 <radialGradient id="radial0" gradientUnits="userSpaceOnUse" cx="16.488304" cy="23.537582" fx="16.488304" fy="23.537582" r="19" gradientTransform="matrix(5.830516,0,0,5.186123,-57.136612,106.88559)">
 <stop offset="0" style="stop-color:rgb(45.09804%,82.352942%,8.627451%);stop-opacity:1;"/>
--- a/mod_register_apps/assets/logos/generic.svg	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_register_apps/assets/logos/generic.svg	Tue Feb 06 18:32:01 2024 +0700
@@ -12,10 +12,6 @@
    version="1.1"
    xml:space="preserve"
    viewBox="0 0 200 200"
-   width="200px"
-   height="200px"
-   x="0px"
-   y="0px"
    enable-background="new 0 0 200 200"
    id="svg36"
    sodipodi:docname="xmpp.svg"
@@ -268,4 +264,4 @@
    d="m 152.72199,157.42799 h 24.546 c 8.561,0 10.63,4.302 10.63,10.063 v 2.516 c 0,4.381 -1.907,9.41 -8.275,9.41 h -17.893 v 7.385 h -9.008 v -29.38 z m 9.01,14.69 h 13.996 c 2.11,0 2.922,-1.377 2.922,-3.123 v -1.135 c 0,-1.99 -0.974,-3.127 -3.693,-3.127 h -13.225 v 7.38 z"
    id="path34"
    style="opacity:0.293;filter:url(#filter1291)" />
-</svg>
\ No newline at end of file
+</svg>
--- a/mod_register_apps/assets/logos/monal.svg	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_register_apps/assets/logos/monal.svg	Tue Feb 06 18:32:01 2024 +0700
@@ -9,8 +9,6 @@
    xmlns="http://www.w3.org/2000/svg"
    xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
    xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
-   width="20mm"
-   height="20.085215mm"
    viewBox="0 0 20 20.085215"
    version="1.1"
    id="svg3834"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_register_apps/assets/logos/renga.svg	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg version="1.1" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
+ <filter width="64" height="64" id="shadow">
+  <feDropShadow dx="3" dy="3" stdDeviation="0" flood-opacity="0.4"/>
+ </filter>
+ <g filter="url(#shadow)">
+  <path fill="none" stroke="#000" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"
+        d="M20.99 2C10.5 2 1.99 9.15 1.99 18C1.99 26.82 10.5 34 20.99 34C31.47 34 40 26.82 40 18C40 9.15 31.47 2 20.99 2z
+           M40 36C37 33 36 26 36 26L28 32C28 32 36 36 40 36z"
+        transform="matrix(1.4544,0,0,1.4544,0,4)"
+  />
+  <linearGradient id="gradient0" gradientUnits="userSpaceOnUse" x1="128" y1="2" x2="128" y2="40">
+   <stop offset="0" stop-color="#fff"/>
+   <stop offset="1" stop-color="#e5e5e5"/>
+  </linearGradient>
+  <path fill="url(#gradient0)"
+        d="M20.99 2C10.5 2 1.99 9.15 1.99 18C1.99 26.82 10.5 34 20.99 34C31.47 34 40 26.82 40 18C40 9.15 31.47 2 20.99 2z
+           M40 36C37 33 36 26 36 26L28 32C28 32 36 36 40 36z"
+        transform="matrix(1.4544,0,0,1.4544,0,4)"
+  />
+  <path fill="#000"
+        d="M22.48 26.76H26.1V28.82H22.48
+           M26.1 21.75V23.99H22.48V21.75
+           M34.03 23.99H30.29V21.75H34.03
+           M30.29 28.82V26.76H34.03V28.82
+           M30.29 40.24V36.75H40.15V33.27H30.29V31.75H38.14V18.77H30.29V17.75H39.71V14.3H30.29V11.57H26.1V14.3H17.15V17.75H26.1V18.77H18.59V31.75H26.1V33.27H16.71V36.75H26.1V40.24
+           M15.39 16.61C12.9 14.22 10.89 12.49 10.89 12.49L7.6 15.07C7.6 15.07 7.6 15.07 9.62 16.79C12 18.82 13.01 20.57 13.01 20.57L16.53 18.16
+           M7.46 25.83V29.75H11.5V37.75C11.5 37.75 11.5 37.75 10.06 39C8.43 40.21 7.03 41.13 7.03 41.13L9.04 45.46C9.04 45.46 9.04 45.46 10.89 43.82C12.35 42.46 13.79 41.05 15.96 43.82C18.84 44.86 23.12 45.07 27.51 45.29C35.15 45.19 39.6 44.94 39.82 43.76C40.43 41.77 40.93 40.75 35.96 41.16C27.48 41.27 23.15 41.08 19.48 40.94C16.96 39.93 15.56 37.46 15.56 37.46V25.83H7.46z
+           M51.62 40.02H48.67V37.75H51.62
+           M54.73 34.99H45.57V44.55H48.67V42.75H54.73
+           M48.82 24.18V21.75H51.59V24.18
+           M73.48 22.75C73.48 22.75 73.48 22.75 73.23 25.18C72.93 27.24 72.65 28.75 72.65 28.75L76.07 29.55C76.07 29.55 76.07 29.55 76.75 26.88C77.4 22.66 77.82 19.35 77.82 19.35L75.03 18.77L74.37 18.88H67.43C67.43 18.88 67.43 18.88 67.82 17.15C68.18 15.21 68.48 13.22 68.48 13.22L64.34 12.61C64.34 12.61 64.34 12.61 63.71 17.93C62.46 23.02 60.26 26.69 60.31 26.47C60.31 26.19 60.31 25.9 60.31 25.9V17.75H62.5V14.3H44.57V17.75H56.53V25.79C56.53 25.79 56.53 25.79 56.53 26.15C56.37 26.25 56.01 26.25 56.01 26.25H54.68V19.21H45.73V28H48.82V26.75H53.39C53.39 26.75 53.39 26.75 53.75 28C54.1 28.97 54.26 29.75 56.34 29.75C57.82 29.72 58.93 29.25 59.62 28.9C59.98 28.47 60.15 27.79 61.2 28.32C62.82 29.33 63.46 29.93 64.59 28.21C65.56 25.88 66.35 22.75 66.35 22.75H66.92V28.07C66.92 28.07 66.92 28.07 66.92 31.43C65.78 38.38 60.34 42.61 60.34 42.61V33.75H62.82V30.27H44.18V33.75H56.53V42.69C56.53 42.69 56.53 42.69 56.53 43.19C56.42 43.29 55.98 43.35 55.65 43.38C54.32 43.38 53.07 43.35 53.53 44.29C54.04 45.72 54.18 46.75 56.28 46.75C57.78 46.72 58.93 46.15 59.79 45.68C60.15 45.04 60.31 43.96 60.98 44.9C61.78 46.11 62.1 46.9 66.46 43.85C68.57 37.88 69.09 34.85 69.51 37.72C71.46 43.96 75.64 46.85 76.25 45.83C77.43 44.02 78.2 42.97 72.4 38.79C71.1 31.49 71.1 27.75 71.1 27.75V22.75H73.48z"
+        transform="matrix(0.6943,0,0,0.669,0.986,10.1795)"
+  />
+ </g>
+</svg>
--- a/mod_register_apps/assets/logos/yaxim.svg	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_register_apps/assets/logos/yaxim.svg	Tue Feb 06 18:32:01 2024 +0700
@@ -1,6 +1,6 @@
 <?xml version="1.0" encoding="UTF-8" standalone="no"?>
 <!-- Created with Inkscape (http://www.inkscape.org/) -->
-<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" width="240" height="240" viewBox="0 0 240 240" id="svg2" version="1.1" inkscape:version="0.91 r13725" sodipodi:docname="icon.svg" inkscape:export-filename="/usr/src/G1/yaxim/asset-graphics/yak-grass.png" inkscape:export-xdpi="100.07505" inkscape:export-ydpi="100.07505">
+<svg xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:cc="http://creativecommons.org/ns#" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns:svg="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" viewBox="0 0 240 240" id="svg2" version="1.1" inkscape:version="0.91 r13725" sodipodi:docname="icon.svg" inkscape:export-filename="/usr/src/G1/yaxim/asset-graphics/yak-grass.png" inkscape:export-xdpi="100.07505" inkscape:export-ydpi="100.07505">
   <defs id="defs4">
     <linearGradient inkscape:collect="always" id="hair-3">
       <stop style="stop-color:#803300;stop-opacity:1;" offset="0" id="stop4211"/>
@@ -62,4 +62,4 @@
     </g>
     <path style="fill:#000000;stroke:#000000;stroke-opacity:1" d="" id="path3427" inkscape:connector-curvature="0"/>
   </g>
-</svg>
\ No newline at end of file
+</svg>
--- a/mod_register_apps/mod_register_apps.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_register_apps/mod_register_apps.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -144,6 +144,18 @@
 			};
 		};
 	};
+	{
+		name  = "Renga";
+		text  = [[XMPP client for Haiku]];
+		image = "assets/logos/renga.svg";
+		link  = "https://pulkomandy.tk/projects/renga";
+		platforms = { "Haiku" };
+		download = {
+			buttons = {
+				{ text = "Download Renga for Haiku", url = "https://depot.haiku-os.org/#!/pkg/renga?bcguid=bc233-PQIA", target="_blank" };
+			};
+		};
+	};
 });
 
 local show_apps = module:get_option_set("site_apps_show");
--- a/mod_register_redirect/README.markdown	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_register_redirect/README.markdown	Tue Feb 06 18:32:01 2024 +0700
@@ -29,7 +29,7 @@
 server/hostname configuration:
 
     registration_whitelist = { "*your whitelisted web server ip address*" }
-    registrarion_url = "*your web registration page url*"
+    registration_url = "*your web registration page url*"
     registration_text = "Your custom instructions banner here"
     registration_oob = true (default) or false, in the case there's no applicable OOB method (e.g. the server admins needs to be contacted by phone)
 
--- a/mod_rest/example/rest.sh	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_rest/example/rest.sh	Tue Feb 06 18:32:01 2024 +0700
@@ -13,6 +13,8 @@
 HOST=""
 DOMAIN=""
 
+SESSION="session-read-only"
+
 if [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/restrc" ]; then
 	# Config file can contain the above settings
 	source "${XDG_CONFIG_HOME:-$HOME/.config}/restrc"
@@ -23,7 +25,7 @@
 fi
 
 if [[ $# == 0 ]]; then
-	echo "${0##*/} [-h HOST] [/path] kind=(message|presence|iq) ...."
+	echo "${0##*/} [-h HOST] [-rw] [/path] kind=(message|presence|iq) ...."
 	# Last arguments are handed to HTTPie, so refer to its docs for further details
 	exit 0
 fi
@@ -35,6 +37,12 @@
 	HOST="$(hostname)"
 fi
 
+if [[ "$1" == "-rw" ]]; then
+	# To e.g. save Accept headers to the session
+	SESSION="session"
+	shift 1
+fi
+
 if [[ "$HOST" != *.* ]]; then
 	# Assumes subdomain of your DOMAIN
 	if [ -z "${DOMAIN:-}" ]; then
@@ -55,4 +63,4 @@
 	shift 1
 fi
 
-https --check-status -p b --session rest -A oauth2 -a "$HOST" --oauth2-scope "$SCOPE" "$HOST/rest$GET_PATH" "$@"
+https --check-status -p b --"$SESSION" rest -A oauth2 -a "$HOST" --oauth2-scope "$SCOPE" "$HOST/rest$GET_PATH" "$@"
--- a/mod_rest/mod_rest.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_rest/mod_rest.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -20,6 +20,9 @@
 
 local tokens = module:depends("tokenauth");
 
+-- Lower than the default c2s size limit to account for possible JSON->XML size increase
+local stanza_size_limit = module:get_option_number("rest_stanza_size_limit", 1024 * 192);
+
 local auth_mechanisms = module:get_option_set("rest_auth_mechanisms", { "Basic", "Bearer" });
 
 local www_authenticate_header;
@@ -277,6 +280,7 @@
 	iq_type = { code = 422; type = "modify"; condition = "invalid-xml"; text = "'iq' stanza must be of type 'get' or 'set'" };
 	iq_tags = { code = 422; type = "modify"; condition = "bad-format"; text = "'iq' stanza must have exactly one child tag" };
 	mediatype = { code = 415; type = "cancel"; condition = "bad-format"; text = "Unsupported media type" };
+	size = { code = 413; type = "modify"; condition = "resource-constraint", text = "Payload too large" };
 });
 
 -- GET → iq-get
@@ -313,6 +317,9 @@
 		origin.type = "c2s";
 		origin.log = log;
 	end
+	if type(request.body) == "string" and #request.body > stanza_size_limit then
+		return post_errors.new("size", { size = #request.body; limit = stanza_size_limit });
+	end
 	local payload, err = parse_request(request, path);
 	if not payload then
 		-- parse fail
--- a/mod_restrict_xmpp/README.markdown	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_restrict_xmpp/README.markdown	Tue Feb 06 18:32:01 2024 +0700
@@ -25,25 +25,35 @@
 ===========
 
 `xmpp:federate`
-: Communicate with other users and services on other hosts on the XMPP network
+:   Communicate with other users and services on other hosts on the XMPP
+    network
+
 `xmpp:account:messages:read`
-: Read incoming messages
+:   Read incoming messages
+
 `xmpp:account:messages:write`
-: Send outgoing messages
+:   Send outgoing messages
+
 `xmpp:account:presence:write`
-: Update presence for the account
+:   Update presence for the account
+
 `xmpp:account:contacts:read`/`xmpp:account:contacts:write`
-: Controls access to the contact list (roster)
+:   Controls access to the contact list (roster)
+
 `xmpp:account:bookmarks:read`/`xmpp:account:bookmarks:write`
-: Controls access to the bookmarks (group chats list)
+:   Controls access to the bookmarks (group chats list)
+
 `xmpp:account:profile:read`/`xmpp:account:profile:write`
-: Controls access to the user's profile (e.g. vCard/avatar)
+:   Controls access to the user's profile (e.g. vCard/avatar)
+
 `xmpp:account:omemo:read`/`xmpp:account:omemo:write`
-: Controls access to the user's OMEMO data
+:   Controls access to the user's OMEMO data
+
 `xmpp:account:blocklist:read`/`xmpp:account:blocklist:write`
-: Controls access to the user's block list
+:   Controls access to the user's block list
+
 `xmpp:account:disco:read`
-: Controls access to the user's service discovery information
+:   Controls access to the user's service discovery information
 
 Compatibility
 =============
--- a/mod_restrict_xmpp/mod_restrict_xmpp.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_restrict_xmpp/mod_restrict_xmpp.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -56,6 +56,7 @@
 	["storage:bookmarks"] = "bookmarks";
 	["urn:xmpp:bookmarks:1"] = "bookmarks";
 
+	["urn:xmpp:vcard4"] = "profile";
 	["urn:xmpp:avatar:data"] = "profile";
 	["urn:xmpp:avatar:metadata"] = "profile";
 	["http://jabber.org/protocol/nick"] = "profile";
@@ -77,6 +78,7 @@
 
 	local payload = stanza.tags[1];
 	local ns = payload and payload.attr.xmlns;
+	if ns == "urn:xmpp:ping" then return end
 	local proto = iq_namespaces[ns];
 	if proto == "pep" then
 		local pubsub = payload:get_child("pubsub", "http://jabber.org/protocol/pubsub");
@@ -113,7 +115,7 @@
 	end
 end
 
-module:default_permission("prosody:restricted", "xmpp:account:presence:write");
+module:default_permission(limited_user_role, "xmpp:account:presence:write");
 module:hook("pre-presence/bare", function (event)
 	if not event.to_self then return; end
 	local stanza = event.stanza;
--- a/mod_s2s_smacks_timeout/README.md	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_s2s_smacks_timeout/README.md	Tue Feb 06 18:32:01 2024 +0700
@@ -1,5 +1,18 @@
+---
+labels:
+- Stage-Obsolete
+---
+
 # Introduction
 
+::: {.alert .alert-warning}
+This behavior has now been merged into
+[mod_s2s][doc:modules:mod_s2s] in trunk and is therefore obsolete
+when used with trunk.
+
+It can still be used with Prosody 0.12 to get this behavior.
+:::
+
 This module closes s2s connections when
 [mod_smacks][doc:modules:mod_smacks] reports that a connection has not
 received a timely acknowledgement as requested, indicating that the
--- a/mod_s2s_status/mod_s2s_status.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_s2s_status/mod_s2s_status.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -18,6 +18,7 @@
 		domain_log = {};
 		status_out[peer_domain] = domain_log;
 	end
+	return domain_log;
 end
 
 local function get_connection_record(domain_log, id)
--- a/mod_sasl2_sm/mod_sasl2_sm.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_sasl2_sm/mod_sasl2_sm.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -54,10 +54,6 @@
 
 -- Bind 2 integration (for enable)
 
-module:hook("advertise-bind-features", function (event)
-	event.features:tag("feature", { var = xmlns_sm }):up();
-end);
-
 module:hook("enable-bind-features", function (event)
 	local sm_enable = event.request:get_child("enable", xmlns_sm);
 	if not sm_enable then return; end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_sasl_ssdp/README.markdown	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,30 @@
+---
+labels:
+- 'Stage-Alpha'
+summary: 'XEP-0474: SASL SCRAM Downgrade Protection'
+...
+
+Introduction
+============
+
+This module implements the experimental XEP-0474: SASL SCRAM Downgrade
+Protection. It provides an alternative downgrade protection mechanism to
+client-side pinning which is currently the most common method of downgrade
+protection.
+
+**Note:** This module implements version 0.3.0 of XEP-0474. As of 2023-12-05,
+this version is not yet published on xmpp.org. Version 0.3.0 of the XEP is
+implemented in Monal 6.0.1. No other clients are currently known to implement
+the XEP at the time of writing.
+
+# Configuration
+
+There are no configuration options for this module, just load it as normal.
+
+# Compatibility
+
+For SASL2 (XEP-0388) clients, it is compatible with the mod_sasl2 community module.
+
+For clients using RFC 6120 SASL, it requires Prosody trunk 33e5edbd6a4a or
+later. It is not compatible with Prosody 0.12 (it will load, but simply
+won't do anything) for "legacy SASL".
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_sasl_ssdp/mod_sasl_ssdp.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,36 @@
+local array = require "util.array";
+local hashes = require "util.hashes";
+local it = require "util.iterators";
+local base64_enc = require "util.encodings".base64.encode;
+
+local hash_functions = {
+	["SCRAM-SHA-1"] = hashes.sha1;
+	["SCRAM-SHA-1-PLUS"] = hashes.sha1;
+	["SCRAM-SHA-256"] = hashes.sha256;
+	["SCRAM-SHA-256-PLUS"] = hashes.sha256;
+};
+
+function add_ssdp_info(event)
+	local sasl_handler = event.session.sasl_handler;
+	local hash = hash_functions[sasl_handler.selected];
+	if not hash then
+		module:log("debug", "Not enabling SSDP for unsupported mechanism: %s", sasl_handler.selected);
+		return;
+	end
+	local mechanism_list = array.collect(it.keys(sasl_handler:mechanisms())):sort();
+	local cb = sasl_handler.profile.cb;
+	local cb_list = cb and array.collect(it.keys(cb)):sort();
+	local ssdp_string;
+	if cb_list then
+		ssdp_string = mechanism_list:concat(",").."|"..cb_list:concat(",");
+	else
+		ssdp_string = mechanism_list:concat(",");
+	end
+	module:log("debug", "Calculated SSDP string: %s", ssdp_string);
+	event.message = event.message..",d="..base64_enc(hash(ssdp_string));
+	sasl_handler.state.server_first_message = event.message;
+end
+
+module:hook("sasl/c2s/challenge", add_ssdp_info, 1);
+module:hook("sasl2/c2s/challenge", add_ssdp_info, 1);
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_server_info/README.md	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,50 @@
+---
+labels:
+- 'Stage-Alpha'
+summary: Manually configure extended service discovery info
+...
+
+XEP-0128 defines a way for servers to provide custom information via service
+discovery. Various XEPs and plugins make use of this functionality, so that
+e.g. clients can look up necessary information.
+
+This module allows the admin to manually configure service discovery
+extensions in the config file. It may be useful as a way to advertise certain
+information.
+
+Everything configured here is publicly visible to other XMPP entities.
+
+## Configuration
+
+The `server_info` option accepts a list of dataforms. A dataform is an array
+of fields. A field has three required properties:
+
+- `type` - usually `text-single` or `list-multi`
+- `var` - the field name
+- `value` the field value
+
+Example configuration:
+
+``` lua
+server_info = {
+
+	-- Our custom form
+	{
+		-- Conventionally XMPP dataforms have a 'FORM_TYPE' field to
+		-- indicate what type of form it is
+		{ type = "hidden", var = "FORM_TYPE", value = "urn:example:foo" };
+
+		-- Advertise that our maximum speed is 88 mph
+		{ type = "text-single", var = "speed", value = "88" };
+
+		-- Advertise that the time is 1:20 AM and zero seconds
+		{ type = "text-single", var = "time", value = "01:21:00" };
+	};
+
+}
+```
+
+## Compatibility
+
+This module should be compatible with Prosody 0.12, and possibly earlier
+versions.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_server_info/mod_server_info.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,18 @@
+-- XEP-0128: Service Discovery Extensions (manual config)
+--
+-- Copyright (C) 2023 Matthew Wild
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local dataforms = require "util.dataforms";
+
+local config = module:get_option("server_info");
+
+if not config or next(config) == nil then return; end -- Nothing to do
+
+for _, form in ipairs(config) do
+	module:add_extension(dataforms.new(form):form({}, "result"));
+end
+
--- a/mod_storage_appendmap/mod_storage_appendmap.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_storage_appendmap/mod_storage_appendmap.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -1,11 +1,13 @@
 local dump = require "util.serialization".serialize;
 local load = require "util.envload".envloadfile;
+local datetime = require "util.datetime".datetime;
 local dm = require "core.storagemanager".olddm;
 
 local REMOVE = {}; -- Special value for removing keys
 
 local driver = {};
 
+local timestamps = module:get_option_boolean("appendmap_timestamps", true);
 
 local keywords = {
 	["do"] = true; ["and"] = true; ["else"] = true; ["break"] = true;
@@ -82,6 +84,9 @@
 
 function map:set_keys(user, keyvalues)
 	local data = serialize_map(keyvalues);
+	if timestamps then
+		data = "-- " .. datetime() .. "\n" .. data;
+	end
 	return dm.append_raw(user, module.host, self.store, "map", data);
 end
 
@@ -94,9 +99,16 @@
 		return os.remove(filename);
 	end
 	local data = serialize_pair(key, value);
+	if timestamps then
+		data = "-- " .. datetime() .. "\n" .. data;
+	end
 	return dm.append_raw(user, module.host, self.store, "map", data);
 end
 
+function map:items()
+	return dm.users(module.host, self.store, "map");
+end
+
 local keyval = { remove = REMOVE };
 local keyval_mt = { __index = keyval };
 
@@ -106,9 +118,16 @@
 
 function keyval:set(user, keyvalues)
 	local data = serialize_map(keyvalues);
+	if timestamps then
+		data = "-- " .. datetime() .. "\n" .. data;
+	end
 	return dm.store_raw(dm.getpath(user, module.host, self.store, "map"), data);
 end
 
+function keyval:users()
+	return dm.users(module.host, self.store, "map");
+end
+
 -- TODO some kind of periodic compaction thing?
 function map:_compact(user)
 	local data = self:get(user);
--- a/mod_storage_ejabberdsql_readonly/mod_storage_ejabberdsql_readonly.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_storage_ejabberdsql_readonly/mod_storage_ejabberdsql_readonly.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -25,9 +25,9 @@
 
 local function keyval_store_get()
 	if store == "accounts" then
-		--for row in engine:select("SELECT `password`,`created_at` FROM `users` WHERE `username`=?", user or "") do
+		--for row in engine:select("SELECT \"password\",\"created_at\" FROM \"users\" WHERE \"username\"=?", user or "") do
 		local result;
-		for row in engine:select("SELECT `password` FROM `users` WHERE `username`=? LIMIT 1", user or "") do result = row end
+		for row in engine:select("SELECT \"password\" FROM \"users\" WHERE \"username\"=? LIMIT 1", user or "") do result = row end
 		local password = result[1];
 		--local created_at = result[2];
 		return { password = password };
@@ -35,8 +35,8 @@
 	elseif store == "roster" then
 		local roster = {};
 		local pending = nil;
-		--for row in engine:select("SELECT `jid`,`nick`,`subscription`,`ask`,`askmessage`,`server`,`subscribe`,`type`,`created_at` FROM `rosterusers` WHERE `username`=?", user or "") do
-		for row in engine:select("SELECT `jid`,`nick`,`subscription`,`ask` FROM `rosterusers` WHERE `username`=?", user or "") do
+		--for row in engine:select("SELECT \"jid\",\"nick\",\"subscription\",\"ask\",\"askmessage\",\"server\",\"subscribe\",\"type\",\"created_at\" FROM \"rosterusers\" WHERE \"username\"=?", user or "") do
+		for row in engine:select("SELECT \"jid\",\"nick\",\"subscription\",\"ask\" FROM \"rosterusers\" WHERE \"username\"=?", user or "") do
 			local contact = row[1];
 			local name = row[2];
 			if name == "" then name = nil; end
@@ -72,7 +72,7 @@
 			--local created_at = row[9];
 
 			local groups = {};
-			for row in engine:select("SELECT `grp` FROM `rostergroups` WHERE `username`=? AND `jid`=?", user or "", contact) do
+			for row in engine:select("SELECT \"grp\" FROM \"rostergroups\" WHERE \"username\"=? AND \"jid\"=?", user or "", contact) do
 				local group = row[1];
 				groups[group] = true;
 			end
@@ -83,7 +83,7 @@
 
 	elseif store == "vcard" then
 		local result = nil;
-		for row in engine:select("SELECT `vcard` FROM `vcard` WHERE `username`=? LIMIT 1", user or "") do result = row end
+		for row in engine:select("SELECT \"vcard\" FROM \"vcard\" WHERE \"username\"=? LIMIT 1", user or "") do result = row end
 		if not result then
 			return nil;
 		end
@@ -95,7 +95,7 @@
 	elseif store == "private" then
 		local private = nil;
 		local result;
-		for row in engine:select("SELECT `namespace`,`data` FROM `private_storage` WHERE `username`=?", user or "") do
+		for row in engine:select("SELECT \"namespace\",\"data\" FROM \"private_storage\" WHERE \"username\"=?", user or "") do
 			if private == nil then private = {} end;
 			local namespace = row[1];
 			local data, err = xml_parse(row[2]);
@@ -125,7 +125,7 @@
 
 function keyval_store:users()
 	local ok, result = engine:transaction(function()
-		return engine:select("SELECT `username` FROM `users`");
+		return engine:select("SELECT \"username\" FROM \"users\"");
 	end);
 	if not ok then return ok, result end
 	return iterator(result);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_storage_s3/README.md	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,20 @@
+---
+labels:
+- 'Stage-Alpha'
+summary: Cloud Native Storage
+...
+
+::: {.alert .alert-danger}
+This storage driver is fully async and requires that all storage access happens in an async-compatible context. As of 2023-10-14 this work in Prosody
+is not yet complete. For now, this module is primarily suited for testing and finding areas where async work is incomplete.
+:::
+
+This module provides storage in Amazon S3 compatible things. It has been tested primarily with MinIO.
+
+``` lua
+s3_bucket = "prosody"
+s3_base_uri = "http://localhost:9000"
+s3_region = "us-east-1"
+s3_access_key = "YOUR-ACCESS-KEY-HERE"
+s3_secret_key = "YOUR-SECRET-KEY-HERE"
+```
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_storage_s3/mod_storage_s3.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -0,0 +1,334 @@
+local http = require "prosody.net.http";
+local array = require "prosody.util.array";
+local async = require "prosody.util.async";
+local dt = require "prosody.util.datetime";
+local hashes = require "prosody.util.hashes";
+local httputil = require "prosody.util.http";
+local it = require "prosody.util.iterators";
+local jid = require "prosody.util.jid";
+local json = require "prosody.util.json";
+local promise = require "prosody.util.promise";
+local set = require "prosody.util.set";
+local st = require "prosody.util.stanza";
+local uuid = require "prosody.util.uuid";
+local xml = require "prosody.util.xml";
+local url = require "socket.url";
+
+local new_uuid = uuid.v7 or uuid.generate;
+local hmac_sha256 = hashes.hmac_sha256;
+local sha256 = hashes.sha256;
+
+local driver = {};
+
+local bucket = module:get_option_string("s3_bucket", "prosody");
+local base_uri = module:get_option_string("s3_base_uri", "http://localhost:9000");
+local region = module:get_option_string("s3_region", "us-east-1");
+
+local access_key = module:get_option_string("s3_access_key");
+local secret_key = module:get_option_string("s3_secret_key");
+
+local aws4_format = "AWS4-HMAC-SHA256 Credential=%s/%s, SignedHeaders=%s, Signature=%s";
+
+local function aws_auth(event)
+	local request, options = event.request, event.options;
+	local method = options.method or "GET";
+	local query = options.query;
+	local payload = options.body;
+
+	local payload_type = nil;
+	if st.is_stanza(payload) then
+		payload_type = "application/xml";
+		payload = tostring(payload);
+	elseif payload ~= nil then
+		payload_type = "application/json";
+		payload = json.encode(payload);
+	end
+	options.body = payload;
+
+	local payload_hash = sha256(payload or "", true);
+
+	local now = os.time();
+	local aws_datetime = os.date("!%Y%m%dT%H%M%SZ", now);
+	local aws_date = os.date("!%Y%m%d", now);
+
+	local headers = {
+		["Accept"] = "*/*";
+		["Authorization"] = nil;
+		["Content-Type"] = payload_type;
+		["Host"] = request.authority;
+		["User-Agent"] = "Prosody XMPP Server";
+		["X-Amz-Content-Sha256"] = payload_hash;
+		["X-Amz-Date"] = aws_datetime;
+	};
+
+	local canonical_uri = url.build({ path = request.path });
+	local canonical_query = "";
+	local canonical_headers = array();
+	local signed_headers = array()
+
+	if query then
+		local sorted_query = array();
+		for name, value in it.sorted_pairs(query) do
+			sorted_query:push({ name = name; value = value });
+		end
+		sorted_query:sort(function (a,b) return a.name < b.name end)
+		canonical_query = httputil.formencode(sorted_query):gsub("%%%x%x", string.upper);
+		request.query = canonical_query;
+	end
+
+	for header_name, header_value in it.sorted_pairs(headers) do
+		header_name = header_name:lower();
+		canonical_headers:push(header_name .. ":" .. header_value .. "\n");
+		signed_headers:push(header_name);
+	end
+
+	canonical_headers = canonical_headers:concat();
+	signed_headers = signed_headers:concat(";");
+
+	local scope = aws_date .. "/" .. region .. "/s3/aws4_request";
+
+	local canonical_request = method .. "\n"
+		.. canonical_uri .. "\n"
+		.. canonical_query .. "\n"
+		.. canonical_headers .. "\n"
+		.. signed_headers .. "\n"
+		.. payload_hash;
+
+	local signature_payload = "AWS4-HMAC-SHA256" .. "\n" .. aws_datetime .. "\n" .. scope .. "\n" .. sha256(canonical_request, true);
+
+	-- This can be cached?
+	local date_key = hmac_sha256("AWS4" .. secret_key, aws_date);
+	local date_region_key = hmac_sha256(date_key, region);
+	local date_region_service_key = hmac_sha256(date_region_key, "s3");
+	local signing_key = hmac_sha256(date_region_service_key, "aws4_request");
+
+	local signature = hmac_sha256(signing_key, signature_payload, true);
+
+	headers["Authorization"] = string.format(aws4_format, access_key, scope, signed_headers, signature);
+
+	options.headers = headers;
+end
+
+function driver:open(store, typ)
+	local mt = self[typ or "keyval"]
+	if not mt then
+		return nil, "unsupported-store";
+	end
+	local httpclient = http.new({ connection_pooling = true });
+	httpclient.events.add_handler("pre-request", aws_auth);
+	return setmetatable({ store = store; bucket = bucket; type = typ; http = httpclient }, mt);
+end
+
+local keyval = { };
+driver.keyval = { __index = keyval; __name = module.name .. " keyval store" };
+
+local function new_request(self, method, path, query, payload)
+	local request = url.parse(base_uri);
+	request.path = path;
+
+	return self.http:request(url.build(request), { method = method; body = payload; query = query });
+end
+
+-- coerce result back into Prosody data type
+local function on_result(response)
+	if response.code == 404 and response.request.method == "GET" then
+		return nil;
+	end
+	if response.code >= 400 then
+		error(response.body);
+	end
+	local content_type = response.headers["content-type"];
+	if content_type == "application/json" then
+		return json.decode(response.body);
+	elseif content_type == "application/xml" then
+		return xml.parse(response.body);
+	elseif content_type == "application/x-www-form-urlencoded" then
+		return httputil.formdecode(response.body);
+	else
+		module:log("warn", "Unknown response data type %s", content_type);
+		return response.body;
+	end
+end
+
+function keyval:_path(key)
+	return url.build_path({
+		is_absolute = true;
+		bucket;
+		jid.escape(module.host);
+		jid.escape(key or "@");
+		jid.escape(self.store);
+	})
+end
+
+function keyval:get(user)
+	return async.wait_for(new_request(self, "GET", self:_path(user)):next(on_result));
+end
+
+function keyval:set(user, data)
+
+	if data == nil or (type(data) == "table" and next(data) == nil) then
+		return async.wait_for(new_request(self, "DELETE", self:_path(user)));
+	end
+
+	return async.wait_for(new_request(self, "PUT", self:_path(user), nil, data));
+end
+
+function keyval:users()
+	local bucket_path = url.build_path({ is_absolute = true; bucket; is_directory = true });
+	local prefix = jid.escape(module.host) .. "/";
+	local list_result, err = async.wait_for(new_request(self, "GET", bucket_path, { prefix = prefix }))
+	if err or list_result.code ~= 200 then
+		return nil, err;
+	end
+	local list_bucket_result = xml.parse(list_result.body);
+	if list_bucket_result:get_child_text("IsTruncated") == "true" then
+		local max_keys = list_bucket_result:get_child_text("MaxKeys");
+		module:log("warn", "Paging truncated results not implemented, max %s %s returned", max_keys, self.store);
+	end
+	local keys = array();
+	local store_part = jid.escape(self.store);
+	for content in list_bucket_result:childtags("Contents") do
+		local key = url.parse_path(content:get_child_text("Key"));
+		if key[3] == store_part then
+			keys:push(jid.unescape(key[2]));
+		end
+	end
+	return function()
+		return keys:pop();
+	end
+end
+
+local archive = {};
+driver.archive = { __index = archive };
+
+archive.caps = {
+	full_id_range = true; -- both before and after used
+	ids = true;
+};
+
+function archive:_path(username, date, when, with, key)
+	return url.build_path({
+		is_absolute = true;
+		bucket;
+		jid.escape(module.host);
+		jid.escape(username or "@");
+		jid.escape(self.store);
+		date or dt.date(when);
+		jid.escape(with and jid.prep(with) or "@");
+		key;
+	})
+end
+
+
+-- PUT .../with/when/id
+function archive:append(username, key, value, when, with)
+	key = key or new_uuid();
+	return async.wait_for(new_request(self, "PUT", self:_path(username, nil, when, with, key), nil, value):next(function(r)
+		if r.code == 200 then
+			return key;
+		else
+			error(r.body);
+		end
+	end));
+end
+
+function archive:find(username, query)
+	local bucket_path = url.build_path({ is_absolute = true; bucket; is_directory = true });
+	local prefix = { jid.escape(module.host); jid.escape(username or "@"); jid.escape(self.store) };
+	if not query then
+		query = {};
+	end
+
+	if query["start"] and query["end"] and dt.date(query["start"]) == dt.date(query["end"]) then
+		table.insert(prefix, dt.date(query["start"]));
+		if query["with"] then
+			table.insert(prefix, jid.escape(query["with"]));
+		end
+	end
+
+	prefix = table.concat(prefix, "/").."/";
+	local list_result, err = async.wait_for(new_request(self, "GET", bucket_path, {
+		prefix = prefix;
+		["max-keys"] = query["limit"] and tostring(query["limit"]);
+	}));
+	if err or list_result.code ~= 200 then
+		return nil, err;
+	end
+	local list_bucket_result = xml.parse(list_result.body);
+	if list_bucket_result:get_child_text("IsTruncated") == "true" then
+		local max_keys = list_bucket_result:get_child_text("MaxKeys");
+		module:log("warn", "Paging truncated results not implemented, max %s %s returned", max_keys, self.store);
+	end
+	local keys = array();
+	local iterwrap = function(...)
+		return ...;
+	end
+	if query["reverse"] then
+		query["before"], query["after"] = query["after"], query["before"];
+		iterwrap = it.reverse;
+	end
+	local ids = query["ids"] and set.new(query["ids"]);
+	local found = not query["after"];
+	for content in iterwrap(list_bucket_result:childtags("Contents")) do
+		local date, with, id = table.unpack(url.parse_path(content:get_child_text("Key")), 4);
+		local when = dt.parse(content:get_child_text("LastModified"));
+		with = jid.unescape(with);
+		if found and query["before"] == id then
+			break
+		end
+		if (not query["with"] or query["with"] == with)
+		and (not query["start"] or query["start"] <= when)
+		and (not query["end"] or query["end"] >= when)
+		and (not ids or ids:contains(id))
+		and found then
+			keys:push({ key = id; date = date; when = when; with = with });
+		end
+		if not found and id == query["after"] then
+			found = not found
+		end
+	end
+	keys:sort(function(a, b)
+		if a.date ~= b.date then
+			return a.date < b.date
+		end
+		if a.when ~= b.when then
+			return a.when < b.when;
+		end
+		return a.key < b.key;
+	end);
+	if query["reverse"] then
+		keys:reverse();
+	end
+	local i = 0;
+	local function get_next()
+		i = i + 1;
+		local item = keys[i];
+		if item == nil then
+			return nil;
+		end
+		-- luacheck: ignore 431/err
+		local value, err = async.wait_for(new_request(self, "GET", self:_path(username or "@", item.date, nil, item.with, item.key)):next(on_result));
+		if not value then
+			module:log("error", "%s", err);
+			return nil;
+		end
+		return item.key, value, item.when, item.with;
+	end
+	return get_next;
+end
+
+function archive:users()
+	return it.unique(keyval.users(self));
+end
+
+local function count(t) local n = 0; for _ in pairs(t) do n = n + 1; end return n; end
+
+function archive:delete(username, query)
+	local deletions = {};
+	for key, _, when, with in self:find(username, query) do
+		deletions[key] = new_request(self, "DELETE", self:_path(username or "@", dt.date(when), nil, with, key));
+	end
+	return async.wait_for(promise.all(deletions):next(count));
+end
+
+module:provides("storage", driver);
--- a/mod_storage_xmlarchive/README.markdown	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_storage_xmlarchive/README.markdown	Tue Feb 06 18:32:01 2024 +0700
@@ -63,12 +63,14 @@
 `mod_storage_xmlarchive`:
 
 ``` bash
-prosodyctl mod_storage_xmlarchive convert $DIR internal $STORE $JID
+prosodyctl mod_storage_xmlarchive convert $DIR internal $STORE $JID+
 ```
 
 Where `$DIR` is `to` or `from`, `$STORE` is e.g. `archive` or `archive2`
-for MAM and `muc_log` for MUC logs. Finally, `$JID` is the JID of the
-user or MUC room to be migrated, which can be repeated.
+for MAM and `muc_log` for MUC logs. Finally, `$JID` is one or more JID
+of the users or MUC rooms to be migrated.
+
+To migrate all users/rooms on a particular host, pass a bare hostname.
 
 ::: {.alert .alert-danger}
 Since this is a destructive command, don't forget to backup your data
--- a/mod_storage_xmlarchive/mod_storage_xmlarchive.lua	Tue Aug 29 23:51:17 2023 +0700
+++ b/mod_storage_xmlarchive/mod_storage_xmlarchive.lua	Tue Feb 06 18:32:01 2024 +0700
@@ -13,6 +13,7 @@
 local new_stream = require "util.xmppstream".new;
 local xml = require "util.xml";
 local async = require "util.async";
+local it = require "util.iterators";
 local empty = {};
 
 if not dm.append_raw then
@@ -33,7 +34,7 @@
 	return getmetatable(s) == st.stanza_mt;
 end
 
-function archive:append(username, _, data, when, with)
+function archive:append(username, id, data, when, with)
 	if not is_stanza(data) then
 		module:log("error", "Attempt to store non-stanza object, traceback: %s", debug.traceback());
 		return nil, "unsupported-datatype";
@@ -56,7 +57,7 @@
 
 	local offset = ok and err or 0;
 
-	local id = day .. "-" .. hmac_sha256(username.."@"..day.."+"..offset, data, true):sub(-16);
+	id = id or day .. "-" .. hmac_sha256(username.."@"..day.."+"..offset, data, true):sub(-16);
 	ok, err = dm.list_append(username.."@"..day, self.host, self.store,
 		{ id = id, when = dt.datetime(when), with = with, offset = offset, length = #data });
 	if ok and first_of_day then
@@ -438,8 +439,13 @@
 	return dates;
 end
 
+-- filter out the 'user@yyyy-mm-dd' stores
+local function skip_at_date(item)
+	return not item:find("@");
+end
+
 function archive:users()
-	return dm.users(module.host, self.store, "list");
+	return it.filter(skip_at_date, dm.users(self.host, self.store, "list"));
 end
 
 local provider = {};
@@ -544,11 +550,16 @@
 		if arg[3] == "internal" then
 			for i = 5, #arg do
 				local user, host = jid.prepped_split(arg[i]);
-				if not user then
-					print(string.format("Argument #%d (%q) is an invalid JID, aborting", i, arg[i]));
-					os.exit(1);
+				if user then
+					print(arg[i]);
+					convert(user, host, store);
+				else
+					-- luacheck: ignore 421/user
+					for user in archive.users({ host = host; store = store }) do
+						print(user.."@"..host);
+						convert(user, host, store);
+					end
 				end
-				convert(user, host, store);
 			end
 			print("Done");
 			return 0;
@@ -557,6 +568,6 @@
 			print("Check out https://modules.prosody.im/mod_migrate");
 		end
 	end
-	print("prosodyctl mod_storage_xmlarchive convert (from|to) internal (archive|archive2|muc_log) user@host");
+	print("prosodyctl mod_storage_xmlarchive convert (from|to) internal (archive|archive2|muc_log) [user@host]");
 end