Merge 0.12->trunk
authorMatthew Wild <mwild1@gmail.com>
Wed, 27 Mar 2024 15:39:03 +0000
changeset 13470 5d9ec2e55d74
parent 13468 2dbc169aae6a (diff)
parent 13469 54a936345aaa (current diff)
child 13471 c2a476f4712a
Merge 0.12->trunk
util/prosodyctl/check.lua
--- a/.editorconfig	Wed Mar 27 15:35:15 2024 +0000
+++ b/.editorconfig	Wed Mar 27 15:39:03 2024 +0000
@@ -1,10 +1,14 @@
+# https://editorconfig.org/
+
 root = true
 
 [*]
+charset = utf-8
 end_of_line = lf
 indent_style = tab
 insert_final_newline = true
 trim_trailing_whitespace = true
+max_line_length = 150
 
 [CHANGES]
 indent_size = 4
@@ -14,6 +18,7 @@
 indent_size = 3
 indent_style = space
 
-[**.xml]
+[*.xml]
+# xmllint --nsclean --encode UTF-8 --noent --format -
 indent_size = 2
 indent_style = space
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.lua-format	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,32 @@
+align_args: false
+align_parameter: false
+align_table_field: true
+break_after_functioncall_lp: false
+break_after_functiondef_lp: false
+break_after_operator: false
+break_after_table_lb: true
+break_before_functioncall_rp: false
+break_before_functiondef_rp: false
+break_before_table_rb: true
+chop_down_kv_table: true
+chop_down_parameter: false
+chop_down_table: true
+column_limit: 150
+column_table_limit: 120
+continuation_indent_width: 1
+double_quote_to_single_quote: false
+extra_sep_at_table_end: true
+indent_width: 1
+keep_simple_control_block_one_line: true
+keep_simple_function_one_line: true
+line_breaks_after_function_body: 1
+line_separator: input
+single_quote_to_double_quote: false
+spaces_around_equals_in_field: true
+spaces_before_call: 1
+spaces_inside_functioncall_parens: false
+spaces_inside_functiondef_parens: false
+spaces_inside_table_braces: true
+tab_width: 1
+table_sep: ";"
+use_tab: true
--- a/.luacheckrc	Wed Mar 27 15:35:15 2024 +0000
+++ b/.luacheckrc	Wed Mar 27 15:39:03 2024 +0000
@@ -2,7 +2,7 @@
 codes = true
 ignore = { "411/err", "421/err", "411/ok", "421/ok", "211/_ENV", "431/log", "214", "581" }
 
-std = "lua53c"
+std = "lua54c"
 max_line_length = 150
 
 read_globals = {
@@ -61,7 +61,10 @@
 		"module.hourly",
 		"module.broadcast",
 		"module.context",
+		"module.could",
 		"module.depends",
+		"module.default_permission",
+		"module.default_permissions",
 		"module.fire_event",
 		"module.get_directory",
 		"module.get_host",
@@ -71,9 +74,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",
@@ -86,8 +92,10 @@
 		"module.load_resource",
 		"module.log",
 		"module.log_status",
+		"module.may",
 		"module.measure",
 		"module.metric",
+		"module.on_ready",
 		"module.open_store",
 		"module.provides",
 		"module.remove_item",
@@ -149,15 +157,10 @@
 		"net/dns.lua";
 		"net/server_select.lua";
 
-		"util/vcard.lua";
-
-		"plugins/mod_storage_sql1.lua";
-
 		"spec/core_moduleapi_spec.lua";
 		"spec/util_http_spec.lua";
 		"spec/util_ip_spec.lua";
 		"spec/util_multitable_spec.lua";
-		"spec/util_rfc6724_spec.lua";
 		"spec/util_throttle_spec.lua";
 
 		"tools/ejabberd2prosody.lua";
@@ -171,6 +174,7 @@
 		"tools/migration/migrator/prosody_sql.lua";
 		"tools/migration/prosody-migrator.lua";
 		"tools/openfire2prosody.lua";
+		"tools/test_mutants.sh.lua";
 		"tools/xep227toprosody.lua";
 	}
 	for _, file in ipairs(exclude_files) do
--- a/.semgrep.yml	Wed Mar 27 15:35:15 2024 +0000
+++ b/.semgrep.yml	Wed Mar 27 15:39:03 2024 +0000
@@ -22,3 +22,9 @@
   message: Non-string default from :get_option_string
   severity: ERROR
   languages: [lua]
+- id: stanza-empty-text-constructor
+  patterns:
+    - pattern: $A:text()
+  message: Use :get_text() to read text, or pass a value here to add text
+  severity: WARNING
+  languages: [lua]
--- a/CHANGES	Wed Mar 27 15:35:15 2024 +0000
+++ b/CHANGES	Wed Mar 27 15:39:03 2024 +0000
@@ -1,3 +1,78 @@
+TRUNK
+=====
+
+## New
+
+### Administration
+
+- Add 'watch log' command to follow live debug logs at runtime (even if disabled)
+
+### Networking
+
+- Honour 'weight' parameter during SRV record selection
+- Support for RFC 8305 "Happy Eyeballs" to improve IPv4/IPv6 connectivity
+- Support for TCP Fast Open in server_epoll (pending LuaSocket support)
+- Support for deferred accept in server_epoll (pending LuaSocket support)
+
+### MUC
+
+- Permissions updates:
+  - Room creation restricted to local users (of the parent host) by default
+    - restrict_room_creation = true restricts to admins, false disables all restrictions
+  - Persistent rooms can only be created by local users (parent host) by default
+    - muc_room_allow_persistent = false restricts to admins
+  - Public rooms can only be created by local users (parent host) by default
+    - muc_room_allow_public = false restricts to admins
+- Commands to show occupants and affiliations in the Shell
+- Save 'reason' text supplied with affiliation change
+
+### Security and authentication
+
+- Advertise supported SASL Channel-Binding types (XEP-0440)
+- Implement RFC 9266 'tls-exporter' channel binding with TLS 1.3
+- Implement 'tls-server-end-point' channel binding
+- New role and permissions framework and API
+- Ability to disable and enable user accounts
+- Full DANE support for s2s
+- A "grace period" is now supported for deletion requests via in-band registration
+
+### Storage
+
+- New 'keyval+' combined keyval/map store type
+- Performance improvements in internal archive stores
+- Ability to use SQLite3 storage using LuaSQLite3 instead of LuaDBI
+
+### Module API
+
+- Config interface API can require that string values be picked from a provided set
+- Acceptable interval can be specified for number options
+- Method for parsing time periods / intervals from config
+- Method for retrieving integer settings from config
+- It is now easy for modules to expose a Prosody shell command, by adding a shell-command item
+- Modules can now implement a module.ready method which will be called after server initialization
+
+### Configuration
+
+- The configuration file now supports referring and appending to options previously set
+- Direct usage of the Lua API in the config file is deprecated, but can now be accessed via Lua.* instead
+
+## Changes
+
+- Support sub-second precision timestamps
+- mod_blocklist: New option 'migrate_legacy_blocking' to disable migration from mod_privacy
+- Moved all modules into the Lua namespace `prosody.`
+- Forwarded header from RFC 7239 supported, disabled by default
+- mod_http_file_share now uses roles framework, affecting access from e.g. components
+- Intervals of mod_cron managed periodic jobs made configurable
+- When mod_smacks is enabled, s2s connections not responding to ack requests are closed.
+- Arguments to `prosodyctl shell` that start with ':' are now turned into method calls
+
+## Removed
+
+- Lua 5.1 support
+- XEP-0090 support removed from mod_time
+- util.rfc6724
+
 0.12.0
 ======
 
--- a/GNUmakefile	Wed Mar 27 15:35:15 2024 +0000
+++ b/GNUmakefile	Wed Mar 27 15:39:03 2024 +0000
@@ -42,6 +42,10 @@
 	$(INSTALL_EXEC) ./prosody.install $(BIN)/prosody
 	$(INSTALL_EXEC) ./prosodyctl.install $(BIN)/prosodyctl
 
+install-loader:
+	$(MKDIR) $(SOURCE)
+	$(INSTALL_DATA) loader.lua $(SOURCE)
+
 install-core:
 	$(MKDIR) $(SOURCE)
 	$(MKDIR) $(SOURCE)/core
@@ -71,12 +75,13 @@
 
 install-plugins:
 	$(MKDIR) $(MODULES)
-	$(MKDIR) $(MODULES)/mod_pubsub $(MODULES)/adhoc $(MODULES)/muc $(MODULES)/mod_mam
+	$(MKDIR) $(MODULES)/mod_pubsub $(MODULES)/adhoc $(MODULES)/muc $(MODULES)/mod_mam $(MODULES)/mod_debug_stanzas
 	$(INSTALL_DATA) plugins/*.lua $(MODULES)
 	$(INSTALL_DATA) plugins/mod_pubsub/*.lua $(MODULES)/mod_pubsub
 	$(INSTALL_DATA) plugins/adhoc/*.lua $(MODULES)/adhoc
 	$(INSTALL_DATA) plugins/muc/*.lua $(MODULES)/muc
 	$(INSTALL_DATA) plugins/mod_mam/*.lua $(MODULES)/mod_mam
+	$(INSTALL_DATA) plugins/mod_debug_stanzas/*.lua $(MODULES)/mod_debug_stanzas
 
 install-man:
 	$(MKDIR) $(MAN)/man1
@@ -88,7 +93,7 @@
 install-data:
 	$(MKDIR_PRIVATE) $(DATA)
 
-install: install-util install-net install-core install-plugins install-bin install-etc install-man install-meta install-data
+install: install-util install-net install-core install-plugins install-bin install-etc install-man install-meta install-data install-loader
 
 clean:
 	rm -f prosody.install
@@ -98,10 +103,10 @@
 	$(MAKE) clean -C util-src
 
 test:
-	$(BUSTED) --lua=$(RUNWITH)
+	$(BUSTED) --helper loader --lua=$(RUNWITH)
 
 test-%:
-	$(BUSTED) --lua=$(RUNWITH) -r $*
+	$(BUSTED) --helper loader --lua=$(RUNWITH) -r $*
 
 integration-test: all
 	$(MKDIR) data
@@ -130,7 +135,7 @@
 	@echo $$(sed -n '/^\tlocal exclude_files/,/^}/p;' .luacheckrc | sed '1d;$d' | wc -l) files ignored
 	shellcheck configure
 
-vpath %.tl teal-src/
+vpath %.tl teal-src/prosody
 %.lua: %.tl
 	tl -I teal-src/ --gen-compat off --gen-target 5.1 gen $^ -o $@
 	-lua-format -i $@
--- a/certs/openssl.cnf	Wed Mar 27 15:35:15 2024 +0000
+++ b/certs/openssl.cnf	Wed Mar 27 15:39:03 2024 +0000
@@ -46,7 +46,7 @@
 
 [ subject_alternative_name ]
 
-# See http://tools.ietf.org/html/rfc6120#section-13.7.1.2 for more info.
+# See https://www.rfc-editor.org/rfc/rfc6120.html#section-13.7.1.2 for more info.
 
 DNS.0       =                                           example.com
 otherName.0 =                 xmppAddr;FORMAT:UTF8,UTF8:example.com
--- a/configure	Wed Mar 27 15:35:15 2024 +0000
+++ b/configure	Wed Mar 27 15:39:03 2024 +0000
@@ -45,7 +45,7 @@
                             Default is \$PREFIX/lib
 --datadir=DIR               Location where the server data should be stored.
                             Default is \$PREFIX/var/lib/$APP_DIRNAME
---lua-version=VERSION       Use specific Lua version: 5.1, 5.2, or 5.3
+--lua-version=VERSION       Use specific Lua version: 5.2, 5.3, or 5.4
                             Default is auto-detected.
 --lua-suffix=SUFFIX         Versioning suffix to use in Lua filenames.
                             Default is "$LUA_SUFFIX" (lua$LUA_SUFFIX...)
@@ -173,7 +173,8 @@
    --lua-version|--with-lua-version)
       [ -n "$value" ] || die "Missing value in flag $key."
       LUA_VERSION="$value"
-      [ "$LUA_VERSION" = "5.1" ] || [ "$LUA_VERSION" = "5.2" ] || [ "$LUA_VERSION" = "5.3" ] || [ "$LUA_VERSION" = "5.4" ] || die "Invalid Lua version in flag $key."
+      [ "$LUA_VERSION" != "5.1" ] || die "Lua 5.1 is no longer supported"
+      [ "$LUA_VERSION" = "5.2" ] || [ "$LUA_VERSION" = "5.3" ] || [ "$LUA_VERSION" = "5.4" ] || die "Invalid Lua version in flag $key."
       LUA_VERSION_SET=yes
       ;;
    --with-lua)
@@ -275,11 +276,11 @@
       CFLAGS="$CFLAGS -ggdb"
    fi
    if [ "$OSPRESET" = "freebsd" ] || [ "$OSPRESET" = "openbsd" ]; then
-      LUA_INCDIR="/usr/local/include/lua51"
+      LUA_INCDIR="/usr/local/include/lua52"
       LUA_INCDIR_SET=yes
       CFLAGS="-Wall -fPIC -I/usr/local/include"
       LDFLAGS="-I/usr/local/include -L/usr/local/lib -shared"
-      LUA_SUFFIX="51"
+      LUA_SUFFIX="52"
       LUA_SUFFIX_SET=yes
       LUA_DIR=/usr/local
       LUA_DIR_SET=yes
@@ -291,16 +292,16 @@
       LUA_INCDIR_SET="yes"
    fi
    if [ "$OSPRESET" = "netbsd" ]; then
-      LUA_INCDIR="/usr/pkg/include/lua-5.1"
+      LUA_INCDIR="/usr/pkg/include/lua-5.2"
       LUA_INCDIR_SET=yes
-      LUA_LIBDIR="/usr/pkg/lib/lua/5.1"
+      LUA_LIBDIR="/usr/pkg/lib/lua/5.2"
       LUA_LIBDIR_SET=yes
       CFLAGS="-Wall -fPIC -I/usr/pkg/include"
       LDFLAGS="-L/usr/pkg/lib -Wl,-rpath,/usr/pkg/lib -shared"
    fi
    if [ "$OSPRESET" = "pkg-config" ]; then
       if [ "$LUA_SUFFIX_SET" != "yes" ]; then
-         LUA_SUFFIX="5.1";
+         LUA_SUFFIX="5.4";
          LUA_SUFFIX_SET=yes
       fi
       LUA_CF="$(pkg-config --cflags-only-I lua"$LUA_SUFFIX")"
@@ -335,7 +336,7 @@
 fi
 
 detect_lua_version() {
-   detected_lua=$("$1" -e 'print(_VERSION:match(" (5%.[1234])$"))' 2> /dev/null)
+   detected_lua=$("$1" -e 'print(_VERSION:match(" (5%.[234])$"))' 2> /dev/null)
    if [ "$detected_lua" != "nil" ]
    then
       if [ "$LUA_VERSION_SET" != "yes" ]
@@ -389,10 +390,7 @@
 lua_interp_found=no
 if [ "$LUA_SUFFIX_SET" != "yes" ]
 then
-   if [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.1" ]
-   then
-      suffixes="5.1 51 -5.1 -51"
-   elif [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.2" ]
+   if [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.2" ]
    then
       suffixes="5.2 52 -5.2 -52"
    elif [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.3" ]
@@ -402,8 +400,7 @@
    then
       suffixes="5.4 54 -5.4 -54"
    else
-      suffixes="5.1 51 -5.1 -51"
-      suffixes="$suffixes 5.2 52 -5.2 -52"
+      suffixes="5.2 52 -5.2 -52"
       suffixes="$suffixes 5.3 53 -5.3 -53"
       suffixes="$suffixes 5.4 54 -5.4 -54"
    fi
--- a/core/certmanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/certmanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,15 +6,13 @@
 -- COPYING file in the source package for more information.
 --
 
-local ssl = require "ssl";
-local configmanager = require "core.configmanager";
-local log = require "util.logger".init("certmanager");
-local ssl_context = ssl.context or require "ssl.context";
-local ssl_newcontext = ssl.newcontext;
-local new_config = require"util.sslconfig".new;
+local configmanager = require "prosody.core.configmanager";
+local log = require "prosody.util.logger".init("certmanager");
+local new_config = require"prosody.net.server".tls_builder;
+local tls = require "prosody.net.tls_luasec";
 local stat = require "lfs".attributes;
 
-local x509 = require "util.x509";
+local x509 = require "prosody.util.x509";
 local lfs = require "lfs";
 
 local tonumber, tostring = tonumber, tostring;
@@ -28,33 +26,10 @@
 local pcall = pcall;
 
 local prosody = prosody;
-local pathutil = require"util.paths";
+local pathutil = require"prosody.util.paths";
 local resolve_path = pathutil.resolve_relative_path;
 local config_path = prosody.paths.config or ".";
 
-local function test_option(option)
-	return not not ssl_newcontext({mode="server",protocol="sslv23",options={ option }});
-end
-
-local luasec_major, luasec_minor = ssl._VERSION:match("^(%d+)%.(%d+)");
-local luasec_version = tonumber(luasec_major) * 100 + tonumber(luasec_minor);
-local luasec_has = ssl.config or {
-	algorithms = {
-		ec = luasec_version >= 5;
-	};
-	capabilities = {
-		curves_list = luasec_version >= 7;
-	};
-	options = {
-		cipher_server_preference = test_option("cipher_server_preference");
-		no_ticket = test_option("no_ticket");
-		no_compression = test_option("no_compression");
-		single_dh_use = test_option("single_dh_use");
-		single_ecdh_use = test_option("single_ecdh_use");
-		no_renegotiation = test_option("no_renegotiation");
-	};
-};
-
 local _ENV = nil;
 -- luacheck: std none
 
@@ -122,7 +97,7 @@
 				local firstline = f:read();
 				if firstline == "-----BEGIN CERTIFICATE-----" and lfs.attributes(find_matching_key(full), "mode") == "file" then
 					f:seek("set")
-					local cert = ssl.loadcertificate(f:read("*a"))
+					local cert = tls.load_certificate(f:read("*a"))
 					-- TODO if more than one cert is found for a name, the most recently
 					-- issued one should be used.
 					-- for now, just filter out expired certs
@@ -207,18 +182,18 @@
 	protocol = "tlsv1+";
 	verify = "none";
 	options = {
-		cipher_server_preference = luasec_has.options.cipher_server_preference;
-		no_ticket = luasec_has.options.no_ticket;
-		no_compression = luasec_has.options.no_compression and configmanager.get("*", "ssl_compression") ~= true;
-		single_dh_use = luasec_has.options.single_dh_use;
-		single_ecdh_use = luasec_has.options.single_ecdh_use;
-		no_renegotiation = luasec_has.options.no_renegotiation;
+		cipher_server_preference = tls.features.options.cipher_server_preference;
+		no_ticket = tls.features.options.no_ticket;
+		no_compression = tls.features.options.no_compression and configmanager.get("*", "ssl_compression") ~= true;
+		single_dh_use = tls.features.options.single_dh_use;
+		single_ecdh_use = tls.features.options.single_ecdh_use;
+		no_renegotiation = tls.features.options.no_renegotiation;
 	};
 	verifyext = {
 		"lsec_continue", -- Continue past certificate verification errors
 		"lsec_ignore_purpose", -- Validate client certificates as if they were server certificates
 	};
-	curve = luasec_has.algorithms.ec and not luasec_has.capabilities.curves_list and "secp384r1";
+	curve = tls.features.algorithms.ec and not tls.features.capabilities.curves_list and "secp384r1";
 	curveslist = {
 		"X25519",
 		"P-384",
@@ -235,7 +210,7 @@
 		"!3DES",       -- 3DES - slow and of questionable security
 		"!aNULL",      -- Ciphers that does not authenticate the connection
 	};
-	dane = luasec_has.capabilities.dane and configmanager.get("*", "use_dane") and { "no_ee_namechecks" };
+	dane = tls.features.capabilities.dane and configmanager.get("*", "use_dane") and { "no_ee_namechecks" };
 }
 
 local mozilla_ssl_configs = {
@@ -304,9 +279,9 @@
 };
 
 
-if luasec_has.curves then
+if tls.features.curves then
 	for i = #core_defaults.curveslist, 1, -1 do
-		if not luasec_has.curves[ core_defaults.curveslist[i] ] then
+		if not tls.features.curves[ core_defaults.curveslist[i] ] then
 			t_remove(core_defaults.curveslist, i);
 		end
 	end
@@ -314,10 +289,6 @@
 	core_defaults.curveslist = nil;
 end
 
-local path_options = { -- These we pass through resolve_path()
-	key = true, certificate = true, cafile = true, capath = true, dhparam = true
-}
-
 local function create_context(host, mode, ...)
 	local cfg = new_config();
 	cfg:apply(core_defaults);
@@ -351,39 +322,12 @@
 
 	if mode == "server" then
 		if not user_ssl_config.certificate then
-			log("info", "No certificate present in SSL/TLS configuration for %s. SNI will be required.", host);
+			log("debug", "No certificate present in SSL/TLS configuration for %s. SNI will be required.", host);
 		end
 		if user_ssl_config.certificate and not user_ssl_config.key then return nil, "No key present in SSL/TLS configuration for "..host; end
 	end
 
-	for option in pairs(path_options) do
-		if type(user_ssl_config[option]) == "string" then
-			user_ssl_config[option] = resolve_path(config_path, user_ssl_config[option]);
-		else
-			user_ssl_config[option] = nil;
-		end
-	end
-
-	-- LuaSec expects dhparam to be a callback that takes two arguments.
-	-- We ignore those because it is mostly used for having a separate
-	-- set of params for EXPORT ciphers, which we don't have by default.
-	if type(user_ssl_config.dhparam) == "string" then
-		local f, err = io_open(user_ssl_config.dhparam);
-		if not f then return nil, "Could not open DH parameters: "..err end
-		local dhparam = f:read("*a");
-		f:close();
-		user_ssl_config.dhparam = function() return dhparam; end
-	end
-
-	local ctx, err = ssl_newcontext(user_ssl_config);
-
-	-- COMPAT Older LuaSec ignores the cipher list from the config, so we have to take care
-	-- of it ourselves (W/A for #x)
-	if ctx and user_ssl_config.ciphers then
-		local success;
-		success, err = ssl_context.setcipher(ctx, user_ssl_config.ciphers);
-		if not success then ctx = nil; end
-	end
+	local ctx, err = cfg:build();
 
 	if not ctx then
 		err = err or "invalid ssl config"
@@ -422,10 +366,16 @@
 local function reload_ssl_config()
 	global_ssl_config = configmanager.get("*", "ssl");
 	global_certificates = configmanager.get("*", "certificates") or "certs";
-	if luasec_has.options.no_compression then
+	if tls.features.options.no_compression then
 		core_defaults.options.no_compression = configmanager.get("*", "ssl_compression") ~= true;
 	end
-	core_defaults.dane = configmanager.get("*", "use_dane") or false;
+	if not configmanager.get("*", "use_dane") then
+		core_defaults.dane = false;
+	elseif tls.features.capabilities.dane then
+		core_defaults.dane = { "no_ee_namechecks" };
+	else
+		core_defaults.dane = true;
+	end
 	cert_index = index_certs(resolve_path(config_path, global_certificates));
 end
 
--- a/core/configmanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/configmanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -11,14 +11,15 @@
       setmetatable, rawget, rawset, io, os, error, dofile, type, pairs, ipairs;
 local format, math_max, t_insert = string.format, math.max, table.insert;
 
-local envload = require"util.envload".envload;
-local deps = require"util.dependencies";
-local resolve_relative_path = require"util.paths".resolve_relative_path;
-local glob_to_pattern = require"util.paths".glob_to_pattern;
+local envload = require"prosody.util.envload".envload;
+local deps = require"prosody.util.dependencies";
+local it = require"prosody.util.iterators";
+local resolve_relative_path = require"prosody.util.paths".resolve_relative_path;
+local glob_to_pattern = require"prosody.util.paths".glob_to_pattern;
 local path_sep = package.config:sub(1,1);
-local get_traceback_table = require "util.debug".get_traceback_table;
+local get_traceback_table = require "prosody.util.debug".get_traceback_table;
 
-local encodings = deps.softreq"util.encodings";
+local encodings = deps.softreq"prosody.util.encodings";
 local nameprep = encodings and encodings.stringprep.nameprep or function (host) return host:lower(); end
 
 local _M = {};
@@ -40,16 +41,10 @@
 	return config;
 end
 
-function _M.get(host, key, _oldkey)
-	if key == "core" then
-		key = _oldkey; -- COMPAT with code that still uses "core"
-	end
+function _M.get(host, key)
 	return config[host][key];
 end
-function _M.rawget(host, key, _oldkey)
-	if key == "core" then
-		key = _oldkey; -- COMPAT with code that still uses "core"
-	end
+function _M.rawget(host, key)
 	local hostconfig = rawget(config, host);
 	if hostconfig then
 		return rawget(hostconfig, key);
@@ -68,10 +63,17 @@
 	return false;
 end
 
-function _M.set(host, key, value, _oldvalue)
-	if key == "core" then
-		key, value = value, _oldvalue; --COMPAT with code that still uses "core"
+local function rawget_option(config_table, host, key)
+	if host and key then
+		local hostconfig = rawget(config_table, host);
+		if not hostconfig then
+			return nil;
+		end
+		return rawget(hostconfig, key);
 	end
+end
+
+function _M.set(host, key, value)
 	return set(config, host, key, value);
 end
 
@@ -114,6 +116,51 @@
 			end
 		end
 	end
+
+	local config_option_proxy_mt = {
+		__index = setmetatable({
+			append = function (self, value)
+				local original_option = self:value();
+				if original_option == nil then
+					original_option = {};
+				end
+				if type(value) ~= "table" then
+					error("'append' operation expects a list of values to append to the existing list", 2);
+				end
+				if value[1] ~= nil then
+					for _, v in ipairs(value) do
+						t_insert(original_option, v);
+					end
+				else
+					for k, v in pairs(value) do
+						original_option[k] = v;
+					end
+				end
+				set(self.config_table, self.host, self.option_name, original_option);
+				return self;
+			end;
+			value = function (self)
+				return rawget_option(self.config_table, self.host, self.option_name);
+			end;
+			values = function (self)
+				return it.values(self:value());
+			end;
+		}, {
+			__index = function (t, k) --luacheck: ignore 212/t
+				error("Unknown config option operation: '"..k.."'", 2);
+			end;
+		});
+
+		__call = function (self, v2)
+			local v = self:value() or {};
+			if type(v) == "table" and type(v2) == "table" then
+				return self:append(v2);
+			end
+
+			error("Invalid syntax - missing '=' perhaps?", 2);
+		end;
+	};
+
 	parser = {};
 	function parser.load(data, config_file, config_table)
 		local set_options = {}; -- set_options[host.."/"..option_name] = true (when the option has been set already in this file)
@@ -128,7 +175,37 @@
 					if k:match("^ENV_") then
 						return os.getenv(k:sub(5));
 					end
-					return rawget(_G, k);
+					if k == "Lua" then
+						return _G;
+					end
+					local val = rawget_option(config_table, env.__currenthost or "*", k);
+
+					local g_val = rawget(_G, k);
+
+					if val ~= nil or g_val == nil then
+						if type(val) == "table" then
+							return setmetatable({
+								config_table = config_table;
+								host = env.__currenthost or "*";
+								option_name = k;
+							}, config_option_proxy_mt);
+						end
+						return val;
+					end
+
+					if g_val ~= nil then
+						t_insert(
+							warnings,
+							("%s:%d: direct usage of the Lua API is deprecated - replace `%s` with `Lua.%s`"):format(
+								config_file,
+								get_line_number(config_file),
+								k,
+								k
+							)
+						);
+					end
+
+					return g_val;
 				end,
 				__newindex = function (_, k, v)
 					local host = env.__currenthost or "*";
--- a/core/features.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/features.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,10 +1,33 @@
-local set = require "util.set";
+local set = require "prosody.util.set";
 
 return {
 	available = set.new{
 		-- mod_bookmarks bundled
 		"mod_bookmarks";
+		-- mod_server_info bundled
+		"mod_server_info";
+		-- Roles, module.may and per-session authz
+		"permissions";
+		-- prosody.* namespace
+		"loader";
+		-- "keyval+" store
+		"keyval+";
 
 		"s2sout-pre-connect-event";
+
+		-- prosody:guest, prosody:registered, prosody:member
+		"split-user-roles";
+
+		-- new moduleapi methods
+		"getopt-enum";
+		"getopt-interval";
+		"getopt-period";
+		"getopt-integer";
+
+		-- new module.ready()
+		"module-ready";
+
+		-- SIGUSR1 and 2 events
+		"signal-events";
 	};
 };
--- a/core/hostmanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/hostmanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,18 +6,18 @@
 -- COPYING file in the source package for more information.
 --
 
-local configmanager = require "core.configmanager";
-local modulemanager = require "core.modulemanager";
-local events_new = require "util.events".new;
-local disco_items = require "util.multitable".new();
+local configmanager = require "prosody.core.configmanager";
+local modulemanager = require "prosody.core.modulemanager";
+local events_new = require "prosody.util.events".new;
+local disco_items = require "prosody.util.multitable".new();
 local NULL = {};
 
-local log = require "util.logger".init("hostmanager");
+local log = require "prosody.util.logger".init("hostmanager");
 
 local hosts = prosody.hosts;
 local prosody_events = prosody.events;
 if not _G.prosody.incoming_s2s then
-	require "core.s2smanager";
+	require "prosody.core.s2smanager";
 end
 local incoming_s2s = _G.prosody.incoming_s2s;
 local core_route_stanza = _G.prosody.core_route_stanza;
--- a/core/loggingmanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/loggingmanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,20 +6,20 @@
 -- COPYING file in the source package for more information.
 --
 
-local format = require "util.format".format;
+local format = require "prosody.util.format".format;
 local setmetatable, rawset, pairs, ipairs, type =
 	setmetatable, rawset, pairs, ipairs, type;
 local stdout = io.stdout;
 local io_open = io.open;
 local math_max, rep = math.max, string.rep;
 local os_date = os.date;
-local getstyle, getstring = require "util.termcolours".getstyle, require "util.termcolours".getstring;
-local st = require "util.stanza";
+local getstyle, getstring = require "prosody.util.termcolours".getstyle, require "prosody.util.termcolours".getstring;
+local st = require "prosody.util.stanza";
 
-local config = require "core.configmanager";
-local logger = require "util.logger";
+local config = require "prosody.core.configmanager";
+local logger = require "prosody.util.logger";
 
-local have_pposix, pposix = pcall(require, "util.pposix");
+local have_pposix, pposix = pcall(require, "prosody.util.pposix");
 have_pposix = have_pposix and pposix._VERSION == "0.4.0";
 
 local _ENV = nil;
--- a/core/moduleapi.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/moduleapi.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,28 +6,30 @@
 -- COPYING file in the source package for more information.
 --
 
-local array = require "util.array";
-local set = require "util.set";
-local it = require "util.iterators";
-local logger = require "util.logger";
-local timer = require "util.timer";
-local resolve_relative_path = require"util.paths".resolve_relative_path;
-local st = require "util.stanza";
-local cache = require "util.cache";
-local errors = require "util.error";
-local promise = require "util.promise";
-local time_now = require "util.time".now;
-local format = require "util.format".format;
-local jid_node = require "util.jid".node;
-local jid_resource = require "util.jid".resource;
+local array = require "prosody.util.array";
+local set = require "prosody.util.set";
+local it = require "prosody.util.iterators";
+local logger = require "prosody.util.logger";
+local timer = require "prosody.util.timer";
+local resolve_relative_path = require"prosody.util.paths".resolve_relative_path;
+local st = require "prosody.util.stanza";
+local cache = require "prosody.util.cache";
+local errors = require "prosody.util.error";
+local promise = require "prosody.util.promise";
+local time_now = require "prosody.util.time".now;
+local format = require "prosody.util.format".format;
+local jid_node = require "prosody.util.jid".node;
+local jid_split = require "prosody.util.jid".split;
+local jid_resource = require "prosody.util.jid".resource;
+local human_io = require "prosody.util.human.io";
 
 local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat;
 local error, setmetatable, type = error, setmetatable, type;
 local ipairs, pairs, select = ipairs, pairs, select;
 local tonumber, tostring = tonumber, tostring;
 local require = require;
-local pack = table.pack or require "util.table".pack; -- table.pack is only in 5.2
-local unpack = table.unpack or unpack; --luacheck: ignore 113 -- renamed in 5.2
+local pack = table.pack;
+local unpack = table.unpack;
 
 local prosody = prosody;
 local hosts = prosody.hosts;
@@ -128,14 +130,14 @@
 end
 
 function api:require(lib)
-	local modulemanager = require"core.modulemanager";
+	local modulemanager = require"prosody.core.modulemanager";
 	local f, n = modulemanager.loader:load_code_ext(self.name, lib, "lib.lua", self.environment);
 	if not f then error("Failed to load plugin library '"..lib.."', error: "..n); end -- FIXME better error message
 	return f();
 end
 
 function api:depends(name)
-	local modulemanager = require"core.modulemanager";
+	local modulemanager = require"prosody.core.modulemanager";
 	if self:get_option_inherited_set("modules_disabled", {}):contains(name) then
 		error("Dependency on disabled module mod_"..name);
 	end
@@ -168,6 +170,10 @@
 		end
 	end
 	self.dependencies[name] = true;
+	if not mod.module.reverse_dependencies then
+		mod.module.reverse_dependencies = {};
+	end
+	mod.module.reverse_dependencies[self.name] = true;
 	return mod;
 end
 
@@ -200,7 +206,7 @@
 end
 
 function api:get_option(name, default_value)
-	local config = require "core.configmanager";
+	local config = require "prosody.core.configmanager";
 	local value = config.get(self.host, name);
 	if value == nil then
 		value = default_value;
@@ -227,12 +233,91 @@
 	return tostring(value);
 end
 
-function api:get_option_number(name, ...)
-	local value = self:get_option_scalar(name, ...);
+function api:get_option_number(name, default_value, min, max)
+	local value = self:get_option_scalar(name, default_value);
 	local ret = tonumber(value);
 	if value ~= nil and ret == nil then
 		self:log("error", "Config option '%s' not understood, expecting a number", name);
 	end
+	if ret == default_value then
+		-- skip interval checks for default or nil
+		return ret;
+	end
+	if min and ret < min then
+		self:log("warn", "Config option '%s' out of bounds %g < %g", name, ret, min);
+		return min;
+	end
+	if max and ret > max then
+		self:log("warn", "Config option '%s' out of bounds %g > %g", name, ret, max);
+		return max;
+	end
+	return ret;
+end
+
+function api:get_option_integer(name, default_value, min, max)
+	local value = self:get_option_number(name, default_value, min or math.mininteger or -2 ^ 52, max or math.maxinteger or 2 ^ 53);
+	if value == default_value then
+		-- pass default trough unaltered, violates ranges sometimes
+		return value;
+	end
+	if math.type(value) == "float" then
+		self:log("warn", "Config option '%s' expected an integer, not a float (%g)", name, value)
+		return math.floor(value);
+	end
+	-- nil or an integer
+	return value;
+end
+
+function api:get_option_period(name, default_value, min, max)
+	local value = self:get_option_scalar(name, default_value);
+
+	local ret;
+	if value == "never" or value == false then
+		-- usually for disabling some periodic thing
+		return math.huge;
+	elseif type(value) == "number" then
+		-- assume seconds
+		ret = value;
+	elseif type(value) == "string" then
+		ret = human_io.parse_duration(value);
+		if value ~= nil and ret == nil then
+			ret = human_io.parse_duration_lax(value);
+			if ret then
+				local num = value:match("%d+");
+				self:log("error", "Config option '%s' is set to ambiguous period '%s' - use full syntax e.g. '%s months' or '%s minutes'", name, value, num, num);
+				-- COMPAT: w/more relaxed behaviour in post-0.12 trunk. Return nil for this case too, eventually.
+			else
+				self:log("error", "Config option '%s' not understood, expecting a period (e.g. \"2 days\")", name);
+				return nil;
+			end
+		end
+	elseif value ~= nil then
+		self:log("error", "Config option '%s' expects a number or a period description string (e.g. \"3 hours\"), not %s", name, type(value));
+		return nil;
+	else
+		return nil;
+	end
+
+	if ret < 0 then
+		self:log("debug", "Treating negative period as infinity");
+		return math.huge;
+	end
+
+	if type(min) == "string" then
+		min = human_io.parse_duration(min);
+	end
+	if min and ret < min then
+		self:log("warn", "Config option '%s' out of bounds %g < %g", name, ret, min);
+		return min;
+	end
+	if type(max) == "string" then
+		max = human_io.parse_duration(max);
+	end
+	if max and ret > max then
+		self:log("warn", "Config option '%s' out of bounds %g > %g", name, ret, max);
+		return max;
+	end
+
 	return ret;
 end
 
@@ -305,6 +390,15 @@
 	return resolve_relative_path(parent, value);
 end
 
+function api:get_option_enum(name, default, ...)
+	local value = self:get_option_scalar(name, default);
+	if value == nil then return nil; end
+	local options = set.new{default, ...};
+	if not options:contains(value) then
+		self:log("error", "Config option '%s' not in set of allowed values (one of: %s)", name, options);
+	end
+	return value;
+end
 
 function api:context(host)
 	return setmetatable({ host = host or "*", global = "*" == host }, { __index = self, __newindex = self });
@@ -328,7 +422,7 @@
 end
 
 function api:get_host_items(key)
-	local modulemanager = require"core.modulemanager";
+	local modulemanager = require"prosody.core.modulemanager";
 	local result = modulemanager.get_items(key, self.host) or {};
 	return result;
 end
@@ -537,11 +631,12 @@
 end
 
 function api:open_store(name, store_type)
-	return require"core.storagemanager".open(self.host, name or self.name, store_type);
+	if self.host == "*" then return nil, "global-storage-not-supported"; end
+	return require"prosody.core.storagemanager".open(self.host, name or self.name, store_type);
 end
 
 function api:measure(name, stat_type, conf)
-	local measure = require "core.statsmanager".measure;
+	local measure = require "prosody.core.statsmanager".measure;
 	local fixed_label_key, fixed_label_value
 	if self.host ~= "*" then
 		fixed_label_key = "host"
@@ -556,7 +651,7 @@
 end
 
 function api:metric(type_, name, unit, description, label_keys, conf)
-	local metric = require "core.statsmanager".metric;
+	local metric = require "prosody.core.statsmanager".metric;
 	local is_scoped = self.host ~= "*"
 	if is_scoped then
 		-- prepend `host` label to label keys if this is not a global module
@@ -578,7 +673,7 @@
 function api:set_status(status_type, status_message, override)
 	local priority = status_priorities[status_type];
 	if not priority then
-		self:log("error", "set_status: Invalid status type '%s', assuming 'info'");
+		self:log("error", "set_status: Invalid status type '%s', assuming 'info'", status_type);
 		status_type, priority = "info", status_priorities.info;
 	end
 	local current_priority = status_priorities[self.status_type] or 0;
@@ -601,4 +696,107 @@
 	return self.status_type, self.status_message, self.status_time;
 end
 
+function api:default_permission(role_name, permission)
+	permission = permission:gsub("^:", self.name..":");
+	if self.host == "*" then
+		for _, host in pairs(hosts) do
+			if host.authz then
+				host.authz.add_default_permission(role_name, permission);
+			end
+		end
+		return
+	end
+	hosts[self.host].authz.add_default_permission(role_name, permission);
+end
+
+function api:default_permissions(role_name, permissions)
+	for _, permission in ipairs(permissions) do
+		self:default_permission(role_name, permission);
+	end
+end
+
+function api:could(action, context)
+	return self:may(action, context, true);
+end
+
+function api:may(action, context, peek)
+	if action:byte(1) == 58 then -- action begins with ':'
+		action = self.name..action; -- prepend module name
+	end
+
+	do
+		-- JID-based actor
+		local actor_jid = type(context) == "string" and context or context.actor_jid;
+		if actor_jid then -- check JID permissions
+			local role;
+			local node, host = jid_split(actor_jid);
+			if host == self.host then
+				role = hosts[host].authz.get_user_role(node);
+			else
+				role = hosts[self.host].authz.get_jid_role(actor_jid);
+			end
+			if not role then
+				if not peek then
+					self:log("debug", "Access denied: JID <%s> may not %s (no role found)", actor_jid, action);
+				end
+				return false;
+			end
+			local permit = role:may(action);
+			if not permit then
+				if not peek then
+					self:log("debug", "Access denied: JID <%s> may not %s (not permitted by role %s)", actor_jid, action, role.name);
+				end
+			end
+			return permit;
+		end
+	end
+
+	-- Session-based actor
+	local session = context.origin or context.session;
+	if type(session) ~= "table" then
+		error("Unable to identify actor session from context");
+	end
+	if session.type == "c2s" and session.host == self.host then
+		local role = session.role;
+		if not role then
+			if not peek then
+				self:log("warn", "Access denied: session %s has no role assigned");
+			end
+			return false;
+		end
+		local permit = role:may(action, context);
+		if not permit and not peek then
+			self:log("debug", "Access denied: session %s (%s) may not %s (not permitted by role %s)",
+				session.id, session.full_jid, action, role.name
+			);
+		end
+		return permit;
+	else
+		local actor_jid = context.stanza.attr.from;
+		local role = hosts[self.host].authz.get_jid_role(actor_jid);
+		if not role then
+			if not peek then
+				self:log("debug", "Access denied: JID <%s> may not %s (no role found)", actor_jid, action);
+			end
+			return false;
+		end
+		local permit = role:may(action, context);
+		if not permit and not peek then
+			self:log("debug", "Access denied: JID <%s> may not %s (not permitted by role %s)", actor_jid, action, role.name);
+		end
+		return permit;
+	end
+end
+
+-- Execute a function, once, but only after startup is complete
+function api:on_ready(f) --luacheck: ignore 212/self
+	return prosody.started:next(f);
+end
+
+-- COMPAT w/post 0.12 trunk
+function api:once(f)
+	self:log("warn", "This module uses deprecated module:once() - switch to module:on_ready() or (better) expose function module.ready()");
+	return self:on_ready(f);
+end
+
 return api;
--- a/core/modulemanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/modulemanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,23 +6,23 @@
 -- COPYING file in the source package for more information.
 --
 
-local array = require "util.array";
-local logger = require "util.logger";
+local array = require "prosody.util.array";
+local logger = require "prosody.util.logger";
 local log = logger.init("modulemanager");
-local config = require "core.configmanager";
-local pluginloader = require "util.pluginloader";
-local envload = require "util.envload";
-local set = require "util.set";
+local config = require "prosody.core.configmanager";
+local pluginloader = require "prosody.util.pluginloader";
+local envload = require "prosody.util.envload";
+local set = require "prosody.util.set";
 
-local core_features = require "core.features".available;
+local core_features = require "prosody.core.features".available;
 
-local new_multitable = require "util.multitable".new;
-local api = require "core.moduleapi"; -- Module API container
+local new_multitable = require "prosody.util.multitable".new;
+local api = require "prosody.core.moduleapi"; -- Module API container
 
 local prosody = prosody;
 local hosts = prosody.hosts;
 
-local xpcall = require "util.xpcall".xpcall;
+local xpcall = require "prosody.util.xpcall".xpcall;
 local debug_traceback = debug.traceback;
 local setmetatable, rawget = setmetatable, rawget;
 local ipairs, pairs, type, t_insert = ipairs, pairs, type, table.insert;
@@ -293,6 +293,10 @@
 				ok, err = do_load_module(host, module_name);
 			end
 		end
+
+		if module_has_method(pluginenv, "ready") then
+			pluginenv.module:on_ready(pluginenv.module.ready);
+		end
 	end
 	if not ok then
 		modulemap[api_instance.host][module_name] = nil;
--- a/core/portmanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/portmanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,11 +1,11 @@
-local config = require "core.configmanager";
-local certmanager = require "core.certmanager";
-local server = require "net.server";
+local config = require "prosody.core.configmanager";
+local certmanager = require "prosody.core.certmanager";
+local server = require "prosody.net.server";
 local socket = require "socket";
 
-local log = require "util.logger".init("portmanager");
-local multitable = require "util.multitable";
-local set = require "util.set";
+local log = require "prosody.util.logger".init("portmanager");
+local multitable = require "prosody.util.multitable";
+local set = require "prosody.util.set";
 
 local table = table;
 local setmetatable, rawset, rawget = setmetatable, rawset, rawget;
@@ -48,14 +48,11 @@
 	if err:match(" in use") then
 		-- FIXME: Use service_name here
 		if port == 5222 or port == 5223 or port == 5269 then
-			friendly_message = "check that Prosody or another XMPP server is "
-				.."not already running and using this port";
-		elseif port == 80 or port == 81 then
-			friendly_message = "check that a HTTP server is not already using "
-				.."this port";
+			friendly_message = "check that Prosody or another XMPP server is not already running and using this port";
+		elseif port == 80 or port == 81 or port == 443 then
+			friendly_message = "check that a HTTP server is not already using this port";
 		elseif port == 5280 then
-			friendly_message = "check that Prosody or a BOSH connection manager "
-				.."is not already running";
+			friendly_message = "check that Prosody or a BOSH connection manager is not already running";
 		else
 			friendly_message = "this port is in use by another application";
 		end
@@ -222,6 +219,13 @@
 	return data.service, data.server;
 end
 
+local function get_tls_config_at(interface, port)
+	local data = active_services:search(nil, interface, port);
+	if not data or not data[1] or not data[1][1] then return nil, "not-found"; end
+	data = data[1][1];
+	return data.tls_cfg;
+end
+
 local function get_service(service_name)
 	return (services[service_name] or {})[1];
 end
@@ -240,21 +244,22 @@
 	log("debug", "Gathering certificates for SNI for host %s, %s service", host, service or "default");
 	for name, interface, port, n, active_service --luacheck: ignore 213
 		in active_services:iter(service, nil, nil, nil) do
-		if active_service.server.hosts and active_service.tls_cfg then
-			local config_prefix = (active_service.config_prefix or name).."_";
-			if config_prefix == "_" then config_prefix = ""; end
-			local prefix_ssl_config = config.get(host, config_prefix.."ssl");
+		if active_service.server and active_service.tls_cfg then
 			local alternate_host = name and config.get(host, name.."_host");
 			if not alternate_host and name == "https" then
 				-- TODO should this be some generic thing? e.g. in the service definition
 				alternate_host = config.get(host, "http_host");
 			end
 			local autocert = certmanager.find_host_cert(alternate_host or host);
-			-- luacheck: ignore 211/cfg
-			local ssl, err, cfg = certmanager.create_context(host, "server", prefix_ssl_config, autocert, active_service.tls_cfg);
-			if ssl then
-				active_service.server.hosts[alternate_host or host] = ssl;
-			else
+			local manualcert = active_service.tls_cfg;
+			local certificate = (autocert and autocert.certificate) or manualcert.certificate;
+			local key = (autocert and autocert.key) or manualcert.key;
+			local ok, err = active_service.server:sslctx():set_sni_host(
+				host,
+				certificate,
+				key
+			);
+			if not ok then
 				log("error", "Error creating TLS context for SNI host %s: %s", host, err);
 			end
 		end
@@ -277,7 +282,7 @@
 	for name, interface, port, n, active_service --luacheck: ignore 213
 		in active_services:iter(nil, nil, nil, nil) do
 		if active_service.tls_cfg then
-			active_service.server.hosts[host] = nil;
+			active_service.server:sslctx():remove_sni_host(host)
 		end
 	end
 end);
@@ -312,6 +317,7 @@
 	unregister_service = unregister_service;
 	close = close;
 	get_service_at = get_service_at;
+	get_tls_config_at = get_tls_config_at;
 	get_service = get_service;
 	get_active_services = get_active_services;
 	get_registered_services = get_registered_services;
--- a/core/rostermanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/rostermanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -9,10 +9,10 @@
 
 
 
-local log = require "util.logger".init("rostermanager");
+local log = require "prosody.util.logger".init("rostermanager");
 
-local new_id = require "util.id".short;
-local new_cache = require "util.cache".new;
+local new_id = require "prosody.util.id".short;
+local new_cache = require "prosody.util.cache".new;
 
 local pairs = pairs;
 local tostring = tostring;
@@ -21,9 +21,9 @@
 local hosts = prosody.hosts;
 local bare_sessions = prosody.bare_sessions;
 
-local um_user_exists = require "core.usermanager".user_exists;
-local st = require "util.stanza";
-local storagemanager = require "core.storagemanager";
+local um_user_exists = require "prosody.core.usermanager".user_exists;
+local st = require "prosody.util.stanza";
+local storagemanager = require "prosody.core.storagemanager";
 
 local _ENV = nil;
 -- luacheck: std none
--- a/core/s2smanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/s2smanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -11,8 +11,8 @@
 local hosts = prosody.hosts;
 local pairs, setmetatable = pairs, setmetatable;
 
-local logger_init = require "util.logger".init;
-local sessionlib = require "util.session";
+local logger_init = require "prosody.util.logger".init;
+local sessionlib = require "prosody.util.session";
 
 local log = logger_init("s2smanager");
 
--- a/core/sessionmanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/sessionmanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -10,20 +10,20 @@
 local tostring, setmetatable = tostring, setmetatable;
 local pairs, next= pairs, next;
 
-local hosts = prosody.hosts;
+local prosody, hosts = prosody, prosody.hosts;
 local full_sessions = prosody.full_sessions;
 local bare_sessions = prosody.bare_sessions;
 
-local logger = require "util.logger";
+local logger = require "prosody.util.logger";
 local log = logger.init("sessionmanager");
-local rm_load_roster = require "core.rostermanager".load_roster;
-local config_get = require "core.configmanager".get;
-local resourceprep = require "util.encodings".stringprep.resourceprep;
-local nodeprep = require "util.encodings".stringprep.nodeprep;
-local generate_identifier = require "util.id".short;
-local sessionlib = require "util.session";
+local rm_load_roster = require "prosody.core.rostermanager".load_roster;
+local config_get = require "prosody.core.configmanager".get;
+local resourceprep = require "prosody.util.encodings".stringprep.resourceprep;
+local nodeprep = require "prosody.util.encodings".stringprep.nodeprep;
+local generate_identifier = require "prosody.util.id".short;
+local sessionlib = require "prosody.util.session";
 
-local initialize_filters = require "util.filters".initialize;
+local initialize_filters = require "prosody.util.filters".initialize;
 local gettime = require "socket".gettime;
 
 local _ENV = nil;
@@ -92,6 +92,51 @@
 	return setmetatable(session, resting_session);
 end
 
+-- Update a session with a new one (transplanting connection, filters, etc.)
+-- new_session should be discarded after this call returns
+local function update_session(to_session, from_session)
+	to_session.log("debug", "Updating with parameters from session %s", from_session.id);
+	from_session.log("debug", "Session absorbed into %s", to_session.id);
+
+	local replaced_conn = to_session.conn;
+	if replaced_conn then
+		to_session.conn = nil;
+	end
+
+	to_session.since = from_session.since;
+	to_session.ip = from_session.ip;
+	to_session.conn = from_session.conn;
+	to_session.rawsend = from_session.rawsend;
+	to_session.rawsend.session = to_session;
+	to_session.rawsend.conn = to_session.conn;
+	to_session.send = from_session.send;
+	to_session.send.session = to_session;
+	to_session.close = from_session.close;
+	to_session.filter = from_session.filter;
+	to_session.filter.session = to_session;
+	to_session.filters = from_session.filters;
+	to_session.send.filter = to_session.filter;
+	to_session.sasl_handler = from_session.sasl_handler;
+	to_session.stream = from_session.stream;
+	to_session.secure = from_session.secure;
+	to_session.hibernating = nil;
+	to_session.resumption_counter = (to_session.resumption_counter or 0) + 1;
+	from_session.log = to_session.log;
+	from_session.type = to_session.type;
+	-- Inform xmppstream of the new session (passed to its callbacks)
+	to_session.stream:set_session(to_session);
+
+	-- Notify modules, allowing them to copy further fields or update state
+	prosody.events.fire_event("c2s-session-updated", {
+		session = to_session;
+		from_session = from_session;
+		replaced_conn = replaced_conn;
+	});
+
+	-- Retire the session we've pulled from, to avoid two sessions on the same connection
+	retire_session(from_session);
+end
+
 local function destroy_session(session, err)
 	if session.destroyed then return; end
 
@@ -130,15 +175,24 @@
 	retire_session(session);
 end
 
-local function make_authenticated(session, username, scope)
+local function make_authenticated(session, username, role_name)
 	username = nodeprep(username);
 	if not username or #username == 0 then return nil, "Invalid username"; end
 	session.username = username;
 	if session.type == "c2s_unauthed" then
 		session.type = "c2s_unbound";
 	end
-	session.auth_scope = scope;
-	session.log("info", "Authenticated as %s@%s", username, session.host or "(unknown)");
+
+	local role;
+	if role_name then
+		role = hosts[session.host].authz.get_role_by_name(role_name);
+	else
+		role = hosts[session.host].authz.get_user_role(username);
+	end
+	if role then
+		sessionlib.set_role(session, role);
+	end
+	session.log("info", "Authenticated as %s@%s [%s]", username, session.host or "(unknown)", role and role.name or "no role");
 	return true;
 end
 
@@ -265,6 +319,7 @@
 return {
 	new_session = new_session;
 	retire_session = retire_session;
+	update_session = update_session;
 	destroy_session = destroy_session;
 	make_authenticated = make_authenticated;
 	bind_resource = bind_resource;
--- a/core/stanza_router.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/stanza_router.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,14 +6,14 @@
 -- COPYING file in the source package for more information.
 --
 
-local log = require "util.logger".init("stanzarouter")
+local log = require "prosody.util.logger".init("stanzarouter")
 
 local hosts = _G.prosody.hosts;
 local tostring = tostring;
-local st = require "util.stanza";
-local jid_split = require "util.jid".split;
-local jid_host = require "util.jid".host;
-local jid_prepped_split = require "util.jid".prepped_split;
+local st = require "prosody.util.stanza";
+local jid_split = require "prosody.util.jid".split;
+local jid_host = require "prosody.util.jid".host;
+local jid_prepped_split = require "prosody.util.jid".prepped_split;
 
 local full_sessions = _G.prosody.full_sessions;
 local bare_sessions = _G.prosody.bare_sessions;
@@ -127,7 +127,7 @@
 		end
 		core_post_stanza(origin, stanza, origin.full_jid);
 	else
-		local h = hosts[stanza.attr.to or origin.host or origin.to_host];
+		local h = hosts[stanza.attr.to or origin.host];
 		if h then
 			local event;
 			if xmlns == nil then
@@ -143,7 +143,7 @@
 			if h.events.fire_event(event, {origin = origin, stanza = stanza}) then return; end
 		end
 		if host and not hosts[host] then host = nil; end -- COMPAT: workaround for a Pidgin bug which sets 'to' to the SRV result
-		handle_unhandled_stanza(host or origin.host or origin.to_host, origin, stanza);
+		handle_unhandled_stanza(host or origin.host, origin, stanza);
 	end
 end
 
--- a/core/statsmanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/statsmanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,10 +1,10 @@
 
-local config = require "core.configmanager";
-local log = require "util.logger".init("stats");
-local timer = require "util.timer";
+local config = require "prosody.core.configmanager";
+local log = require "prosody.util.logger".init("stats");
+local timer = require "prosody.util.timer";
 local fire_event = prosody.events.fire_event;
-local array = require "util.array";
-local timed = require "util.openmetrics".timed;
+local array = require "prosody.util.array";
+local timed = require "prosody.util.openmetrics".timed;
 
 local stats_interval_config = config.get("*", "statistics_interval");
 local stats_interval = tonumber(stats_interval_config);
@@ -26,8 +26,8 @@
 end
 
 local builtin_providers = {
-	internal = "util.statistics";
-	statsd = "util.statsd";
+	internal = "prosody.util.statistics";
+	statsd = "prosody.util.statsd";
 };
 
 
--- a/core/storagemanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/storagemanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -3,12 +3,12 @@
 local setmetatable = setmetatable;
 local rawset = rawset;
 
-local config = require "core.configmanager";
-local datamanager = require "util.datamanager";
-local modulemanager = require "core.modulemanager";
-local multitable = require "util.multitable";
-local log = require "util.logger".init("storagemanager");
-local async = require "util.async";
+local config = require "prosody.core.configmanager";
+local datamanager = require "prosody.util.datamanager";
+local modulemanager = require "prosody.core.modulemanager";
+local multitable = require "prosody.util.multitable";
+local log = require "prosody.util.logger".init("storagemanager");
+local async = require "prosody.util.async";
 local debug = debug;
 
 local prosody = prosody;
@@ -91,24 +91,8 @@
 end
 
 local function get_storage_config(host)
-	-- COMPAT w/ unreleased Prosody 0.10 and the once-experimental mod_storage_sql2 in peoples' config files
-	local storage_config = config.get(host, "storage");
-	local found_sql2;
-	if storage_config == "sql2" then
-		storage_config, found_sql2 = "sql", true;
-	elseif type(storage_config) == "table" then
-		for store_name, driver_name in pairs(storage_config) do
-			if driver_name == "sql2" then
-				storage_config[store_name] = "sql";
-				found_sql2 = true;
-			end
-		end
-	end
-	if found_sql2 then
-		log("error", "The temporary 'sql2' storage module has now been renamed to 'sql', "
-			.."please update your config file: https://prosody.im/doc/modules/mod_storage_sql2");
-	end
-	return storage_config;
+	-- Here used to be some some compat checks
+	return config.get(host, "storage");
 end
 
 local function get_driver(host, store)
@@ -203,6 +187,37 @@
 	};
 }
 
+local combined_store_mt = {
+	__index = {
+		-- keyval
+		get = function (self, name)
+			return self.keyval_store:get(name);
+		end;
+		set = function (self, name, data)
+			return self.keyval_store:set(name, data);
+		end;
+		items = function (self)
+			return self.keyval_store:users();
+		end;
+		-- map
+		get_key = function (self, name, key)
+			return self.map_store:get(name, key);
+		end;
+		set_key = function (self, name, key, value)
+			return self.map_store:set(name, key, value);
+		end;
+		set_keys = function (self, name, map)
+			return self.map_store:set_keys(name, map);
+		end;
+		get_key_from_all = function (self, key)
+			return self.map_store:get_all(key);
+		end;
+		delete_key_from_all = function (self, key)
+			return self.map_store:delete_all(key);
+		end;
+	};
+};
+
 local open; -- forward declaration
 
 local function create_map_shim(host, store)
@@ -213,7 +228,49 @@
 	}, map_shim_mt);
 end
 
+local function open_combined(host, store)
+	local driver, driver_name = get_driver(host, store);
+
+	-- Open keyval
+	local keyval_store, err = driver:open(store, "keyval");
+	if not keyval_store then
+		if err == "unsupported-store" then
+			log("debug", "Storage driver %s does not support store %s (keyval), falling back to null driver",
+				driver_name, store);
+			keyval_store, err = null_storage_driver, nil;
+		end
+	end
+
+	local map_store;
+	if keyval_store then
+		-- Open map
+		map_store, err = driver:open(store, "map");
+		if not map_store then
+			if err == "unsupported-store" then
+				log("debug", "Storage driver %s does not support store %s (map), falling back to shim",
+					driver_name, store);
+				map_store, err = setmetatable({ keyval_store = keyval_store }, map_shim_mt), nil;
+			end
+		end
+	end
+
+	if not(keyval_store and map_store) then
+		return nil, err;
+	end
+	local combined_store = setmetatable({
+		keyval_store = keyval_store;
+		map_store = map_store;
+		remove = map_store.remove;
+	}, combined_store_mt);
+	local event_data = { host = host, store_name = store, store_type = "keyval+", store = combined_store };
+	hosts[host].events.fire_event("store-opened", event_data);
+	return event_data.store, event_data.store_err;
+end
+
 function open(host, store, typ)
+	if typ == "keyval+" then -- TODO: default in some release?
+		return open_combined(host, store);
+	end
 	local driver, driver_name = get_driver(host, store);
 	local ret, err = driver:open(store, typ);
 	if not ret then
--- a/core/usermanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/core/usermanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,17 +6,13 @@
 -- COPYING file in the source package for more information.
 --
 
-local modulemanager = require "core.modulemanager";
-local log = require "util.logger".init("usermanager");
+local modulemanager = require "prosody.core.modulemanager";
+local log = require "prosody.util.logger".init("usermanager");
 local type = type;
-local it = require "util.iterators";
-local jid_bare = require "util.jid".bare;
-local jid_split = require "util.jid".split;
-local jid_prep = require "util.jid".prep;
-local config = require "core.configmanager";
-local sasl_new = require "util.sasl".new;
-local storagemanager = require "core.storagemanager";
-local set = require "util.set";
+local jid_split = require "prosody.util.jid".split;
+local config = require "prosody.core.configmanager";
+local sasl_new = require "prosody.util.sasl".new;
+local storagemanager = require "prosody.core.storagemanager";
 
 local prosody = _G.prosody;
 local hosts = prosody.hosts;
@@ -25,6 +21,8 @@
 
 local default_provider = "internal_hashed";
 
+local debug = debug;
+
 local _ENV = nil;
 -- luacheck: std none
 
@@ -36,26 +34,26 @@
 	});
 end
 
-local global_admins_config = config.get("*", "admins");
-if type(global_admins_config) ~= "table" then
-	global_admins_config = nil; -- TODO: factor out moduleapi magic config handling and use it here
-end
-local global_admins = set.new(global_admins_config) / jid_prep;
+local fallback_authz_provider = {
+	-- luacheck: ignore 212
+	get_jids_with_role = function (role) end;
+
+	get_user_role = function (user) end;
+	set_user_role = function (user, role_name) end;
 
-local admin_role = { ["prosody:admin"] = true };
-local global_authz_provider = {
-	get_user_roles = function (user) end; --luacheck: ignore 212/user
-	get_jid_roles = function (jid)
-		if global_admins:contains(jid) then
-			return admin_role;
-		end
-	end;
-	get_jids_with_role = function (role)
-		if role ~= "prosody:admin" then return {}; end
-		return it.to_array(global_admins);
-	end;
-	set_user_roles = function (user, roles) end; -- luacheck: ignore 212
-	set_jid_roles = function (jid, roles) end; -- luacheck: ignore 212
+	get_user_secondary_roles = function (user) end;
+	add_user_secondary_role = function (user, host, role_name) end;
+	remove_user_secondary_role = function (user, host, role_name) end;
+
+	user_can_assume_role = function(user, role_name) end;
+
+	get_jid_role = function (jid) end;
+	set_jid_role = function (jid, role) end;
+
+	get_users_with_role = function (role_name) end;
+	add_default_permission = function (role_name, action, policy) end;
+	get_role_by_name = function (role_name) end;
+	get_all_roles = function () end;
 };
 
 local provider_mt = { __index = new_null_provider() };
@@ -66,7 +64,7 @@
 	local authz_provider_name = config.get(host, "authorization") or "internal";
 
 	local authz_mod = modulemanager.load(host, "authz_"..authz_provider_name);
-	host_session.authz = authz_mod or global_authz_provider;
+	host_session.authz = authz_mod or fallback_authz_provider;
 
 	if host_session.type ~= "local" then return; end
 
@@ -116,6 +114,12 @@
 	return ok, err;
 end
 
+local function get_account_info(username, host)
+	local method = hosts[host].users.get_account_info;
+	if not method then return nil, "method not supported"; end
+	return method(username);
+end
+
 local function user_exists(username, host)
 	if hosts[host].sessions[username] then return true; end
 	return hosts[host].users.user_exists(username);
@@ -132,6 +136,43 @@
 	return storagemanager.purge(username, host);
 end
 
+local function user_is_enabled(username, host)
+	local method = hosts[host].users.is_enabled;
+	if method then return method(username); end
+
+	-- Fallback
+	local info, err = get_account_info(username, host);
+	if info and info.enabled ~= nil then
+		return info.enabled;
+	elseif err ~= "method not implemented" then
+		-- Storage issues etetc
+		return info, err;
+	end
+
+	-- API unsupported implies users are always enabled
+	return true;
+end
+
+local function enable_user(username, host)
+	local method = hosts[host].users.enable;
+	if not method then return nil, "method not supported"; end
+	local ret, err = method(username);
+	if ret then
+		prosody.events.fire_event("user-enabled", { username = username, host = host });
+	end
+	return ret, err;
+end
+
+local function disable_user(username, host, meta)
+	local method = hosts[host].users.disable;
+	if not method then return nil, "method not supported"; end
+	local ret, err = method(username, meta);
+	if ret then
+		prosody.events.fire_event("user-disabled", { username = username, host = host, meta = meta });
+	end
+	return ret, err;
+end
+
 local function users(host)
 	return hosts[host].users.users();
 end
@@ -144,70 +185,143 @@
 	return hosts[host].users;
 end
 
-local function get_roles(jid, host)
+local function get_user_role(user, host)
 	if host and not hosts[host] then return false; end
-	if type(jid) ~= "string" then return false; end
+	if type(user) ~= "string" then return false; end
 
-	jid = jid_bare(jid);
-	host = host or "*";
+	return hosts[host].authz.get_user_role(user);
+end
+
+local function set_user_role(user, host, role_name)
+	if host and not hosts[host] then return false; end
+	if type(user) ~= "string" then return false; end
 
-	local actor_user, actor_host = jid_split(jid);
-	local roles;
-
-	local authz_provider = (host ~= "*" and hosts[host].authz) or global_authz_provider;
+	local role, err = hosts[host].authz.set_user_role(user, role_name);
+	if role then
+		prosody.events.fire_event("user-role-changed", {
+			username = user, host = host, role = role;
+		});
+	end
+	return role, err;
+end
 
-	if actor_user and actor_host == host then -- Local user
-		roles = authz_provider.get_user_roles(actor_user);
-	else -- Remote user/JID
-		roles = authz_provider.get_jid_roles(jid);
+local function create_user_with_role(username, password, host, role)
+	local ok, err = create_user(username, nil, host);
+	if not ok then return ok, err; end
+
+	local role_ok, role_err = set_user_role(username, host, role);
+	if not role_ok then
+		delete_user(username, host);
+		return nil, "Failed to assign role: "..role_err;
 	end
 
-	return roles;
+	if password then
+		local pw_ok, pw_err = set_password(username, password, host);
+		if not pw_ok then
+			return nil, "Failed to set password: "..pw_err;
+		end
+
+		local enable_ok, enable_err = enable_user(username, host);
+		if not enable_ok and enable_err ~= "method not implemented" then
+			return enable_ok, "Failed to enable account: "..enable_err;
+		end
+	end
+
+	return true;
+end
+
+local function user_can_assume_role(user, host, role_name)
+	if host and not hosts[host] then return false; end
+	if type(user) ~= "string" then return false; end
+
+	return hosts[host].authz.user_can_assume_role(user, role_name);
 end
 
-local function set_roles(jid, host, roles)
+local function add_user_secondary_role(user, host, role_name)
 	if host and not hosts[host] then return false; end
-	if type(jid) ~= "string" then return false; end
-
-	jid = jid_bare(jid);
-	host = host or "*";
-
-	local actor_user, actor_host = jid_split(jid);
+	if type(user) ~= "string" then return false; end
 
-	local authz_provider = (host ~= "*" and hosts[host].authz) or global_authz_provider;
-	if actor_user and actor_host == host then -- Local user
-		local ok, err = authz_provider.set_user_roles(actor_user, roles);
-		if ok then
-			prosody.events.fire_event("user-roles-changed", {
-				username = actor_user, host = actor_host
-			});
-		end
-		return ok, err;
-	else -- Remote entity
-		return authz_provider.set_jid_roles(jid, roles)
+	local role, err = hosts[host].authz.add_user_secondary_role(user, role_name);
+	if role then
+		prosody.events.fire_event("user-role-added", {
+			username = user, host = host, role = role;
+		});
 	end
+	return role, err;
+end
+
+local function remove_user_secondary_role(user, host, role_name)
+	if host and not hosts[host] then return false; end
+	if type(user) ~= "string" then return false; end
+
+	local ok, err = hosts[host].authz.remove_user_secondary_role(user, role_name);
+	if ok then
+		prosody.events.fire_event("user-role-removed", {
+			username = user, host = host, role_name = role_name;
+		});
+	end
+	return ok, err;
 end
 
+local function get_user_secondary_roles(user, host)
+	if host and not hosts[host] then return false; end
+	if type(user) ~= "string" then return false; end
+
+	return hosts[host].authz.get_user_secondary_roles(user);
+end
+
+local function get_jid_role(jid, host)
+	local jid_node, jid_host = jid_split(jid);
+	if host == jid_host and jid_node then
+		return hosts[host].authz.get_user_role(jid_node);
+	end
+	return hosts[host].authz.get_jid_role(jid);
+end
+
+local function set_jid_role(jid, host, role_name)
+	local _, jid_host = jid_split(jid);
+	if host == jid_host then
+		return nil, "unexpected-local-jid";
+	end
+	return hosts[host].authz.set_jid_role(jid, role_name)
+end
+
+local strict_deprecate_is_admin;
+local legacy_admin_roles = { ["prosody:admin"] = true, ["prosody:operator"] = true };
 local function is_admin(jid, host)
-	local roles = get_roles(jid, host);
-	return roles and roles["prosody:admin"];
+	if strict_deprecate_is_admin == nil then
+		strict_deprecate_is_admin = (config.get("*", "strict_deprecate_is_admin") == true);
+	end
+	if strict_deprecate_is_admin then
+		log("error", "Attempt to use deprecated is_admin() API: %s", debug.traceback());
+		return false;
+	end
+	log("warn", "Usage of legacy is_admin() API, which will be disabled in a future build: %s", debug.traceback());
+	log("warn", "See https://prosody.im/doc/developers/permissions about the new permissions API");
+	return legacy_admin_roles[get_jid_role(jid, host)] or false;
 end
 
 local function get_users_with_role(role, host)
 	if not hosts[host] then return false; end
 	if type(role) ~= "string" then return false; end
-
 	return hosts[host].authz.get_users_with_role(role);
 end
 
 local function get_jids_with_role(role, host)
 	if host and not hosts[host] then return false; end
 	if type(role) ~= "string" then return false; end
-
-	host = host or "*";
+	return hosts[host].authz.get_jids_with_role(role);
+end
 
-	local authz_provider = (host ~= "*" and hosts[host].authz) or global_authz_provider;
-	return authz_provider.get_jids_with_role(role);
+local function get_role_by_name(role_name, host)
+	if host and not hosts[host] then return false; end
+	if type(role_name) ~= "string" then return false; end
+	return hosts[host].authz.get_role_by_name(role_name);
+end
+
+local function get_all_roles(host)
+	if host and not hosts[host] then return false; end
+	return hosts[host].authz.get_all_roles();
 end
 
 return {
@@ -216,15 +330,30 @@
 	test_password = test_password;
 	get_password = get_password;
 	set_password = set_password;
+	get_account_info = get_account_info;
 	user_exists = user_exists;
 	create_user = create_user;
+	create_user_with_role = create_user_with_role;
 	delete_user = delete_user;
+	user_is_enabled = user_is_enabled;
+	enable_user = enable_user;
+	disable_user = disable_user;
 	users = users;
 	get_sasl_handler = get_sasl_handler;
 	get_provider = get_provider;
-	get_roles = get_roles;
-	set_roles = set_roles;
+	get_user_role = get_user_role;
+	set_user_role = set_user_role;
+	user_can_assume_role = user_can_assume_role;
+	add_user_secondary_role = add_user_secondary_role;
+	remove_user_secondary_role = remove_user_secondary_role;
+	get_user_secondary_roles = get_user_secondary_roles;
+	get_users_with_role = get_users_with_role;
+	get_jid_role = get_jid_role;
+	set_jid_role = set_jid_role;
+	get_jids_with_role = get_jids_with_role;
+	get_role_by_name = get_role_by_name;
+	get_all_roles = get_all_roles;
+
+	-- Deprecated
 	is_admin = is_admin;
-	get_users_with_role = get_users_with_role;
-	get_jids_with_role = get_jids_with_role;
 };
--- a/doc/doap.xml	Wed Mar 27 15:35:15 2024 +0000
+++ b/doc/doap.xml	Wed Mar 27 15:39:03 2024 +0000
@@ -56,18 +56,23 @@
     <implements rdf:resource="https://www.rfc-editor.org/info/rfc6455"/>
     <implements rdf:resource="https://www.rfc-editor.org/info/rfc6901"/>
     <implements rdf:resource="https://www.rfc-editor.org/info/rfc7233"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc7239"/>
     <implements rdf:resource="https://www.rfc-editor.org/info/rfc7301"/>
     <implements rdf:resource="https://www.rfc-editor.org/info/rfc7395"/>
     <implements rdf:resource="https://www.rfc-editor.org/info/rfc7590"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc7628"/>
     <implements rdf:resource="https://www.rfc-editor.org/info/rfc7673"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc8305"/>
+    <implements rdf:resource="https://www.rfc-editor.org/info/rfc9266"/>
     <implements rdf:resource="https://datatracker.ietf.org/doc/draft-cridland-xmpp-session/">
       <!-- since=0.6.0 note=Added in hg:0bbbc9042361 -->
     </implements>
+    <implements rdf:resource="https://datatracker.ietf.org/doc/draft-ietf-dance-client-auth"/>
     <implements rdf:resource="http://www.unicode.org/reports/tr39/"/>
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0004.html"/>
-        <xmpp:version>2.12.1</xmpp:version>
+        <xmpp:version>2.13.0</xmpp:version>
         <xmpp:since>0.4.0</xmpp:since>
         <xmpp:status>partial</xmpp:status>
         <xmpp:note>no support for multiple items (reported tag)</xmpp:note>
@@ -119,7 +124,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0045.html"/>
-        <xmpp:version>1.34.1</xmpp:version>
+        <xmpp:version>1.34.5</xmpp:version>
         <xmpp:since>0.3.0</xmpp:since>
         <xmpp:status>partial</xmpp:status>
       </xmpp:SupportedXep>
@@ -172,7 +177,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0060.html"/>
-        <xmpp:version>1.22.0</xmpp:version>
+        <xmpp:version>1.24.1</xmpp:version>
         <xmpp:since>0.9.0</xmpp:since>
         <xmpp:status>partial</xmpp:status>
         <xmpp:note>mod_pubsub</xmpp:note>
@@ -240,7 +245,8 @@
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0090.html"/>
         <xmpp:version>1.2</xmpp:version>
         <xmpp:since>0.1.0</xmpp:since>
-        <xmpp:status>complete</xmpp:status>
+        <xmpp:until>trunk</xmpp:until>
+        <xmpp:status>removed</xmpp:status>
         <xmpp:note>mod_time</xmpp:note>
       </xmpp:SupportedXep>
     </implements>
@@ -268,6 +274,7 @@
         <xmpp:version>1.0</xmpp:version>
         <xmpp:since>0.9.0</xmpp:since>
         <xmpp:status>complete</xmpp:status>
+        <xmpp:note>util.jid.(un)escape, missing rejection of \20 at start or end per xep version 1.1</xmpp:note>
       </xmpp:SupportedXep>
     </implements>
     <implements>
@@ -297,7 +304,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0115.html"/>
-        <xmpp:version>1.5.2</xmpp:version>
+        <xmpp:version>1.6.0</xmpp:version>
         <xmpp:since>0.8.0</xmpp:since>
         <xmpp:status>complete</xmpp:status>
       </xmpp:SupportedXep>
@@ -355,7 +362,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0138.html"/>
-        <xmpp:version>2.0</xmpp:version>
+        <xmpp:version>2.1</xmpp:version>
         <xmpp:since>0.6.0</xmpp:since>
         <xmpp:until>0.10.0</xmpp:until>
         <xmpp:status>removed</xmpp:status>
@@ -390,7 +397,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0163.html"/>
-        <xmpp:version>1.2.1</xmpp:version>
+        <xmpp:version>1.2.2</xmpp:version>
         <xmpp:since>0.5.0</xmpp:since>
         <xmpp:status>complete</xmpp:status>
       </xmpp:SupportedXep>
@@ -561,7 +568,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0215.html"/>
-        <xmpp:version>0.7.1</xmpp:version>
+        <xmpp:version>1.0.0</xmpp:version>
         <xmpp:status>complete</xmpp:status>
         <xmpp:since>0.12.0</xmpp:since>
         <xmpp:note>mod_external_services</xmpp:note>
@@ -623,7 +630,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0280.html"/>
-        <xmpp:version>1.0.0</xmpp:version>
+        <xmpp:version>1.0.1</xmpp:version>
         <xmpp:status>complete</xmpp:status>
         <xmpp:since>0.10.0</xmpp:since>
       </xmpp:SupportedXep>
@@ -657,7 +664,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0297.html"/>
-        <xmpp:version>1.0.0</xmpp:version>
+        <xmpp:version>1.0</xmpp:version>
         <xmpp:since>0.11.0</xmpp:since>
         <xmpp:status>complete</xmpp:status>
         <xmpp:note>Used by XEP-0280, XEP-0313</xmpp:note>
@@ -683,7 +690,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0313.html"/>
-        <xmpp:version>1.0.0</xmpp:version>
+        <xmpp:version>1.1.0</xmpp:version>
         <xmpp:status>complete</xmpp:status>
         <xmpp:since>0.10.0</xmpp:since>
         <xmpp:note>mod_mam, mod_muc_mam</xmpp:note>
@@ -719,7 +726,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0353.html"/>
-        <xmpp:version>0.4.0</xmpp:version>
+        <xmpp:version>0.6.0</xmpp:version>
         <xmpp:since>0.11.6</xmpp:since>
         <xmpp:status>complete</xmpp:status>
         <xmpp:note>triggers buffer flush in mod_csi_simple since 0.11.6; recognised by mod_carbons and mod_mam since 0.12</xmpp:note>
@@ -728,7 +735,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0359.html"/>
-        <xmpp:version>0.6.1</xmpp:version>
+        <xmpp:version>0.7.0</xmpp:version>
         <xmpp:status>complete</xmpp:status>
         <xmpp:since>0.10.0</xmpp:since>
         <xmpp:note>Used in context of XEP-0313 by mod_mam and mod_muc_mam</xmpp:note>
@@ -737,7 +744,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0363.html"/>
-        <xmpp:version>1.0.0</xmpp:version>
+        <xmpp:version>1.1.0</xmpp:version>
         <xmpp:status>complete</xmpp:status>
         <xmpp:since>0.12.0</xmpp:since>
         <xmpp:note>mod_http_file_share</xmpp:note>
@@ -763,7 +770,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0380.html"/>
-        <xmpp:version>0.3.0</xmpp:version>
+        <xmpp:version>0.4.0</xmpp:version>
         <xmpp:since>0.11.0</xmpp:since>
         <xmpp:status>complete</xmpp:status>
         <xmpp:note>Used in context of XEP-0352</xmpp:note>
@@ -772,7 +779,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0384.html"/>
-        <xmpp:version>0.8.1</xmpp:version>
+        <xmpp:version>0.8.3</xmpp:version>
         <xmpp:status>complete</xmpp:status>
         <xmpp:note>via XEP-0163, XEP-0222</xmpp:note>
       </xmpp:SupportedXep>
@@ -789,7 +796,7 @@
     <implements>
       <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0401.html"/>
-        <xmpp:version>0.3.0</xmpp:version>
+        <xmpp:version>0.5.0</xmpp:version>
         <xmpp:since>0.12.0</xmpp:since>
         <xmpp:status>partial</xmpp:status>
       </xmpp:SupportedXep>
@@ -839,11 +846,34 @@
     </implements>
     <implements>
       <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0440.html"/>
+        <xmpp:version>0.4.0</xmpp:version>
+        <xmpp:since>trunk</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0441.html"/>
         <xmpp:version>0.2.0</xmpp:version>
         <xmpp:status>complete</xmpp:status>
         <xmpp:note>Broken out of XEP-0313</xmpp:note>
       </xmpp:SupportedXep>
     </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0445.html"/>
+        <xmpp:version>0.2.0</xmpp:version>
+        <xmpp:since>0.12.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
+      </xmpp:SupportedXep>
+    </implements>
+    <implements>
+      <xmpp:SupportedXep>
+        <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0478.html"/>
+        <xmpp:version>0.1.0</xmpp:version>
+        <xmpp:since>trunk</xmpp:since>
+      </xmpp:SupportedXep>
+    </implements>
   </Project>
 </rdf:RDF>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/doc/hgrc-email.ini	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,16 @@
+# Settings for using `hg email`
+
+[email]
+# See `hg help config.email`
+to = prosody-dev@googlegroups.com
+
+[smtp]
+# See `hg help config.smtp`
+host = mail.example.com
+port = submission
+tls = starttls
+username =
+
+[extensions]
+# command to send changesets as (a series of) patch emails
+patchbomb =
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/doc/hgrc.ini	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,43 @@
+[paths]
+default = https://hg.prosody.im/trunk/
+default:pushrev = .
+default:pushurl = ssh://hg@hg.prosody.im/prosody-hg/
+
+contrib = https://hg.prosody.im/contrib/
+contrib:bookmarks.mode = ignore
+contrib:pushrev = .
+contrib:pushurl = ssh://hg@hg.prosody.im/contrib/
+
+[ui]
+
+# The Mercurial project recommends enabling tweakdefaults to get slight
+# improvements to the UI over time. Make sure to set HGPLAIN in the environment
+# when writing scripts!
+tweakdefaults = True
+
+[phases]
+# Disable marking changesets as published when pushing to a local repository
+publish = False
+
+[revsetalias]
+# Convenient alias to find current trunk revision
+trunk = last(public() and branch("default"))
+[experimental]
+# Require changes to have a topic branch
+topic-mode = enforce
+
+[fix]
+trailing-whitespace:command = sed
+trailing-whitespace:linerange = -e '{first},{last}s/\s\+$//'
+trailing-whitespace:pattern = set:not binary()
+
+astyle:command = astyle --indent=tab --attach-classes --indent-switches --break-blocks --pad-oper --unpad-paren --add-braces --align-pointer=name --lineend=linux
+astyle:pattern = set:**.c
+json:command = json_pp -json_opt canonical,pretty
+json:pattern = set:**.json
+
+[extensions]
+# The Mercurial Changeset Evolution plugin is strongly recommended
+evolve =
+# support for topic branches
+topic =
--- a/doc/storage.tld	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,68 +0,0 @@
--- Storage Interface API Description
---
--- This is written as a TypedLua description
-
--- Key-Value stores (the default)
-
-interface keyval_store
-	get : ( self, string? ) -> (any) | (nil, string)
-	set : ( self, string?, any ) -> (boolean) | (nil, string)
-end
-
--- Map stores (key-key-value stores)
-
-interface map_store
-	get : ( self, string?, any ) -> (any) | (nil, string)
-	set : ( self, string?, any, any ) -> (boolean) | (nil, string)
-	set_keys : ( self, string?, { any : any }) -> (boolean) | (nil, string)
-	remove : {}
-end
-
--- Archive stores
-
-typealias archive_query = {
-	"start"  : number?, -- timestamp
-	"end"    : number?, -- timestamp
-	"with"   : string?,
-	"after"  : string?, -- archive id
-	"before" : string?, -- archive id
-	"total"  : boolean?,
-}
-
-interface archive_store
-	-- Optional set of capabilities
-	caps   : {
-		-- Optional total count of matching items returned as second return value from :find()
-		"total" : boolean?,
-	}?
-
-	-- Add to the archive
-	append : ( self, string?, string?, any, number?, string? ) -> (string) | (nil, string)
-
-	-- Iterate over archive
-	find   : ( self, string?, archive_query? ) -> ( () -> ( string, any, number?, string? ), integer? )
-
-	-- Removal of items. API like find. Optional?
-	delete : ( self, string?, archive_query? ) -> (boolean) | (number) | (nil, string)
-
-	-- Array of dates which do have messages (Optional?)
-	dates  : ( self, string? ) -> ({ string }) | (nil, string)
-
-	-- Map of counts per "with" field
-	summary : ( self, string?, archive_query? ) -> ( { string : integer } ) | (nil, string)
-
-	-- Map-store API
-	get    : ( self, string, string ) -> (stanza, number?, string?) | (nil, string)
-	set    : ( self, string, string, stanza, number?, string? ) -> (boolean) | (nil, string)
-end
-
--- This represents moduleapi
-interface module
-	-- If the first string is omitted then the name of the module is used
-	-- The second string is one of "keyval" (default), "map" or "archive"
-	open_store : (self, string?, string?) -> (keyval_store) | (map_store) | (archive_store) | (nil, string)
-
-	-- Other module methods omitted
-end
-
-module : module
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/loader.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,35 @@
+-- Allow for both require"util.foo" and require"prosody.util.foo" for a
+-- transition period while we update all require calls.
+
+if (...) == "prosody.loader" then
+	if not package.path:find "prosody" then
+		-- For require"util.foo" also look in paths equivalent to "prosody.util.foo"
+		package.path = package.path:gsub("([^;]*)(?[^;]*)", "%1prosody/%2;%1%2");
+		package.cpath = package.cpath:gsub("([^;]*)(?[^;]*)", "%1prosody/%2;%1%2");
+	end
+else
+	-- When requiring "prosody.x", also look for "x"
+	for i = #package.searchers, 1, -1 do
+		local search = package.searchers[i];
+		table.insert(package.searchers, i, function(module_name)
+			local lib = module_name:match("^prosody%.(.*)$");
+			if lib then
+				return search(lib);
+			end
+		end)
+	end
+end
+
+-- Look for already loaded module with or without prefix
+setmetatable(package.loaded, {
+	__index = function(loaded, module_name)
+		local suffix = module_name:match("^prosody%.(.*)$");
+		if suffix then
+			return rawget(loaded, suffix);
+		end
+		local prefixed = rawget(loaded, "prosody." .. module_name);
+		if prefixed ~= nil then
+			return prefixed;
+		end
+	end;
+})
--- a/makefile	Wed Mar 27 15:35:15 2024 +0000
+++ b/makefile	Wed Mar 27 15:39:03 2024 +0000
@@ -44,6 +44,10 @@
 	$(INSTALL_EXEC) ./prosody.install $(BIN)/prosody
 	$(INSTALL_EXEC) ./prosodyctl.install $(BIN)/prosodyctl
 
+install-loader:
+	$(MKDIR) $(SOURCE)
+	$(INSTALL_DATA) loader.lua $(SOURCE)
+
 install-core:
 	$(MKDIR) $(SOURCE)
 	$(MKDIR) $(SOURCE)/core
@@ -73,12 +77,13 @@
 
 install-plugins:
 	$(MKDIR) $(MODULES)
-	$(MKDIR) $(MODULES)/mod_pubsub $(MODULES)/adhoc $(MODULES)/muc $(MODULES)/mod_mam
+	$(MKDIR) $(MODULES)/mod_pubsub $(MODULES)/adhoc $(MODULES)/muc $(MODULES)/mod_mam $(MODULES)/mod_debug_stanzas
 	$(INSTALL_DATA) plugins/*.lua $(MODULES)
 	$(INSTALL_DATA) plugins/mod_pubsub/*.lua $(MODULES)/mod_pubsub
 	$(INSTALL_DATA) plugins/adhoc/*.lua $(MODULES)/adhoc
 	$(INSTALL_DATA) plugins/muc/*.lua $(MODULES)/muc
 	$(INSTALL_DATA) plugins/mod_mam/*.lua $(MODULES)/mod_mam
+	$(INSTALL_DATA) plugins/mod_debug_stanzas/*.lua $(MODULES)/mod_debug_stanzas
 
 install-man:
 	$(MKDIR) $(MAN)/man1
@@ -90,7 +95,7 @@
 install-data:
 	$(MKDIR_PRIVATE) $(DATA)
 
-install: install-util install-net install-core install-plugins install-bin install-etc install-man install-meta install-data
+install: install-util install-net install-core install-plugins install-bin install-etc install-man install-meta install-data install-loader
 
 clean:
 	rm -f prosody.install
--- a/net/adns.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/adns.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,11 +6,11 @@
 -- COPYING file in the source package for more information.
 --
 
-local server = require "net.server";
-local new_resolver = require "net.dns".resolver;
-local promise = require "util.promise";
+local server = require "prosody.net.server";
+local new_resolver = require "prosody.net.dns".resolver;
+local promise = require "prosody.util.promise";
 
-local log = require "util.logger".init("adns");
+local log = require "prosody.util.logger".init("adns");
 
 log("debug", "Using legacy DNS API (missing lua-unbound?)"); -- TODO write docs about luaunbound
 -- TODO Raise log level once packages are available
--- a/net/connect.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/connect.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,8 +1,8 @@
-local server = require "net.server";
-local log = require "util.logger".init("net.connect");
-local new_id = require "util.id".short;
+local server = require "prosody.net.server";
+local log = require "prosody.util.logger".init("net.connect");
+local new_id = require "prosody.util.id".short;
+local timer = require "prosody.util.timer";
 
--- TODO #1246 Happy Eyeballs
 -- FIXME RFC 6724
 -- FIXME Error propagation from resolvers doesn't work
 -- FIXME #1428 Reuse DNS resolver object between service and basic resolver
@@ -28,16 +28,17 @@
 
 local function attempt_connection(p)
 	p:log("debug", "Checking for targets...");
-	if p.conn then
-		pending_connections_map[p.conn] = nil;
-		p.conn = nil;
-	end
-	p.target_resolver:next(function (conn_type, ip, port, extra)
+	p.target_resolver:next(function (conn_type, ip, port, extra, more_targets_available)
 		if not conn_type then
 			-- No more targets to try
 			p:log("debug", "No more connection targets to try", p.target_resolver.last_error);
-			if p.listeners.onfail then
-				p.listeners.onfail(p.data, p.last_error or p.target_resolver.last_error or "unable to resolve service");
+			if next(p.conns) == nil then
+				p:log("debug", "No more targets, no pending connections. Connection failed.");
+				if p.listeners.onfail then
+					p.listeners.onfail(p.data, p.last_error or p.target_resolver.last_error or "unable to resolve service");
+				end
+			else
+				p:log("debug", "One or more connection attempts are still pending. Waiting for now.");
 			end
 			return;
 		end
@@ -49,8 +50,16 @@
 			p.last_error = err or "unknown reason";
 			return attempt_connection(p);
 		end
-		p.conn = conn;
+		p.conns[conn] = true;
 		pending_connections_map[conn] = p;
+		if more_targets_available then
+			timer.add_task(0.250, function ()
+				if not p.connected then
+					p:log("debug", "Still not connected, making parallel connection attempt...");
+					attempt_connection(p);
+				end
+			end);
+		end
 	end);
 end
 
@@ -62,6 +71,13 @@
 		return;
 	end
 	pending_connections_map[conn] = nil;
+	if p.connected then
+		-- We already succeeded in connecting
+		p.conns[conn] = nil;
+		conn:close();
+		return;
+	end
+	p.connected = true;
 	p:log("debug", "Successfully connected");
 	conn:setlistener(p.listeners, p.data);
 	return p.listeners.onconnect(conn);
@@ -73,9 +89,18 @@
 		log("warn", "Failed connection, but unexpected!");
 		return;
 	end
+	p.conns[conn] = nil;
+	pending_connections_map[conn] = nil;
 	p.last_error = reason or "unknown reason";
 	p:log("debug", "Connection attempt failed: %s", p.last_error);
-	attempt_connection(p);
+	if p.connected then
+		p:log("debug", "Connection already established, ignoring failure");
+	elseif next(p.conns) == nil then
+		p:log("debug", "No pending connection attempts, and not yet connected");
+		attempt_connection(p);
+	else
+		p:log("debug", "Other attempts are still pending, ignoring failure");
+	end
 end
 
 local function connect(target_resolver, listeners, options, data)
@@ -85,6 +110,7 @@
 		listeners = assert(listeners);
 		options = options or {};
 		data = data;
+		conns = {};
 	}, pending_connection_mt);
 
 	p:log("debug", "Starting connection process");
--- a/net/cqueues.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/cqueues.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,9 +7,9 @@
 -- This module allows you to use cqueues with a net.server mainloop
 --
 
-local server = require "net.server";
+local server = require "prosody.net.server";
 local cqueues = require "cqueues";
-local timer = require "util.timer";
+local timer = require "prosody.util.timer";
 assert(cqueues.VERSION >= 20150113, "cqueues newer than 20150113 required")
 
 -- Create a single top level cqueue
--- a/net/dns.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/dns.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,18 +8,18 @@
 -- todo: cache results of encodeName
 
 
--- reference: http://tools.ietf.org/html/rfc1035
--- reference: http://tools.ietf.org/html/rfc1876 (LOC)
+-- reference: https://www.rfc-editor.org/rfc/rfc1035.html
+-- reference: https://www.rfc-editor.org/rfc/rfc1876.html (LOC)
 
 
 local socket = require "socket";
-local have_timer, timer = pcall(require, "util.timer");
-local new_ip = require "util.ip".new_ip;
-local have_util_net, util_net = pcall(require, "util.net");
+local have_timer, timer = pcall(require, "prosody.util.timer");
+local new_ip = require "prosody.util.ip".new_ip;
+local have_util_net, util_net = pcall(require, "prosody.util.net");
 
-local log = require "util.logger".init("dns");
+local log = require "prosody.util.logger".init("dns");
 
-local _, windows = pcall(require, "util.windows");
+local _, windows = pcall(require, "prosody.util.windows");
 local is_windows = (_ and windows) or os.getenv("WINDIR");
 
 local coroutine, io, math, string, table =
--- a/net/http.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/http.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,17 +6,17 @@
 -- COPYING file in the source package for more information.
 --
 
-local b64 = require "util.encodings".base64.encode;
+local b64 = require "prosody.util.encodings".base64.encode;
 local url = require "socket.url"
-local httpstream_new = require "net.http.parser".new;
-local util_http = require "util.http";
-local events = require "util.events";
-local verify_identity = require"util.x509".verify_identity;
-local promise = require "util.promise";
-local http_errors = require "net.http.errors";
+local httpstream_new = require "prosody.net.http.parser".new;
+local util_http = require "prosody.util.http";
+local events = require "prosody.util.events";
+local verify_identity = require"prosody.util.x509".verify_identity;
+local promise = require "prosody.util.promise";
+local http_errors = require "prosody.net.http.errors";
 
-local basic_resolver = require "net.resolvers.basic";
-local connect = require "net.connect".connect;
+local basic_resolver = require "prosody.net.resolvers.basic";
+local connect = require "prosody.net.connect".connect;
 
 local ssl_available = pcall(require, "ssl");
 
@@ -25,10 +25,10 @@
 local tonumber, tostring, traceback =
       tonumber, tostring, debug.traceback;
 local os_time = os.time;
-local xpcall = require "util.xpcall".xpcall;
+local xpcall = require "prosody.util.xpcall".xpcall;
 local error = error
 
-local log = require "util.logger".init("http");
+local log = require "prosody.util.logger".init("http");
 
 local _ENV = nil;
 -- luacheck: std none
@@ -51,10 +51,19 @@
 	return ...;
 end
 
-local function destroy_request(request)
+local function destroy_request(request, force)
 	local conn = request.conn;
 	if conn then
 		request.conn = nil;
+		local pool = request.http.pool;
+		if pool and not force then
+			local pool_id = request.scheme .. "://" .. request.authority;
+			if not pool[pool_id] then
+				pool[conn] = pool_id;
+				pool[pool_id] = conn;
+				return;
+			end
+		end
 		conn:close()
 	end
 end
@@ -106,7 +115,8 @@
 				request.callback(r.body, r.code, r, request);
 				request.callback = nil;
 			end
-			destroy_request(request);
+			local persistent = (","..(r.headers.connection or "keep-alive")..","):find(",keep-alive,")
+			destroy_request(request, persistent);
 		end
 		local function options_cb()
 			return request;
@@ -193,6 +203,13 @@
 	if request and request.conn then
 		request:reader(nil, err or "closed");
 	end
+	if request and request.http.pool then
+		local pool = request.http.pool;
+		local pool_id = pool[conn];
+		if pool_id then
+			pool[pool_id], pool[conn] = nil, nil;
+		end
+	end
 	requests[conn] = nil;
 end
 
@@ -253,6 +270,12 @@
 		["User-Agent"] = "Prosody XMPP Server";
 	};
 
+	if self.pool then
+		headers["Connection"] = "keep-alive";
+	else
+		headers["Connection"] = "close";
+	end
+
 	if req.userinfo then
 		headers["Authorization"] = "Basic "..b64(req.userinfo);
 	end
@@ -296,6 +319,23 @@
 		end
 	end
 
+	if self.pool then
+		local pool_id = req.scheme .. "://" .. req.authority;
+		local conn = self.pool[pool_id];
+		if conn then
+			log("debug", "Re-using connection to %s from pool", req.host);
+			self.pool[pool_id] = nil;
+			self.pool[conn] = nil;
+			req.conn = conn;
+			requests[conn] = req;
+			self.events.fire_event("request", { http = self, request = req, url = u });
+			listener.onconnect(conn);
+			return req;
+		else
+			log("debug", "Opening a new connection for this request");
+		end
+	end
+
 	local http_service = basic_resolver.new(host, port_number, "tcp", { servername = req.host; use_dane = use_dane });
 	connect(http_service, listener, { sslctx = sslctx }, req);
 
@@ -332,6 +372,10 @@
 		end or new;
 		events = events.new();
 	};
+	if options and options.connection_pooling then
+		-- util.cache in the future?
+		http.pool = {};
+	end
 	return http;
 end
 
--- a/net/http/codes.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/http/codes.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -2,62 +2,62 @@
 local response_codes = {
 	-- Source: http://www.iana.org/assignments/http-status-codes
 
-	[100] = "Continue"; -- RFC7231, Section 6.2.1
-	[101] = "Switching Protocols"; -- RFC7231, Section 6.2.2
+	[100] = "Continue"; -- RFC9110, Section 15.2.1
+	[101] = "Switching Protocols"; -- RFC9110, Section 15.2.2
 	[102] = "Processing";
 	[103] = "Early Hints";
 	-- [104-199] = "Unassigned";
 
-	[200] = "OK"; -- RFC7231, Section 6.3.1
-	[201] = "Created"; -- RFC7231, Section 6.3.2
-	[202] = "Accepted"; -- RFC7231, Section 6.3.3
-	[203] = "Non-Authoritative Information"; -- RFC7231, Section 6.3.4
-	[204] = "No Content"; -- RFC7231, Section 6.3.5
-	[205] = "Reset Content"; -- RFC7231, Section 6.3.6
-	[206] = "Partial Content"; -- RFC7233, Section 4.1
+	[200] = "OK"; -- RFC9110, Section 15.3.1
+	[201] = "Created"; -- RFC9110, Section 15.3.2
+	[202] = "Accepted"; -- RFC9110, Section 15.3.3
+	[203] = "Non-Authoritative Information"; -- RFC9110, Section 15.3.4
+	[204] = "No Content"; -- RFC9110, Section 15.3.5
+	[205] = "Reset Content"; -- RFC9110, Section 15.3.6
+	[206] = "Partial Content"; -- RFC9110, Section 15.3.7
 	[207] = "Multi-Status";
 	[208] = "Already Reported";
 	-- [209-225] = "Unassigned";
 	[226] = "IM Used";
 	-- [227-299] = "Unassigned";
 
-	[300] = "Multiple Choices"; -- RFC7231, Section 6.4.1
-	[301] = "Moved Permanently"; -- RFC7231, Section 6.4.2
-	[302] = "Found"; -- RFC7231, Section 6.4.3
-	[303] = "See Other"; -- RFC7231, Section 6.4.4
-	[304] = "Not Modified"; -- RFC7232, Section 4.1
-	[305] = "Use Proxy"; -- RFC7231, Section 6.4.5
-	-- [306] = "(Unused)"; -- RFC7231, Section 6.4.6
-	[307] = "Temporary Redirect"; -- RFC7231, Section 6.4.7
-	[308] = "Permanent Redirect";
+	[300] = "Multiple Choices"; -- RFC9110, Section 15.4.1
+	[301] = "Moved Permanently"; -- RFC9110, Section 15.4.2
+	[302] = "Found"; -- RFC9110, Section 15.4.3
+	[303] = "See Other"; -- RFC9110, Section 15.4.4
+	[304] = "Not Modified"; -- RFC9110, Section 15.4.5
+	[305] = "Use Proxy"; -- RFC9110, Section 15.4.6
+	-- [306] = "(Unused)"; -- RFC9110, Section 15.4.7
+	[307] = "Temporary Redirect"; -- RFC9110, Section 15.4.8
+	[308] = "Permanent Redirect"; -- RFC9110, Section 15.4.9
 	-- [309-399] = "Unassigned";
 
-	[400] = "Bad Request"; -- RFC7231, Section 6.5.1
-	[401] = "Unauthorized"; -- RFC7235, Section 3.1
-	[402] = "Payment Required"; -- RFC7231, Section 6.5.2
-	[403] = "Forbidden"; -- RFC7231, Section 6.5.3
-	[404] = "Not Found"; -- RFC7231, Section 6.5.4
-	[405] = "Method Not Allowed"; -- RFC7231, Section 6.5.5
-	[406] = "Not Acceptable"; -- RFC7231, Section 6.5.6
-	[407] = "Proxy Authentication Required"; -- RFC7235, Section 3.2
-	[408] = "Request Timeout"; -- RFC7231, Section 6.5.7
-	[409] = "Conflict"; -- RFC7231, Section 6.5.8
-	[410] = "Gone"; -- RFC7231, Section 6.5.9
-	[411] = "Length Required"; -- RFC7231, Section 6.5.10
-	[412] = "Precondition Failed"; -- RFC7232, Section 4.2
-	[413] = "Payload Too Large"; -- RFC7231, Section 6.5.11
-	[414] = "URI Too Long"; -- RFC7231, Section 6.5.12
-	[415] = "Unsupported Media Type"; -- RFC7231, Section 6.5.13
-	[416] = "Range Not Satisfiable"; -- RFC7233, Section 4.4
-	[417] = "Expectation Failed"; -- RFC7231, Section 6.5.14
+	[400] = "Bad Request"; -- RFC9110, Section 15.5.1
+	[401] = "Unauthorized"; -- RFC9110, Section 15.5.2
+	[402] = "Payment Required"; -- RFC9110, Section 15.5.3
+	[403] = "Forbidden"; -- RFC9110, Section 15.5.4
+	[404] = "Not Found"; -- RFC9110, Section 15.5.5
+	[405] = "Method Not Allowed"; -- RFC9110, Section 15.5.6
+	[406] = "Not Acceptable"; -- RFC9110, Section 15.5.7
+	[407] = "Proxy Authentication Required"; -- RFC9110, Section 15.5.8
+	[408] = "Request Timeout"; -- RFC9110, Section 15.5.9
+	[409] = "Conflict"; -- RFC9110, Section 15.5.10
+	[410] = "Gone"; -- RFC9110, Section 15.5.11
+	[411] = "Length Required"; -- RFC9110, Section 15.5.12
+	[412] = "Precondition Failed"; -- RFC9110, Section 15.5.13
+	[413] = "Content Too Large"; -- RFC9110, Section 15.5.14
+	[414] = "URI Too Long"; -- RFC9110, Section 15.5.15
+	[415] = "Unsupported Media Type"; -- RFC9110, Section 15.5.16
+	[416] = "Range Not Satisfiable"; -- RFC9110, Section 15.5.17
+	[417] = "Expectation Failed"; -- RFC9110, Section 15.5.18
 	[418] = "I'm a teapot"; -- RFC2324, Section 2.3.2
 	-- [419-420] = "Unassigned";
-	[421] = "Misdirected Request"; -- RFC7540, Section 9.1.2
-	[422] = "Unprocessable Entity";
+	[421] = "Misdirected Request"; -- RFC9110, Section 15.5.20
+	[422] = "Unprocessable Content"; -- RFC9110, Section 15.5.21
 	[423] = "Locked";
 	[424] = "Failed Dependency";
 	[425] = "Too Early";
-	[426] = "Upgrade Required"; -- RFC7231, Section 6.5.15
+	[426] = "Upgrade Required"; -- RFC9110, Section 15.5.22
 	-- [427] = "Unassigned";
 	[428] = "Precondition Required";
 	[429] = "Too Many Requests";
@@ -67,17 +67,17 @@
 	[451] = "Unavailable For Legal Reasons";
 	-- [452-499] = "Unassigned";
 
-	[500] = "Internal Server Error"; -- RFC7231, Section 6.6.1
-	[501] = "Not Implemented"; -- RFC7231, Section 6.6.2
-	[502] = "Bad Gateway"; -- RFC7231, Section 6.6.3
-	[503] = "Service Unavailable"; -- RFC7231, Section 6.6.4
-	[504] = "Gateway Timeout"; -- RFC7231, Section 6.6.5
-	[505] = "HTTP Version Not Supported"; -- RFC7231, Section 6.6.6
+	[500] = "Internal Server Error"; -- RFC9110, Section 15.6.1
+	[501] = "Not Implemented"; -- RFC9110, Section 15.6.2
+	[502] = "Bad Gateway"; -- RFC9110, Section 15.6.3
+	[503] = "Service Unavailable"; -- RFC9110, Section 15.6.4
+	[504] = "Gateway Timeout"; -- RFC9110, Section 15.6.5
+	[505] = "HTTP Version Not Supported"; -- RFC9110, Section 15.6.6
 	[506] = "Variant Also Negotiates";
 	[507] = "Insufficient Storage";
 	[508] = "Loop Detected";
 	-- [509] = "Unassigned";
-	[510] = "Not Extended";
+	[510] = "Not Extended"; -- (OBSOLETED)
 	[511] = "Network Authentication Required";
 	-- [512-599] = "Unassigned";
 };
--- a/net/http/errors.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/http/errors.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -2,8 +2,8 @@
 -- and a function to return a util.error object given callback 'code' and 'body'
 -- parameters.
 
-local codes = require "net.http.codes";
-local util_error = require "util.error";
+local codes = require "prosody.net.http.codes";
+local util_error = require "prosody.util.error";
 
 local error_templates = {
 	-- This code is used by us to report a client-side or connection error.
--- a/net/http/files.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/http/files.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,10 +6,10 @@
 -- COPYING file in the source package for more information.
 --
 
-local server = require"net.http.server";
+local server = require"prosody.net.http.server";
 local lfs = require "lfs";
-local new_cache = require "util.cache".new;
-local log = require "util.logger".init("net.http.files");
+local new_cache = require "prosody.util.cache".new;
+local log = require "prosody.util.logger".init("net.http.files");
 
 local os_date = os.date;
 local open = io.open;
@@ -23,7 +23,7 @@
 	forbidden_chars_pattern = "[/%z\001-\031\127\"*:<>?|]"
 end
 
-local urldecode = require "util.http".urldecode;
+local urldecode = require "prosody.util.http".urldecode;
 local function sanitize_path(path) --> util.paths or util.http?
 	if not path then return end
 	local out = {};
--- a/net/http/parser.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/http/parser.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,8 +1,8 @@
 local tonumber = tonumber;
 local assert = assert;
 local url_parse = require "socket.url".parse;
-local urldecode = require "util.http".urldecode;
-local dbuffer = require "util.dbuffer";
+local urldecode = require "prosody.util.http".urldecode;
+local dbuffer = require "prosody.util.dbuffer";
 
 local function preprocess_path(path)
 	path = urldecode((path:gsub("//+", "/")));
--- a/net/http/server.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/http/server.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,19 +1,21 @@
 
 local t_insert, t_concat = table.insert, table.concat;
-local parser_new = require "net.http.parser".new;
-local events = require "util.events".new();
-local addserver = require "net.server".addserver;
-local log = require "util.logger".init("http.server");
+local parser_new = require "prosody.net.http.parser".new;
+local events = require "prosody.util.events".new();
+local addserver = require "prosody.net.server".addserver;
+local logger = require "prosody.util.logger";
+local log = logger.init("http.server");
 local os_date = os.date;
 local pairs = pairs;
 local s_upper = string.upper;
 local setmetatable = setmetatable;
-local cache = require "util.cache";
-local codes = require "net.http.codes";
-local promise = require "util.promise";
-local errors = require "util.error";
+local cache = require "prosody.util.cache";
+local codes = require "prosody.net.http.codes";
+local promise = require "prosody.util.promise";
+local errors = require "prosody.util.error";
 local blocksize = 2^16;
-local async = require "util.async";
+local async = require "prosody.util.async";
+local id = require"prosody.util.id";
 
 local _M = {};
 
@@ -105,7 +107,12 @@
 
 function runner_callbacks:error(err)
 	log("error", "Traceback[httpserver]: %s", err);
-	self.data.conn:write("HTTP/1.0 500 Internal Server Error\r\n\r\n"..events.fire_event("http-error", { code = 500, private_message = err }));
+	local response = { headers = { content_type = "text/plain" }; body = "" };
+	response.body = events.fire_event("http-error", { code = 500; private_message = err; response = response });
+	self.data.conn:write("HTTP/1.0 500 Internal Server Error\r\n\z
+		X-Content-Type-Options: nosniff\r\n\z
+		Content-Type: " .. response.headers.content_type .. "\r\n\r\n");
+	self.data.conn:write(response.body);
 	self.data.conn:close();
 end
 
@@ -128,6 +135,8 @@
 	end, runner_callbacks, session);
 	local function success_cb(request)
 		--log("debug", "success_cb: %s", request.path);
+		request.id = id.short();
+		request.log = logger.init("http." .. request.method .. "-" .. request.id);
 		request.ip = ip;
 		request.secure = secure;
 		session.thread:run(request);
@@ -232,6 +241,8 @@
 	request.headers = headers;
 	request.conn = conn;
 
+	request.log("debug", "%s %s HTTP/%s", request.method, request.path, request.httpversion);
+
 	local date_header = os_date('!%a, %d %b %Y %H:%M:%S GMT'); -- FIXME use
 	local conn_header = request.headers.connection;
 	conn_header = conn_header and ","..conn_header:gsub("[ \t]", ""):lower().."," or ""
@@ -249,10 +260,12 @@
 	local is_head_request = request.method == "HEAD";
 
 	local response = {
+		id = request.id;
+		log = request.log;
 		request = request;
 		is_head_request = is_head_request;
 		status_code = 200;
-		headers = { date = date_header, connection = response_conn_header };
+		headers = { date = date_header; connection = response_conn_header; x_request_id = request.id };
 		persistent = persistent;
 		conn = conn;
 		send = _M.send_response;
@@ -281,11 +294,9 @@
 	local global_event = request.method.." "..request.path:match("[^?]*");
 
 	local payload = { request = request, response = response };
-	log("debug", "Firing event: %s", global_event);
 	local result = events.fire_event(global_event, payload);
 	if result == nil and is_head_request then
 		local global_head_event = "GET "..request.path:match("[^?]*");
-		log("debug", "Firing event: %s", global_head_event);
 		result = events.fire_event(global_head_event, payload);
 	end
 	if result == nil then
@@ -306,12 +317,10 @@
 		end
 
 		local host_event = request.method.." "..host..request.path:match("[^?]*");
-		log("debug", "Firing event: %s", host_event);
 		result = events.fire_event(host_event, payload);
 
 		if result == nil and is_head_request then
 			local host_head_event = "GET "..host..request.path:match("[^?]*");
-			log("debug", "Firing event: %s", host_head_event);
 			result = events.fire_event(host_head_event, payload);
 		end
 	end
@@ -321,6 +330,7 @@
 
 local function prepare_header(response)
 	local status_line = "HTTP/"..response.request.httpversion.." "..(response.status or codes[response.status_code]);
+	response.log("debug", "%s", status_line);
 	local headers = response.headers;
 	local output = { status_line };
 	for k,v in pairs(headers) do
@@ -378,11 +388,11 @@
 			response.conn:write(chunk);
 		else
 			incomplete[response.conn] = nil;
+			if f.close then f:close(); end
 			if chunked then
 				response.conn:write("0\r\n\r\n");
 			end
 			-- io.write("\n");
-			if f.close then f:close(); end
 			return response:done();
 		end
 	end
@@ -397,9 +407,8 @@
 		response:on_destroy();
 		response.on_destroy = nil;
 	end
-	if response.persistent then
-		response:finish_cb();
-	else
+	response:finish_cb();
+	if not response.persistent then
 		response.conn:close();
 	end
 end
--- a/net/httpserver.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/httpserver.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,5 +1,5 @@
 -- COMPAT w/pre-0.9
-local log = require "util.logger".init("net.httpserver");
+local log = require "prosody.util.logger".init("net.httpserver");
 local traceback = debug.traceback;
 
 local _ENV = nil;
--- a/net/resolvers/basic.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/resolvers/basic.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,14 +1,62 @@
-local adns = require "net.adns";
-local inet_pton = require "util.net".pton;
-local inet_ntop = require "util.net".ntop;
-local idna_to_ascii = require "util.encodings".idna.to_ascii;
-local unpack = table.unpack or unpack; -- luacheck: ignore 113
+local adns = require "prosody.net.adns";
+local inet_pton = require "prosody.util.net".pton;
+local inet_ntop = require "prosody.util.net".ntop;
+local idna_to_ascii = require "prosody.util.encodings".idna.to_ascii;
+local promise = require "prosody.util.promise";
+local t_move = require "prosody.util.table".move;
 
 local methods = {};
 local resolver_mt = { __index = methods };
 
 -- FIXME RFC 6724
 
+local function do_dns_lookup(self, dns_resolver, record_type, name, allow_insecure)
+	return promise.new(function (resolve, reject)
+		local ipv = (record_type == "A" and "4") or (record_type == "AAAA" and "6") or nil;
+		if ipv and self.extra["use_ipv"..ipv] == false then
+			return reject(("IPv%s disabled - %s lookup skipped"):format(ipv, record_type));
+		elseif record_type == "TLSA" and self.extra.use_dane ~= true then
+			return reject("DANE disabled - TLSA lookup skipped");
+		end
+		dns_resolver:lookup(function (answer, err)
+			if not answer then
+				return reject(err);
+			elseif answer.bogus then
+				return reject(("Validation error in %s lookup"):format(record_type));
+			elseif not (answer.secure or allow_insecure) then
+				return reject(("Insecure response in %s lookup"):format(record_type));
+			elseif answer.status and #answer == 0 then
+				return reject(("%s in %s lookup"):format(answer.status, record_type));
+			end
+
+			local targets = { secure = answer.secure };
+			for _, record in ipairs(answer) do
+				if ipv then
+					table.insert(targets, { self.conn_type..ipv, record[record_type:lower()], self.port, self.extra });
+				else
+					table.insert(targets, record[record_type:lower()]);
+				end
+			end
+			return resolve(targets);
+		end, name, record_type, "IN");
+	end);
+end
+
+local function merge_targets(ipv4_targets, ipv6_targets)
+	local result = { secure = ipv4_targets.secure and ipv6_targets.secure };
+	local common_length = math.min(#ipv4_targets, #ipv6_targets);
+	for i = 1, common_length do
+		table.insert(result, ipv6_targets[i]);
+		table.insert(result, ipv4_targets[i]);
+	end
+	if common_length < #ipv4_targets then
+		t_move(ipv4_targets, common_length+1, #ipv4_targets, common_length+1, result);
+	elseif common_length < #ipv6_targets then
+		t_move(ipv6_targets, common_length+1, #ipv6_targets, common_length+1, result);
+	end
+	return result;
+end
+
 -- Find the next target to connect to, and
 -- pass it to cb()
 function methods:next(cb)
@@ -18,7 +66,7 @@
 			return;
 		end
 		local next_target = table.remove(self.targets, 1);
-		cb(unpack(next_target, 1, 4));
+		cb(next_target[1], next_target[2], next_target[3], next_target[4], not not self.targets[1]);
 		return;
 	end
 
@@ -28,91 +76,47 @@
 		return;
 	end
 
-	local secure = true;
-	local tlsa = {};
-	local targets = {};
-	local n = 3;
-	local function ready()
-		n = n - 1;
-		if n > 0 then return; end
-		self.targets = targets;
+	-- Resolve DNS to target list
+	local dns_resolver = adns.resolver();
+
+	local dns_lookups = {
+		ipv4 = do_dns_lookup(self, dns_resolver, "A", self.hostname, true);
+		ipv6 = do_dns_lookup(self, dns_resolver, "AAAA", self.hostname, true);
+		tlsa = do_dns_lookup(self, dns_resolver, "TLSA", ("_%d._%s.%s"):format(self.port, self.conn_type, self.hostname));
+	};
+
+	promise.all_settled(dns_lookups):next(function (dns_results)
+		-- Combine targets, assign to self.targets, self:next(cb)
+		local have_ipv4 = dns_results.ipv4.status == "fulfilled";
+		local have_ipv6 = dns_results.ipv6.status == "fulfilled";
+
+		if have_ipv4 and have_ipv6 then
+			self.targets = merge_targets(dns_results.ipv4.value, dns_results.ipv6.value);
+		elseif have_ipv4 then
+			self.targets = dns_results.ipv4.value;
+		elseif have_ipv6 then
+			self.targets = dns_results.ipv6.value;
+		else
+			self.targets = {};
+		end
+
 		if self.extra and self.extra.use_dane then
-			if secure and tlsa[1] then
-				self.extra.tlsa = tlsa;
+			if self.targets.secure and dns_results.tlsa.status == "fulfilled" then
+				self.extra.tlsa = dns_results.tlsa.value;
 				self.extra.dane_hostname = self.hostname;
 			else
 				self.extra.tlsa = nil;
 				self.extra.dane_hostname = nil;
 			end
+		elseif self.extra and self.extra.srv_secure then
+			self.extra.secure_hostname = self.hostname;
 		end
-		self:next(cb);
-	end
-
-	-- Resolve DNS to target list
-	local dns_resolver = adns.resolver();
-
-	if not self.extra or self.extra.use_ipv4 ~= false then
-		dns_resolver:lookup(function (answer, err)
-			if answer then
-				secure = secure and answer.secure;
-				for _, record in ipairs(answer) do
-					table.insert(targets, { self.conn_type.."4", record.a, self.port, self.extra });
-				end
-				if answer.bogus then
-					self.last_error = "Validation error in A lookup";
-				elseif answer.status then
-					self.last_error = answer.status .. " in A lookup";
-				end
-			else
-				self.last_error = err;
-			end
-			ready();
-		end, self.hostname, "A", "IN");
-	else
-		ready();
-	end
 
-	if not self.extra or self.extra.use_ipv6 ~= false then
-		dns_resolver:lookup(function (answer, err)
-			if answer then
-				secure = secure and answer.secure;
-				for _, record in ipairs(answer) do
-					table.insert(targets, { self.conn_type.."6", record.aaaa, self.port, self.extra });
-				end
-				if answer.bogus then
-					self.last_error = "Validation error in AAAA lookup";
-				elseif answer.status then
-					self.last_error = answer.status .. " in AAAA lookup";
-				end
-			else
-				self.last_error = err;
-			end
-			ready();
-		end, self.hostname, "AAAA", "IN");
-	else
-		ready();
-	end
-
-	if self.extra and self.extra.use_dane == true then
-		dns_resolver:lookup(function (answer, err)
-			if answer then
-				secure = secure and answer.secure;
-				for _, record in ipairs(answer) do
-					table.insert(tlsa, record.tlsa);
-				end
-				if answer.bogus then
-					self.last_error = "Validation error in TLSA lookup";
-				elseif answer.status then
-					self.last_error = answer.status .. " in TLSA lookup";
-				end
-			else
-				self.last_error = err;
-			end
-			ready();
-		end, ("_%d._tcp.%s"):format(self.port, self.hostname), "TLSA", "IN");
-	else
-		ready();
-	end
+		self:next(cb);
+	end):catch(function (err)
+		self.last_error = err;
+		self.targets = {};
+	end);
 end
 
 local function new(hostname, port, conn_type, extra)
@@ -137,7 +141,7 @@
 		hostname = ascii_host;
 		port = port;
 		conn_type = conn_type;
-		extra = extra;
+		extra = extra or {};
 		targets = targets;
 	}, resolver_mt);
 end
--- a/net/resolvers/manual.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/resolvers/manual.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,6 @@
 local methods = {};
 local resolver_mt = { __index = methods };
-local unpack = table.unpack or unpack; -- luacheck: ignore 113
+local unpack = table.unpack;
 
 -- Find the next target to connect to, and
 -- pass it to cb()
--- a/net/resolvers/service.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/resolvers/service.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,24 +1,79 @@
-local adns = require "net.adns";
-local basic = require "net.resolvers.basic";
-local inet_pton = require "util.net".pton;
-local idna_to_ascii = require "util.encodings".idna.to_ascii;
-local unpack = table.unpack or unpack; -- luacheck: ignore 113
+local adns = require "prosody.net.adns";
+local basic = require "prosody.net.resolvers.basic";
+local inet_pton = require "prosody.util.net".pton;
+local idna_to_ascii = require "prosody.util.encodings".idna.to_ascii;
 
 local methods = {};
 local resolver_mt = { __index = methods };
 
+local function new_target_selector(rrset)
+	local rr_count = rrset and #rrset;
+	if not rr_count or rr_count == 0 then
+		rrset = nil;
+	else
+		table.sort(rrset, function (a, b) return a.srv.priority < b.srv.priority end);
+	end
+	local rrset_pos = 1;
+	local priority_bucket, bucket_total_weight, bucket_len, bucket_used;
+	return function ()
+		if not rrset then return; end
+
+		if not priority_bucket or bucket_used >= bucket_len then
+			if rrset_pos > rr_count then return; end -- Used up all records
+
+			-- Going to start on a new priority now. Gather up all the next
+			-- records with the same priority and add them to priority_bucket
+			priority_bucket, bucket_total_weight, bucket_len, bucket_used = {}, 0, 0, 0;
+			local current_priority;
+			repeat
+				local curr_record = rrset[rrset_pos].srv;
+				if not current_priority then
+					current_priority = curr_record.priority;
+				elseif current_priority ~= curr_record.priority then
+					break;
+				end
+				table.insert(priority_bucket, curr_record);
+				bucket_total_weight = bucket_total_weight + curr_record.weight;
+				bucket_len = bucket_len + 1;
+				rrset_pos = rrset_pos + 1;
+			until rrset_pos > rr_count;
+		end
+
+		bucket_used = bucket_used + 1;
+		local n, running_total = math.random(0, bucket_total_weight), 0;
+		local target_record;
+		for i = 1, bucket_len do
+			local candidate = priority_bucket[i];
+			if candidate then
+				running_total = running_total + candidate.weight;
+				if running_total >= n then
+					target_record = candidate;
+					bucket_total_weight = bucket_total_weight - candidate.weight;
+					priority_bucket[i] = nil;
+					break;
+				end
+			end
+		end
+		return target_record;
+	end;
+end
+
 -- Find the next target to connect to, and
 -- pass it to cb()
 function methods:next(cb)
-	if self.targets then
-		if not self.resolver then
-			if #self.targets == 0 then
+	if self.resolver or self._get_next_target then
+		if not self.resolver then -- Do we have a basic resolver currently?
+			-- We don't, so fetch a new SRV target, create a new basic resolver for it
+			local next_srv_target = self._get_next_target and self._get_next_target();
+			if not next_srv_target then
+				-- No more SRV targets left
 				cb(nil);
 				return;
 			end
-			local next_target = table.remove(self.targets, 1);
-			self.resolver = basic.new(unpack(next_target, 1, 4));
+			-- Create a new basic resolver for this SRV target
+			self.resolver = basic.new(next_srv_target.target, next_srv_target.port, self.conn_type, self.extra);
 		end
+		-- Look up the next (basic) target from the current target's resolver
 		self.resolver:next(function (...)
 			if self.resolver then
 				self.last_error = self.resolver.last_error;
@@ -31,6 +86,9 @@
 			end
 		end);
 		return;
+	elseif self.in_progress then
+		cb(nil);
+		return;
 	end
 
 	if not self.hostname then
@@ -39,9 +97,9 @@
 		return;
 	end
 
-	local targets = {};
+	self.in_progress = true;
+
 	local function ready()
-		self.targets = targets;
 		self:next(cb);
 	end
 
@@ -53,17 +111,23 @@
 			answer = {};
 		end
 		if answer then
-			if self.extra and not answer.secure then
-				self.extra.use_dane = false;
-			elseif answer.bogus then
+			if answer.bogus then
 				self.last_error = "Validation error in SRV lookup";
 				ready();
 				return;
+			elseif not answer.secure then
+				if self.extra then
+					-- Insecure results, so no DANE
+					self.extra.use_dane = false;
+				end
+			end
+			if self.extra then
+				self.extra.srv_secure = answer.secure;
 			end
 
 			if #answer == 0 then
 				if self.extra and self.extra.default_port then
-					table.insert(targets, { self.hostname, self.extra.default_port, self.conn_type, self.extra });
+					self.resolver = basic.new(self.hostname, self.extra.default_port, self.conn_type, self.extra);
 				else
 					self.last_error = "zero SRV records found";
 				end
@@ -77,10 +141,7 @@
 				return;
 			end
 
-			table.sort(answer, function (a, b) return a.srv.priority < b.srv.priority end);
-			for _, record in ipairs(answer) do
-				table.insert(targets, { record.srv.target, record.srv.port, self.conn_type, self.extra });
-			end
+			self._get_next_target = new_target_selector(answer);
 		else
 			self.last_error = err;
 		end
--- a/net/server.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/server.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,20 +6,23 @@
 -- COPYING file in the source package for more information.
 --
 
-if not (prosody and prosody.config_loaded) then
-	-- This module only supports loading inside Prosody, outside Prosody
-	-- you should directly require net.server_select or server_event, etc.
-	error(debug.traceback("Loading outside Prosody or Prosody not yet initialized"), 0);
+local function log(level, format, ...)
+	print("net.server", level, format:format(...));
 end
 
-local log = require "util.logger".init("net.server");
-
-local default_backend = "epoll";
+local default_backend = "select";
+local server_type = default_backend;
 
-local server_type = require "core.configmanager".get("*", "network_backend") or default_backend;
+if (prosody and prosody.config_loaded) then
+	default_backend = "epoll";
+	log = require"prosody.util.logger".init("net.server");
+	server_type = require"prosody.core.configmanager".get("*", "network_backend") or default_backend;
 
-if require "core.configmanager".get("*", "use_libevent") then
-	server_type = "event";
+	if require"prosody.core.configmanager".get("*", "use_libevent") then
+		server_type = "event";
+	end
+elseif pcall(require, "prosody.util.poll") then
+	server_type = "epoll";
 end
 
 if server_type == "event" then
@@ -32,7 +35,7 @@
 local server;
 local set_config;
 if server_type == "event" then
-	server = require "net.server_event";
+	server = require "prosody.net.server_event";
 
 	local defaults = {};
 	for k,v in pairs(server.cfg) do
@@ -61,7 +64,7 @@
 elseif server_type == "select" then
 	-- TODO Remove completely.
 	log("warn", "select is deprecated, the new default is epoll. For more info see https://prosody.im/doc/network_backend");
-	server = require "net.server_select";
+	server = require "prosody.net.server_select";
 
 	local defaults = {};
 	for k,v in pairs(server.getsettings()) do
@@ -75,7 +78,7 @@
 		server.changesettings(select_settings);
 	end
 else
-	server = require("net.server_"..server_type);
+	server = require("prosody.net.server_"..server_type);
 	set_config = server.set_config;
 	if not server.get_backend then
 		function server.get_backend()
@@ -85,7 +88,7 @@
 end
 
 -- If server.hook_signal exists, replace signal.signal()
-local has_signal, signal = pcall(require, "util.signal");
+local has_signal, signal = pcall(require, "prosody.util.signal");
 if has_signal then
 	if server.hook_signal then
 		function signal.signal(signal_id, handler)
@@ -109,7 +112,7 @@
 end
 
 if prosody and set_config then
-	local config_get = require "core.configmanager".get;
+	local config_get = require "prosody.core.configmanager".get;
 	local function load_config()
 		local settings = config_get("*", "network_settings") or {};
 		return set_config(settings);
@@ -118,6 +121,15 @@
 	prosody.events.add_handler("config-reloaded", load_config);
 end
 
--- require "net.server" shall now forever return this,
+if prosody and server.tls_builder then
+	local tls_builder = server.tls_builder;
+	-- resolving the basedir here avoids util.sslconfig depending on
+	-- prosody.paths.config
+	function server.tls_builder()
+		return tls_builder(prosody.paths.config or "")
+	end
+end
+
+-- require "prosody.net.server" shall now forever return this,
 -- ie. server_select or server_event as chosen above.
 return server;
--- a/net/server_epoll.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/server_epoll.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -15,21 +15,23 @@
 local pairs = pairs;
 local ipairs = ipairs;
 local traceback = debug.traceback;
-local logger = require "util.logger";
+local logger = require "prosody.util.logger";
 local log = logger.init("server_epoll");
 local socket = require "socket";
-local luasec = require "ssl";
-local realtime = require "util.time".now;
-local monotonic = require "util.time".monotonic;
-local indexedbheap = require "util.indexedbheap";
-local createtable = require "util.table".create;
-local inet = require "util.net";
+local realtime = require "prosody.util.time".now;
+local monotonic = require "prosody.util.time".monotonic;
+local indexedbheap = require "prosody.util.indexedbheap";
+local createtable = require "prosody.util.table".create;
+local inet = require "prosody.util.net";
 local inet_pton = inet.pton;
 local _SOCKETINVALID = socket._SOCKETINVALID or -1;
-local new_id = require "util.id".short;
-local xpcall = require "util.xpcall".xpcall;
+local new_id = require "prosody.util.id".short;
+local xpcall = require "prosody.util.xpcall".xpcall;
+local sslconfig = require "prosody.util.sslconfig";
+local tls_impl = require "prosody.net.tls_luasec";
+local have_signal, signal = pcall(require, "prosody.util.signal");
 
-local poller = require "util.poll"
+local poller = require "prosody.util.poll"
 local EEXIST = poller.EEXIST;
 local ENOENT = poller.ENOENT;
 
@@ -91,6 +93,12 @@
 
 	--- How long to wait after getting the shutdown signal before forcefully tearing down every socket
 	shutdown_deadline = 5;
+
+	-- TCP Fast Open
+	tcp_fastopen = false;
+
+	-- Defer accept until incoming data is available
+	tcp_defer_accept = false;
 }};
 local cfg = default_config.__index;
 
@@ -393,6 +401,9 @@
 	end
 	if r == nil then r = self._wantread; end
 	if w == nil then w = self._wantwrite; end
+	if r  == self._wantread and w == self._wantwrite then
+		return true
+	end
 	local ok, err, errno = poll:set(fd, r, w);
 	if not ok then
 		self:debug("Could not update poller state: %s(%d)", err, errno);
@@ -614,6 +625,47 @@
 	self._sslctx = sslctx;
 end
 
+function interface:sslctx()
+	return self.tls_ctx
+end
+
+function interface:ssl_info()
+	local sock = self.conn;
+	if not sock then return nil, "not-connected" end
+	if not sock.info then return nil, "not-implemented"; end
+	return sock:info();
+end
+
+function interface:ssl_peercertificate()
+	local sock = self.conn;
+	if not sock then return nil, "not-connected" end
+	if not sock.getpeercertificate then return nil, "not-implemented"; end
+	return sock:getpeercertificate();
+end
+
+function interface:ssl_peerverification()
+	local sock = self.conn;
+	if not sock then return nil, "not-connected" end
+	if not sock.getpeerverification then return nil, { { "Chain verification not supported" } }; end
+	return sock:getpeerverification();
+end
+
+function interface:ssl_peerfinished()
+	local sock = self.conn;
+	if not sock then return nil, "not-connected" end
+	if not sock.getpeerfinished then return nil, "not-implemented"; end
+	return sock:getpeerfinished();
+end
+
+function interface:ssl_exportkeyingmaterial(label, len, context)
+	local sock = self.conn;
+	if not sock then return nil, "not-connected" end
+	if sock.exportkeyingmaterial then
+		return sock:exportkeyingmaterial(label, len, context);
+	end
+end
+
+
 function interface:starttls(tls_ctx)
 	if tls_ctx then self.tls_ctx = tls_ctx; end
 	self.starttls = false;
@@ -641,11 +693,7 @@
 	self.starttls = false;
 	self:debug("Starting TLS now");
 	self:updatenames(); -- Can't getpeer/sockname after wrap()
-	local ok, conn, err = pcall(luasec.wrap, self.conn, self.tls_ctx);
-	if not ok then
-		conn, err = ok, conn;
-		self:debug("Failed to initialize TLS: %s", err);
-	end
+	local conn, err = self.tls_ctx:wrap(self.conn);
 	if not conn then
 		self:on("disconnect", err);
 		self:destroy();
@@ -656,8 +704,8 @@
 	if conn.sni then
 		if self.servername then
 			conn:sni(self.servername);
-		elseif self._server and type(self._server.hosts) == "table" and next(self._server.hosts) ~= nil then
-			conn:sni(self._server.hosts, true);
+		elseif next(self.tls_ctx._sni_contexts) ~= nil then
+			conn:sni(self.tls_ctx._sni_contexts, true);
 		end
 	end
 	if self.extra and self.extra.tlsa and conn.settlsa then
@@ -741,7 +789,6 @@
 		end
 	end
 
-	conn:updatenames();
 	return conn;
 end
 
@@ -767,6 +814,7 @@
 		return;
 	end
 	local client = wrapsocket(conn, self, nil, self.listeners);
+	client:updatenames();
 	client:debug("New connection %s on server %s", client, self);
 	client:defaultoptions();
 	client._writable = cfg.opportunistic_writes;
@@ -885,6 +933,12 @@
 		log = logger.init(("serv%s"):format(new_id()));
 	}, interface_mt);
 	server:debug("Server %s created", server);
+	if cfg.tcp_fastopen then
+		server:setoption("tcp-fastopen", cfg.tcp_fastopen);
+	end
+	if type(cfg.tcp_defer_accept) == "number" then
+		server:setoption("tcp-defer-accept", cfg.tcp_defer_accept);
+	end
 	server:add(true, false);
 	return server;
 end
@@ -908,6 +962,7 @@
 -- COMPAT
 local function wrapclient(conn, addr, port, listeners, read_size, tls_ctx, extra)
 	local client = wrapsocket(conn, nil, read_size, listeners, tls_ctx, extra);
+	client:updatenames();
 	if not client.peername then
 		client.peername, client.peerport = addr, port;
 	end
@@ -941,9 +996,13 @@
 	if not conn then return conn, err; end
 	local ok, err = conn:settimeout(0);
 	if not ok then return ok, err; end
+	local client = wrapsocket(conn, nil, read_size, listeners, tls_ctx, extra)
+	if cfg.tcp_fastopen then
+		client:setoption("tcp-fastopen-connect", 1);
+	end
 	local ok, err = conn:setpeername(addr, port);
 	if not ok and err ~= "timeout" then return ok, err; end
-	local client = wrapsocket(conn, nil, read_size, listeners, tls_ctx, extra)
+	client:updatenames();
 	local ok, err = client:init();
 	if not client.peername then
 		-- otherwise not set until connected
@@ -1032,12 +1091,38 @@
 	end
 end
 
+local function loop_once()
+	runtimers(); -- Ignore return value because we only do this once
+	local fd, r, w = poll:wait(0);
+	if fd then
+		local conn = fds[fd];
+		if conn then
+			if r then
+				conn:onreadable();
+			end
+			if w then
+				conn:onwritable();
+			end
+		else
+			log("debug", "Removing unknown fd %d", fd);
+			poll:del(fd);
+		end
+	else
+		return fd, r;
+	end
+end
+
 -- Main loop
 local function loop(once)
-	repeat
-		local t = runtimers(cfg.max_wait, cfg.min_wait);
+	if once then
+		return loop_once();
+	end
+
+	local t = 0;
+	while not quitting do
 		local fd, r, w = poll:wait(t);
-		while fd do
+		if fd then
+			t = 0;
 			local conn = fds[fd];
 			if conn then
 				if r then
@@ -1050,15 +1135,35 @@
 				log("debug", "Removing unknown fd %d", fd);
 				poll:del(fd);
 			end
-			fd, r, w = poll:wait(0);
-		end
-		if r ~= "timeout" and r ~= "signal" then
+		elseif r == "timeout" then
+			t = runtimers(cfg.max_wait, cfg.min_wait);
+		elseif r ~= "signal" then
 			log("debug", "epoll_wait error: %s[%d]", r, w);
 		end
-	until once or (quitting and next(fds) == nil);
+	end
 	return quitting;
 end
 
+local hook_signal;
+if have_signal and signal.signalfd then
+	local function dispatch(self)
+		return self:on("signal", self.conn:read());
+	end
+
+	function hook_signal(signum, cb)
+		local sigfd = signal.signalfd(signum);
+		if not sigfd then
+			log("error", "Could not hook signal %d", signum);
+			return nil, "failed";
+		end
+		local watch = watchfd(sigfd, dispatch);
+		watch.listeners = { onsignal = cb };
+		watch.close = nil; -- revert to default
+		watch:noise("Signal handler %d ready", signum);
+		return watch;
+	end
+end
+
 return {
 	get_backend = function () return "epoll"; end;
 	addserver = addserver;
@@ -1084,6 +1189,11 @@
 	set_config = function (newconfig)
 		cfg = setmetatable(newconfig, default_config);
 	end;
+	hook_signal = hook_signal;
+
+	tls_builder = function(basedir)
+		return sslconfig._new(tls_impl.new_context, basedir)
+	end,
 
 	-- libevent emulation
 	event = { EV_READ = "r", EV_WRITE = "w", EV_READWRITE = "rw", EV_LEAVE = -1 };
--- a/net/server_event.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/server_event.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -47,15 +47,17 @@
 local coroutine_wrap = coroutine.wrap
 local coroutine_yield = coroutine.yield
 
-local has_luasec, ssl = pcall ( require , "ssl" )
+local has_luasec = pcall ( require , "ssl" )
 local socket = require "socket"
 local levent = require "luaevent.core"
-local inet = require "util.net";
+local inet = require "prosody.util.net";
 local inet_pton = inet.pton;
+local sslconfig = require "prosody.util.sslconfig";
+local tls_impl = require "prosody.net.tls_luasec";
 
 local socket_gettime = socket.gettime
 
-local log = require ("util.logger").init("socket")
+local log = require ("prosody.util.logger").init("socket")
 
 local function debug(...)
 	return log("debug", ("%s "):rep(select('#', ...)), ...)
@@ -153,7 +155,7 @@
 	_ = self.eventwrite and self.eventwrite:close( )
 	self.eventread, self.eventwrite = nil, nil
 	local err
-	self.conn, err = ssl.wrap( self.conn, self._sslctx )
+	self.conn, err = self._sslctx:wrap(self.conn)
 	if err then
 		self.fatalerror = err
 		self.conn = nil  -- cannot be used anymore
@@ -168,8 +170,8 @@
 	if self.conn.sni then
 		if self.servername then
 			self.conn:sni(self.servername);
-		elseif self._server and type(self._server.hosts) == "table" and next(self._server.hosts) ~= nil then
-			self.conn:sni(self._server.hosts, true);
+		elseif next(self._sslctx._sni_contexts) ~= nil then
+			self.conn:sni(self._sslctx._sni_contexts, true);
 		end
 	end
 
@@ -274,6 +276,34 @@
 	return self:_lock(self.nointerface, true, self.nowriting);
 end
 
+function interface_mt:sslctx()
+	return self._sslctx
+end
+
+function interface_mt:ssl_info()
+	local sock = self.conn;
+	if not sock.info then return nil, "not-implemented"; end
+	return sock:info();
+end
+
+function interface_mt:ssl_peercertificate()
+	local sock = self.conn;
+	if not sock.getpeercertificate then return nil, "not-implemented"; end
+	return sock:getpeercertificate();
+end
+
+function interface_mt:ssl_peerverification()
+	local sock = self.conn;
+	if not sock.getpeerverification then return nil, { { "Chain verification not supported" } }; end
+	return sock:getpeerverification();
+end
+
+function interface_mt:ssl_peerfinished()
+	local sock = self.conn;
+	if not sock.getpeerfinished then return nil, "not-implemented"; end
+	return sock:getpeerfinished();
+end
+
 function interface_mt:resume()
 	self:_lock(self.nointerface, false, self.nowriting);
 	if self.readcallback and not self.eventread then
@@ -924,6 +954,10 @@
 	add_task = add_task,
 	watchfd = watchfd,
 
+	tls_builder = function(basedir)
+		return sslconfig._new(tls_impl.new_context, basedir)
+	end,
+
 	__NAME = SCRIPT_NAME,
 	__DATE = LAST_MODIFIED,
 	__AUTHOR = SCRIPT_AUTHOR,
--- a/net/server_select.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/server_select.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -11,7 +11,7 @@
 	return _G[ what ]
 end
 
-local log, table_concat = require ("util.logger").init("socket"), table.concat;
+local log, table_concat = require ("prosody.util.logger").init("socket"), table.concat;
 local out_put = function (...) return log("debug", table_concat{...}); end
 local out_error = function (...) return log("warn", table_concat{...}); end
 
@@ -47,15 +47,15 @@
 
 --// extern libs //--
 
-local has_luasec, luasec = pcall ( require , "ssl" )
 local luasocket = use "socket" or require "socket"
 local luasocket_gettime = luasocket.gettime
-local inet = require "util.net";
+local inet = require "prosody.util.net";
 local inet_pton = inet.pton;
+local sslconfig = require "prosody.util.sslconfig";
+local has_luasec, tls_impl = pcall(require, "prosody.net.tls_luasec");
 
 --// extern lib methods //--
 
-local ssl_wrap = ( has_luasec and luasec.wrap )
 local socket_bind = luasocket.bind
 local socket_select = luasocket.select
 
@@ -359,6 +359,21 @@
 	handler.sslctx = function ( )
 		return sslctx
 	end
+	handler.ssl_info = function( )
+		return socket.info and socket:info()
+	end
+	handler.ssl_peercertificate = function( )
+		if not socket.getpeercertificate then return nil, "not-implemented"; end
+		return socket:getpeercertificate()
+	end
+	handler.ssl_peerverification = function( )
+		if not socket.getpeerverification then return nil, { { "Chain verification not supported" } }; end
+		return socket:getpeerverification();
+	end
+	handler.ssl_peerfinished = function( )
+		if not socket.getpeerfinished then return nil, "not-implemented"; end
+		return socket:getpeerfinished();
+	end
 	handler.send = function( _, data, i, j )
 		return send( socket, data, i, j )
 	end
@@ -652,7 +667,7 @@
 			end
 			out_put( "server.lua: attempting to start tls on " .. tostring( socket ) )
 			local oldsocket, err = socket
-			socket, err = ssl_wrap( socket, sslctx )	-- wrap socket
+			socket, err = sslctx:wrap(socket)	-- wrap socket
 
 			if not socket then
 				out_put( "server.lua: error while starting tls on client: ", tostring(err or "unknown error") )
@@ -662,8 +677,8 @@
 			if socket.sni then
 				if self.servername then
 					socket:sni(self.servername);
-				elseif self._server and type(self._server.hosts) == "table" and next(self._server.hosts) ~= nil then
-					socket:sni(self.server().hosts, true);
+				elseif next(sslctx._sni_contexts) ~= nil then
+					socket:sni(sslctx._sni_contexts, true);
 				end
 			end
 
@@ -1169,4 +1184,8 @@
 	removeserver = removeserver,
 	get_backend = get_backend,
 	changesettings = changesettings,
+
+	tls_builder = function(basedir)
+		return sslconfig._new(tls_impl.new_context, basedir)
+	end,
 }
--- a/net/stun.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/stun.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,11 +1,11 @@
-local base64 = require "util.encodings".base64;
-local hashes = require "util.hashes";
-local net = require "util.net";
-local random = require "util.random";
-local struct = require "util.struct";
-local bit32 = require"util.bitcompat";
-local sxor = require"util.strbitop".sxor;
-local new_ip = require "util.ip".new_ip;
+local base64 = require "prosody.util.encodings".base64;
+local hashes = require "prosody.util.hashes";
+local net = require "prosody.util.net";
+local random = require "prosody.util.random";
+local struct = require "prosody.util.struct";
+local bit32 = require"prosody.util.bitcompat";
+local sxor = require"prosody.util.strbitop".sxor;
+local new_ip = require "prosody.util.ip".new_ip;
 
 --- Public helpers
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/net/tls_luasec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,114 @@
+-- Prosody IM
+-- Copyright (C) 2021 Prosody folks
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+--[[
+This file provides a shim abstraction over LuaSec, consolidating some code
+which was previously spread between net.server backends, portmanager and
+certmanager.
+
+The goal is to provide a more or less well-defined API on top of LuaSec which
+abstracts away some of the things which are not needed and simplifies usage of
+commonly used things (such as SNI contexts). Eventually, network backends
+which do not rely on LuaSocket+LuaSec should be able to provide *this* API
+instead of having to mimic LuaSec.
+]]
+local ssl = require "ssl";
+local ssl_newcontext = ssl.newcontext;
+local ssl_context = ssl.context or require "ssl.context";
+local io_open = io.open;
+
+local context_api = {};
+local context_mt = {__index = context_api};
+
+function context_api:set_sni_host(host, cert, key)
+	local ctx, err = self._builder:clone():apply({
+		certificate = cert,
+		key = key,
+	}):build();
+	if not ctx then
+		return false, err
+	end
+
+	self._sni_contexts[host] = ctx._inner
+
+	return true, nil
+end
+
+function context_api:remove_sni_host(host)
+	self._sni_contexts[host] = nil
+end
+
+function context_api:wrap(sock)
+	local ok, conn, err = pcall(ssl.wrap, sock, self._inner);
+	if not ok then
+		return nil, err
+	end
+	return conn, nil
+end
+
+local function new_context(cfg, builder)
+	-- LuaSec expects dhparam to be a callback that takes two arguments.
+	-- We ignore those because it is mostly used for having a separate
+	-- set of params for EXPORT ciphers, which we don't have by default.
+	if type(cfg.dhparam) == "string" then
+		local f, err = io_open(cfg.dhparam);
+		if not f then return nil, "Could not open DH parameters: "..err end
+		local dhparam = f:read("*a");
+		f:close();
+		cfg.dhparam = function() return dhparam; end
+	end
+
+	local inner, err = ssl_newcontext(cfg);
+	if not inner then
+		return nil, err
+	end
+
+	-- COMPAT Older LuaSec ignores the cipher list from the config, so we have to take care
+	-- of it ourselves (W/A for #x)
+	if inner and cfg.ciphers then
+		local success;
+		success, err = ssl_context.setcipher(inner, cfg.ciphers);
+		if not success then
+			return nil, err
+		end
+	end
+
+	return setmetatable({
+		_inner = inner,
+		_builder = builder,
+		_sni_contexts = {},
+	}, context_mt), nil
+end
+
+-- Feature detection / guessing
+local function test_option(option)
+	return not not ssl_newcontext({mode="server",protocol="sslv23",options={ option }});
+end
+local luasec_major, luasec_minor = ssl._VERSION:match("^(%d+)%.(%d+)");
+local luasec_version = tonumber(luasec_major) * 100 + tonumber(luasec_minor);
+local luasec_has = ssl.config or {
+	algorithms = {
+		ec = luasec_version >= 5;
+	};
+	capabilities = {
+		curves_list = luasec_version >= 7;
+	};
+	options = {
+		cipher_server_preference = test_option("cipher_server_preference");
+		no_ticket = test_option("no_ticket");
+		no_compression = test_option("no_compression");
+		single_dh_use = test_option("single_dh_use");
+		single_ecdh_use = test_option("single_ecdh_use");
+		no_renegotiation = test_option("no_renegotiation");
+	};
+};
+
+return {
+	features = luasec_has;
+	new_context = new_context,
+	load_certificate = ssl.loadcertificate;
+};
--- a/net/unbound.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/unbound.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -13,15 +13,15 @@
 local s_upper = string.upper;
 local noop = function() end;
 
-local logger = require "util.logger";
+local logger = require "prosody.util.logger";
 local log = logger.init("unbound");
-local net_server = require "net.server";
+local net_server = require "prosody.net.server";
 local libunbound = require"lunbound";
-local promise = require"util.promise";
-local new_id = require "util.id".short;
+local promise = require"prosody.util.promise";
+local new_id = require "prosody.util.id".short;
 
 local gettime = require"socket".gettime;
-local dns_utils = require"util.dns";
+local dns_utils = require"prosody.util.dns";
 local classes, types, errors = dns_utils.classes, dns_utils.types, dns_utils.errors;
 local parsers = dns_utils.parsers;
 
@@ -44,7 +44,7 @@
 
 local unbound_config;
 if prosody then
-	local config = require"core.configmanager";
+	local config = require"prosody.core.configmanager";
 	unbound_config = add_defaults(config.get("*", "unbound"));
 	prosody.events.add_handler("config-reloaded", function()
 		unbound_config = add_defaults(config.get("*", "unbound"));
--- a/net/websocket.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/websocket.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,13 +8,13 @@
 
 local t_concat = table.concat;
 
-local http = require "net.http";
-local frames = require "net.websocket.frames";
-local base64 = require "util.encodings".base64;
-local sha1 = require "util.hashes".sha1;
-local random_bytes = require "util.random".bytes;
-local timer = require "util.timer";
-local log = require "util.logger".init "websocket";
+local http = require "prosody.net.http";
+local frames = require "prosody.net.websocket.frames";
+local base64 = require "prosody.util.encodings".base64;
+local sha1 = require "prosody.util.hashes".sha1;
+local random_bytes = require "prosody.util.random".bytes;
+local timer = require "prosody.util.timer";
+local log = require "prosody.util.logger".init "websocket";
 
 local close_timeout = 3; -- Seconds to wait after sending close frame until closing connection.
 
--- a/net/websocket/frames.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/net/websocket/frames.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,17 +6,17 @@
 -- COPYING file in the source package for more information.
 --
 
-local random_bytes = require "util.random".bytes;
+local random_bytes = require "prosody.util.random".bytes;
 
-local bit = require "util.bitcompat";
+local bit = require "prosody.util.bitcompat";
 local band = bit.band;
 local bor = bit.bor;
-local sbit = require "util.strbitop";
+local sbit = require "prosody.util.strbitop";
 local sxor = sbit.sxor;
 
 local s_char = string.char;
-local s_pack = require"util.struct".pack;
-local s_unpack = require"util.struct".unpack;
+local s_pack = require"prosody.util.struct".pack;
+local s_unpack = require"prosody.util.struct".unpack;
 
 local function pack_uint16be(x)
 	return s_pack(">I2", x);
@@ -77,7 +77,6 @@
 end
 
 -- XORs the string `str` with the array of bytes `key`
--- TODO: optimize
 local function apply_mask(str, key, from, to)
 	return sxor(str:sub(from or 1, to or -1), key);
 end
--- a/plugins/adhoc/adhoc.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/adhoc/adhoc.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -4,7 +4,7 @@
 -- COPYING file in the source package for more information.
 --
 
-local st, uuid = require "util.stanza", require "util.uuid";
+local st, uuid = require "prosody.util.stanza", require "prosody.util.uuid";
 
 local xmlns_cmd = "http://jabber.org/protocol/commands";
 
@@ -23,9 +23,15 @@
 function _M.new(name, node, handler, permission)
 	if not permission then
 		error "adhoc.new() expects a permission argument, none given"
+	elseif permission == "user" then
+		error "the permission mode 'user' has been renamed 'any', please update your code"
 	end
-	if permission == "user" then
-		error "the permission mode 'user' has been renamed 'any', please update your code"
+	if permission == "admin" then
+		module:default_permission("prosody:admin", "adhoc:"..node);
+		permission = "check";
+	elseif permission == "global_admin" then
+		module:default_permission("prosody:operator", "adhoc:"..node);
+		permission = "check";
 	end
 	return { name = name, node = node, handler = handler, cmdtag = _cmdtag, permission = permission };
 end
@@ -34,6 +40,8 @@
 	local cmdtag = stanza.tags[1]
 	local sessionid = cmdtag.attr.sessionid or uuid.generate();
 	local dataIn = {
+		origin = origin;
+		stanza = stanza;
 		to = stanza.attr.to;
 		from = stanza.attr.from;
 		action = cmdtag.attr.action or "execute";
--- a/plugins/adhoc/mod_adhoc.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/adhoc/mod_adhoc.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -5,28 +5,26 @@
 -- COPYING file in the source package for more information.
 --
 
-local it = require "util.iterators";
-local st = require "util.stanza";
-local is_admin = require "core.usermanager".is_admin;
-local jid_host = require "util.jid".host;
+local it = require "prosody.util.iterators";
+local st = require "prosody.util.stanza";
+local jid_host = require "prosody.util.jid".host;
 local adhoc_handle_cmd = module:require "adhoc".handle_cmd;
 local xmlns_cmd = "http://jabber.org/protocol/commands";
 local commands = {};
 
 module:add_feature(xmlns_cmd);
 
+local function check_permissions(event, node, command, execute)
+	return (command.permission == "check" and module:may("adhoc:"..node, event, not execute))
+	    or (command.permission == "local_user" and jid_host(event.stanza.attr.from) == module.host)
+	    or (command.permission == "any");
+end
+
 module:hook("host-disco-info-node", function (event)
 	local stanza, origin, reply, node = event.stanza, event.origin, event.reply, event.node;
 	if commands[node] then
-		local from = stanza.attr.from;
-		local privileged = is_admin(from, stanza.attr.to);
-		local global_admin = is_admin(from);
-		local hostname = jid_host(from);
 		local command = commands[node];
-		if (command.permission == "admin" and privileged)
-		    or (command.permission == "global_admin" and global_admin)
-		    or (command.permission == "local_user" and hostname == module.host)
-		    or (command.permission == "any") then
+		if check_permissions(event, node, command) then
 			reply:tag("identity", { name = command.name,
 			    category = "automation", type = "command-node" }):up();
 			reply:tag("feature", { var = xmlns_cmd }):up();
@@ -44,20 +42,13 @@
 end);
 
 module:hook("host-disco-items-node", function (event)
-	local stanza, reply, disco_node = event.stanza, event.reply, event.node;
+	local reply, disco_node = event.reply, event.node;
 	if disco_node ~= xmlns_cmd then
 		return;
 	end
 
-	local from = stanza.attr.from;
-	local admin = is_admin(from, stanza.attr.to);
-	local global_admin = is_admin(from);
-	local hostname = jid_host(from);
 	for node, command in it.sorted_pairs(commands) do
-		if (command.permission == "admin" and admin)
-		    or (command.permission == "global_admin" and global_admin)
-		    or (command.permission == "local_user" and hostname == module.host)
-		    or (command.permission == "any") then
+		if check_permissions(event, node, command) then
 			reply:tag("item", { name = command.name,
 			    node = node, jid = module:get_host() });
 			reply:up();
@@ -71,20 +62,14 @@
 	local node = stanza.tags[1].attr.node
 	local command = commands[node];
 	if command then
-		local from = stanza.attr.from;
-		local admin = is_admin(from, stanza.attr.to);
-		local global_admin = is_admin(from);
-		local hostname = jid_host(from);
-		if (command.permission == "admin" and not admin)
-		    or (command.permission == "global_admin" and not global_admin)
-		    or (command.permission == "local_user" and hostname ~= module.host) then
+		if not check_permissions(event, node, command, true) then
 			origin.send(st.error_reply(stanza, "auth", "forbidden", "You don't have permission to execute this command"):up()
-			    :add_child(commands[node]:cmdtag("canceled")
+				:add_child(command:cmdtag("canceled")
 				:tag("note", {type="error"}):text("You don't have permission to execute this command")));
 			return true
 		end
 		-- User has permission now execute the command
-		adhoc_handle_cmd(commands[node], origin, stanza);
+		adhoc_handle_cmd(command, origin, stanza);
 		return true;
 	end
 end, 500);
--- a/plugins/mod_admin_adhoc.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_admin_adhoc.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -14,23 +14,25 @@
 
 local module_host = module:get_host();
 
-local keys = require "util.iterators".keys;
-local usermanager_user_exists = require "core.usermanager".user_exists;
-local usermanager_create_user = require "core.usermanager".create_user;
-local usermanager_delete_user = require "core.usermanager".delete_user;
-local usermanager_set_password = require "core.usermanager".set_password;
-local hostmanager_activate = require "core.hostmanager".activate;
-local hostmanager_deactivate = require "core.hostmanager".deactivate;
-local rm_load_roster = require "core.rostermanager".load_roster;
-local st, jid = require "util.stanza", require "util.jid";
-local timer_add_task = require "util.timer".add_task;
-local dataforms_new = require "util.dataforms".new;
-local array = require "util.array";
-local modulemanager = require "core.modulemanager";
+local keys = require "prosody.util.iterators".keys;
+local usermanager_user_exists = require "prosody.core.usermanager".user_exists;
+local usermanager_create_user = require "prosody.core.usermanager".create_user;
+local usermanager_delete_user = require "prosody.core.usermanager".delete_user;
+local usermanager_disable_user = require "prosody.core.usermanager".disable_user;
+local usermanager_enable_user = require "prosody.core.usermanager".enable_user;
+local usermanager_set_password = require "prosody.core.usermanager".set_password;
+local hostmanager_activate = require "prosody.core.hostmanager".activate;
+local hostmanager_deactivate = require "prosody.core.hostmanager".deactivate;
+local rm_load_roster = require "prosody.core.rostermanager".load_roster;
+local st, jid = require "prosody.util.stanza", require "prosody.util.jid";
+local timer_add_task = require "prosody.util.timer".add_task;
+local dataforms_new = require "prosody.util.dataforms".new;
+local array = require "prosody.util.array";
+local modulemanager = require "prosody.core.modulemanager";
 local core_post_stanza = prosody.core_post_stanza;
-local adhoc_simple = require "util.adhoc".new_simple_form;
-local adhoc_initial = require "util.adhoc".new_initial_data_form;
-local set = require"util.set";
+local adhoc_simple = require "prosody.util.adhoc".new_simple_form;
+local adhoc_initial = require "prosody.util.adhoc".new_initial_data_form;
+local set = require"prosody.util.set";
 
 module:depends("adhoc");
 local adhoc_new = module:require "adhoc".new;
@@ -152,6 +154,66 @@
 			"The following accounts could not be deleted:\n"..t_concat(failed, "\n") or "") };
 end);
 
+local disable_user_layout = dataforms_new{
+	title = "Disabling a User";
+	instructions = "Fill out this form to disable a user.";
+
+	{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" };
+	{ name = "accountjids", type = "jid-multi", required = true, label = "The Jabber ID(s) to disable" };
+};
+
+local disable_user_command_handler = adhoc_simple(disable_user_layout, function(fields, err, data)
+	if err then
+		return generate_error_message(err);
+	end
+	local failed = {};
+	local succeeded = {};
+	for _, aJID in ipairs(fields.accountjids) do
+		local username, host = jid.split(aJID);
+		if (host == module_host) and  usermanager_user_exists(username, host) and usermanager_disable_user(username, host) then
+			module:log("info", "User %s has been disabled by %s", aJID, jid.bare(data.from));
+			succeeded[#succeeded+1] = aJID;
+		else
+			module:log("debug", "Tried to disable non-existent user %s", aJID);
+			failed[#failed+1] = aJID;
+		end
+	end
+	return {status = "completed", info = (#succeeded ~= 0 and
+			"The following accounts were successfully disabled:\n"..t_concat(succeeded, "\n").."\n" or "")..
+			(#failed ~= 0 and
+			"The following accounts could not be disabled:\n"..t_concat(failed, "\n") or "") };
+end);
+
+local enable_user_layout = dataforms_new{
+	title = "Re-Enable a User";
+	instructions = "Fill out this form to enable a user.";
+
+	{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" };
+	{ name = "accountjids", type = "jid-multi", required = true, label = "The Jabber ID(s) to re-enable" };
+};
+
+local enable_user_command_handler = adhoc_simple(enable_user_layout, function(fields, err, data)
+	if err then
+		return generate_error_message(err);
+	end
+	local failed = {};
+	local succeeded = {};
+	for _, aJID in ipairs(fields.accountjids) do
+		local username, host = jid.split(aJID);
+		if (host == module_host) and  usermanager_user_exists(username, host) and usermanager_enable_user(username, host) then
+			module:log("info", "User %s has been enabled by %s", aJID, jid.bare(data.from));
+			succeeded[#succeeded+1] = aJID;
+		else
+			module:log("debug", "Tried to enable non-existent user %s", aJID);
+			failed[#failed+1] = aJID;
+		end
+	end
+	return {status = "completed", info = (#succeeded ~= 0 and
+			"The following accounts were successfully enabled:\n"..t_concat(succeeded, "\n").."\n" or "")..
+			(#failed ~= 0 and
+			"The following accounts could not be enabled:\n"..t_concat(failed, "\n") or "") };
+end);
+
 -- Ending a user's session
 local function disconnect_user(match_jid)
 	local node, hostname, givenResource = jid.split(match_jid);
@@ -804,6 +866,8 @@
 local change_user_password_desc = adhoc_new("Change User Password", "http://jabber.org/protocol/admin#change-user-password", change_user_password_command_handler, "admin");
 local config_reload_desc = adhoc_new("Reload configuration", "http://prosody.im/protocol/config#reload", config_reload_handler, "global_admin");
 local delete_user_desc = adhoc_new("Delete User", "http://jabber.org/protocol/admin#delete-user", delete_user_command_handler, "admin");
+local disable_user_desc = adhoc_new("Disable User", "http://jabber.org/protocol/admin#disable-user", disable_user_command_handler, "admin");
+local enable_user_desc = adhoc_new("Re-Enable User", "http://jabber.org/protocol/admin#reenable-user", enable_user_command_handler, "admin");
 local end_user_session_desc = adhoc_new("End User Session", "http://jabber.org/protocol/admin#end-user-session", end_user_session_handler, "admin");
 local get_user_roster_desc = adhoc_new("Get User Roster","http://jabber.org/protocol/admin#get-user-roster", get_user_roster_handler, "admin");
 local get_user_stats_desc = adhoc_new("Get User Statistics","http://jabber.org/protocol/admin#user-stats", get_user_stats_handler, "admin");
@@ -824,6 +888,8 @@
 module:provides("adhoc", change_user_password_desc);
 module:provides("adhoc", config_reload_desc);
 module:provides("adhoc", delete_user_desc);
+module:provides("adhoc", disable_user_desc);
+module:provides("adhoc", enable_user_desc);
 module:provides("adhoc", end_user_session_desc);
 module:provides("adhoc", get_user_roster_desc);
 module:provides("adhoc", get_user_stats_desc);
--- a/plugins/mod_admin_shell.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_admin_shell.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -10,38 +10,41 @@
 module:set_global();
 module:depends("admin_socket");
 
-local hostmanager = require "core.hostmanager";
-local modulemanager = require "core.modulemanager";
-local s2smanager = require "core.s2smanager";
-local portmanager = require "core.portmanager";
-local helpers = require "util.helpers";
-local server = require "net.server";
-local st = require "util.stanza";
+local hostmanager = require "prosody.core.hostmanager";
+local modulemanager = require "prosody.core.modulemanager";
+local s2smanager = require "prosody.core.s2smanager";
+local portmanager = require "prosody.core.portmanager";
+local helpers = require "prosody.util.helpers";
+local it = require "prosody.util.iterators";
+local server = require "prosody.net.server";
+local schema = require "prosody.util.jsonschema";
+local st = require "prosody.util.stanza";
 
 local _G = _G;
 
 local prosody = _G.prosody;
 
-local unpack = table.unpack or unpack; -- luacheck: ignore 113
-local iterators = require "util.iterators";
+local unpack = table.unpack;
+local iterators = require "prosody.util.iterators";
 local keys, values = iterators.keys, iterators.values;
-local jid_bare, jid_split, jid_join = import("util.jid", "bare", "prepped_split", "join");
-local set, array = require "util.set", require "util.array";
-local cert_verify_identity = require "util.x509".verify_identity;
-local envload = require "util.envload".envload;
-local envloadfile = require "util.envload".envloadfile;
-local has_pposix, pposix = pcall(require, "util.pposix");
-local async = require "util.async";
-local serialization = require "util.serialization";
+local jid_bare, jid_split, jid_join, jid_resource, jid_compare = import("prosody.util.jid", "bare", "prepped_split", "join", "resource", "compare");
+local set, array = require "prosody.util.set", require "prosody.util.array";
+local cert_verify_identity = require "prosody.util.x509".verify_identity;
+local envload = require "prosody.util.envload".envload;
+local envloadfile = require "prosody.util.envload".envloadfile;
+local has_pposix, pposix = pcall(require, "prosody.util.pposix");
+local async = require "prosody.util.async";
+local serialization = require "prosody.util.serialization";
 local serialize_config = serialization.new ({ fatal = false, unquoted = true});
-local time = require "util.time";
-local promise = require "util.promise";
+local time = require "prosody.util.time";
+local promise = require "prosody.util.promise";
+local logger = require "prosody.util.logger";
 
 local t_insert = table.insert;
 local t_concat = table.concat;
 
-local format_number = require "util.human.units".format;
-local format_table = require "util.human.io".table;
+local format_number = require "prosody.util.human.units".format;
+local format_table = require "prosody.util.human.io".table;
 
 local function capitalize(s)
 	if not s then return end
@@ -62,6 +65,86 @@
 local def_env = module:shared("env");
 local default_env_mt = { __index = def_env };
 
+local function new_section(section_desc)
+	return setmetatable({}, {
+		help = {
+			desc = section_desc;
+			commands = {};
+		};
+	});
+end
+
+local help_topics = {};
+local function help_topic(name)
+	return function (desc)
+		return function (content)
+			help_topics[name] = {
+				desc = desc;
+				content = content;
+			};
+		end;
+	end
+end
+
+-- Seed with default sections and their description text
+help_topic "console" "Help regarding the console itself" [[
+Hey! Welcome to Prosody's admin console.
+First thing, if you're ever wondering how to get out, simply type 'quit'.
+Secondly, note that we don't support the full telnet protocol yet (it's coming)
+so you may have trouble using the arrow keys, etc. depending on your system.
+
+For now we offer a couple of handy shortcuts:
+!! - Repeat the last command
+!old!new! - repeat the last command, but with 'old' replaced by 'new'
+
+For those well-versed in Prosody's internals, or taking instruction from those who are,
+you can prefix a command with > to escape the console sandbox, and access everything in
+the running server. Great fun, but be careful not to break anything :)
+]];
+
+local available_columns; --forward declaration so it is reachable from the help
+
+help_topic "columns" "Information about customizing session listings" (function (self, print)
+	print [[The columns shown by c2s:show() and s2s:show() can be customizied via the]]
+	print [['columns' argument as described here.]]
+	print [[]]
+	print [[Columns can be specified either as "id jid ipv" or as {"id", "jid", "ipv"}.]]
+	print [[Available columns are:]]
+	local meta_columns = {
+		{ title = "ID"; width = 5 };
+		{ title = "Column Title"; width = 12 };
+		{ title = "Description"; width = 12 };
+	};
+	-- auto-adjust widths
+	for column, spec in pairs(available_columns) do
+		meta_columns[1].width = math.max(meta_columns[1].width or 0, #column);
+		meta_columns[2].width = math.max(meta_columns[2].width or 0, #(spec.title or ""));
+		meta_columns[3].width = math.max(meta_columns[3].width or 0, #(spec.description or ""));
+	end
+	local row = format_table(meta_columns, self.session.width)
+	print(row());
+	for column, spec in iterators.sorted_pairs(available_columns) do
+		print(row({ column, spec.title, spec.description }));
+	end
+	print [[]]
+	print [[Most fields on the internal session structures can also be used as columns]]
+	-- Also, you can pass a table column specification directly, with mapper callback and all
+end);
+
+help_topic "roles"   "Show information about user roles" [[
+Roles may grant access or restrict users from certain operations.
+
+Built-in roles are:
+  prosody:guest      - Guest/anonymous user
+  prosody:registered - Registered user
+  prosody:member     - Provisioned user
+  prosody:admin      - Host administrator
+  prosody:operator - Server administrator
+
+Roles can be assigned using the user management commands (see 'help user').
+]];
+
+
 local function redirect_output(target, session)
 	local env = setmetatable({ print = session.print }, { __index = function (_, k) return rawget(target, k); end });
 	env.dofile = function(name)
@@ -83,8 +166,8 @@
 	self.data.print("Error: "..tostring(err));
 end
 
-local function send_repl_output(session, line)
-	return session.send(st.stanza("repl-output"):text(tostring(line)));
+local function send_repl_output(session, line, attr)
+	return session.send(st.stanza("repl-output", attr):text(tostring(line)));
 end
 
 function console:new_session(admin_session)
@@ -99,8 +182,14 @@
 			end
 			return send_repl_output(admin_session, table.concat(t, "\t"));
 		end;
+		write = function (t)
+			return send_repl_output(admin_session, t, { eol = "0" });
+		end;
 		serialize = tostring;
 		disconnect = function () admin_session:close(); end;
+		is_connected = function ()
+			return not not admin_session.conn;
+		end
 	};
 	session.env = setmetatable({}, default_env_mt);
 
@@ -126,6 +215,11 @@
 		session = console:new_session(event.origin);
 		event.origin.shell_session = session;
 	end
+
+	local default_width = 132; -- The common default of 80 is a bit too narrow for e.g. s2s:show(), 132 was another common width for hardware terminals
+	local margin = 2; -- To account for '| ' when lines are printed
+	session.width = (tonumber(event.stanza.attr.width) or default_width)-margin;
+
 	local line = event.stanza:get_text();
 	local useglobalenv;
 
@@ -135,7 +229,7 @@
 		line = line:gsub("^>", "");
 		useglobalenv = true;
 	else
-		local command = line:match("^%w+") or line:match("%p");
+		local command = line:match("^(%w+) ") or line:match("^%w+$") or line:match("%p");
 		if commands[command] then
 			commands[command](session, line);
 			event.origin.send(result);
@@ -201,148 +295,50 @@
 	return true;
 end);
 
+local function describe_command(s)
+	local section, name, args, desc = s:match("^([%w_]+):([%w_]+)%(([^)]*)%) %- (.+)$");
+	if not section then
+		error("Failed to parse command description: "..s);
+	end
+	local command_help = getmetatable(def_env[section]).help.commands;
+	command_help[name] = {
+		desc = desc;
+		args = array.collect(args:gmatch("[%w_]+")):map(function (arg_name)
+			return { name = arg_name };
+		end);
+	};
+end
+
 -- Console commands --
 -- These are simple commands, not valid standalone in Lua
 
-local available_columns; --forward declaration so it is reachable from the help
-
+-- Help about individual topics is handled by def_env.help
 function commands.help(session, data)
 	local print = session.print;
-	local section = data:match("^help (%w+)");
-	if not section then
-		print [[Commands are divided into multiple sections. For help on a particular section, ]]
-		print [[type: help SECTION (for example, 'help c2s'). Sections are: ]]
-		print [[]]
-		local row = format_table({ { title = "Section"; width = 7 }; { title = "Description"; width = "100%" } })
-		print(row())
-		print(row { "c2s"; "Commands to manage local client-to-server sessions" })
-		print(row { "s2s"; "Commands to manage sessions between this server and others" })
-		print(row { "http"; "Commands to inspect HTTP services" }) -- XXX plural but there is only one so far
-		print(row { "module"; "Commands to load/reload/unload modules/plugins" })
-		print(row { "host"; "Commands to activate, deactivate and list virtual hosts" })
-		print(row { "user"; "Commands to create and delete users, and change their passwords" })
-		print(row { "roles"; "Show information about user roles" })
-		print(row { "muc"; "Commands to create, list and manage chat rooms" })
-		print(row { "stats"; "Commands to show internal statistics" })
-		print(row { "server"; "Uptime, version, shutting down, etc." })
-		print(row { "port"; "Commands to manage ports the server is listening on" })
-		print(row { "dns"; "Commands to manage and inspect the internal DNS resolver" })
-		print(row { "xmpp"; "Commands for sending XMPP stanzas" })
-		print(row { "debug"; "Commands for debugging the server" })
-		print(row { "config"; "Reloading the configuration, etc." })
-		print(row { "columns"; "Information about customizing session listings" })
-		print(row { "console"; "Help regarding the console itself" })
-	elseif section == "c2s" then
-		print [[c2s:show(jid, columns) - Show all client sessions with the specified JID (or all if no JID given)]]
-		print [[c2s:show_tls(jid) - Show TLS cipher info for encrypted sessions]]
-		print [[c2s:count() - Count sessions without listing them]]
-		print [[c2s:close(jid) - Close all sessions for the specified JID]]
-		print [[c2s:closeall() - Close all active c2s connections ]]
-	elseif section == "s2s" then
-		print [[s2s:show(domain, columns) - Show all s2s connections for the given domain (or all if no domain given)]]
-		print [[s2s:show_tls(domain) - Show TLS cipher info for encrypted sessions]]
-		print [[s2s:close(from, to) - Close a connection from one domain to another]]
-		print [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]]
-	elseif section == "http" then
-		print [[http:list(hosts) - Show HTTP endpoints]]
-	elseif section == "module" then
-		print [[module:info(module, host) - Show information about a loaded module]]
-		print [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]]
-		print [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]]
-		print [[module:unload(module, host) - The same, but just unloads the module from memory]]
-		print [[module:list(host) - List the modules loaded on the specified host]]
-	elseif section == "host" then
-		print [[host:activate(hostname) - Activates the specified host]]
-		print [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]]
-		print [[host:list() - List the currently-activated hosts]]
-	elseif section == "user" then
-		print [[user:create(jid, password, roles) - Create the specified user account]]
-		print [[user:password(jid, password) - Set the password for the specified user account]]
-		print [[user:roles(jid, host) - Show current roles for an user]]
-		print [[user:setroles(jid, host, roles) - Set roles for an user (see 'help roles')]]
-		print [[user:delete(jid) - Permanently remove the specified user account]]
-		print [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]]
-	elseif section == "roles" then
-		print [[Roles may grant access or restrict users from certain operations]]
-		print [[Built-in roles are:]]
-		print [[  prosody:admin - Administrator]]
-		print [[  (empty set) - Normal user]]
-		print [[]]
-		print [[The canonical role format looks like: { ["example:role"] = true }]]
-		print [[For convenience, the following formats are also accepted:]]
-		print [["admin" - short for "prosody:admin", the normal admin status (like the admins config option)]]
-		print [["example:role" - short for {["example:role"]=true}]]
-		print [[{"example:role"} - short for {["example:role"]=true}]]
-	elseif section == "muc" then
-		-- TODO `muc:room():foo()` commands
-		print [[muc:create(roomjid, { config }) - Create the specified MUC room with the given config]]
-		print [[muc:list(host) - List rooms on the specified MUC component]]
-		print [[muc:room(roomjid) - Reference the specified MUC room to access MUC API methods]]
-	elseif section == "server" then
-		print [[server:version() - Show the server's version number]]
-		print [[server:uptime() - Show how long the server has been running]]
-		print [[server:memory() - Show details about the server's memory usage]]
-		print [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]]
-	elseif section == "port" then
-		print [[port:list() - Lists all network ports prosody currently listens on]]
-		print [[port:close(port, interface) - Close a port]]
-	elseif section == "dns" then
-		print [[dns:lookup(name, type, class) - Do a DNS lookup]]
-		print [[dns:addnameserver(nameserver) - Add a nameserver to the list]]
-		print [[dns:setnameserver(nameserver) - Replace the list of name servers with the supplied one]]
-		print [[dns:purge() - Clear the DNS cache]]
-		print [[dns:cache() - Show cached records]]
-	elseif section == "xmpp" then
-		print [[xmpp:ping(localhost, remotehost) -- Sends a ping to a remote XMPP server and reports the response]]
-	elseif section == "config" then
-		print [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]]
-		print [[config:get([host,] option) - Show the value of a config option.]]
-	elseif section == "stats" then -- luacheck: ignore 542
-		print [[stats:show(pattern) - Show internal statistics, optionally filtering by name with a pattern]]
-		print [[stats:show():cfgraph() - Show a cumulative frequency graph]]
-		print [[stats:show():histogram() - Show a histogram of selected metric]]
-	elseif section == "debug" then
-		print [[debug:logevents(host) - Enable logging of fired events on host]]
-		print [[debug:events(host, event) - Show registered event handlers]]
-		print [[debug:timers() - Show information about scheduled timers]]
-	elseif section == "console" then
-		print [[Hey! Welcome to Prosody's admin console.]]
-		print [[First thing, if you're ever wondering how to get out, simply type 'quit'.]]
-		print [[Secondly, note that we don't support the full telnet protocol yet (it's coming)]]
-		print [[so you may have trouble using the arrow keys, etc. depending on your system.]]
-		print [[]]
-		print [[For now we offer a couple of handy shortcuts:]]
-		print [[!! - Repeat the last command]]
-		print [[!old!new! - repeat the last command, but with 'old' replaced by 'new']]
-		print [[]]
-		print [[For those well-versed in Prosody's internals, or taking instruction from those who are,]]
-		print [[you can prefix a command with > to escape the console sandbox, and access everything in]]
-		print [[the running server. Great fun, but be careful not to break anything :)]]
-	elseif section == "columns" then
-		print [[The columns shown by c2s:show() and s2s:show() can be customizied via the]]
-		print [['columns' argument as described here.]]
-		print [[]]
-		print [[Columns can be specified either as "id jid ipv" or as {"id", "jid", "ipv"}.]]
-		print [[Available columns are:]]
-		local meta_columns = {
-			{ title = "ID"; width = 5 };
-			{ title = "Column Title"; width = 12 };
-			{ title = "Description"; width = 12 };
-		};
-		-- auto-adjust widths
-		for column, spec in pairs(available_columns) do
-			meta_columns[1].width = math.max(meta_columns[1].width or 0, #column);
-			meta_columns[2].width = math.max(meta_columns[2].width or 0, #(spec.title or ""));
-			meta_columns[3].width = math.max(meta_columns[3].width or 0, #(spec.description or ""));
-		end
-		local row = format_table(meta_columns, 120)
-		print(row());
-		for column, spec in iterators.sorted_pairs(available_columns) do
-			print(row({ column, spec.title, spec.description }));
-		end
-		print [[]]
-		print [[Most fields on the internal session structures can also be used as columns]]
-		-- Also, you can pass a table column specification directly, with mapper callback and all
+
+	local topic = data:match("^help (%w+)");
+	if topic then
+		return def_env.help[topic]({ session = session });
+	end
+
+	print [[Commands are divided into multiple sections. For help on a particular section, ]]
+	print [[type: help SECTION (for example, 'help c2s'). Sections are: ]]
+	print [[]]
+	local row = format_table({ { title = "Section", width = 7 }, { title = "Description", width = "100%" } }, session.width)
+	print(row())
+	for section_name, section in it.sorted_pairs(def_env) do
+		local section_mt = getmetatable(section);
+		local section_help = section_mt and section_mt.help;
+		print(row { section_name; section_help and section_help.desc or "" });
+	end
+
+	print("");
+
+	print [[In addition to info about commands, the following general topics are available:]]
+
+	print("");
+	for topic_name, topic_info in it.sorted_pairs(help_topics) do
+		print(topic_name .. " - "..topic_info.desc);
 	end
 end
 
@@ -350,10 +346,13 @@
 -- Anything in def_env will be accessible within the session as a global variable
 
 --luacheck: ignore 212/self
-local serialize_defaults = module:get_option("console_prettyprint_settings",
-	{ fatal = false; unquoted = true; maxdepth = 2; table_iterator = "pairs" })
+local serialize_defaults = module:get_option("console_prettyprint_settings", {
+	preset = "pretty";
+	maxdepth = 2;
+	table_iterator = "pairs";
+})
 
-def_env.output = {};
+def_env.output = new_section("Configure admin console output");
 function def_env.output:configure(opts)
 	if type(opts) ~= "table" then
 		opts = { preset = opts };
@@ -375,7 +374,57 @@
 	self.session.serialize = serialization.new(opts);
 end
 
-def_env.server = {};
+def_env.help = setmetatable({}, {
+	help = {
+		desc = "Show this help about available commands";
+		commands = {};
+	};
+	__index = function (_, section_name)
+		return function (self)
+			local print = self.session.print;
+			local section_mt = getmetatable(def_env[section_name]);
+			local section_help = section_mt and section_mt.help;
+
+			local c = 0;
+
+			if section_help then
+				print("Help: "..section_name);
+				if section_help.desc then
+					print(section_help.desc);
+				end
+				print(("-"):rep(#(section_help.desc or section_name)));
+				print("");
+
+				if section_help.content then
+					print(section_help.content);
+					print("");
+				end
+
+				for command, command_help in it.sorted_pairs(section_help.commands or {}) do
+					c = c + 1;
+					local args = command_help.args:pluck("name"):concat(", ");
+					local desc = command_help.desc or command_help.module and ("Provided by mod_"..command_help.module) or "";
+					print(("%s:%s(%s) - %s"):format(section_name, command, args, desc));
+				end
+			elseif help_topics[section_name] then
+				local topic = help_topics[section_name];
+				if type(topic.content) == "function" then
+					topic.content(self, print);
+				else
+					print(topic.content);
+				end
+				print("");
+				return true, "Showing help topic '"..section_name.."'";
+			else
+				print("Unknown topic: "..section_name);
+			end
+			print("");
+			return true, ("%d command(s) listed"):format(c);
+		end;
+	end;
+});
+
+def_env.server = new_section("Uptime, version, shutting down, etc.");
 
 function def_env.server:insane_reload()
 	prosody.unlock_globals();
@@ -384,10 +433,12 @@
 	return true, "Server reloaded";
 end
 
+describe_command [[server:version() - Show the server's version number]]
 function def_env.server:version()
 	return true, tostring(prosody.version or "unknown");
 end
 
+describe_command [[server:uptime() - Show how long the server has been running]]
 function def_env.server:uptime()
 	local t = os.time()-prosody.start_time;
 	local seconds = t%60;
@@ -402,6 +453,7 @@
 		minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time));
 end
 
+describe_command [[server:shutdown(reason) - Shut down the server, with an optional reason to be broadcast to all connections]]
 function def_env.server:shutdown(reason, code)
 	prosody.shutdown(reason, code);
 	return true, "Shutdown initiated";
@@ -411,6 +463,7 @@
 	return format_number(kb*1024, "B", "b");
 end
 
+describe_command [[server:memory() - Show details about the server's memory usage]]
 function def_env.server:memory()
 	if not has_pposix or not pposix.meminfo then
 		return true, "Lua is using "..human(collectgarbage("count"));
@@ -423,7 +476,7 @@
 	return true, "OK";
 end
 
-def_env.module = {};
+def_env.module = new_section("Commands to load/reload/unload modules/plugins");
 
 local function get_hosts_set(hosts)
 	if type(hosts) == "table" then
@@ -469,6 +522,7 @@
 	return hosts_set;
 end
 
+describe_command [[module:info(module, host) - Show information about a loaded module]]
 function def_env.module:info(name, hosts)
 	if not name then
 		return nil, "module name expected";
@@ -481,6 +535,16 @@
 
 	local function item_name(item) return item.name; end
 
+	local function task_timefmt(t)
+		if not t then
+			return "no last run time"
+		elseif os.difftime(os.time(), t) < 86400 then
+			return os.date("last run today at %H:%M", t);
+		else
+			return os.date("last run %A at %H:%M", t);
+		end
+	end
+
 	local friendly_descriptions = {
 		["adhoc-provider"] = "Ad-hoc commands",
 		["auth-provider"] = "Authentication provider",
@@ -498,12 +562,22 @@
 		["auth-provider"] = item_name,
 		["storage-provider"] = item_name,
 		["http-provider"] = function(item, mod) return mod:http_url(item.name, item.default_path); end,
-		["net-provider"] = item_name,
+		["net-provider"] = function(item)
+			local service_name = item.name;
+			local ports_list = {};
+			for _, interface, port in portmanager.get_active_services():iter(service_name, nil, nil) do
+				table.insert(ports_list, "["..interface.."]:"..port);
+			end
+			if not ports_list[1] then
+				return service_name..": not listening on any ports";
+			end
+			return service_name..": "..table.concat(ports_list, ", ");
+		end,
 		["measure"] = function(item) return item.name .. " (" .. suf(item.conf and item.conf.unit, " ") .. item.type .. ")"; end,
 		["metric"] = function(item)
 			return ("%s (%s%s)%s"):format(item.name, suf(item.mf.unit, " "), item.mf.type_, pre(": ", item.mf.description));
 		end,
-		["task"] = function (item) return string.format("%s (%s)", item.name or item.id, item.when); end
+		["task"] = function (item) return string.format("%s (%s, %s)", item.name or item.id, item.when, task_timefmt(item.last)); end
 	};
 
 	for host in hosts do
@@ -533,21 +607,37 @@
 		if mod.module.dependencies and next(mod.module.dependencies) ~= nil then
 			print("  dependencies:");
 			for dep in pairs(mod.module.dependencies) do
-				print("  - mod_" .. dep);
+				-- Dependencies are per module instance, not per host, so dependencies
+				-- of/on global modules may list modules not actually loaded on the
+				-- current host.
+				if modulemanager.is_loaded(host, dep) then
+					print("  - mod_" .. dep);
+				end
+			end
+		end
+		if mod.module.reverse_dependencies and next(mod.module.reverse_dependencies) ~= nil then
+			print("  reverse dependencies:");
+			for dep in pairs(mod.module.reverse_dependencies) do
+				if modulemanager.is_loaded(host, dep) then
+					print("  - mod_" .. dep);
+				end
 			end
 		end
 	end
 	return true;
 end
 
-function def_env.module:load(name, hosts, config)
+describe_command [[module:load(module, host) - Load the specified module on the specified host (or all hosts if none given)]]
+function def_env.module:load(name, hosts)
 	hosts = get_hosts_with_module(hosts);
 
 	-- Load the module for each host
 	local ok, err, count, mod = true, nil, 0;
 	for host in hosts do
+		local configured_modules, component = modulemanager.get_modules_for_host(host);
+
 		if (not modulemanager.is_loaded(host, name)) then
-			mod, err = modulemanager.load(host, name, config);
+			mod, err = modulemanager.load(host, name);
 			if not mod then
 				ok = false;
 				if err == "global-module-already-loaded" then
@@ -560,6 +650,10 @@
 			else
 				count = count + 1;
 				self.session.print("Loaded for "..mod.module.host);
+
+				if not (configured_modules:contains(name) or name == component) then
+					self.session.print("Note: Module will not be loaded after restart unless enabled in configuration");
+				end
 			end
 		end
 	end
@@ -567,12 +661,15 @@
 	return ok, (ok and "Module loaded onto "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
 end
 
+describe_command [[module:unload(module, host) - The same, but just unloads the module from memory]]
 function def_env.module:unload(name, hosts)
 	hosts = get_hosts_with_module(hosts, name);
 
 	-- Unload the module for each host
 	local ok, err, count = true, nil, 0;
 	for host in hosts do
+		local configured_modules, component = modulemanager.get_modules_for_host(host);
+
 		if modulemanager.is_loaded(host, name) then
 			ok, err = modulemanager.unload(host, name);
 			if not ok then
@@ -581,6 +678,10 @@
 			else
 				count = count + 1;
 				self.session.print("Unloaded from "..host);
+
+				if configured_modules:contains(name) or name == component then
+					self.session.print("Note: Module will be loaded after restart unless disabled in configuration");
+				end
 			end
 		end
 	end
@@ -593,6 +694,7 @@
 	else return a:gsub("[^.]+", string.reverse):reverse() < b:gsub("[^.]+", string.reverse):reverse(); end
 end
 
+describe_command [[module:reload(module, host) - The same, but unloads and loads the module (saving state if the module supports it)]]
 function def_env.module:reload(name, hosts)
 	hosts = array.collect(get_hosts_with_module(hosts, name)):sort(_sort_hosts)
 
@@ -616,6 +718,7 @@
 	return ok, (ok and "Module reloaded on "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
 end
 
+describe_command [[module:list(host) - List the modules loaded on the specified host]]
 function def_env.module:list(hosts)
 	hosts = array.collect(set.new({ not hosts and "*" or nil }) + get_hosts_set(hosts)):sort(_sort_hosts);
 
@@ -642,9 +745,10 @@
 	end
 end
 
-def_env.config = {};
+def_env.config = new_section("Reloading the configuration, etc.");
+
 function def_env.config:load(filename, format)
-	local config_load = require "core.configmanager".load;
+	local config_load = require "prosody.core.configmanager".load;
 	local ok, err = config_load(filename, format);
 	if not ok then
 		return false, err or "Unknown error loading config";
@@ -652,20 +756,30 @@
 	return true, "Config loaded";
 end
 
+describe_command [[config:get([host,] option) - Show the value of a config option.]]
 function def_env.config:get(host, key)
 	if key == nil then
 		host, key = "*", host;
 	end
-	local config_get = require "core.configmanager".get
+	local config_get = require "prosody.core.configmanager".get
 	return true, serialize_config(config_get(host, key));
 end
 
+describe_command [[config:set([host,] option, value) - Update the value of a config option without writing to the config file.]]
+function def_env.config:set(host, key, value)
+	if host ~= "*" and not prosody.hosts[host] then
+		host, key, value = "*", host, key;
+	end
+	return require "prosody.core.configmanager".set(host, key, value);
+end
+
+describe_command [[config:reload() - Reload the server configuration. Modules may need to be reloaded for changes to take effect.]]
 function def_env.config:reload()
 	local ok, err = prosody.reload_config();
 	return ok, (ok and "Config reloaded (you may need to reload modules to take effect)") or tostring(err);
 end
 
-def_env.c2s = {};
+def_env.c2s = new_section("Commands to manage local client-to-server sessions");
 
 local function get_jid(session)
 	if session.username then
@@ -702,6 +816,7 @@
 	end);
 end
 
+describe_command [[c2s:count() - Count sessions without listing them]]
 function def_env.c2s:count()
 	local c2s = get_c2s();
 	return true, "Total: "..  #c2s .." clients";
@@ -719,7 +834,7 @@
 	jid = {
 		title = "JID";
 		description = "Full JID of user session";
-		width = 32;
+		width = "3p";
 		key = "full_jid";
 		mapper = function(full_jid, session) return full_jid or get_jid(session) end;
 	};
@@ -727,7 +842,7 @@
 		title = "Host";
 		description = "Local hostname";
 		key = "host";
-		width = 22;
+		width = "1p";
 		mapper = function(host, session)
 			return host or get_s2s_hosts(session) or "?";
 		end;
@@ -735,7 +850,7 @@
 	remote = {
 		title = "Remote";
 		description = "Remote hostname";
-		width = 22;
+		width = "1p";
 		mapper = function(_, session)
 			return select(2, get_s2s_hosts(session));
 		end;
@@ -743,7 +858,7 @@
 	port = {
 		title = "Port";
 		description = "Server port used";
-		width = 5;
+		width = #string.format("%d", 0xffff); -- max 16 bit unsigned integer
 		align = "right";
 		key = "conn";
 		mapper = function(conn)
@@ -755,7 +870,7 @@
 	dir = {
 		title = "Dir";
 		description = "Direction of server-to-server connection";
-		width = 3;
+		width = #"<->";
 		key = "direction";
 		mapper = function(dir, session)
 			if session.incoming and session.outgoing then return "<->"; end
@@ -763,12 +878,23 @@
 			if dir == "incoming" then return "<--"; end
 		end;
 	};
-	id = { title = "Session ID"; description = "Internal session ID used in logging"; width = 20; key = "id" };
-	type = { title = "Type"; description = "Session type"; width = #"c2s_unauthed"; key = "type" };
+	id = {
+		title = "Session ID";
+		description = "Internal session ID used in logging";
+		-- Depends on log16(?) of pointers which may vary over runtime, so + some margin
+		width = math.max(#"c2s", #"s2sin", #"s2sout") + #(tostring({}):match("%x+$")) + 2;
+		key = "id";
+	};
+	type = {
+		title = "Type";
+		description = "Session type";
+		width = math.max(#"c2s_unauthed", #"s2sout_unauthed");
+		key = "type";
+	};
 	method = {
 		title = "Method";
 		description = "Connection method";
-		width = 10;
+		width = math.max(#"BOSH", #"WebSocket", #"TCP");
 		mapper = function(_, session)
 			if session.bosh_version then
 				return "BOSH";
@@ -782,15 +908,20 @@
 	ipv = {
 		title = "IPv";
 		description = "Internet Protocol version (4 or 6)";
-		width = 4;
+		width = #"IPvX";
 		key = "ip";
 		mapper = function(ip) if ip then return ip:find(":") and "IPv6" or "IPv4"; end end;
 	};
-	ip = { title = "IP address"; description = "IP address the session connected from"; width = 40; key = "ip" };
+	ip = {
+		title = "IP address";
+		description = "IP address the session connected from";
+		width = module:get_option_boolean("use_ipv6", true) and #"ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff" or #"198.051.100.255";
+		key = "ip";
+	};
 	status = {
 		title = "Status";
 		description = "Presence status";
-		width = 6;
+		width = math.max(#"online", #"chat");
 		key = "presence";
 		mapper = function(p)
 			if not p then return ""; end
@@ -801,24 +932,22 @@
 		title = "Security";
 		description = "TLS version or security status";
 		key = "conn";
-		width = 8;
+		width = math.max(#"secure", #"TLSvX.Y");
 		mapper = function(conn, session)
 			if not session.secure then return "insecure"; end
 			if not conn or not conn:ssl() then return "secure" end
-			local sock = conn and conn:socket();
-			if not sock then return "secure"; end
-			local tls_info = sock.info and sock:info();
+			local tls_info = conn.ssl_info and conn:ssl_info();
 			return tls_info and tls_info.protocol or "secure";
 		end;
 	};
 	encryption = {
 		title = "Encryption";
 		description = "Encryption algorithm used (TLS cipher suite)";
-		width = 30;
+		-- openssl ciphers 'ALL:COMPLEMENTOFALL' | tr : \\n | awk 'BEGIN {n=1} length() > n {n=length()} END {print(n)}'
+		width = #"ECDHE-ECDSA-CHACHA20-POLY1305";
 		key = "conn";
 		mapper = function(conn)
-			local sock = conn and conn:socket();
-			local info = sock and sock.info and sock:info();
+			local info = conn and conn.ssl_info and conn:ssl_info();
 			if info then return info.cipher end
 		end;
 	};
@@ -826,19 +955,27 @@
 		title = "Certificate";
 		description = "Validation status of certificate";
 		key = "cert_identity_status";
-		width = 11;
+		width = math.max(#"Expired", #"Self-signed", #"Untrusted", #"Mismatched", #"Unknown");
 		mapper = function(cert_status, session)
-			if cert_status then return capitalize(cert_status); end
-			if session.cert_chain_status == "invalid" then
+			if cert_status == "invalid" then
+				-- non-nil cert_identity_status implies valid chain, which covers just
+				-- about every error condition except mismatched certificate names
+				return "Mismatched";
+			elseif cert_status then
+				-- basically only "valid"
+				return capitalize(cert_status);
+			end
+			-- no certificate status,
+			if session.cert_chain_errors then
 				local cert_errors = set.new(session.cert_chain_errors[1]);
 				if cert_errors:contains("certificate has expired") then
 					return "Expired";
 				elseif cert_errors:contains("self signed certificate") then
 					return "Self-signed";
 				end
+				-- Some other cert issue, or something up the chain
+				-- TODO borrow more logic from mod_s2s/friendly_cert_error()
 				return "Untrusted";
-			elseif session.cert_identity_status == "invalid" then
-				return "Mismatched";
 			end
 			return "Unknown";
 		end;
@@ -846,7 +983,7 @@
 	sni = {
 		title = "SNI";
 		description = "Hostname requested in TLS";
-		width = 22;
+		width = "1p"; -- same as host, remote etc
 		mapper = function(_, session)
 			if not session.conn then return end
 			local sock = session.conn:socket();
@@ -856,7 +993,7 @@
 	alpn = {
 		title = "ALPN";
 		description = "Protocol requested in TLS";
-		width = 11;
+		width = math.max(#"http/1.1", #"xmpp-client", #"xmpp-server");
 		mapper = function(_, session)
 			if not session.conn then return end
 			local sock = session.conn:socket();
@@ -867,7 +1004,8 @@
 		title = "SM";
 		description = "Stream Management (XEP-0198) status";
 		key = "smacks";
-		width = 11;
+		-- FIXME shorter synonym for hibernating
+		width = math.max(#"yes", #"no", #"hibernating");
 		mapper = function(smacks_xmlns, session)
 			if not smacks_xmlns then return "no"; end
 			if session.hibernating then return "hibernating"; end
@@ -901,7 +1039,7 @@
 		title = "Dialback";
 		description = "Legacy server verification";
 		key = "dialback_key";
-		width = 13;
+		width = math.max(#"Not used", #"Not initiated", #"Initiated", #"Completed");
 		mapper = function (dialback_key, session)
 			if not dialback_key then
 				if session.type == "s2sin" or session.type == "s2sout" then
@@ -915,6 +1053,16 @@
 			end
 		end
 	};
+	role = {
+		title = "Role";
+		description = "Session role with 'prosody:' prefix removed";
+		width = "1p";
+		key = "role";
+		mapper = function(role)
+			local name = role and role.name;
+			return name and name:match"^prosody:(%w+)" or name;
+		end;
+	}
 };
 
 local function get_colspec(colspec, default)
@@ -922,7 +1070,7 @@
 	local columns = {};
 	for i, col in pairs(colspec or default) do
 		if type(col) == "string" then
-			columns[i] = available_columns[col] or { title = capitalize(col); width = 20; key = col };
+			columns[i] = available_columns[col] or { title = capitalize(col); width = "1p"; key = col };
 		elseif type(col) ~= "table" then
 			return false, ("argument %d: expected string|table but got %s"):format(i, type(col));
 		else
@@ -933,14 +1081,15 @@
 	return columns;
 end
 
+describe_command [[c2s:show(jid, columns) - Show all client sessions with the specified JID (or all if no JID given)]]
 function def_env.c2s:show(match_jid, colspec)
 	local print = self.session.print;
-	local columns = get_colspec(colspec, { "id"; "jid"; "ipv"; "status"; "secure"; "smacks"; "csi" });
-	local row = format_table(columns, 120);
+	local columns = get_colspec(colspec, { "id"; "jid"; "role"; "ipv"; "status"; "secure"; "smacks"; "csi" });
+	local row = format_table(columns, self.session.width);
 
 	local function match(session)
 		local jid = get_jid(session)
-		return (not match_jid) or jid == match_jid;
+		return (not match_jid) or match_jid == "*" or jid_compare(jid, match_jid);
 	end
 
 	local group_by_host = true;
@@ -973,6 +1122,7 @@
 	return true, ("%d c2s sessions shown"):format(total_count);
 end
 
+describe_command [[c2s:show_tls(jid) - Show TLS cipher info for encrypted sessions]]
 function def_env.c2s:show_tls(match_jid)
 	return self:show(match_jid, { "jid"; "id"; "secure"; "encryption" });
 end
@@ -986,6 +1136,7 @@
 	end
 end
 
+describe_command [[c2s:close(jid) - Close all sessions for the specified JID]]
 function def_env.c2s:close(match_jid, text, condition)
 	local count = 0;
 	show_c2s(function (jid, session)
@@ -997,6 +1148,7 @@
 	return true, "Total: "..count.." sessions closed";
 end
 
+describe_command [[c2s:closeall() - Close all active c2s connections ]]
 function def_env.c2s:closeall(text, condition)
 	local count = 0;
 	--luacheck: ignore 212/jid
@@ -1008,7 +1160,8 @@
 end
 
 
-def_env.s2s = {};
+def_env.s2s = new_section("Commands to manage sessions between this server and others");
+
 local function _sort_s2s(a, b)
 	local a_local, a_remote = get_s2s_hosts(a);
 	local b_local, b_remote = get_s2s_hosts(b);
@@ -1016,14 +1169,31 @@
 	return _sort_hosts(a_local or "", b_local or "");
 end
 
+local function match_wildcard(match_jid, jid)
+	-- host == host or (host) == *.(host) or sub(.host) == *(.host)
+	return jid == match_jid or jid == match_jid:sub(3) or jid:sub(-#match_jid + 1) == match_jid:sub(2);
+end
+
+local function match_s2s_jid(session, match_jid)
+	local host, remote = get_s2s_hosts(session);
+	if not match_jid or match_jid == "*" then
+		return true;
+	elseif host == match_jid or remote == match_jid then
+		return true;
+	elseif match_jid:sub(1, 2) == "*." then
+		return match_wildcard(match_jid, host) or match_wildcard(match_jid, remote);
+	end
+	return false;
+end
+
+describe_command [[s2s:show(domain, columns) - Show all s2s connections for the given domain (or all if no domain given)]]
 function def_env.s2s:show(match_jid, colspec)
 	local print = self.session.print;
 	local columns = get_colspec(colspec, { "id"; "host"; "dir"; "remote"; "ipv"; "secure"; "s2s_sasl"; "dialback" });
-	local row = format_table(columns, 132);
+	local row = format_table(columns, self.session.width);
 
 	local function match(session)
-		local host, remote = get_s2s_hosts(session);
-		return not match_jid or host == match_jid or remote == match_jid;
+		return match_s2s_jid(session, match_jid);
 	end
 
 	local group_by_host = true;
@@ -1057,6 +1227,7 @@
 	return true, ("%d s2s connections shown"):format(total_count);
 end
 
+describe_command [[s2s:show_tls(domain) - Show TLS cipher info for encrypted sessions]]
 function def_env.s2s:show_tls(match_jid)
 	return self:show(match_jid, { "id"; "host"; "dir"; "remote"; "secure"; "encryption"; "cert" });
 end
@@ -1090,7 +1261,7 @@
 	local print = self.session.print;
 	local s2s_sessions = module:shared"/*/s2s/sessions";
 	local domain_sessions = set.new(array.collect(values(s2s_sessions)))
-		/function(session) return (session.to_host == domain or session.from_host == domain) and session or nil; end;
+		/function(session) return match_s2s_jid(session, domain) and session or nil; end;
 	local cert_set = {};
 	for session in domain_sessions do
 		local conn = session.conn;
@@ -1179,6 +1350,7 @@
 		.." presented by "..domain..".");
 end
 
+describe_command [[s2s:close(from, to) - Close a connection from one domain to another]]
 function def_env.s2s:close(from, to, text, condition)
 	local print, count = self.session.print, 0;
 	local s2s_sessions = module:shared"/*/s2s/sessions";
@@ -1193,22 +1365,22 @@
 	end
 
 	for _, session in pairs(s2s_sessions) do
-		local id = session.id or (session.type..tostring(session):match("[a-f0-9]+$"));
-		if (match_id and match_id == id)
-		or (session.from_host == from and session.to_host == to) then
+		local id = session.id or (session.type .. tostring(session):match("[a-f0-9]+$"));
+		if (match_id and match_id == id) or ((from and match_wildcard(from, session.to_host)) or (to and match_wildcard(to, session.to_host))) then
 			print(("Closing connection from %s to %s [%s]"):format(session.from_host, session.to_host, id));
 			(session.close or s2smanager.destroy_session)(session, build_reason(text, condition));
-			count = count + 1 ;
+			count = count + 1;
 		end
 	end
 	return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s");
 end
 
+describe_command [[s2s:closeall(host) - Close all the incoming/outgoing s2s sessions to specified host]]
 function def_env.s2s:closeall(host, text, condition)
 	local count = 0;
 	local s2s_sessions = module:shared"/*/s2s/sessions";
 	for _,session in pairs(s2s_sessions) do
-		if not host or session.from_host == host or session.to_host == host then
+		if not host or host == "*" or match_s2s_jid(session, host) then
 			session:close(build_reason(text, condition));
 			count = count + 1;
 		end
@@ -1217,37 +1389,42 @@
 	else return true, "Closed "..count.." s2s session"..((count == 1 and "") or "s"); end
 end
 
-def_env.host = {}; def_env.hosts = def_env.host;
+def_env.host = new_section("Commands to activate, deactivate and list virtual hosts");
 
+describe_command [[host:activate(hostname) - Activates the specified host]]
 function def_env.host:activate(hostname, config)
 	return hostmanager.activate(hostname, config);
 end
+
+describe_command [[host:deactivate(hostname) - Disconnects all clients on this host and deactivates]]
 function def_env.host:deactivate(hostname, reason)
 	return hostmanager.deactivate(hostname, reason);
 end
 
+describe_command [[host:list() - List the currently-activated hosts]]
 function def_env.host:list()
 	local print = self.session.print;
 	local i = 0;
-	local type;
+	local host_type;
 	for host, host_session in iterators.sorted_pairs(prosody.hosts, _sort_hosts) do
 		i = i + 1;
-		type = host_session.type;
-		if type == "local" then
+		host_type = host_session.type;
+		if host_type == "local" then
 			print(host);
 		else
-			type = module:context(host):get_option_string("component_module", type);
-			if type ~= "component" then
-				type = type .. " component";
+			host_type = module:context(host):get_option_string("component_module", host_type);
+			if host_type ~= "component" then
+				host_type = host_type .. " component";
 			end
-			print(("%s (%s)"):format(host, type));
+			print(("%s (%s)"):format(host, host_type));
 		end
 	end
 	return true, i.." hosts";
 end
 
-def_env.port = {};
+def_env.port = new_section("Commands to manage ports the server is listening on");
 
+describe_command [[port:list() - Lists all network ports prosody currently listens on]]
 function def_env.port:list()
 	local print = self.session.print;
 	local services = portmanager.get_active_services().data;
@@ -1266,6 +1443,7 @@
 	return true, n_services.." services listening on "..n_ports.." ports";
 end
 
+describe_command [[port:close(port, interface) - Close a port]]
 function def_env.port:close(close_port, close_interface)
 	close_port = assert(tonumber(close_port), "Invalid port number");
 	local n_closed = 0;
@@ -1288,7 +1466,7 @@
 	return true, "Closed "..n_closed.." ports";
 end
 
-def_env.muc = {};
+def_env.muc = new_section("Commands to create, list and manage chat rooms");
 
 local console_room_mt = {
 	__index = function (self, k) return self.room[k]; end;
@@ -1307,6 +1485,21 @@
 	return room_name, host;
 end
 
+local function get_muc(room_jid)
+	local room_name, host = check_muc(room_jid);
+	if not room_name then
+		return room_name, host;
+	end
+	local room_obj = prosody.hosts[host].modules.muc.get_room_from_jid(room_jid);
+	if not room_obj then
+		return nil, "No such room: "..room_jid;
+	end
+	return room_obj;
+end
+
+local muc_util = module:require"muc/util";
+
+describe_command [[muc:create(roomjid, { config }) - Create the specified MUC room with the given config]]
 function def_env.muc:create(room_jid, config)
 	local room_name, host = check_muc(room_jid);
 	if not room_name then
@@ -1318,18 +1511,16 @@
 	return prosody.hosts[host].modules.muc.create_room(room_jid, config);
 end
 
+describe_command [[muc:room(roomjid) - Reference the specified MUC room to access MUC API methods]]
 function def_env.muc:room(room_jid)
-	local room_name, host = check_muc(room_jid);
-	if not room_name then
-		return room_name, host;
-	end
-	local room_obj = prosody.hosts[host].modules.muc.get_room_from_jid(room_jid);
+	local room_obj, err = get_muc(room_jid);
 	if not room_obj then
-		return nil, "No such room: "..room_jid;
+		return room_obj, err;
 	end
 	return setmetatable({ room = room_obj }, console_room_mt);
 end
 
+describe_command [[muc:list(host) - List rooms on the specified MUC component]]
 function def_env.muc:list(host)
 	local host_session = prosody.hosts[host];
 	if not host_session or not host_session.modules.muc then
@@ -1344,36 +1535,160 @@
 	return true, c.." rooms";
 end
 
-local um = require"core.usermanager";
+describe_command [[muc:occupants(roomjid, filter) - List room occupants, optionally filtered on substring or role]]
+function def_env.muc:occupants(room_jid, filter)
+	local room_obj, err = get_muc(room_jid);
+	if not room_obj then
+		return room_obj, err;
+	end
 
-local function coerce_roles(roles)
-	if roles == "admin" then roles = "prosody:admin"; end
-	if type(roles) == "string" then roles = { [roles] = true }; end
-	if roles[1] then for i, role in ipairs(roles) do roles[role], roles[i] = true, nil; end end
-	return roles;
+	local print = self.session.print;
+	local row = format_table({
+		{ title = "Role"; width = 12; key = "role" }; -- longest role name
+		{ title = "JID"; width = "75%"; key = "bare_jid" };
+		{ title = "Nickname"; width = "25%"; key = "nick"; mapper = jid_resource };
+	}, self.session.width);
+	local occupants = array.collect(iterators.select(2, room_obj:each_occupant()));
+	local total = #occupants;
+	if filter then
+		occupants:filter(function(occupant)
+			return occupant.role == filter or jid_resource(occupant.nick):find(filter, 1, true);
+		end);
+	end
+	local displayed = #occupants;
+	occupants:sort(function(a, b)
+		if a.role ~= b.role then
+			return muc_util.valid_roles[a.role] > muc_util.valid_roles[b.role];
+		else
+			return a.bare_jid < b.bare_jid;
+		end
+	end);
+
+	if displayed == 0 then
+		return true, ("%d out of %d occupant%s listed"):format(displayed, total, total ~= 1 and "s" or "")
+	end
+
+	print(row());
+	for _, occupant in ipairs(occupants) do
+		print(row(occupant));
+	end
+
+	if total == displayed then
+		return true, ("%d occupant%s listed"):format(total, total ~= 1 and "s" or "")
+	else
+		return true, ("%d out of %d occupant%s listed"):format(displayed, total, total ~= 1 and "s" or "")
+	end
 end
 
-def_env.user = {};
-function def_env.user:create(jid, password, roles)
+describe_command [[muc:affiliations(roomjid, filter) - List affiliated members of the room, optionally filtered on substring or affiliation]]
+function def_env.muc:affiliations(room_jid, filter)
+	local room_obj, err = get_muc(room_jid);
+	if not room_obj then
+		return room_obj, err;
+	end
+
+	local print = self.session.print;
+	local row = format_table({
+		{ title = "Affiliation"; width = 12 }; -- longest affiliation name
+		{ title = "JID"; width = "75%" };
+		{ title = "Nickname"; width = "25%"; key = "reserved_nickname" };
+	}, self.session.width);
+	local affiliated = array();
+	for affiliated_jid, affiliation, affiliation_data in room_obj:each_affiliation() do
+		affiliated:push(setmetatable({ affiliation; affiliated_jid }, { __index = affiliation_data }));
+	end
+
+	local total = #affiliated;
+	if filter then
+		affiliated:filter(function(affiliation)
+			return filter == affiliation[1] or affiliation[2]:find(filter, 1, true);
+		end);
+	end
+	local displayed = #affiliated;
+	local aff_ranking = muc_util.valid_affiliations;
+	affiliated:sort(function(a, b)
+		if a[1] ~= b[1] then
+			return aff_ranking[a[1]] > aff_ranking[b[1]];
+		else
+			return a[2] < b[2];
+		end
+	end);
+
+	if displayed == 0 then
+		return true, ("%d out of %d affiliations%s listed"):format(displayed, total, total ~= 1 and "s" or "")
+	end
+
+	print(row());
+	for _, affiliation in ipairs(affiliated) do
+		print(row(affiliation));
+	end
+
+
+	if total == displayed then
+		return true, ("%d affiliation%s listed"):format(total, total ~= 1 and "s" or "")
+	else
+		return true, ("%d out of %d affiliation%s listed"):format(displayed, total, total ~= 1 and "s" or "")
+	end
+end
+
+local um = require"prosody.core.usermanager";
+
+def_env.user = new_section("Commands to create and delete users, and change their passwords");
+
+describe_command [[user:create(jid, password, role) - Create the specified user account]]
+function def_env.user:create(jid, password, role)
 	local username, host = jid_split(jid);
 	if not prosody.hosts[host] then
 		return nil, "No such host: "..host;
 	elseif um.user_exists(username, host) then
 		return nil, "User exists";
 	end
-	local ok, err = um.create_user(username, password, host);
+
+	if not role then
+		role = module:get_option_string("default_provisioned_role", "prosody:member");
+	end
+
+	local ok, err = um.create_user_with_role(username, password, host, role);
+	if not ok then
+		return nil, "Could not create user: "..err;
+	end
+
+	return true, ("Created %s with role '%s'"):format(jid, role);
+end
+
+describe_command [[user:disable(jid) - Disable the specified user account, preventing login]]
+function def_env.user:disable(jid)
+	local username, host = jid_split(jid);
+	if not prosody.hosts[host] then
+		return nil, "No such host: "..host;
+	elseif not um.user_exists(username, host) then
+		return nil, "No such user";
+	end
+	local ok, err = um.disable_user(username, host);
 	if ok then
-		if ok and roles then
-			roles = coerce_roles(roles);
-			local roles_ok, rerr = um.set_roles(jid, host, roles);
-			if not roles_ok then return nil, "User created, but could not set roles: " .. tostring(rerr); end
-		end
-		return true, "User created";
+		return true, "User disabled";
 	else
-		return nil, "Could not create user: "..err;
+		return nil, "Could not disable user: "..err;
 	end
 end
 
+describe_command [[user:enable(jid) - Enable the specified user account, restoring login access]]
+function def_env.user:enable(jid)
+	local username, host = jid_split(jid);
+	if not prosody.hosts[host] then
+		return nil, "No such host: "..host;
+	elseif not um.user_exists(username, host) then
+		return nil, "No such user";
+	end
+	local ok, err = um.enable_user(username, host);
+	if ok then
+		return true, "User enabled";
+	else
+		return nil, "Could not enable user: "..err;
+	end
+end
+
+describe_command [[user:delete(jid) - Permanently remove the specified user account]]
 function def_env.user:delete(jid)
 	local username, host = jid_split(jid);
 	if not prosody.hosts[host] then
@@ -1389,6 +1704,7 @@
 	end
 end
 
+describe_command [[user:password(jid, password) - Set the password for the specified user account]]
 function def_env.user:password(jid, password)
 	local username, host = jid_split(jid);
 	if not prosody.hosts[host] then
@@ -1404,43 +1720,71 @@
 	end
 end
 
-function def_env.user:roles(jid, host, new_roles)
-	if new_roles or type(host) == "table" then
-		return nil, "Use user:setroles(jid, host, roles) to change user roles";
-	end
+describe_command [[user:roles(jid, host) - Show current roles for an user]]
+function def_env.user:role(jid, host)
+	local print = self.session.print;
 	local username, userhost = jid_split(jid);
 	if host == nil then host = userhost; end
-	if host ~= "*" and not prosody.hosts[host] then
+	if not prosody.hosts[host] then
 		return nil, "No such host: "..host;
 	elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then
 		return nil, "No such user";
 	end
-	local roles = um.get_roles(jid, host);
-	if not roles then return true, "No roles"; end
-	local count = 0;
-	local print = self.session.print;
-	for role in pairs(roles) do
+
+	local primary_role = um.get_user_role(username, host);
+	local secondary_roles = um.get_user_secondary_roles(username, host);
+
+	print(primary_role and primary_role.name or "<none>");
+
+	local count = primary_role and 1 or 0;
+	for role_name in pairs(secondary_roles or {}) do
 		count = count + 1;
-		print(role);
+		print(role_name.." (secondary)");
 	end
+
 	return true, count == 1 and "1 role" or count.." roles";
 end
-def_env.user.showroles = def_env.user.roles; -- COMPAT
+def_env.user.roles = def_env.user.role;
 
--- user:roles("someone@example.com", "example.com", {"prosody:admin"})
--- user:roles("someone@example.com", {"prosody:admin"})
-function def_env.user:setroles(jid, host, new_roles)
+describe_command [[user:setrole(jid, host, role) - Set primary role of a user (see 'help roles')]]
+-- user:setrole("someone@example.com", "example.com", "prosody:admin")
+-- user:setrole("someone@example.com", "prosody:admin")
+function def_env.user:setrole(jid, host, new_role)
 	local username, userhost = jid_split(jid);
-	if new_roles == nil then host, new_roles = userhost, host; end
-	if host ~= "*" and not prosody.hosts[host] then
+	if new_role == nil then host, new_role = userhost, host; end
+	if not prosody.hosts[host] then
 		return nil, "No such host: "..host;
 	elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then
 		return nil, "No such user";
 	end
-	if host == "*" then host = nil; end
-	return um.set_roles(jid, host, coerce_roles(new_roles));
+	return um.set_user_role(username, host, new_role);
 end
 
+describe_command [[user:addrole(jid, host, role) - Add a secondary role to a user]]
+function def_env.user:addrole(jid, host, new_role)
+	local username, userhost = jid_split(jid);
+	if new_role == nil then host, new_role = userhost, host; end
+	if not prosody.hosts[host] then
+		return nil, "No such host: "..host;
+	elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then
+		return nil, "No such user";
+	end
+	return um.add_user_secondary_role(username, host, new_role);
+end
+
+describe_command [[user:delrole(jid, host, role) - Remove a secondary role from a user]]
+function def_env.user:delrole(jid, host, role_name)
+	local username, userhost = jid_split(jid);
+	if role_name == nil then host, role_name = userhost, host; end
+	if not prosody.hosts[host] then
+		return nil, "No such host: "..host;
+	elseif prosody.hosts[userhost] and not um.user_exists(username, userhost) then
+		return nil, "No such user";
+	end
+	return um.remove_user_secondary_role(username, host, role_name);
+end
+
+describe_command [[user:list(hostname, pattern) - List users on the specified host, optionally filtering with a pattern]]
 -- TODO switch to table view, include roles
 function def_env.user:list(host, pat)
 	if not host then
@@ -1460,9 +1804,10 @@
 	return true, "Showing "..(pat and (matches.." of ") or "all " )..total.." users";
 end
 
-def_env.xmpp = {};
+def_env.xmpp = new_section("Commands for sending XMPP stanzas");
 
-local new_id = require "util.id".medium;
+describe_command [[xmpp:ping(localhost, remotehost) - Sends a ping to a remote XMPP server and reports the response]]
+local new_id = require "prosody.util.id".medium;
 function def_env.xmpp:ping(localhost, remotehost, timeout)
 	localhost = select(2, jid_split(localhost));
 	remotehost = select(2, jid_split(remotehost));
@@ -1509,12 +1854,12 @@
 		module:unhook("s2sin-established", onestablished);
 		module:unhook("s2s-destroyed", ondestroyed);
 	end):next(function(pong)
-		return ("pong from %s in %gs"):format(pong.stanza.attr.from, time.now() - time_start);
+		return ("pong from %s on %s in %gs"):format(pong.stanza.attr.from, pong.origin.id, time.now() - time_start);
 	end);
 end
 
-def_env.dns = {};
-local adns = require"net.adns";
+def_env.dns = new_section("Commands to manage and inspect the internal DNS resolver");
+local adns = require"prosody.net.adns";
 
 local function get_resolver(session)
 	local resolver = session.dns_resolver;
@@ -1525,43 +1870,54 @@
 	return resolver;
 end
 
+describe_command [[dns:lookup(name, type, class) - Do a DNS lookup]]
 function def_env.dns:lookup(name, typ, class)
 	local resolver = get_resolver(self.session);
 	return resolver:lookup_promise(name, typ, class)
 end
 
+describe_command [[dns:addnameserver(nameserver) - Add a nameserver to the list]]
 function def_env.dns:addnameserver(...)
 	local resolver = get_resolver(self.session);
 	resolver._resolver:addnameserver(...)
 	return true
 end
 
+describe_command [[dns:setnameserver(nameserver) - Replace the list of name servers with the supplied one]]
 function def_env.dns:setnameserver(...)
 	local resolver = get_resolver(self.session);
 	resolver._resolver:setnameserver(...)
 	return true
 end
 
+describe_command [[dns:purge() - Clear the DNS cache]]
 function def_env.dns:purge()
 	local resolver = get_resolver(self.session);
 	resolver._resolver:purge()
 	return true
 end
 
+describe_command [[dns:cache() - Show cached records]]
 function def_env.dns:cache()
 	local resolver = get_resolver(self.session);
 	return true, "Cache:\n"..tostring(resolver._resolver.cache)
 end
 
-def_env.http = {};
+def_env.http = new_section("Commands to inspect HTTP services");
 
+describe_command [[http:list(hosts) - Show HTTP endpoints]]
 function def_env.http:list(hosts)
 	local print = self.session.print;
 	hosts = array.collect(set.new({ not hosts and "*" or nil }) + get_hosts_set(hosts)):sort(_sort_hosts);
-	local output = format_table({
-			{ title = "Module", width = "20%" },
-			{ title = "URL", width = "80%" },
-		}, 132);
+	local output_simple = format_table({
+		{ title = "Module"; width = "1p" };
+		{ title = "External URL"; width = "6p" };
+	}, self.session.width);
+	local output_split = format_table({
+		{ title = "Module"; width = "1p" };
+		{ title = "External URL"; width = "3p" };
+		{ title = "Internal URL"; width = "3p" };
+	}, self.session.width);
 
 	for _, host in ipairs(hosts) do
 		local http_apps = modulemanager.get_items("http-provider", host);
@@ -1572,12 +1928,14 @@
 			else
 				print("HTTP endpoints on "..host..(http_host and (" (using "..http_host.."):") or ":"));
 			end
-			print(output());
+			print(output_split());
 			for _, provider in ipairs(http_apps) do
 				local mod = provider._provided_by;
-				local url = module:context(host):http_url(provider.name, provider.default_path);
+				local external = module:context(host):http_url(provider.name, provider.default_path);
+				local internal = module:context(host):http_url(provider.name, provider.default_path, "internal");
+				if external==internal then internal="" end
 				mod = mod and "mod_"..mod or ""
-				print(output{mod, url});
+				print((internal=="" and output_simple or output_split){mod, external, internal});
 			end
 			print("");
 		end
@@ -1592,18 +1950,83 @@
 	return true;
 end
 
-def_env.debug = {};
+def_env.watch = new_section("Commands for watching live logs from the server");
+
+describe_command [[watch:log() - Follow debug logs]]
+function def_env.watch:log()
+	local writing = false;
+	local sink = logger.add_simple_sink(function (source, level, message)
+		if writing then return; end
+		writing = true;
+		self.session.print(source, level, message);
+		writing = false;
+	end);
+
+	while self.session.is_connected() do
+		async.sleep(3);
+	end
+	if not logger.remove_sink(sink) then
+		module:log("warn", "Unable to remove watch:log() sink");
+	end
+end
 
+describe_command [[watch:stanzas(target, filter) - Watch live stanzas matching the specified target and filter]]
+local stanza_watchers = module:require("mod_debug_stanzas/watcher");
+function def_env.watch:stanzas(target_spec, filter_spec)
+	local function handler(event_type, stanza, session)
+		if stanza then
+			if event_type == "sent" then
+				self.session.print(("\n<!-- sent to %s -->"):format(session.id));
+			elseif event_type == "received" then
+				self.session.print(("\n<!-- received from %s -->"):format(session.id));
+			else
+				self.session.print(("\n<!-- %s (%s) -->"):format(event_type, session.id));
+			end
+			self.session.print(stanza);
+		elseif session then
+			self.session.print("\n<!-- session "..session.id.." "..event_type.." -->");
+		elseif event_type then
+			self.session.print("\n<!-- "..event_type.." -->");
+		end
+	end
+
+	stanza_watchers.add({
+		target_spec = {
+			jid = target_spec;
+		};
+		filter_spec = filter_spec and {
+			with_jid = filter_spec;
+		};
+	}, handler);
+
+	while self.session.is_connected() do
+		async.sleep(3);
+	end
+
+	stanza_watchers.remove(handler);
+end
+
+def_env.debug = new_section("Commands for debugging the server");
+
+describe_command [[debug:logevents(host) - Enable logging of fired events on host]]
 function def_env.debug:logevents(host)
-	helpers.log_host_events(host);
+	if host == "*" then
+		helpers.log_events(prosody.events);
+	elseif host == "http" then
+		helpers.log_events(require "prosody.net.http.server"._events);
+		return true
+	else
+		helpers.log_host_events(host);
+	end
 	return true;
 end
 
+describe_command [[debug:events(host, event) - Show registered event handlers]]
 function def_env.debug:events(host, event)
 	local events_obj;
 	if host and host ~= "*" then
 		if host == "http" then
-			events_obj = require "net.http.server"._events;
+			events_obj = require "prosody.net.http.server"._events;
 		elseif not prosody.hosts[host] then
 			return false, "Unknown host: "..host;
 		else
@@ -1615,9 +2038,10 @@
 	return true, helpers.show_events(events_obj, event);
 end
 
+describe_command [[debug:timers() - Show information about scheduled timers]]
 function def_env.debug:timers()
 	local print = self.session.print;
-	local add_task = require"util.timer".add_task;
+	local add_task = require"prosody.util.timer".add_task;
 	local h, params = add_task.h, add_task.params;
 	local function normalize_time(t)
 		return t;
@@ -1671,10 +2095,70 @@
 	return true;
 end
 
--- COMPAT: debug:timers() was timer:info() for some time in trunk
-def_env.timer = { info = def_env.debug.timers };
+describe_command [[debug:async() - Show information about pending asynchronous tasks]]
+function def_env.debug:async(runner_id)
+	local print = self.session.print;
+	local time_now = time.now();
+
+	if runner_id then
+		for runner, since in pairs(async.waiting_runners) do
+			if runner.id == runner_id then
+				print("ID        ", runner.id);
+				local f = runner.func;
+				if f == async.default_runner_func then
+					print("Function ", tostring(runner.current_item).." (from work queue)");
+				else
+					print("Function ", tostring(f));
+					if st.is_stanza(runner.current_item) then
+						print("Stanza:")
+						print("\t"..runner.current_item:indent(2):pretty_print());
+					else
+						print("Work item", self.session.serialize(runner.current_item, "debug"));
+					end
+				end
 
-def_env.stats = {};
+				print("Coroutine ", tostring(runner.thread).." ("..coroutine.status(runner.thread)..")");
+				print("Since     ", since);
+				print("Status    ", ("%s since %s (%0.2f seconds ago)"):format(runner.state, os.date("%Y-%m-%d %R:%S", math.floor(since)), time_now-since));
+				print("");
+				print(debug.traceback(runner.thread));
+				return true, "Runner is "..runner.state;
+			end
+		end
+		return nil, "Runner not found or is currently idle";
+	end
+
+	local row = format_table({
+		{ title = "ID"; width = 12 };
+		{ title = "Function"; width = "10p" };
+		{ title = "Status"; width = "16" };
+		{ title = "Location"; width = "10p" };
+	}, self.session.width);
+	print(row())
+
+	local c = 0;
+	for runner, since in pairs(async.waiting_runners) do
+		c = c + 1;
+		local f = runner.func;
+		if f == async.default_runner_func then
+			f = runner.current_item;
+		end
+		-- We want to fetch the location in the code that the runner yielded from,
+		-- excluding util.async's wrapper code. A level of  `2` assumes that we
+		-- yielded directly from a function in util.async. This is *currently* true
+		-- of all util.async yields, but it's fragile.
+		local location = debug.getinfo(runner.thread, 2);
+		print(row {
+			runner.id;
+			tostring(f);
+			("%s (%0.2fs)"):format(runner.state, time_now - since);
+			location.short_src..(location.currentline and ":"..location.currentline or "");
+		});
+	end
+	return true, ("%d runners pending"):format(c);
+end
+
+def_env.stats = new_section("Commands to show internal statistics");
 
 local short_units = {
 	seconds = "s",
@@ -1913,8 +2397,10 @@
 	return setmetatable({ session = self.session, stats = true, now = time.now() }, stats_mt);
 end
 
+describe_command [[stats:show(pattern) - Show internal statistics, optionally filtering by name with a pattern.]]
+-- Undocumented currently, you can append :histogram() or :cfgraph() to stats:show() for rendered graphs.
 function def_env.stats:show(name_filter)
-	local statsman = require "core.statsmanager"
+	local statsman = require "prosody.core.statsmanager"
 	local collect = statsman.collect
 	if collect then
 		-- force collection if in manual mode
@@ -1934,6 +2420,176 @@
 	return displayed_stats;
 end
 
+local command_metadata_schema = {
+	type = "object";
+	properties = {
+		section = { type = "string" };
+		section_desc = { type = "string" };
+
+		name = { type = "string" };
+		desc = { type = "string" };
+		help = { type = "string" };
+		args = {
+			type = "array";
+			items = {
+				type = "object";
+				properties = {
+					name = { type = "string", required = true };
+					type = { type = "string", required = false };
+				};
+			};
+		};
+	};
+
+	required = { "name", "section", "desc", "args" };
+};
+
+-- host_commands[section..":"..name][host] = handler
+-- host_commands[section..":"..name][false] = metadata
+local host_commands = {};
+
+local function new_item_handlers(command_host)
+	local function on_command_added(event)
+		local command = event.item;
+		local mod_name = command._provided_by and ("mod_"..command._provided_by) or "<unknown module>";
+		if not schema.validate(command_metadata_schema, command) or type(command.handler) ~= "function" then
+			module:log("warn", "Ignoring command added by %s: missing or invalid data", mod_name);
+			return;
+		end
+
+		local handler = command.handler;
+
+		if command_host then
+			if type(command.host_selector) ~= "string" then
+				module:log("warn", "Ignoring command %s:%s() added by %s - missing/invalid host_selector", command.section, command.name, mod_name);
+				return;
+			end
+			local qualified_name = command.section..":"..command.name;
+			local host_command_info = host_commands[qualified_name];
+			if not host_command_info then
+				local selector_index;
+				for i, arg in ipairs(command.args) do
+					if arg.name == command.host_selector then
+						selector_index = i + 1; -- +1 to account for 'self'
+						break;
+					end
+				end
+				if not selector_index then
+					module:log("warn", "Command %s() host selector argument '%s' not found - not registering", qualified_name, command.host_selector);
+					return;
+				end
+				host_command_info = {
+					[false] = {
+						host_selector = command.host_selector;
+						handler = function (...)
+							local selected_host = select(2, jid_split((select(selector_index, ...))));
+							if type(selected_host) ~= "string" then
+								return nil, "Invalid or missing argument '"..command.host_selector.."'";
+							end
+							if not prosody.hosts[selected_host] then
+								return nil, "Unknown host: "..selected_host;
+							end
+							local host_handler = host_commands[qualified_name][selected_host];
+							if not host_handler then
+								return nil, "This command is not available on "..selected_host;
+							end
+							return host_handler(...);
+						end;
+					};
+				};
+				host_commands[qualified_name] = host_command_info;
+			end
+			if host_command_info[command_host] then
+				module:log("warn", "Command %s() is already registered - overwriting with %s", qualified_name, mod_name);
+			end
+			host_command_info[command_host] = handler;
+		end
+
+		local section_t = def_env[command.section];
+		if not section_t then
+			section_t = {};
+			def_env[command.section] = section_t;
+		end
+
+		if command_host then
+			section_t[command.name] = host_commands[command.section..":"..command.name][false].handler;
+		else
+			section_t[command.name] = command.handler;
+		end
+
+		local section_mt = getmetatable(section_t);
+		if not section_mt then
+			section_mt = {};
+			setmetatable(section_t, section_mt);
+		end
+		local section_help = section_mt.help;
+		if not section_help then
+			section_help = {
+				desc = command.section_desc;
+				commands = {};
+			};
+			section_mt.help = section_help;
+		end
+
+		section_help.commands[command.name] = {
+			desc = command.desc;
+			full = command.help;
+			args = array(command.args);
+			module = command._provided_by;
+		};
+
+		module:log("debug", "Shell command added by mod_%s: %s:%s()", mod_name, command.section, command.name);
+	end
+
+	local function on_command_removed(event)
+		local command = event.item;
+
+		local handler = event.item.handler;
+		if type(handler) ~= "function" or not schema.validate(command_metadata_schema, command) then
+			return;
+		end
+
+		local section_t = def_env[command.section];
+		if not section_t or section_t[command.name] ~= handler then
+			return;
+		end
+
+		section_t[command.name] = nil;
+		if next(section_t) == nil then -- Delete section if empty
+			def_env[command.section] = nil;
+		end
+
+		if command_host then
+			local host_command_info = host_commands[command.section..":"..command.name];
+			if host_command_info then
+				-- Remove our host handler
+				host_command_info[command_host] = nil;
+				-- Clean up entire command entry if there are no per-host handlers left
+				local any_hosts = false;
+				for k in pairs(host_command_info) do
+					if k then -- metadata is false, ignore it
+						any_hosts = true;
+						break;
+					end
+				end
+				if not any_hosts then
+					host_commands[command.section..":"..command.name] = nil;
+				end
+			end
+		end
+	end
+	return on_command_added, on_command_removed;
+end
+
+module:handle_items("shell-command", new_item_handlers());
+
+function module.add_host(host_module)
+	host_module:handle_items("shell-command", new_item_handlers(host_module.host));
+end
+
+function module.unload()
+	stanza_watchers.cleanup();
+end
 
 
 -------------
--- a/plugins/mod_admin_socket.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_admin_socket.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,7 +8,7 @@
 	-- constructor was exported instead of a module table. Due to the lack of a
 	-- proper release of LuaSocket, distros have settled on shipping either the
 	-- last RC tag or some commit since then.
-	-- Here we accomodate both variants.
+	-- Here we accommodate both variants.
 	unix = { stream = unix };
 end
 if not have_unix or type(unix) ~= "table" then
@@ -16,10 +16,10 @@
 	return;
 end
 
-local server = require "net.server";
+local server = require "prosody.net.server";
 
-local adminstream = require "util.adminstream";
-local st = require "util.stanza";
+local adminstream = require "prosody.util.adminstream";
+local st = require "prosody.util.stanza";
 
 local socket_path = module:get_option_path("admin_socket", "prosody.sock", "data");
 
--- a/plugins/mod_admin_telnet.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_admin_telnet.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -12,8 +12,8 @@
 
 local console_listener = { default_port = 5582; default_mode = "*a"; interface = "127.0.0.1" };
 
-local async = require "util.async";
-local st = require "util.stanza";
+local async = require "prosody.util.async";
+local st = require "prosody.util.stanza";
 
 local def_env = module:shared("admin_shell/env");
 local default_env_mt = { __index = def_env };
--- a/plugins/mod_announce.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_announce.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,10 +6,9 @@
 -- COPYING file in the source package for more information.
 --
 
-local st, jid = require "util.stanza", require "util.jid";
+local st, jid = require "prosody.util.stanza", require "prosody.util.jid";
 
 local hosts = prosody.hosts;
-local is_admin = require "core.usermanager".is_admin;
 
 function send_to_online(message, host)
 	local sessions;
@@ -34,6 +33,7 @@
 	return c;
 end
 
+module:default_permission("prosody:admin", ":send-announcement");
 
 -- Old <message>-based jabberd-style announcement sending
 function handle_announcement(event)
@@ -45,8 +45,8 @@
 		return; -- Not an announcement
 	end
 
-	if not is_admin(stanza.attr.from, host) then
-		-- Not an admin? Not allowed!
+	if not module:may(":send-announcement", event) then
+		-- Not allowed!
 		module:log("warn", "Non-admin '%s' tried to send server announcement", stanza.attr.from);
 		return;
 	end
@@ -63,7 +63,7 @@
 module:hook("message/host", handle_announcement);
 
 -- Ad-hoc command (XEP-0133)
-local dataforms_new = require "util.dataforms".new;
+local dataforms_new = require "prosody.util.dataforms".new;
 local announce_layout = dataforms_new{
 	title = "Making an Announcement";
 	instructions = "Fill out this form to make an announcement to all\nactive users of this service.";
--- a/plugins/mod_auth_anonymous.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_auth_anonymous.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,8 +7,8 @@
 --
 -- luacheck: ignore 212
 
-local new_sasl = require "util.sasl".new;
-local datamanager = require "util.datamanager";
+local new_sasl = require "prosody.util.sasl".new;
+local datamanager = require "prosody.util.datamanager";
 local hosts = prosody.hosts;
 
 local allow_storage = module:get_option_boolean("allow_anonymous_storage", false);
--- a/plugins/mod_auth_insecure.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_auth_insecure.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,9 +7,9 @@
 --
 -- luacheck: ignore 212
 
-local datamanager = require "util.datamanager";
-local new_sasl = require "util.sasl".new;
-local saslprep = require "util.encodings".stringprep.saslprep;
+local datamanager = require "prosody.util.datamanager";
+local new_sasl = require "prosody.util.sasl".new;
+local saslprep = require "prosody.util.encodings".stringprep.saslprep;
 
 local host = module.host;
 local provider = { name = "insecure" };
@@ -27,6 +27,7 @@
 		return nil, "Password fails SASLprep.";
 	end
 	if account then
+		account.updated = os.time();
 		account.password = password;
 		return datamanager.store(username, host, "accounts", account);
 	end
@@ -38,7 +39,8 @@
 end
 
 function provider.create_user(username, password)
-	return datamanager.store(username, host, "accounts", {password = password});
+	local now = os.time();
+	return datamanager.store(username, host, "accounts", { created = now; updated = now; password = password });
 end
 
 function provider.delete_user(username)
--- a/plugins/mod_auth_internal_hashed.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_auth_internal_hashed.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -9,26 +9,27 @@
 
 local max = math.max;
 
-local scram_hashers = require "util.sasl.scram".hashers;
-local usermanager = require "core.usermanager";
-local generate_uuid = require "util.uuid".generate;
-local new_sasl = require "util.sasl".new;
-local hex = require"util.hex";
+local scram_hashers = require "prosody.util.sasl.scram".hashers;
+local generate_uuid = require "prosody.util.uuid".generate;
+local new_sasl = require "prosody.util.sasl".new;
+local hex = require"prosody.util.hex";
 local to_hex, from_hex = hex.encode, hex.decode;
-local saslprep = require "util.encodings".stringprep.saslprep;
-local secure_equals = require "util.hashes".equals;
+local saslprep = require "prosody.util.encodings".stringprep.saslprep;
+local secure_equals = require "prosody.util.hashes".equals;
 
 local log = module._log;
 local host = module.host;
 
 local accounts = module:open_store("accounts");
 
-local hash_name = module:get_option_string("password_hash", "SHA-1");
+local hash_name = module:get_option_enum("password_hash", "SHA-1", "SHA-256");
 local get_auth_db = assert(scram_hashers[hash_name], "SCRAM-"..hash_name.." not supported by SASL library");
 local scram_name = "scram_"..hash_name:gsub("%-","_"):lower();
 
 -- Default; can be set per-user
-local default_iteration_count = module:get_option_number("default_iteration_count", 10000);
+local default_iteration_count = module:get_option_integer("default_iteration_count", 10000, 4096);
+
+local tokenauth = module:depends("tokenauth");
 
 -- define auth provider
 local provider = {};
@@ -86,11 +87,22 @@
 		account.server_key = server_key_hex
 
 		account.password = nil;
+		account.updated = os.time();
 		return accounts:set(username, account);
 	end
 	return nil, "Account not available.";
 end
 
+function provider.get_account_info(username)
+	local account = accounts:get(username);
+	if not account then return nil, "Account not available"; end
+	return {
+		created = account.created;
+		password_updated = account.updated;
+		enabled = not account.disabled;
+	};
+end
+
 function provider.user_exists(username)
 	local account = accounts:get(username);
 	if not account then
@@ -100,13 +112,36 @@
 	return true;
 end
 
+function provider.is_enabled(username) -- luacheck: ignore 212
+	local info, err = provider.get_account_info(username);
+	if not info then return nil, err; end
+	return info.enabled;
+end
+
+function provider.enable(username)
+	-- TODO map store?
+	local account = accounts:get(username);
+	account.disabled = nil;
+	account.updated = os.time();
+	return accounts:set(username, account);
+end
+
+function provider.disable(username, meta)
+	local account = accounts:get(username);
+	account.disabled = true;
+	account.disabled_meta = meta;
+	account.updated = os.time();
+	return accounts:set(username, account);
+end
+
 function provider.users()
 	return accounts:users();
 end
 
 function provider.create_user(username, password)
+	local now = os.time();
 	if password == nil then
-		return accounts:set(username, {});
+		return accounts:set(username, { created = now; updated = now; disabled = true });
 	end
 	local salt = generate_uuid();
 	local valid, stored_key, server_key = get_auth_db(password, salt, default_iteration_count);
@@ -117,7 +152,8 @@
 	local server_key_hex = to_hex(server_key);
 	return accounts:set(username, {
 		stored_key = stored_key_hex, server_key = server_key_hex,
-		salt = salt, iteration_count = default_iteration_count
+		salt = salt, iteration_count = default_iteration_count,
+		created = now, updated = now;
 	});
 end
 
@@ -127,8 +163,8 @@
 
 function provider.get_sasl_handler()
 	local testpass_authentication_profile = {
-		plain_test = function(_, username, password, realm)
-			return usermanager.test_password(username, realm, password), true;
+		plain_test = function(_, username, password)
+			return provider.test_password(username, password), provider.is_enabled(username);
 		end,
 		[scram_name] = function(_, username)
 			local credentials = accounts:get(username);
@@ -145,8 +181,9 @@
 			local iteration_count, salt = credentials.iteration_count, credentials.salt;
 			stored_key = stored_key and from_hex(stored_key);
 			server_key = server_key and from_hex(server_key);
-			return stored_key, server_key, iteration_count, salt, true;
-		end
+			return stored_key, server_key, iteration_count, salt, not credentials.disabled;
+		end;
+		oauthbearer = tokenauth.sasl_handler(provider, "oauth2", module:shared("tokenauth/oauthbearer_config"));
 	};
 	return new_sasl(host, testpass_authentication_profile);
 end
--- a/plugins/mod_auth_internal_plain.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_auth_internal_plain.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,10 +6,10 @@
 -- COPYING file in the source package for more information.
 --
 
-local usermanager = require "core.usermanager";
-local new_sasl = require "util.sasl".new;
-local saslprep = require "util.encodings".stringprep.saslprep;
-local secure_equals = require "util.hashes".equals;
+local usermanager = require "prosody.core.usermanager";
+local new_sasl = require "prosody.util.sasl".new;
+local saslprep = require "prosody.util.encodings".stringprep.saslprep;
+local secure_equals = require "prosody.util.hashes".equals;
 
 local log = module._log;
 local host = module.host;
@@ -48,11 +48,21 @@
 	local account = accounts:get(username);
 	if account then
 		account.password = password;
+		account.updated = os.time();
 		return accounts:set(username, account);
 	end
 	return nil, "Account not available.";
 end
 
+function provider.get_account_info(username)
+	local account = accounts:get(username);
+	if not account then return nil, "Account not available"; end
+	return {
+		created = account.created;
+		password_updated = account.updated;
+	};
+end
+
 function provider.user_exists(username)
 	local account = accounts:get(username);
 	if not account then
@@ -67,11 +77,18 @@
 end
 
 function provider.create_user(username, password)
+	local now = os.time();
+	if password == nil then
+		return accounts:set(username, { created = now, updated = now, disabled = true });
+	end
 	password = saslprep(password);
 	if not password then
 		return nil, "Password fails SASLprep.";
 	end
-	return accounts:set(username, {password = password});
+	return accounts:set(username, {
+		password = password;
+		created = now, updated = now;
+	});
 end
 
 function provider.delete_user(username)
--- a/plugins/mod_auth_ldap.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_auth_ldap.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,7 +1,6 @@
 -- mod_auth_ldap
 
-local jid_split = require "util.jid".split;
-local new_sasl = require "util.sasl".new;
+local new_sasl = require "prosody.util.sasl".new;
 local lualdap = require "lualdap";
 
 local function ldap_filter_escape(s)
@@ -13,14 +12,21 @@
 local ldap_rootdn = module:get_option_string("ldap_rootdn", "");
 local ldap_password = module:get_option_string("ldap_password", "");
 local ldap_tls = module:get_option_boolean("ldap_tls");
-local ldap_scope = module:get_option_string("ldap_scope", "subtree");
+local ldap_scope = module:get_option_enum("ldap_scope", "subtree", "base", "onelevel");
 local ldap_filter = module:get_option_string("ldap_filter", "(uid=$user)"):gsub("%%s", "$user", 1);
 local ldap_base = assert(module:get_option_string("ldap_base"), "ldap_base is a required option for ldap");
-local ldap_mode = module:get_option_string("ldap_mode", "bind");
+local ldap_mode = module:get_option_enum("ldap_mode", "bind", "getpasswd");
 local ldap_admins = module:get_option_string("ldap_admin_filter",
 	module:get_option_string("ldap_admins")); -- COMPAT with mistake in documentation
 local host = ldap_filter_escape(module:get_option_string("realm", module.host));
 
+if ldap_admins then
+	module:log("error", "The 'ldap_admin_filter' option has been deprecated, "..
+	           "and will be ignored. Equivalent functionality may be added in "..
+	           "the future if there is demand."
+	);
+end
+
 -- Initiate connection
 local ld = nil;
 module.unload = function() if ld then pcall(ld, ld.close); end end
@@ -133,22 +139,4 @@
 	module:log("error", "Unsupported ldap_mode %s", tostring(ldap_mode));
 end
 
-if ldap_admins then
-	function provider.is_admin(jid)
-		local username, user_host = jid_split(jid);
-		if user_host ~= module.host then
-			return false;
-		end
-		return ldap_do("search", 2, {
-			base = ldap_base;
-			scope = ldap_scope;
-			sizelimit = 1;
-			filter = ldap_admins:gsub("%$(%a+)", {
-				user = ldap_filter_escape(username);
-				host = host;
-			});
-		});
-	end
-end
-
 module:provides("auth", provider);
--- a/plugins/mod_authz_internal.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_authz_internal.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,59 +1,350 @@
-local array = require "util.array";
-local it = require "util.iterators";
-local set = require "util.set";
-local jid_split = require "util.jid".split;
-local normalize = require "util.jid".prep;
+local array = require "prosody.util.array";
+local it = require "prosody.util.iterators";
+local set = require "prosody.util.set";
+local jid_split, jid_bare, jid_host = import("prosody.util.jid", "split", "bare", "host");
+local normalize = require "prosody.util.jid".prep;
+local roles = require "prosody.util.roles";
+
+local config_global_admin_jids = module:context("*"):get_option_set("admins", {}) / normalize;
 local config_admin_jids = module:get_option_inherited_set("admins", {}) / normalize;
 local host = module.host;
-local role_store = module:open_store("roles");
-local role_map_store = module:open_store("roles", "map");
+local host_suffix = host:gsub("^[^%.]+%.", "");
+
+local hosts = prosody.hosts;
+local is_anon_host = module:get_option_string("authentication") == "anonymous";
+local default_user_role = module:get_option_string("default_user_role", is_anon_host and "prosody:guest" or "prosody:registered");
+
+local is_component = hosts[host].type == "component";
+local host_user_role, server_user_role, public_user_role;
+if is_component then
+	host_user_role = module:get_option_string("host_user_role", "prosody:registered");
+	server_user_role = module:get_option_string("server_user_role");
+	public_user_role = module:get_option_string("public_user_role");
+end
+
+local role_store = module:open_store("account_roles");
+local role_map_store = module:open_store("account_roles", "map");
+
+local role_registry = {};
 
-local admin_role = { ["prosody:admin"] = true };
+function register_role(role)
+	if role_registry[role.name] ~= nil then
+		return error("A role '"..role.name.."' is already registered");
+	end
+	if not roles.is_role(role) then
+		-- Convert table syntax to real role object
+		for i, inherited_role in ipairs(role.inherits or {}) do
+			if type(inherited_role) == "string" then
+				role.inherits[i] = assert(role_registry[inherited_role], "The named role '"..inherited_role.."' is not registered");
+			end
+		end
+		if not role.permissions then role.permissions = {}; end
+		for _, allow_permission in ipairs(role.allow or {}) do
+			role.permissions[allow_permission] = true;
+		end
+		for _, deny_permission in ipairs(role.deny or {}) do
+			role.permissions[deny_permission] = false;
+		end
+		role = roles.new(role);
+	end
+	role_registry[role.name] = role;
+end
+
+-- Default roles
+
+-- For untrusted guest/anonymous users
+register_role {
+	name = "prosody:guest";
+	priority = 15;
+};
 
-function get_user_roles(user)
-	if config_admin_jids:contains(user.."@"..host) then
-		return admin_role;
+-- For e.g. self-registered accounts
+register_role {
+	name = "prosody:registered";
+	priority = 25;
+	inherits = { "prosody:guest" };
+};
+
+
+-- For trusted/provisioned accounts
+register_role {
+	name = "prosody:member";
+	priority = 35;
+	inherits = { "prosody:registered" };
+};
+
+-- For administrators, e.g. of a host
+register_role {
+	name = "prosody:admin";
+	priority = 50;
+	inherits = { "prosody:member" };
+};
+
+-- For server operators (full access)
+register_role {
+	name = "prosody:operator";
+	priority = 75;
+	inherits = { "prosody:admin" };
+};
+
+
+-- Process custom roles from config
+
+local custom_roles = module:get_option_array("custom_roles", {});
+for n, role_config in ipairs(custom_roles) do
+	local ok, err = pcall(register_role, role_config);
+	if not ok then
+		module:log("error", "Error registering custom role %s: %s", role_config.name or tostring(n), err);
 	end
-	return role_store:get(user);
+end
+
+-- Process custom permissions from config
+
+local config_add_perms = module:get_option("add_permissions", {});
+local config_remove_perms = module:get_option("remove_permissions", {});
+
+for role_name, added_permissions in pairs(config_add_perms) do
+	if not role_registry[role_name] then
+		module:log("error", "Cannot add permissions to unknown role '%s'", role_name);
+	else
+		for _, permission in ipairs(added_permissions) do
+			role_registry[role_name]:set_permission(permission, true, true);
+		end
+	end
 end
 
-function set_user_roles(user, roles)
-	role_store:set(user, roles)
-	return true;
+for role_name, removed_permissions in pairs(config_remove_perms) do
+	if not role_registry[role_name] then
+		module:log("error", "Cannot remove permissions from unknown role '%s'", role_name);
+	else
+		for _, permission in ipairs(removed_permissions) do
+			role_registry[role_name]:set_permission(permission, false, true);
+		end
+	end
+end
+
+-- Public API
+
+-- Get the primary role of a user
+function get_user_role(user)
+	local bare_jid = user.."@"..host;
+
+	-- Check config first
+	if config_global_admin_jids:contains(bare_jid) then
+		return role_registry["prosody:operator"];
+	elseif config_admin_jids:contains(bare_jid) then
+		return role_registry["prosody:admin"];
+	end
+
+	-- Check storage
+	local stored_roles, err = role_store:get(user);
+	if not stored_roles then
+		if err then
+			-- Unable to fetch role, fail
+			return nil, err;
+		end
+		-- No role set, use default role
+		return role_registry[default_user_role];
+	end
+	if stored_roles._default == nil then
+		-- No primary role explicitly set, return default
+		return role_registry[default_user_role];
+	end
+	local primary_stored_role = role_registry[stored_roles._default];
+	if not primary_stored_role then
+		return nil, "unknown-role";
+	end
+	return primary_stored_role;
 end
 
-function get_users_with_role(role)
-	local storage_role_users = it.to_array(it.keys(role_map_store:get_all(role) or {}));
-	if role == "prosody:admin" then
-		local config_admin_users = config_admin_jids / function (admin_jid)
+-- Set the primary role of a user
+function set_user_role(user, role_name)
+	local role = role_registry[role_name];
+	if not role then
+		return error("Cannot assign default user an unknown role: "..tostring(role_name));
+	end
+	local keys_update = {
+		_default = role_name;
+		-- Primary role cannot be secondary role
+		[role_name] = role_map_store.remove;
+	};
+	if role_name == default_user_role then
+		-- Don't store default
+		keys_update._default = role_map_store.remove;
+	end
+	local ok, err = role_map_store:set_keys(user, keys_update);
+	if not ok then
+		return nil, err;
+	end
+	return role;
+end
+
+function add_user_secondary_role(user, role_name)
+	if not role_registry[role_name] then
+		return error("Cannot assign default user an unknown role: "..tostring(role_name));
+	end
+	role_map_store:set(user, role_name, true);
+end
+
+function remove_user_secondary_role(user, role_name)
+	role_map_store:set(user, role_name, nil);
+end
+
+function get_user_secondary_roles(user)
+	local stored_roles, err = role_store:get(user);
+	if not stored_roles then
+		if err then
+			-- Unable to fetch role, fail
+			return nil, err;
+		end
+		-- No role set
+		return {};
+	end
+	stored_roles._default = nil;
+	for role_name in pairs(stored_roles) do
+		stored_roles[role_name] = role_registry[role_name];
+	end
+	return stored_roles;
+end
+
+function user_can_assume_role(user, role_name)
+	local primary_role = get_user_role(user);
+	if primary_role and primary_role.name == role_name then
+		return true;
+	end
+	local secondary_roles = get_user_secondary_roles(user);
+	if secondary_roles and secondary_roles[role_name] then
+		return true;
+	end
+	return false;
+end
+
+-- This function is *expensive*
+function get_users_with_role(role_name)
+	local function role_filter(username, default_role) --luacheck: ignore 212/username
+		return default_role == role_name;
+	end
+	local primary_role_users = set.new(it.to_array(it.filter(role_filter, pairs(role_map_store:get_all("_default") or {}))));
+	local secondary_role_users = set.new(it.to_array(it.keys(role_map_store:get_all(role_name) or {})));
+
+	local config_set;
+	if role_name == "prosody:admin" then
+		config_set = config_admin_jids;
+	elseif role_name == "prosody:operator" then
+		config_set = config_global_admin_jids;
+	end
+	if config_set then
+		local config_admin_users = config_set / function (admin_jid)
 			local j_node, j_host = jid_split(admin_jid);
 			if j_host == host then
 				return j_node;
 			end
 		end;
-		return it.to_array(config_admin_users + set.new(storage_role_users));
+		return it.to_array(config_admin_users + primary_role_users + secondary_role_users);
 	end
-	return storage_role_users;
+	return it.to_array(primary_role_users + secondary_role_users);
 end
 
-function get_jid_roles(jid)
-	if config_admin_jids:contains(jid) then
-		return admin_role;
+function get_jid_role(jid)
+	local bare_jid = jid_bare(jid);
+	if config_global_admin_jids:contains(bare_jid) then
+		return role_registry["prosody:operator"];
+	elseif config_admin_jids:contains(bare_jid) then
+		return role_registry["prosody:admin"];
+	elseif is_component then
+		local user_host = jid_host(bare_jid);
+		if host_user_role and user_host == host_suffix then
+			return role_registry[host_user_role];
+		elseif server_user_role and hosts[user_host] then
+			return role_registry[server_user_role];
+		elseif public_user_role then
+			return role_registry[public_user_role];
+		end
 	end
 	return nil;
 end
 
-function set_jid_roles(jid) -- luacheck: ignore 212
+function set_jid_role(jid, role_name) -- luacheck: ignore 212
 	return false;
 end
 
-function get_jids_with_role(role)
+function get_jids_with_role(role_name)
 	-- Fetch role users from storage
-	local storage_role_jids = array.map(get_users_with_role(role), function (username)
+	local storage_role_jids = array.map(get_users_with_role(role_name), function (username)
 		return username.."@"..host;
 	end);
-	if role == "prosody:admin" then
+	if role_name == "prosody:admin" then
 		return it.to_array(config_admin_jids + set.new(storage_role_jids));
+	elseif role_name == "prosody:operator" then
+		return it.to_array(config_global_admin_jids + set.new(storage_role_jids));
 	end
 	return storage_role_jids;
 end
+
+function add_default_permission(role_name, action, policy)
+	local role = role_registry[role_name];
+	if not role then
+		module:log("warn", "Attempt to add default permission for unknown role: %s", role_name);
+		return nil, "no-such-role";
+	end
+	if policy == nil then policy = true; end
+	module:log("debug", "Adding policy %s for permission %s on role %s", policy, action, role_name);
+	return role:set_permission(action, policy);
+end
+
+function get_role_by_name(role_name)
+	return assert(role_registry[role_name], role_name);
+end
+
+function get_all_roles()
+	return role_registry;
+end
+
+-- COMPAT: Migrate from 0.12 role storage
+local function do_migration(migrate_host)
+	local old_role_store = assert(module:context(migrate_host):open_store("roles"));
+	local new_role_store = assert(module:context(migrate_host):open_store("account_roles"));
+
+	local migrated, failed, skipped = 0, 0, 0;
+	-- Iterate all users
+	for username in assert(old_role_store:users()) do
+		local old_roles = it.to_array(it.filter(function (k) return k:sub(1,1) ~= "_"; end, it.keys(old_role_store:get(username))));
+		if #old_roles == 1 then
+			local ok, err = new_role_store:set(username, {
+				_default = old_roles[1];
+			});
+			if ok then
+				migrated = migrated + 1;
+			else
+				failed = failed + 1;
+				print("EE: Failed to store new role info for '"..username.."': "..err);
+			end
+		else
+			print("WW: User '"..username.."' has multiple roles and cannot be automatically migrated");
+			skipped = skipped + 1;
+		end
+	end
+	return migrated, failed, skipped;
+end
+
+function module.command(arg)
+	if arg[1] == "migrate" then
+		table.remove(arg, 1);
+		local migrate_host = arg[1];
+		if not migrate_host or not prosody.hosts[migrate_host] then
+			print("EE: Please supply a valid host to migrate to the new role storage");
+			return 1;
+		end
+
+		-- Initialize storage layer
+		require "prosody.core.storagemanager".initialize_host(migrate_host);
+
+		print("II: Migrating roles...");
+		local migrated, failed, skipped = do_migration(migrate_host);
+		print(("II: %d migrated, %d failed, %d skipped"):format(migrated, failed, skipped));
+		return (failed + skipped == 0) and 0 or 1;
+	else
+		print("EE: Unknown command: "..(arg[1] or "<none given>"));
+		print("    Hint: try 'migrate'?");
+	end
+end
--- a/plugins/mod_blocklist.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_blocklist.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -9,34 +9,29 @@
 -- This module implements XEP-0191: Blocking Command
 --
 
-local user_exists = require"core.usermanager".user_exists;
-local rostermanager = require"core.rostermanager";
+local user_exists = require"prosody.core.usermanager".user_exists;
+local rostermanager = require"prosody.core.rostermanager";
 local is_contact_subscribed = rostermanager.is_contact_subscribed;
 local is_contact_pending_in = rostermanager.is_contact_pending_in;
 local load_roster = rostermanager.load_roster;
 local save_roster = rostermanager.save_roster;
-local st = require"util.stanza";
+local st = require"prosody.util.stanza";
 local st_error_reply = st.error_reply;
-local jid_prep = require"util.jid".prep;
-local jid_split = require"util.jid".split;
+local jid_prep = require"prosody.util.jid".prep;
+local jid_split = require"prosody.util.jid".split;
 
 local storage = module:open_store();
 local sessions = prosody.hosts[module.host].sessions;
 local full_sessions = prosody.full_sessions;
 
--- First level cache of blocklists by username.
--- Weak table so may randomly expire at any time.
-local cache = setmetatable({}, { __mode = "v" });
-
--- Second level of caching, keeps a fixed number of items, also anchors
--- items in the above cache.
+-- Cache of blocklists, keeps a fixed number of items.
 --
 -- The size of this affects how often we will need to load a blocklist from
 -- disk, which we want to avoid during routing. On the other hand, we don't
 -- want to use too much memory either, so this can be tuned by advanced
 -- users. TODO use science to figure out a better default, 64 is just a guess.
-local cache_size = module:get_option_number("blocklist_cache_size", 64);
-local cache2 = require"util.cache".new(cache_size);
+local cache_size = module:get_option_integer("blocklist_cache_size", 256, 1);
+local blocklist_cache = require"prosody.util.cache".new(cache_size);
 
 local null_blocklist = {};
 
@@ -48,12 +43,12 @@
 		return ok, err;
 	end
 	-- Successful save, update the cache
-	cache2:set(username, blocklist);
-	cache[username] = blocklist;
+	blocklist_cache:set(username, blocklist);
 	return true;
 end
 
 -- Migrates from the old mod_privacy storage
+-- TODO mod_privacy was removed in 0.10.0, this should be phased out
 local function migrate_privacy_list(username)
 	local legacy_data = module:open_store("privacy"):get(username);
 	if not legacy_data or not legacy_data.lists or not legacy_data.default then return; end
@@ -77,8 +72,15 @@
 	return migrated_data;
 end
 
+if not module:get_option_boolean("migrate_legacy_blocking", true) then
+	migrate_privacy_list = function (username)
+		module:log("debug", "Migrating from mod_privacy disabled, user '%s' will start with a fresh blocklist", username);
+		return nil;
+	end
+end
+
 local function get_blocklist(username)
-	local blocklist = cache2:get(username);
+	local blocklist = blocklist_cache:get(username);
 	if not blocklist then
 		if not user_exists(username, module.host) then
 			return null_blocklist;
@@ -90,9 +92,8 @@
 		if not blocklist then
 			blocklist = { [false] = { created = os.time(); }; };
 		end
-		cache2:set(username, blocklist);
+		blocklist_cache:set(username, blocklist);
 	end
-	cache[username] = blocklist;
 	return blocklist;
 end
 
@@ -100,7 +101,7 @@
 	local origin, stanza = event.origin, event.stanza;
 	local username = origin.username;
 	local reply = st.reply(stanza):tag("blocklist", { xmlns = "urn:xmpp:blocking" });
-	local blocklist = cache[username] or get_blocklist(username);
+	local blocklist = get_blocklist(username);
 	for jid in pairs(blocklist) do
 		if jid then
 			reply:tag("item", { jid = jid }):up();
@@ -159,7 +160,7 @@
 		return true;
 	end
 
-	local blocklist = cache[username] or get_blocklist(username);
+	local blocklist = get_blocklist(username);
 
 	local new_blocklist = {
 		-- We set the [false] key to something as a signal not to migrate privacy lists
@@ -233,8 +234,7 @@
 -- Cache invalidation, solved!
 module:hook_global("user-deleted", function (event)
 	if event.host == module.host then
-		cache2:set(event.username, nil);
-		cache[event.username] = nil;
+		blocklist_cache:set(event.username, nil);
 	end
 end);
 
@@ -249,7 +249,7 @@
 end);
 
 local function is_blocked(user, jid)
-	local blocklist = cache[user] or get_blocklist(user);
+	local blocklist = get_blocklist(user);
 	if blocklist[jid] then return true; end
 	local node, host = jid_split(jid);
 	return blocklist[host] or node and blocklist[node..'@'..host];
--- a/plugins/mod_bookmarks.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_bookmarks.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,10 +1,10 @@
-local mm = require "core.modulemanager";
+local mm = require "prosody.core.modulemanager";
 if mm.get_modules_for_host(module.host):contains("bookmarks2") then
 	error("mod_bookmarks and mod_bookmarks2 are conflicting, please disable one of them.", 0);
 end
 
-local st = require "util.stanza";
-local jid_split = require "util.jid".split;
+local st = require "prosody.util.stanza";
+local jid_split = require "prosody.util.jid".split;
 
 local mod_pep = module:depends "pep";
 local private_storage = module:open_store("private", "map");
--- a/plugins/mod_bosh.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_bosh.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,21 +8,21 @@
 
 module:set_global();
 
-local new_xmpp_stream = require "util.xmppstream".new;
-local sm = require "core.sessionmanager";
+local new_xmpp_stream = require "prosody.util.xmppstream".new;
+local sm = require "prosody.core.sessionmanager";
 local sm_destroy_session = sm.destroy_session;
-local new_uuid = require "util.uuid".generate;
+local new_uuid = require "prosody.util.uuid".generate;
 local core_process_stanza = prosody.core_process_stanza;
-local st = require "util.stanza";
-local logger = require "util.logger";
+local st = require "prosody.util.stanza";
+local logger = require "prosody.util.logger";
 local log = module._log;
-local initialize_filters = require "util.filters".initialize;
+local initialize_filters = require "prosody.util.filters".initialize;
 local math_min = math.min;
 local tostring, type = tostring, type;
 local traceback = debug.traceback;
-local runner = require"util.async".runner;
-local nameprep = require "util.encodings".stringprep.nameprep;
-local cache = require "util.cache";
+local runner = require"prosody.util.async".runner;
+local nameprep = require "prosody.util.encodings".stringprep.nameprep;
+local cache = require "prosody.util.cache";
 
 local xmlns_streams = "http://etherx.jabber.org/streams";
 local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams";
@@ -36,16 +36,16 @@
 local BOSH_MAX_REQUESTS = 2;
 
 -- The number of seconds a BOSH session should remain open with no requests
-local bosh_max_inactivity = module:get_option_number("bosh_max_inactivity", 60);
+local bosh_max_inactivity = module:get_option_period("bosh_max_inactivity", 60);
 -- The minimum amount of time between requests with no payload
-local bosh_max_polling = module:get_option_number("bosh_max_polling", 5);
+local bosh_max_polling = module:get_option_period("bosh_max_polling", 5);
 -- The maximum amount of time that the server will hold onto a request before replying
 -- (the client can set this to a lower value when it connects, if it chooses)
-local bosh_max_wait = module:get_option_number("bosh_max_wait", 120);
+local bosh_max_wait = module:get_option_period("bosh_max_wait", 120);
 
 local consider_bosh_secure = module:get_option_boolean("consider_bosh_secure");
 local cross_domain = module:get_option("cross_domain_bosh");
-local stanza_size_limit = module:get_option_number("c2s_stanza_size_limit", 1024*256);
+local stanza_size_limit = module:get_option_integer("c2s_stanza_size_limit", 1024*256, 10000);
 
 if cross_domain ~= nil then
 	module:log("info", "The 'cross_domain_bosh' option has been deprecated");
@@ -325,7 +325,7 @@
 		sid = new_uuid();
 		-- TODO use util.session
 		local session = {
-			type = "c2s_unauthed", conn = request.conn, sid = sid, host = attr.to,
+			base_type = "c2s", type = "c2s_unauthed", conn = request.conn, sid = sid, host = attr.to,
 			rid = rid - 1, -- Hack for initial session setup, "previous" rid was $current_request - 1
 			bosh_version = attr.ver, bosh_wait = wait, streamid = sid,
 			bosh_max_inactive = bosh_max_inactivity, bosh_responses = cache.new(BOSH_HOLD+1):table();
@@ -456,7 +456,7 @@
 
 	if session.notopen then
 		local features = st.stanza("stream:features");
-		module:context(session.host):fire_event("stream-features", { origin = session, features = features });
+		module:context(session.host):fire_event("stream-features", { origin = session, features = features, stream = attr });
 		session.send(features);
 		session.notopen = nil;
 	end
@@ -559,6 +559,6 @@
 	});
 end
 
-if require"core.modulemanager".get_modules_for_host("*"):contains(module.name) then
+if require"prosody.core.modulemanager".get_modules_for_host("*"):contains(module.name) then
 	module:add_host();
 end
--- a/plugins/mod_c2s.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_c2s.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,15 +8,15 @@
 
 module:set_global();
 
-local add_task = require "util.timer".add_task;
-local new_xmpp_stream = require "util.xmppstream".new;
-local nameprep = require "util.encodings".stringprep.nameprep;
-local sessionmanager = require "core.sessionmanager";
-local statsmanager = require "core.statsmanager";
-local st = require "util.stanza";
+local add_task = require "prosody.util.timer".add_task;
+local new_xmpp_stream = require "prosody.util.xmppstream".new;
+local nameprep = require "prosody.util.encodings".stringprep.nameprep;
+local sessionmanager = require "prosody.core.sessionmanager";
+local statsmanager = require "prosody.core.statsmanager";
+local st = require "prosody.util.stanza";
 local sm_new_session, sm_destroy_session = sessionmanager.new_session, sessionmanager.destroy_session;
-local uuid_generate = require "util.uuid".generate;
-local async = require "util.async";
+local uuid_generate = require "prosody.util.uuid".generate;
+local async = require "prosody.util.async";
 local runner = async.runner;
 
 local tostring, type = tostring, type;
@@ -25,10 +25,10 @@
 
 local log = module._log;
 
-local c2s_timeout = module:get_option_number("c2s_timeout", 300);
-local stream_close_timeout = module:get_option_number("c2s_close_timeout", 5);
+local c2s_timeout = module:get_option_period("c2s_timeout", "5 minutes");
+local stream_close_timeout = module:get_option_period("c2s_close_timeout", 5);
 local opt_keepalives = module:get_option_boolean("c2s_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true));
-local stanza_size_limit = module:get_option_number("c2s_stanza_size_limit", 1024*256);
+local stanza_size_limit = module:get_option_integer("c2s_stanza_size_limit", 1024*256,10000);
 
 local measure_connections = module:metric("gauge", "connections", "", "Established c2s connections", {"host", "type", "ip_family"});
 
@@ -117,8 +117,7 @@
 		session.secure = true;
 		session.encrypted = true;
 
-		local sock = session.conn:socket();
-		local info = sock.info and sock:info();
+		local info = session.conn:ssl_info();
 		if type(info) == "table" then
 			(session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher);
 			session.compressed = info.compression;
@@ -129,8 +128,13 @@
 	end
 
 	local features = st.stanza("stream:features");
-	hosts[session.host].events.fire_event("stream-features", { origin = session, features = features });
+	hosts[session.host].events.fire_event("stream-features", { origin = session, features = features, stream = attr });
 	if features.tags[1] or session.full_jid then
+		if stanza_size_limit then
+			features:reset();
+			features:tag("limits", { xmlns = "urn:xmpp:stream-limits:0" })
+				:text_tag("max-bytes", string.format("%d", stanza_size_limit)):up();
+		end
 		send(features);
 	else
 		if session.secure then
@@ -248,6 +252,9 @@
 local function disconnect_user_sessions(reason, leave_resource)
 	return function (event)
 		local username, host, resource = event.username, event.host, event.resource;
+		if not (hosts[host] and hosts[host].type == "local") then
+			return -- not a local VirtualHost so no sessions
+		end
 		local user = hosts[host].sessions[username];
 		if user and user.sessions then
 			for r, session in pairs(user.sessions) do
@@ -260,8 +267,18 @@
 end
 
 module:hook_global("user-password-changed", disconnect_user_sessions({ condition = "reset", text = "Password changed" }, true), 200);
-module:hook_global("user-roles-changed", disconnect_user_sessions({ condition = "reset", text = "Roles changed" }), 200);
+module:hook_global("user-role-changed", disconnect_user_sessions({ condition = "reset", text = "Role changed" }), 200);
 module:hook_global("user-deleted", disconnect_user_sessions({ condition = "not-authorized", text = "Account deleted" }), 200);
+module:hook_global("user-disabled", disconnect_user_sessions({ condition = "not-authorized", text = "Account disabled" }), 200);
+
+module:hook_global("c2s-session-updated", function (event)
+	sessions[event.session.conn] = event.session;
+	local replaced_conn = event.replaced_conn;
+	if replaced_conn then
+		sessions[replaced_conn] = nil;
+		replaced_conn:close();
+	end
+end);
 
 function runner_callbacks:ready()
 	if self.data.conn then
@@ -293,10 +310,10 @@
 	if conn:ssl() then
 		session.secure = true;
 		session.encrypted = true;
+		session.ssl_ctx = conn:sslctx();
 
 		-- Check if TLS compression is used
-		local sock = conn:socket();
-		local info = sock.info and sock:info();
+		local info = conn:ssl_info();
 		if type(info) == "table" then
 			(session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher);
 			session.compressed = info.compression;
@@ -354,11 +371,13 @@
 		end
 	end
 
-	if c2s_timeout then
-		add_task(c2s_timeout, function ()
+	if c2s_timeout < math.huge then
+		session.c2s_timeout = add_task(c2s_timeout, function ()
 			if session.type == "c2s_unauthed" then
 				(session.log or log)("debug", "Connection still not authenticated after c2s_timeout=%gs, closing it", c2s_timeout);
 				session:close("connection-timeout");
+			else
+				session.c2s_timeout = nil;
 			end
 		end);
 	end
@@ -426,7 +445,7 @@
 
 module:hook("server-stopping", function(event) -- luacheck: ignore 212/event
 	-- Close ports
-	local pm = require "core.portmanager";
+	local pm = require "prosody.core.portmanager";
 	for _, netservice in pairs(module.items["net-provider"]) do
 		pm.unregister_service(netservice.name, netservice);
 	end
--- a/plugins/mod_carbons.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_carbons.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -3,9 +3,9 @@
 --
 -- This file is MIT/X11 licensed.
 
-local st = require "util.stanza";
-local jid_bare = require "util.jid".bare;
-local jid_resource = require "util.jid".resource;
+local st = require "prosody.util.stanza";
+local jid_bare = require "prosody.util.jid".bare;
+local jid_resource = require "prosody.util.jid".resource;
 local xmlns_carbons = "urn:xmpp:carbons:2";
 local xmlns_forward = "urn:xmpp:forward:0";
 local full_sessions, bare_sessions = prosody.full_sessions, prosody.bare_sessions;
--- a/plugins/mod_component.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_component.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -10,16 +10,16 @@
 
 local t_concat = table.concat;
 local tostring, type = tostring, type;
-local xpcall = require "util.xpcall".xpcall;
+local xpcall = require "prosody.util.xpcall".xpcall;
 local traceback = debug.traceback;
 
-local logger = require "util.logger";
-local sha1 = require "util.hashes".sha1;
-local st = require "util.stanza";
+local logger = require "prosody.util.logger";
+local sha1 = require "prosody.util.hashes".sha1;
+local st = require "prosody.util.stanza";
 
-local jid_split = require "util.jid".split;
-local new_xmpp_stream = require "util.xmppstream".new;
-local uuid_gen = require "util.uuid".generate;
+local jid_host = require "prosody.util.jid".host;
+local new_xmpp_stream = require "prosody.util.xmppstream".new;
+local uuid_gen = require "prosody.util.uuid".generate;
 
 local core_process_stanza = prosody.core_process_stanza;
 local hosts = prosody.hosts;
@@ -27,7 +27,8 @@
 local log = module._log;
 
 local opt_keepalives = module:get_option_boolean("component_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true));
-local stanza_size_limit = module:get_option_number("component_stanza_size_limit", module:get_option_number("s2s_stanza_size_limit", 1024*512));
+local stanza_size_limit = module:get_option_integer("component_stanza_size_limit",
+	module:get_option_integer("s2s_stanza_size_limit", 1024 * 512, 10000), 10000);
 
 local sessions = module:shared("sessions");
 
@@ -85,7 +86,7 @@
 		end
 
 		if env.connected then
-			local policy = module:get_option_string("component_conflict_resolve", "kick_new");
+			local policy = module:get_option_enum("component_conflict_resolve", "kick_new", "kick_old");
 			if policy == "kick_old" then
 				env.session:close{ condition = "conflict", text = "Replaced by a new connection" };
 			else -- kick_new
@@ -222,22 +223,19 @@
 	end
 	if not stanza.attr.xmlns or stanza.attr.xmlns == "jabber:client" then
 		local from = stanza.attr.from;
-		if from then
-			if session.component_validate_from then
-				local _, domain = jid_split(stanza.attr.from);
-				if domain ~= session.host then
-					-- Return error
-					session.log("warn", "Component sent stanza with missing or invalid 'from' address");
-					session:close{
-						condition = "invalid-from";
-						text = "Component tried to send from address <"..tostring(from)
-							   .."> which is not in domain <"..tostring(session.host)..">";
-					};
-					return;
-				end
+		if session.component_validate_from then
+			if not from or (jid_host(from) ~= session.host) then
+				-- Return error
+				session.log("warn", "Component sent stanza with missing or invalid 'from' address");
+				session:close{
+					condition = "invalid-from";
+					text = "Component tried to send from address <"..(from or "< [missing 'from' attribute] >")
+						   .."> which is not in domain <"..tostring(session.host)..">";
+				};
+				return;
 			end
-		else
-			stanza.attr.from = session.host; -- COMPAT: Strictly we shouldn't allow this
+		elseif not from then
+			stanza.attr.from = session.host;
 		end
 		if not stanza.attr.to then
 			session.log("warn", "Rejecting stanza with no 'to' address");
--- a/plugins/mod_cron.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_cron.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,9 +1,10 @@
 module:set_global();
 
-local async = require("util.async");
-local datetime = require("util.datetime");
+local async = require("prosody.util.async");
 
-local periods = { hourly = 3600; daily = 86400; weekly = 7 * 86400 }
+local cron_initial_delay = module:get_option_number("cron_initial_delay", 1);
+local cron_check_delay = module:get_option_number("cron_check_delay", 3600);
+local cron_spread_factor = module:get_option_number("cron_spread_factor", 0);
 
 local active_hosts = {}
 
@@ -14,18 +15,16 @@
 
 	local function save_task(task, started_at) last_run_times:set(nil, task.id, started_at); end
 
+	local function restore_task(task) if task.last == nil then task.last = last_run_times:get(nil, task.id); end end
+
 	local function task_added(event)
 		local task = event.item;
 		if task.name == nil then task.name = task.when; end
 		if task.id == nil then task.id = event.source.name .. "/" .. task.name:gsub("%W", "_"):lower(); end
-		if task.last == nil then task.last = last_run_times:get(nil, task.id); end
+		task.period = host_module:get_option_period(task.id:gsub("/", "_") .. "_period", "1" .. task.when, 60, 86400 * 7 * 53);
+		task.restore = restore_task;
 		task.save = save_task;
-		module:log("debug", "%s task %s added, last run %s", task.when, task.id,
-			task.last and datetime.datetime(task.last) or "never");
-		if task.last == nil then
-			local now = os.time();
-			task.last = now - now % periods[task.when];
-		end
+		module:log("debug", "%s task %s added", task.when, task.id);
 		return true
 	end
 
@@ -40,26 +39,55 @@
 	function host_module.unload() active_hosts[host_module.host] = nil; end
 end
 
-local function should_run(when, last) return not last or last + periods[when] * 0.995 <= os.time() end
+local function should_run(task, last) return not last or last + task.period * 0.995 <= os.time() end
 
 local function run_task(task)
+	task:restore();
+	if not should_run(task, task.last) then return end
 	local started_at = os.time();
 	task:run(started_at);
 	task.last = started_at;
 	task:save(started_at);
 end
 
+local function spread(t, factor)
+	return t * (1 - factor + 2*factor*math.random());
+end
+
 local task_runner = async.runner(run_task);
-scheduled = module:add_timer(1, function()
+scheduled = module:add_timer(cron_initial_delay, function()
 	module:log("info", "Running periodic tasks");
-	local delay = 3600;
+	local delay = spread(cron_check_delay, cron_spread_factor);
 	for host in pairs(active_hosts) do
 		module:log("debug", "Running periodic tasks for host %s", host);
-		for _, task in ipairs(module:context(host):get_host_items("task")) do
-			module:log("debug", "Considering %s task %s (%s)", task.when, task.id, task.run);
-			if should_run(task.when, task.last) then task_runner:run(task); end
-		end
+		for _, task in ipairs(module:context(host):get_host_items("task")) do task_runner:run(task); end
 	end
-	module:log("debug", "Wait %ds", delay);
+	module:log("debug", "Wait %gs", delay);
 	return delay
 end);
+
+module:add_item("shell-command", {
+	section = "cron";
+	section_desc = "View and manage recurring tasks";
+	name = "tasks";
+	desc = "View registered tasks";
+	args = {};
+	handler = function(self, filter_host)
+		local format_table = require("prosody.util.human.io").table;
+		local it = require("util.iterators");
+		local row = format_table({
+			{ title = "Host"; width = "2p" };
+			{ title = "Task"; width = "3p" };
+			{ title = "Desc"; width = "3p" };
+			{ title = "When"; width = "1p" };
+			{ title = "Last run"; width = "20" };
+		}, self.session.width);
+		local print = self.session.print;
+		print(row());
+		for host in it.sorted_pairs(filter_host and { [filter_host] = true } or active_hosts) do
+			for _, task in ipairs(module:context(host):get_host_items("task")) do
+				print(row({ host; task.id; task.name; task.when; task.last and os.date("%Y-%m-%d %R:%S", task.last) or "never" }));
+			end
+		end
+	end;
+});
--- a/plugins/mod_csi.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_csi.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,10 +1,11 @@
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 local xmlns_csi = "urn:xmpp:csi:0";
 local csi_feature = st.stanza("csi", { xmlns = xmlns_csi });
 
-local csi_handler_available = nil;
+local change = module:metric("counter", "changes", "events", "CSI state changes", {"csi_state"});
+
 module:hook("stream-features", function (event)
-	if event.origin.username and csi_handler_available then
+	if event.origin.username then
 		event.features:add_child(csi_feature);
 	end
 end);
@@ -13,6 +14,7 @@
 	return function (event)
 		if event.origin.username then
 			event.origin.state = event.stanza.name;
+			change:with_labels(event.stanza.name):add(1);
 			module:fire_event(name, event);
 			return true;
 		end
@@ -21,15 +23,3 @@
 
 module:hook("stanza/"..xmlns_csi..":active", refire_event("csi-client-active"));
 module:hook("stanza/"..xmlns_csi..":inactive", refire_event("csi-client-inactive"));
-
-function module.load()
-	if prosody.hosts[module.host].events._handlers["csi-client-active"] then
-		csi_handler_available = true;
-		module:set_status("core", "CSI handler module loaded");
-	else
-		csi_handler_available = false;
-		module:set_status("warn", "No CSI handler module loaded");
-	end
-end
-module:hook("module-loaded", module.load);
-module:hook("module-unloaded", module.load);
--- a/plugins/mod_csi_simple.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_csi_simple.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,14 +6,14 @@
 
 module:depends"csi"
 
-local jid = require "util.jid";
-local st = require "util.stanza";
-local dt = require "util.datetime";
-local filters = require "util.filters";
-local timer = require "util.timer";
+local jid = require "prosody.util.jid";
+local st = require "prosody.util.stanza";
+local dt = require "prosody.util.datetime";
+local filters = require "prosody.util.filters";
+local timer = require "prosody.util.timer";
 
-local queue_size = module:get_option_number("csi_queue_size", 256);
-local resume_delay = module:get_option_number("csi_resume_inactive_delay", 5);
+local queue_size = module:get_option_integer("csi_queue_size", 256, 1);
+local resume_delay = module:get_option_period("csi_resume_inactive_delay", 5);
 
 local important_payloads = module:get_option_set("csi_important_payloads", { });
 
@@ -116,6 +116,9 @@
 	{ "reason" }
 );
 
+local flush_sizes = module:metric("histogram", "flush_stanza_count", "", "Number of stanzas flushed at once", {},
+	{ buckets = { 0, 1, 2, 4, 8, 16, 32, 64, 128, 256 } }):with_labels();
+
 local function manage_buffer(stanza, session)
 	local ctr = session.csi_counter or 0;
 	if session.state ~= "inactive" then
@@ -129,6 +132,7 @@
 			session.csi_measure_buffer_hold = nil;
 		end
 		flush_reasons:with_labels(why or "important"):add(1);
+		flush_sizes:sample(ctr);
 		session.log("debug", "Flushing buffer (%s; queue size is %d)", why or "important", session.csi_counter);
 		session.state = "flushing";
 		module:fire_event("csi-flushing", { session = session });
@@ -147,6 +151,7 @@
 	session.log("debug", "Flushing buffer (%s; queue size is %d)", "client activity", session.csi_counter);
 	session.state = "flushing";
 	module:fire_event("csi-flushing", { session = session });
+	flush_sizes:sample(ctr);
 	flush_reasons:with_labels("client activity"):add(1);
 	if session.csi_measure_buffer_hold then
 		session.csi_measure_buffer_hold();
@@ -258,7 +263,7 @@
 		return 1;
 	end
 	-- luacheck: ignore 212/self
-	local xmppstream = require "util.xmppstream";
+	local xmppstream = require "prosody.util.xmppstream";
 	local input_session = { notopen = true }
 	local stream_callbacks = { stream_ns = "jabber:client", default_ns = "jabber:client" };
 	function stream_callbacks:handlestanza(stanza)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_debug_reset.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,36 @@
+-- This module will "reset" the server when the client connection count drops
+-- to zero. This is somewhere between a reload and a full process restart.
+-- It is useful to ensure isolation between test runs, for example. It may
+-- also be of use for some kinds of manual testing.
+
+module:set_global();
+
+local hostmanager = require "prosody.core.hostmanager";
+
+local function do_reset()
+	module:log("info", "Performing reset...");
+	local hosts = {};
+	for host in pairs(prosody.hosts) do
+		table.insert(hosts, host);
+	end
+	module:fire_event("server-resetting");
+	for _, host in ipairs(hosts) do
+		hostmanager.deactivate(host);
+		hostmanager.activate(host);
+		module:log("info", "Reset complete");
+		module:fire_event("server-reset");
+	end
+end
+
+function module.add_host(host_module)
+	host_module:hook("resource-unbind", function ()
+		if next(prosody.full_sessions) == nil then
+			do_reset();
+		end
+	end);
+end
+
+local console_env = module:shared("/*/admin_shell/env");
+console_env.debug_reset = {
+	reset = do_reset;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_debug_stanzas/watcher.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,220 @@
+local filters = require "prosody.util.filters";
+local jid = require "prosody.util.jid";
+local set = require "prosody.util.set";
+
+local client_watchers = {};
+
+-- active_filters[session] = {
+--   filter_func = filter_func;
+--   downstream = { cb1, cb2, ... };
+-- }
+local active_filters = {};
+
+local function subscribe_session_stanzas(session, handler, reason)
+	if active_filters[session] then
+		table.insert(active_filters[session].downstream, handler);
+		if reason then
+			handler(reason, nil, session);
+		end
+		return;
+	end
+	local downstream = { handler };
+	active_filters[session] = {
+		filter_in = function (stanza)
+			module:log("debug", "NOTIFY WATCHER %d", #downstream);
+			for i = 1, #downstream do
+				downstream[i]("received", stanza, session);
+			end
+			return stanza;
+		end;
+		filter_out = function (stanza)
+			module:log("debug", "NOTIFY WATCHER %d", #downstream);
+			for i = 1, #downstream do
+				downstream[i]("sent", stanza, session);
+			end
+			return stanza;
+		end;
+		downstream = downstream;
+	};
+	filters.add_filter(session, "stanzas/in", active_filters[session].filter_in);
+	filters.add_filter(session, "stanzas/out", active_filters[session].filter_out);
+	if reason then
+		handler(reason, nil, session);
+	end
+end
+
+local function unsubscribe_session_stanzas(session, handler, reason)
+	local active_filter = active_filters[session];
+	if not active_filter then
+		return;
+	end
+	for i = #active_filter.downstream, 1, -1 do
+		if active_filter.downstream[i] == handler then
+			table.remove(active_filter.downstream, i);
+			if reason then
+				handler(reason, nil, session);
+			end
+		end
+	end
+	if #active_filter.downstream == 0 then
+		filters.remove_filter(session, "stanzas/in", active_filter.filter_in);
+		filters.remove_filter(session, "stanzas/out", active_filter.filter_out);
+	end
+	active_filters[session] = nil;
+end
+
+local function unsubscribe_all_from_session(session, reason)
+	local active_filter = active_filters[session];
+	if not active_filter then
+		return;
+	end
+	for i = #active_filter.downstream, 1, -1 do
+		local handler = table.remove(active_filter.downstream, i);
+		if reason then
+			handler(reason, nil, session);
+		end
+	end
+	filters.remove_filter(session, "stanzas/in", active_filter.filter_in);
+	filters.remove_filter(session, "stanzas/out", active_filter.filter_out);
+	active_filters[session] = nil;
+end
+
+local function unsubscribe_handler_from_all(handler, reason)
+	for session in pairs(active_filters) do
+		unsubscribe_session_stanzas(session, handler, reason);
+	end
+end
+
+local s2s_watchers = {};
+
+module:hook("s2sin-established", function (event)
+	for _, watcher in ipairs(s2s_watchers) do
+		if watcher.target_spec == event.session.from_host then
+			subscribe_session_stanzas(event.session, watcher.handler, "opened");
+		end
+	end
+end);
+
+module:hook("s2sout-established", function (event)
+	for _, watcher in ipairs(s2s_watchers) do
+		if watcher.target_spec == event.session.to_host then
+			subscribe_session_stanzas(event.session, watcher.handler, "opened");
+		end
+	end
+end);
+
+module:hook("s2s-closed", function (event)
+	unsubscribe_all_from_session(event.session, "closed");
+end);
+
+local watched_hosts = set.new();
+
+local handler_map = setmetatable({}, { __mode = "kv" });
+
+local function add_stanza_watcher(spec, orig_handler)
+	local function filtering_handler(event_type, stanza, session)
+		if stanza and spec.filter_spec then
+			if spec.filter_spec.with_jid then
+				if event_type == "sent" and (not stanza.attr.from or not jid.compare(stanza.attr.from, spec.filter_spec.with_jid)) then
+					return;
+				elseif event_type == "received" and (not stanza.attr.to or not jid.compare(stanza.attr.to, spec.filter_spec.with_jid)) then
+					return;
+				end
+			end
+		end
+		return orig_handler(event_type, stanza, session);
+	end
+	handler_map[orig_handler] = filtering_handler;
+	if spec.target_spec.jid then
+		local target_is_remote_host = not jid.node(spec.target_spec.jid) and not prosody.hosts[spec.target_spec.jid];
+
+		if target_is_remote_host then
+			-- Watch s2s sessions
+			table.insert(s2s_watchers, {
+				target_spec = spec.target_spec.jid;
+				handler = filtering_handler;
+				orig_handler = orig_handler;
+			});
+
+			-- Scan existing s2sin for matches
+			for session in pairs(prosody.incoming_s2s) do
+				if spec.target_spec.jid == session.from_host then
+					subscribe_session_stanzas(session, filtering_handler, "attached");
+				end
+			end
+			-- Scan existing s2sout for matches
+			for local_host, local_session in pairs(prosody.hosts) do --luacheck: ignore 213/local_host
+				for remote_host, remote_session in pairs(local_session.s2sout) do
+					if spec.target_spec.jid == remote_host then
+						subscribe_session_stanzas(remote_session, filtering_handler, "attached");
+					end
+				end
+			end
+		else
+			table.insert(client_watchers, {
+				target_spec = spec.target_spec.jid;
+				handler = filtering_handler;
+				orig_handler = orig_handler;
+			});
+			local host = jid.host(spec.target_spec.jid);
+			if not watched_hosts:contains(host) and prosody.hosts[host] then
+				module:context(host):hook("resource-bind", function (event)
+					for _, watcher in ipairs(client_watchers) do
+						module:log("debug", "NEW CLIENT: %s vs %s", event.session.full_jid, watcher.target_spec);
+						if jid.compare(event.session.full_jid, watcher.target_spec) then
+							module:log("debug", "MATCH");
+							subscribe_session_stanzas(event.session, watcher.handler, "opened");
+						else
+							module:log("debug", "NO MATCH");
+						end
+					end
+				end);
+
+				module:context(host):hook("resource-unbind", function (event)
+					unsubscribe_all_from_session(event.session, "closed");
+				end);
+
+				watched_hosts:add(host);
+			end
+			for full_jid, session in pairs(prosody.full_sessions) do
+				if jid.compare(full_jid, spec.target_spec.jid) then
+					subscribe_session_stanzas(session, filtering_handler, "attached");
+				end
+			end
+		end
+	else
+		error("No recognized target selector");
+	end
+end
+
+local function remove_stanza_watcher(orig_handler)
+	local handler = handler_map[orig_handler];
+	unsubscribe_handler_from_all(handler, "detached");
+	handler_map[orig_handler] = nil;
+
+	for i = #client_watchers, 1, -1 do
+		if client_watchers[i].orig_handler == orig_handler then
+			table.remove(client_watchers, i);
+		end
+	end
+
+	for i = #s2s_watchers, 1, -1 do
+		if s2s_watchers[i].orig_handler == orig_handler then
+			table.remove(s2s_watchers, i);
+		end
+	end
+end
+
+local function cleanup(reason)
+	client_watchers = {};
+	s2s_watchers = {};
+	for session in pairs(active_filters) do
+		unsubscribe_all_from_session(session, reason or "cancelled");
+	end
+end
+
+return {
+	add = add_stanza_watcher;
+	remove = remove_stanza_watcher;
+	cleanup = cleanup;
+};
--- a/plugins/mod_dialback.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_dialback.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -10,12 +10,12 @@
 
 local log = module._log;
 
-local st = require "util.stanza";
-local sha256_hash = require "util.hashes".sha256;
-local sha256_hmac = require "util.hashes".hmac_sha256;
-local secure_equals = require "util.hashes".equals;
-local nameprep = require "util.encodings".stringprep.nameprep;
-local uuid_gen = require"util.uuid".generate;
+local st = require "prosody.util.stanza";
+local sha256_hash = require "prosody.util.hashes".sha256;
+local sha256_hmac = require "prosody.util.hashes".hmac_sha256;
+local secure_equals = require "prosody.util.hashes".equals;
+local nameprep = require "prosody.util.encodings".stringprep.nameprep;
+local uuid_gen = require"prosody.util.uuid".generate;
 
 local xmlns_stream = "http://etherx.jabber.org/streams";
 
--- a/plugins/mod_disco.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_disco.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,13 +6,12 @@
 -- COPYING file in the source package for more information.
 --
 
-local get_children = require "core.hostmanager".get_children;
-local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
-local um_is_admin = require "core.usermanager".is_admin;
-local jid_split = require "util.jid".split;
-local jid_bare = require "util.jid".bare;
-local st = require "util.stanza"
-local calculate_hash = require "util.caps".calculate_hash;
+local get_children = require "prosody.core.hostmanager".get_children;
+local is_contact_subscribed = require "prosody.core.rostermanager".is_contact_subscribed;
+local jid_split = require "prosody.util.jid".split;
+local jid_bare = require "prosody.util.jid".bare;
+local st = require "prosody.util.stanza"
+local calculate_hash = require "prosody.util.caps".calculate_hash;
 
 local expose_admins = module:get_option_boolean("disco_expose_admins", false);
 
@@ -162,14 +161,16 @@
 	end
 end);
 
+module:default_permission("prosody:admin", ":be-discovered-admin");
+
 -- Handle disco requests to user accounts
 if module:get_host_type() ~= "local" then	return end -- skip for components
 module:hook("iq-get/bare/http://jabber.org/protocol/disco#info:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
 	local node = stanza.tags[1].attr.node;
 	local username = jid_split(stanza.attr.to) or origin.username;
-	local is_admin = um_is_admin(stanza.attr.to or origin.full_jid, module.host)
-	if not stanza.attr.to or (expose_admins and is_admin) or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
+	local target_is_admin = module:may(":be-discovered-admin", stanza.attr.to or origin.full_jid);
+	if not stanza.attr.to or (expose_admins and target_is_admin) or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
 		if node and node ~= "" then
 			local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#info', node=node});
 			reply:tag("feature", { var = "http://jabber.org/protocol/disco#info" }):up();
@@ -187,7 +188,7 @@
 		end
 		local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#info'});
 		if not reply.attr.from then reply.attr.from = origin.username.."@"..origin.host; end -- COMPAT To satisfy Psi when querying own account
-		if is_admin then
+		if target_is_admin then
 			reply:tag('identity', {category='account', type='admin'}):up();
 		elseif prosody.hosts[module.host].users.name == "anonymous" then
 			reply:tag('identity', {category='account', type='anonymous'}):up();
--- a/plugins/mod_external_services.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_external_services.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,22 +1,22 @@
 
-local dt = require "util.datetime";
-local base64 = require "util.encodings".base64;
-local hashes = require "util.hashes";
-local st = require "util.stanza";
-local jid = require "util.jid";
-local array = require "util.array";
-local set = require "util.set";
+local dt = require "prosody.util.datetime";
+local base64 = require "prosody.util.encodings".base64;
+local hashes = require "prosody.util.hashes";
+local st = require "prosody.util.stanza";
+local jid = require "prosody.util.jid";
+local array = require "prosody.util.array";
+local set = require "prosody.util.set";
 
 local default_host = module:get_option_string("external_service_host", module.host);
-local default_port = module:get_option_number("external_service_port");
+local default_port = module:get_option_integer("external_service_port", nil, 1, 65535);
 local default_secret = module:get_option_string("external_service_secret");
-local default_ttl = module:get_option_number("external_service_ttl", 86400);
+local default_ttl = module:get_option_period("external_service_ttl", "1 day");
 
 local configured_services = module:get_option_array("external_services", {});
 
 local access = module:get_option_set("external_service_access", {});
 
--- https://tools.ietf.org/html/draft-uberti-behave-turn-rest-00
+-- https://datatracker.ietf.org/doc/html/draft-uberti-behave-turn-rest-00
 local function behave_turn_rest_credentials(srv, item, secret)
 	local ttl = default_ttl;
 	if type(item.ttl) == "number" then
--- a/plugins/mod_groups.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_groups.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -10,8 +10,8 @@
 local groups;
 local members;
 
-local datamanager = require "util.datamanager";
-local jid_prep = require "util.jid".prep;
+local datamanager = require "prosody.util.datamanager";
+local jid_prep = require "prosody.util.jid".prep;
 
 local module_host = module:get_host();
 
--- a/plugins/mod_http.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_http.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -11,24 +11,26 @@
 	module:depends("http_errors");
 end);
 
-local portmanager = require "core.portmanager";
-local moduleapi = require "core.moduleapi";
+local portmanager = require "prosody.core.portmanager";
+local moduleapi = require "prosody.core.moduleapi";
 local url_parse = require "socket.url".parse;
 local url_build = require "socket.url".build;
-local normalize_path = require "util.http".normalize_path;
-local set = require "util.set";
+local http_util = require "prosody.util.http";
+local normalize_path = http_util.normalize_path;
+local set = require "prosody.util.set";
+local array = require "prosody.util.array";
 
-local ip_util = require "util.ip";
+local ip_util = require "prosody.util.ip";
 local new_ip = ip_util.new_ip;
 local match_ip = ip_util.match;
 local parse_cidr = ip_util.parse_cidr;
 
-local server = require "net.http.server";
+local server = require "prosody.net.http.server";
 
 server.set_default_host(module:get_option_string("http_default_host"));
 
-server.set_option("body_size_limit", module:get_option_number("http_max_content_size"));
-server.set_option("buffer_size_limit", module:get_option_number("http_max_buffer_size"));
+server.set_option("body_size_limit", module:get_option_number("http_max_content_size", nil, 0));
+server.set_option("buffer_size_limit", module:get_option_number("http_max_buffer_size", nil, 0));
 
 -- CORS settings
 local cors_overrides = module:get_option("http_cors_override", {});
@@ -36,7 +38,7 @@
 local opt_headers = module:get_option_set("access_control_allow_headers", { "Content-Type" });
 local opt_origins = module:get_option_set("access_control_allow_origins");
 local opt_credentials = module:get_option_boolean("access_control_allow_credentials", false);
-local opt_max_age = module:get_option_number("access_control_max_age", 2 * 60 * 60);
+local opt_max_age = module:get_option_period("access_control_max_age", "2 hours");
 local opt_default_cors = module:get_option_boolean("http_default_cors_enabled", true);
 
 local function get_http_event(host, app_path, key)
@@ -75,11 +77,12 @@
 local ports_by_scheme = { http = 80, https = 443, };
 
 -- Helper to deduce a module's external URL
-function moduleapi.http_url(module, app_name, default_path)
+function moduleapi.http_url(module, app_name, default_path, mode)
 	app_name = app_name or (module.name:gsub("^http_", ""));
 
 	local external_url = url_parse(module:get_option_string("http_external_url"));
-	if external_url then
+	if external_url and mode ~= "internal" then
+		-- Current URL does not depend on knowing which ports are used, only configuration.
 		local url = {
 			scheme = external_url.scheme;
 			host = external_url.host;
@@ -91,6 +94,36 @@
 		return url_build(url);
 	end
 
+	if prosody.process_type ~= "prosody" then
+		-- We generally don't open ports outside of Prosody, so we can't rely on
+		-- portmanager to tell us which ports and services are used and derive the
+		-- URL from that, so instead we derive it entirely from configuration.
+		local https_ports = module:get_option_array("https_ports", { 5281 });
+		local scheme = "https";
+		local port = tonumber(https_ports[1]);
+		if not port then
+			-- https is disabled and no http_external_url set
+			scheme = "http";
+			local http_ports = module:get_option_array("http_ports", { 5280 });
+			port = tonumber(http_ports[1]);
+			if not port then
+				return "http://disabled.invalid/";
+			end
+		end
+
+		local url = {
+			scheme = scheme;
+			host = module:get_option_string("http_host", module.global and module:get_option_string("http_default_host") or module.host);
+			port = port;
+			path = get_base_path(module, app_name, default_path or "/" .. app_name);
+		}
+		if ports_by_scheme[url.scheme] == url.port then
+			url.port = nil
+		end
+		return url_build(url);
+	end
+
+	-- Use portmanager to find the actual port of https or http services
 	local services = portmanager.get_active_services();
 	local http_services = services:get("https") or services:get("http") or {};
 	for interface, ports in pairs(http_services) do -- luacheck: ignore 213/interface
@@ -112,12 +145,16 @@
 	return "http://disabled.invalid/";
 end
 
+local function header_set_tostring(header_value)
+	return array(header_value:items()):concat(", ");
+end
+
 local function apply_cors_headers(response, methods, headers, max_age, allow_credentials, allowed_origins, origin)
 	if allowed_origins and not allowed_origins[origin] then
 		return;
 	end
-	response.headers.access_control_allow_methods = tostring(methods);
-	response.headers.access_control_allow_headers = tostring(headers);
+	response.headers.access_control_allow_methods = header_set_tostring(methods);
+	response.headers.access_control_allow_headers = header_set_tostring(headers);
 	response.headers.access_control_max_age = tostring(max_age)
 	response.headers.access_control_allow_origin = origin or "*";
 	if allow_credentials then
@@ -292,7 +329,13 @@
 
 local trusted_proxies = module:get_option_set("trusted_proxies", { "127.0.0.1", "::1" })._items;
 
+--- deal with [ipv6]:port / ip:port format
+local function normal_ip(ip)
+	return ip:match("^%[([%x:]*)%]") or ip:match("^([%d.]+)") or ip;
+end
+
 local function is_trusted_proxy(ip)
+	ip = normal_ip(ip);
 	if trusted_proxies[ip] then
 		return true;
 	end
@@ -308,6 +351,30 @@
 local function get_forwarded_connection_info(request) --> ip:string, secure:boolean
 	local ip = request.ip;
 	local secure = request.secure; -- set by net.http.server
+
+	local forwarded = http_util.parse_forwarded(request.headers.forwarded);
+	if forwarded then
+		request.forwarded = forwarded;
+		for i = #forwarded, 1, -1 do
+			local proxy = forwarded[i]
+			if is_trusted_proxy(ip) then
+				ip = normal_ip(proxy["for"]);
+				secure = secure and proxy.proto == "https";
+			else
+				break
+			end
+		end
+	end
+
+	return ip, secure;
+end
+
+-- TODO switch to RFC 7239 by default once support is more common
+if module:get_option_boolean("http_legacy_x_forwarded", true) then
+function get_forwarded_connection_info(request) --> ip:string, secure:boolean
+	local ip = request.ip;
+	local secure = request.secure; -- set by net.http.server
+
 	local forwarded_for = request.headers.x_forwarded_for;
 	if forwarded_for then
 		-- luacheck: ignore 631
@@ -330,6 +397,7 @@
 
 	return ip, secure;
 end
+end
 
 module:wrap_object_event(server._events, false, function (handlers, event_name, event_data)
 	local request = event_data.request;
--- a/plugins/mod_http_errors.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_http_errors.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,9 +1,9 @@
 module:set_global();
 
-local server = require "net.http.server";
-local codes = require "net.http.codes";
-local xml_escape = require "util.stanza".xml_escape;
-local render = require "util.interpolation".new("%b{}", xml_escape);
+local server = require "prosody.net.http.server";
+local codes = require "prosody.net.http.codes";
+local xml_escape = require "prosody.util.stanza".xml_escape;
+local render = require "prosody.util.interpolation".new("%b{}", xml_escape);
 
 local show_private = module:get_option_boolean("http_errors_detailed", false);
 local always_serve = module:get_option_boolean("http_errors_always_show", true);
@@ -35,13 +35,13 @@
 <meta charset="utf-8">
 <title>{title}</title>
 <style>
-body{margin-top:14%;text-align:center;background-color:#f8f8f8;font-family:sans-serif}
+:root{color-scheme:light dark}
+body{margin-top:14%;text-align:center;font-family:sans-serif}
 h1{font-size:xx-large}
 p{font-size:x-large}
 p.warning>span{font-size:large;background-color:yellow}
 p.extra{font-size:large;font-family:courier}
 @media(prefers-color-scheme:dark){
-body{background-color:#161616;color:#eee}
 p.warning>span{background-color:inherit;color:yellow}
 }
 </style>
--- a/plugins/mod_http_file_share.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_http_file_share.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,17 +8,16 @@
 -- Again, from the top!
 
 local t_insert = table.insert;
-local jid = require "util.jid";
-local st = require "util.stanza";
+local jid = require "prosody.util.jid";
+local st = require "prosody.util.stanza";
 local url = require "socket.url";
-local dm = require "core.storagemanager".olddm;
-local jwt = require "util.jwt";
-local errors = require "util.error";
-local dataform = require "util.dataforms".new;
-local urlencode = require "util.http".urlencode;
-local dt = require "util.datetime";
-local hi = require "util.human.units";
-local cache = require "util.cache";
+local dm = require "prosody.core.storagemanager".olddm;
+local errors = require "prosody.util.error";
+local dataform = require "prosody.util.dataforms".new;
+local urlencode = require "prosody.util.http".urlencode;
+local dt = require "prosody.util.datetime";
+local hi = require "prosody.util.human.units";
+local cache = require "prosody.util.cache";
 local lfs = require "lfs";
 
 local unknown = math.abs(0/0);
@@ -35,17 +34,21 @@
 local persist_stats = module:open_store("upload_stats", "map");
 -- id, <request>, time, owner
 
-local secret = module:get_option_string(module.name.."_secret", require"util.id".long());
+local secret = module:get_option_string(module.name.."_secret", require"prosody.util.id".long());
 local external_base_url = module:get_option_string(module.name .. "_base_url");
-local file_size_limit = module:get_option_number(module.name .. "_size_limit", 10 * 1024 * 1024); -- 10 MB
+local file_size_limit = module:get_option_integer(module.name .. "_size_limit", 10 * 1024 * 1024, 0); -- 10 MB
 local file_types = module:get_option_set(module.name .. "_allowed_file_types", {});
 local safe_types = module:get_option_set(module.name .. "_safe_file_types", {"image/*","video/*","audio/*","text/plain"});
-local expiry = module:get_option_number(module.name .. "_expires_after", 7 * 86400);
-local daily_quota = module:get_option_number(module.name .. "_daily_quota", file_size_limit*10); -- 100 MB / day
-local total_storage_limit = module:get_option_number(module.name.."_global_quota", unlimited);
+local expiry = module:get_option_period(module.name .. "_expires_after", "1w");
+local daily_quota = module:get_option_integer(module.name .. "_daily_quota", file_size_limit*10, 0); -- 100 MB / day
+local total_storage_limit = module:get_option_integer(module.name.."_global_quota", unlimited, 0);
+
+local create_jwt, verify_jwt = require"prosody.util.jwt".init("HS256", secret, secret, { default_ttl = 600 });
 
 local access = module:get_option_set(module.name .. "_access", {});
 
+module:default_permission("prosody:registered", ":upload");
+
 if not external_base_url then
 	module:depends("http");
 end
@@ -76,12 +79,12 @@
 local measure_quota_cache_size = module:measure("quota_cache", "amount");
 local measure_total_storage_usage = module:measure("total_storage", "amount", { unit = "bytes" });
 
-do
+module:on_ready(function ()
 	local total, err = persist_stats:get(nil, "total");
 	if not err then
 		total_storage_usage = tonumber(total) or 0;
 	end
-end
+end)
 
 module:hook_global("stats-update", function ()
 	measure_upload_cache_size(upload_cache:count());
@@ -135,7 +138,7 @@
 
 function may_upload(uploader, filename, filesize, filetype) -- > boolean, error
 	local uploader_host = jid.host(uploader);
-	if not ((access:empty() and prosody.hosts[uploader_host]) or access:contains(uploader) or access:contains(uploader_host)) then
+	if not (module:may(":upload", uploader) or access:contains(uploader) or access:contains(uploader_host)) then
 		return false, upload_errors.new("access");
 	end
 
@@ -169,16 +172,13 @@
 end
 
 function get_authz(slot, uploader, filename, filesize, filetype)
-local now = os.time();
-	return jwt.sign(secret, {
+	return create_jwt({
 		-- token properties
 		sub = uploader;
-		iat = now;
-		exp = now+300;
 
 		-- slot properties
 		slot = slot;
-		expires = expiry >= 0 and (now+expiry) or nil;
+		expires = expiry < math.huge and (os.time()+expiry) or nil;
 		-- file properties
 		filename = filename;
 		filesize = filesize;
@@ -249,32 +249,34 @@
 
 function handle_upload(event, path) -- PUT /upload/:slot
 	local request = event.request;
-	local authz = request.headers.authorization;
-	if authz then
-		authz = authz:match("^Bearer (.*)")
-	end
-	if not authz then
-		module:log("debug", "Missing or malformed Authorization header");
-		event.response.headers.www_authenticate = "Bearer";
-		return 401;
-	end
-	local authed, upload_info = jwt.verify(secret, authz);
-	if not (authed and type(upload_info) == "table" and type(upload_info.exp) == "number") then
-		module:log("debug", "Unauthorized or invalid token: %s, %q", authed, upload_info);
-		return 401;
-	end
-	if not request.body_sink and upload_info.exp < os.time() then
-		module:log("debug", "Authorization token expired on %s", dt.datetime(upload_info.exp));
-		return 410;
-	end
-	if not path or upload_info.slot ~= path:match("^[^/]+") then
-		module:log("debug", "Invalid upload slot: %q, path: %q", upload_info.slot, path);
-		return 400;
-	end
-	if request.headers.content_length and tonumber(request.headers.content_length) ~= upload_info.filesize then
-		return 413;
-		-- Note: We don't know the size if the upload is streamed in chunked encoding,
-		-- so we also check the final file size on completion.
+	local upload_info = request.http_file_share_upload_info;
+
+	if not upload_info then -- Initial handling of request
+		local authz = request.headers.authorization;
+		if authz then
+			authz = authz:match("^Bearer (.*)")
+		end
+		if not authz then
+			module:log("debug", "Missing or malformed Authorization header");
+			event.response.headers.www_authenticate = "Bearer";
+			return 401;
+		end
+		local authed, authed_upload_info = verify_jwt(authz);
+		if not authed then
+			module:log("debug", "Unauthorized or invalid token: %s, %q", authz, authed_upload_info);
+			return 401;
+		end
+		if not path or authed_upload_info.slot ~= path:match("^[^/]+") then
+			module:log("debug", "Invalid upload slot: %q, path: %q", authed_upload_info.slot, path);
+			return 400;
+		end
+		if request.headers.content_length and tonumber(request.headers.content_length) ~= authed_upload_info.filesize then
+			return 413;
+			-- Note: We don't know the size if the upload is streamed in chunked encoding,
+			-- so we also check the final file size on completion.
+		end
+		upload_info = authed_upload_info;
+		request.http_file_share_upload_info = upload_info;
 	end
 
 	local filename = get_filename(upload_info.slot, true);
@@ -452,9 +454,9 @@
 
 if expiry >= 0 and not external_base_url then
 	-- TODO HTTP DELETE to the external endpoint?
-	local array = require "util.array";
-	local async = require "util.async";
-	local ENOENT = require "util.pposix".ENOENT;
+	local array = require "prosody.util.array";
+	local async = require "prosody.util.async";
+	local ENOENT = require "prosody.util.pposix".ENOENT;
 
 	local function sleep(t)
 		local wait, done = async.waiter();
--- a/plugins/mod_http_files.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_http_files.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -9,11 +9,11 @@
 module:depends("http");
 
 local open = io.open;
-local fileserver = require"net.http.files";
+local fileserver = require"prosody.net.http.files";
 
 local base_path = module:get_option_path("http_files_dir", module:get_option_path("http_path"));
-local cache_size = module:get_option_number("http_files_cache_size", 128);
-local cache_max_file_size = module:get_option_number("http_files_cache_max_file_size", 4096);
+local cache_size = module:get_option_integer("http_files_cache_size", 128, 1);
+local cache_max_file_size = module:get_option_integer("http_files_cache_max_file_size", 4096, 1);
 local dir_indices = module:get_option_array("http_index_files", { "index.html", "index.htm" });
 local directory_index = module:get_option_boolean("http_dir_listing");
 
@@ -74,12 +74,12 @@
 	if opts.index_files == nil then
 		opts.index_files = dir_indices;
 	end
-	module:log("warn", "%s should be updated to use 'net.http.files' instead of mod_http_files", get_calling_module());
+	module:log("warn", "%s should be updated to use 'prosody.net.http.files' instead of mod_http_files", get_calling_module());
 	return fileserver.serve(opts);
 end
 
 function wrap_route(routes)
-	module:log("debug", "%s should be updated to use 'net.http.files' instead of mod_http_files", get_calling_module());
+	module:log("debug", "%s should be updated to use 'prosody.net.http.files' instead of mod_http_files", get_calling_module());
 	for route,handler in pairs(routes) do
 		if type(handler) ~= "function" then
 			routes[route] = fileserver.serve(handler);
--- a/plugins/mod_http_openmetrics.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_http_openmetrics.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,8 +8,8 @@
 
 module:set_global();
 
-local statsman = require "core.statsmanager";
-local ip = require "util.ip";
+local statsman = require "prosody.core.statsmanager";
+local ip = require "prosody.util.ip";
 
 local get_metric_registry = statsman.get_metric_registry;
 local collect = statsman.collect;
--- a/plugins/mod_invites.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_invites.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,10 +1,12 @@
-local id = require "util.id";
-local it = require "util.iterators";
+local id = require "prosody.util.id";
+local it = require "prosody.util.iterators";
 local url = require "socket.url";
-local jid_node = require "util.jid".node;
-local jid_split = require "util.jid".split;
+local jid_node = require "prosody.util.jid".node;
+local jid_split = require "prosody.util.jid".split;
+local argparse = require "prosody.util.argparse";
+local human_io = require "prosody.util.human.io";
 
-local default_ttl = module:get_option_number("invite_expiry", 86400 * 7);
+local default_ttl = module:get_option_period("invite_expiry", "1 week");
 
 local token_storage;
 if prosody.process_type == "prosody" or prosody.shutdown then
@@ -201,53 +203,103 @@
 end
 
 --- shell command
-do
-	-- Since the console is global this overwrites the command for
-	-- each host it's loaded on, but this should be fine.
-
-	local get_module = require "core.modulemanager".get_module;
-
-	local console_env = module:shared("/*/admin_shell/env");
+module:add_item("shell-command", {
+	section = "invite";
+	section_desc = "Create and manage invitations";
+	name = "create_account";
+	desc = "Create an invitation to make an account on this server with the specified JID (supply only a hostname to allow any username)";
+	args = { { name = "user_jid", type = "string" } };
+	host_selector = "user_jid";
 
-	-- luacheck: ignore 212/self
-	console_env.invite = {};
-	function console_env.invite:create_account(user_jid)
-		local username, host = jid_split(user_jid);
-		local mod_invites, err = get_module(host, "invites");
-		if not mod_invites then return nil, err or "mod_invites not loaded on this host"; end
-		local invite, err = mod_invites.create_account(username);
+	handler = function (self, user_jid) --luacheck: ignore 212/self
+		local username = jid_split(user_jid);
+		local invite, err = create_account(username);
 		if not invite then return nil, err; end
 		return true, invite.landing_page or invite.uri;
-	end
+	end;
+});
 
-	function console_env.invite:create_contact(user_jid, allow_registration)
-		local username, host = jid_split(user_jid);
-		local mod_invites, err = get_module(host, "invites");
-		if not mod_invites then return nil, err or "mod_invites not loaded on this host"; end
-		local invite, err = mod_invites.create_contact(username, allow_registration);
+module:add_item("shell-command", {
+	section = "invite";
+	section_desc = "Create and manage invitations";
+	name = "create_contact";
+	desc = "Create an invitation to become contacts with the specified user";
+	args = { { name = "user_jid", type = "string" }, { name = "allow_registration" } };
+	host_selector = "user_jid";
+
+	handler = function (self, user_jid, allow_registration) --luacheck: ignore 212/self
+		local username = jid_split(user_jid);
+		local invite, err = create_contact(username, allow_registration);
 		if not invite then return nil, err; end
 		return true, invite.landing_page or invite.uri;
-	end
-end
+	end;
+});
+
+local subcommands = {};
 
 --- prosodyctl command
 function module.command(arg)
-	if #arg < 2 or arg[1] ~= "generate" then
+	local opts = argparse.parse(arg, { short_params = { h = "help"; ["?"] = "help" } });
+	local cmd = table.remove(arg, 1); -- pop command
+	if opts.help or not cmd or not subcommands[cmd] then
 		print("usage: prosodyctl mod_"..module.name.." generate example.com");
 		return 2;
 	end
-	table.remove(arg, 1); -- pop command
-
-	local sm = require "core.storagemanager";
-	local mm = require "core.modulemanager";
+	return subcommands[cmd](arg);
+end
 
-	local host = arg[1];
-	assert(prosody.hosts[host], "Host "..tostring(host).." does not exist");
+function subcommands.generate(arg)
+	local function help(short)
+		print("usage: prosodyctl mod_" .. module.name .. " generate DOMAIN --reset USERNAME")
+		print("usage: prosodyctl mod_" .. module.name .. " generate DOMAIN [--admin] [--role ROLE] [--group GROUPID]...")
+		if short then return 2 end
+		print()
+		print("This command has two modes: password reset and new account.")
+		print("If --reset is given, the command operates in password reset mode and in new account mode otherwise.")
+		print()
+		print("required arguments in password reset mode:")
+		print()
+		print("    --reset USERNAME  Generate a password reset link for the given USERNAME.")
+		print()
+		print("optional arguments in new account mode:")
+		print()
+		print("    --admin           Make the new user privileged")
+		print("                      Equivalent to --role prosody:admin")
+		print("    --role ROLE       Grant the given ROLE to the new user")
+		print("    --group GROUPID   Add the user to the group with the given ID")
+		print("                      Can be specified multiple times")
+		print("    --expires-after T Time until the invite expires (e.g. '1 week')")
+		print()
+		print("--group can be specified multiple times; the user will be added to all groups.")
+		print()
+		print("--reset and the other options cannot be mixed.")
+		return 2
+	end
+
+	local earlyopts = argparse.parse(arg, { short_params = { h = "help"; ["?"] = "help" } });
+	if earlyopts.help or not earlyopts[1] then
+		return help();
+	end
+
+	local sm = require "prosody.core.storagemanager";
+	local mm = require "prosody.core.modulemanager";
+
+	local host = table.remove(arg, 1); -- pop host
+	if not host then return help(true) end
 	sm.initialize_host(host);
-	table.remove(arg, 1); -- pop host
 	module.host = host; --luacheck: ignore 122/module
 	token_storage = module:open_store("invite_token", "map");
 
+	local opts = argparse.parse(arg, {
+		short_params = { h = "help"; ["?"] = "help"; g = "group" };
+		value_params = { group = true; reset = true; role = true };
+		array_params = { group = true; role = true };
+	});
+
+	if opts.help then
+		return help();
+	end
+
 	-- Load mod_invites
 	local invites = module:depends("invites");
 	-- Optional community module that if used, needs to be loaded here
@@ -257,71 +309,28 @@
 	end
 
 	local allow_reset;
-	local roles;
-	local groups = {};
 
-	while #arg > 0 do
-		local value = arg[1];
-		table.remove(arg, 1);
-		if value == "--help" then
-			print("usage: prosodyctl mod_"..module.name.." generate DOMAIN --reset USERNAME")
-			print("usage: prosodyctl mod_"..module.name.." generate DOMAIN [--admin] [--role ROLE] [--group GROUPID]...")
-			print()
-			print("This command has two modes: password reset and new account.")
-			print("If --reset is given, the command operates in password reset mode and in new account mode otherwise.")
-			print()
-			print("required arguments in password reset mode:")
-			print()
-			print("    --reset USERNAME  Generate a password reset link for the given USERNAME.")
-			print()
-			print("optional arguments in new account mode:")
-			print()
-			print("    --admin           Make the new user privileged")
-			print("                      Equivalent to --role prosody:admin")
-			print("    --role ROLE       Grant the given ROLE to the new user")
-			print("    --group GROUPID   Add the user to the group with the given ID")
-			print("                      Can be specified multiple times")
-			print()
-			print("--role and --admin override each other; the last one wins")
-			print("--group can be specified multiple times; the user will be added to all groups.")
-			print()
-			print("--reset and the other options cannot be mixed.")
-			return 2
-		elseif value == "--reset" then
-			local nodeprep = require "util.encodings".stringprep.nodeprep;
-			local username = nodeprep(arg[1])
-			table.remove(arg, 1);
-			if not username then
-				print("Please supply a valid username to generate a reset link for");
-				return 2;
-			end
-			allow_reset = username;
-		elseif value == "--admin" then
-			roles = { ["prosody:admin"] = true };
-		elseif value == "--role" then
-			local rolename = arg[1];
-			if not rolename then
-				print("Please supply a role name");
-				return 2;
-			end
-			roles = { [rolename] = true };
-			table.remove(arg, 1);
-		elseif value == "--group" or value == "-g" then
-			local groupid = arg[1];
-			if not groupid then
-				print("Please supply a group ID")
-				return 2;
-			end
-			table.insert(groups, groupid);
-			table.remove(arg, 1);
-		else
-			print("unexpected argument: "..value)
+	if opts.reset then
+		local nodeprep = require "prosody.util.encodings".stringprep.nodeprep;
+		local username = nodeprep(opts.reset)
+		if not username then
+			print("Please supply a valid username to generate a reset link for");
+			return 2;
 		end
+		allow_reset = username;
+	end
+
+	local roles = opts.role or {};
+	local groups = opts.groups or {};
+
+	if opts.admin then
+		-- Insert it first since we don't get order out of argparse
+		table.insert(roles, 1, "prosody:admin");
 	end
 
 	local invite;
 	if allow_reset then
-		if roles then
+		if roles[1] then
 			print("--role/--admin and --reset are mutually exclusive")
 			return 2;
 		end
@@ -333,7 +342,7 @@
 		invite = assert(invites.create_account(nil, {
 			roles = roles,
 			groups = groups
-		}));
+		}, opts.expires_after and human_io.parse_duration(opts.expires_after)));
 	end
 
 	print(invite.landing_page or invite.uri);
--- a/plugins/mod_invites_adhoc.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_invites_adhoc.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,8 +1,7 @@
 -- XEP-0401: Easy User Onboarding
-local dataforms = require "util.dataforms";
-local datetime = require "util.datetime";
-local split_jid = require "util.jid".split;
-local usermanager = require "core.usermanager";
+local dataforms = require "prosody.util.dataforms";
+local datetime = require "prosody.util.datetime";
+local split_jid = require "prosody.util.jid".split;
 
 local new_adhoc = module:require("adhoc").new;
 
@@ -13,8 +12,7 @@
 -- on the server, use the option above instead.
 local allow_contact_invites = module:get_option_boolean("allow_contact_invites", true);
 
-local allow_user_invite_roles = module:get_option_set("allow_user_invites_by_roles");
-local deny_user_invite_roles = module:get_option_set("deny_user_invites_by_roles");
+module:default_permission(allow_user_invites and "prosody:registered" or "prosody:admin", ":invite-users");
 
 local invites;
 if prosody.shutdown then -- COMPAT hack to detect prosodyctl
@@ -42,36 +40,8 @@
 
 -- This is for checking if the specified JID may create invites
 -- that allow people to register accounts on this host.
-local function may_invite_new_users(jid)
-	if usermanager.get_roles then
-		local user_roles = usermanager.get_roles(jid, module.host);
-		if not user_roles then
-			-- User has no roles we can check, just return default
-			return allow_user_invites;
-		end
-
-		if user_roles["prosody:admin"] then
-			return true;
-		end
-		if allow_user_invite_roles then
-			for allowed_role in allow_user_invite_roles do
-				if user_roles[allowed_role] then
-					return true;
-				end
-			end
-		end
-		if deny_user_invite_roles then
-			for denied_role in deny_user_invite_roles do
-				if user_roles[denied_role] then
-					return false;
-				end
-			end
-		end
-	elseif usermanager.is_admin(jid, module.host) then -- COMPAT w/0.11
-		return true; -- Admins may always create invitations
-	end
-	-- No role matches, so whatever the default is
-	return allow_user_invites;
+local function may_invite_new_users(context)
+	return module:may(":invite-users", context);
 end
 
 module:depends("adhoc");
@@ -91,7 +61,7 @@
 					};
 				};
 			end
-			local invite = invites.create_contact(username, may_invite_new_users(data.from), {
+			local invite = invites.create_contact(username, may_invite_new_users(data), {
 				source = data.from
 			});
 			--TODO: check errors
--- a/plugins/mod_invites_register.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_invites_register.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,7 +1,7 @@
-local st = require "util.stanza";
-local jid_split = require "util.jid".split;
-local jid_bare = require "util.jid".bare;
-local rostermanager = require "core.rostermanager";
+local st = require "prosody.util.stanza";
+local jid_split = require "prosody.util.jid".split;
+local jid_bare = require "prosody.util.jid".bare;
+local rostermanager = require "prosody.core.rostermanager";
 
 local require_encryption = module:get_option_boolean("c2s_require_encryption",
 	module:get_option_boolean("require_encryption", true));
@@ -147,7 +147,20 @@
 	if validated_invite.additional_data then
 		module:log("debug", "Importing roles from invite");
 		local roles = validated_invite.additional_data.roles;
-		if roles then
+		if roles and roles[1] ~= nil then
+			local um = require "prosody.core.usermanager";
+			local ok, err = um.set_user_role(event.username, module.host, roles[1]);
+			if not ok then
+				module:log("error", "Could not set role %s for newly registered user %s: %s", roles[1], event.username, err);
+			end
+			for i = 2, #roles do
+				local ok, err = um.add_user_secondary_role(event.username, module.host, roles[i]);
+				if not ok then
+					module:log("warn", "Could not add secondary role %s for newly registered user %s: %s", roles[i], event.username, err);
+				end
+			end
+		elseif roles and type(next(roles)) == "string" then
+			module:log("warn", "Invite carries legacy, migration required for user '%s' for role set %q to take effect", event.username, roles);
 			module:open_store("roles"):set(contact_username, roles);
 		end
 	end
--- a/plugins/mod_iq.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_iq.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,7 +7,7 @@
 --
 
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 local full_sessions = prosody.full_sessions;
 
--- a/plugins/mod_lastactivity.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_lastactivity.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,10 +6,10 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza";
-local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
-local jid_bare = require "util.jid".bare;
-local jid_split = require "util.jid".split;
+local st = require "prosody.util.stanza";
+local is_contact_subscribed = require "prosody.core.rostermanager".is_contact_subscribed;
+local jid_bare = require "prosody.util.jid".bare;
+local jid_split = require "prosody.util.jid".split;
 
 module:add_feature("jabber:iq:last");
 
--- a/plugins/mod_legacyauth.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_legacyauth.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,17 +8,17 @@
 
 
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 local t_concat = table.concat;
 
 local secure_auth_only = module:get_option("c2s_require_encryption",
 	module:get_option("require_encryption", true))
 	or not(module:get_option("allow_unencrypted_plain_auth"));
 
-local sessionmanager = require "core.sessionmanager";
-local usermanager = require "core.usermanager";
-local nodeprep = require "util.encodings".stringprep.nodeprep;
-local resourceprep = require "util.encodings".stringprep.resourceprep;
+local sessionmanager = require "prosody.core.sessionmanager";
+local usermanager = require "prosody.core.usermanager";
+local nodeprep = require "prosody.util.encodings".stringprep.nodeprep;
+local resourceprep = require "prosody.util.encodings".stringprep.resourceprep;
 
 module:add_feature("jabber:iq:auth");
 module:hook("stream-features", function(event)
--- a/plugins/mod_limits.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_limits.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,13 +1,13 @@
 -- Because we deal with pre-authed sessions and streams we can't be host-specific
 module:set_global();
 
-local filters = require "util.filters";
-local throttle = require "util.throttle";
-local timer = require "util.timer";
+local filters = require "prosody.util.filters";
+local throttle = require "prosody.util.throttle";
+local timer = require "prosody.util.timer";
 local ceil = math.ceil;
 
 local limits_cfg = module:get_option("limits", {});
-local limits_resolution = module:get_option_number("limits_resolution", 1);
+local limits_resolution = module:get_option_period("limits_resolution", 1);
 
 local default_bytes_per_second = 3000;
 local default_burst = 2;
--- a/plugins/mod_mam/mamprefs.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_mam/mamprefs.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -10,11 +10,14 @@
 --
 -- luacheck: ignore 122/prosody
 
-local global_default_policy = module:get_option_string("default_archive_policy", true);
-if global_default_policy ~= "roster" then
-	global_default_policy = module:get_option_boolean("default_archive_policy", global_default_policy);
+local global_default_policy = module:get_option_enum("default_archive_policy", "always", "roster", "never", true, false);
+local smart_enable = module:get_option_boolean("mam_smart_enable", false);
+
+if global_default_policy == "always" then
+	global_default_policy = true;
+elseif global_default_policy == "never" then
+	global_default_policy = false;
 end
-local smart_enable = module:get_option_boolean("mam_smart_enable", false);
 
 do
 	-- luacheck: ignore 211/prefs_format
--- a/plugins/mod_mam/mamprefsxml.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_mam/mamprefsxml.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -10,8 +10,8 @@
 -- XEP-0313: Message Archive Management for Prosody
 --
 
-local st = require"util.stanza";
-local jid_prep = require"util.jid".prep;
+local st = require"prosody.util.stanza";
+local jid_prep = require"prosody.util.jid".prep;
 local xmlns_mam = "urn:xmpp:mam:2";
 
 local default_attrs = {
--- a/plugins/mod_mam/mod_mam.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_mam/mod_mam.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -15,36 +15,36 @@
 local xmlns_forward = "urn:xmpp:forward:0";
 local xmlns_st_id   = "urn:xmpp:sid:0";
 
-local um = require "core.usermanager";
-local st = require "util.stanza";
-local rsm = require "util.rsm";
+local um = require "prosody.core.usermanager";
+local st = require "prosody.util.stanza";
+local rsm = require "prosody.util.rsm";
 local get_prefs = module:require"mamprefs".get;
 local set_prefs = module:require"mamprefs".set;
 local prefs_to_stanza = module:require"mamprefsxml".tostanza;
 local prefs_from_stanza = module:require"mamprefsxml".fromstanza;
-local jid_bare = require "util.jid".bare;
-local jid_split = require "util.jid".split;
-local jid_resource = require "util.jid".resource;
-local jid_prepped_split = require "util.jid".prepped_split;
-local dataform = require "util.dataforms".new;
-local get_form_type = require "util.dataforms".get_type;
+local jid_bare = require "prosody.util.jid".bare;
+local jid_split = require "prosody.util.jid".split;
+local jid_resource = require "prosody.util.jid".resource;
+local jid_prepped_split = require "prosody.util.jid".prepped_split;
+local dataform = require "prosody.util.dataforms".new;
+local get_form_type = require "prosody.util.dataforms".get_type;
 local host = module.host;
 
-local rm_load_roster = require "core.rostermanager".load_roster;
+local rm_load_roster = require "prosody.core.rostermanager".load_roster;
 
 local is_stanza = st.is_stanza;
 local tostring = tostring;
-local time_now = os.time;
+local time_now = require "prosody.util.time".now;
 local m_min = math.min;
 local timestamp, datestamp = import( "util.datetime", "datetime", "date");
-local default_max_items, max_max_items = 20, module:get_option_number("max_archive_query_results", 50);
+local default_max_items, max_max_items = 20, module:get_option_integer("max_archive_query_results", 50, 0);
 local strip_tags = module:get_option_set("dont_archive_namespaces", { "http://jabber.org/protocol/chatstates" });
 
 local archive_store = module:get_option_string("archive_store", "archive");
 local archive = module:open_store(archive_store, "archive");
 
-local cleanup_after = module:get_option_string("archive_expires_after", "1w");
-local archive_item_limit = module:get_option_number("storage_archive_item_limit", archive.caps and archive.caps.quota or 1000);
+local cleanup_after = module:get_option_period("archive_expires_after", "1w");
+local archive_item_limit = module:get_option_integer("storage_archive_item_limit", archive.caps and archive.caps.quota or 1000, 0);
 local archive_truncate = math.floor(archive_item_limit * 0.99);
 
 if not archive.find then
@@ -53,8 +53,12 @@
 end
 local use_total = module:get_option_boolean("mam_include_total", true);
 
-function schedule_cleanup()
-	-- replaced later if cleanup is enabled
+function schedule_cleanup(_username, _date) -- luacheck: ignore 212
+	-- Called to make a note of which users have messages on which days, which in
+	-- turn is used to optimize the message expiry routine.
+	--
+	-- This noop is conditionally replaced later depending on retention settings
+	-- and storage backend capabilities.
 end
 
 -- Handle prefs.
@@ -245,8 +249,7 @@
 			return true;
 		end
 
-		local id, _, when = first();
-		if id then
+		for id, _, when in first do
 			reply:tag("start", { id = id, timestamp = timestamp(when) }):up();
 		end
 	end
@@ -258,8 +261,7 @@
 			return true;
 		end
 
-		local id, _, when = last();
-		if id then
+		for id, _, when in last do
 			reply:tag("end", { id = id, timestamp = timestamp(when) }):up();
 		end
 	end
@@ -437,7 +439,7 @@
 		local time = time_now();
 		local ok, err = archive:append(store_user, nil, clone_for_storage, time, with);
 		if not ok and err == "quota-limit" then
-			if type(cleanup_after) == "number" then
+			if cleanup_after ~= math.huge then
 				module:log("debug", "User '%s' over quota, cleaning archive", store_user);
 				local cleaned = archive:delete(store_user, {
 					["end"] = (os.time() - cleanup_after);
@@ -502,20 +504,10 @@
 	end
 end);
 
-if cleanup_after ~= "never" then
+if cleanup_after ~= math.huge then
 	local cleanup_storage = module:open_store("archive_cleanup");
 	local cleanup_map = module:open_store("archive_cleanup", "map");
 
-	local day = 86400;
-	local multipliers = { d = day, w = day * 7, m = 31 * day, y = 365.2425 * day };
-	local n, m = cleanup_after:lower():match("(%d+)%s*([dwmy]?)");
-	if not n then
-		module:log("error", "Could not parse archive_expires_after string %q", cleanup_after);
-		return false;
-	end
-
-	cleanup_after = tonumber(n) * ( multipliers[m] or 1 );
-
 	module:log("debug", "archive_expires_after = %d -- in seconds", cleanup_after);
 
 	if not archive.delete then
@@ -528,7 +520,7 @@
 	-- outside the cleanup range.
 
 	if not (archive.caps and archive.caps.wildcard_delete) then
-		local last_date = require "util.cache".new(module:get_option_number("archive_cleanup_date_cache_size", 1000));
+		local last_date = require "prosody.util.cache".new(module:get_option_integer("archive_cleanup_date_cache_size", 1000, 1));
 		function schedule_cleanup(username, date)
 			date = date or datestamp();
 			if last_date:get(username) == date then return end
@@ -541,7 +533,7 @@
 
 	local cleanup_time = module:measure("cleanup", "times");
 
-	local async = require "util.async";
+	local async = require "prosody.util.async";
 	module:daily("Remove expired messages", function ()
 		local cleanup_done = cleanup_time();
 
--- a/plugins/mod_message.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_message.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -10,10 +10,10 @@
 local full_sessions = prosody.full_sessions;
 local bare_sessions = prosody.bare_sessions;
 
-local st = require "util.stanza";
-local jid_bare = require "util.jid".bare;
-local jid_split = require "util.jid".split;
-local user_exists = require "core.usermanager".user_exists;
+local st = require "prosody.util.stanza";
+local jid_bare = require "prosody.util.jid".bare;
+local jid_split = require "prosody.util.jid".split;
+local user_exists = require "prosody.core.usermanager".user_exists;
 
 local function process_to_bare(bare, origin, stanza)
 	local user = bare_sessions[bare];
--- a/plugins/mod_mimicking.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_mimicking.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,13 +6,13 @@
 -- COPYING file in the source package for more information.
 --
 
-local encodings = require "util.encodings";
+local encodings = require "prosody.util.encodings";
 assert(encodings.confusable, "This module requires that Prosody be built with ICU");
 local skeleton = encodings.confusable.skeleton;
 
-local usage = require "util.prosodyctl".show_usage;
-local usermanager = require "core.usermanager";
-local storagemanager = require "core.storagemanager";
+local usage = require "prosody.util.prosodyctl".show_usage;
+local usermanager = require "prosody.core.usermanager";
+local storagemanager = require "prosody.core.storagemanager";
 
 local skeletons
 function module.load()
--- a/plugins/mod_motd.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_motd.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -13,7 +13,7 @@
 
 if not motd_text then return; end
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 motd_text = motd_text:gsub("^%s*(.-)%s*$", "%1"):gsub("\n[ \t]+", "\n"); -- Strip indentation from the config
 
--- a/plugins/mod_muc_mam.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_muc_mam.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -16,28 +16,28 @@
 local xmlns_muc_user = "http://jabber.org/protocol/muc#user";
 local muc_form_enable = "muc#roomconfig_enablearchiving"
 
-local st = require "util.stanza";
-local rsm = require "util.rsm";
-local jid_bare = require "util.jid".bare;
-local jid_split = require "util.jid".split;
-local jid_prep = require "util.jid".prep;
-local dataform = require "util.dataforms".new;
-local get_form_type = require "util.dataforms".get_type;
+local st = require "prosody.util.stanza";
+local rsm = require "prosody.util.rsm";
+local jid_bare = require "prosody.util.jid".bare;
+local jid_split = require "prosody.util.jid".split;
+local jid_prep = require "prosody.util.jid".prep;
+local dataform = require "prosody.util.dataforms".new;
+local get_form_type = require "prosody.util.dataforms".get_type;
 
 local mod_muc = module:depends"muc";
 local get_room_from_jid = mod_muc.get_room_from_jid;
 
 local is_stanza = st.is_stanza;
 local tostring = tostring;
-local time_now = os.time;
+local time_now = require "prosody.util.time".now;
 local m_min = math.min;
-local timestamp, datestamp = import("util.datetime", "datetime", "date");
-local default_max_items, max_max_items = 20, module:get_option_number("max_archive_query_results", 50);
+local timestamp, datestamp = import("prosody.util.datetime", "datetime", "date");
+local default_max_items, max_max_items = 20, module:get_option_integer("max_archive_query_results", 50, 0);
 
-local cleanup_after = module:get_option_string("muc_log_expires_after", "1w");
+local cleanup_after = module:get_option_period("muc_log_expires_after", "1w");
 
 local default_history_length = 20;
-local max_history_length = module:get_option_number("max_history_messages", math.huge);
+local max_history_length = module:get_option_integer("max_history_messages", math.huge, 0);
 
 local function get_historylength(room)
 	return math.min(room._data.history_length or default_history_length, max_history_length);
@@ -53,7 +53,7 @@
 local archive_store = "muc_log";
 local archive = module:open_store(archive_store, "archive");
 
-local archive_item_limit = module:get_option_number("storage_archive_item_limit", archive.caps and archive.caps.quota or 1000);
+local archive_item_limit = module:get_option_integer("storage_archive_item_limit", archive.caps and archive.caps.quota or 1000, 0);
 local archive_truncate = math.floor(archive_item_limit * 0.99);
 
 if archive.name == "null" or not archive.find then
@@ -397,7 +397,7 @@
 	local id, err = archive:append(room_node, nil, stored_stanza, time, with);
 
 	if not id and err == "quota-limit" then
-		if type(cleanup_after) == "number" then
+		if cleanup_after ~= math.huge then
 			module:log("debug", "Room '%s' over quota, cleaning archive", room_node);
 			local cleaned = archive:delete(room_node, {
 				["end"] = (os.time() - cleanup_after);
@@ -467,20 +467,10 @@
 
 -- Cleanup
 
-if cleanup_after ~= "never" then
+if cleanup_after ~= math.huge then
 	local cleanup_storage = module:open_store("muc_log_cleanup");
 	local cleanup_map = module:open_store("muc_log_cleanup", "map");
 
-	local day = 86400;
-	local multipliers = { d = day, w = day * 7, m = 31 * day, y = 365.2425 * day };
-	local n, m = cleanup_after:lower():match("(%d+)%s*([dwmy]?)");
-	if not n then
-		module:log("error", "Could not parse muc_log_expires_after string %q", cleanup_after);
-		return false;
-	end
-
-	cleanup_after = tonumber(n) * ( multipliers[m] or 1 );
-
 	module:log("debug", "muc_log_expires_after = %d -- in seconds", cleanup_after);
 
 	if not archive.delete then
@@ -492,7 +482,7 @@
 	-- messages, we collect the union of sets of rooms from dates that fall
 	-- outside the cleanup range.
 
-	local last_date = require "util.cache".new(module:get_option_number("muc_log_cleanup_date_cache_size", 1000));
+	local last_date = require "prosody.util.cache".new(module:get_option_integer("muc_log_cleanup_date_cache_size", 1000, 1));
 	if not ( archive.caps and archive.caps.wildcard_delete ) then
 		function schedule_cleanup(roomname, date)
 			date = date or datestamp();
@@ -506,7 +496,7 @@
 
 	local cleanup_time = module:measure("cleanup", "times");
 
-	local async = require "util.async";
+	local async = require "prosody.util.async";
 	module:daily("Remove expired messages", function ()
 		local cleanup_done = cleanup_time();
 
--- a/plugins/mod_muc_unique.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_muc_unique.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,6 @@
 -- XEP-0307: Unique Room Names for Multi-User Chat
-local st = require "util.stanza";
-local unique_name = require "util.id".medium;
+local st = require "prosody.util.stanza";
+local unique_name = require "prosody.util.id".medium;
 module:add_feature "http://jabber.org/protocol/muc#unique"
 module:hook("iq-get/host/http://jabber.org/protocol/muc#unique:unique", function(event)
 	local origin, stanza = event.origin, event.stanza;
--- a/plugins/mod_net_multiplex.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_net_multiplex.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,10 +1,10 @@
 module:set_global();
 
-local array = require "util.array";
-local max_buffer_len = module:get_option_number("multiplex_buffer_size", 1024);
-local default_mode = module:get_option_number("network_default_read_size", 4096);
+local array = require "prosody.util.array";
+local max_buffer_len = module:get_option_integer("multiplex_buffer_size", 1024, 1);
+local default_mode = module:get_option_integer("network_default_read_size", 4096, 0);
 
-local portmanager = require "core.portmanager";
+local portmanager = require "prosody.core.portmanager";
 
 local available_services = {};
 local service_by_protocol = {};
--- a/plugins/mod_offline.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_offline.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,8 +7,8 @@
 --
 
 
-local datetime = require "util.datetime";
-local jid_split = require "util.jid".split;
+local datetime = require "prosody.util.datetime";
+local jid_split = require "prosody.util.jid".split;
 
 local offline_messages = module:open_store("offline", "archive");
 
--- a/plugins/mod_pep.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_pep.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,16 +1,16 @@
-local pubsub = require "util.pubsub";
-local jid_bare = require "util.jid".bare;
-local jid_split = require "util.jid".split;
-local jid_join = require "util.jid".join;
-local set_new = require "util.set".new;
-local st = require "util.stanza";
-local calculate_hash = require "util.caps".calculate_hash;
-local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
-local cache = require "util.cache";
-local set = require "util.set";
-local new_id = require "util.id".medium;
-local storagemanager = require "core.storagemanager";
-local usermanager = require "core.usermanager";
+local pubsub = require "prosody.util.pubsub";
+local jid_bare = require "prosody.util.jid".bare;
+local jid_split = require "prosody.util.jid".split;
+local jid_join = require "prosody.util.jid".join;
+local set_new = require "prosody.util.set".new;
+local st = require "prosody.util.stanza";
+local calculate_hash = require "prosody.util.caps".calculate_hash;
+local is_contact_subscribed = require "prosody.core.rostermanager".is_contact_subscribed;
+local cache = require "prosody.util.cache";
+local set = require "prosody.util.set";
+local new_id = require "prosody.util.id".medium;
+local storagemanager = require "prosody.core.storagemanager";
+local usermanager = require "prosody.core.usermanager";
 
 local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
 local xmlns_pubsub_event = "http://jabber.org/protocol/pubsub#event";
@@ -24,7 +24,7 @@
 local pep_service_items = {};
 
 -- size of caches with full pubsub service objects
-local service_cache_size = module:get_option_number("pep_service_cache_size", 1000);
+local service_cache_size = module:get_option_integer("pep_service_cache_size", 1000, 1);
 
 -- username -> util.pubsub service object
 local services = cache.new(service_cache_size, function (username, _)
@@ -36,7 +36,7 @@
 end):table();
 
 -- size of caches with smaller objects
-local info_cache_size = module:get_option_number("pep_info_cache_size", 10000);
+local info_cache_size = module:get_option_integer("pep_info_cache_size", 10000, 1);
 
 -- username -> recipient -> set of nodes
 local recipients = cache.new(info_cache_size):table();
@@ -49,7 +49,7 @@
 local node_config = module:open_store("pep", "map");
 local known_nodes = module:open_store("pep");
 
-local max_max_items = module:get_option_number("pep_max_items", 256);
+local max_max_items = module:get_option_number("pep_max_items", 256, 0);
 
 local function tonumber_max_items(n)
 	if n == "max" then
@@ -136,10 +136,14 @@
 local function get_broadcaster(username)
 	local user_bare = jid_join(username, host);
 	local function simple_broadcast(kind, node, jids, item, _, node_obj)
+		local expose_publisher;
 		if node_obj then
 			if node_obj.config["notify_"..kind] == false then
 				return;
 			end
+			if node_obj.config.itemreply == "publisher" then
+				expose_publisher = true;
+			end
 		end
 		if kind == "retract" then
 			kind = "items"; -- XEP-0060 signals retraction in an <items> container
@@ -151,6 +155,9 @@
 				if node_obj and node_obj.config.include_payload == false then
 					item:maptags(function () return nil; end);
 				end
+				if not expose_publisher then
+					item.attr.publisher = nil;
+				end
 			end
 		end
 
@@ -306,7 +313,7 @@
 	if ok and config.send_last_published_item ~= "on_sub_and_presence" then return end
 	local ok, id, item = service:get_last_item(node, jid);
 	if not (ok and id) then return; end
-	service.config.broadcaster("items", node, { [jid] = true }, item);
+	service.config.broadcaster("items", node, { [jid] = true }, item, true, service.nodes[node], service);
 end
 
 local function update_subscriptions(recipient, service_name, nodes)
--- a/plugins/mod_pep_simple.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_pep_simple.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,15 +7,15 @@
 --
 
 
-local jid_bare = require "util.jid".bare;
-local jid_split = require "util.jid".split;
-local st = require "util.stanza";
-local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
+local jid_bare = require "prosody.util.jid".bare;
+local jid_split = require "prosody.util.jid".split;
+local st = require "prosody.util.stanza";
+local is_contact_subscribed = require "prosody.core.rostermanager".is_contact_subscribed;
 local pairs = pairs;
 local next = next;
 local type = type;
-local unpack = table.unpack or unpack; -- luacheck: ignore 113
-local calculate_hash = require "util.caps".calculate_hash;
+local unpack = table.unpack;
+local calculate_hash = require "prosody.util.caps".calculate_hash;
 local core_post_stanza = prosody.core_post_stanza;
 local bare_sessions = prosody.bare_sessions;
 
--- a/plugins/mod_ping.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_ping.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,7 +6,7 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 module:add_feature("urn:xmpp:ping");
 
--- a/plugins/mod_posix.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_posix.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,167 +6,6 @@
 -- COPYING file in the source package for more information.
 --
 
-
-local want_pposix_version = "0.4.0";
-
-local pposix = assert(require "util.pposix");
-if pposix._VERSION ~= want_pposix_version then
-	module:log("warn", "Unknown version (%s) of binary pposix module, expected %s."
-		.. "Perhaps you need to recompile?", tostring(pposix._VERSION), want_pposix_version);
-end
-
-local have_signal, signal = pcall(require, "util.signal");
-if not have_signal then
-	module:log("warn", "Couldn't load signal library, won't respond to SIGTERM");
-end
-
-local lfs = require "lfs";
-local stat = lfs.attributes;
-
-local prosody = _G.prosody;
-
 module:set_global(); -- we're a global module
 
-local umask = module:get_option_string("umask", "027");
-pposix.umask(umask);
-
--- Don't even think about it!
-if not prosody.start_time then -- server-starting
-	if pposix.getuid() == 0 and not module:get_option_boolean("run_as_root") then
-		module:log("error", "Danger, Will Robinson! Prosody doesn't need to be run as root, so don't do it!");
-		module:log("error", "For more information on running Prosody as root, see https://prosody.im/doc/root");
-		prosody.shutdown("Refusing to run as root", 1);
-	end
-end
-
-local pidfile;
-local pidfile_handle;
-
-local function remove_pidfile()
-	if pidfile_handle then
-		pidfile_handle:close();
-		os.remove(pidfile);
-		pidfile, pidfile_handle = nil, nil;
-	end
-end
-
-local function write_pidfile()
-	if pidfile_handle then
-		remove_pidfile();
-	end
-	pidfile = module:get_option_path("pidfile", nil, "data");
-	if pidfile then
-		local err;
-		local mode = stat(pidfile) and "r+" or "w+";
-		pidfile_handle, err = io.open(pidfile, mode);
-		if not pidfile_handle then
-			module:log("error", "Couldn't write pidfile at %s; %s", pidfile, err);
-			prosody.shutdown("Couldn't write pidfile", 1);
-		else
-			if not lfs.lock(pidfile_handle, "w") then -- Exclusive lock
-				local other_pid = pidfile_handle:read("*a");
-				module:log("error", "Another Prosody instance seems to be running with PID %s, quitting", other_pid);
-				pidfile_handle = nil;
-				prosody.shutdown("Prosody already running", 1);
-			else
-				pidfile_handle:close();
-				pidfile_handle, err = io.open(pidfile, "w+");
-				if not pidfile_handle then
-					module:log("error", "Couldn't write pidfile at %s; %s", pidfile, err);
-					prosody.shutdown("Couldn't write pidfile", 1);
-				else
-					if lfs.lock(pidfile_handle, "w") then
-						pidfile_handle:write(tostring(pposix.getpid()));
-						pidfile_handle:flush();
-					end
-				end
-			end
-		end
-	end
-end
-
-local daemonize = prosody.opts.daemonize;
-
-if daemonize == nil then
-	-- Fall back to config file if not specified on command-line
-	daemonize = module:get_option_boolean("daemonize", nil);
-	if daemonize ~= nil then
-		module:log("warn", "The 'daemonize' option has been deprecated, specify -D or -F on the command line instead.");
-		-- TODO: Write some docs and include a link in the warning.
-	end
-end
-
-local function remove_log_sinks()
-	local lm = require "core.loggingmanager";
-	lm.register_sink_type("console", nil);
-	lm.register_sink_type("stdout", nil);
-	lm.reload_logging();
-end
-
-if daemonize then
-	local function daemonize_server()
-		module:log("info", "Prosody is about to detach from the console, disabling further console output");
-		remove_log_sinks();
-		local ok, ret = pposix.daemonize();
-		if not ok then
-			module:log("error", "Failed to daemonize: %s", ret);
-		elseif ret and ret > 0 then
-			os.exit(0);
-		else
-			module:log("info", "Successfully daemonized to PID %d", pposix.getpid());
-			write_pidfile();
-		end
-	end
-	module:hook("server-started", daemonize_server)
-else
-	-- Not going to daemonize, so write the pid of this process
-	write_pidfile();
-end
-
-module:hook("server-stopped", remove_pidfile);
-
--- Set signal handlers
-if have_signal then
-	module:add_timer(0, function ()
-		signal.signal("SIGTERM", function ()
-			module:log("warn", "Received SIGTERM");
-			prosody.main_thread:run(function ()
-				prosody.unlock_globals();
-				prosody.shutdown("Received SIGTERM");
-				prosody.lock_globals();
-			end);
-		end);
-
-		signal.signal("SIGHUP", function ()
-			module:log("info", "Received SIGHUP");
-			prosody.main_thread:run(function ()
-				prosody.reload_config();
-			end);
-			-- this also reloads logging
-		end);
-
-		signal.signal("SIGINT", function ()
-			module:log("info", "Received SIGINT");
-			prosody.main_thread:run(function ()
-				prosody.unlock_globals();
-				prosody.shutdown("Received SIGINT");
-				prosody.lock_globals();
-			end);
-		end);
-
-		signal.signal("SIGUSR1", function ()
-			module:log("info", "Received SIGUSR1");
-			module:fire_event("signal/SIGUSR1");
-		end);
-
-		signal.signal("SIGUSR2", function ()
-			module:log("info", "Received SIGUSR2");
-			module:fire_event("signal/SIGUSR2");
-		end);
-	end);
-end
-
--- For other modules to reference
-features = {
-	signal_events = true;
-};
+-- TODO delete this whole concept
--- a/plugins/mod_presence.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_presence.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -15,19 +15,19 @@
 
 local core_post_stanza = prosody.core_post_stanza;
 local core_process_stanza = prosody.core_process_stanza;
-local st = require "util.stanza";
-local jid_split = require "util.jid".split;
-local jid_bare = require "util.jid".bare;
-local datetime = require "util.datetime";
+local st = require "prosody.util.stanza";
+local jid_split = require "prosody.util.jid".split;
+local jid_bare = require "prosody.util.jid".bare;
+local datetime = require "prosody.util.datetime";
 local hosts = prosody.hosts;
 local bare_sessions = prosody.bare_sessions;
 local full_sessions = prosody.full_sessions;
 local NULL = {};
 
-local rostermanager = require "core.rostermanager";
-local sessionmanager = require "core.sessionmanager";
+local rostermanager = require "prosody.core.rostermanager";
+local sessionmanager = require "prosody.core.sessionmanager";
 
-local recalc_resource_map = require "util.presence".recalc_resource_map;
+local recalc_resource_map = require "prosody.util.presence".recalc_resource_map;
 
 local ignore_presence_priority = module:get_option_boolean("ignore_presence_priority", false);
 
--- a/plugins/mod_private.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_private.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,7 +7,7 @@
 --
 
 
-local st = require "util.stanza"
+local st = require "prosody.util.stanza"
 
 local private_storage = module:open_store("private", "map");
 
--- a/plugins/mod_proxy65.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_proxy65.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -9,11 +9,11 @@
 
 module:set_global();
 
-local jid_compare, jid_prep = require "util.jid".compare, require "util.jid".prep;
-local st = require "util.stanza";
-local sha1 = require "util.hashes".sha1;
-local server = require "net.server";
-local portmanager = require "core.portmanager";
+local jid_compare, jid_prep = require "prosody.util.jid".compare, require "prosody.util.jid".prep;
+local st = require "prosody.util.stanza";
+local sha1 = require "prosody.util.hashes".sha1;
+local server = require "prosody.net.server";
+local portmanager = require "prosody.core.portmanager";
 
 local sessions = module:shared("sessions");
 local transfers = module:shared("transfers");
--- a/plugins/mod_pubsub/mod_pubsub.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_pubsub/mod_pubsub.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,10 +1,9 @@
-local pubsub = require "util.pubsub";
-local st = require "util.stanza";
-local jid_bare = require "util.jid".bare;
-local usermanager = require "core.usermanager";
-local new_id = require "util.id".medium;
-local storagemanager = require "core.storagemanager";
-local xtemplate = require "util.xtemplate";
+local pubsub = require "prosody.util.pubsub";
+local st = require "prosody.util.stanza";
+local jid_bare = require "prosody.util.jid".bare;
+local new_id = require "prosody.util.id".medium;
+local storagemanager = require "prosody.core.storagemanager";
+local xtemplate = require "prosody.util.xtemplate";
 
 local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
 local xmlns_pubsub_event = "http://jabber.org/protocol/pubsub#event";
@@ -13,7 +12,7 @@
 local autocreate_on_publish = module:get_option_boolean("autocreate_on_publish", false);
 local autocreate_on_subscribe = module:get_option_boolean("autocreate_on_subscribe", false);
 local pubsub_disco_name = module:get_option_string("name", "Prosody PubSub Service");
-local expose_publisher = module:get_option_boolean("expose_publisher", false)
+local service_expose_publisher = module:get_option_boolean("expose_publisher")
 
 local service;
 
@@ -40,7 +39,7 @@
 --   get(node_name)
 --   users(): iterator over (node_name)
 
-local max_max_items = module:get_option_number("pubsub_max_items", 256);
+local max_max_items = module:get_option_integer("pubsub_max_items", 256, 1);
 
 local function tonumber_max_items(n)
 	if n == "max" then
@@ -82,7 +81,11 @@
 			if node_obj and node_obj.config.include_payload == false then
 				item:maptags(function () return nil; end);
 			end
-			if not expose_publisher then
+			local node_expose_publisher = service_expose_publisher;
+			if node_expose_publisher == nil and node_obj and node_obj.config.itemreply == "publisher" then
+				node_expose_publisher = true;
+			end
+			if not node_expose_publisher then
 				item.attr.publisher = nil;
 			elseif not item.attr.publisher and actor ~= true then
 				item.attr.publisher = service.config.normalize_jid(actor);
@@ -136,12 +139,22 @@
 
 -- Compose a textual representation of Atom payloads
 local summary_templates = module:get_option("pubsub_summary_templates", {
-	["http://www.w3.org/2005/Atom"] = "{summary|or{{author/name|and{{author/name} posted }}{title}}}";
+	["http://www.w3.org/2005/Atom"] = "{@pubsub:title|and{*{@pubsub:title}*\n\n}}{summary|or{{author/name|and{{author/name} posted }}{title}}}";
 })
 
 for pubsub_type, template in pairs(summary_templates) do
 	module:hook("pubsub-summary/"..pubsub_type, function (event)
 		local payload = event.payload;
+
+		local got_config, node_config = service:get_node_config(event.node, true);
+		if got_config then
+			payload = st.clone(payload);
+			payload.attr["xmlns:pubsub"] = xmlns_pubsub;
+			payload.attr["pubsub:node"] = event.node;
+			payload.attr["pubsub:title"] = node_config.title;
+			payload.attr["pubsub:description"] = node_config.description;
+		end
+
 		return xtemplate.render(template, payload, tostring);
 	end, -1);
 end
@@ -176,10 +189,11 @@
 	end
 end);
 
-local admin_aff = module:get_option_string("default_admin_affiliation", "owner");
+local admin_aff = module:get_option_enum("default_admin_affiliation", "owner", "publisher", "member", "outcast", "none");
+module:default_permission("prosody:admin", ":service-admin");
 local function get_affiliation(jid)
 	local bare_jid = jid_bare(jid);
-	if bare_jid == module.host or usermanager.is_admin(bare_jid, module.host) then
+	if bare_jid == module.host or module:may(":service-admin", bare_jid) then
 		return admin_aff;
 	end
 end
@@ -192,7 +206,7 @@
 	service = new_service;
 	service.config.autocreate_on_publish = autocreate_on_publish;
 	service.config.autocreate_on_subscribe = autocreate_on_subscribe;
-	service.config.expose_publisher = expose_publisher;
+	service.config.expose_publisher = service_expose_publisher;
 
 	service.config.nodestore = node_store;
 	service.config.itemstore = create_simple_itemstore;
@@ -219,7 +233,7 @@
 	set_service(pubsub.new({
 		autocreate_on_publish = autocreate_on_publish;
 		autocreate_on_subscribe = autocreate_on_subscribe;
-		expose_publisher = expose_publisher;
+		expose_publisher = service_expose_publisher;
 
 		node_defaults = {
 			["persist_items"] = true;
@@ -236,3 +250,46 @@
 		normalize_jid = jid_bare;
 	}));
 end
+
+local function get_service(service_jid)
+	return assert(assert(prosody.hosts[service_jid], "Unknown pubsub service").modules.pubsub, "Not a pubsub service").service;
+end
+
+module:add_item("shell-command", {
+	section = "pubsub";
+	section_desc = "Manage publish/subscribe nodes";
+	name = "create_node";
+	desc = "Create a node with the specified name";
+	args = {
+		{ name = "service_jid", type = "string" };
+		{ name = "node_name",   type = "string" };
+	};
+	host_selector = "service_jid";
+
+	handler = function (self, service_jid, node_name) --luacheck: ignore 212/self
+		return get_service(service_jid):create(node_name, true);
+	end;
+});
+
+module:add_item("shell-command", {
+	section = "pubsub";
+	section_desc = "Manage publish/subscribe nodes";
+	name = "list_nodes";
+	desc = "List nodes on a pubsub service";
+	args = {
+		{ name = "service_jid", type = "string" };
+	};
+	host_selector = "service_jid";
+
+	handler = function (self, service_jid) --luacheck: ignore 212/self
+		-- luacheck: ignore 431/service
+		local service = get_service(service_jid);
+		local nodes = select(2, assert(service:get_nodes(true)));
+		local count = 0;
+		for node_name in pairs(nodes) do
+			count = count + 1;
+			self.session.print(node_name);
+		end
+		return true, ("%d nodes"):format(count);
+	end;
+});
--- a/plugins/mod_pubsub/pubsub.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_pubsub/pubsub.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,13 +1,13 @@
-local t_unpack = table.unpack or unpack; -- luacheck: ignore 113
+local t_unpack = table.unpack;
 local time_now = os.time;
 
-local jid_prep = require "util.jid".prep;
-local set = require "util.set";
-local st = require "util.stanza";
-local it = require "util.iterators";
-local uuid_generate = require "util.uuid".generate;
-local dataform = require"util.dataforms".new;
-local errors = require "util.error";
+local jid_prep = require "prosody.util.jid".prep;
+local set = require "prosody.util.set";
+local st = require "prosody.util.stanza";
+local it = require "prosody.util.iterators";
+local uuid_generate = require "prosody.util.uuid".generate;
+local dataform = require"prosody.util.dataforms".new;
+local errors = require "prosody.util.error";
 
 local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
 local xmlns_pubsub_errors = "http://jabber.org/protocol/pubsub#errors";
@@ -164,6 +164,17 @@
 		var = "pubsub#notify_retract";
 		value = true;
 	};
+	{
+		type = "list-single";
+		label = "Specify whose JID to include as the publisher of items";
+		name = "itemreply";
+		var = "pubsub#itemreply";
+		options = {
+			{ label = "Include the node owner's JID", value = "owner" };
+			{ label = "Include the item publisher's JID", value = "publisher" };
+			{ label = "Don't include any JID with items", value = "none", default = true };
+		};
+	};
 };
 _M.node_config_form = node_config_form;
 
@@ -347,6 +358,13 @@
 		origin.send(pubsub_error_reply(stanza, "nodeid-required"));
 		return true;
 	end
+
+	local node_obj = service.nodes[node];
+	if not node_obj then
+		origin.send(pubsub_error_reply(stanza, "item-not-found"));
+		return true;
+	end
+
 	local resultspec; -- TODO rsm.get()
 	if items.attr.max_items then
 		resultspec = { max = tonumber(items.attr.max_items) };
@@ -358,6 +376,9 @@
 	end
 
 	local expose_publisher = service.config.expose_publisher;
+	if expose_publisher == nil and node_obj.config.itemreply == "publisher" then
+		expose_publisher = true;
+	end
 
 	local data = st.stanza("items", { node = node });
 	local iter, v, i = ipairs(results);
@@ -678,8 +699,7 @@
 function handlers.set_retract(origin, stanza, retract, service)
 	local node, notify = retract.attr.node, retract.attr.notify;
 	notify = (notify == "1") or (notify == "true");
-	local item = retract:get_child("item");
-	local id = item and item.attr.id
+	local id = retract:get_child_attr("item", nil, "id");
 	if not (node and id) then
 		origin.send(pubsub_error_reply(stanza, node and "item-not-found" or "nodeid-required"));
 		return true;
--- a/plugins/mod_register_ibr.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_register_ibr.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,19 +7,21 @@
 --
 
 
-local st = require "util.stanza";
-local dataform_new = require "util.dataforms".new;
-local usermanager_user_exists  = require "core.usermanager".user_exists;
-local usermanager_create_user  = require "core.usermanager".create_user;
-local usermanager_set_password = require "core.usermanager".create_user;
-local usermanager_delete_user  = require "core.usermanager".delete_user;
-local nodeprep = require "util.encodings".stringprep.nodeprep;
-local util_error = require "util.error";
+local st = require "prosody.util.stanza";
+local dataform_new = require "prosody.util.dataforms".new;
+local usermanager_user_exists  = require "prosody.core.usermanager".user_exists;
+local usermanager_create_user_with_role  = require "prosody.core.usermanager".create_user_with_role;
+local usermanager_set_password = require "prosody.core.usermanager".create_user;
+local usermanager_delete_user  = require "prosody.core.usermanager".delete_user;
+local nodeprep = require "prosody.util.encodings".stringprep.nodeprep;
+local util_error = require "prosody.util.error";
 
-local additional_fields = module:get_option("additional_registration_fields", {});
+local additional_fields = module:get_option_array("additional_registration_fields", {});
 local require_encryption = module:get_option_boolean("c2s_require_encryption",
 	module:get_option_boolean("require_encryption", true));
 
+local default_role = module:get_option_string("register_ibr_default_role", "prosody:registered");
+
 pcall(function ()
 	module:depends("register_limits");
 end);
@@ -166,7 +168,12 @@
 		return true;
 	end
 
-	local user = { username = username, password = password, host = host, additional = data, ip = session.ip, session = session, allowed = true }
+	local user = {
+		username = username, password = password, host = host;
+		additional = data, ip = session.ip, session = session;
+		role = default_role;
+		allowed = true;
+	};
 	module:fire_event("user-registering", user);
 	if not user.allowed then
 		local error_type, error_condition, reason;
@@ -200,7 +207,7 @@
 		end
 	end
 
-	local created, err = usermanager_create_user(username, password, host);
+	local created, err = usermanager_create_user_with_role(username, password, host, user.role);
 	if created then
 		data.registered = os.time();
 		if not account_details:set(username, data) then
--- a/plugins/mod_register_limits.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_register_limits.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,23 +7,23 @@
 --
 
 
-local create_throttle = require "util.throttle".create;
-local new_cache = require "util.cache".new;
-local ip_util = require "util.ip";
+local create_throttle = require "prosody.util.throttle".create;
+local new_cache = require "prosody.util.cache".new;
+local ip_util = require "prosody.util.ip";
 local new_ip = ip_util.new_ip;
 local match_ip = ip_util.match;
 local parse_cidr = ip_util.parse_cidr;
-local errors = require "util.error";
+local errors = require "prosody.util.error";
 
 -- COMPAT drop old option names
-local min_seconds_between_registrations = module:get_option_number("min_seconds_between_registrations");
+local min_seconds_between_registrations = module:get_option_period("min_seconds_between_registrations");
 local allowlist_only = module:get_option_boolean("allowlist_registration_only", module:get_option_boolean("whitelist_registration_only"));
 local allowlisted_ips = module:get_option_set("registration_allowlist", module:get_option("registration_whitelist", { "127.0.0.1", "::1" }))._items;
 local blocklisted_ips = module:get_option_set("registration_blocklist", module:get_option_set("registration_blacklist", {}))._items;
 
-local throttle_max = module:get_option_number("registration_throttle_max", min_seconds_between_registrations and 1);
-local throttle_period = module:get_option_number("registration_throttle_period", min_seconds_between_registrations);
-local throttle_cache_size = module:get_option_number("registration_throttle_cache_size", 100);
+local throttle_max = module:get_option_number("registration_throttle_max", min_seconds_between_registrations and 1, 0);
+local throttle_period = module:get_option_period("registration_throttle_period", min_seconds_between_registrations);
+local throttle_cache_size = module:get_option_integer("registration_throttle_cache_size", 100, 1);
 local blocklist_overflow = module:get_option_boolean("blocklist_on_registration_throttle_overload",
 	module:get_option_boolean("blacklist_on_registration_throttle_overload", false));
 
--- a/plugins/mod_roster.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_roster.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,18 +7,18 @@
 --
 
 
-local st = require "util.stanza"
+local st = require "prosody.util.stanza"
 
-local jid_split = require "util.jid".split;
-local jid_resource = require "util.jid".resource;
-local jid_prep = require "util.jid".prep;
+local jid_split = require "prosody.util.jid".split;
+local jid_resource = require "prosody.util.jid".resource;
+local jid_prep = require "prosody.util.jid".prep;
 local tonumber = tonumber;
 local pairs = pairs;
 
-local rm_load_roster = require "core.rostermanager".load_roster;
-local rm_remove_from_roster = require "core.rostermanager".remove_from_roster;
-local rm_add_to_roster = require "core.rostermanager".add_to_roster;
-local rm_roster_push = require "core.rostermanager".roster_push;
+local rm_load_roster = require "prosody.core.rostermanager".load_roster;
+local rm_remove_from_roster = require "prosody.core.rostermanager".remove_from_roster;
+local rm_add_to_roster = require "prosody.core.rostermanager".add_to_roster;
+local rm_roster_push = require "prosody.core.rostermanager".roster_push;
 
 module:add_feature("jabber:iq:roster");
 
--- a/plugins/mod_s2s.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_s2s.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -16,32 +16,32 @@
 local t_insert = table.insert;
 local traceback = debug.traceback;
 
-local add_task = require "util.timer".add_task;
-local stop_timer = require "util.timer".stop;
-local st = require "util.stanza";
-local initialize_filters = require "util.filters".initialize;
-local nameprep = require "util.encodings".stringprep.nameprep;
-local new_xmpp_stream = require "util.xmppstream".new;
-local s2s_new_incoming = require "core.s2smanager".new_incoming;
-local s2s_new_outgoing = require "core.s2smanager".new_outgoing;
-local s2s_destroy_session = require "core.s2smanager".destroy_session;
-local uuid_gen = require "util.uuid".generate;
-local async = require "util.async";
+local add_task = require "prosody.util.timer".add_task;
+local stop_timer = require "prosody.util.timer".stop;
+local st = require "prosody.util.stanza";
+local initialize_filters = require "prosody.util.filters".initialize;
+local nameprep = require "prosody.util.encodings".stringprep.nameprep;
+local new_xmpp_stream = require "prosody.util.xmppstream".new;
+local s2s_new_incoming = require "prosody.core.s2smanager".new_incoming;
+local s2s_new_outgoing = require "prosody.core.s2smanager".new_outgoing;
+local s2s_destroy_session = require "prosody.core.s2smanager".destroy_session;
+local uuid_gen = require "prosody.util.uuid".generate;
+local async = require "prosody.util.async";
 local runner = async.runner;
-local connect = require "net.connect".connect;
-local service = require "net.resolvers.service";
-local resolver_chain = require "net.resolvers.chain";
-local errors = require "util.error";
-local set = require "util.set";
+local connect = require "prosody.net.connect".connect;
+local service = require "prosody.net.resolvers.service";
+local resolver_chain = require "prosody.net.resolvers.chain";
+local errors = require "prosody.util.error";
+local set = require "prosody.util.set";
 
-local connect_timeout = module:get_option_number("s2s_timeout", 90);
-local stream_close_timeout = module:get_option_number("s2s_close_timeout", 5);
+local connect_timeout = module:get_option_period("s2s_timeout", 90);
+local stream_close_timeout = module:get_option_period("s2s_close_timeout", 5);
 local opt_keepalives = module:get_option_boolean("s2s_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true));
 local secure_auth = module:get_option_boolean("s2s_secure_auth", false); -- One day...
 local secure_domains, insecure_domains =
 	module:get_option_set("s2s_secure_domains", {})._items, module:get_option_set("s2s_insecure_domains", {})._items;
 local require_encryption = module:get_option_boolean("s2s_require_encryption", true);
-local stanza_size_limit = module:get_option_number("s2s_stanza_size_limit", 1024*512);
+local stanza_size_limit = module:get_option_integer("s2s_stanza_size_limit", 1024*512, 10000);
 
 local measure_connections_inbound = module:metric(
 	"gauge", "connections_inbound", "",
@@ -95,6 +95,12 @@
 };
 local s2s_service_options_mt = { __index = s2s_service_options }
 
+if module:get_option_boolean("use_dane", false) then
+	-- DANE is supported in net.connect but only for outgoing connections,
+	-- to authenticate incoming connections with DANE we need
+	module:depends("s2s_auth_dane_in");
+end
+
 module:hook("stats-update", function ()
 	measure_connections_inbound:clear()
 	measure_connections_outbound:clear()
@@ -146,17 +152,17 @@
 	elseif type(reason) == "string" then
 		reason_text = reason;
 	end
-	for i, data in ipairs(sendq) do
-		local reply = data[2];
-		if reply and not(reply.attr.xmlns) and bouncy_stanzas[reply.name] then
-			reply.attr.type = "error";
-			reply:tag("error", {type = error_type, by = session.from_host})
-				:tag(condition, {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}):up();
-			if reason_text then
-				reply:tag("text", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"})
-					:text("Server-to-server connection failed: "..reason_text):up();
-			end
+	for i, stanza in ipairs(sendq) do
+		if not stanza.attr.xmlns and bouncy_stanzas[stanza.name] and stanza.attr.type ~= "error" and stanza.attr.type ~= "result" then
+			local reply = st.error_reply(
+				stanza,
+				error_type,
+				condition,
+				reason_text and ("Server-to-server connection failed: "..reason_text) or nil
+			);
 			core_process_stanza(dummy, reply);
+		else
+			(session.log or log)("debug", "Not eligible for bouncing, discarding %s", stanza:top_tag());
 		end
 		sendq[i] = nil;
 	end
@@ -182,15 +188,11 @@
 		(host.log or log)("debug", "trying to send over unauthed s2sout to "..to_host);
 
 		-- Queue stanza until we are able to send it
-		local queued_item = {
-			tostring(stanza),
-			stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza);
-		};
 		if host.sendq then
-			t_insert(host.sendq, queued_item);
+			t_insert(host.sendq, st.clone(stanza));
 		else
 			-- luacheck: ignore 122
-			host.sendq = { queued_item };
+			host.sendq = { st.clone(stanza) };
 		end
 		host.log("debug", "stanza [%s] queued ", stanza.name);
 		return true;
@@ -215,7 +217,7 @@
 
 	-- Store in buffer
 	host_session.bounce_sendq = bounce_sendq;
-	host_session.sendq = { {tostring(stanza), stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza)} };
+	host_session.sendq = { st.clone(stanza) };
 	log("debug", "stanza [%s] queued until connection complete", stanza.name);
 	-- FIXME Cleaner solution to passing extra data from resolvers to net.server
 	-- This mt-clone allows resolvers to add extra data, currently used for DANE TLSA records
@@ -255,9 +257,32 @@
 	end
 	module:hook("route/remote", route_to_existing_session, -1);
 	module:hook("route/remote", route_to_new_session, -10);
+	module:hook("s2sout-stream-features", function (event)
+		if stanza_size_limit then
+			event.features:tag("limits", { xmlns = "urn:xmpp:stream-limits:0" })
+				:text_tag("max-bytes", string.format("%d", stanza_size_limit)):up();
+		end
+	end);
+	module:hook_tag("urn:xmpp:bidi", "bidi", function(session, stanza)
+		-- Advertising features on bidi connections where no <stream:features> is sent in the other direction
+		local limits = stanza:get_child("limits", "urn:xmpp:stream-limits:0");
+		if limits then
+			session.outgoing_stanza_size_limit = tonumber(limits:get_child_text("max-bytes"));
+		end
+	end, 100);
 	module:hook("s2s-authenticated", make_authenticated, -1);
 	module:hook("s2s-read-timeout", keepalive, -1);
+	module:hook("smacks-ack-delayed", function (event)
+		if event.origin.type == "s2sin" or event.origin.type == "s2sout" then
+			event.origin:close("connection-timeout");
+			return true;
+		end
+	end, -1);
 	module:hook_stanza("http://etherx.jabber.org/streams", "features", function (session, stanza) -- luacheck: ignore 212/stanza
+		local limits = stanza:get_child("limits", "urn:xmpp:stream-limits:0");
+		if limits then
+			session.outgoing_stanza_size_limit = tonumber(limits:get_child_text("max-bytes"));
+		end
 		if session.type == "s2sout" then
 			-- Stream is authenticated and we are seem to be done with feature negotiation,
 			-- so the stream is ready for stanzas.  RFC 6120 Section 4.3
@@ -283,7 +308,7 @@
 	function module.unload()
 		if module.reloading then return end
 		for _, session in pairs(sessions) do
-			if session.to_host == module.host or session.from_host == module.host then
+			if session.host == module.host then
 				session:close("host-gone");
 			end
 		end
@@ -328,8 +353,8 @@
 		if sendq then
 			session.log("debug", "sending %d queued stanzas across new outgoing connection to %s", #sendq, session.to_host);
 			local send = session.sends2s;
-			for i, data in ipairs(sendq) do
-				send(data[1]);
+			for i, stanza in ipairs(sendq) do
+				send(stanza);
 				sendq[i] = nil;
 			end
 			session.sendq = nil;
@@ -393,10 +418,10 @@
 --- Helper to check that a session peer's certificate is valid
 local function check_cert_status(session)
 	local host = session.direction == "outgoing" and session.to_host or session.from_host
-	local conn = session.conn:socket()
+	local conn = session.conn
 	local cert
-	if conn.getpeercertificate then
-		cert = conn:getpeercertificate()
+	if conn.ssl_peercertificate then
+		cert = conn:ssl_peercertificate()
 	end
 
 	return module:fire_event("s2s-check-certificate", { host = host, session = session, cert = cert });
@@ -408,8 +433,7 @@
 	session.secure = true;
 	session.encrypted = true;
 
-	local sock = session.conn:socket();
-	local info = sock.info and sock:info();
+	local info = session.conn:ssl_info();
 	if type(info) == "table" then
 		(session.log or log)("info", "Stream encrypted (%s with %s)", info.protocol, info.cipher);
 		session.compressed = info.compression;
@@ -438,7 +462,8 @@
 	session.had_stream = true; -- Had a stream opened at least once
 
 	-- TODO: Rename session.secure to session.encrypted
-	if session.secure == false then
+	if session.secure == false then -- Set by mod_tls during STARTTLS handshake
+		session.starttls = "completed";
 		session_secure(session);
 	end
 
@@ -526,6 +551,12 @@
 			end
 
 			if ( session.type == "s2sin" or session.type == "s2sout" ) or features.tags[1] then
+				if stanza_size_limit then
+					features:reset();
+					features:tag("limits", { xmlns = "urn:xmpp:stream-limits:0" })
+						:text_tag("max-bytes", string.format("%d", stanza_size_limit)):up();
+				end
+
 				log("debug", "Sending stream features: %s", features);
 				session.sends2s(features);
 			else
@@ -760,6 +791,7 @@
 	local w = conn.write;
 
 	if conn:ssl() then
+		-- Direct TLS was used
 		session_secure(session);
 	end
 
@@ -770,6 +802,11 @@
 		end
 		if t then
 			t = filter("bytes/out", tostring(t));
+			if session.outgoing_stanza_size_limit and #t > session.outgoing_stanza_size_limit then
+				log("warn", "Attempt to send a stanza exceeding session limit of %dB (%dB)!", session.outgoing_stanza_size_limit, #t);
+				-- TODO Pass identifiable error condition back to allow appropriate handling
+				return false
+			end
 			if t then
 				return w(conn, t);
 			end
@@ -938,6 +975,18 @@
 				return "has expired";
 			elseif cert_errors:contains("self signed certificate") then
 				return "is self-signed";
+			elseif cert_errors:contains("no matching DANE TLSA records") then
+				return "does not match any DANE TLSA records";
+			end
+
+			local chain_errors = set.new(session.cert_chain_errors[2]);
+			for i, e in pairs(session.cert_chain_errors) do
+				if i > 2 then chain_errors:add_list(e); end
+			end
+			if chain_errors:contains("certificate has expired") then
+				return "has an expired certificate chain";
+			elseif chain_errors:contains("no matching DANE TLSA records") then
+				return "does not match any DANE TLSA records";
 			end
 		end
 		return "is not trusted"; -- for some other reason
@@ -966,6 +1015,8 @@
 		-- In practice most cases are configuration mistakes or forgotten
 		-- certificate renewals. We think it's better to let the other party
 		-- know about the problem so that they can fix it.
+		--
+		-- Note: Bounce message must not include name of server, as it may leak half your JID in semi-anon MUCs.
 		session:close({ condition = "not-authorized", text = "Your server's certificate "..reason },
 			nil, "Remote server's certificate "..reason);
 		return false;
@@ -976,7 +1027,7 @@
 
 module:hook("server-stopping", function(event)
 	-- Close ports
-	local pm = require "core.portmanager";
+	local pm = require "prosody.core.portmanager";
 	for _, netservice in pairs(module.items["net-provider"]) do
 		pm.unregister_service(netservice.name, netservice);
 	end
--- a/plugins/mod_s2s_auth_certs.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_s2s_auth_certs.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,7 +1,6 @@
 module:set_global();
 
-local cert_verify_identity = require "util.x509".verify_identity;
-local NULL = {};
+local cert_verify_identity = require "prosody.util.x509".verify_identity;
 local log = module._log;
 
 local measure_cert_statuses = module:metric("counter", "checked", "", "Certificate validation results",
@@ -9,25 +8,26 @@
 
 module:hook("s2s-check-certificate", function(event)
 	local session, host, cert = event.session, event.host, event.cert;
-	local conn = session.conn:socket();
+	local conn = session.conn;
 	local log = session.log or log;
 
+	local secure_hostname = conn.extra and conn.extra.secure_hostname;
+
 	if not cert then
 		log("warn", "No certificate provided by %s", host or "unknown host");
 		return;
 	end
 
-	local chain_valid, errors;
-	if conn.getpeerverification then
-		chain_valid, errors = conn:getpeerverification();
-	else
-		chain_valid, errors = false, { { "Chain verification not supported by this version of LuaSec" } };
-	end
+	local chain_valid, errors = conn:ssl_peerverification();
 	-- Is there any interest in printing out all/the number of errors here?
 	if not chain_valid then
 		log("debug", "certificate chain validation result: invalid");
-		for depth, t in pairs(errors or NULL) do
-			log("debug", "certificate error(s) at depth %d: %s", depth-1, table.concat(t, ", "))
+		if type(errors) == "table" then
+			for depth, t in pairs(errors) do
+				log("debug", "certificate error(s) at depth %d: %s", depth-1, table.concat(t, ", "));
+			end
+		else
+			log("debug", "certificate error: %s", errors);
 		end
 		session.cert_chain_status = "invalid";
 		session.cert_chain_errors = errors;
@@ -45,6 +45,14 @@
 			end
 			log("debug", "certificate identity validation result: %s", session.cert_identity_status);
 		end
+
+		-- Check for DNSSEC-signed SRV hostname
+		if secure_hostname and session.cert_identity_status ~= "valid" then
+			if cert_verify_identity(secure_hostname, "xmpp-server", cert) then
+				module:log("info", "Secure SRV name delegation %q -> %q", secure_hostname, host);
+				session.cert_identity_status = "valid"
+			end
+		end
 	end
 	measure_cert_statuses:with_labels(session.cert_chain_status or "unknown", session.cert_identity_status or "unknown"):add(1);
 end, 509);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_s2s_auth_dane_in.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,130 @@
+module:set_global();
+
+local dns = require "prosody.net.adns";
+local async = require "prosody.util.async";
+local encodings = require "prosody.util.encodings";
+local hashes = require "prosody.util.hashes";
+local promise = require "prosody.util.promise";
+local x509 = require "prosody.util.x509";
+
+local idna_to_ascii = encodings.idna.to_ascii;
+local sha256 = hashes.sha256;
+local sha512 = hashes.sha512;
+
+local use_dane = module:get_option_boolean("use_dane", nil);
+if use_dane == nil then
+	module:log("warn", "DANE support incomplete, add use_dane = true in the global section to support outgoing s2s connections");
+elseif use_dane == false then
+	module:log("debug", "DANE support disabled with use_dane = false, disabling.")
+	return
+end
+
+local function ensure_secure(r)
+	assert(r.secure, "insecure");
+	return r;
+end
+
+local function ensure_nonempty(r)
+	assert(r[1], "empty");
+	return r;
+end
+
+local function flatten(a)
+	local seen = {};
+	local ret = {};
+	for _, rrset in ipairs(a) do
+		for _, rr in ipairs(rrset) do
+			if not seen[tostring(rr)] then
+				table.insert(ret, rr);
+				seen[tostring(rr)] = true;
+			end
+		end
+	end
+	return ret;
+end
+
+local lazy_tlsa_mt = {
+	__index = function(t, i)
+		if i == 1 then
+			local h = sha256(t[0]);
+			t[1] = h;
+			return h;
+		elseif i == 2 then
+			local h = sha512(t[0]);
+			t[1] = h;
+			return h;
+		end
+	end;
+}
+local function lazy_hash(t)
+	return setmetatable(t, lazy_tlsa_mt);
+end
+
+module:hook("s2s-check-certificate", function(event)
+	local session, host, cert = event.session, event.host, event.cert;
+	local log = session.log or module._log;
+
+	if not host or not cert or session.direction ~= "incoming" then
+		return
+	end
+
+	local by_select_match = {
+		[0] = lazy_hash {
+			-- cert
+			[0] = x509.pem2der(cert:pem());
+
+		};
+	}
+	if cert.pubkey then
+		by_select_match[1] = lazy_hash {
+			-- spki
+			[0] = x509.pem2der(cert:pubkey());
+		};
+	end
+
+	local resolver = dns.resolver();
+
+	local dns_domain = idna_to_ascii(host);
+
+	local function fetch_tlsa(res)
+		local tlsas = {};
+		for _, rr in ipairs(res) do
+			if rr.srv.target == "." then return {}; end
+			table.insert(tlsas, resolver:lookup_promise(("_%d._tcp.%s"):format(rr.srv.port, rr.srv.target), "TLSA"):next(ensure_secure));
+		end
+		return promise.all(tlsas):next(flatten);
+	end
+
+	local ret = async.wait_for(resolver:lookup_promise("_xmpp-server." .. dns_domain, "TLSA"):next(ensure_secure):next(ensure_nonempty):catch(function()
+		return promise.all({
+			resolver:lookup_promise("_xmpps-server._tcp." .. dns_domain, "SRV"):next(ensure_secure):next(fetch_tlsa);
+			resolver:lookup_promise("_xmpp-server._tcp." .. dns_domain, "SRV"):next(ensure_secure):next(fetch_tlsa);
+		}):next(flatten);
+	end));
+
+	if not ret then
+		return
+	end
+
+	local found_supported = false;
+	for _, rr in ipairs(ret) do
+		if rr.tlsa.use == 3 and by_select_match[rr.tlsa.select] and rr.tlsa.match <= 2 then
+			found_supported = true;
+			if rr.tlsa.data == by_select_match[rr.tlsa.select][rr.tlsa.match] then
+				module:log("debug", "%s matches", rr)
+				session.cert_chain_status = "valid";
+				session.cert_identity_status = "valid";
+				return true;
+			end
+		else
+			log("debug", "Unsupported DANE TLSA record: %s", rr);
+		end
+	end
+
+	if found_supported then
+		session.cert_chain_status = "invalid";
+		session.cert_identity_status = nil;
+		return true;
+	end
+
+end, 800);
--- a/plugins/mod_s2s_bidi.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_s2s_bidi.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -5,7 +5,7 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 local xmlns_bidi_feature = "urn:xmpp:features:bidi"
 local xmlns_bidi = "urn:xmpp:bidi";
@@ -25,7 +25,9 @@
 		if bidi then
 			session.incoming = true;
 			session.log("debug", "Requesting bidirectional stream");
-			session.sends2s(st.stanza("bidi", { xmlns = xmlns_bidi }));
+			local request_bidi = st.stanza("bidi", { xmlns = xmlns_bidi });
+			module:fire_event("s2sout-stream-features", { origin = session, features = request_bidi });
+			session.sends2s(request_bidi);
 		end
 	end
 end, 200);
--- a/plugins/mod_saslauth.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_saslauth.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,19 +8,26 @@
 -- luacheck: ignore 431/log
 
 
-local st = require "util.stanza";
-local sm_bind_resource = require "core.sessionmanager".bind_resource;
-local sm_make_authenticated = require "core.sessionmanager".make_authenticated;
-local base64 = require "util.encodings".base64;
-local set = require "util.set";
-local errors = require "util.error";
+local st = require "prosody.util.stanza";
+local sm_bind_resource = require "prosody.core.sessionmanager".bind_resource;
+local sm_make_authenticated = require "prosody.core.sessionmanager".make_authenticated;
+local base64 = require "prosody.util.encodings".base64;
+local set = require "prosody.util.set";
+local errors = require "prosody.util.error";
+local hex = require "prosody.util.hex";
+local pem2der = require"util.x509".pem2der;
+local hashes = require"util.hashes";
+local ssl = require "ssl"; -- FIXME Isolate LuaSec from the rest of the code
 
-local usermanager_get_sasl_handler = require "core.usermanager".get_sasl_handler;
+local certmanager = require "core.certmanager";
+local pm_get_tls_config_at = require "prosody.core.portmanager".get_tls_config_at;
+local usermanager_get_sasl_handler = require "prosody.core.usermanager".get_sasl_handler;
 
 local secure_auth_only = module:get_option_boolean("c2s_require_encryption", module:get_option_boolean("require_encryption", true));
 local allow_unencrypted_plain_auth = module:get_option_boolean("allow_unencrypted_plain_auth", false)
 local insecure_mechanisms = module:get_option_set("insecure_sasl_mechanisms", allow_unencrypted_plain_auth and {} or {"PLAIN", "LOGIN"});
 local disabled_mechanisms = module:get_option_set("disable_sasl_mechanisms", { "DIGEST-MD5" });
+local tls_server_end_point_hash = module:get_option_string("tls_server_end_point_hash");
 
 local log = module._log;
 
@@ -49,11 +56,14 @@
 		return "failure", "temporary-auth-failure", "Connection gone";
 	end
 	if status == "failure" then
-		module:fire_event("authentication-failure", { session = session, condition = ret, text = err_msg });
+		local event = { session = session, condition = ret, text = err_msg };
+		module:fire_event("authentication-failure", event);
 		session.sasl_handler = session.sasl_handler:clean_clone();
+		ret, err_msg = event.condition, event.text;
 	elseif status == "success" then
-		local ok, err = sm_make_authenticated(session, session.sasl_handler.username, session.sasl_handler.scope);
+		local ok, err = sm_make_authenticated(session, session.sasl_handler.username, session.sasl_handler.role);
 		if ok then
+			session.sasl_resource = session.sasl_handler.resource;
 			module:fire_event("authentication-success", { session = session });
 			session.sasl_handler = nil;
 			session:reset_stream();
@@ -77,9 +87,12 @@
 			return true;
 		end
 	end
-	local status, ret, err_msg = session.sasl_handler:process(text);
+	local sasl_handler = session.sasl_handler;
+	local status, ret, err_msg = sasl_handler:process(text);
 	status, ret, err_msg = handle_status(session, status, ret, err_msg);
-	local s = build_reply(status, ret, err_msg);
+	local event = { session = session, message = ret, error_text = err_msg };
+	module:fire_event("sasl/"..session.base_type.."/"..status, event);
+	local s = build_reply(status, event.message, event.error_text);
 	session.send(s);
 	return true;
 end
@@ -205,6 +218,12 @@
 
 	if session.type ~= "c2s_unauthed" or module:get_host_type() ~= "local" then return; end
 
+	-- event for preemptive checks, rate limiting etc
+	module:fire_event("authentication-attempt", event);
+	if event.allowed == false then
+		session.send(build_reply("failure", event.error_condition or "not-authorized", event.error_text));
+		return true;
+	end
 	if session.sasl_handler and session.sasl_handler.selected then
 		session.sasl_handler = nil; -- allow starting a new SASL negotiation before completing an old one
 	end
@@ -242,7 +261,53 @@
 end);
 
 local function tls_unique(self)
-	return self.userdata["tls-unique"]:getpeerfinished();
+	return self.userdata["tls-unique"]:ssl_peerfinished();
+end
+
+local function tls_exporter(conn)
+	if not conn.ssl_exportkeyingmaterial then return end
+	return conn:ssl_exportkeyingmaterial("EXPORTER-Channel-Binding", 32, "");
+end
+
+local function sasl_tls_exporter(self)
+	return tls_exporter(self.userdata["tls-exporter"]);
+end
+
+local function tls_server_end_point(self)
+	local cert_hash = self.userdata["tls-server-end-point"];
+	if cert_hash then return hex.from(cert_hash); end
+
+	local conn = self.userdata["tls-server-end-point-conn"];
+	local cert = conn.getlocalcertificate and conn:getlocalcertificate();
+
+	if not cert then
+		-- We don't know that this is the right cert, it could have been replaced on
+		-- disk since we started.
+		local certfile = self.userdata["tls-server-end-point-cert"];
+		if not certfile then return end
+		local f = io.open(certfile);
+		if not f then return end
+		local certdata = f:read("*a");
+		f:close();
+		cert = ssl.loadcertificate(certdata);
+	end
+
+	-- Hash function selection, see RFC 5929 §4.1
+	local hash, hash_name = hashes.sha256, "sha256";
+	if cert.getsignaturename then
+		local sigalg = cert:getsignaturename():lower():match("sha%d+");
+		if sigalg and sigalg ~= "sha1" and hashes[sigalg] then
+			-- This should have ruled out MD5 and SHA1
+			hash, hash_name = hashes[sigalg], sigalg;
+		end
+	end
+
+	local certdata_der = pem2der(cert:pem());
+	local hashed_der = hash(certdata_der);
+
+	module:log("debug", "tls-server-end-point: hex(%s(der)) = %q, hash = %s", hash_name, hex.encode(hashed_der));
+
+	return hashed_der;
 end
 
 local mechanisms_attr = { xmlns='urn:ietf:params:xml:ns:xmpp-sasl' };
@@ -258,22 +323,58 @@
 		end
 		local sasl_handler = usermanager_get_sasl_handler(module.host, origin)
 		origin.sasl_handler = sasl_handler;
+		local channel_bindings = set.new()
 		if origin.encrypted then
 			-- check whether LuaSec has the nifty binding to the function needed for tls-unique
 			-- FIXME: would be nice to have this check only once and not for every socket
 			if sasl_handler.add_cb_handler then
-				local socket = origin.conn:socket();
-				local info = socket.info and socket:info();
-				if info.protocol == "TLSv1.3" then
+				local info = origin.conn:ssl_info();
+				if info and info.protocol == "TLSv1.3" then
 					log("debug", "Channel binding 'tls-unique' undefined in context of TLS 1.3");
-				elseif socket.getpeerfinished and socket:getpeerfinished() then
+					if tls_exporter(origin.conn) then
+						log("debug", "Channel binding 'tls-exporter' supported");
+						sasl_handler:add_cb_handler("tls-exporter", sasl_tls_exporter);
+						channel_bindings:add("tls-exporter");
+					end
+				elseif origin.conn.ssl_peerfinished and origin.conn:ssl_peerfinished() then
 					log("debug", "Channel binding 'tls-unique' supported");
 					sasl_handler:add_cb_handler("tls-unique", tls_unique);
+					channel_bindings:add("tls-unique");
 				else
 					log("debug", "Channel binding 'tls-unique' not supported (by LuaSec?)");
 				end
+
+				local certfile;
+				if tls_server_end_point_hash == "auto" then
+					tls_server_end_point_hash = nil;
+					local ssl_cfg = origin.ssl_cfg;
+					if not ssl_cfg then
+						local server = origin.conn:server();
+						local tls_config = pm_get_tls_config_at(server:ip(), server:serverport());
+						local autocert = certmanager.find_host_cert(origin.conn:socket():getsniname());
+						ssl_cfg = autocert or tls_config;
+					end
+
+					certfile = ssl_cfg and ssl_cfg.certificate;
+					if certfile then
+						log("debug", "Channel binding 'tls-server-end-point' can be offered based on the certificate used");
+						sasl_handler:add_cb_handler("tls-server-end-point", tls_server_end_point);
+						channel_bindings:add("tls-server-end-point");
+					else
+						log("debug", "Channel binding 'tls-server-end-point' set to 'auto' but cannot determine cert");
+					end
+				elseif tls_server_end_point_hash then
+					log("debug", "Channel binding 'tls-server-end-point' can be offered with the configured certificate hash");
+					sasl_handler:add_cb_handler("tls-server-end-point", tls_server_end_point);
+					channel_bindings:add("tls-server-end-point");
+				end
+
 				sasl_handler["userdata"] = {
-					["tls-unique"] = socket;
+					["tls-unique"] = origin.conn;
+					["tls-exporter"] = origin.conn;
+					["tls-server-end-point-cert"] = certfile;
+					["tls-server-end-point-conn"] = origin.conn;
+					["tls-server-end-point"] = tls_server_end_point_hash;
 				};
 			else
 				log("debug", "Channel binding not supported by SASL handler");
@@ -306,6 +407,14 @@
 				mechanisms:tag("mechanism"):text(mechanism):up();
 			end
 			features:add_child(mechanisms);
+			if not channel_bindings:empty() then
+				-- XXX XEP-0440 is Experimental
+				features:tag("sasl-channel-binding", {xmlns='urn:xmpp:sasl-cb:0'})
+				for channel_binding in channel_bindings do
+					features:tag("channel-binding", {type=channel_binding}):up()
+				end
+				features:up();
+			end
 			return;
 		end
 
@@ -328,7 +437,7 @@
 				authmod, available_disabled);
 		end
 
-	else
+	elseif not origin.full_jid then
 		features:tag("bind", bind_attr):tag("required"):up():up();
 		features:tag("session", xmpp_session_attr):tag("optional"):up():up();
 	end
@@ -350,14 +459,15 @@
 
 module:hook("stanza/iq/urn:ietf:params:xml:ns:xmpp-bind:bind", function(event)
 	local origin, stanza = event.origin, event.stanza;
-	local resource;
-	if stanza.attr.type == "set" then
+	local resource = origin.sasl_resource;
+	if stanza.attr.type == "set" and not resource then
 		local bind = stanza.tags[1];
 		resource = bind:get_child("resource");
 		resource = resource and #resource.tags == 0 and resource[1] or nil;
 	end
 	local success, err_type, err, err_msg = sm_bind_resource(origin, resource);
 	if success then
+		origin.sasl_resource = nil;
 		origin.send(st.reply(stanza)
 			:tag("bind", { xmlns = xmlns_bind })
 			:tag("jid"):text(origin.full_jid));
--- a/plugins/mod_scansion_record.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_scansion_record.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -2,11 +2,11 @@
 local devices = { "", "phone", "laptop", "tablet", "toaster", "fridge", "shoe" };
 local users = {};
 
-local filters = require "util.filters";
-local id = require "util.id";
-local dt = require "util.datetime";
-local dm = require "util.datamanager";
-local st = require "util.stanza";
+local filters = require "prosody.util.filters";
+local id = require "prosody.util.id";
+local dt = require "prosody.util.datetime";
+local dm = require "prosody.util.datamanager";
+local st = require "prosody.util.stanza";
 
 local record_id = id.short():lower();
 local record_date = os.date("%Y%b%d"):lower();
--- a/plugins/mod_server_contact_info.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_server_contact_info.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,21 +6,22 @@
 -- COPYING file in the source package for more information.
 --
 
-local array = require "util.array";
-local jid = require "util.jid";
+local array = require "prosody.util.array";
+local jid = require "prosody.util.jid";
 local url = require "socket.url";
 
+module:depends("server_info");
+
 -- Source: http://xmpp.org/registrar/formtypes.html#http:--jabber.org-network-serverinfo
-local form_layout = require "util.dataforms".new({
-	{ var = "FORM_TYPE"; type = "hidden"; value = "http://jabber.org/network/serverinfo"; };
-	{ name = "abuse", var = "abuse-addresses", type = "list-multi" },
-	{ name = "admin", var = "admin-addresses", type = "list-multi" },
-	{ name = "feedback", var = "feedback-addresses", type = "list-multi" },
-	{ name = "sales", var = "sales-addresses", type = "list-multi" },
-	{ name = "security", var = "security-addresses", type = "list-multi" },
-	{ name = "status", var = "status-addresses", type = "list-multi" },
-	{ name = "support", var = "support-addresses", type = "list-multi" },
-});
+local address_types = {
+	abuse = "abuse-addresses";
+	admin = "admin-addresses";
+	feedback = "feedback-addresses";
+	sales = "sales-addresses";
+	security = "security-addresses";
+	status = "status-addresses";
+	support = "support-addresses";
+};
 
 -- JIDs of configured service admins are used as fallback
 local admins = module:get_option_inherited_set("admins", {});
@@ -29,4 +30,17 @@
 	admin = array.collect(admins / jid.prep / function(admin) return url.build({scheme = "xmpp"; path = admin}); end);
 });
 
-module:add_extension(form_layout:form(contact_config, "result"));
+local fields = {};
+
+for key, field_var in pairs(address_types) do
+	if contact_config[key] then
+		table.insert(fields, {
+			type = "list-multi";
+			name = key;
+			var = field_var;
+			value = contact_config[key];
+		});
+	end
+end
+
+module:add_item("server-info-fields", fields);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_server_info.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,55 @@
+local dataforms = require "prosody.util.dataforms";
+
+local server_info_config = module:get_option("server_info", {});
+local server_info_custom_fields = module:get_option_array("server_info_extensions");
+
+-- Source: http://xmpp.org/registrar/formtypes.html#http:--jabber.org-network-serverinfo
+local form_layout = dataforms.new({
+	{ var = "FORM_TYPE"; type = "hidden"; value = "http://jabber.org/network/serverinfo" };
+});
+
+if server_info_custom_fields then
+	for _, field in ipairs(server_info_custom_fields) do
+		table.insert(form_layout, field);
+	end
+end
+
+local generated_form;
+
+function update_form()
+	local new_form = form_layout:form(server_info_config, "result");
+	if generated_form then
+		module:remove_item("extension", generated_form);
+	end
+	generated_form = new_form;
+	module:add_item("extension", generated_form);
+end
+
+function add_fields(event)
+	local fields = event.item;
+	for _, field in ipairs(fields) do
+		table.insert(form_layout, field);
+	end
+	update_form();
+end
+
+function remove_fields(event)
+	local removed_fields = event.item;
+	for _, removed_field in ipairs(removed_fields) do
+		local removed_var = removed_field.var or removed_field.name;
+		for i, field in ipairs(form_layout) do
+			local var = field.var or field.name
+			if var == removed_var then
+				table.remove(form_layout, i);
+				break;
+			end
+		end
+	end
+	update_form();
+end
+
+module:handle_items("server-info-fields", add_fields, remove_fields);
+
+function module.load()
+	update_form();
+end
--- a/plugins/mod_smacks.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_smacks.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -2,7 +2,7 @@
 --
 -- Copyright (C) 2010-2015 Matthew Wild
 -- Copyright (C) 2010 Waqas Hussain
--- Copyright (C) 2012-2021 Kim Alvefur
+-- Copyright (C) 2012-2022 Kim Alvefur
 -- Copyright (C) 2012 Thijs Alkemade
 -- Copyright (C) 2014 Florian Zeitz
 -- Copyright (C) 2016-2020 Thilo Molitor
@@ -10,6 +10,7 @@
 -- This project is MIT/X11 licensed. Please see the
 -- COPYING file in the source package for more information.
 --
+-- TODO unify sendq and smqueue
 
 local tonumber = tonumber;
 local tostring = tostring;
@@ -38,23 +39,23 @@
 	"histogram",
 	"resumption_age", "seconds", "time the session had been hibernating at the time of a resumption",
 	{},
-	{buckets = { 0, 1, 2, 5, 10, 30, 60, 120, 300, 600 }}
+	{buckets = {0, 1, 12, 60, 360, 900, 1440, 3600, 14400, 86400}}
 ):with_labels();
 local sessions_expired = module:measure("sessions_expired", "counter");
 local sessions_started = module:measure("sessions_started", "counter");
 
 
-local datetime = require "util.datetime";
-local add_filter = require "util.filters".add_filter;
-local jid = require "util.jid";
-local smqueue = require "util.smqueue";
-local st = require "util.stanza";
-local timer = require "util.timer";
-local new_id = require "util.id".short;
-local watchdog = require "util.watchdog";
-local it = require"util.iterators";
+local datetime = require "prosody.util.datetime";
+local add_filter = require "prosody.util.filters".add_filter;
+local jid = require "prosody.util.jid";
+local smqueue = require "prosody.util.smqueue";
+local st = require "prosody.util.stanza";
+local timer = require "prosody.util.timer";
+local new_id = require "prosody.util.id".short;
+local watchdog = require "prosody.util.watchdog";
+local it = require"prosody.util.iterators";
 
-local sessionmanager = require "core.sessionmanager";
+local sessionmanager = require "prosody.core.sessionmanager";
 
 local xmlns_errors = "urn:ietf:params:xml:ns:xmpp-stanzas";
 local xmlns_delay = "urn:xmpp:delay";
@@ -65,14 +66,14 @@
 local sm2_attr = { xmlns = xmlns_sm2 };
 local sm3_attr = { xmlns = xmlns_sm3 };
 
-local queue_size = module:get_option_number("smacks_max_queue_size", 500);
-local resume_timeout = module:get_option_number("smacks_hibernation_time", 600);
+local queue_size = module:get_option_integer("smacks_max_queue_size", 500, 1);
+local resume_timeout = module:get_option_period("smacks_hibernation_time", "10 minutes");
 local s2s_smacks = module:get_option_boolean("smacks_enabled_s2s", true);
 local s2s_resend = module:get_option_boolean("smacks_s2s_resend", false);
-local max_unacked_stanzas = module:get_option_number("smacks_max_unacked_stanzas", 0);
-local max_inactive_unacked_stanzas = module:get_option_number("smacks_max_inactive_unacked_stanzas", 256);
-local delayed_ack_timeout = module:get_option_number("smacks_max_ack_delay", 30);
-local max_old_sessions = module:get_option_number("smacks_max_old_sessions", 10);
+local max_unacked_stanzas = module:get_option_integer("smacks_max_unacked_stanzas", 0, 0);
+local max_inactive_unacked_stanzas = module:get_option_integer("smacks_max_inactive_unacked_stanzas", 256, 0);
+local delayed_ack_timeout = module:get_option_period("smacks_max_ack_delay", 30);
+local max_old_sessions = module:get_option_integer("smacks_max_old_sessions", 10, 0);
 
 local c2s_sessions = module:shared("/*/c2s/sessions");
 local local_sessions = prosody.hosts[module.host].sessions;
@@ -83,13 +84,43 @@
 local old_session_registry = module:open_store("smacks_h", "map");
 local session_registry = module:shared "/*/smacks/resumption-tokens"; -- > user@host/resumption-token --> resource
 
-local ack_errors = require"util.error".init("mod_smacks", xmlns_sm3, {
+local function registry_key(session, id)
+	return jid.join(session.username, session.host, id or session.resumption_token);
+end
+
+local function track_session(session, id)
+	session_registry[registry_key(session, id)] = session;
+	session.resumption_token = id;
+end
+
+local function save_old_session(session)
+	session_registry[registry_key(session)] = nil;
+	return old_session_registry:set(session.username, session.resumption_token,
+		{ h = session.handled_stanza_count; t = os.time() })
+end
+
+local function clear_old_session(session, id)
+	session_registry[registry_key(session, id)] = nil;
+	return old_session_registry:set(session.username, id or session.resumption_token, nil)
+end
+
+local ack_errors = require"prosody.util.error".init("mod_smacks", xmlns_sm3, {
 	head = { condition = "undefined-condition"; text = "Client acknowledged more stanzas than sent by server" };
 	tail = { condition = "undefined-condition"; text = "Client acknowledged less stanzas than already acknowledged" };
 	pop = { condition = "internal-server-error"; text = "Something went wrong with Stream Management" };
 	overflow = { condition = "resource-constraint", text = "Too many unacked stanzas remaining, session can't be resumed" }
 });
 
+local enable_errors = require "prosody.util.error".init("mod_smacks", xmlns_sm3, {
+	already_enabled = { condition = "unexpected-request", text = "Stream management is already enabled" };
+	bind_required = { condition = "unexpected-request", text = "Client must bind a resource before enabling stream management" };
+	unavailable = { condition = "service-unavailable", text = "Stream management is not available for this stream" };
+	-- Resumption
+	expired = { condition = "item-not-found", text = "Session expired, and cannot be resumed" };
+	already_bound = { condition = "unexpected-request", text = "Cannot resume another session after a resource is bound" };
+	unknown_session = { condition = "item-not-found", text = "Unknown session" };
+});
+
 -- COMPAT note the use of compatibility wrapper in events (queue:table())
 
 local function ack_delayed(session, stanza)
@@ -104,18 +135,18 @@
 end
 
 local function can_do_smacks(session, advertise_only)
-	if session.smacks then return false, "unexpected-request", "Stream management is already enabled"; end
+	if session.smacks then return false, enable_errors.new("already_enabled"); end
 
 	local session_type = session.type;
 	if session.username then
 		if not(advertise_only) and not(session.resource) then -- Fail unless we're only advertising sm
-			return false, "unexpected-request", "Client must bind a resource before enabling stream management";
+			return false, enable_errors.new("bind_required");
 		end
 		return true;
 	elseif s2s_smacks and (session_type == "s2sin" or session_type == "s2sout") then
 		return true;
 	end
-	return false, "service-unavailable", "Stream management is not available for this stream";
+	return false, enable_errors.new("unavailable");
 end
 
 module:hook("stream-features",
@@ -155,13 +186,12 @@
 
 local function request_ack(session, reason)
 	local queue = session.outgoing_stanza_queue;
-	session.log("debug", "Sending <r> (inside timer, before send) from %s - #queue=%d", reason, queue:count_unacked());
+	session.log("debug", "Sending <r> from %s - #queue=%d", reason, queue:count_unacked());
 	session.awaiting_ack = true;
 	(session.sends2s or session.send)(st.stanza("r", { xmlns = session.smacks }))
 	if session.destroyed then return end -- sending something can trigger destruction
 	-- expected_h could be lower than this expression e.g. more stanzas added to the queue meanwhile)
 	session.last_requested_h = queue:count_acked() + queue:count_unacked();
-	session.log("debug", "Sending <r> (inside timer, after send) from %s - #queue=%d", reason, queue:count_unacked());
 	if not session.delayed_ack_timer then
 		session.delayed_ack_timer = timer.add_task(delayed_ack_timeout, function()
 			ack_delayed(session, nil); -- we don't know if this is the only new stanza in the queue
@@ -180,7 +210,6 @@
 	-- supposed to be nil.
 	-- However, when using mod_smacks with mod_websocket, then mod_websocket's
 	-- stanzas/out filter can get called before this one and adds the xmlns.
-	if session.resending_unacked then return stanza end
 	if not session.smacks then return stanza end
 	local is_stanza = st.is_stanza(stanza) and
 		(not stanza.attr.xmlns or stanza.attr.xmlns == 'jabber:client')
@@ -234,8 +263,7 @@
 	if session.smacks == nil then return end
 	if session.resumption_token then
 		session.log("debug", "Revoking resumption token");
-		session_registry[jid.join(session.username, session.host, session.resumption_token)] = nil;
-		old_session_registry:set(session.username, session.resumption_token, nil);
+		clear_old_session(session);
 		session.resumption_token = nil;
 	else
 		session.log("debug", "Session not resumable");
@@ -274,17 +302,16 @@
 	return session;
 end
 
-function handle_enable(session, stanza, xmlns_sm)
-	local ok, err, err_text = can_do_smacks(session);
+function do_enable(session, stanza)
+	local ok, err = can_do_smacks(session);
 	if not ok then
-		session.log("warn", "Failed to enable smacks: %s", err_text); -- TODO: XEP doesn't say we can send error text, should it?
-		(session.sends2s or session.send)(st.stanza("failed", { xmlns = xmlns_sm }):tag(err, { xmlns = xmlns_errors}));
-		return true;
+		session.log("warn", "Failed to enable smacks: %s", err.text); -- TODO: XEP doesn't say we can send error text, should it?
+		return nil, err;
 	end
 
 	if session.username then
 		local old_sessions, err = all_old_sessions:get(session.username);
-		module:log("debug", "Old sessions: %q", old_sessions)
+		session.log("debug", "Old sessions: %q", old_sessions)
 		if old_sessions then
 			local keep, count = {}, 0;
 			for token, info in it.sorted_pairs(old_sessions, function(a, b)
@@ -296,54 +323,73 @@
 			end
 			all_old_sessions:set(session.username, keep);
 		elseif err then
-			module:log("error", "Unable to retrieve old resumption counters: %s", err);
+			session.log("error", "Unable to retrieve old resumption counters: %s", err);
 		end
 	end
 
-	module:log("debug", "Enabling stream management");
-	session.smacks = xmlns_sm;
-
-	wrap_session(session, false);
-
-	local resume_max;
 	local resume_token;
 	local resume = stanza.attr.resume;
 	if (resume == "true" or resume == "1") and session.username then
 		-- resumption on s2s is not currently supported
 		resume_token = new_id();
-		session_registry[jid.join(session.username, session.host, resume_token)] = session;
-		session.resumption_token = resume_token;
-		resume_max = tostring(resume_timeout);
 	end
-	(session.sends2s or session.send)(st.stanza("enabled", { xmlns = xmlns_sm, id = resume_token, resume = resume, max = resume_max }));
+
+	return {
+		type = "enabled";
+		id = resume_token;
+		resume_max = resume_token and tostring(resume_timeout) or nil;
+		session = session;
+		finish = function ()
+			session.log("debug", "Enabling stream management");
+
+			session.smacks = stanza.attr.xmlns;
+			if resume_token then
+				track_session(session, resume_token);
+			end
+			wrap_session(session, false);
+		end;
+	};
+end
+
+function handle_enable(session, stanza, xmlns_sm)
+	local enabled, err = do_enable(session, stanza);
+	if not enabled then
+		(session.sends2s or session.send)(st.stanza("failed", { xmlns = xmlns_sm }):add_error(err));
+		return true;
+	end
+
+	(session.sends2s or session.send)(st.stanza("enabled", {
+		xmlns = xmlns_sm;
+		id = enabled.id;
+		resume = enabled.id and "true" or nil; -- COMPAT w/ Conversations 2.10.10 requires 'true' not '1'
+		max = enabled.resume_max;
+	}));
+
+	session.smacks = xmlns_sm;
+	enabled.finish();
+
 	return true;
 end
 module:hook_tag(xmlns_sm2, "enable", function (session, stanza) return handle_enable(session, stanza, xmlns_sm2); end, 100);
 module:hook_tag(xmlns_sm3, "enable", function (session, stanza) return handle_enable(session, stanza, xmlns_sm3); end, 100);
 
-module:hook_tag("http://etherx.jabber.org/streams", "features",
-		function (session, stanza)
-			-- Needs to be done after flushing sendq since those aren't stored as
-			-- stanzas and counting them is weird.
-			-- TODO unify sendq and smqueue
-			timer.add_task(1e-6, function ()
-				if can_do_smacks(session) then
-					if stanza:get_child("sm", xmlns_sm3) then
-						session.sends2s(st.stanza("enable", sm3_attr));
-						session.smacks = xmlns_sm3;
-					elseif stanza:get_child("sm", xmlns_sm2) then
-						session.sends2s(st.stanza("enable", sm2_attr));
-						session.smacks = xmlns_sm2;
-					else
-						return;
-					end
-					wrap_session_out(session, false);
-				end
-			end);
-		end);
+module:hook_tag("http://etherx.jabber.org/streams", "features", function(session, stanza)
+	if can_do_smacks(session) then
+		session.smacks_feature = stanza:get_child("sm", xmlns_sm3) or stanza:get_child("sm", xmlns_sm2);
+	end
+end);
+
+module:hook("s2sout-established", function (event)
+	local session = event.session;
+	if not session.smacks_feature then return end
+
+	session.smacks = session.smacks_feature.attr.xmlns;
+	wrap_session_out(session, false);
+	session.sends2s(st.stanza("enable", { xmlns = session.smacks }));
+end);
 
 function handle_enabled(session, stanza, xmlns_sm) -- luacheck: ignore 212/stanza
-	module:log("debug", "Enabling stream management");
+	session.log("debug", "Enabling stream management");
 	session.smacks = xmlns_sm;
 
 	wrap_session_in(session, false);
@@ -357,10 +403,10 @@
 
 function handle_r(origin, stanza, xmlns_sm) -- luacheck: ignore 212/stanza
 	if not origin.smacks then
-		module:log("debug", "Received ack request from non-smack-enabled session");
+		origin.log("debug", "Received ack request from non-smack-enabled session");
 		return;
 	end
-	module:log("debug", "Received ack request, acking for %d", origin.handled_stanza_count);
+	origin.log("debug", "Received ack request, acking for %d", origin.handled_stanza_count);
 	-- Reply with <a>
 	(origin.sends2s or origin.send)(st.stanza("a", { xmlns = xmlns_sm, h = format_h(origin.handled_stanza_count) }));
 	-- piggyback our own ack request if needed (see request_ack_if_needed() for explanation of last_requested_h)
@@ -413,13 +459,14 @@
 	local queue = session.outgoing_stanza_queue;
 	local unacked = queue:count_unacked()
 	if unacked > 0 then
+		local error_from = jid.join(session.username, session.host or module.host);
 		tx_dropped_stanzas:sample(unacked);
 		session.smacks = false; -- Disable queueing
 		session.outgoing_stanza_queue = nil;
 		for stanza in queue._queue:consume() do
 			if not module:fire_event("delivery/failure", { session = session, stanza = stanza }) then
 				if stanza.attr.type ~= "error" and stanza.attr.from ~= session.full_jid then
-					local reply = st.error_reply(stanza, "cancel", "recipient-unavailable");
+					local reply = st.error_reply(stanza, "cancel", "recipient-unavailable", nil, error_from);
 					module:send(reply);
 				end
 			end
@@ -495,11 +542,8 @@
 		end
 
 		session.log("debug", "Destroying session for hibernating too long");
-		session_registry[jid.join(session.username, session.host, session.resumption_token)] = nil;
-		old_session_registry:set(session.username, session.resumption_token,
-			{ h = session.handled_stanza_count; t = os.time() });
+		save_old_session(session);
 		session.resumption_token = nil;
-		session.resending_unacked = true; -- stop outgoing_stanza_filter from re-queueing anything anymore
 		sessionmanager.destroy_session(session, "Hibernating too long");
 		sessions_expired(1);
 	end);
@@ -533,131 +577,110 @@
 module:hook("s2sout-destroyed", handle_s2s_destroyed);
 module:hook("s2sin-destroyed", handle_s2s_destroyed);
 
-local function get_session_id(session)
-	return session.id or (tostring(session):match("[a-f0-9]+$"));
-end
-
-function handle_resume(session, stanza, xmlns_sm)
+function do_resume(session, stanza)
 	if session.full_jid then
 		session.log("warn", "Tried to resume after resource binding");
-		session.send(st.stanza("failed", { xmlns = xmlns_sm })
-			:tag("unexpected-request", { xmlns = xmlns_errors })
-		);
-		return true;
+		return nil, enable_errors.new("already_bound");
 	end
 
 	local id = stanza.attr.previd;
-	local original_session = session_registry[jid.join(session.username, session.host, id)];
+	local original_session = session_registry[registry_key(session, id)];
 	if not original_session then
 		local old_session = old_session_registry:get(session.username, id);
 		if old_session then
 			session.log("debug", "Tried to resume old expired session with id %s", id);
-			session.send(st.stanza("failed", { xmlns = xmlns_sm, h = format_h(old_session.h) })
-				:tag("item-not-found", { xmlns = xmlns_errors })
-			);
-			old_session_registry:set(session.username, id, nil);
+			clear_old_session(session, id);
 			resumption_expired(1);
-		else
-			session.log("debug", "Tried to resume non-existent session with id %s", id);
-			session.send(st.stanza("failed", { xmlns = xmlns_sm })
-				:tag("item-not-found", { xmlns = xmlns_errors })
-			);
-		end;
-	else
-		if original_session.hibernating_watchdog then
-			original_session.log("debug", "Letting the watchdog go");
-			original_session.hibernating_watchdog:cancel();
-			original_session.hibernating_watchdog = nil;
-		elseif session.hibernating then
-			original_session.log("error", "Hibernating session has no watchdog!")
+			return nil, enable_errors.new("expired", { h = old_session.h });
 		end
-		-- zero age = was not hibernating yet
-		local age = 0;
-		if original_session.hibernating then
-			local now = os_time();
-			age = now - original_session.hibernating;
-		end
-		session.log("debug", "mod_smacks resuming existing session %s...", get_session_id(original_session));
-		original_session.log("debug", "mod_smacks session resumed from %s...", get_session_id(session));
-		-- TODO: All this should move to sessionmanager (e.g. session:replace(new_session))
-		if original_session.conn then
-			original_session.log("debug", "mod_smacks closing an old connection for this session");
-			local conn = original_session.conn;
-			c2s_sessions[conn] = nil;
-			conn:close();
-		end
+		session.log("debug", "Tried to resume non-existent session with id %s", id);
+		return nil, enable_errors.new("unknown_session");
+	end
+
+	if original_session.hibernating_watchdog then
+		original_session.log("debug", "Letting the watchdog go");
+		original_session.hibernating_watchdog:cancel();
+		original_session.hibernating_watchdog = nil;
+	elseif session.hibernating then
+		original_session.log("error", "Hibernating session has no watchdog!")
+	end
+	-- zero age = was not hibernating yet
+	local age = 0;
+	if original_session.hibernating then
+		local now = os_time();
+		age = now - original_session.hibernating;
+	end
+
+	session.log("debug", "mod_smacks resuming existing session %s...", original_session.id);
+
+	local queue = original_session.outgoing_stanza_queue;
+	local h = tonumber(stanza.attr.h);
+
+	original_session.log("debug", "Pre-resumption #queue = %d", queue:count_unacked())
+	local acked, err = ack_errors.coerce(queue:ack(h)); -- luacheck: ignore 211/acked
+
+	if not err and not queue:resumable() then
+		err = ack_errors.new("overflow");
+	end
+
+	if err then
+		session.log("debug", "Resumption failed: %s", err);
+		return nil, err;
+	end
+
+	-- Update original_session with the parameters (connection, etc.) from the new session
+	sessionmanager.update_session(original_session, session);
 
-		local migrated_session_log = session.log;
-		original_session.ip = session.ip;
-		original_session.conn = session.conn;
-		original_session.rawsend = session.rawsend;
-		original_session.rawsend.session = original_session;
-		original_session.rawsend.conn = original_session.conn;
-		original_session.send = session.send;
-		original_session.send.session = original_session;
-		original_session.close = session.close;
-		original_session.filter = session.filter;
-		original_session.filter.session = original_session;
-		original_session.filters = session.filters;
-		original_session.send.filter = original_session.filter;
-		original_session.stream = session.stream;
-		original_session.secure = session.secure;
-		original_session.hibernating = nil;
-		original_session.resumption_counter = (original_session.resumption_counter or 0) + 1;
-		session.log = original_session.log;
-		session.type = original_session.type;
-		wrap_session(original_session, true);
-		-- Inform xmppstream of the new session (passed to its callbacks)
-		original_session.stream:set_session(original_session);
-		-- Similar for connlisteners
-		c2s_sessions[session.conn] = original_session;
+	return {
+		type = "resumed";
+		session = original_session;
+		id = id;
+		-- Return function to complete the resumption and resync unacked stanzas
+		-- This is two steps so we can support SASL2/ISR
+		finish = function ()
+			-- Ok, we need to re-send any stanzas that the client didn't see
+			-- ...they are what is now left in the outgoing stanza queue
+			-- We have to use the send of "session" because we don't want to add our resent stanzas
+			-- to the outgoing queue again
 
-		local queue = original_session.outgoing_stanza_queue;
-		local h = tonumber(stanza.attr.h);
+			original_session.log("debug", "resending all unacked stanzas that are still queued after resume, #queue = %d", queue:count_unacked());
+			for _, queued_stanza in queue:resume() do
+				original_session.send(queued_stanza);
+			end
+			original_session.log("debug", "all stanzas resent, enabling stream management on resumed stream, #queue = %d", queue:count_unacked());
 
-		original_session.log("debug", "Pre-resumption #queue = %d", queue:count_unacked())
-		local acked, err = ack_errors.coerce(queue:ack(h)); -- luacheck: ignore 211/acked
-
-		if not err and not queue:resumable() then
-			err = ack_errors.new("overflow");
-		end
+			-- Add our own handlers to the resumed session (filters have been reset in the update)
+			wrap_session(original_session, true);
 
-		if err or not queue:resumable() then
-			original_session.send(st.stanza("failed",
-				{ xmlns = xmlns_sm; h = format_h(original_session.handled_stanza_count); previd = id }));
-			original_session:close(err);
-			return false;
-		end
-
-		original_session.send(st.stanza("resumed", { xmlns = xmlns_sm,
-			h = format_h(original_session.handled_stanza_count), previd = id }));
-
-		-- Ok, we need to re-send any stanzas that the client didn't see
-		-- ...they are what is now left in the outgoing stanza queue
-		-- We have to use the send of "session" because we don't want to add our resent stanzas
-		-- to the outgoing queue again
+			-- Let everyone know that we are no longer hibernating
+			module:fire_event("smacks-hibernation-end", {origin = session, resumed = original_session, queue = queue:table()});
+			original_session.awaiting_ack = nil; -- Don't wait for acks from before the resumption
+			request_ack_now_if_needed(original_session, true, "handle_resume", nil);
+			resumption_age:sample(age);
+		end;
+	};
+end
 
-		session.log("debug", "resending all unacked stanzas that are still queued after resume, #queue = %d", queue:count_unacked());
-		-- FIXME Which session is it that the queue filter sees?
-		session.resending_unacked = true;
-		original_session.resending_unacked = true;
-		for _, queued_stanza in queue:resume() do
-			session.send(queued_stanza);
-		end
-		session.resending_unacked = nil;
-		original_session.resending_unacked = nil;
-		session.log("debug", "all stanzas resent, now disabling send() in this migrated session, #queue = %d", queue:count_unacked());
-		function session.send(stanza) -- luacheck: ignore 432
-			migrated_session_log("error", "Tried to send stanza on old session migrated by smacks resume (maybe there is a bug?): %s", tostring(stanza));
-			return false;
-		end
-		module:fire_event("smacks-hibernation-end", {origin = session, resumed = original_session, queue = queue:table()});
-		original_session.awaiting_ack = nil; -- Don't wait for acks from before the resumption
-		request_ack_now_if_needed(original_session, true, "handle_resume", nil);
-		resumption_age:sample(age);
+function handle_resume(session, stanza, xmlns_sm)
+	local resumed, err = do_resume(session, stanza);
+	if not resumed then
+		session.send(st.stanza("failed", { xmlns = xmlns_sm, h = format_h(err.context.h) })
+			:tag(err.condition, { xmlns = xmlns_errors }));
+		return true;
 	end
+
+	session = resumed.session;
+
+	-- Inform client of successful resumption
+	session.send(st.stanza("resumed", { xmlns = xmlns_sm,
+		h = format_h(session.handled_stanza_count), previd = resumed.id }));
+
+	-- Complete resume (sync stanzas, etc.)
+	resumed.finish();
+
 	return true;
 end
+
 module:hook_tag(xmlns_sm2, "resume", function (session, stanza) return handle_resume(session, stanza, xmlns_sm2); end);
 module:hook_tag(xmlns_sm3, "resume", function (session, stanza) return handle_resume(session, stanza, xmlns_sm3); end);
 
@@ -712,8 +735,7 @@
 	for _, user in pairs(local_sessions) do
 		for _, session in pairs(user.sessions) do
 			if session.resumption_token then
-				if old_session_registry:set(session.username, session.resumption_token,
-					{ h = session.handled_stanza_count; t = os.time() }) then
+				if save_old_session(session) then
 					session.resumption_token = nil;
 
 					-- Deal with unacked stanzas
--- a/plugins/mod_stanza_debug.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_stanza_debug.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,6 @@
 module:set_global();
 
-local filters = require "util.filters";
+local filters = require "prosody.util.filters";
 
 local function log_send(t, session)
 	if t and t ~= "" and t ~= " " then
--- a/plugins/mod_storage_internal.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_storage_internal.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,17 +1,20 @@
-local cache = require "util.cache";
-local datamanager = require "core.storagemanager".olddm;
-local array = require "util.array";
-local datetime = require "util.datetime";
-local st = require "util.stanza";
-local now = require "util.time".now;
-local id = require "util.id".medium;
-local jid_join = require "util.jid".join;
-local set = require "util.set";
+local cache = require "prosody.util.cache";
+local datamanager = require "prosody.core.storagemanager".olddm;
+local array = require "prosody.util.array";
+local datetime = require "prosody.util.datetime";
+local st = require "prosody.util.stanza";
+local now = require "prosody.util.time".now;
+local id = require "prosody.util.id".medium;
+local jid_join = require "prosody.util.jid".join;
+local set = require "prosody.util.set";
+local it = require "prosody.util.iterators";
 
 local host = module.host;
 
-local archive_item_limit = module:get_option_number("storage_archive_item_limit", 10000);
-local archive_item_count_cache = cache.new(module:get_option("storage_archive_item_limit_cache_size", 1000));
+local archive_item_limit = module:get_option_integer("storage_archive_item_limit", 10000, 0);
+local archive_item_count_cache = cache.new(module:get_option_integer("storage_archive_item_limit_cache_size", 1000, 1));
+
+local use_shift = module:get_option_boolean("storage_archive_experimental_fast_delete", false);
 
 local driver = {};
 
@@ -121,100 +124,144 @@
 	return key;
 end
 
+local function binary_search(haystack, test, min, max)
+	if min == nil then
+		min = 1;
+	end
+	if max == nil then
+		max = #haystack;
+	end
+
+	local floor = math.floor;
+	while min < max do
+		local mid = floor((max + min) / 2);
+
+		local result = test(haystack[mid]);
+		if result < 0 then
+			max = mid;
+		elseif result > 0 then
+			min = mid + 1;
+		else
+			return mid, haystack[mid];
+		end
+	end
+
+	return min, nil;
+end
+
 function archive:find(username, query)
-	local items, err = datamanager.list_load(username, host, self.store);
-	if not items then
+	local list, err = datamanager.list_open(username, host, self.store);
+	if not list then
 		if err then
-			return items, err;
+			return list, err;
 		elseif query then
 			if query.before or query.after then
 				return nil, "item-not-found";
 			end
 			if query.total then
-				return function () end, 0;
+				return function()
+				end, 0;
 			end
 		end
-		return function () end;
+		return function()
+		end;
 	end
-	local count = nil;
-	local i, last_key = 0;
+
+	local i = 0;
+	local iter = function()
+		i = i + 1;
+		return list[i]
+	end
+
 	if query then
-		items = array(items);
+		if query.reverse then
+			i = #list + 1
+			iter = function()
+				i = i - 1
+				return list[i]
+			end
+			query.before, query.after = query.after, query.before;
+		end
 		if query.key then
-			items:filter(function (item)
+			iter = it.filter(function(item)
 				return item.key == query.key;
-			end);
+			end, iter);
 		end
 		if query.ids then
 			local ids = set.new(query.ids);
-			items:filter(function (item)
+			iter = it.filter(function(item)
 				return ids:contains(item.key);
-			end);
+			end, iter);
 		end
 		if query.with then
-			items:filter(function (item)
+			iter = it.filter(function(item)
 				return item.with == query.with;
-			end);
+			end, iter);
 		end
 		if query.start then
-			items:filter(function (item)
-				local when = item.when or datetime.parse(item.attr.stamp);
-				return when >= query.start;
-			end);
+			if not query.reverse then
+				local wi = binary_search(list, function(item)
+					local when = item.when or datetime.parse(item.attr.stamp);
+					return query.start - when;
+				end);
+				i = wi - 1;
+			else
+				iter = it.filter(function(item)
+					local when = item.when or datetime.parse(item.attr.stamp);
+					return when >= query.start;
+				end, iter);
+			end
 		end
 		if query["end"] then
-			items:filter(function (item)
-				local when = item.when or datetime.parse(item.attr.stamp);
-				return when <= query["end"];
-			end);
-		end
-		if query.total then
-			count = #items;
-		end
-		if query.reverse then
-			items:reverse();
-			if query.before then
-				local found = false;
-				for j = 1, #items do
-					if (items[j].key or tostring(j)) == query.before then
-						found = true;
-						i = j;
-						break;
-					end
+			if query.reverse then
+				local wi = binary_search(list, function(item)
+					local when = item.when or datetime.parse(item.attr.stamp);
+					return query["end"] - when;
+				end);
+				if wi then
+					i = wi + 1;
 				end
-				if not found then
-					return nil, "item-not-found";
-				end
+			else
+				iter = it.filter(function(item)
+					local when = item.when or datetime.parse(item.attr.stamp);
+					return when <= query["end"];
+				end, iter);
 			end
-			last_key = query.after;
-		elseif query.after then
+		end
+		if query.after then
 			local found = false;
-			for j = 1, #items do
-				if (items[j].key or tostring(j)) == query.after then
-					found = true;
-					i = j;
-					break;
+			iter = it.filter(function(item)
+				local found_after = found;
+				if item.key == query.after then
+					found = true
 				end
-			end
-			if not found then
-				return nil, "item-not-found";
-			end
-			last_key = query.before;
-		elseif query.before then
-			last_key = query.before;
+				return found_after;
+			end, iter);
 		end
-		if query.limit and #items - i > query.limit then
-			items[i+query.limit+1] = nil;
+		if query.before then
+			local found = false;
+			iter = it.filter(function(item)
+				if item.key == query.before then
+					found = true
+				end
+				return not found;
+			end, iter);
+		end
+		if query.limit then
+			iter = it.head(query.limit, iter);
 		end
 	end
-	return function ()
-		i = i + 1;
-		local item = items[i];
-		if not item or (last_key and item.key == last_key) then
-			return;
+
+	return function()
+		local item = iter();
+		if item == nil then
+			if list.close then
+				list:close();
+			end
+			return
 		end
-		local key = item.key or tostring(i);
-		local when = item.when or datetime.parse(item.attr.stamp);
+		local key = item.key;
+		local when = item.when or item.attr and datetime.parse(item.attr.stamp);
 		local with = item.with;
 		item.key, item.when, item.with = nil, nil, nil;
 		item.attr.stamp = nil;
@@ -222,7 +269,7 @@
 		item.attr.stamp_legacy = nil;
 		item = st.deserialize(item);
 		return key, item, when, with;
-	end, count;
+	end
 end
 
 function archive:get(username, wanted_key)
@@ -297,12 +344,53 @@
 	return datamanager.users(host, self.store, "list");
 end
 
+function archive:trim(username, to_when)
+	local cache_key = jid_join(username, host, self.store);
+	local list, err = datamanager.list_open(username, host, self.store);
+	if not list then
+		if err == nil then
+			module:log("debug", "store already empty, can't trim");
+			return 0;
+		end
+		return list, err;
+	end
+
+	-- shortcut: check if the last item should be trimmed, if so, drop the whole archive
+	local last = list[#list].when or datetime.parse(list[#list].attr.stamp);
+	if last <= to_when then
+		if list.close then
+			list:close()
+		end
+		return datamanager.list_store(username, host, self.store, nil);
+	end
+
+	-- luacheck: ignore 211/exact
+	local i, exact = binary_search(list, function(item)
+		local when = item.when or datetime.parse(item.attr.stamp);
+		return to_when - when;
+	end);
+	if list.close then
+		list:close()
+	end
+	-- TODO if exact then ... off by one?
+	if i == 1 then return 0; end
+	local ok, err = datamanager.list_shift(username, host, self.store, i);
+	if not ok then return ok, err; end
+	archive_item_count_cache:set(cache_key, nil); -- TODO calculate how many items are left
+	return i-1;
+end
+
 function archive:delete(username, query)
 	local cache_key = jid_join(username, host, self.store);
 	if not query or next(query) == nil then
-		archive_item_count_cache:set(cache_key, nil);
+		archive_item_count_cache:set(cache_key, nil); -- nil because we don't check if the following succeeds
 		return datamanager.list_store(username, host, self.store, nil);
 	end
+
+	if use_shift and next(query) == "end" and next(query, "end") == nil then
+		return self:trim(username, query["end"]);
+	end
+
 	local items, err = datamanager.list_load(username, host, self.store);
 	if not items then
 		if err then
--- a/plugins/mod_storage_memory.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_storage_memory.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,15 +1,15 @@
-local serialize = require "util.serialization".serialize;
-local array = require "util.array";
-local envload = require "util.envload".envload;
-local st = require "util.stanza";
+local serialize = require "prosody.util.serialization".serialize;
+local array = require "prosody.util.array";
+local envload = require "prosody.util.envload".envload;
+local st = require "prosody.util.stanza";
 local is_stanza = st.is_stanza or function (s) return getmetatable(s) == st.stanza_mt end
-local new_id = require "util.id".medium;
-local set = require "util.set";
+local new_id = require "prosody.util.id".medium;
+local set = require "prosody.util.set";
 
 local auto_purge_enabled = module:get_option_boolean("storage_memory_temporary", false);
 local auto_purge_stores = module:get_option_set("storage_memory_temporary_stores", {});
 
-local archive_item_limit = module:get_option_number("storage_archive_item_limit", 1000);
+local archive_item_limit = module:get_option_integer("storage_archive_item_limit", 1000, 0);
 
 local memory = setmetatable({}, {
 	__index = function(t, k)
--- a/plugins/mod_storage_sql.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_storage_sql.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,19 +1,34 @@
 
 -- luacheck: ignore 212/self
 
-local cache = require "util.cache";
-local json = require "util.json";
-local sql = require "util.sql";
-local xml_parse = require "util.xml".parse;
-local uuid = require "util.uuid";
-local resolve_relative_path = require "util.paths".resolve_relative_path;
-local jid_join = require "util.jid".join;
+local cache = require "prosody.util.cache";
+local json = require "prosody.util.json";
+local xml_parse = require "prosody.util.xml".parse;
+local uuid = require "prosody.util.uuid";
+local resolve_relative_path = require "prosody.util.paths".resolve_relative_path;
+local jid_join = require "prosody.util.jid".join;
 
-local is_stanza = require"util.stanza".is_stanza;
+local is_stanza = require"prosody.util.stanza".is_stanza;
 local t_concat = table.concat;
 
+local have_dbisql, dbisql = pcall(require, "prosody.util.sql");
+local have_sqlite, sqlite = pcall(require, "prosody.util.sqlite3");
+if not have_dbisql then
+	module:log("debug", "Could not load LuaDBI, error was: %s", dbisql)
+	dbisql = nil;
+end
+if not have_sqlite then
+	module:log("debug", "Could not load LuaSQLite3, error was: %s", sqlite)
+	sqlite = nil;
+end
+if not (have_dbisql or have_sqlite) then
+	module:log("error", "LuaDBI or LuaSQLite3 are required for using SQL databases but neither are installed");
+	module:log("error", "Please install at least one of LuaDBI and LuaSQLite3. See https://prosody.im/doc/depends");
+	error("No SQL library available")
+end
+
 local noop = function() end
-local unpack = table.unpack or unpack; -- luacheck: ignore 113
+local unpack = table.unpack;
 local function iterator(result)
 	return function(result_)
 		local row = result_();
@@ -59,9 +74,8 @@
 end
 
 local host = module.host;
-local user, store;
 
-local function keyval_store_get()
+local function keyval_store_get(user, store)
 	local haveany;
 	local result = {};
 	local select_sql = [[
@@ -86,7 +100,7 @@
 		return result;
 	end
 end
-local function keyval_store_set(data)
+local function keyval_store_set(data, user, store)
 	local delete_sql = [[
 	DELETE FROM "prosody"
 	WHERE "host"=? AND "user"=? AND "store"=?
@@ -121,19 +135,15 @@
 local keyval_store = {};
 keyval_store.__index = keyval_store;
 function keyval_store:get(username)
-	user, store = username, self.store;
-	local ok, result = engine:transaction(keyval_store_get);
+	local ok, result = engine:transaction(keyval_store_get, username, self.store);
 	if not ok then
-		module:log("error", "Unable to read from database %s store for %s: %s", store, username or "<host>", result);
+		module:log("error", "Unable to read from database %s store for %s: %s", self.store, username or "<host>", result);
 		return nil, result;
 	end
 	return result;
 end
 function keyval_store:set(username, data)
-	user,store = username,self.store;
-	return engine:transaction(function()
-		return keyval_store_set(data);
-	end);
+	return engine:transaction(keyval_store_set, data, username, self.store);
 end
 function keyval_store:users()
 	local ok, result = engine:transaction(function()
@@ -150,8 +160,8 @@
 
 --- Archive store API
 
-local archive_item_limit = module:get_option_number("storage_archive_item_limit");
-local archive_item_count_cache = cache.new(module:get_option("storage_archive_item_limit_cache_size", 1000));
+local archive_item_limit = module:get_option_integer("storage_archive_item_limit", nil, 0);
+local archive_item_count_cache = cache.new(module:get_option_integer("storage_archive_item_limit_cache_size", 1000, 1));
 
 local item_count_cache_hit = module:measure("item_count_cache_hit", "rate");
 local item_count_cache_miss = module:measure("item_count_cache_miss", "rate")
@@ -201,6 +211,13 @@
 		("host","user","store","key","type","value")
 		VALUES (?,?,?,?,?,?);
 		]];
+		local upsert_sql = [[
+		INSERT INTO "prosody"
+		("host","user","store","key","type","value")
+		VALUES (?,?,?,?,?,?)
+		ON CONFLICT ("host", "user","store", "key")
+		DO UPDATE SET "type"=?, "value"=?;
+		]];
 		local select_extradata_sql = [[
 		SELECT "type", "value"
 		FROM "prosody"
@@ -208,7 +225,10 @@
 		LIMIT 1;
 		]];
 		for key, data in pairs(keydatas) do
-			if type(key) == "string" and key ~= "" then
+			if type(key) == "string" and key ~= "" and engine.params.driver ~= "MySQL" and data ~= self.remove then
+				local t, value = assert(serialize(data));
+				engine:insert(upsert_sql, host, username or "", self.store, key, t, value, t, value);
+			elseif type(key) == "string" and key ~= "" then
 				engine:delete(delete_sql,
 					host, username or "", self.store, key);
 				if data ~= self.remove then
@@ -291,37 +311,43 @@
 	local user,store = username,self.store;
 	local cache_key = jid_join(username, host, store);
 	local item_count = archive_item_count_cache:get(cache_key);
-	if not item_count then
-		item_count_cache_miss();
-		local ok, ret = engine:transaction(function()
-			local count_sql = [[
-			SELECT COUNT(*) FROM "prosodyarchive"
-			WHERE "host"=? AND "user"=? AND "store"=?;
-			]];
-			local result = engine:select(count_sql, host, user, store);
-			if result then
-				for row in result do
-					item_count = row[1];
-				end
-			end
-		end);
-		if not ok or not item_count then
-			module:log("error", "Failed while checking quota for %s: %s", username, ret);
-			return nil, "Failure while checking quota";
-		end
-		archive_item_count_cache:set(cache_key, item_count);
-	else
-		item_count_cache_hit();
-	end
 
 	if archive_item_limit then
+		if not item_count then
+			item_count_cache_miss();
+			local ok, ret = engine:transaction(function()
+				local count_sql = [[
+				SELECT COUNT(*) FROM "prosodyarchive"
+				WHERE "host"=? AND "user"=? AND "store"=?;
+				]];
+				local result = engine:select(count_sql, host, user, store);
+				if result then
+					for row in result do
+						item_count = row[1];
+					end
+				end
+			end);
+			if not ok or not item_count then
+				module:log("error", "Failed while checking quota for %s: %s", username, ret);
+				return nil, "Failure while checking quota";
+			end
+			archive_item_count_cache:set(cache_key, item_count);
+		else
+			item_count_cache_hit();
+		end
+
 		module:log("debug", "%s has %d items out of %d limit", username, item_count, archive_item_limit);
 		if item_count >= archive_item_limit then
 			return nil, "quota-limit";
 		end
 	end
 
+	-- FIXME update the schema to allow precision timestamps
 	when = when or os.time();
+	if engine.params.driver ~= "SQLite3" then
+		-- SQLite3 doesn't enforce types :)
+		when = math.floor(when);
+	end
 	with = with or "";
 	local ok, ret = engine:transaction(function()
 		local delete_sql = [[
@@ -334,16 +360,19 @@
 		VALUES (?,?,?,?,?,?,?,?);
 		]];
 		if key then
+			-- TODO use UPSERT like map store
 			local result = engine:delete(delete_sql, host, user or "", store, key);
-			if result then
+			if result and item_count then
 				item_count = item_count - result:affected();
 			end
 		else
-			key = uuid.generate();
+			key = uuid.v7();
 		end
 		local t, encoded_value = assert(serialize(value));
 		engine:insert(insert_sql, host, user or "", store, when, with, key, t, encoded_value);
-		archive_item_count_cache:set(cache_key, item_count+1);
+		if item_count then
+			archive_item_count_cache:set(cache_key, item_count+1);
+		end
 		return key;
 	end);
 	if not ok then return ok, ret; end
@@ -354,12 +383,12 @@
 local function archive_where(query, args, where)
 	-- Time range, inclusive
 	if query.start then
-		args[#args+1] = query.start
+		args[#args+1] = math.floor(query.start);
 		where[#where+1] = "\"when\" >= ?"
 	end
 
 	if query["end"] then
-		args[#args+1] = query["end"];
+		args[#args+1] = math.floor(query["end"]);
 		if query.start then
 			where[#where] = "\"when\" BETWEEN ? AND ?" -- is this inclusive?
 		else
@@ -382,8 +411,7 @@
 	-- Set of ids
 	if query.ids then
 		local nids, nargs = #query.ids, #args;
-		-- COMPAT Lua 5.1: No separator argument to string.rep
-		where[#where + 1] = "\"key\" IN (" .. string.rep("?,", nids):sub(1,-2) .. ")";
+		where[#where + 1] = "\"key\" IN (" .. string.rep("?", nids, ",") .. ")";
 		for i, id in ipairs(query.ids) do
 			args[nargs+i] = id;
 		end
@@ -611,7 +639,7 @@
 				LIMIT %s OFFSET ?
 			);]];
 			if engine.params.driver == "SQLite3" then
-				if engine._have_delete_limit then
+				if engine.sqlite_compile_options.enable_update_delete_limit then
 					sql_query = [[
 					DELETE FROM "prosodyarchive"
 					WHERE %s
@@ -630,7 +658,13 @@
 		archive_item_count_cache:clear();
 	else
 		local cache_key = jid_join(username, host, self.store);
-		archive_item_count_cache:set(cache_key, nil);
+		if query.start == nil and query.with == nil and query["end"] == nil and query.key == nil and query.ids == nil and query.truncate == nil then
+			-- All items deleted, count should be zero.
+			archive_item_count_cache:set(cache_key, 0);
+		else
+			-- Not sure how many items left
+			archive_item_count_cache:set(cache_key, nil);
+		end
 	end
 	return ok and stmt:affected(), stmt;
 end
@@ -648,10 +682,27 @@
 	return iterator(result);
 end
 
+local keyvalplus = {
+	__index = {
+		-- keyval
+		get = keyval_store.get;
+		set = keyval_store.set;
+		items = keyval_store.users;
+		-- map
+		get_key = map_store.get;
+		set_key = map_store.set;
+		remove = map_store.remove;
+		set_keys = map_store.set_keys;
+		get_key_from_all = map_store.get_all;
+		delete_key_from_all = map_store.delete_all;
+	};
+}
+
 local stores = {
 	keyval = keyval_store;
 	map = map_store;
 	archive = archive_store;
+	["keyval+"] = keyvalplus;
 };
 
 --- Implement storage driver API
@@ -692,6 +743,7 @@
 
 
 local function create_table(engine) -- luacheck: ignore 431/engine
+	local sql = engine.params.driver == "SQLite3" and sqlite or dbisql;
 	local Table, Column, Index = sql.Table, sql.Column, sql.Index;
 
 	local ProsodyTable = Table {
@@ -702,7 +754,7 @@
 		Column { name="key", type="TEXT", nullable=false };
 		Column { name="type", type="TEXT", nullable=false };
 		Column { name="value", type="MEDIUMTEXT", nullable=false };
-		Index { name="prosody_index", "host", "user", "store", "key" };
+		Index { name = "prosody_unique_index"; unique = engine.params.driver ~= "MySQL"; "host"; "user"; "store"; "key" };
 	};
 	engine:transaction(function()
 		ProsodyTable:create(engine);
@@ -732,6 +784,7 @@
 local function upgrade_table(engine, params, apply_changes) -- luacheck: ignore 431/engine
 	local changes = false;
 	if params.driver == "MySQL" then
+		local sql = dbisql;
 		local success,err = engine:transaction(function()
 			do
 				local result = assert(engine:execute("SHOW COLUMNS FROM \"prosody\" WHERE \"Field\"='value' and \"Type\"='text'"));
@@ -799,12 +852,38 @@
 		success,err = engine:transaction(function()
 			return engine:execute(check_encoding_query, params.database,
 				engine.charset, engine.charset.."_bin");
-		end);
-		if not success then
-			module:log("error", "Failed to check/upgrade database encoding: %s", err or "unknown error");
-			return false;
+			end);
+			if not success then
+				module:log("error", "Failed to check/upgrade database encoding: %s", err or "unknown error");
+				return false;
+			end
+		else
+			local indices = {};
+			engine:transaction(function ()
+				if params.driver == "SQLite3" then
+					for row in engine:select [[SELECT "name" FROM "sqlite_schema" WHERE "type"='index' AND "tbl_name"='prosody' AND "name"='prosody_index';]] do
+						indices[row[1]] = true;
+					end
+				elseif params.driver == "PostgreSQL" then
+					for row in engine:select [[SELECT "indexname" FROM "pg_indexes" WHERE "tablename"='prosody' AND "indexname"='prosody_index';]] do
+						indices[row[1]] = true;
+					end
+				end
+			end)
+			if indices["prosody_index"] then
+				if apply_changes then
+					local success = engine:transaction(function ()
+						return assert(engine:execute([[DROP INDEX "prosody_index";]]));
+					end);
+					if not success then
+						module:log("error", "Failed to delete obsolete index \"prosody_index\"");
+						return false;
+					end
+				else
+					changes = true;
+				end
+			end
 		end
-	end
 	return changes;
 end
 
@@ -831,12 +910,13 @@
 function module.load()
 	local engines = module:shared("/*/sql/connections");
 	local params = normalize_params(module:get_option("sql", default_params));
+	local sql = params.driver == "SQLite3" and sqlite or dbisql;
 	local db_uri = sql.db2uri(params);
 	engine = engines[db_uri];
 	if not engine then
 		module:log("debug", "Creating new engine %s", db_uri);
 		engine = sql:create_engine(params, function (engine) -- luacheck: ignore 431/engine
-			if module:get_option("sql_manage_tables", true) then
+			if module:get_option_boolean("sql_manage_tables", true) then
 				-- Automatically create table, ignore failure (table probably already exists)
 				-- FIXME: we should check in information_schema, etc.
 				create_table(engine);
@@ -847,28 +927,74 @@
 				end
 			end
 			if engine.params.driver == "SQLite3" then
+				local compile_options = {}
 				for row in engine:select("PRAGMA compile_options") do
-					if row[1] == "ENABLE_UPDATE_DELETE_LIMIT" then
-						engine._have_delete_limit = true;
+					local option = row[1]:lower();
+					local opt, val = option:match("^([^=]+)=(.*)$");
+					compile_options[opt or option] = tonumber(val) or val or true;
+				end
+				engine.sqlite_compile_options = compile_options;
+
+				local journal_mode = "delete";
+				for row in engine:select[[PRAGMA journal_mode;]] do
+					journal_mode = row[1];
+				end
+
+				-- Note: These things can't be changed with in a transaction. LuaDBI
+				-- opens a transaction automatically for every statement(?), so this
+				-- will not work there.
+				local tune = module:get_option_enum("sqlite_tune", "default", "normal", "fast", "safe");
+				if tune == "normal" then
+					if journal_mode ~= "wal" then
+						engine:execute("PRAGMA journal_mode=WAL;");
 					end
+					engine:execute("PRAGMA auto_vacuum=FULL;");
+					engine:execute("PRAGMA synchronous=NORMAL;")
+				elseif tune == "fast" then
+					if journal_mode ~= "wal" then
+						engine:execute("PRAGMA journal_mode=WAL;");
+					end
+					if compile_options.secure_delete then
+						engine:execute("PRAGMA secure_delete=FAST;");
+					end
+					engine:execute("PRAGMA synchronous=OFF;")
+					engine:execute("PRAGMA fullfsync=0;")
+				elseif tune == "safe" then
+					if journal_mode ~= "delete" then
+						engine:execute("PRAGMA journal_mode=DELETE;");
+					end
+					engine:execute("PRAGMA synchronous=EXTRA;")
+					engine:execute("PRAGMA fullfsync=1;")
 				end
+
+				for row in engine:select[[PRAGMA journal_mode;]] do
+					journal_mode = row[1];
+				end
+
+				module:log("debug", "SQLite3 database %q operating with journal_mode=%s", engine.params.database, journal_mode);
 			end
+			module:set_status("info", "Connected to " .. engine.params.driver);
+		end, function (engine) -- luacheck: ignore 431/engine
+			module:set_status("error", "Disconnected from " .. engine.params.driver);
 		end);
 		engines[sql.db2uri(params)] = engine;
+	else
+		module:set_status("info", "Using existing engine");
 	end
 
 	module:provides("storage", driver);
 end
 
 function module.command(arg)
-	local config = require "core.configmanager";
-	local hi = require "util.human.io";
+	local config = require "prosody.core.configmanager";
+	local hi = require "prosody.util.human.io";
 	local command = table.remove(arg, 1);
 	if command == "upgrade" then
 		-- We need to find every unique dburi in the config
 		local uris = {};
 		for host in pairs(prosody.hosts) do -- luacheck: ignore 431/host
 			local params = normalize_params(config.get(host, "sql") or default_params);
+			local sql = engine.params.driver == "SQLite3" and sqlite or dbisql;
 			uris[sql.db2uri(params)] = params;
 		end
 		print("We will check and upgrade the following databases:\n");
@@ -884,6 +1010,7 @@
 		-- Upgrade each one
 		for _, params in pairs(uris) do
 			print("Checking "..params.database.."...");
+			local sql = params.driver == "SQLite3" and sqlite or dbisql;
 			engine = sql:create_engine(params);
 			upgrade_table(engine, params, true);
 		end
--- a/plugins/mod_storage_xep0227.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_storage_xep0227.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -2,22 +2,22 @@
 local ipairs, pairs = ipairs, pairs;
 local setmetatable = setmetatable;
 local tostring = tostring;
-local next, unpack = next, table.unpack or unpack; --luacheck: ignore 113/unpack
+local next, unpack = next, table.unpack;
 local os_remove = os.remove;
 local io_open = io.open;
-local jid_bare = require "util.jid".bare;
-local jid_prep = require "util.jid".prep;
-local jid_join = require "util.jid".join;
+local jid_bare = require "prosody.util.jid".bare;
+local jid_prep = require "prosody.util.jid".prep;
+local jid_join = require "prosody.util.jid".join;
 
-local array = require "util.array";
-local base64 = require "util.encodings".base64;
-local dt = require "util.datetime";
-local hex = require "util.hex";
-local it = require "util.iterators";
-local paths = require"util.paths";
-local set = require "util.set";
-local st = require "util.stanza";
-local parse_xml_real = require "util.xml".parse;
+local array = require "prosody.util.array";
+local base64 = require "prosody.util.encodings".base64;
+local dt = require "prosody.util.datetime";
+local hex = require "prosody.util.hex";
+local it = require "prosody.util.iterators";
+local paths = require"prosody.util.paths";
+local set = require "prosody.util.set";
+local st = require "prosody.util.stanza";
+local parse_xml_real = require "prosody.util.xml".parse;
 
 local lfs = require "lfs";
 
@@ -80,7 +80,7 @@
 -- In order to support custom account properties
 local extended = "http://prosody.im/protocol/extended-xep0227\1";
 
-local scram_hash_name = module:get_option_string("password_hash", "SHA-1");
+local scram_hash_name = module:get_option_enum("password_hash", "SHA-1", "SHA-256");
 local scram_properties = set.new({ "server_key", "stored_key", "iteration_count", "salt" });
 
 handlers.accounts = {
--- a/plugins/mod_time.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_time.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,9 +6,9 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza";
-local datetime = require "util.datetime".datetime;
-local legacy = require "util.datetime".legacy;
+local st = require "prosody.util.stanza";
+local datetime = require "prosody.util.datetime".datetime;
+local now = require "prosody.util.time".now;
 
 -- XEP-0202: Entity Time
 
@@ -18,23 +18,10 @@
 	local origin, stanza = event.origin, event.stanza;
 	origin.send(st.reply(stanza):tag("time", {xmlns="urn:xmpp:time"})
 		:tag("tzo"):text("+00:00"):up() -- TODO get the timezone in a platform independent fashion
-		:tag("utc"):text(datetime()));
+		:tag("utc"):text(datetime(now())));
 	return true;
 end
 
 module:hook("iq-get/bare/urn:xmpp:time:time", time_handler);
 module:hook("iq-get/host/urn:xmpp:time:time", time_handler);
 
--- XEP-0090: Entity Time (deprecated)
-
-module:add_feature("jabber:iq:time");
-
-local function legacy_time_handler(event)
-	local origin, stanza = event.origin, event.stanza;
-	origin.send(st.reply(stanza):tag("query", {xmlns="jabber:iq:time"})
-		:tag("utc"):text(legacy()));
-	return true;
-end
-
-module:hook("iq-get/bare/jabber:iq:time:query", legacy_time_handler);
-module:hook("iq-get/host/jabber:iq:time:query", legacy_time_handler);
--- a/plugins/mod_tls.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_tls.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,14 +6,14 @@
 -- COPYING file in the source package for more information.
 --
 
-local create_context = require "core.certmanager".create_context;
-local rawgetopt = require"core.configmanager".rawget;
-local st = require "util.stanza";
+local create_context = require "prosody.core.certmanager".create_context;
+local rawgetopt = require"prosody.core.configmanager".rawget;
+local st = require "prosody.util.stanza";
 
-local c2s_require_encryption = module:get_option("c2s_require_encryption", module:get_option("require_encryption", true));
-local s2s_require_encryption = module:get_option("s2s_require_encryption", true);
-local allow_s2s_tls = module:get_option("s2s_allow_encryption") ~= false;
-local s2s_secure_auth = module:get_option("s2s_secure_auth");
+local c2s_require_encryption = module:get_option_boolean("c2s_require_encryption", module:get_option_boolean("require_encryption", true));
+local s2s_require_encryption = module:get_option_boolean("s2s_require_encryption", true);
+local allow_s2s_tls = module:get_option_boolean("s2s_allow_encryption", true);
+local s2s_secure_auth = module:get_option_boolean("s2s_secure_auth", false);
 
 if s2s_secure_auth and s2s_require_encryption == false then
 	module:log("warn", "s2s_secure_auth implies s2s_require_encryption, but s2s_require_encryption is set to false");
@@ -62,7 +62,7 @@
 
 	module:log("debug", "Creating context for s2sout");
 	-- for outgoing server connections
-	ssl_ctx_s2sout, err_s2sout, ssl_cfg_s2sout = create_context(host.host, "client", host_s2s, host_ssl, global_s2s, request_client_certs, xmpp_alpn);
+	ssl_ctx_s2sout, err_s2sout, ssl_cfg_s2sout = create_context(host.host, "client", host_s2s, host_ssl, global_s2s, xmpp_alpn);
 	if not ssl_ctx_s2sout then module:log("error", "Error creating contexts for s2sout: %s", err_s2sout); end
 
 	module:log("debug", "Creating context for s2sin");
@@ -80,6 +80,9 @@
 module:hook_global("config-reloaded", module.load);
 
 local function can_do_tls(session)
+	if session.secure then
+		return false;
+	end
 	if session.conn and not session.conn.starttls then
 		if not session.secure then
 			session.log("debug", "Underlying connection does not support STARTTLS");
@@ -125,7 +128,15 @@
 -- Hook <starttls/>
 module:hook("stanza/urn:ietf:params:xml:ns:xmpp-tls:starttls", function(event)
 	local origin = event.origin;
+	origin.starttls = "requested";
 	if can_do_tls(origin) then
+		if origin.conn.block_reads then
+			-- we need to ensure that no data is read anymore, otherwise we could end up in a situation where
+			-- <proceed/> is sent and the socket receives the TLS handshake (and passes the data to lua) before
+			-- it is asked to initiate TLS
+			-- (not with the classical single-threaded server backends)
+			origin.conn:block_reads()
+		end
 		(origin.sends2s or origin.send)(starttls_proceed);
 		if origin.destroyed then return end
 		origin:reset_stream();
@@ -166,6 +177,7 @@
 			module:log("debug", "%s is not offering TLS", session.to_host);
 			return;
 		end
+		session.starttls = "initiated";
 		session.sends2s(starttls_initiate);
 		return true;
 	end
@@ -183,7 +195,8 @@
 	if session.type == "s2sout_unauthed" and can_do_tls(session) then
 		module:log("debug", "Proceeding with TLS on s2sout...");
 		session:reset_stream();
-		session.conn:starttls(session.ssl_ctx);
+		session.starttls = "proceeding"
+		session.conn:starttls(session.ssl_ctx, session.to_host);
 		session.secure = false;
 		return true;
 	end
--- a/plugins/mod_tokenauth.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_tokenauth.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,82 +1,354 @@
-local id = require "util.id";
-local jid = require "util.jid";
-local base64 = require "util.encodings".base64;
+local base64 = require "prosody.util.encodings".base64;
+local hashes = require "prosody.util.hashes";
+local id = require "prosody.util.id";
+local jid = require "prosody.util.jid";
+local random = require "prosody.util.random";
+local usermanager = require "prosody.core.usermanager";
+local generate_identifier = require "prosody.util.id".short;
+
+local token_store = module:open_store("auth_tokens", "keyval+");
+
+local access_time_granularity = module:get_option_period("token_auth_access_time_granularity", 60);
+local empty_grant_lifetime = module:get_option_period("tokenless_grant_ttl", "2w");
 
-local token_store = module:open_store("auth_tokens", "map");
+local function select_role(username, host, role_name)
+	if not role_name then return end
+	local role = usermanager.get_role_by_name(role_name, host);
+	if not role then return end
+	if not usermanager.user_can_assume_role(username, host, role.name) then return end
+	return role;
+end
 
-function create_jid_token(actor_jid, token_jid, token_scope, token_ttl)
-	token_jid = jid.prep(token_jid);
-	if not actor_jid or token_jid ~= actor_jid and not jid.compare(token_jid, actor_jid) then
+function create_grant(actor_jid, grant_jid, grant_ttl, grant_data)
+	grant_jid = jid.prep(grant_jid);
+	if not actor_jid or actor_jid ~= grant_jid and not jid.compare(grant_jid, actor_jid) then
+		module:log("debug", "Actor <%s> is not permitted to create a token granting access to JID <%s>", actor_jid, grant_jid);
 		return nil, "not-authorized";
 	end
 
-	local token_username, token_host, token_resource = jid.split(token_jid);
+	local grant_username, grant_host, grant_resource = jid.split(grant_jid);
+
+	if grant_host ~= module.host then
+		return nil, "invalid-host";
+	end
+
+	local grant_id = id.short();
+	local now = os.time();
+
+	local grant = {
+		id = grant_id;
+
+		owner = actor_jid;
+		created = now;
+		expires = grant_ttl and (now + grant_ttl) or nil;
+		accessed = now;
+
+		jid = grant_jid;
+		resource = grant_resource;
+
+		data = grant_data;
+
+		-- tokens[<hash-name>..":"..<secret>] = token_info
+		tokens = {};
+	};
+
+	local ok, err = token_store:set_key(grant_username, grant_id, grant);
+	if not ok then
+		return nil, err;
+	end
+
+	module:fire_event("token-grant-created", {
+		id = grant_id;
+		grant = grant;
+		username = grant_username;
+		host = grant_host;
+	});
+
+	return grant;
+end
+
+function create_token(grant_jid, grant, token_role, token_ttl, token_purpose, token_data)
+	if (token_data and type(token_data) ~= "table") or (token_purpose and type(token_purpose) ~= "string") then
+		return nil, "bad-request";
+	end
+	local grant_username, grant_host = jid.split(grant_jid);
+	if grant_host ~= module.host then
+		return nil, "invalid-host";
+	end
+	if type(grant) == "string" then -- lookup by id
+		grant = token_store:get_key(grant_username, grant);
+		if not grant then return nil; end
+	end
+
+	if not grant.tokens then return nil, "internal-server-error"; end -- old-style token?
+
+	local now = os.time();
+	local expires = grant.expires; -- Default to same expiry as grant
+	if token_ttl then -- explicit lifetime requested
+		if expires then
+			-- Grant has an expiry, so limit to that or shorter
+			expires = math.min(now + token_ttl, expires);
+		else
+			-- Grant never expires, just use whatever expiry is requested for the token
+			expires = now + token_ttl;
+		end
+	end
+
+	local token_info = {
+		role = token_role;
+
+		created = now;
+		expires = expires;
+		purpose = token_purpose;
+
+		data = token_data;
+	};
+
+	local token_secret = random.bytes(18);
+	grant.tokens["sha256:"..hashes.sha256(token_secret, true)] = token_info;
 
+	local ok, err = token_store:set_key(grant_username, grant.id, grant);
+	if not ok then
+		return nil, err;
+	end
+
+	local token_string = "secret-token:"..base64.encode("2;"..grant.id..";"..token_secret..";"..grant.jid);
+	return token_string, token_info;
+end
+
+local function parse_token(encoded_token)
+	if not encoded_token then return nil; end
+	local encoded_data = encoded_token:match("^secret%-token:(.+)$");
+	if not encoded_data then return nil; end
+	local token = base64.decode(encoded_data);
+	if not token then return nil; end
+	local token_id, token_secret, token_jid = token:match("^2;([^;]+);(..................);(.+)$");
+	if not token_id then return nil; end
+	local token_user, token_host = jid.split(token_jid);
+	return token_id, token_user, token_host, token_secret;
+end
+
+local function clear_expired_grant_tokens(grant, now)
+	local updated;
+	now = now or os.time();
+	for secret, token_info in pairs(grant.tokens) do
+		local expires = token_info.expires;
+		if expires and expires < now then
+			grant.tokens[secret] = nil;
+			updated = true;
+		end
+	end
+	return updated;
+end
+
+local function _get_validated_grant_info(username, grant)
+	if type(grant) == "string" then
+		grant = token_store:get_key(username, grant);
+	end
+	if not grant or not grant.created or not grant.id then return nil; end
+
+	-- Invalidate grants from before last password change
+	local account_info = usermanager.get_account_info(username, module.host);
+	local password_updated_at = account_info and account_info.password_updated;
+	local now = os.time();
+	if password_updated_at and grant.created < password_updated_at then
+		module:log("debug", "Token grant %s of %s issued before last password change, invalidating it now", grant.id, username);
+		token_store:set_key(username, grant.id, nil);
+		return nil, "not-authorized";
+	elseif grant.expires and grant.expires < now then
+		module:log("debug", "Token grant %s of %s expired, cleaning up", grant.id, username);
+		token_store:set_key(username, grant.id, nil);
+		return nil, "expired";
+	end
+
+	if not grant.tokens then
+		module:log("debug", "Token grant %s of %s without tokens, cleaning up", grant.id, username);
+		token_store:set_key(username, grant.id, nil);
+		return nil, "invalid";
+	end
+
+	local found_expired = false
+	for secret_hash, token_info in pairs(grant.tokens) do
+		if token_info.expires and token_info.expires < now then
+			module:log("debug", "Token %s of grant %s of %s has expired, cleaning it up", secret_hash:sub(-8), grant.id, username);
+			grant.tokens[secret_hash] = nil;
+			found_expired = true;
+		end
+	end
+
+	if not grant.expires and next(grant.tokens) == nil and grant.accessed + empty_grant_lifetime < now then
+		module:log("debug", "Token %s of %s grant has no tokens, discarding", grant.id, username);
+		token_store:set_key(username, grant.id, nil);
+		return nil, "expired";
+	elseif found_expired then
+		token_store:set_key(username, grant.id, grant);
+	end
+
+	return grant;
+end
+
+local function _get_validated_token_info(token_id, token_user, token_host, token_secret)
 	if token_host ~= module.host then
 		return nil, "invalid-host";
 	end
 
-	local token_info = {
-		owner = actor_jid;
-		created = os.time();
-		expires = token_ttl and (os.time() + token_ttl) or nil;
-		jid = token_jid;
-		session = {
-			username = token_username;
-			host = token_host;
-			resource = token_resource;
+	local grant, err = token_store:get_key(token_user, token_id);
+	if not grant or not grant.tokens then
+		if err then
+			module:log("error", "Unable to read from token storage: %s", err);
+			return nil, "internal-error";
+		end
+		module:log("warn", "Invalid token in storage (%s / %s)", token_user, token_id);
+		return nil, "not-authorized";
+	end
+
+	-- Check provided secret
+	local secret_hash = "sha256:"..hashes.sha256(token_secret, true);
+	local token_info = grant.tokens[secret_hash];
+	if not token_info then
+		module:log("debug", "No tokens matched the given secret");
+		return nil, "not-authorized";
+	end
 
-			auth_scope = token_scope;
-		};
-	};
+	-- Check expiry
+	local now = os.time();
+	if token_info.expires and token_info.expires < now then
+		module:log("debug", "Token has expired, cleaning it up");
+		grant.tokens[secret_hash] = nil;
+		token_store:set_key(token_user, token_id, grant);
+		return nil, "not-authorized";
+	end
 
-	local token_id = id.long();
-	local token = base64.encode("1;"..jid.join(token_username, token_host)..";"..token_id);
-	token_store:set(token_username, token_id, token_info);
+	-- Verify grant validity (expiry, etc.)
+	grant = _get_validated_grant_info(token_user, grant);
+	if not grant then
+		return nil, "not-authorized";
+	end
 
-	return token, token_info;
+	-- Update last access time if necessary
+	local last_accessed = grant.accessed;
+	if not last_accessed or (now - last_accessed) > access_time_granularity then
+		grant.accessed = now;
+		clear_expired_grant_tokens(grant); -- Clear expired tokens while we're here
+		token_store:set_key(token_user, token_id, grant);
+	end
+
+	token_info.id = token_id;
+	token_info.grant = grant;
+	token_info.jid = grant.jid;
+
+	return token_info;
 end
 
-local function parse_token(encoded_token)
-	local token = base64.decode(encoded_token);
-	if not token then return nil; end
-	local token_jid, token_id = token:match("^1;([^;]+);(.+)$");
-	if not token_jid then return nil; end
-	local token_user, token_host = jid.split(token_jid);
-	return token_id, token_user, token_host;
+function get_grant_info(username, grant_id)
+	local grant = _get_validated_grant_info(username, grant_id);
+	if not grant then return nil; end
+
+	-- Caller is only interested in the grant, no need to expose token stuff to them
+	grant.tokens = nil;
+
+	return grant;
+end
+
+function get_user_grants(username)
+	local grants = token_store:get(username);
+	if not grants then return nil; end
+	for grant_id, grant in pairs(grants) do
+		grants[grant_id] = _get_validated_grant_info(username, grant);
+	end
+	return grants;
 end
 
 function get_token_info(token)
-	local token_id, token_user, token_host = parse_token(token);
+	local token_id, token_user, token_host, token_secret = parse_token(token);
+	if not token_id then
+		module:log("warn", "Failed to verify access token: %s", token_user);
+		return nil, "invalid-token-format";
+	end
+	return _get_validated_token_info(token_id, token_user, token_host, token_secret);
+end
+
+function get_token_session(token, resource)
+	local token_id, token_user, token_host, token_secret = parse_token(token);
 	if not token_id then
+		module:log("warn", "Failed to verify access token: %s", token_user);
+		return nil, "invalid-token-format";
+	end
+
+	local token_info, err = _get_validated_token_info(token_id, token_user, token_host, token_secret);
+	if not token_info then return nil, err; end
+
+	local role = select_role(token_user, token_host, token_info.role);
+	if not role then return nil, "not-authorized"; end
+	return {
+		username = token_user;
+		host = token_host;
+		resource = token_info.resource or resource or generate_identifier();
+
+		role = role;
+	};
+end
+
+function revoke_token(token)
+	local grant_id, token_user, token_host, token_secret = parse_token(token);
+	if not grant_id then
+		module:log("warn", "Failed to verify access token: %s", token_user);
 		return nil, "invalid-token-format";
 	end
 	if token_host ~= module.host then
 		return nil, "invalid-host";
 	end
-
-	local token_info, err = token_store:get(token_user, token_id);
-	if not token_info then
-		if err then
-			return nil, "internal-error";
-		end
-		return nil, "not-authorized";
+	local grant, err = _get_validated_grant_info(token_user, grant_id);
+	if not grant then return grant, err; end
+	local secret_hash = "sha256:"..hashes.sha256(token_secret, true);
+	local token_info = grant.tokens[secret_hash];
+	if not grant or not token_info then
+		return nil, "item-not-found";
 	end
-
-	if token_info.expires and token_info.expires < os.time() then
-		return nil, "not-authorized";
+	grant.tokens[secret_hash] = nil;
+	local ok, err = token_store:set_key(token_user, grant_id, grant);
+	if not ok then
+		return nil, err;
 	end
-
-	return token_info
+	module:fire_event("token-revoked", {
+		grant_id = grant_id;
+		grant = grant;
+		info = token_info;
+		username = token_user;
+		host = token_host;
+	});
+	return true;
 end
 
-function revoke_token(token)
-	local token_id, token_user, token_host = parse_token(token);
-	if not token_id then
-		return nil, "invalid-token-format";
+function revoke_grant(username, grant_id)
+	local ok, err = token_store:set_key(username, grant_id, nil);
+	if not ok then return nil, err; end
+	module:fire_event("token-grant-revoked", { id = grant_id, username = username, host = module.host });
+	return true;
+end
+
+function sasl_handler(auth_provider, purpose, extra)
+	return function (sasl, token, realm, _authzid)
+		local token_info, err = get_token_info(token);
+		if not token_info then
+			module:log("debug", "SASL handler failed to verify token: %s", err);
+			return nil, nil, extra;
+		end
+		local token_user, token_host, resource = jid.split(token_info.grant.jid);
+		if realm ~= token_host or (purpose and token_info.purpose ~= purpose) then
+			return nil, nil, extra;
+		end
+		if auth_provider.is_enabled and not auth_provider.is_enabled(token_user) then
+			return true, false, token_info;
+		end
+		sasl.resource = resource;
+		sasl.token_info = token_info;
+		return token_user, true, token_info;
+	end;
+end
+
+module:daily("clear expired grants", function()
+	for username in token_store:items() do
+		get_user_grants(username); -- clears out expired grants
 	end
-	if token_host ~= module.host then
-		return nil, "invalid-host";
-	end
-	return token_store:set(token_user, token_id, nil);
-end
+end)
--- a/plugins/mod_tombstones.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_tombstones.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,16 +1,16 @@
 -- TODO warn when trying to create an user before the tombstone expires
 -- e.g. via telnet or other admin interface
-local datetime = require "util.datetime";
-local errors = require "util.error";
-local jid_node = require"util.jid".node;
-local st = require "util.stanza";
+local datetime = require "prosody.util.datetime";
+local errors = require "prosody.util.error";
+local jid_node = require"prosody.util.jid".node;
+local st = require "prosody.util.stanza";
 
 -- Using a map store as key-value store so that removal of all user data
 -- does not also remove the tombstone, which would defeat the point
 local graveyard = module:open_store(nil, "map");
-local graveyard_cache = require "util.cache".new(module:get_option_number("tombstone_cache_size", 1024));
+local graveyard_cache = require "prosody.util.cache".new(module:get_option_integer("tombstone_cache_size", 1024, 1));
 
-local ttl = module:get_option_number("user_tombstone_expiry", nil);
+local ttl = module:get_option_period("user_tombstone_expiry", nil);
 -- Keep tombstones forever by default
 --
 -- Rationale:
--- a/plugins/mod_turn_external.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_turn_external.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,12 +1,12 @@
-local set = require "util.set";
+local set = require "prosody.util.set";
 
 local secret = module:get_option_string("turn_external_secret");
 local host = module:get_option_string("turn_external_host", module.host);
 local user = module:get_option_string("turn_external_user");
-local port = module:get_option_number("turn_external_port", 3478);
-local ttl = module:get_option_number("turn_external_ttl", 86400);
+local port = module:get_option_integer("turn_external_port", 3478, 1, 65535);
+local ttl = module:get_option_period("turn_external_ttl", "1 day");
 local tcp = module:get_option_boolean("turn_external_tcp", false);
-local tls_port = module:get_option_number("turn_external_tls_port");
+local tls_port = module:get_option_integer("turn_external_tls_port", nil, 1, 65535);
 
 if not secret then
 	module:log_status("error", "Failed to initialize: the 'turn_external_secret' option is not set in your configuration");
--- a/plugins/mod_uptime.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_uptime.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,7 +6,7 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 local start_time = prosody.start_time;
 module:hook_global("server-started", function() start_time = prosody.start_time end);
--- a/plugins/mod_user_account_management.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_user_account_management.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,16 +7,28 @@
 --
 
 
-local st = require "util.stanza";
-local usermanager_set_password = require "core.usermanager".set_password;
-local usermanager_delete_user = require "core.usermanager".delete_user;
-local nodeprep = require "util.encodings".stringprep.nodeprep;
-local jid_bare = require "util.jid".bare;
+local st = require "prosody.util.stanza";
+local usermanager = require "prosody.core.usermanager";
+local nodeprep = require "prosody.util.encodings".stringprep.nodeprep;
+local jid_bare, jid_node = import("prosody.util.jid", "bare", "node");
 
 local compat = module:get_option_boolean("registration_compat", true);
+local soft_delete_period = module:get_option_period("registration_delete_grace_period");
+local deleted_accounts = module:open_store("accounts_cleanup");
 
 module:add_feature("jabber:iq:register");
 
+-- Allow us to 'freeze' a session and retrieve properties even after it is
+-- destroyed
+local function capture_session_properties(session)
+	return setmetatable({
+		id = session.id;
+		ip = session.ip;
+		type = session.type;
+		client_id = session.client_id;
+	}, { __index = session });
+end
+
 -- Password change and account deletion handler
 local function handle_registration_stanza(event)
 	local session, stanza = event.origin, event.stanza;
@@ -34,6 +46,12 @@
 		if query.tags[1] and query.tags[1].name == "remove" then
 			local username, host = session.username, session.host;
 
+			if host ~= module.host then -- Sanity check for safety
+				module:log("error", "Host mismatch on deletion request (a bug): %s ~= %s", host, module.host);
+				session.send(st.error_reply(stanza, "cancel", "internal-server-error"));
+				return true;
+			end
+
 			-- This one weird trick sends a reply to this stanza before the user is deleted
 			local old_session_close = session.close;
 			session.close = function(self, ...)
@@ -41,24 +59,57 @@
 				return old_session_close(self, ...);
 			end
 
-			local ok, err = usermanager_delete_user(username, host);
+			local old_session = capture_session_properties(session);
+
+			if not soft_delete_period then
+				local ok, err = usermanager.delete_user(username, host);
+
+				if not ok then
+					log("debug", "Removing user account %s@%s failed: %s", username, host, err);
+					session.close = old_session_close;
+					session.send(st.error_reply(stanza, "cancel", "service-unavailable", err));
+					return true;
+				end
+
+				log("info", "User removed their account: %s@%s (deleted)", username, host);
+				module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = old_session });
+			else
+				local ok, err = usermanager.disable_user(username, host, {
+					reason = "ibr";
+					comment = "Deletion requested by user";
+					when = os.time();
+				});
 
-			if not ok then
-				log("debug", "Removing user account %s@%s failed: %s", username, host, err);
-				session.close = old_session_close;
-				session.send(st.error_reply(stanza, "cancel", "service-unavailable", err));
-				return true;
+				if not ok then
+					log("debug", "Removing (disabling) user account %s@%s failed: %s", username, host, err);
+					session.close = old_session_close;
+					session.send(st.error_reply(stanza, "cancel", "service-unavailable", err));
+					return true;
+				end
+
+				local status = {
+					deleted_at = os.time();
+					pending_until = os.time() + soft_delete_period;
+					client_id = session.client_id;
+				};
+				deleted_accounts:set(username, status);
+
+				log("info", "User removed their account: %s@%s (disabled, pending deletion)", username, host);
+				module:fire_event("user-deregistered-pending", {
+					username = username;
+					host = host;
+					source = "mod_register";
+					session = old_session;
+					status = status;
+				});
 			end
-
-			log("info", "User removed their account: %s@%s", username, host);
-			module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = session });
 		else
 			local username = query:get_child_text("username");
 			local password = query:get_child_text("password");
 			if username and password then
 				username = nodeprep(username);
 				if username == session.username then
-					if usermanager_set_password(username, password, session.host, session.resource) then
+					if usermanager.set_password(username, password, session.host, session.resource) then
 						session.send(st.reply(stanza));
 					else
 						-- TODO unable to write file, file may be locked, etc, what's the correct error?
@@ -85,3 +136,103 @@
 	end);
 end
 
+-- This improves UX of soft-deleted accounts by informing the user that the
+-- account has been deleted, rather than just disabled. They can e.g. contact
+-- their admin if this was a mistake.
+module:hook("authentication-failure", function (event)
+	if event.condition ~= "account-disabled" then return; end
+	local session = event.session;
+	local sasl_handler = session and session.sasl_handler;
+	if sasl_handler.username then
+		local status = deleted_accounts:get(sasl_handler.username);
+		if status then
+			event.text = "Account deleted";
+		end
+	end
+end, -1000);
+
+function restore_account(username)
+	local pending, pending_err = deleted_accounts:get(username);
+	if not pending then
+		return nil, pending_err or "Account not pending deletion";
+	end
+	local account_info, err = usermanager.get_account_info(username, module.host);
+	if not account_info then
+		return nil, "Couldn't fetch account info: "..err;
+	end
+	local forget_ok, forget_err = deleted_accounts:set(username, nil);
+	if not forget_ok then
+		return nil, "Couldn't remove account from deletion queue: "..forget_err;
+	end
+	local enable_ok, enable_err = usermanager.enable_user(username, module.host);
+	if not enable_ok then
+		return nil, "Removed account from deletion queue, but couldn't enable it: "..enable_err;
+	end
+	return true, "Account restored";
+end
+
+-- Automatically clear pending deletion if an account is re-enabled
+module:context("*"):hook("user-enabled", function (event)
+	if event.host ~= module.host then return; end
+	deleted_accounts:set(event.username, nil);
+end);
+
+local cleanup_time = module:measure("cleanup", "times");
+
+function cleanup_soft_deleted_accounts()
+	local cleanup_done = cleanup_time();
+	local success, fail, restored, pending = 0, 0, 0, 0;
+
+	for username in deleted_accounts:users() do
+		module:log("debug", "Processing account cleanup for '%s'", username);
+		local account_info, account_info_err = usermanager.get_account_info(username, module.host);
+		if not account_info then
+			module:log("warn", "Unable to process delayed deletion of user '%s': %s", username, account_info_err);
+			fail = fail + 1;
+		else
+			if account_info.enabled == false then
+				local meta = deleted_accounts:get(username);
+				if meta.pending_until <= os.time() then
+					local ok, err = usermanager.delete_user(username, module.host);
+					if not ok then
+						module:log("warn", "Unable to process delayed deletion of user '%s': %s", username, err);
+						fail = fail + 1;
+					else
+						success = success + 1;
+						deleted_accounts:set(username, nil);
+						module:log("debug", "Deleted account '%s' successfully", username);
+						module:fire_event("user-deregistered", { username = username, host = module.host, source = "mod_register" });
+					end
+				else
+					pending = pending + 1;
+				end
+			else
+				module:log("warn", "Account '%s' is not disabled, removing from deletion queue", username);
+				restored = restored + 1;
+			end
+		end
+	end
+
+	module:log("debug", "%d accounts scheduled for future deletion", pending);
+
+	if success > 0 or fail > 0 then
+		module:log("info", "Completed account cleanup - %d accounts deleted (%d failed, %d restored, %d pending)", success, fail, restored, pending);
+	end
+	cleanup_done();
+end
+
+module:daily("Remove deleted accounts", cleanup_soft_deleted_accounts);
+
+--- shell command
+module:add_item("shell-command", {
+	section = "user";
+	name = "restore";
+	desc = "Restore a user account scheduled for deletion";
+	args = {
+		{ name = "jid", type = "string" };
+	};
+	host_selector = "jid";
+	handler = function (self, jid) --luacheck: ignore 212/self
+		return restore_account(jid_node(jid));
+	end;
+});
--- a/plugins/mod_vcard.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_vcard.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,8 +6,8 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza"
-local jid_split = require "util.jid".split;
+local st = require "prosody.util.stanza"
+local jid_split = require "prosody.util.jid".split;
 
 local vcards = module:open_store();
 
--- a/plugins/mod_vcard4.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_vcard4.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,5 +1,5 @@
-local st = require "util.stanza"
-local jid_split = require "util.jid".split;
+local st = require "prosody.util.stanza"
+local jid_split = require "prosody.util.jid".split;
 
 local mod_pep = module:depends("pep");
 
--- a/plugins/mod_vcard_legacy.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_vcard_legacy.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,10 +1,10 @@
-local st = require "util.stanza";
-local jid_split = require "util.jid".split;
+local st = require "prosody.util.stanza";
+local jid_split = require "prosody.util.jid".split;
 
 local mod_pep = module:depends("pep");
 
-local sha1 = require "util.hashes".sha1;
-local base64_decode = require "util.encodings".base64.decode;
+local sha1 = require "prosody.util.hashes".sha1;
+local base64_decode = require "prosody.util.encodings".base64.decode;
 
 local vcards = module:open_store("vcard");
 
--- a/plugins/mod_version.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_version.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,7 +6,7 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 module:add_feature("jabber:iq:version");
 
@@ -20,7 +20,7 @@
 		platform = "Windows";
 	else
 		local os_version_command = module:get_option_string("os_version_command");
-		local ok, pposix = pcall(require, "util.pposix");
+		local ok, pposix = pcall(require, "prosody.util.pposix");
 		if not os_version_command and (ok and pposix and pposix.uname) then
 			platform = pposix.uname().sysname;
 		end
--- a/plugins/mod_watchregistrations.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_watchregistrations.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,14 +8,14 @@
 
 
 local host = module:get_host();
-local jid_prep = require "util.jid".prep;
+local jid_prep = require "prosody.util.jid".prep;
 
 local registration_watchers = module:get_option_set("registration_watchers", module:get_option("admins", {})) / jid_prep;
 local registration_from = module:get_option_string("registration_from", host);
 local registration_notification = module:get_option_string("registration_notification", "User $username just registered on $host from $ip");
-local msg_type = module:get_option_string("registration_notification_type", "chat");
+local msg_type = module:get_option_enum("registration_notification_type", "chat", "normal", "headline");
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 module:hook("user-registered", function (user)
 	module:log("debug", "Notifying of new registration");
--- a/plugins/mod_websocket.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_websocket.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,19 +8,19 @@
 
 module:set_global();
 
-local add_task = require "util.timer".add_task;
-local add_filter = require "util.filters".add_filter;
-local sha1 = require "util.hashes".sha1;
-local base64 = require "util.encodings".base64.encode;
-local st = require "util.stanza";
-local parse_xml = require "util.xml".parse;
-local contains_token = require "util.http".contains_token;
-local portmanager = require "core.portmanager";
-local sm_destroy_session = require"core.sessionmanager".destroy_session;
+local add_task = require "prosody.util.timer".add_task;
+local add_filter = require "prosody.util.filters".add_filter;
+local sha1 = require "prosody.util.hashes".sha1;
+local base64 = require "prosody.util.encodings".base64.encode;
+local st = require "prosody.util.stanza";
+local parse_xml = require "prosody.util.xml".parse;
+local contains_token = require "prosody.util.http".contains_token;
+local portmanager = require "prosody.core.portmanager";
+local sm_destroy_session = require"prosody.core.sessionmanager".destroy_session;
 local log = module._log;
-local dbuffer = require "util.dbuffer";
+local dbuffer = require "prosody.util.dbuffer";
 
-local websocket_frames = require"net.websocket.frames";
+local websocket_frames = require"prosody.net.websocket.frames";
 local parse_frame = websocket_frames.parse;
 local build_frame = websocket_frames.build;
 local build_close = websocket_frames.build_close;
@@ -28,10 +28,10 @@
 
 local t_concat = table.concat;
 
-local stanza_size_limit = module:get_option_number("c2s_stanza_size_limit", 1024 * 256);
-local frame_buffer_limit = module:get_option_number("websocket_frame_buffer_limit", 2 * stanza_size_limit);
-local frame_fragment_limit = module:get_option_number("websocket_frame_fragment_limit", 8);
-local stream_close_timeout = module:get_option_number("c2s_close_timeout", 5);
+local stanza_size_limit = module:get_option_integer("c2s_stanza_size_limit", 1024 * 256, 10000);
+local frame_buffer_limit = module:get_option_integer("websocket_frame_buffer_limit", 2 * stanza_size_limit, 0);
+local frame_fragment_limit = module:get_option_integer("websocket_frame_fragment_limit", 8, 0);
+local stream_close_timeout = module:get_option_period("c2s_close_timeout", 5);
 local consider_websocket_secure = module:get_option_boolean("consider_websocket_secure");
 local cross_domain = module:get_option("cross_domain_websocket");
 if cross_domain ~= nil then
@@ -370,6 +370,6 @@
 	module:hook("c2s-read-timeout", keepalive, -0.9);
 end
 
-if require"core.modulemanager".get_modules_for_host("*"):contains(module.name) then
+if require"prosody.core.modulemanager".get_modules_for_host("*"):contains(module.name) then
 	module:add_host();
 end
--- a/plugins/mod_welcome.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/mod_welcome.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -9,7 +9,7 @@
 local host = module:get_host();
 local welcome_text = module:get_option_string("welcome_message", "Hello $username, welcome to the $host IM server!");
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 module:hook("user-registered",
 	function (user)
--- a/plugins/muc/hats.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/hats.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,4 +1,4 @@
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 local muc_util = module:require "muc/util";
 
 local xmlns_hats = "xmpp:prosody.im/protocol/hats:1";
--- a/plugins/muc/hidden.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/hidden.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,7 +8,7 @@
 --
 
 local restrict_public = not module:get_option_boolean("muc_room_allow_public", true);
-local um_is_admin = require "core.usermanager".is_admin;
+module:default_permission(restrict_public and "prosody:admin" or "prosody:registered", ":create-public-room");
 
 local function get_hidden(room)
 	return room._data.hidden;
@@ -22,8 +22,8 @@
 end
 
 module:hook("muc-config-form", function(event)
-	if restrict_public and not um_is_admin(event.actor, module.host) then
-		-- Don't show option if public rooms are restricted and user is not admin of this host
+	if not module:may(":create-public-room", event.actor) then
+		-- Hide config option if this user is not allowed to create public rooms
 		return;
 	end
 	table.insert(event.form, {
@@ -36,7 +36,7 @@
 end, 100-9);
 
 module:hook("muc-config-submitted/muc#roomconfig_publicroom", function(event)
-	if restrict_public and not um_is_admin(event.actor, module.host) then
+	if not module:may(":create-public-room", event.actor) then
 		return; -- Not allowed
 	end
 	if set_hidden(event.room, not event.value) then
--- a/plugins/muc/history.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/history.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,11 +8,11 @@
 --
 
 local gettime = os.time;
-local datetime = require "util.datetime";
-local st = require "util.stanza";
+local datetime = require "prosody.util.datetime";
+local st = require "prosody.util.stanza";
 
 local default_history_length = 20;
-local max_history_length = module:get_option_number("max_history_messages", math.huge);
+local max_history_length = module:get_option_integer("max_history_messages", math.huge, 0);
 
 local function set_max_history_length(_max_history_length)
 	max_history_length = _max_history_length or math.huge;
--- a/plugins/muc/lock.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/lock.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,10 +7,10 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 local lock_rooms = module:get_option_boolean("muc_room_locking", true);
-local lock_room_timeout = module:get_option_number("muc_room_lock_timeout", 300);
+local lock_room_timeout = module:get_option_period("muc_room_lock_timeout", "5 minutes");
 
 local function lock(room)
 	module:fire_event("muc-room-locked", {room = room;});
--- a/plugins/muc/members_only.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/members_only.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,7 +7,7 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 local muc_util = module:require "muc/util";
 local valid_affiliations = muc_util.valid_affiliations;
--- a/plugins/muc/mod_muc.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/mod_muc.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -89,18 +89,17 @@
 local presence_broadcast = module:require "muc/presence_broadcast";
 room_mt.get_presence_broadcast = presence_broadcast.get;
 room_mt.set_presence_broadcast = presence_broadcast.set;
-room_mt.get_valid_broadcast_roles = presence_broadcast.get_valid_broadcast_roles;
+room_mt.get_valid_broadcast_roles = presence_broadcast.get_valid_broadcast_roles; -- FIXME doesn't exist in the library
 
 local occupant_id = module:require "muc/occupant_id";
 room_mt.get_salt = occupant_id.get_room_salt;
 room_mt.get_occupant_id = occupant_id.get_occupant_id;
 
-local jid_split = require "util.jid".split;
-local jid_prep = require "util.jid".prep;
-local jid_bare = require "util.jid".bare;
-local st = require "util.stanza";
-local cache = require "util.cache";
-local um_is_admin = require "core.usermanager".is_admin;
+local jid_split = require "prosody.util.jid".split;
+local jid_prep = require "prosody.util.jid".prep;
+local jid_bare = require "prosody.util.jid".bare;
+local st = require "prosody.util.stanza";
+local cache = require "prosody.util.cache";
 
 module:require "muc/config_form_sections";
 
@@ -111,21 +110,26 @@
 module:require "muc/hats";
 module:require "muc/lock";
 
-local function is_admin(jid)
-	return um_is_admin(jid, module.host);
-end
+module:default_permissions("prosody:admin", {
+	":automatic-ownership";
+	":create-room";
+	":recreate-destroyed-room";
+});
+module:default_permissions("prosody:guest", {
+	":list-rooms";
+});
 
 if module:get_option_boolean("component_admins_as_room_owners", true) then
 	-- Monkey patch to make server admins room owners
 	local _get_affiliation = room_mt.get_affiliation;
 	function room_mt:get_affiliation(jid)
-		if is_admin(jid) then return "owner"; end
+		if module:could(":automatic-ownership", jid) then return "owner"; end
 		return _get_affiliation(self, jid);
 	end
 
 	local _set_affiliation = room_mt.set_affiliation;
 	function room_mt:set_affiliation(actor, jid, affiliation, reason, data)
-		if affiliation ~= "owner" and is_admin(jid) then return nil, "modify", "not-acceptable"; end
+		if affiliation ~= "owner" and module:could(":automatic-ownership", jid) then return nil, "modify", "not-acceptable"; end
 		return _set_affiliation(self, actor, jid, affiliation, reason, data);
 	end
 end
@@ -158,8 +162,8 @@
 	end
 end
 
-local max_rooms = module:get_option_number("muc_max_rooms");
-local max_live_rooms = module:get_option_number("muc_room_cache_size", 100);
+local max_rooms = module:get_option_integer("muc_max_rooms", nil, 0);
+local max_live_rooms = module:get_option_integer("muc_room_cache_size", 100, 1);
 
 local room_hit = module:measure("room_hit", "rate");
 local room_miss = module:measure("room_miss", "rate")
@@ -281,15 +285,14 @@
 	room:set_public(module:get_option_boolean("muc_room_default_public", false));
 	room:set_persistent(module:get_option_boolean("muc_room_default_persistent", room:get_persistent()));
 	room:set_members_only(module:get_option_boolean("muc_room_default_members_only", room:get_members_only()));
-	room:set_allow_member_invites(module:get_option_boolean("muc_room_default_allow_member_invites",
-		room:get_allow_member_invites()));
+	room:set_allow_member_invites(module:get_option_boolean("muc_room_default_allow_member_invites", room:get_allow_member_invites()));
 	room:set_moderated(module:get_option_boolean("muc_room_default_moderated", room:get_moderated()));
-	room:set_whois(module:get_option_boolean("muc_room_default_public_jids",
-		room:get_whois() == "anyone") and "anyone" or "moderators");
+	room:set_whois(module:get_option_boolean("muc_room_default_public_jids", room:get_whois() == "anyone") and "anyone" or "moderators");
 	room:set_changesubject(module:get_option_boolean("muc_room_default_change_subject", room:get_changesubject()));
-	room:set_historylength(module:get_option_number("muc_room_default_history_length", room:get_historylength()));
+	room:set_historylength(module:get_option_integer("muc_room_default_history_length", room:get_historylength(), 0));
 	room:set_language(lang or module:get_option_string("muc_room_default_language"));
-	room:set_presence_broadcast(module:get_option("muc_room_default_presence_broadcast", room:get_presence_broadcast()));
+	room:set_presence_broadcast(module:get_option_enum("muc_room_default_presence_broadcast", room:get_presence_broadcast(), "visitor", "participant",
+		"moderator"));
 end
 
 function create_room(room_jid, config)
@@ -350,8 +353,12 @@
 end
 
 module:hook("host-disco-items", function(event)
+	module:log("debug", "host-disco-items called");
+	if not module:could(":list-rooms", event) then
+		module:log("debug", "Returning empty room list to unauthorized request");
+		return;
+	end
 	local reply = event.reply;
-	module:log("debug", "host-disco-items called");
 	if next(room_items_cache) ~= nil then
 		for jid, room_name in pairs(room_items_cache) do
 			if room_name == "" then room_name = nil; end
@@ -388,7 +395,7 @@
 
 if module:get_option_boolean("muc_tombstones", true) then
 
-	local ttl = module:get_option_number("muc_tombstone_expiry", 86400 * 31);
+	local ttl = module:get_option_period("muc_tombstone_expiry", "31 days");
 
 	module:hook("muc-room-destroyed",function(event)
 		local room = event.room;
@@ -412,26 +419,15 @@
 	end, -10);
 end
 
-do
-	local restrict_room_creation = module:get_option("restrict_room_creation");
-	if restrict_room_creation == true then
-		restrict_room_creation = "admin";
+local restrict_room_creation = module:get_option_enum("restrict_room_creation", false, true, "local");
+module:default_permission(restrict_room_creation == true and "prosody:admin" or "prosody:registered", ":create-room");
+module:hook("muc-room-pre-create", function(event)
+	local origin, stanza = event.origin, event.stanza;
+	if restrict_room_creation ~= false and not module:may(":create-room", event) then
+		origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Room creation is restricted", module.host));
+		return true;
 	end
-	if restrict_room_creation then
-		local host_suffix = module.host:gsub("^[^%.]+%.", "");
-		module:hook("muc-room-pre-create", function(event)
-			local origin, stanza = event.origin, event.stanza;
-			local user_jid = stanza.attr.from;
-			if not is_admin(user_jid) and not (
-				restrict_room_creation == "local" and
-				select(2, jid_split(user_jid)) == host_suffix
-			) then
-				origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Room creation is restricted", module.host));
-				return true;
-			end
-		end);
-	end
-end
+end);
 
 for event_name, method in pairs {
 	-- Normal room interactions
@@ -465,7 +461,7 @@
 
 		if room and room._data.destroyed then
 			if room._data.locked < os.time()
-			or (is_admin(stanza.attr.from) and stanza.name == "presence" and stanza.attr.type == nil) then
+			or (module:may(":recreate-destroyed-room", event) and stanza.name == "presence" and stanza.attr.type == nil) then
 				-- Allow the room to be recreated by admin or after time has passed
 				delete_room(room);
 				room = nil;
@@ -516,10 +512,10 @@
 	module:depends "adhoc";
 	local t_concat = table.concat;
 	local adhoc_new = module:require "adhoc".new;
-	local adhoc_initial = require "util.adhoc".new_initial_data_form;
-	local adhoc_simple = require "util.adhoc".new_simple_form;
-	local array = require "util.array";
-	local dataforms_new = require "util.dataforms".new;
+	local adhoc_initial = require "prosody.util.adhoc".new_initial_data_form;
+	local adhoc_simple = require "prosody.util.adhoc".new_simple_form;
+	local array = require "prosody.util.array";
+	local dataforms_new = require "prosody.util.dataforms".new;
 
 	local destroy_rooms_layout = dataforms_new {
 		title = "Destroy rooms";
--- a/plugins/muc/muc.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/muc.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -12,18 +12,18 @@
 local next = next;
 local setmetatable = setmetatable;
 
-local dataform = require "util.dataforms";
-local iterators = require "util.iterators";
-local jid_split = require "util.jid".split;
-local jid_bare = require "util.jid".bare;
-local jid_prep = require "util.jid".prep;
-local jid_join = require "util.jid".join;
-local jid_resource = require "util.jid".resource;
-local resourceprep = require "util.encodings".stringprep.resourceprep;
-local st = require "util.stanza";
-local base64 = require "util.encodings".base64;
-local hmac_sha256 = require "util.hashes".hmac_sha256;
-local new_id = require "util.id".medium;
+local dataform = require "prosody.util.dataforms";
+local iterators = require "prosody.util.iterators";
+local jid_split = require "prosody.util.jid".split;
+local jid_bare = require "prosody.util.jid".bare;
+local jid_prep = require "prosody.util.jid".prep;
+local jid_join = require "prosody.util.jid".join;
+local jid_resource = require "prosody.util.jid".resource;
+local resourceprep = require "prosody.util.encodings".stringprep.resourceprep;
+local st = require "prosody.util.stanza";
+local base64 = require "prosody.util.encodings".base64;
+local hmac_sha256 = require "prosody.util.hashes".hmac_sha256;
+local new_id = require "prosody.util.id".medium;
 
 local log = module._log;
 
@@ -1079,7 +1079,10 @@
 	local reason = item:get_child_text("reason");
 	local success, errtype, err
 	if item.attr.affiliation and item.attr.jid and not item.attr.role then
-		local registration_data;
+		local registration_data = self:get_affiliation_data(item.attr.jid) or {};
+		if reason then
+			registration_data.reason = reason;
+		end
 		if item.attr.nick then
 			local room_nick = self.jid.."/"..item.attr.nick;
 			local existing_occupant = self:get_occupant_by_nick(room_nick);
@@ -1088,7 +1091,7 @@
 				self:set_role(true, room_nick, nil, "This nickname is reserved");
 			end
 			module:log("debug", "Reserving %s for %s (%s)", item.attr.nick, item.attr.jid, item.attr.affiliation);
-			registration_data = { reserved_nickname = item.attr.nick };
+			registration_data.reserved_nickname = item.attr.nick;
 		end
 		success, errtype, err = self:set_affiliation(actor, item.attr.jid, item.attr.affiliation, reason, registration_data);
 	elseif item.attr.role and item.attr.nick and not item.attr.affiliation then
@@ -1119,9 +1122,13 @@
 		if (affiliation_rank >= valid_affiliations.admin and affiliation_rank >= _aff_rank)
 		or (self:get_members_only() and self:get_whois() == "anyone" and affiliation_rank >= valid_affiliations.member) then
 			local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin");
-			for jid in self:each_affiliation(_aff or "none") do
+			for jid, _, data in self:each_affiliation(_aff or "none") do
 				local nick = self:get_registered_nick(jid);
-				reply:tag("item", {affiliation = _aff, jid = jid, nick = nick }):up();
+				reply:tag("item", {affiliation = _aff, jid = jid, nick = nick });
+				if data and data.reason then
+					reply:text_tag("reason", data.reason);
+				end
+				reply:up();
 			end
 			origin.send(reply:up());
 			return true;
--- a/plugins/muc/occupant.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/occupant.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,6 @@
 local pairs = pairs;
 local setmetatable = setmetatable;
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 local util = module:require "muc/util";
 
 local function get_filtered_presence(stanza)
--- a/plugins/muc/occupant_id.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/occupant_id.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -4,9 +4,9 @@
 -- (C) 2020 Maxime “pep” Buquet <pep@bouah.net>
 -- (C) 2020 Matthew Wild <mwild1@gmail.com>
 
-local uuid = require "util.uuid";
-local hmac_sha256 = require "util.hashes".hmac_sha256;
-local b64encode = require "util.encodings".base64.encode;
+local uuid = require "prosody.util.uuid";
+local hmac_sha256 = require "prosody.util.hashes".hmac_sha256;
+local b64encode = require "prosody.util.encodings".base64.encode;
 
 local xmlns_occupant_id = "urn:xmpp:occupant-id:0";
 
--- a/plugins/muc/password.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/password.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,7 +7,7 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 local function get_password(room)
 	return room._data.password;
--- a/plugins/muc/persistent.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/persistent.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,7 +8,10 @@
 --
 
 local restrict_persistent = not module:get_option_boolean("muc_room_allow_persistent", true);
-local um_is_admin = require "core.usermanager".is_admin;
+module:default_permission(
+	restrict_persistent and "prosody:admin" or "prosody:registered",
+	":create-persistent-room"
+);
 
 local function get_persistent(room)
 	return room._data.persistent;
@@ -22,8 +25,8 @@
 end
 
 module:hook("muc-config-form", function(event)
-	if restrict_persistent and not um_is_admin(event.actor, module.host) then
-		-- Don't show option if hidden rooms are restricted and user is not admin of this host
+	if not module:may(":create-persistent-room", event.actor) then
+		-- Hide config option if this user is not allowed to create persistent rooms
 		return;
 	end
 	table.insert(event.form, {
@@ -36,7 +39,7 @@
 end, 100-5);
 
 module:hook("muc-config-submitted/muc#roomconfig_persistentroom", function(event)
-	if restrict_persistent and not um_is_admin(event.actor, module.host) then
+	if not module:may(":create-persistent-room", event.actor) then
 		return; -- Not allowed
 	end
 	if set_persistent(event.room, event.value) then
--- a/plugins/muc/presence_broadcast.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/presence_broadcast.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,7 +7,7 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 local valid_roles = { "none", "visitor", "participant", "moderator" };
 local default_broadcast = {
--- a/plugins/muc/register.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/register.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,8 +1,8 @@
-local jid_bare = require "util.jid".bare;
-local jid_resource = require "util.jid".resource;
-local resourceprep = require "util.encodings".stringprep.resourceprep;
-local st = require "util.stanza";
-local dataforms = require "util.dataforms";
+local jid_bare = require "prosody.util.jid".bare;
+local jid_resource = require "prosody.util.jid".resource;
+local resourceprep = require "prosody.util.encodings".stringprep.resourceprep;
+local st = require "prosody.util.stanza";
+local dataforms = require "prosody.util.dataforms";
 
 local allow_unaffiliated = module:get_option_boolean("allow_unaffiliated_register", false);
 
@@ -94,8 +94,10 @@
 		local nick = get_registered_nick(room, jid_bare(stanza.attr.from));
 		if nick then
 			if event.occupant then
+				-- someone is joining, force their nickname to the registered one
 				event.occupant.nick = jid_bare(event.occupant.nick) .. "/" .. nick;
 			elseif event.dest_occupant.nick ~= jid_bare(event.dest_occupant.nick) .. "/" .. nick then
+				-- someone is trying to change nickname to something other than their registered nickname, can't have that
 				module:log("debug", "Attempt by %s to join as %s, but their reserved nick is %s", stanza.attr.from, requested_nick, nick);
 				local reply = st.error_reply(stanza, "cancel", "not-acceptable", nil, room.jid):up();
 				origin.send(reply);
--- a/plugins/muc/request.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/request.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,14 +7,14 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza";
-local jid_resource = require "util.jid".resource;
+local st = require "prosody.util.stanza";
+local jid_resource = require "prosody.util.jid".resource;
 
 module:hook("muc-disco#info", function(event)
 	event.reply:tag("feature", {var = "http://jabber.org/protocol/muc#request"}):up();
 end);
 
-local voice_request_form = require "util.dataforms".new({
+local voice_request_form = require "prosody.util.dataforms".new({
 	title = "Voice Request";
 	{
 		name = "FORM_TYPE";
--- a/plugins/muc/subject.lib.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/plugins/muc/subject.lib.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,8 +7,8 @@
 -- COPYING file in the source package for more information.
 --
 
-local st = require "util.stanza";
-local dt = require "util.datetime";
+local st = require "prosody.util.stanza";
+local dt = require "prosody.util.datetime";
 
 local muc_util = module:require "muc/util";
 local valid_roles = muc_util.valid_roles;
--- a/prosody	Wed Mar 27 15:35:15 2024 +0000
+++ b/prosody	Wed Mar 27 15:39:03 2024 +0000
@@ -44,9 +44,19 @@
 end
 
 
+-- Check before first require, to preempt the probable failure
+if _VERSION < "Lua 5.2" then
+	io.stderr:write("Prosody is no longer compatible with Lua 5.1\n")
+	io.stderr:write("See https://prosody.im/doc/depends#lua for more information\n")
+	return os.exit(1);
+end
 
-local startup = require "util.startup";
-local async = require "util.async";
+if not pcall(require, "prosody.loader") then
+	pcall(require, "loader");
+end
+
+local startup = require "prosody.util.startup";
+local async = require "prosody.util.async";
 
 -- Note: it's important that this thread is not GC'd, as some C libraries
 -- that are initialized here store a pointer to it ( :/ ).
@@ -73,7 +83,7 @@
 	end
 
 	local sleep = require"socket".sleep;
-	local server = require "net.server";
+	local server = require "prosody.net.server";
 
 	while select(2, xpcall(server.loop, catch_uncaught_error)) ~= "quitting" do
 		sleep(0.2);
--- a/prosodyctl	Wed Mar 27 15:35:15 2024 +0000
+++ b/prosodyctl	Wed Mar 27 15:39:03 2024 +0000
@@ -44,22 +44,33 @@
 
 -----------
 
-local startup = require "util.startup";
+-- Check before first require, to preempt the probable failure
+if _VERSION < "Lua 5.2" then
+	io.stderr:write("Prosody is no longer compatible with Lua 5.1\n")
+	io.stderr:write("See https://prosody.im/doc/depends#lua for more information\n")
+	return os.exit(1);
+end
+
+if not pcall(require, "prosody.loader") then
+	pcall(require, "loader");
+end
+
+local startup = require "prosody.util.startup";
 startup.prosodyctl();
 
 -----------
 
-local configmanager = require "core.configmanager";
-local modulemanager = require "core.modulemanager"
-local prosodyctl = require "util.prosodyctl"
+local configmanager = require "prosody.core.configmanager";
+local modulemanager = require "prosody.core.modulemanager"
+local prosodyctl = require "prosody.util.prosodyctl"
 local socket = require "socket"
-local dependencies = require "util.dependencies";
+local dependencies = require "prosody.util.dependencies";
 local lfs = dependencies.softreq "lfs";
 
 -----------------------
 
-local parse_args = require "util.argparse".parse;
-local human_io = require "util.human.io";
+local parse_args = require "prosody.util.argparse".parse;
+local human_io = require "prosody.util.human.io";
 
 local show_message, show_warning = prosodyctl.show_message, prosodyctl.show_warning;
 local show_usage = prosodyctl.show_usage;
@@ -67,7 +78,7 @@
 local call_luarocks = prosodyctl.call_luarocks;
 local error_messages = prosodyctl.error_messages;
 
-local jid_split = require "util.jid".prepped_split;
+local jid_split = require "prosody.util.jid".prepped_split;
 
 local prosodyctl_timeout = (configmanager.get("*", "prosodyctl_timeout") or 5) * 2;
 -----------------------
@@ -427,8 +438,8 @@
 	end
 
 	local pwd = ".";
-	local sorted_pairs = require "util.iterators".sorted_pairs;
-	local hg = require"util.mercurial";
+	local sorted_pairs = require "prosody.util.iterators".sorted_pairs;
+	local hg = require"prosody.util.mercurial";
 	local relpath = configmanager.resolve_relative_path;
 
 	print("Prosody "..(prosody.version or "(unknown version)"));
@@ -450,7 +461,7 @@
 				.."\n  ";
 		end)));
 	print("");
-	local have_pposix, pposix = pcall(require, "util.pposix");
+	local have_pposix, pposix = pcall(require, "prosody.util.pposix");
 	if have_pposix and pposix.uname then
 		print("# Operating system");
 		local uname, err = pposix.uname();
@@ -483,7 +494,7 @@
 	print("");
 	print("# Network");
 	print("");
-	print("Backend: "..require "net.server".get_backend());
+	print("Backend: "..require "prosody.net.server".get_backend());
 	print("");
 	print("# Lua module versions");
 	local module_versions, longest_name = {}, 8;
@@ -505,7 +516,7 @@
 	}
 	local lunbound = dependencies.softreq"lunbound";
 	local lxp = dependencies.softreq"lxp";
-	local hashes = dependencies.softreq"util.hashes";
+	local hashes = dependencies.softreq"prosody.util.hashes";
 	for name, module in pairs(package.loaded) do
 		local version_field = alternate_version_fields[name] or "_VERSION";
 		if type(module) == "table" and rawget(module, version_field)
@@ -538,7 +549,7 @@
 	end
 	print("");
 	print("# library versions");
-	if require "net.server".event_base then
+	if require "prosody.net.server".event_base then
 		library_versions["libevent"] = require"luaevent".core.libevent_version();
 	end
 	for name, version in sorted_pairs(library_versions) do
@@ -554,6 +565,15 @@
 		return 0;
 	end
 
+	if arg[1] and arg[1]:match"^mod_" then
+		-- TODO reword the usage text, document
+		local shell = require "prosody.util.prosodyctl.shell";
+		arg[1] = arg[1]:match("^mod_(.*)"); -- strip mod_ prefix
+		table.insert(arg, 1, "module");
+		table.insert(arg, 2, "reload");
+		return shell.shell(arg);
+	end
+
 	service_command_warning("reload");
 
 	if not prosodyctl.isrunning() then
@@ -573,7 +593,7 @@
 end
 -- ejabberdctl compatibility
 
-local unpack = table.unpack or unpack; -- luacheck: ignore 113
+local unpack = table.unpack;
 
 function commands.register(arg)
 	local user, host, password = unpack(arg);
@@ -628,8 +648,8 @@
 
 ---------------------
 
-local async = require "util.async";
-local server = require "net.server";
+local async = require "prosody.util.async";
+local server = require "prosody.net.server";
 local watchers = {
 	error = function (_, err)
 		error(err);
@@ -675,7 +695,7 @@
 	end
 
 	if command and not commands[command] then
-		local ok, command_module = pcall(require, "util.prosodyctl."..command);
+		local ok, command_module = pcall(require, "prosody.util.prosodyctl."..command);
 		if ok and command_module[command] then
 			commands[command] = command_module[command];
 		end
@@ -692,7 +712,7 @@
 		print("");
 		print("Where COMMAND may be one of:");
 
-		local hidden_commands = require "util.set".new{ "register", "unregister" };
+		local hidden_commands = require "prosody.util.set".new{ "register", "unregister" };
 		local commands_order = {
 			"Process management:",
 				"start"; "stop"; "restart"; "reload"; "status";
--- a/spec/core_moduleapi_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/core_moduleapi_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -11,8 +11,8 @@
 
 local module = setmetatable({}, {__index = api});
 local opt = nil;
-function module:log() end
-function module:get_option(name)
+function module.log(_self) end
+function module.get_option(_self, name)
 	if name == "opt" then
 		return opt;
 	else
@@ -20,7 +20,7 @@
 	end
 end
 
-function test_option_value(value, returns)
+local function test_option_value(value, returns)
 	opt = value;
 	assert(module:get_option_number("opt") == returns.number, "number doesn't match");
 	assert(module:get_option_string("opt") == returns.string, "string doesn't match");
--- a/spec/core_storagemanager_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/core_storagemanager_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,13 +1,11 @@
-local unpack = table.unpack or unpack; -- luacheck: ignore 113
-local server = require "net.server_select";
-package.loaded["net.server"] = server;
+local unpack = table.unpack;
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 
 local function mock_prosody()
 	_G.prosody = {
 		core_post_stanza = function () end;
-		events = require "util.events".new();
+		events = require "prosody.util.events".new();
 		hosts = {};
 		paths = {
 			data = "./data";
@@ -36,6 +34,11 @@
 	};
 };
 
+local test_only_driver = os.getenv "PROSODY_TEST_ONLY_STORAGE";
+if test_only_driver then
+	configs = { [test_only_driver] = configs[test_only_driver] }
+end
+
 local test_host = "storage-unit-tests.invalid";
 
 describe("storagemanager", function ()
@@ -47,10 +50,10 @@
 		insulate(tagged_name.." #storage backend", function ()
 			mock_prosody();
 
-			local config = require "core.configmanager";
-			local sm = require "core.storagemanager";
-			local hm = require "core.hostmanager";
-			local mm = require "core.modulemanager";
+			local config = require "prosody.core.configmanager";
+			local sm = require "prosody.core.storagemanager";
+			local hm = require "prosody.core.hostmanager";
+			local mm = require "prosody.core.modulemanager";
 
 			-- Simple check to ensure insulation is working correctly
 			assert.is_nil(config.get(test_host, "storage"));
@@ -196,6 +199,136 @@
 				end);
 			end);
 
+			describe("keyval+ stores", function ()
+				-- These tests rely on being executed in order, disable any order
+				-- randomization for this block
+				randomize(false);
+
+				local store, kv_store, map_store;
+				it("may be opened", function ()
+					store = assert(sm.open(test_host, "test-kv+", "keyval+"));
+				end);
+
+				local simple_data = { foo = "bar" };
+
+				it("may set data for a user", function ()
+					assert(store:set("user9999", simple_data));
+				end);
+
+				it("may get data for a user", function ()
+					assert.same(simple_data, assert(store:get("user9999")));
+				end);
+
+				it("may be opened as a keyval store", function ()
+					kv_store = assert(sm.open(test_host, "test-kv+", "keyval"));
+					assert.same(simple_data, assert(kv_store:get("user9999")));
+				end);
+
+				it("may be opened as a map store", function ()
+					map_store = assert(sm.open(test_host, "test-kv+", "map"));
+					assert.same("bar", assert(map_store:get("user9999", "foo")));
+				end);
+
+				it("may remove data for a user", function ()
+					assert(store:set("user9999", nil));
+					local ret, err = store:get("user9999");
+					assert.is_nil(ret);
+					assert.is_nil(err);
+				end);
+
+
+				it("may set a specific key for a user", function ()
+					assert(store:set_key("user9999", "foo", "bar"));
+					assert.same(kv_store:get("user9999"), { foo = "bar" });
+				end);
+
+				it("may get a specific key for a user", function ()
+					assert.equal("bar", store:get_key("user9999", "foo"));
+				end);
+
+				it("may find all users with a specific key", function ()
+					assert.is_function(store.get_key_from_all);
+					assert(store:set_key("user9999b", "bar", "bar"));
+					assert(store:set_key("user9999c", "foo", "blah"));
+					local ret, err = store:get_key_from_all("foo");
+					assert.is_nil(err);
+					assert.same({ user9999 = "bar", user9999c = "blah" }, ret);
+				end);
+
+				it("rejects empty or non-string keys to get_all", function ()
+					assert.is_function(store.get_key_from_all);
+					do
+						local ret, err = store:get_key_from_all("");
+						assert.is_nil(ret);
+						assert.is_not_nil(err);
+					end
+					do
+						local ret, err = store:get_key_from_all(true);
+						assert.is_nil(ret);
+						assert.is_not_nil(err);
+					end
+				end);
+
+				it("rejects empty or non-string keys to delete_all", function ()
+					assert.is_function(store.delete_key_from_all);
+					do
+						local ret, err = store:delete_key_from_all("");
+						assert.is_nil(ret);
+						assert.is_not_nil(err);
+					end
+					do
+						local ret, err = store:delete_key_from_all(true);
+						assert.is_nil(ret);
+						assert.is_not_nil(err);
+					end
+				end);
+
+				it("may delete all instances of a specific key", function ()
+					assert.is_function(store.delete_key_from_all);
+					assert(store:set_key("user9999b", "foo", "hello"));
+
+					assert(store:delete_key_from_all("bar"));
+					-- Ensure key was deleted
+					do
+						local ret, err = store:get_key("user9999b", "bar");
+						assert.is_nil(ret);
+						assert.is_nil(err);
+					end
+					-- Ensure other users/keys are intact
+					do
+						local ret, err = store:get_key("user9999", "foo");
+						assert.equal("bar", ret);
+						assert.is_nil(err);
+					end
+					do
+						local ret, err = store:get_key("user9999b", "foo");
+						assert.equal("hello", ret);
+						assert.is_nil(err);
+					end
+					do
+						local ret, err = store:get_key("user9999c", "foo");
+						assert.equal("blah", ret);
+						assert.is_nil(err);
+					end
+				end);
+
+				it("may remove data for a specific key for a user", function ()
+					assert(store:set_key("user9999", "foo", nil));
+					do
+						local ret, err = store:get_key("user9999", "foo");
+						assert.is_nil(ret);
+						assert.is_nil(err);
+					end
+
+					assert(store:set_key("user9999b", "foo", nil));
+					do
+						local ret, err = store:get_key("user9999b", "foo");
+						assert.is_nil(ret);
+						assert.is_nil(err);
+					end
+				end);
+			end);
+
 			describe("archive stores", function ()
 				randomize(false);
 
@@ -211,13 +344,13 @@
 				local test_time = 1539204123;
 
 				local test_data = {
-					{ nil, test_stanza, test_time, "contact@example.com" };
-					{ nil, test_stanza, test_time+1, "contact2@example.com" };
-					{ nil, test_stanza, test_time+2, "contact2@example.com" };
+					{ nil, test_stanza, test_time-3, "contact@example.com" };
+					{ nil, test_stanza, test_time-2, "contact2@example.com" };
 					{ nil, test_stanza, test_time-1, "contact2@example.com" };
-					{ nil, test_stanza, test_time-1, "contact3@example.com" };
-					{ nil, test_stanza, test_time+0, "contact3@example.com" };
+					{ nil, test_stanza, test_time+0, "contact2@example.com" };
 					{ nil, test_stanza, test_time+1, "contact3@example.com" };
+					{ nil, test_stanza, test_time+2, "contact3@example.com" };
+					{ nil, test_stanza, test_time+3, "contact3@example.com" };
 				};
 
 				it("can be added to", function ()
@@ -260,7 +393,7 @@
 							assert.equal("test", item.name);
 							assert.equal("urn:example:foo", item.attr.xmlns);
 							assert.equal(2, #item.tags);
-							assert.equal(test_time, when);
+							assert.equal(test_time-3, when);
 						end
 						assert.equal(1, count);
 					end);
@@ -298,16 +431,16 @@
 							assert.equal("test", item.name);
 							assert.equal("urn:example:foo", item.attr.xmlns);
 							assert.equal(2, #item.tags);
-							assert(test_time <= when);
+							assert(when >= test_time, ("%d >= %d"):format(when, test_time));
 						end
-						assert.equal(#test_data - 2, count);
+						assert.equal(#test_data - 3, count);
 					end);
 
 					it("by time (start+end)", function ()
 						-- luacheck: ignore 211/err
 						local data, err = archive:find("user", {
-							["start"] = test_time;
-							["end"] = test_time+1;
+							["start"] = test_time-1;
+							["end"] = test_time+2;
 						});
 						assert.truthy(data);
 						local count = 0;
@@ -318,8 +451,8 @@
 							assert.equal("test", item.name);
 							assert.equal("urn:example:foo", item.attr.xmlns);
 							assert.equal(2, #item.tags);
-							assert(when >= test_time, ("%d >= %d"):format(when, test_time));
-							assert(when <= test_time+1, ("%d <= %d"):format(when, test_time+1));
+							assert(when >= test_time-1, ("%d >= %d"):format(when, test_time));
+							assert(when <= test_time+2, ("%d <= %d"):format(when, test_time+1));
 						end
 						assert.equal(4, count);
 					end);
@@ -427,6 +560,30 @@
 
 					end);
 
+					-- This tests combines the reverse flag with 'before' and 'after' to
+					-- ensure behaviour remains correct
+					it("by id (before and after) in reverse #full_id_range", function ()
+						assert.truthy(archive.caps and archive.caps.full_id_range, "full ID range support")
+						local data, err = archive:find("user", {
+								["after"] = test_data[1][1];
+								["before"] = test_data[4][1];
+								reverse = true;
+							});
+						assert.truthy(data, err);
+						local count = 0;
+						for id, item in data do
+							count = count + 1;
+							assert.truthy(id);
+							assert.equal(test_data[4-count][1], id);
+							assert(st.is_stanza(item));
+							assert.equal("test", item.name);
+							assert.equal("urn:example:foo", item.attr.xmlns);
+							assert.equal(2, #item.tags);
+						end
+						assert.equal(2, count);
+					end);
+
+
 
 				end);
 
@@ -466,7 +623,7 @@
 					local data, err = archive:find("user", {
 						with = "contact@example.com";
 					});
-					assert.truthy(data);
+					assert.truthy(data, err);
 					local count = 0;
 					for id, item, when in data do -- luacheck: ignore id item when
 						count = count + 1;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/inputs/test_keys.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,179 @@
+local test_keys = {
+	-- ECDSA keypair from jwt.io
+	ecdsa_private_pem = [[
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgevZzL1gdAFr88hb2
+OF/2NxApJCzGCEDdfSp6VQO30hyhRANCAAQRWz+jn65BtOMvdyHKcvjBeBSDZH2r
+1RTwjmYSi9R/zpBnuQ4EiMnCqfMPWiZqB4QdbAd0E7oH50VpuZ1P087G
+-----END PRIVATE KEY-----
+]];
+
+	ecdsa_public_pem = [[
+-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEVs/o5+uQbTjL3chynL4wXgUg2R9
+q9UU8I5mEovUf86QZ7kOBIjJwqnzD1omageEHWwHdBO6B+dFabmdT9POxg==
+-----END PUBLIC KEY-----
+]];
+
+	-- Self-generated ECDSA keypair
+	alt_ecdsa_private_pem = [[
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgQnn4AHz2Zy+JMAgp
+AZfKAm9F3s6791PstPf5XjHtETKhRANCAAScv9jI3+BOXXlCOXwmQYosIbl9mf4V
+uOwfIoCYSLylAghyxO0n2of8Kji+D+4C1zxNKmZIQa4s8neaIIzXnMY1
+-----END PRIVATE KEY-----
+]];
+
+	alt_ecdsa_public_pem = [[
+-----BEGIN PUBLIC KEY-----
+MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEnL/YyN/gTl15Qjl8JkGKLCG5fZn+
+FbjsHyKAmEi8pQIIcsTtJ9qH/Co4vg/uAtc8TSpmSEGuLPJ3miCM15zGNQ==
+-----END PUBLIC KEY-----
+]];
+
+	-- JWT reference keys for ES512
+
+	ecdsa_521_public_pem = [[
+-----BEGIN PUBLIC KEY-----
+MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBgc4HZz+/fBbC7lmEww0AO3NK9wVZ
+PDZ0VEnsaUFLEYpTzb90nITtJUcPUbvOsdZIZ1Q8fnbquAYgxXL5UgHMoywAib47
+6MkyyYgPk0BXZq3mq4zImTRNuaU9slj9TVJ3ScT3L1bXwVuPJDzpr5GOFpaj+WwM
+Al8G7CqwoJOsW7Kddns=
+-----END PUBLIC KEY-----
+]];
+
+	ecdsa_521_private_pem = [[
+-----BEGIN PRIVATE KEY-----
+MIHuAgEAMBAGByqGSM49AgEGBSuBBAAjBIHWMIHTAgEBBEIBiyAa7aRHFDCh2qga
+9sTUGINE5jHAFnmM8xWeT/uni5I4tNqhV5Xx0pDrmCV9mbroFtfEa0XVfKuMAxxf
+Z6LM/yKhgYkDgYYABAGBzgdnP798FsLuWYTDDQA7c0r3BVk8NnRUSexpQUsRilPN
+v3SchO0lRw9Ru86x1khnVDx+duq4BiDFcvlSAcyjLACJvjvoyTLJiA+TQFdmrear
+jMiZNE25pT2yWP1NUndJxPcvVtfBW48kPOmvkY4WlqP5bAwCXwbsKrCgk6xbsp12
+ew==
+-----END PRIVATE KEY-----
+]];
+
+	-- Self-generated keys for ES512
+
+	alt_ecdsa_521_public_pem = [[
+-----BEGIN PUBLIC KEY-----
+MIGbMBAGByqGSM49AgEGBSuBBAAjA4GGAAQBIxV0ecG/+qFc/kVPKs8Z6tjJEuRe
+dzrEaqABY6THu7BhCjEoxPr6iRYdiFPzNruFORsCAKf/NFLSoCqyrw9S0YMA1xc+
+uW01145oxT7Sp8BOH1MyOh7xNh+LFLi6X4lV6j5GQrM1sKSa3O5m0+VJmLy5b7cy
+oxNCzXrnEByz+EO2nYI=
+-----END PUBLIC KEY-----
+]];
+
+	alt_ecdsa_521_private_pem = [[
+-----BEGIN EC PRIVATE KEY-----
+MIHcAgEBBEIAV2XJQ4/5Pa5m43/AJdL4XzrRV/l7eQ1JObqmI95YDs3zxM5Mfygz
+DivhvuPdZCZUR+TdZQEdYN4LpllCzrDwmTCgBwYFK4EEACOhgYkDgYYABAEjFXR5
+wb/6oVz+RU8qzxnq2MkS5F53OsRqoAFjpMe7sGEKMSjE+vqJFh2IU/M2u4U5GwIA
+p/80UtKgKrKvD1LRgwDXFz65bTXXjmjFPtKnwE4fUzI6HvE2H4sUuLpfiVXqPkZC
+szWwpJrc7mbT5UmYvLlvtzKjE0LNeucQHLP4Q7adgg==
+-----END EC PRIVATE KEY-----
+]];
+
+	-- Self-generated EdDSA (Ed25519) keypair
+	eddsa_private_pem = [[
+-----BEGIN PRIVATE KEY-----
+MC4CAQAwBQYDK2VwBCIEIOmrajEfnqdzdJzkJ4irQMCGbYRqrl0RlwPHIw+a5b7M
+-----END PRIVATE KEY-----
+]];
+
+	eddsa_public_pem = [[
+-----BEGIN PUBLIC KEY-----
+MCowBQYDK2VwAyEAFipbSXeGvPVK7eA4+hIOdutZTUUyXswVSbMGi0j1QKE=
+-----END PUBLIC KEY-----
+]];
+
+	-- RSA keypair from jwt.io
+	rsa_private_pem = [[
+-----BEGIN PRIVATE KEY-----
+MIIEvwIBADANBgkqhkiG9w0BAQEFAASCBKkwggSlAgEAAoIBAQC7VJTUt9Us8cKj
+MzEfYyjiWA4R4/M2bS1GB4t7NXp98C3SC6dVMvDuictGeurT8jNbvJZHtCSuYEvu
+NMoSfm76oqFvAp8Gy0iz5sxjZmSnXyCdPEovGhLa0VzMaQ8s+CLOyS56YyCFGeJZ
+qgtzJ6GR3eqoYSW9b9UMvkBpZODSctWSNGj3P7jRFDO5VoTwCQAWbFnOjDfH5Ulg
+p2PKSQnSJP3AJLQNFNe7br1XbrhV//eO+t51mIpGSDCUv3E0DDFcWDTH9cXDTTlR
+ZVEiR2BwpZOOkE/Z0/BVnhZYL71oZV34bKfWjQIt6V/isSMahdsAASACp4ZTGtwi
+VuNd9tybAgMBAAECggEBAKTmjaS6tkK8BlPXClTQ2vpz/N6uxDeS35mXpqasqskV
+laAidgg/sWqpjXDbXr93otIMLlWsM+X0CqMDgSXKejLS2jx4GDjI1ZTXg++0AMJ8
+sJ74pWzVDOfmCEQ/7wXs3+cbnXhKriO8Z036q92Qc1+N87SI38nkGa0ABH9CN83H
+mQqt4fB7UdHzuIRe/me2PGhIq5ZBzj6h3BpoPGzEP+x3l9YmK8t/1cN0pqI+dQwY
+dgfGjackLu/2qH80MCF7IyQaseZUOJyKrCLtSD/Iixv/hzDEUPfOCjFDgTpzf3cw
+ta8+oE4wHCo1iI1/4TlPkwmXx4qSXtmw4aQPz7IDQvECgYEA8KNThCO2gsC2I9PQ
+DM/8Cw0O983WCDY+oi+7JPiNAJwv5DYBqEZB1QYdj06YD16XlC/HAZMsMku1na2T
+N0driwenQQWzoev3g2S7gRDoS/FCJSI3jJ+kjgtaA7Qmzlgk1TxODN+G1H91HW7t
+0l7VnL27IWyYo2qRRK3jzxqUiPUCgYEAx0oQs2reBQGMVZnApD1jeq7n4MvNLcPv
+t8b/eU9iUv6Y4Mj0Suo/AU8lYZXm8ubbqAlwz2VSVunD2tOplHyMUrtCtObAfVDU
+AhCndKaA9gApgfb3xw1IKbuQ1u4IF1FJl3VtumfQn//LiH1B3rXhcdyo3/vIttEk
+48RakUKClU8CgYEAzV7W3COOlDDcQd935DdtKBFRAPRPAlspQUnzMi5eSHMD/ISL
+DY5IiQHbIH83D4bvXq0X7qQoSBSNP7Dvv3HYuqMhf0DaegrlBuJllFVVq9qPVRnK
+xt1Il2HgxOBvbhOT+9in1BzA+YJ99UzC85O0Qz06A+CmtHEy4aZ2kj5hHjECgYEA
+mNS4+A8Fkss8Js1RieK2LniBxMgmYml3pfVLKGnzmng7H2+cwPLhPIzIuwytXywh
+2bzbsYEfYx3EoEVgMEpPhoarQnYPukrJO4gwE2o5Te6T5mJSZGlQJQj9q4ZB2Dfz
+et6INsK0oG8XVGXSpQvQh3RUYekCZQkBBFcpqWpbIEsCgYAnM3DQf3FJoSnXaMhr
+VBIovic5l0xFkEHskAjFTevO86Fsz1C2aSeRKSqGFoOQ0tmJzBEs1R6KqnHInicD
+TQrKhArgLXX4v3CddjfTRJkFWDbE/CkvKZNOrcf1nhaGCPspRJj2KUkj1Fhl9Cnc
+dn/RsYEONbwQSjIfMPkvxF+8HQ==
+-----END PRIVATE KEY-----
+]];
+
+	rsa_public_pem = [[
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu1SU1LfVLPHCozMxH2Mo
+4lgOEePzNm0tRgeLezV6ffAt0gunVTLw7onLRnrq0/IzW7yWR7QkrmBL7jTKEn5u
++qKhbwKfBstIs+bMY2Zkp18gnTxKLxoS2tFczGkPLPgizskuemMghRniWaoLcyeh
+kd3qqGElvW/VDL5AaWTg0nLVkjRo9z+40RQzuVaE8AkAFmxZzow3x+VJYKdjykkJ
+0iT9wCS0DRTXu269V264Vf/3jvredZiKRkgwlL9xNAwxXFg0x/XFw005UWVRIkdg
+cKWTjpBP2dPwVZ4WWC+9aGVd+Gyn1o0CLelf4rEjGoXbAAEgAqeGUxrcIlbjXfbc
+mwIDAQAB
+-----END PUBLIC KEY-----
+]];
+
+
+	-- Self-generated RSA keypair
+	alt_rsa_private_pem = [[
+-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEA4bt6kor2TomqRXfjCFe6T42ibatloyHntZCUdlDDAkUh4oJ/
+4jDCXAUMYqmEsZKCPXxUGQgrmSmNnJPEDMTq3XLDsjhyN4stxEi0UVAiqqBkcEnk
+qbQIJSc9v5gpQF8IuJFWRvSNic0uClFL5W9R2s5AHcOhdFYKeDuitqHT5r+dC7cy
+WZs5YleKaESxmK6i6wMVhL9adAilTuETyMH0yLSh+aXsPYhjns4AbjGmiKOjqd5w
+sPwllEg6rGcIUi/o79z9HN8yLMXq3XNFCCA8RI4Zh3cADI1I5fe6wk1ETN+30cDw
+dGQ+uQbaQrzqmKVRNjZcorMwBjsOX5AMQBFx7wIDAQABAoIBAGxj5pZpTZ4msnEL
+ASQnY9oBS4ZXr8UmaamgU/mADDOR2JR4T0ngWeNvtSPG/GV70TgO9B7U8oJoFoyh
+05jCEXjmO5vfSNDs7rv6oUMONKczvybABKGMRgD5F8hhGyXCvGBLwV7u3OvXbw0b
+PlNcIbTsJpNkNam0CvDyyc3iZOq+HjIqituREV7lDw0rFeAR2YfEWn4VjZsQRZUZ
+XkpQJ5silrXgGemIEGqVA4YyM7i2HmTiLozfVYaVckMc02VFgOaoK9Z/wGlBxtS5
+evc/IGErSA4dc7uXBEeVjhtZoBkof2JV9BNt4hl4KN9wX3tkEX5Aq1K2lirSmg2r
+k+UEtwkCgYEA/5uYg25OR+jCFY/7uNS8e32Re1lgDeO+TeT1m+hcF1gCb2GBLifL
+yprnuytaz1/mPqawfwbilaxntLBoa5cmNKB3zDsgv4sM451yGZ0oxU0dXpDVHblu
+3nhxcaOXtb8jiSsr2MqgMbFlu7m8OupIliS+s8Pq72s6HUQQRKbJ+9MCgYEA4hQl
+1W/7nDI2SR4Q3UapQnaUjmDVxX5OD+E4RpKuRF6xF7Ao2CLZusMVo8WN8YiSQP2c
+RnzQNKgAVy/1zlhaaQDTs2TmSy9iStbuNZ8P+Gh6kmQXuHxwPyURSmwdpgZdL3+D
+8tt6pQNQ0vsLjA9VwHmzIT+rsxPmTxKNvBdNK/UCgYByP6zqyioJMDtYAfRkiAn7
+NIQLW0Z4ztvn2zgAyNoowPjNqgpgg/8t/xEm8tjzKg0y4bSwAnbSqa3s8JCrznKQ
+QU1qpt8bXl6TenNeiYWIstA2zYvEbnbkz3b9cT7FSLrse7RsgR0bOQyc3QcKWl+5
+ZJEsrpxbCVV/cUXIObi8awKBgQDOI8rfk+0bXhlrkBOWf/CjnpYUQK2LF4C8MALt
+Lp/hzWmyjLihYx2eknUv0Fl966ZXxidxiisaaDlvRlbeIGfHqK5fu9fUpE7+qH2p
+vPCF81YYF1YdrLF4kiby8iQSl2juf1nj3kY1IhHXXnsH6Y+qIg24emLntXRhkyxT
+XffK5QKBgGbzEvVgDkerw1SiefAaZnLumJJXBlKjJ00Sq8YLeViyFC/sr4EfG/cV
+7VYRhBw3e7RcYSBAA7uv8i3iIeCFjFooIZUARqXk4+yW753tY5nSJTWfkR7Bp5Pa
+9jKloxckbZKMjH23a+ABOxomY3l93KOBvjLvMYqccuREOwaT12cn
+-----END RSA PRIVATE KEY-----
+]];
+
+	alt_rsa_public_pem = [[
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4bt6kor2TomqRXfjCFe6
+T42ibatloyHntZCUdlDDAkUh4oJ/4jDCXAUMYqmEsZKCPXxUGQgrmSmNnJPEDMTq
+3XLDsjhyN4stxEi0UVAiqqBkcEnkqbQIJSc9v5gpQF8IuJFWRvSNic0uClFL5W9R
+2s5AHcOhdFYKeDuitqHT5r+dC7cyWZs5YleKaESxmK6i6wMVhL9adAilTuETyMH0
+yLSh+aXsPYhjns4AbjGmiKOjqd5wsPwllEg6rGcIUi/o79z9HN8yLMXq3XNFCCA8
+RI4Zh3cADI1I5fe6wk1ETN+30cDwdGQ+uQbaQrzqmKVRNjZcorMwBjsOX5AMQBFx
+7wIDAQAB
+-----END PUBLIC KEY-----
+]];
+};
+
+return test_keys;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/net_resolvers_service_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,241 @@
+local set = require "util.set";
+
+insulate("net.resolvers.service", function ()
+	local adns = {
+		resolver = function ()
+			return {
+				lookup = function (_, cb, qname, qtype, qclass)
+					if qname == "_xmpp-server._tcp.example.com"
+					   and (qtype or "SRV") == "SRV"
+					   and (qclass or "IN") == "IN" then
+						cb({
+							{ -- 60+35+60
+								srv = { target = "xmpp0-a.example.com", port = 5228, priority = 0, weight = 60 };
+							};
+							{
+								srv = { target = "xmpp0-b.example.com", port = 5216, priority = 0, weight = 35 };
+							};
+							{
+								srv = { target = "xmpp0-c.example.com", port = 5200, priority = 0, weight = 0 };
+							};
+							{
+								srv = { target = "xmpp0-d.example.com", port = 5256, priority = 0, weight = 120 };
+							};
+
+							{
+								srv = { target = "xmpp1-a.example.com", port = 5273, priority = 1, weight = 30 };
+							};
+							{
+								srv = { target = "xmpp1-b.example.com", port = 5274, priority = 1, weight = 30 };
+							};
+
+							{
+								srv = { target = "xmpp2.example.com", port = 5275, priority = 2, weight = 0 };
+							};
+						});
+					elseif qname == "_xmpp-server._tcp.single.example.com"
+					   and (qtype or "SRV") == "SRV"
+					   and (qclass or "IN") == "IN" then
+						cb({
+							{
+								srv = { target = "xmpp0-a.example.com", port = 5269, priority = 0, weight = 0 };
+							};
+						});
+					elseif qname == "_xmpp-server._tcp.half.example.com"
+					   and (qtype or "SRV") == "SRV"
+					   and (qclass or "IN") == "IN" then
+						cb({
+							{
+								srv = { target = "xmpp0-a.example.com", port = 5269, priority = 0, weight = 0 };
+							};
+							{
+								srv = { target = "xmpp0-b.example.com", port = 5270, priority = 0, weight = 1 };
+							};
+						});
+					elseif qtype == "A" then
+						local l = qname:match("%-(%a)%.example.com$") or "1";
+						local d = ("%d"):format(l:byte())
+						cb({
+							{
+								a = "127.0.0."..d;
+							};
+						});
+					elseif qtype == "AAAA" then
+						local l = qname:match("%-(%a)%.example.com$") or "1";
+						local d = ("%04d"):format(l:byte())
+						cb({
+							{
+								aaaa = "fdeb:9619:649e:c7d9::"..d;
+							};
+						});
+					else
+						cb(nil);
+					end
+				end;
+			};
+		end;
+	};
+	package.loaded["net.adns"] = mock(adns);
+	local resolver = require "net.resolvers.service";
+	math.randomseed(os.time());
+	it("works for 99% of deployments", function ()
+		-- Most deployments only have a single SRV record, let's make
+		-- sure that works okay
+
+		local expected_targets = set.new({
+			-- xmpp0-a
+			"tcp4  127.0.0.97  5269";
+			"tcp6  fdeb:9619:649e:c7d9::0097  5269";
+		});
+		local received_targets = set.new({});
+
+		local r = resolver.new("single.example.com", "xmpp-server");
+		local done = false;
+		local function handle_target(...)
+			if ... == nil then
+				done = true;
+				-- No more targets
+				return;
+			end
+			received_targets:add(table.concat({ ... }, "  ", 1, 3));
+		end
+		r:next(handle_target);
+		while not done do
+			r:next(handle_target);
+		end
+
+		-- We should have received all expected targets, and no unexpected
+		-- ones:
+		assert.truthy(set.xor(received_targets, expected_targets):empty());
+	end);
+
+	it("supports A/AAAA fallback", function ()
+		-- Many deployments don't have any SRV records, so we should
+		-- fall back to A/AAAA records instead when that is the case
+
+		local expected_targets = set.new({
+			-- xmpp0-a
+			"tcp4  127.0.0.97  5269";
+			"tcp6  fdeb:9619:649e:c7d9::0097  5269";
+		});
+		local received_targets = set.new({});
+
+		local r = resolver.new("xmpp0-a.example.com", "xmpp-server", "tcp", { default_port = 5269 });
+		local done = false;
+		local function handle_target(...)
+			if ... == nil then
+				done = true;
+				-- No more targets
+				return;
+			end
+			received_targets:add(table.concat({ ... }, "  ", 1, 3));
+		end
+		r:next(handle_target);
+		while not done do
+			r:next(handle_target);
+		end
+
+		-- We should have received all expected targets, and no unexpected
+		-- ones:
+		assert.truthy(set.xor(received_targets, expected_targets):empty());
+	end);
+
+
+	it("works", function ()
+		local expected_targets = set.new({
+			-- xmpp0-a
+			"tcp4  127.0.0.97  5228";
+			"tcp6  fdeb:9619:649e:c7d9::0097  5228";
+			"tcp4  127.0.0.97  5273";
+			"tcp6  fdeb:9619:649e:c7d9::0097  5273";
+
+			-- xmpp0-b
+			"tcp4  127.0.0.98  5274";
+			"tcp6  fdeb:9619:649e:c7d9::0098  5274";
+			"tcp4  127.0.0.98  5216";
+			"tcp6  fdeb:9619:649e:c7d9::0098  5216";
+
+			-- xmpp0-c
+			"tcp4  127.0.0.99  5200";
+			"tcp6  fdeb:9619:649e:c7d9::0099  5200";
+
+			-- xmpp0-d
+			"tcp4  127.0.0.100  5256";
+			"tcp6  fdeb:9619:649e:c7d9::0100  5256";
+
+			-- xmpp2
+			"tcp4  127.0.0.49  5275";
+			"tcp6  fdeb:9619:649e:c7d9::0049  5275";
+
+		});
+		local received_targets = set.new({});
+
+		local r = resolver.new("example.com", "xmpp-server");
+		local done = false;
+		local function handle_target(...)
+			if ... == nil then
+				done = true;
+				-- No more targets
+				return;
+			end
+			received_targets:add(table.concat({ ... }, "  ", 1, 3));
+		end
+		r:next(handle_target);
+		while not done do
+			r:next(handle_target);
+		end
+
+		-- We should have received all expected targets, and no unexpected
+		-- ones:
+		assert.truthy(set.xor(received_targets, expected_targets):empty());
+	end);
+
+	it("balances across weights correctly #slow", function ()
+		-- This mimics many repeated connections to 'example.com' (mock
+		-- records defined above), and records the port number of the
+		-- first target. Therefore it (should) only return priority
+		-- 0 records, and the input data is constructed such that the
+		-- last two digits of the port number represent the percentage
+		-- of times that record should (on average) be picked first.
+
+		-- To prevent random test failures, we test across a handful
+		-- of fixed (randomly selected) seeds.
+		for _, seed in ipairs({ 8401877, 3943829, 7830992 }) do
+			math.randomseed(seed);
+
+			local results = {};
+			local function run()
+				local run_results = {};
+				local r = resolver.new("example.com", "xmpp-server");
+				local function record_target(...)
+					if ... == nil then
+						-- No more targets
+						return;
+					end
+					run_results = { ... };
+				end
+				r:next(record_target);
+				return run_results[3];
+			end
+
+			for _ = 1, 1000 do
+				local port = run();
+				results[port] = (results[port] or 0) + 1;
+			end
+
+			local ports = {};
+			for port in pairs(results) do
+				table.insert(ports, port);
+			end
+			table.sort(ports);
+			for _, port in ipairs(ports) do
+				--print("PORT", port, tostring((results[port]/1000) * 100).."% hits (expected "..tostring(port-5200).."%)");
+				local hit_pct = (results[port]/1000) * 100;
+				local expected_pct = port - 5200;
+				--print(hit_pct, expected_pct, math.abs(hit_pct - expected_pct));
+				assert.is_true(math.abs(hit_pct - expected_pct) < 5);
+			end
+			--print("---");
+		end
+	end);
+end);
--- a/spec/scansion/extdisco.scs	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/extdisco.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -17,8 +17,8 @@
 	<iq type='result' id='lx2' from='localhost'>
 		<services xmlns='urn:xmpp:extdisco:2'>
 			<service host='default.example' transport='udp' port='9876' type='stun'/>
-			<service port='9876' type='turn' restricted='1' password='yHYYBDN7M3mdlug0LTdJbW0GvvQ=' transport='udp' host='default.example' username='1219525744'/>
-			<service port='9876' type='turn' restricted='1' password='1Uc6QfrDhIlbK97rGCUQ/cUICxs=' transport='udp' host='default.example' username='1219525744'/>
+			<service port='9876' type='turn' restricted='1' password='{scansion:any}' transport='udp' host='default.example' username='{scansion:any}'/>
+			<service port='9876' type='turn' restricted='1' password='{scansion:any}' transport='udp' host='default.example' username='{scansion:any}'/>
 			<service port='2121' type='ftp' restricted='1' password='password' transport='tcp' host='default.example' username='john'/>
 			<service port='21' type='ftp' restricted='1' password='password' transport='tcp' host='ftp.example.com' username='john'/>
 		</services>
@@ -47,8 +47,8 @@
 Romeo receives:
 	<iq type='result' id='lx4' from='localhost'>
 		<credentials xmlns='urn:xmpp:extdisco:2'>
-			<service port='9876' type='turn' restricted='1' password='yHYYBDN7M3mdlug0LTdJbW0GvvQ=' transport='udp' host='default.example' username='1219525744'/>
-			<service port='9876' type='turn' restricted='1' password='1Uc6QfrDhIlbK97rGCUQ/cUICxs=' transport='udp' host='default.example' username='1219525744'/>
+			<service port='9876' type='turn' restricted='1' password='{scansion:any}' transport='udp' host='default.example' username='{scansion:any}'/>
+			<service port='9876' type='turn' restricted='1' password='{scansion:any}' transport='udp' host='default.example' username='{scansion:any}'/>
 		</credentials>
 	</iq>
 
--- a/spec/scansion/lastactivity.scs	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/lastactivity.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -37,7 +37,7 @@
 
 Romeo receives:
 	<iq type='result' id='a'>
-		<query xmlns='jabber:iq:last' seconds='0'>Goodbye</query>
+		<query xmlns='jabber:iq:last' seconds='{scansion:any}'>Goodbye</query>
 	</iq>
 
 Romeo disconnects
--- a/spec/scansion/mam_extended.scs	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/mam_extended.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -45,8 +45,8 @@
 Romeo receives:
 	<iq type="result" id="mamextmeta">
 		<metadata xmlns="urn:xmpp:mam:2">
-			<start timestamp="2008-08-22T21:09:04Z" xmlns="urn:xmpp:mam:2" id="{scansion:any}"/>
-			<end timestamp="2008-08-22T21:09:04Z" xmlns="urn:xmpp:mam:2" id="{scansion:any}"/>
+			<start timestamp="{scansion:capture:start}" xmlns="urn:xmpp:mam:2" id="{scansion:capture:first}"/>
+			<end timestamp="{scansion:capture:end}" xmlns="urn:xmpp:mam:2" id="{scansion:capture:last}"/>
 		</metadata>
 	</iq>
 
@@ -57,9 +57,9 @@
 
 Romeo receives:
 	<message to="${Romeo's full JID}">
-		<result xmlns="urn:xmpp:mam:2" queryid="q1" id="{scansion:any}">
+		<result xmlns="urn:xmpp:mam:2" queryid="q1" id="{scansion:capture:first}">
 			<forwarded xmlns="urn:xmpp:forward:0">
-				<delay stamp="2008-08-22T21:09:04Z" xmlns="urn:xmpp:delay"/>
+				<delay stamp="{scansion:capture:start}" xmlns="urn:xmpp:delay"/>
 				<message to="someone@localhost" xmlns="jabber:client" type="chat" xml:lang="en" id="chat01" from="${Romeo's full JID}">
 					<body>Hello</body>
 				</message>
@@ -69,9 +69,9 @@
 
 Romeo receives:
 	<message to="${Romeo's full JID}">
-		<result xmlns="urn:xmpp:mam:2" queryid="q1" id="{scansion:any}">
+		<result xmlns="urn:xmpp:mam:2" queryid="q1" id="{scansion:capture:last}">
 			<forwarded xmlns="urn:xmpp:forward:0">
-				<delay stamp="2008-08-22T21:09:04Z" xmlns="urn:xmpp:delay"/>
+				<delay stamp="{scansion:capture:end}" xmlns="urn:xmpp:delay"/>
 				<message to="someone@localhost" xmlns="jabber:client" type="chat" xml:lang="en" id="chat02" from="${Romeo's full JID}">
 					<body>U there?</body>
 				</message>
@@ -98,7 +98,7 @@
 	<message to="${Romeo's full JID}">
 		<result xmlns="urn:xmpp:mam:2" queryid="q1" id="{scansion:any}">
 			<forwarded xmlns="urn:xmpp:forward:0">
-				<delay stamp="2008-08-22T21:09:04Z" xmlns="urn:xmpp:delay"/>
+				<delay stamp="{scansion:capture:start}" xmlns="urn:xmpp:delay"/>
 				<message to="someone@localhost" xmlns="jabber:client" type="chat" xml:lang="en" id="chat02" from="${Romeo's full JID}">
 					<body>U there?</body>
 				</message>
@@ -110,7 +110,7 @@
 	<message to="${Romeo's full JID}">
 		<result xmlns="urn:xmpp:mam:2" queryid="q1" id="{scansion:any}">
 			<forwarded xmlns="urn:xmpp:forward:0">
-				<delay stamp="2008-08-22T21:09:04Z" xmlns="urn:xmpp:delay"/>
+				<delay stamp="{scansion:capture:end}" xmlns="urn:xmpp:delay"/>
 				<message to="someone@localhost" xmlns="jabber:client" type="chat" xml:lang="en" id="chat01" from="${Romeo's full JID}">
 					<body>Hello</body>
 				</message>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_outcast_reason.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,72 @@
+# Save ban reason
+
+[Client] Romeo
+	password: password
+	jid: user@localhost
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="muc-outcast-reason@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from="muc-outcast-reason@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<status code="201"/>
+			<item jid="${Romeo's full JID}" role="moderator" affiliation="owner"/>
+			<status code="110"/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type="groupchat" from="muc-outcast-reason@conference.localhost">
+		<subject/>
+	</message>
+
+Romeo sends:
+	<iq id="lx5" to="muc-outcast-reason@conference.localhost" type="set">
+		<query xmlns="http://jabber.org/protocol/muc#admin">
+			<item affiliation="outcast" jid="tybalt@localhost">
+				<reason>Hey calm down</reason>
+			</item>
+		</query>
+	</iq>
+
+Romeo receives:
+	<message from="muc-outcast-reason@conference.localhost">
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<status code="301"/>
+			<item jid="tybalt@localhost" affiliation="outcast">
+				<reason>Hey calm down</reason>
+			</item>
+		</x>
+	</message>
+
+Romeo receives:
+	<iq id="lx5" type="result" from="muc-outcast-reason@conference.localhost"/>
+
+Romeo sends:
+	<iq id="lx6" to="muc-outcast-reason@conference.localhost" type="get">
+		<query xmlns="http://jabber.org/protocol/muc#admin">
+			<item affiliation="outcast"/>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="lx6" type="result" from="muc-outcast-reason@conference.localhost">
+		<query xmlns="http://jabber.org/protocol/muc#admin">
+			<item jid="tybalt@localhost" affiliation="outcast">
+				<reason>Hey calm down</reason>
+			</item>
+		</query>
+	</iq>
+
+Romeo disconnects
+
+Romeo sends:
+	<presence type='unavailable'/>
+
--- a/spec/scansion/muc_subject_issue_667.scs	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/muc_subject_issue_667.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -42,6 +42,21 @@
 		<body>Hello everyone</body>
 	</message>
 
+# this should be treated as a normal message
+Romeo sends:
+	<message to="issue667@conference.localhost" type="groupchat">
+		<subject>New thread</subject>
+		<thread>498acea5-5894-473f-b4c6-c77319d11c75</thread>
+		<store xmlns="urn:xmpp:hints"/>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" from="issue667@conference.localhost/Romeo">
+		<subject>New thread</subject>
+		<thread>498acea5-5894-473f-b4c6-c77319d11c75</thread>
+		<store xmlns="urn:xmpp:hints"/>
+	</message>
+
 # Resync
 Romeo sends:
 	<presence to="issue667@conference.localhost/Romeo">
@@ -63,6 +78,13 @@
 		<body>Hello everyone</body>
 	</message>
 
+Romeo receives:
+	<message type="groupchat" from="issue667@conference.localhost/Romeo">
+		<subject>New thread</subject>
+		<thread>498acea5-5894-473f-b4c6-c77319d11c75</thread>
+		<store xmlns="urn:xmpp:hints"/>
+	</message>
+
 # the still empty subject
 Romeo receives:
 	<message type="groupchat" from="issue667@conference.localhost">
@@ -116,6 +138,13 @@
 
 Romeo receives:
 	<message type="groupchat" from="issue667@conference.localhost/Romeo">
+		<subject>New thread</subject>
+		<thread>498acea5-5894-473f-b4c6-c77319d11c75</thread>
+		<store xmlns="urn:xmpp:hints"/>
+	</message>
+
+Romeo receives:
+	<message type="groupchat" from="issue667@conference.localhost/Romeo">
 		<body>Lorem ipsum dolor sit amet</body>
 	</message>
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pep_itemreply.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,205 @@
+# PEP itemreply (publisher) configuration
+# This tests that itemreply == "publisher" will add the 'publisher' attribute
+# to notifications. Since this is not the default behaviour, the normal
+# publish and subscribe test cases cover testing that it is not included
+# otherwise.
+
+[Client] Romeo
+	jid: pep-test-df6zdvkv@localhost
+	password: password
+
+[Client] Juliet
+	jid: pep-test-5k90xvps@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence>
+		<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/>
+	</presence>
+
+Romeo receives:
+	<iq type='get' id='disco' from="${Romeo's JID}">
+		<query node='http://code.matthewwild.co.uk/verse/#PDH7CGVPRERS2WUqBD18PHGEzaY=' xmlns='http://jabber.org/protocol/disco#info'/>
+	</iq>
+
+Romeo receives:
+	<presence from="${Romeo's full JID}">
+		<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/>
+	</presence>
+
+Romeo sends:
+	<iq type='get' id='6'>
+		<query ver='' xmlns='jabber:iq:roster'/>
+	</iq>
+
+Romeo receives:
+	<iq type='result' id='6'>
+		<query ver='1' xmlns='jabber:iq:roster'/>
+	</iq>
+
+Juliet connects
+
+Juliet sends:
+	<presence>
+		<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/>
+	</presence>
+
+Juliet receives:
+	<iq type='get' id='disco' from="${Juliet's JID}">
+		<query node='http://code.matthewwild.co.uk/verse/#PDH7CGVPRERS2WUqBD18PHGEzaY=' xmlns='http://jabber.org/protocol/disco#info'/>
+	</iq>
+
+Juliet receives:
+	<presence from="${Juliet's full JID}">
+		<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/>
+	</presence>
+
+Juliet sends:
+	<iq type='get' id='6'>
+		<query ver='' xmlns='jabber:iq:roster'/>
+	</iq>
+
+Juliet receives:
+	<iq type='result' id='6'>
+		<query ver='1' xmlns='jabber:iq:roster'/>
+	</iq>
+
+Romeo sends:
+	<iq type='result' id='disco' to='pep-test-df6zdvkv@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#PDH7CGVPRERS2WUqBD18PHGEzaY='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/></query></iq>
+
+Romeo sends:
+	<presence type='subscribe' to="${Juliet's JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='set' id='{scansion:any}'><query ver='1' xmlns='jabber:iq:roster'><item ask='subscribe' jid='pep-test-5k90xvps@localhost' subscription='none'/></query></iq>
+
+Romeo receives:
+	<presence type='unavailable' to='pep-test-df6zdvkv@localhost' from='pep-test-5k90xvps@localhost'/>
+
+Juliet receives:
+	<presence type='subscribe' from='pep-test-df6zdvkv@localhost' to='pep-test-5k90xvps@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet sends:
+	<iq type='result' id='disco' to='pep-test-5k90xvps@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#PDH7CGVPRERS2WUqBD18PHGEzaY='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/></query></iq>
+
+Juliet sends:
+	<presence type='subscribe' to="${Romeo's JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet receives:
+	<iq type='set' id='{scansion:any}'><query ver='2' xmlns='jabber:iq:roster'><item ask='subscribe' jid='pep-test-df6zdvkv@localhost' subscription='none'/></query></iq>
+
+Juliet receives:
+	<presence type='unavailable' to='pep-test-5k90xvps@localhost' from='pep-test-df6zdvkv@localhost'/>
+
+Romeo receives:
+	<presence type='subscribe' from='pep-test-5k90xvps@localhost' to='pep-test-df6zdvkv@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo sends:
+	<iq type='result' id='fixme'/>
+
+Romeo sends:
+	<presence type='subscribed' to='pep-test-5k90xvps@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='set' id='{scansion:any}'><query ver='3' xmlns='jabber:iq:roster'><item ask='subscribe' jid='pep-test-5k90xvps@localhost' subscription='from'/></query></iq>
+
+Juliet receives:
+	<presence type='subscribed' from='pep-test-df6zdvkv@localhost' to='pep-test-5k90xvps@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet receives:
+	<iq type='set' id='{scansion:any}'><query ver='3' xmlns='jabber:iq:roster'><item jid='pep-test-df6zdvkv@localhost' subscription='to'/></query></iq>
+
+Juliet receives:
+	<presence to='pep-test-5k90xvps@localhost' from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/><delay xmlns='urn:xmpp:delay' stamp='{scansion:any}' from='localhost'/></presence>
+
+Juliet sends:
+	<presence type='subscribed' to='pep-test-df6zdvkv@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet receives:
+	<iq type='set' id='{scansion:any}'><query ver='4' xmlns='jabber:iq:roster'><item jid='pep-test-df6zdvkv@localhost' subscription='both'/></query></iq>
+
+Juliet receives:
+	<presence to='pep-test-5k90xvps@localhost' from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/><delay xmlns='urn:xmpp:delay' stamp='{scansion:any}' from='localhost'/></presence>
+
+Romeo receives:
+	<presence type='subscribed' from='pep-test-5k90xvps@localhost' to='pep-test-df6zdvkv@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='set' id='{scansion:any}'><query ver='4' xmlns='jabber:iq:roster'><item jid='pep-test-5k90xvps@localhost' subscription='both'/></query></iq>
+
+Romeo receives:
+	<presence to='pep-test-df6zdvkv@localhost' from="${Juliet's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/><delay xmlns='urn:xmpp:delay' stamp='{scansion:any}' from='localhost'/></presence>
+
+Juliet sends:
+	<iq type='result' id='fixme'/>
+
+Romeo sends:
+	<iq type='result' id='fixme'/>
+
+Romeo sends:
+	<iq type='result' id='fixme'/>
+
+Romeo sends:
+	<presence><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='m/sIsyfzKk8X1okZMtStR43nQQg=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='get' id='disco' from='pep-test-df6zdvkv@localhost'><query node='http://code.matthewwild.co.uk/verse/#m/sIsyfzKk8X1okZMtStR43nQQg=' xmlns='http://jabber.org/protocol/disco#info'/></iq>
+
+Romeo receives:
+	<presence from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='m/sIsyfzKk8X1okZMtStR43nQQg=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='get' id='disco' from='pep-test-5k90xvps@localhost'><query node='http://code.matthewwild.co.uk/verse/#m/sIsyfzKk8X1okZMtStR43nQQg=' xmlns='http://jabber.org/protocol/disco#info'/></iq>
+
+Juliet receives:
+	<presence from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='m/sIsyfzKk8X1okZMtStR43nQQg=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo sends:
+	<presence><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='IfQwbaaDB4LEP5tkGArEaB/3Y+s=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='get' id='disco' from='pep-test-df6zdvkv@localhost'><query node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s=' xmlns='http://jabber.org/protocol/disco#info'/></iq>
+
+Romeo receives:
+	<presence from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='IfQwbaaDB4LEP5tkGArEaB/3Y+s=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='get' id='disco' from='pep-test-5k90xvps@localhost'><query node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s=' xmlns='http://jabber.org/protocol/disco#info'/></iq>
+
+Romeo sends:
+	<iq type='result' id='disco' to='pep-test-df6zdvkv@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#m/sIsyfzKk8X1okZMtStR43nQQg='/></iq>
+
+Romeo sends:
+	<iq type='result' id='disco' to='pep-test-5k90xvps@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#m/sIsyfzKk8X1okZMtStR43nQQg='/></iq>
+
+Romeo sends:
+	<iq type='result' id='disco' to='pep-test-df6zdvkv@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/tune+notify'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/><feature var='http://jabber.org/protocol/mood+notify'/></query></iq>
+
+Juliet receives:
+	<presence from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='IfQwbaaDB4LEP5tkGArEaB/3Y+s=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet sends:
+	<iq type='result' id='fixme'/>
+
+Juliet sends:
+	<iq type='set' id='7'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/tune'><item id='current' publisher="${Juliet's JID}"><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></publish><publish-options><x type='submit' xmlns='jabber:x:data'><field type='hidden' var='FORM_TYPE'><value>http://jabber.org/protocol/pubsub#publish-options</value></field><field var='pubsub#persist_items'><value>true</value></field><field var='pubsub#itemreply'><value>publisher</value></field></x></publish-options></pubsub></iq>
+
+Juliet receives:
+	<iq type='result' id='7' ><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/tune'><item id='current'/></publish></pubsub></iq>
+
+Romeo receives:
+	<message type='headline' from='pep-test-5k90xvps@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current' publisher="${Juliet's JID}"><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
+
+Romeo sends:
+	<iq type='result' id='disco' to='pep-test-5k90xvps@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/tune+notify'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/><feature var='http://jabber.org/protocol/mood+notify'/></query></iq>
+
+Romeo receives:
+	<message type='headline' from='pep-test-5k90xvps@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current' publisher="${Juliet's JID}"><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
+
+Juliet disconnects
+
+Romeo disconnects
--- a/spec/scansion/pep_nickname.scs	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/pep_nickname.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -58,7 +58,7 @@
 	<message type="headline" from="romeo@localhost">
 	  <event xmlns="http://jabber.org/protocol/pubsub#event">
 	    <items node="http://jabber.org/protocol/nick">
-	      <item id="current" publisher="${Romeo's JID}">
+	      <item id="current">
 	        <nickname xmlns="http://jabber.org/protocol/nick"/>
 	      </item>
 	    </items>
--- a/spec/scansion/pep_publish_subscribe.scs	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/pep_publish_subscribe.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -182,7 +182,7 @@
 	<iq type='result' id='fixme'/>
 
 Juliet sends:
-	<iq type='set' id='7'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/tune'><item id='current' publisher="${Juliet's JID}"><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></publish></pubsub></iq>
+	<iq type='set' id='7'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/tune'><item id='current'><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></publish></pubsub></iq>
 
 Juliet receives:
 	<iq type='result' id='7' ><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/tune'><item id='current'/></publish></pubsub></iq>
@@ -197,13 +197,13 @@
 	<iq type='result' id='{scansion:any}'/>
 
 Romeo receives:
-	<message type='headline' from='pep-test-tqvqu_pv@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current' publisher="${Juliet's JID}"><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
+	<message type='headline' from='pep-test-tqvqu_pv@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current'><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
 
 Romeo sends:
 	<iq type='result' id='disco' to='pep-test-tqvqu_pv@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/tune+notify'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/><feature var='http://jabber.org/protocol/mood+notify'/></query></iq>
 
 Romeo receives:
-	<message type='headline' from='pep-test-tqvqu_pv@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current' publisher="${Juliet's JID}"><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
+	<message type='headline' from='pep-test-tqvqu_pv@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current'><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
 
 Juliet disconnects
 
--- a/spec/scansion/prosody.cfg.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/prosody.cfg.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,20 +1,9 @@
 --luacheck: ignore
 
--- Mock time functions to simplify tests
-function _G.os.time()
-	return 1219439344;
-end
-package.preload["util.time"] = function ()
-	return {
-		now = function () return 1219439344.1; end;
-		monotonic = function () return 0.1; end;
-	}
-end
-
 admins = { "admin@localhost" }
 
 network_backend = ENV_PROSODY_NETWORK_BACKEND or "epoll"
-network_settings = require"util.json".decode(ENV_PROSODY_NETWORK_SETTINGS or "{}")
+network_settings = Lua.require"prosody.util.json".decode(ENV_PROSODY_NETWORK_SETTINGS or "{}")
 
 modules_enabled = {
 	-- Generally required
@@ -66,6 +55,9 @@
 		"tombstones";
 		"user_account_management";
 
+	-- Required for integration testing
+		"debug_reset";
+
 	-- Useful for testing
 		--"scansion_record"; -- Records things that happen in scansion test case format
 }
--- a/spec/scansion/pubsub_config.scs	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/pubsub_config.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -119,6 +119,18 @@
 					<field var="pubsub#notify_retract" label="Whether to notify subscribers when items are removed from the node" type="boolean">
 						<value>1</value>
 					</field>
+					<field label="Specify whose JID to include as the publisher of items" var="pubsub#itemreply" type="list-single">
+						<option label="Include the node owner's JID">
+							<value>owner</value>
+						</option>
+						<option label="Include the item publisher's JID">
+							<value>publisher</value>
+						</option>
+						<option label="Don't include any JID with items">
+							<value>none</value>
+						</option>
+						<value>none</value>
+					</field>
 				</x>
 			</configure>
 		</pubsub>
--- a/spec/scansion/pubsub_max_items.scs	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/pubsub_max_items.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -114,6 +114,18 @@
 		<field var="pubsub#notify_retract" label="Whether to notify subscribers when items are removed from the node" type="boolean">
 		  <value>1</value>
 		</field>
+		<field label="Specify whose JID to include as the publisher of items" var="pubsub#itemreply" type="list-single">
+			<option label="Include the node owner's JID">
+				<value>owner</value>
+			</option>
+			<option label="Include the item publisher's JID">
+				<value>publisher</value>
+			</option>
+			<option label="Don't include any JID with items">
+				<value>none</value>
+			</option>
+			<value>none</value>
+		</field>
 	      </x>
 	    </configure>
 	  </pubsub>
--- a/spec/scansion/pubsub_multi_items.scs	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/pubsub_multi_items.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -114,6 +114,18 @@
 		<field var="pubsub#notify_retract" label="Whether to notify subscribers when items are removed from the node" type="boolean">
 		  <value>1</value>
 		</field>
+		<field label="Specify whose JID to include as the publisher of items" var="pubsub#itemreply" type="list-single">
+			<option label="Include the node owner's JID">
+				<value>owner</value>
+			</option>
+			<option label="Include the item publisher's JID">
+				<value>publisher</value>
+			</option>
+			<option label="Don't include any JID with items">
+				<value>none</value>
+			</option>
+			<value>none</value>
+		</field>
 	      </x>
 	    </configure>
 	  </pubsub>
--- a/spec/scansion/pubsub_preconditions.scs	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/pubsub_preconditions.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -118,6 +118,18 @@
 					<field var="pubsub#notify_retract" label="Whether to notify subscribers when items are removed from the node" type="boolean">
 						<value>1</value>
 					</field>
+					<field label="Specify whose JID to include as the publisher of items" var="pubsub#itemreply" type="list-single">
+						<option label="Include the node owner's JID">
+							<value>owner</value>
+						</option>
+						<option label="Include the item publisher's JID">
+							<value>publisher</value>
+						</option>
+						<option label="Don't include any JID with items">
+							<value>none</value>
+						</option>
+						<value>none</value>
+					</field>
 				</x>
 			</configure>
 		</pubsub>
@@ -199,6 +211,9 @@
 					<field var="pubsub#notify_retract" type="boolean" label="Whether to notify subscribers when items are removed from the node">
 						<value>1</value>
 					</field>
+					<field var="pubsub#itemreply" type="boolean">
+						<value>none</value>
+					</field>
 				</x>
 			</configure>
 		</pubsub>
--- a/spec/scansion/uptime.scs	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/uptime.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -15,7 +15,7 @@
 
 Romeo receives:
 	<iq type='result' id='a' from='localhost'>
-		<query xmlns='jabber:iq:last' seconds='0'/>
+		<query xmlns='jabber:iq:last' seconds='{scansion:any}'/>
 	</iq>
 
 Romeo disconnects
--- a/spec/scansion/vcard_temp.scs	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/scansion/vcard_temp.scs	Wed Mar 27 15:39:03 2024 +0000
@@ -51,8 +51,6 @@
 		</vCard>
 	</iq>
 
-Romeo disconnects
-
 Juliet connects
 
 Juliet sends:
@@ -77,4 +75,6 @@
 
 Juliet disconnects
 
+Romeo disconnects
+
 # recording ended on 2018-10-20T15:02:14Z
--- a/spec/util_argparse_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_argparse_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -50,4 +50,9 @@
 		assert.equal("-h", where, "returned where");
 	end);
 
+	it("supports array arguments", function ()
+		local opts, err = parse({ "--item"; "foo"; "--item"; "bar" }, { array_params = { item = true } });
+		assert.falsy(err);
+		assert.same({"foo","bar"}, opts.item);
+	end)
 end);
--- a/spec/util_bitcompat_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_bitcompat_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -24,4 +24,8 @@
 	it("lshift works", function ()
 		assert.equal(0xFF00, bit.lshift(0xFF, 8));
 	end);
+
+	it("bnot works", function ()
+		assert.equal(0x0000FF00, bit.band(0xFFFFFFFF, bit.bnot(0xFFFF00FF)));
+	end);
 end);
--- a/spec/util_cache_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_cache_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -4,6 +4,20 @@
 describe("util.cache", function()
 	describe("#new()", function()
 		it("should work", function()
+			do
+				local c = cache.new(1);
+				assert.is_not_nil(c);
+
+				assert.has_error(function ()
+					cache.new(0);
+				end);
+				assert.has_error(function ()
+					cache.new(-1);
+				end);
+				assert.has_error(function ()
+					cache.new("foo");
+				end);
+			end
 
 			local c = cache.new(5);
 
@@ -314,7 +328,7 @@
 
 		end);
 
-		(_VERSION=="Lua 5.1" and pending or it)(":table works", function ()
+		it(":table works", function ()
 			local t = cache.new(3):table();
 			assert.is.table(t);
 			t["a"] = "1";
@@ -336,5 +350,63 @@
 				assert.spy(i).was_called_with("c", "3");
 				assert.spy(i).was_called_with("d", "4");
 		end);
+
+		local function vs(t)
+			local vs_ = {};
+			for v in t:values() do
+				vs_[#vs_+1] = v;
+			end
+			return vs_;
+		end
+
+		it(":values works", function ()
+			local t = cache.new(3);
+			t:set("k1", "v1");
+			t:set("k2", "v2");
+			assert.same({"v2", "v1"}, vs(t));
+			t:set("k3", "v3");
+			assert.same({"v3", "v2", "v1"}, vs(t));
+			t:set("k4", "v4");
+			assert.same({"v4", "v3", "v2"}, vs(t));
+		end);
+
+		it(":resize works", function ()
+			local c = cache.new(5);
+			for i = 1, 5 do
+				c:set(("k%d"):format(i), ("v%d"):format(i));
+			end
+			assert.same({"v5", "v4", "v3", "v2", "v1"}, vs(c));
+			assert.has_error(function ()
+				c:resize(-1);
+			end);
+			assert.has_error(function ()
+				c:resize(0);
+			end);
+			assert.has_error(function ()
+				c:resize("foo");
+			end);
+			c:resize(3);
+			assert.same({"v5", "v4", "v3"}, vs(c));
+		end);
+
+		it("eviction stuff", function ()
+			local c = cache.new(4, function(_k,_v,c)
+				if c.size < 10 then
+					c:resize(c.size*2);
+				end
+			end)
+			for i = 1,20 do
+				c:set(i,i)
+			end
+			assert.equal(16, c.size);
+			assert.is_nil(c:get(1))
+			assert.is_nil(c:get(4))
+			assert.equal(5, c:get(5))
+			assert.equal(20, c:get(20))
+			c:resize(4)
+			assert.equal(20, c:get(20))
+			assert.equal(17, c:get(17))
+			assert.is_nil(c:get(10))
+		end)
 	end);
 end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_crypto_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,184 @@
+local test_keys = require "spec.inputs.test_keys";
+
+describe("util.crypto", function ()
+	local crypto = require "util.crypto";
+	local random = require "util.random";
+
+	describe("generate_ed25519_keypair", function ()
+		local keypair = crypto.generate_ed25519_keypair();
+		assert.is_not_nil(keypair);
+		assert.equal("ED25519", keypair:get_type());
+	end)
+
+	describe("import_private_pem", function ()
+		it("can import ECDSA keys", function ()
+			local ecdsa_key = crypto.import_private_pem(test_keys.ecdsa_private_pem);
+			assert.equal("id-ecPublicKey", ecdsa_key:get_type());
+		end);
+
+		it("can import EdDSA (Ed25519) keys", function ()
+			local ed25519_key = crypto.import_private_pem(crypto.generate_ed25519_keypair():private_pem());
+			assert.equal("ED25519", ed25519_key:get_type());
+		end);
+
+		it("can import RSA keys", function ()
+			-- TODO
+		end);
+
+		it("rejects invalid keys", function ()
+			assert.is_nil(crypto.import_private_pem(test_keys.eddsa_public_pem));
+			assert.is_nil(crypto.import_private_pem(test_keys.ecdsa_public_pem));
+			assert.is_nil(crypto.import_private_pem("foo"));
+			assert.is_nil(crypto.import_private_pem(""));
+		end);
+	end);
+
+	describe("import_public_pem", function ()
+		it("can import ECDSA public keys", function ()
+			local ecdsa_key = crypto.import_public_pem(test_keys.ecdsa_public_pem);
+			assert.equal("id-ecPublicKey", ecdsa_key:get_type());
+		end);
+
+		it("can import EdDSA (Ed25519) public keys", function ()
+			local ed25519_key = crypto.import_public_pem(test_keys.eddsa_public_pem);
+			assert.equal("ED25519", ed25519_key:get_type());
+		end);
+
+		it("can import RSA public keys", function ()
+			-- TODO
+		end);
+	end);
+
+	describe("PEM export", function ()
+		it("works", function ()
+			local ecdsa_key = crypto.import_public_pem(test_keys.ecdsa_public_pem);
+			assert.equal("id-ecPublicKey", ecdsa_key:get_type());
+			assert.equal(test_keys.ecdsa_public_pem, ecdsa_key:public_pem());
+
+			assert.has_error(function ()
+				-- Fails because private key is not available
+				ecdsa_key:private_pem();
+			end);
+
+			local ecdsa_private_key = crypto.import_private_pem(test_keys.ecdsa_private_pem);
+			assert.equal(test_keys.ecdsa_private_pem, ecdsa_private_key:private_pem());
+		end);
+	end);
+
+	describe("sign/verify with", function ()
+		local test_cases = {
+			ed25519 = {
+				crypto.ed25519_sign, crypto.ed25519_verify;
+				key = crypto.import_private_pem(test_keys.eddsa_private_pem);
+				sig_length = 64;
+			};
+			ecdsa = {
+				crypto.ecdsa_sha256_sign, crypto.ecdsa_sha256_verify;
+				key = crypto.import_private_pem(test_keys.ecdsa_private_pem);
+			};
+		};
+		for test_name, test in pairs(test_cases) do
+			local key = test.key;
+			describe(test_name, function ()
+				it("works", function ()
+					local sign, verify = test[1], test[2];
+					local sig = assert(sign(key, "Hello world"));
+					assert.is_string(sig);
+					if test.sig_length then
+						assert.equal(test.sig_length, #sig);
+					end
+
+					do
+						local ok = verify(key, "Hello world", sig);
+						assert.is_truthy(ok);
+					end
+					do -- Incorrect signature
+						local ok = verify(key, "Hello world", sig:sub(1, -2)..string.char((sig:byte(-1)+1)%255));
+						assert.is_falsy(ok);
+					end
+					do -- Incorrect message
+						local ok = verify(key, "Hello earth", sig);
+						assert.is_falsy(ok);
+					end
+					do -- Incorrect message (embedded NUL)
+						local ok = verify(key, "Hello world\0foo", sig);
+						assert.is_falsy(ok);
+					end
+				end);
+			end);
+		end
+	end);
+
+	describe("ECDSA signatures", function ()
+		local hex = require "util.hex";
+		local sig = hex.decode((([[
+			304402203e936e7b0bc62887e0e9d675afd08531a930384cfcf301
+			f25d13053a2ebf141d02205a5a7c7b7ac5878d004cb79b17b39346
+			6b0cd1043718ffc31c153b971d213a8e
+		]]):gsub("%s+", "")));
+		it("can be parsed", function ()
+			local r, s = crypto.parse_ecdsa_signature(sig, 32);
+			assert.is_string(r);
+			assert.is_string(s);
+			assert.equal(32, #r);
+			assert.equal(32, #s);
+		end);
+		it("fails to parse invalid signatures", function ()
+			local invalid_sigs = {
+				"";
+				"\000";
+				string.rep("\000", 64);
+				string.rep("\000", 72);
+				string.rep("\000", 256);
+				string.rep("\255", 72);
+				string.rep("\255", 3);
+			};
+			for _, invalid_sig in ipairs(invalid_sigs) do
+				local r, s = crypto.parse_ecdsa_signature(invalid_sig, 32);
+				assert.is_nil(r);
+				assert.is_nil(s);
+			end
+		end);
+		it("can be built", function ()
+			local r, s = crypto.parse_ecdsa_signature(sig, 32);
+			local rebuilt_sig = crypto.build_ecdsa_signature(r, s);
+			assert.equal(sig, rebuilt_sig);
+		end);
+	end);
+
+	describe("AES-GCM encryption", function ()
+		it("works", function ()
+			local message = "foo\0bar";
+			local key_128_bit = random.bytes(16);
+			local key_256_bit = random.bytes(32);
+			local test_cases = {
+				{ crypto.aes_128_gcm_encrypt, crypto.aes_128_gcm_decrypt, key = key_128_bit };
+				{ crypto.aes_256_gcm_encrypt, crypto.aes_256_gcm_decrypt, key = key_256_bit };
+			};
+			for _, params in pairs(test_cases) do
+				local iv = params.iv or random.bytes(12);
+				local encrypted = params[1](params.key, iv, message);
+				assert.not_equal(message, encrypted);
+				local decrypted = params[2](params.key, iv, encrypted);
+				assert.equal(message, decrypted);
+			end
+		end);
+	end);
+
+	describe("AES-CTR encryption", function ()
+		it("works", function ()
+			local message = "foo\0bar hello world";
+			local key_256_bit = random.bytes(32);
+			local test_cases = {
+				{ crypto.aes_256_ctr_decrypt, crypto.aes_256_ctr_decrypt, key = key_256_bit };
+			};
+			for _, params in pairs(test_cases) do
+				local iv = params.iv or random.bytes(16);
+				local encrypted = params[1](params.key, iv, message);
+				assert.not_equal(message, encrypted);
+				local decrypted = params[2](params.key, iv, encrypted);
+				assert.equal(message, decrypted);
+			end
+		end);
+	end);
+end);
--- a/spec/util_dataforms_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_dataforms_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -130,7 +130,7 @@
 		assert.truthy(st.is_stanza(xform));
 		assert.equal("x", xform.name);
 		assert.equal("jabber:x:data", xform.attr.xmlns);
-		assert.equal("FORM_TYPE", xform:find("field@var"));
+		assert.equal("FORM_TYPE", xform:get_child_attr("field", nil, "var"));
 		assert.equal("xmpp:prosody.im/spec/util.dataforms#1", xform:find("field/value#"));
 		local allowed_direct_children = {
 			title = true,
--- a/spec/util_datamapper_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_datamapper_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -15,22 +15,22 @@
 	setup(function()
 
 		-- a convenience function for simple attributes, there's a few of them
-		local function attr() return {["$ref"]="#/$defs/attr"} end
+		local attr = {["$ref"]="#/$defs/attr"};
 		s = {
 			["$defs"] = { attr = { type = "string"; xml = { attribute = true } } };
 			type = "object";
 			xml = {name = "message"; namespace = "jabber:client"};
 			properties = {
-				to = attr();
-				from = attr();
-				type = attr();
-				id = attr();
+				to = attr;
+				from = attr;
+				type = attr;
+				id = attr;
 				body = true; -- should be assumed to be a string
 				lang = {type = "string"; xml = {attribute = true; prefix = "xml"}};
 				delay = {
 					type = "object";
 					xml = {namespace = "urn:xmpp:delay"; name = "delay"};
-					properties = {stamp = attr(); from = attr(); reason = {type = "string"; xml = {text = true}}};
+					properties = {stamp = attr; from = attr; reason = {type = "string"; xml = {text = true}}};
 				};
 				state = {
 					type = "string";
@@ -66,8 +66,8 @@
 						xml = {name = "stanza-id"; namespace = "urn:xmpp:sid:0"};
 						type = "object";
 						properties = {
-							id = attr();
-							by = attr();
+							id = attr;
+							by = attr;
 						};
 					};
 				};
@@ -120,10 +120,10 @@
 				namespace = "jabber:client"
 			};
 			properties = {
-				to = attr();
-				from = attr();
-				type = attr();
-				id = attr();
+				to = attr;
+				from = attr;
+				type = attr;
+				id = attr;
 				disco = {
 					type = "object";
 					xml = {
--- a/spec/util_datetime_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_datetime_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -16,7 +16,10 @@
 			assert.truthy(string.find(date(), "^%d%d%d%d%-%d%d%-%d%d$"));
 		end);
 		it("should work", function ()
-			assert.equals(date(1136239445), "2006-01-02");
+			assert.equals("2006-01-02", date(1136239445));
+		end);
+		it("should ignore fractional parts", function ()
+			assert.equals("2006-01-02", date(1136239445.5));
 		end);
 	end);
 	describe("#time", function ()
@@ -32,8 +35,14 @@
 			assert.truthy(string.find(time(), "^%d%d:%d%d:%d%d"));
 		end);
 		it("should work", function ()
-			assert.equals(time(1136239445), "22:04:05");
+			assert.equals("22:04:05", time(1136239445));
 		end);
+		it("should handle precision", function ()
+			assert.equal("14:46:31.158200", time(1660488391.1582))
+			assert.equal("14:46:32.158200", time(1660488392.1582))
+			assert.equal("14:46:33.158200", time(1660488393.1582))
+			assert.equal("14:46:33.999900", time(1660488393.9999))
+		end)
 	end);
 	describe("#datetime", function ()
 		local datetime = util_datetime.datetime;
@@ -48,14 +57,23 @@
 			assert.truthy(string.find(datetime(), "^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%d"));
 		end);
 		it("should work", function ()
-			assert.equals(datetime(1136239445), "2006-01-02T22:04:05Z");
+			assert.equals("2006-01-02T22:04:05Z", datetime(1136239445));
 		end);
+		it("should handle precision", function ()
+			assert.equal("2022-08-14T14:46:31.158200Z", datetime(1660488391.1582))
+			assert.equal("2022-08-14T14:46:32.158200Z", datetime(1660488392.1582))
+			assert.equal("2022-08-14T14:46:33.158200Z", datetime(1660488393.1582))
+			assert.equal("2022-08-14T14:46:33.999900Z", datetime(1660488393.9999))
+		end)
 	end);
 	describe("#legacy", function ()
 		local legacy = util_datetime.legacy;
 		it("should exist", function ()
 			assert.is_function(legacy);
 		end);
+		it("should not add precision", function ()
+			assert.equal("20220814T14:46:31", legacy(1660488391.1582));
+		end);
 	end);
 	describe("#parse", function ()
 		local parse = util_datetime.parse;
@@ -64,13 +82,23 @@
 		end);
 		it("should work", function ()
 			-- Timestamp used by Go
-			assert.equals(parse("2017-11-19T17:58:13Z"),     1511114293);
-			assert.equals(parse("2017-11-19T18:58:50+0100"), 1511114330);
-			assert.equals(parse("2006-01-02T15:04:05-0700"), 1136239445);
+			assert.equals(1511114293, parse("2017-11-19T17:58:13Z"));
+			assert.equals(1511114330, parse("2017-11-19T18:58:50+0100"));
+			assert.equals(1136239445, parse("2006-01-02T15:04:05-0700"));
+			assert.equals(1136239445, parse("2006-01-02T15:04:05-07"));
 		end);
 		it("should handle timezones", function ()
 			-- https://xmpp.org/extensions/xep-0082.html#example-2 and 3
 			assert.equals(parse("1969-07-21T02:56:15Z"), parse("1969-07-20T21:56:15-05:00"));
 		end);
+		it("should handle precision", function ()
+			-- floating point comparison is not an exact science
+			assert.truthy(math.abs(1660488392.1582 - parse("2022-08-14T14:46:32.158200Z")) < 0.001)
+		end)
+		it("should return nil when given invalid inputs", function ()
+			assert.is_nil(parse(nil));
+			assert.is_nil(parse("hello world"));
+			assert.is_nil(parse("2017-11-19T18:58:50$0100"));
+		end);
 	end);
 end);
--- a/spec/util_dbuffer_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_dbuffer_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,6 +6,8 @@
 		end);
 		it("can be created", function ()
 			assert.truthy(dbuffer.new());
+			assert.truthy(dbuffer.new(1));
+			assert.truthy(dbuffer.new(1024));
 		end);
 		it("won't create an empty buffer", function ()
 			assert.falsy(dbuffer.new(0));
@@ -15,10 +17,21 @@
 		end);
 	end);
 	describe(":write", function ()
-		local b = dbuffer.new();
+		local b = dbuffer.new(10, 3);
 		it("works", function ()
 			assert.truthy(b:write("hi"));
 		end);
+		it("fails when the buffer is full", function ()
+			local ret = b:write(" there world, this is a long piece of data");
+			assert.is_falsy(ret);
+		end);
+		it("works when max_chunks is reached", function ()
+			-- Chunks are an optimization, dbuffer should collapse chunks when needed
+			for _ = 1, 8 do
+				assert.truthy(b:write("!"));
+			end
+			assert.falsy(b:write("!")); -- Length reached
+		end);
 	end);
 
 	describe(":read", function ()
@@ -34,6 +47,14 @@
 			assert.equal(" ", b:read());
 			assert.equal("world", b:read());
 		end);
+		it("fails when there is not enough data in the buffer", function ()
+			local b = dbuffer.new(12);
+			b:write("hello");
+			b:write(" ");
+			b:write("world");
+			assert.is_falsy(b:read(12));
+			assert.is_falsy(b:read(13));
+		end);
 	end);
 
 	describe(":read_until", function ()
@@ -68,9 +89,46 @@
 			assert.equal(5, b:len());
 			assert.equal("world", b:read(5));
 		end);
+		it("works across chunks", function ()
+			assert.truthy(b:write("hello"));
+			assert.truthy(b:write(" "));
+			assert.truthy(b:write("world"));
+			assert.truthy(b:discard(3));
+			assert.equal(8, b:length());
+			assert.truthy(b:discard(3));
+			assert.equal(5, b:length());
+			assert.equal("world", b:read(5));
+		end);
+		it("can discard the entire buffer", function ()
+			assert.equal(b:len(), 0);
+			assert.truthy(b:write("hello world"));
+			assert.truthy(b:discard(11));
+			assert.equal(0, b:len());
+			assert.truthy(b:write("hello world"));
+			assert.truthy(b:discard(12));
+			assert.equal(0, b:len());
+			assert.truthy(b:write("hello world"));
+			assert.truthy(b:discard(128));
+			assert.equal(0, b:len());
+		end);
+		it("works on an empty buffer", function ()
+			assert.truthy(dbuffer.new():discard());
+			assert.truthy(dbuffer.new():discard(0));
+			assert.truthy(dbuffer.new():discard(1));
+		end);
 	end);
 
 	describe(":collapse()", function ()
+		it("works", function ()
+			local b = dbuffer.new();
+			b:write("hello");
+			b:write(" ");
+			b:write("world");
+			b:collapse(6);
+			local ret, bytes = b:read_chunk();
+			assert.equal("hello ", ret);
+			assert.equal(6, bytes);
+		end);
 		it("works on an empty buffer", function ()
 			local b = dbuffer.new();
 			b:collapse();
@@ -115,6 +173,11 @@
 				end
 			end
 		end);
+
+		it("works on an empty buffer", function ()
+			local b = dbuffer.new();
+			assert.equal("", b:sub(1, 12));
+		end);
 	end);
 
 	describe(":byte", function ()
@@ -122,7 +185,11 @@
 		local s = "hello world"
 		local function test_byte(b, x, y)
 			local string_result, buffer_result = {s:byte(x, y)}, {b:byte(x, y)};
-			assert.same(string_result, buffer_result, ("buffer:byte(%d, %s) does not match string:byte()"):format(x, y and ("%d"):format(y) or "nil"));
+			assert.same(
+				string_result,
+				buffer_result,
+				("buffer:byte(%s, %s) does not match string:byte()"):format(x and ("%d"):format(x) or "nil", y and ("%d"):format(y) or "nil")
+			);
 		end
 
 		it("is equivalent to string:byte", function ()
@@ -132,6 +199,7 @@
 			test_byte(b, 3);
 			test_byte(b, -1);
 			test_byte(b, -3);
+			test_byte(b, nil, 5);
 			for i = -13, 13 do
 				for j = -13, 13 do
 					test_byte(b, i, j);
--- a/spec/util_error_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_error_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -56,6 +56,9 @@
 			assert.equal(e, err.context.stanza);
 			assert.equal("error.example", err.context.by);
 			assert.not_nil(err.extra.tag);
+			assert.not_has_error(function ()
+				errors.from_stanza(st.message())
+			end);
 		end);
 	end);
 
--- a/spec/util_format_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_format_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -13,7 +13,7 @@
 			assert.equal("true", format("%s", true));
 			assert.equal("[true]", format("%d", true));
 			assert.equal("% [true]", format("%%", true));
-			assert.equal("{ }", format("%q", { }));
+			assert.equal("{}", format("%q", {}));
 			assert.equal("[1.5]", format("%d", 1.5));
 			assert.equal("[7.3786976294838e+19]", format("%d", 73786976294838206464));
 		end);
@@ -333,29 +333,27 @@
 				end);
 			end);
 
-			if _VERSION > "Lua 5.1" then -- COMPAT no %a or %A in Lua 5.1
-				describe("to %a", function ()
-					it("works", function ()
-						assert.equal("0x1.84p+6", format("%a", 97))
-						assert.equal("-0x1.81c8p+13", format("%a", -12345))
-						assert.equal("0x1.8p+0", format("%a", 1.5))
-						assert.equal("0x1p+66", format("%a", 73786976294838206464))
-						assert.equal("inf", format("%a", math.huge))
-						assert.equal("0x1.fffffffcp+30", format("%a", 2147483647))
-					end);
+			describe("to %a", function ()
+				it("works", function ()
+					assert.equal("0x1.84p+6", format("%a", 97))
+					assert.equal("-0x1.81c8p+13", format("%a", -12345))
+					assert.equal("0x1.8p+0", format("%a", 1.5))
+					assert.equal("0x1p+66", format("%a", 73786976294838206464))
+					assert.equal("inf", format("%a", math.huge))
+					assert.equal("0x1.fffffffcp+30", format("%a", 2147483647))
 				end);
+			end);
 
-				describe("to %A", function ()
-					it("works", function ()
-						assert.equal("0X1.84P+6", format("%A", 97))
-						assert.equal("-0X1.81C8P+13", format("%A", -12345))
-						assert.equal("0X1.8P+0", format("%A", 1.5))
-						assert.equal("0X1P+66", format("%A", 73786976294838206464))
-						assert.equal("INF", format("%A", math.huge))
-						assert.equal("0X1.FFFFFFFCP+30", format("%A", 2147483647))
-					end);
+			describe("to %A", function ()
+				it("works", function ()
+					assert.equal("0X1.84P+6", format("%A", 97))
+					assert.equal("-0X1.81C8P+13", format("%A", -12345))
+					assert.equal("0X1.8P+0", format("%A", 1.5))
+					assert.equal("0X1P+66", format("%A", 73786976294838206464))
+					assert.equal("INF", format("%A", math.huge))
+					assert.equal("0X1.FFFFFFFCP+30", format("%A", 2147483647))
 				end);
-			end
+			end);
 
 			describe("to %e", function ()
 				it("works", function ()
@@ -670,7 +668,7 @@
 
 			describe("to %q", function ()
 				it("works", function ()
-					assert.matches('{__type="function",__error="fail"}', format("%q", function() end))
+					assert.matches('%[%[function: 0[xX]%x+]]', format("%q", function() end))
 				end);
 			end);
 
@@ -769,7 +767,7 @@
 
 			describe("to %q", function ()
 				it("works", function ()
-					assert.matches('{__type="thread",__error="fail"}', format("%q", coroutine.create(function() end)))
+					assert.matches('_%[%[thread: 0[xX]%x+]]', format("%q", coroutine.create(function() end)))
 				end);
 			end);
 
@@ -882,8 +880,8 @@
 
 			describe("to %q", function ()
 				it("works", function ()
-					assert.matches("{ }", format("%q", { }))
-					assert.equal("{ }", format("%q", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
+					assert.matches("{}", format("%q", { }))
+					assert.equal("{}", format("%q", setmetatable({},{__tostring=function ()return "foo \1\2\3 bar"end})))
 				end);
 			end);
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_fsm_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,250 @@
+describe("util.fsm", function ()
+	local new_fsm = require "util.fsm".new;
+
+	do
+		local fsm = new_fsm({
+			transitions = {
+				{ name = "melt", from = "solid", to = "liquid" };
+				{ name = "freeze", from = "liquid", to = "solid" };
+			};
+		});
+
+		it("works", function ()
+			local water = fsm:init("liquid");
+			water:freeze();
+			assert.equal("solid", water.state);
+			water:melt();
+			assert.equal("liquid", water.state);
+		end);
+
+		it("does not allow invalid transitions", function ()
+			local water = fsm:init("liquid");
+			assert.has_errors(function ()
+				water:melt();
+			end, "Invalid state transition: liquid cannot melt");
+
+			water:freeze();
+			assert.equal("solid", water.state);
+
+			water:melt();
+			assert.equal("liquid", water.state);
+
+			assert.has_errors(function ()
+				water:melt();
+			end, "Invalid state transition: liquid cannot melt");
+		end);
+	end
+
+	it("notifies observers", function ()
+		local n = 0;
+		local has_become_solid = spy.new(function (transition)
+			assert.is_table(transition);
+			assert.equal("solid", transition.to);
+			assert.is_not_nil(transition.instance);
+			n = n + 1;
+			if n == 1 then
+				assert.is_nil(transition.from);
+				assert.is_nil(transition.from_attr);
+			elseif n == 2 then
+				assert.equal("liquid", transition.from);
+				assert.is_nil(transition.from_attr);
+				assert.equal("freeze", transition.name);
+			end
+		end);
+		local is_melting = spy.new(function (transition)
+			assert.is_table(transition);
+			assert.equal("melt", transition.name);
+			assert.is_not_nil(transition.instance);
+		end);
+		local fsm = new_fsm({
+			transitions = {
+				{ name = "melt", from = "solid", to = "liquid" };
+				{ name = "freeze", from = "liquid", to = "solid" };
+			};
+			state_handlers = {
+				solid = has_become_solid;
+			};
+
+			transition_handlers = {
+				melt = is_melting;
+			};
+		});
+
+		local water = fsm:init("liquid");
+		assert.spy(has_become_solid).was_not_called();
+
+		local ice = fsm:init("solid"); --luacheck: ignore 211/ice
+		assert.spy(has_become_solid).was_called(1);
+
+		water:freeze();
+
+		assert.spy(is_melting).was_not_called();
+		water:melt();
+		assert.spy(is_melting).was_called(1);
+	end);
+
+	local function test_machine(fsm_spec, expected_transitions, test_func)
+		fsm_spec.handlers = fsm_spec.handlers or {};
+		fsm_spec.handlers.transitioned = function (transition)
+			local expected_transition = table.remove(expected_transitions, 1);
+			assert.same(expected_transition, {
+				name = transition.name;
+				to = transition.to;
+				to_attr = transition.to_attr;
+				from = transition.from;
+				from_attr = transition.from_attr;
+			});
+		end;
+		local fsm = new_fsm(fsm_spec);
+		test_func(fsm);
+		assert.equal(0, #expected_transitions);
+	end
+
+
+	it("handles transitions with the same name", function ()
+		local expected_transitions = {
+			{ name = nil   , from = "none", to = "A" };
+			{ name = "step", from = "A", to = "B" };
+			{ name = "step", from = "B", to = "C" };
+			{ name = "step", from = "C", to = "D" };
+		};
+
+		test_machine({
+			default_state = "none";
+			transitions = {
+				{ name = "step", from = "A", to = "B" };
+				{ name = "step", from = "B", to = "C" };
+				{ name = "step", from = "C", to = "D" };
+			};
+		}, expected_transitions, function (fsm)
+			local i = fsm:init("A");
+			i:step(); -- B
+			i:step(); -- C
+			i:step(); -- D
+			assert.has_errors(function ()
+				i:step();
+			end, "Invalid state transition: D cannot step");
+		end);
+	end);
+
+	it("handles supports wildcard transitions", function ()
+		local expected_transitions = {
+			{ name = nil   , from = "none", to = "A" };
+			{ name = "step", from = "A", to = "B" };
+			{ name = "step", from = "B", to = "C" };
+			{ name = "reset", from = "C", to = "A" };
+			{ name = "step", from = "A", to = "B" };
+			{ name = "step", from = "B", to = "C" };
+			{ name = "step", from = "C", to = "D" };
+		};
+
+		test_machine({
+			default_state = "none";
+			transitions = {
+				{ name = "step", from = "A", to = "B" };
+				{ name = "step", from = "B", to = "C" };
+				{ name = "step", from = "C", to = "D" };
+				{ name = "reset", from = "*", to = "A" };
+			};
+		}, expected_transitions, function (fsm)
+			local i = fsm:init("A");
+			i:step(); -- B
+			i:step(); -- C
+			i:reset(); -- A
+			i:step(); -- B
+			i:step(); -- C
+			i:step(); -- D
+			assert.has_errors(function ()
+				i:step();
+			end, "Invalid state transition: D cannot step");
+		end);
+	end);
+
+	it("supports specifying multiple from states", function ()
+		local expected_transitions = {
+			{ name = nil   , from = "none", to = "A" };
+			{ name = "step", from = "A", to = "B" };
+			{ name = "step", from = "B", to = "C" };
+			{ name = "reset", from = "C", to = "A" };
+			{ name = "step", from = "A", to = "B" };
+			{ name = "step", from = "B", to = "C" };
+			{ name = "step", from = "C", to = "D" };
+		};
+
+		test_machine({
+			default_state = "none";
+			transitions = {
+				{ name = "step", from = "A", to = "B" };
+				{ name = "step", from = "B", to = "C" };
+				{ name = "step", from = "C", to = "D" };
+				{ name = "reset", from = {"B", "C", "D"}, to = "A" };
+			};
+		}, expected_transitions, function (fsm)
+			local i = fsm:init("A");
+			i:step(); -- B
+			i:step(); -- C
+			i:reset(); -- A
+			assert.has_errors(function ()
+				i:reset();
+			end, "Invalid state transition: A cannot reset");
+			i:step(); -- B
+			i:step(); -- C
+			i:step(); -- D
+			assert.has_errors(function ()
+				i:step();
+			end, "Invalid state transition: D cannot step");
+		end);
+	end);
+
+	it("handles transitions with the same start/end state", function ()
+		local expected_transitions = {
+			{ name = nil   , from = "none", to = "A" };
+			{ name = "step", from = "A", to = "B" };
+			{ name = "step", from = "B", to = "B" };
+			{ name = "step", from = "B", to = "B" };
+		};
+
+		test_machine({
+			default_state = "none";
+			transitions = {
+				{ name = "step", from = "A", to = "B" };
+				{ name = "step", from = "B", to = "B" };
+			};
+		}, expected_transitions, function (fsm)
+			local i = fsm:init("A");
+			i:step(); -- B
+			i:step(); -- B
+			i:step(); -- B
+		end);
+	end);
+
+	it("can identify instances of a specific fsm", function ()
+		local fsm1 = new_fsm({ default_state = "a" });
+		local fsm2 = new_fsm({ default_state = "a" });
+
+		local i1 = fsm1:init();
+		local i2 = fsm2:init();
+
+		assert.truthy(fsm1:is_instance(i1));
+		assert.truthy(fsm2:is_instance(i2));
+
+		assert.falsy(fsm1:is_instance(i2));
+		assert.falsy(fsm2:is_instance(i1));
+	end);
+
+	it("errors when an invalid initial state is passed", function ()
+		local fsm1 = new_fsm({
+			transitions = {
+				{ name = "", from = "A", to = "B" };
+			};
+		});
+
+		assert.has_no_errors(function ()
+			fsm1:init("A");
+		end);
+
+		assert.has_errors(function ()
+			fsm1:init("C");
+		end);
+	end);
+end);
--- a/spec/util_hashes_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_hashes_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -4,6 +4,8 @@
 
 -- Also see spec for util.hmac where HMAC test cases reside
 
+--luacheck: ignore 631
+
 describe("PBKDF2-HMAC-SHA1", function ()
 	it("test vector 1", function ()
 		local P = "password"
@@ -53,3 +55,56 @@
 end);
 
 
+describe("SHA-3", function ()
+	describe("256", function ()
+		it("works", function ()
+			local expected = "a7ffc6f8bf1ed76651c14756a061d662f580ff4de43b49fa82d80a4b80f8434a"
+			assert.equal(expected, hashes.sha3_256("", true));
+		end);
+	end);
+	describe("512", function ()
+		it("works", function ()
+			local expected = "a69f73cca23a9ac5c8b567dc185a756e97c982164fe25859e0d1dcc1475c80a615b2123af1f5f94c11e3e9402c3ac558f500199d95b6d3e301758586281dcd26"
+			assert.equal(expected, hashes.sha3_512("", true));
+		end);
+	end);
+end);
+
+describe("HKDF", function ()
+	describe("HMAC-SHA256", function ()
+		describe("RFC 5869", function ()
+			it("test vector A.1", function ()
+				local ikm = hex.decode("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
+				local salt = hex.decode("000102030405060708090a0b0c");
+				local info = hex.decode("f0f1f2f3f4f5f6f7f8f9");
+
+				local expected = "3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865";
+
+				local ret = hashes.hkdf_hmac_sha256(42, ikm, salt, info);
+				assert.equal(expected, hex.encode(ret));
+			end);
+
+			it("test vector A.2", function ()
+				local ikm = hex.decode("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f404142434445464748494a4b4c4d4e4f");
+				local salt = hex.decode("606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9fa0a1a2a3a4a5a6a7a8a9aaabacadaeaf");
+				local info = hex.decode("b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff");
+
+				local expected = "b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87";
+
+				local ret = hashes.hkdf_hmac_sha256(82, ikm, salt, info);
+				assert.equal(expected, hex.encode(ret));
+			end);
+
+			it("test vector A.3", function ()
+				local ikm = hex.decode("0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b0b");
+				local salt = "";
+				local info = "";
+
+				local expected = "8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8";
+
+				local ret = hashes.hkdf_hmac_sha256(42, ikm, salt, info);
+				assert.equal(expected, hex.encode(ret));
+			end);
+		end);
+	end);
+end);
--- a/spec/util_hashring_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_hashring_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,7 @@
 local hashring = require "util.hashring";
 
 describe("util.hashring", function ()
+	randomize(false);
 
 	local sha256 = require "util.hashes".sha256;
 
@@ -82,4 +83,11 @@
 		end
 	end);
 
+	it("should support values associated with nodes", function ()
+		local r = hashring.new(128, sha256);
+		r:add_node("node1", { a = 1 });
+		local node, value = r:get_node("foo");
+		assert.is_equal("node1", node);
+		assert.same({ a = 1 }, value);
+	end);
 end);
--- a/spec/util_http_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_http_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -41,6 +41,7 @@
 		end);
 
 		it("should encode special characters with escaping", function()
+			-- luacheck: ignore 631
 			assert.are.equal(http.formencode({ { name = "one two", value = "1"}, { name = "two one&", value = "2" } }), "one+two=1&two+one%26=2", "Form encoded");
 		end);
 	end);
@@ -108,4 +109,25 @@
 			assert.is_(http.contains_token("fo o", "foo"));
 		end);
 	end);
+
+do
+	describe("parse_forwarded", function()
+		it("works", function()
+			assert.same({ { ["for"] = "[2001:db8:cafe::17]:4711" } }, http.parse_forwarded('For="[2001:db8:cafe::17]:4711"'), "case insensitive");
+
+			assert.same({ { ["for"] = "192.0.2.60"; proto = "http"; by = "203.0.113.43" } }, http.parse_forwarded('for=192.0.2.60;proto=http;by=203.0.113.43'),
+				"separated by semicolon");
+
+			assert.same({ { ["for"] = "192.0.2.43" }; { ["for"] = "198.51.100.17" } }, http.parse_forwarded('for=192.0.2.43, for=198.51.100.17'),
+				"Values from multiple proxy servers can be appended using a comma");
+
+		end)
+		it("rejects quoted quotes", function ()
+			assert.falsy(http.parse_forwarded('foo="bar\"bar'), "quoted quotes");
+		end)
+		pending("deals with quoted quotes", function ()
+			assert.same({ { foo = 'bar"baz' } }, http.parse_forwarded('foo="bar\"bar'), "quoted quotes");
+		end)
+	end)
+end
 end);
--- a/spec/util_human_io_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_human_io_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -42,6 +42,64 @@
 			assert.equal("räksmörgås", human_io.ellipsis("räksmörgås", 10));
 		end);
 	end);
+
+	describe("parse_duration", function ()
+		local function test(expected, duration)
+			return assert.equal(expected, human_io.parse_duration(duration), ("%q -> %d"):format(duration, expected));
+		end
+		local function should_fail(duration)
+			assert.is_nil(human_io.parse_duration(duration), "invalid duration should fail: %q");
+		end
+		it("works", function ()
+			test(1, "1s");
+			test(60, "1min");
+			test(60, "1 min");
+			test(60, "1 minute");
+			test(120, "2min");
+			test(7200, "2h");
+			test(7200, "2 hours");
+			test(86400, "1d");
+			test(604800, "1w");
+			test(604800, "1week");
+			test(1814400, "3 weeks");
+			test(2678400, "1month");
+			test(2678400, "1 month");
+			test(31536000, "365 days");
+			test(31556952, "1 year");
+
+			should_fail("two weeks");
+			should_fail("1m");
+			should_fail("1mi");
+			should_fail("1mo");
+		end);
+	end);
+
+	describe("parse_duration_lax", function ()
+		local function test(expected, duration)
+			return assert.equal(expected, human_io.parse_duration_lax(duration), ("%q -> %d"):format(duration, expected));
+		end
+		it("works", function ()
+			test(1, "1s");
+			test(60, "1mi");
+			test(60, "1min");
+			test(60, "1 min");
+			test(60, "1 minute");
+			test(120, "2min");
+			test(7200, "2h");
+			test(7200, "2 hours");
+			test(86400, "1d");
+			test(604800, "1w");
+			test(604800, "1week");
+			test(1814400, "3 weeks");
+			test(2678400, "1m");
+			test(2678400, "1mo");
+			test(2678400, "1month");
+			test(2678400, "1 month");
+			test(31536000, "365 days");
+			test(31556952, "1 year");
+			return assert.is_nil(human_io.parse_duration_lax("two weeks"), "\"2 weeks\" -> nil");
+		end);
+	end);
 end);
 
 
--- a/spec/util_ip_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_ip_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -36,6 +36,8 @@
 
 			assert.are.equal(match(_"8.8.8.8", _"8.8.0.0", 16), true);
 			assert.are.equal(match(_"8.8.4.4", _"8.8.0.0", 16), true);
+
+			assert.are.equal(match(_"fe80::1", _"fec0::", 10), false);
 		end);
 	end);
 
@@ -98,6 +100,30 @@
 			assert_cpl6("abcd::1", "abcd::1", 128);
 			assert_cpl6("abcd::abcd", "abcd::", 112);
 			assert_cpl6("abcd::abcd", "abcd::abcd:abcd", 96);
+
+			assert_cpl6("fe80::1", "fec0::", 9);
+		end);
+	end);
+
+	describe("#truncate()", function ()
+		it("should work for IPv4", function ()
+			local ip1 = ip.new_ip("192.168.0.1");
+			local ip2 = ip.truncate(ip1, 16);
+			assert.truthy(ip.is_ip(ip2));
+			assert.equal("192.168.0.0", ip2.normal);
+			assert.equal("192.168.0.1", ip1.normal); -- original unmodified
+		end);
+
+		it("should work for IPv6", function ()
+			local ip1 = ip.new_ip("2001:db8::ff00:42:8329");
+			local ip2 = ip.truncate(ip1, 24);
+			assert.truthy(ip.is_ip(ip2));
+			assert.equal("2001:d00::", ip2.normal);
+			assert.equal("2001:db8::ff00:42:8329", ip1.normal); -- original unmodified
+		end);
+
+		it("accepts a string", function ()
+			assert.equal("127.0.0.0", ip.truncate("127.0.0.1", 8).normal);
 		end);
 	end);
 end);
--- a/spec/util_iterators_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_iterators_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -10,6 +10,14 @@
 			end
 			assert.same(output, expect);
 		end);
+		it("should work with only a single iterator", function ()
+			local expect = { "a", "b", "c" };
+			local output = {};
+			for x in iter.join(iter.values({"a", "b", "c"})) do
+				table.insert(output, x);
+			end
+			assert.same(output, expect);
+		end);
 	end);
 
 	describe("sorted_pairs", function ()
--- a/spec/util_jid_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_jid_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -48,6 +48,47 @@
 		end)
 	end);
 
+	describe("#prepped_split()", function()
+		local function test(input_jid, expected_node, expected_server, expected_resource)
+			local rnode, rserver, rresource = jid.prepped_split(input_jid);
+			assert.are.equal(expected_node, rnode, "split("..tostring(input_jid)..") failed");
+			assert.are.equal(expected_server, rserver, "split("..tostring(input_jid)..") failed");
+			assert.are.equal(expected_resource, rresource, "split("..tostring(input_jid)..") failed");
+		end
+
+		it("should work", function()
+			-- Valid JIDs
+			test("node@server", 		"node", "server", nil		);
+			test("node@server/resource", 	"node", "server", "resource"        );
+			test("server", 			nil, 	"server", nil               );
+			test("server/resource", 	nil, 	"server", "resource"        );
+			test("server/resource@foo", 	nil, 	"server", "resource@foo"    );
+			test("server/resource@foo/bar",	nil, 	"server", "resource@foo/bar");
+
+			-- Always invalid JIDs
+			test(nil,                nil, nil, nil);
+			test("node@/server",     nil, nil, nil);
+			test("@server",          nil, nil, nil);
+			test("@server/resource", nil, nil, nil);
+			test("@/resource", nil, nil, nil);
+			test("@server/", nil, nil, nil);
+			test("server/", nil, nil, nil);
+			test("/resource", nil, nil, nil);
+		end);
+		it("should reject invalid arguments", function ()
+			assert.has_error(function () jid.prepped_split(false) end)
+		end)
+		it("should strip empty root label", function ()
+			test("node@server.", "node", "server", nil);
+		end);
+		it("should fail for JIDs that fail stringprep", function ()
+			test("node@invalid-\128-server", nil, nil, nil);
+			test("node@invalid-\194\128-server", nil, nil, nil);
+			test("<invalid node>@server", nil, nil, nil);
+			test("node@server/invalid-\000-resource", nil, nil, nil);
+		end);
+	end);
+
 
 	describe("#bare()", function()
 		it("should work", function()
--- a/spec/util_jsonpointer_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_jsonpointer_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -21,9 +21,11 @@
 		 }]])
 		end)
 		it("works", function()
+			assert.is_nil(jp.resolve("string", "/string"))
 			assert.same(example, jp.resolve(example, ""));
 			assert.same({ "bar", "baz" }, jp.resolve(example, "/foo"));
 			assert.same("bar", jp.resolve(example, "/foo/0"));
+			assert.same(nil, jp.resolve(example, "/foo/-"));
 			assert.same(0, jp.resolve(example, "/"));
 			assert.same(1, jp.resolve(example, "/a~1b"));
 			assert.same(2, jp.resolve(example, "/c%d"));
--- a/spec/util_jsonschema_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_jsonschema_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -2,7 +2,7 @@
 local json = require "util.json";
 local lfs = require "lfs";
 
--- https://github.com/json-schema-org/JSON-Schema-Test-Suite.git 2.0.0-550-g88d6948
+-- https://github.com/json-schema-org/JSON-Schema-Test-Suite.git 2.0.0-755-g7950d9e
 local test_suite_dir = "spec/JSON-Schema-Test-Suite/tests/draft2020-12"
 if lfs.attributes(test_suite_dir, "mode") ~= "directory" then return end
 
@@ -13,30 +13,25 @@
 	["additionalProperties.json:1:0"] = "NYI",
 	["anchor.json"] = "$anchor NYI",
 	["const.json:1"] = "deepcompare",
-	["const.json:13:2"] = "IEEE 754 equality",
 	["const.json:2"] = "deepcompare",
 	["const.json:8"] = "deepcompare",
 	["const.json:9"] = "deepcompare",
 	["contains.json:0:5"] = "distinguishing objects from arrays",
 	["defs.json"] = "need built-in meta-schema",
-	["dependentRequired.json"] = "NYI",
-	["dependentSchemas.json"] = "NYI",
+	["dependentSchemas.json:2:2"] = "NYI", -- minProperties
 	["dynamicRef.json"] = "NYI",
 	["enum.json:1:3"] = "deepcompare",
 	["id.json"] = "NYI",
-	["maxContains.json"] = "NYI",
-	["maxLength.json:0:4"] = "UTF-16",
 	["maxProperties.json"] = "NYI",
-	["minContains.json"] = "NYI",
-	["minLength.json:0:4"] = "UTF-16",
 	["minProperties.json"] = "NYI",
 	["multipleOf.json:1"] = "multiples of IEEE 754 fractions",
 	["multipleOf.json:2"] = "multiples of IEEE 754 fractions",
+	["multipleOf.json:4"] = "multiples of IEEE 754 fractions",
 	["pattern.json"] = "NYI",
 	["patternProperties.json"] = "NYI",
 	["properties.json:1:2"] = "NYI",
 	["properties.json:1:3"] = "NYI",
-	["ref.json:0:3"] = "NYI additionalProperties",
+	["ref.json:0:3"] = "util.jsonpointer recursive issue?",
 	["ref.json:11"] = "NYI",
 	["ref.json:12:1"] = "FIXME",
 	["ref.json:13"] = "NYI",
@@ -54,8 +49,14 @@
 	["ref.json:6:1"] = "NYI",
 	["ref.json:20"] = "NYI",
 	["ref.json:25"] = "NYI",
+	["ref.json:29"] = "NYI",
+	["ref.json:30"] = "NYI",
+	["ref.json:31"] = "NYI",
+	["ref.json:32"] = "NYI",
+	["not.json:6"] = "NYI",
 	["refRemote.json"] = "DEFINITELY NYI",
 	["required.json:0:2"] = "distinguishing objects from arrays",
+	["type.json:0:1"] = "1.0 is not an integer!",
 	["type.json:3:4"] = "distinguishing objects from arrays",
 	["type.json:3:6"] = "null is weird",
 	["type.json:4:3"] = "distinguishing objects from arrays",
@@ -64,11 +65,12 @@
 	["type.json:9:6"] = "null is weird",
 	["unevaluatedItems.json"] = "NYI",
 	["unevaluatedProperties.json"] = "NYI",
-	["uniqueItems.json:0:11"] = "deepcompare",
-	["uniqueItems.json:0:13"] = "deepcompare",
+	["uniqueItems.json:0:10"] = "deepcompare",
+	["uniqueItems.json:0:12"] = "deepcompare",
 	["uniqueItems.json:0:14"] = "deepcompare",
-	["uniqueItems.json:0:22"] = "deepcompare",
-	["uniqueItems.json:0:24"] = "deepcompare",
+	["uniqueItems.json:0:15"] = "deepcompare",
+	["uniqueItems.json:0:23"] = "deepcompare",
+	["uniqueItems.json:0:25"] = "deepcompare",
 	["uniqueItems.json:0:9"] = "deepcompare",
 	["unknownKeyword.json"] = "NYI",
 	["vocabulary.json"] = "NYI",
--- a/spec/util_jwt_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_jwt_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,4 +1,12 @@
 local jwt = require "util.jwt";
+local test_keys = require "spec.inputs.test_keys";
+
+local array = require "util.array";
+local iter = require "util.iterators";
+local set = require "util.set";
+
+-- Ignore long lines. We have some long tokens embedded here.
+--luacheck: ignore 631
 
 describe("util.jwt", function ()
 	it("validates", function ()
@@ -8,6 +16,9 @@
 		local ok, parsed = jwt.verify(key, token);
 		assert.truthy(ok)
 		assert.same({ payload = "this" }, parsed);
+
+
+
 	end);
 	it("rejects invalid", function ()
 		local key = "secret";
@@ -16,5 +27,233 @@
 		local ok = jwt.verify(key, token);
 		assert.falsy(ok)
 	end);
+
+	local function jwt_reference_token(token)
+		return {
+			name = "jwt.io reference";
+			token;
+			{     -- payload
+				sub = "1234567890";
+				name = "John Doe";
+				admin = true;
+				iat = 1516239022;
+			};
+		};
+	end
+
+	local untested_algorithms = set.new(array.collect(iter.keys(jwt._algorithms)));
+
+	local test_cases = {
+		{
+			algorithm = "HS256";
+			keys = {
+				{ "your-256-bit-secret", "your-256-bit-secret" };
+				{ "another-secret", "another-secret" };
+			};
+
+			jwt_reference_token [[eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyLCJhZG1pbiI6dHJ1ZX0.F-cvL2RcfQhUtCavIM7q7zYE8drmj2LJk0JRkrS6He4]];
+		};
+		{
+			algorithm = "HS384";
+			keys = {
+				{ "your-384-bit-secret", "your-384-bit-secret" };
+				{ "another-secret", "another-secret" };
+			};
+
+			jwt_reference_token [[eyJhbGciOiJIUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.bQTnz6AuMJvmXXQsVPrxeQNvzDkimo7VNXxHeSBfClLufmCVZRUuyTwJF311JHuh]];
+		};
+		{
+			algorithm = "HS512";
+			keys = {
+				{ "your-512-bit-secret", "your-512-bit-secret" };
+				{ "another-secret", "another-secret" };
+			};
+
+			jwt_reference_token [[eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.VFb0qJ1LRg_4ujbZoRMXnVkUgiuKq5KxWqNdbKq_G9Vvz-S1zZa9LPxtHWKa64zDl2ofkT8F6jBt_K4riU-fPg]];
+		};
+		{
+			algorithm = "ES256";
+			keys = {
+				{ test_keys.ecdsa_private_pem, test_keys.ecdsa_public_pem };
+				{ test_keys.alt_ecdsa_private_pem, test_keys.alt_ecdsa_public_pem };
+			};
+			{
+				name = "jwt.io reference";
+				[[eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.tyh-VfuzIxCyGYDlkBA7DfyjrqmSHu6pQ2hoZuFqUSLPNY2N0mpHb3nk5K17HWP_3cYHBw7AhHale5wky6-sVA]];
+				{     -- payload
+					sub = "1234567890";
+					name = "John Doe";
+					admin = true;
+					iat = 1516239022;
+				};
+			};
+		};
+		{
+			algorithm = "ES512";
+			keys = {
+				{ test_keys.ecdsa_521_private_pem, test_keys.ecdsa_521_public_pem };
+				{ test_keys.alt_ecdsa_521_private_pem, test_keys.alt_ecdsa_521_public_pem };
+			};
+			{
+				name = "jwt.io reference";
+				[[eyJhbGciOiJFUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.AbVUinMiT3J_03je8WTOIl-VdggzvoFgnOsdouAs-DLOtQzau9valrq-S6pETyi9Q18HH-EuwX49Q7m3KC0GuNBJAc9Tksulgsdq8GqwIqZqDKmG7hNmDzaQG1Dpdezn2qzv-otf3ZZe-qNOXUMRImGekfQFIuH_MjD2e8RZyww6lbZk]];
+				{     -- payload
+					sub = "1234567890";
+					name = "John Doe";
+					admin = true;
+					iat = 1516239022;
+				};
+			};
+		};
+		{
+			algorithm = "RS256";
+			keys = {
+				{ test_keys.rsa_private_pem, test_keys.rsa_public_pem };
+				{ test_keys.alt_rsa_private_pem, test_keys.alt_rsa_public_pem };
+			};
+			{
+				name = "jwt.io reference";
+				[[eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACwyXPGffz5aXHc6lty1Y2t4SWRqGteragsVdZufDn5BlnJl9pdR_kdVFUsra2rWKEofkZeIC4yWytE58sMIihvo9H1ScmmVwBcQP6XETqYd0aSHp1gOa9RdUPDvoXQ5oqygTqVtxaDr6wUFKrKItgBMzWIdNZ6y7O9E0DhEPTbE9rfBo6KTFsHAZnMg4k68CDp2woYIaXbmYTWcvbzIuHO7_37GT79XdIwkm95QJ7hYC9RiwrV7mesbY4PAahERJawntho0my942XheVLmGwLMBkQ]];
+				{     -- payload
+					sub = "1234567890";
+					name = "John Doe";
+					admin = true;
+					iat = 1516239022;
+				};
+			};
+		};
+		{
+			algorithm = "RS384";
+			keys = {
+				{ test_keys.rsa_private_pem, test_keys.rsa_public_pem };
+				{ test_keys.alt_rsa_private_pem, test_keys.alt_rsa_public_pem };
+			};
+
+			jwt_reference_token [[eyJhbGciOiJSUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.o1hC1xYbJolSyh0-bOY230w22zEQSk5TiBfc-OCvtpI2JtYlW-23-8B48NpATozzMHn0j3rE0xVUldxShzy0xeJ7vYAccVXu2Gs9rnTVqouc-UZu_wJHkZiKBL67j8_61L6SXswzPAQu4kVDwAefGf5hyYBUM-80vYZwWPEpLI8K4yCBsF6I9N1yQaZAJmkMp_Iw371Menae4Mp4JusvBJS-s6LrmG2QbiZaFaxVJiW8KlUkWyUCns8-qFl5OMeYlgGFsyvvSHvXCzQrsEXqyCdS4tQJd73ayYA4SPtCb9clz76N1zE5WsV4Z0BYrxeb77oA7jJhh994RAPzCG0hmQ]];
+		};
+		{
+			algorithm = "RS512";
+			keys = {
+				{ test_keys.rsa_private_pem, test_keys.rsa_public_pem };
+				{ test_keys.alt_rsa_private_pem, test_keys.alt_rsa_public_pem };
+			};
+
+			jwt_reference_token [[eyJhbGciOiJSUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.jYW04zLDHfR1v7xdrW3lCGZrMIsVe0vWCfVkN2DRns2c3MN-mcp_-RE6TN9umSBYoNV-mnb31wFf8iun3fB6aDS6m_OXAiURVEKrPFNGlR38JSHUtsFzqTOj-wFrJZN4RwvZnNGSMvK3wzzUriZqmiNLsG8lktlEn6KA4kYVaM61_NpmPHWAjGExWv7cjHYupcjMSmR8uMTwN5UuAwgW6FRstCJEfoxwb0WKiyoaSlDuIiHZJ0cyGhhEmmAPiCwtPAwGeaL1yZMcp0p82cpTQ5Qb-7CtRov3N4DcOHgWYk6LomPR5j5cCkePAz87duqyzSMpCB0mCOuE3CU2VMtGeQ]];
+		};
+		{
+			algorithm = "PS256";
+			keys = {
+				{ test_keys.rsa_private_pem, test_keys.rsa_public_pem };
+				{ test_keys.alt_rsa_private_pem, test_keys.alt_rsa_public_pem };
+			};
+
+			jwt_reference_token [[eyJhbGciOiJQUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.iOeNU4dAFFeBwNj6qdhdvm-IvDQrTa6R22lQVJVuWJxorJfeQww5Nwsra0PjaOYhAMj9jNMO5YLmud8U7iQ5gJK2zYyepeSuXhfSi8yjFZfRiSkelqSkU19I-Ja8aQBDbqXf2SAWA8mHF8VS3F08rgEaLCyv98fLLH4vSvsJGf6ueZSLKDVXz24rZRXGWtYYk_OYYTVgR1cg0BLCsuCvqZvHleImJKiWmtS0-CymMO4MMjCy_FIl6I56NqLE9C87tUVpo1mT-kbg5cHDD8I7MjCW5Iii5dethB4Vid3mZ6emKjVYgXrtkOQ-JyGMh6fnQxEFN1ft33GX2eRHluK9eg]];
+		};
+		{
+			algorithm = "PS384";
+			keys = {
+				{ test_keys.rsa_private_pem, test_keys.rsa_public_pem };
+				{ test_keys.alt_rsa_private_pem, test_keys.alt_rsa_public_pem };
+			};
+
+			jwt_reference_token [[eyJhbGciOiJQUzM4NCIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.Lfe_aCQme_gQpUk9-6l9qesu0QYZtfdzfy08w8uqqPH_gnw-IVyQwyGLBHPFBJHMbifdSMxPjJjkCD0laIclhnBhowILu6k66_5Y2z78GHg8YjKocAvB-wSUiBhuV6hXVxE5emSjhfVz2OwiCk2bfk2hziRpkdMvfcITkCx9dmxHU6qcEIsTTHuH020UcGayB1-IoimnjTdCsV1y4CMr_ECDjBrqMdnontkqKRIM1dtmgYFsJM6xm7ewi_ksG_qZHhaoBkxQ9wq9OVQRGiSZYowCp73d2BF3jYMhdmv2JiaUz5jRvv6lVU7Quq6ylVAlSPxeov9voYHO1mgZFCY1kQ]];
+		};
+		{
+			algorithm = "PS512";
+			keys = {
+				{ test_keys.rsa_private_pem, test_keys.rsa_public_pem };
+				{ test_keys.alt_rsa_private_pem, test_keys.alt_rsa_public_pem };
+			};
+
+			jwt_reference_token [[eyJhbGciOiJQUzUxMiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.J5W09-rNx0pt5_HBiydR-vOluS6oD-RpYNa8PVWwMcBDQSXiw6-EPW8iSsalXPspGj3ouQjAnOP_4-zrlUUlvUIt2T79XyNeiKuooyIFvka3Y5NnGiOUBHWvWcWp4RcQFMBrZkHtJM23sB5D7Wxjx0-HFeNk-Y3UJgeJVhg5NaWXypLkC4y0ADrUBfGAxhvGdRdULZivfvzuVtv6AzW6NRuEE6DM9xpoWX_4here-yvLS2YPiBTZ8xbB3axdM99LhES-n52lVkiX5AWg2JJkEROZzLMpaacA_xlbUz_zbIaOaoqk8gB5oO7kI6sZej3QAdGigQy-hXiRnW_L98d4GQ]];
+		};
+	};
+
+	local function do_verify_test(algorithm, verifying_key, token, expect_payload)
+		local verify = jwt.new_verifier(algorithm, verifying_key);
+
+		assert.is_string(token);
+		local result = {verify(token)};
+		if expect_payload then
+			assert.same({
+				true; -- success
+				expect_payload; -- payload
+			}, result);
+		else
+			assert.same({
+				false;
+				"signature-mismatch";
+			}, result);
+		end
+	end
+
+	local function do_sign_verify_test(algorithm, signing_key, verifying_key, expect_success, expect_token)
+		local sign = jwt.new_signer(algorithm, signing_key);
+
+		local test_payload = {
+			sub = "1234567890";
+			name = "John Doe";
+			admin = true;
+			iat = 1516239022;
+		};
+
+		local token = sign(test_payload);
+
+		if expect_token then
+			assert.equal(expect_token, token);
+		end
+
+		do_verify_test(algorithm, verifying_key, token, expect_success and test_payload or false);
+	end
+
+
+	for _, algorithm_tests in ipairs(test_cases) do
+		local algorithm = algorithm_tests.algorithm;
+		local keypairs = algorithm_tests.keys;
+
+		untested_algorithms:remove(algorithm);
+
+		describe(algorithm, function ()
+			describe("can do basic sign and verify", function ()
+				for keypair_n, keypair in ipairs(keypairs) do
+					local signing_key, verifying_key = keypair[1], keypair[2];
+					it(("(test key pair %d)"):format(keypair_n), function ()
+						do_sign_verify_test(algorithm, signing_key, verifying_key, true);
+					end);
+				end
+			end);
+
+			if #keypairs >= 2 then
+				it("rejects invalid tokens", function ()
+					do_sign_verify_test(algorithm, keypairs[1][1], keypairs[2][2], false);
+				end);
+			else
+				pending("rejects invalid tokens", function ()
+					error("Needs at least 2 key pairs");
+				end);
+			end
+
+			if #algorithm_tests > 0 then
+				for test_n, test_case in ipairs(algorithm_tests) do
+					it("can verify "..(test_case.name or (("test case %d"):format(test_n))), function ()
+						do_verify_test(
+							algorithm,
+							test_case.verifying_key or keypairs[1][2],
+							test_case[1],
+							test_case[2]
+						);
+					end);
+				end
+			else
+				pending("can verify reference tokens", function ()
+					error("No test tokens provided");
+				end);
+			end
+		end);
+	end
+
+	for algorithm in untested_algorithms do
+		pending(algorithm.." tests", function () end);
+	end
 end);
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_paseto_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,292 @@
+-- Ignore long lines in this file
+--luacheck: ignore 631
+
+describe("util.paseto", function ()
+	local paseto = require "util.paseto";
+	local json = require "util.json";
+	local hex = require "util.hex";
+
+	describe("v3.local", function ()
+		local function parse_test_cases(json_test_cases)
+			local input_cases = json.decode(json_test_cases);
+			local output_cases = {};
+			for _, case in ipairs(input_cases) do
+				assert.is_string(case.name, "Bad test case: expected name");
+				assert.is_nil(output_cases[case.name], "Bad test case: duplicate name");
+				output_cases[case.name] = function ()
+					local key = hex.decode(case.key);
+					local payload, err = paseto.v3_local.decrypt(case.token, key, case.footer, case["implicit-assertion"]);
+					if case["expect-fail"] then
+						assert.is_nil(payload);
+					else
+						assert.is_nil(err);
+						assert.same(json.decode(case.payload), payload);
+					end
+				end;
+			end
+			return output_cases;
+		end
+
+		local test_cases = parse_test_cases [=[[
+			    {
+			      "name": "3-E-1",
+			      "expect-fail": false,
+			      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+			      "nonce": "0000000000000000000000000000000000000000000000000000000000000000",
+			      "token": "v3.local.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADbfcIURX_0pVZVU1mAESUzrKZAsRm2EsD6yBoZYn6cpVZNzSJOhSDN-sRaWjfLU-yn9OJH1J_B8GKtOQ9gSQlb8yk9Iza7teRdkiR89ZFyvPPsVjjFiepFUVcMa-LP18zV77f_crJrVXWa5PDNRkCSeHfBBeg",
+			      "payload": "{\"data\":\"this is a secret message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+			      "footer": "",
+			      "implicit-assertion": ""
+			    },
+			    {
+			      "name": "3-E-2",
+			      "expect-fail": false,
+			      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+			      "nonce": "0000000000000000000000000000000000000000000000000000000000000000",
+			      "token": "v3.local.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADbfcIURX_0pVZVU1mAESUzrKZAqhWxBMDgyBoZYn6cpVZNzSJOhSDN-sRaWjfLU-yn9OJH1J_B8GKtOQ9gSQlb8yk9IzZfaZpReVpHlDSwfuygx1riVXYVs-UjcrG_apl9oz3jCVmmJbRuKn5ZfD8mHz2db0A",
+			      "payload": "{\"data\":\"this is a hidden message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+			      "footer": "",
+			      "implicit-assertion": ""
+			    },
+			    {
+			      "name": "3-E-3",
+			      "expect-fail": false,
+			      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+			      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+			      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0ROIIykcrGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJlxnt5xyhQjFJomwnt7WW_7r2VT0G704ifult011-TgLCyQ2X8imQhniG_hAQ4BydM",
+			      "payload": "{\"data\":\"this is a secret message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+			      "footer": "",
+			      "implicit-assertion": ""
+			    },
+			    {
+			      "name": "3-E-4",
+			      "expect-fail": false,
+			      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+			      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+			      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0X-4P3EcxGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJlBZa_gOpVj4gv0M9lV6Pwjp8JS_MmaZaTA1LLTULXybOBZ2S4xMbYqYmDRhh3IgEk",
+			      "payload": "{\"data\":\"this is a hidden message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+			      "footer": "",
+			      "implicit-assertion": ""
+			    },
+			    {
+			      "name": "3-E-5",
+			      "expect-fail": false,
+			      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+			      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+			      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0ROIIykcrGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJlkYSIbXOgVuIQL65UMdW9WcjOpmqvjqD40NNzed-XPqn1T3w-bJvitYpUJL_rmihc.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+			      "payload": "{\"data\":\"this is a secret message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+			      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+			      "implicit-assertion": ""
+			    },
+			    {
+			      "name": "3-E-6",
+			      "expect-fail": false,
+			      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+			      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+			      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0X-4P3EcxGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJmSeEMphEWHiwtDKJftg41O1F8Hat-8kQ82ZIAMFqkx9q5VkWlxZke9ZzMBbb3Znfo.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+			      "payload": "{\"data\":\"this is a hidden message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+			      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+			      "implicit-assertion": ""
+			    },
+			    {
+			      "name": "3-E-7",
+			      "expect-fail": false,
+			      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+			      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+			      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0ROIIykcrGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJkzWACWAIoVa0bz7EWSBoTEnS8MvGBYHHo6t6mJunPrFR9JKXFCc0obwz5N-pxFLOc.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+			      "payload": "{\"data\":\"this is a secret message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+			      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+			      "implicit-assertion": "{\"test-vector\":\"3-E-7\"}"
+			    },
+			    {
+			      "name": "3-E-8",
+			      "expect-fail": false,
+			      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+			      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+			      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0X-4P3EcxGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJmZHSSKYR6AnPYJV6gpHtx6dLakIG_AOPhu8vKexNyrv5_1qoom6_NaPGecoiz6fR8.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+			      "payload": "{\"data\":\"this is a hidden message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+			      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+			      "implicit-assertion": "{\"test-vector\":\"3-E-8\"}"
+			    },
+			    {
+			      "name": "3-E-9",
+			      "expect-fail": false,
+			      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+			      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+			      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0X-4P3EcxGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJlk1nli0_wijTH_vCuRwckEDc82QWK8-lG2fT9wQF271sgbVRVPjm0LwMQZkvvamqU.YXJiaXRyYXJ5LXN0cmluZy10aGF0LWlzbid0LWpzb24",
+			      "payload": "{\"data\":\"this is a hidden message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+			      "footer": "arbitrary-string-that-isn't-json",
+			      "implicit-assertion": "{\"test-vector\":\"3-E-9\"}"
+			    },
+			    {
+			      "name": "3-F-3",
+			      "expect-fail": true,
+			      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+			      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+			      "token": "v4.local.1JgN1UG8TFAYS49qsx8rxlwh-9E4ONUm3slJXYi5EibmzxpF0Q-du6gakjuyKCBX8TvnSLOKqCPu8Yh3WSa5yJWigPy33z9XZTJF2HQ9wlLDPtVn_Mu1pPxkTU50ZaBKblJBufRA.YXJiaXRyYXJ5LXN0cmluZy10aGF0LWlzbid0LWpzb24",
+			      "payload": null,
+			      "footer": "arbitrary-string-that-isn't-json",
+			      "implicit-assertion": "{\"test-vector\":\"3-F-3\"}"
+			    },
+			    {
+			      "name": "3-F-4",
+			      "expect-fail": true,
+			      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+			      "nonce": "0000000000000000000000000000000000000000000000000000000000000000",
+			      "token": "v3.local.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADbfcIURX_0pVZVU1mAESUzrKZAsRm2EsD6yBoZYn6cpVZNzSJOhSDN-sRaWjfLU-yn9OJH1J_B8GKtOQ9gSQlb8yk9Iza7teRdkiR89ZFyvPPsVjjFiepFUVcMa-LP18zV77f_crJrVXWa5PDNRkCSeHfBBeh",
+			      "payload": null,
+			      "footer": "",
+			      "implicit-assertion": ""
+			    },
+			    {
+			      "name": "3-F-5",
+			      "expect-fail": true,
+			      "nonce": "26f7553354482a1d91d4784627854b8da6b8042a7966523c2b404e8dbbe7f7f2",
+			      "key": "707172737475767778797a7b7c7d7e7f808182838485868788898a8b8c8d8e8f",
+			      "token": "v3.local.JvdVM1RIKh2R1HhGJ4VLjaa4BCp5ZlI8K0BOjbvn9_LwY78vQnDait-Q-sjhF88dG2B0ROIIykcrGHn8wzPbTrqObHhyoKpjy3cwZQzLdiwRsdEK5SDvl02_HjWKJW2oqGMOQJlkYSIbXOgVuIQL65UMdW9WcjOpmqvjqD40NNzed-XPqn1T3w-bJvitYpUJL_rmihc=.eyJraWQiOiJVYmtLOFk2aXY0R1poRnA2VHgzSVdMV0xmTlhTRXZKY2RUM3pkUjY1WVp4byJ9",
+			      "payload": null,
+			      "footer": "{\"kid\":\"UbkK8Y6iv4GZhFp6Tx3IWLWLfNXSEvJcdT3zdR65YZxo\"}",
+			      "implicit-assertion": ""
+			}
+			]]=];
+		for name, test in pairs(test_cases) do
+			it("test case "..name, test);
+		end
+
+		describe("basic sign/verify", function ()
+			local key = paseto.v3_local.new_key();
+			local sign, verify = paseto.v3_local.init(key);
+
+			--luacheck: ignore 211/sign2
+			local key2 = paseto.v3_local.new_key();
+			local sign2, verify2 = paseto.v3_local.init(key2);
+
+			it("works", function ()
+				local payload = { foo = "hello world", b = { 1, 2, 3 } };
+
+				local tok = sign(payload);
+				assert.same(payload, verify(tok));
+				assert.is_nil(verify2(tok));
+			end);
+
+			it("rejects tokens if implicit assertion fails", function ()
+				local payload = { foo = "hello world", b = { 1, 2, 3 } };
+				local tok = sign(payload, nil, "my-custom-assertion");
+				assert.is_nil(verify(tok, nil, "my-incorrect-assertion"));
+				assert.is_nil(verify(tok, nil, nil));
+				assert.same(payload, verify(tok, nil, "my-custom-assertion"));
+			end);
+		end);
+	end);
+
+	describe("v4.public", function ()
+		local function parse_test_cases(json_test_cases)
+			local input_cases = json.decode(json_test_cases);
+			local output_cases = {};
+			for _, case in ipairs(input_cases) do
+				assert.is_string(case.name, "Bad test case: expected name");
+				assert.is_nil(output_cases[case.name], "Bad test case: duplicate name");
+				output_cases[case.name] = function ()
+					local verify_key = paseto.v4_public.import_public_key(case["public-key-pem"]);
+					local payload, err = paseto.v4_public.verify(case.token, verify_key, case.footer, case["implicit-assertion"]);
+					if case["expect-fail"] then
+						assert.is_nil(payload);
+					else
+						assert.is_nil(err);
+						assert.same(json.decode(case.payload), payload);
+					end
+				end;
+			end
+			return output_cases;
+		end
+
+		local test_cases = parse_test_cases [=[[
+			{
+			"name": "4-S-1",
+			"expect-fail": false,
+			"public-key": "1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+			"secret-key": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+			"secret-key-seed": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a3774",
+			"secret-key-pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILTL+0PfTOIQcn2VPkpxMwf6Gbt9n4UEFDjZ4RuUKjd0\n-----END PRIVATE KEY-----",
+			"public-key-pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAHrnbu7wEfAP9cGBOAHHwmH4Wsot1ciXBHwBBXQ4gsaI=\n-----END PUBLIC KEY-----",
+			"token": "v4.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAyMi0wMS0wMVQwMDowMDowMCswMDowMCJ9bg_XBBzds8lTZShVlwwKSgeKpLT3yukTw6JUz3W4h_ExsQV-P0V54zemZDcAxFaSeef1QlXEFtkqxT1ciiQEDA",
+			"payload": "{\"data\":\"this is a signed message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+			"footer": "",
+			"implicit-assertion": ""
+			},
+			{
+			"name": "4-S-2",
+			"expect-fail": false,
+			"public-key": "1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+			"secret-key": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+			"secret-key-seed": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a3774",
+			"secret-key-pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILTL+0PfTOIQcn2VPkpxMwf6Gbt9n4UEFDjZ4RuUKjd0\n-----END PRIVATE KEY-----",
+			"public-key-pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAHrnbu7wEfAP9cGBOAHHwmH4Wsot1ciXBHwBBXQ4gsaI=\n-----END PUBLIC KEY-----",
+			"token": "v4.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAyMi0wMS0wMVQwMDowMDowMCswMDowMCJ9v3Jt8mx_TdM2ceTGoqwrh4yDFn0XsHvvV_D0DtwQxVrJEBMl0F2caAdgnpKlt4p7xBnx1HcO-SPo8FPp214HDw.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+			"payload": "{\"data\":\"this is a signed message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+			"footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+			"implicit-assertion": ""
+			},
+			{
+			"name": "4-S-3",
+			"expect-fail": false,
+			"public-key": "1eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+			"secret-key": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a37741eb9dbbbbc047c03fd70604e0071f0987e16b28b757225c11f00415d0e20b1a2",
+			"secret-key-seed": "b4cbfb43df4ce210727d953e4a713307fa19bb7d9f85041438d9e11b942a3774",
+			"secret-key-pem": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEILTL+0PfTOIQcn2VPkpxMwf6Gbt9n4UEFDjZ4RuUKjd0\n-----END PRIVATE KEY-----",
+			"public-key-pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAHrnbu7wEfAP9cGBOAHHwmH4Wsot1ciXBHwBBXQ4gsaI=\n-----END PUBLIC KEY-----",
+			"token": "v4.public.eyJkYXRhIjoidGhpcyBpcyBhIHNpZ25lZCBtZXNzYWdlIiwiZXhwIjoiMjAyMi0wMS0wMVQwMDowMDowMCswMDowMCJ9NPWciuD3d0o5eXJXG5pJy-DiVEoyPYWs1YSTwWHNJq6DZD3je5gf-0M4JR9ipdUSJbIovzmBECeaWmaqcaP0DQ.eyJraWQiOiJ6VmhNaVBCUDlmUmYyc25FY1Q3Z0ZUaW9lQTlDT2NOeTlEZmdMMVc2MGhhTiJ9",
+			"payload": "{\"data\":\"this is a signed message\",\"exp\":\"2022-01-01T00:00:00+00:00\"}",
+			"footer": "{\"kid\":\"zVhMiPBP9fRf2snEcT7gFTioeA9COcNy9DfgL1W60haN\"}",
+			"implicit-assertion": "{\"test-vector\":\"4-S-3\"}"
+			}]]=];
+		for name, test in pairs(test_cases) do
+			it("test case "..name, test);
+		end
+
+		describe("basic sign/verify", function ()
+			local function new_keypair()
+				local kp = paseto.v4_public.new_keypair();
+				return kp:private_pem(), kp:public_pem();
+			end
+
+			local privkey1, pubkey1 = new_keypair();
+			local privkey2, pubkey2 = new_keypair();
+			local sign1, verify1 = paseto.v4_public.init(privkey1, pubkey1);
+			local sign2, verify2 = paseto.v4_public.init(privkey2, pubkey2);
+
+			it("works", function ()
+				local payload = { foo = "hello world", b = { 1, 2, 3 } };
+
+				local tok1 = sign1(payload);
+				assert.same(payload, verify1(tok1));
+				assert.is_nil(verify2(tok1));
+
+				local tok2 = sign2(payload);
+				assert.same(payload, verify2(tok2));
+				assert.is_nil(verify1(tok2));
+			end);
+
+			it("rejects tokens if implicit assertion fails", function ()
+				local payload = { foo = "hello world", b = { 1, 2, 3 } };
+				local tok = sign1(payload, nil, "my-custom-assertion");
+				assert.is_nil(verify1(tok, nil, "my-incorrect-assertion"));
+				assert.is_nil(verify1(tok, nil, nil));
+				assert.same(payload, verify1(tok, nil, "my-custom-assertion"));
+			end);
+		end);
+	end);
+
+	describe("pae", function ()
+		it("encodes correctly", function ()
+			-- These test cases are taken from the PASETO docs
+			-- https://github.com/paseto-standard/paseto-spec/blob/master/docs/01-Protocol-Versions/Common.md
+			assert.equal("\x00\x00\x00\x00\x00\x00\x00\x00", paseto.pae{});
+			assert.equal("\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00", paseto.pae{''});
+			assert.equal("\x01\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00test", paseto.pae{'test'});
+			assert.has_errors(function ()
+				paseto.pae("test");
+			end);
+		end);
+	end);
+end);
--- a/spec/util_poll_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_poll_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,35 @@
-describe("util.poll", function ()
-	it("loads", function ()
-		require "util.poll"
+describe("util.poll", function()
+	local poll;
+	setup(function()
+		poll = require "util.poll";
+	end);
+	it("loads", function()
+		assert.is_table(poll);
+		assert.is_function(poll.new);
+		assert.is_string(poll.api);
 	end);
+	describe("new", function()
+		local p;
+		setup(function()
+			p = poll.new();
+		end)
+		it("times out", function ()
+			local fd, err = p:wait(0);
+			assert.falsy(fd);
+			assert.equal("timeout", err);
+		end);
+		it("works", function()
+			-- stdout should be writable, right?
+			assert.truthy(p:add(1, false, true));
+			local fd, r, w = p:wait(1);
+			assert.is_number(fd);
+			assert.is_boolean(r);
+			assert.is_boolean(w);
+			assert.equal(1, fd);
+			assert.falsy(r);
+			assert.truthy(w);
+			assert.truthy(p:del(1));
+		end);
+	end)
 end);
 
--- a/spec/util_promise_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_promise_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,6 +7,11 @@
 			assert(promise.new());
 		end);
 	end);
+	it("supplies a sensible tostring()", function ()
+		local s = tostring(promise.new());
+		assert.truthy(s:find("promise", 1, true));
+		assert.truthy(s:find("pending", 1, true));
+	end);
 	it("notifies immediately for fulfilled promises", function ()
 		local p = promise.new(function (resolve)
 			resolve("foo");
@@ -30,6 +35,27 @@
 		r("foo");
 		assert.spy(cb).was_called(1);
 	end);
+	it("ignores resolve/reject of settled promises", function ()
+		local res, rej;
+		local p = promise.new(function (resolve, reject)
+			res, rej = resolve, reject;
+		end);
+		local cb = spy.new(function (v)
+			assert.equal("foo", v);
+		end);
+		p:next(cb, cb);
+		assert.spy(cb).was_called(0);
+		res("foo");
+		assert.spy(cb).was_called(1);
+		rej("bar");
+		assert.spy(cb).was_called(1);
+		rej(promise.resolve("bar"));
+		assert.spy(cb).was_called(1);
+		res(promise.reject("bar"));
+		assert.spy(cb).was_called(1);
+		res(promise.resolve("bar"));
+		assert.spy(cb).was_called(1);
+	end);
 	it("allows chaining :next() calls", function ()
 		local r;
 		local result;
@@ -438,6 +464,26 @@
 				{ status = "rejected", reason = "this fails" };
 			}, result);
 		end);
+		it("works when all promises reject", function ()
+			local r1, r2;
+			local p1, p2 = promise.new(function (_, reject) r1 = reject end), promise.new(function (_, reject) r2 = reject end);
+			local p = promise.all_settled({ p1, p2 });
+
+			local result;
+			local cb = spy.new(function (v)
+				result = v;
+			end);
+			p:next(cb);
+			assert.spy(cb).was_called(0);
+			r2("this fails");
+			assert.spy(cb).was_called(0);
+			r1("this fails too");
+			assert.spy(cb).was_called(1);
+			assert.same({
+				{ status = "rejected", reason = "this fails too" };
+				{ status = "rejected", reason = "this fails" };
+			}, result);
+		end);
 		it("works with non-numeric keys", function ()
 			local r1, r2;
 			local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (resolve) r2 = resolve end);
--- a/spec/util_rfc6724_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,97 +0,0 @@
-
-local rfc6724 = require "util.rfc6724";
-local new_ip = require"util.ip".new_ip;
-
-describe("util.rfc6724", function()
-	describe("#source()", function()
-		it("should work", function()
-			assert.are.equal(rfc6724.source(new_ip("2001:db8:1::1", "IPv6"),
-					{new_ip("2001:db8:3::1", "IPv6"), new_ip("fe80::1", "IPv6")}).addr,
-				"2001:db8:3::1",
-				"prefer appropriate scope");
-			assert.are.equal(rfc6724.source(new_ip("ff05::1", "IPv6"),
-					{new_ip("2001:db8:3::1", "IPv6"), new_ip("fe80::1", "IPv6")}).addr,
-				"2001:db8:3::1",
-				"prefer appropriate scope");
-			assert.are.equal(rfc6724.source(new_ip("2001:db8:1::1", "IPv6"),
-					{new_ip("2001:db8:1::1", "IPv6"), new_ip("2001:db8:2::1", "IPv6")}).addr,
-				"2001:db8:1::1",
-				"prefer same address"); -- "2001:db8:1::1" should be marked "deprecated" here, we don't handle that right now
-			assert.are.equal(rfc6724.source(new_ip("fe80::1", "IPv6"),
-					{new_ip("fe80::2", "IPv6"), new_ip("2001:db8:1::1", "IPv6")}).addr,
-				"fe80::2",
-				"prefer appropriate scope"); -- "fe80::2" should be marked "deprecated" here, we don't handle that right now
-			assert.are.equal(rfc6724.source(new_ip("2001:db8:1::1", "IPv6"),
-					{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3::2", "IPv6")}).addr,
-				"2001:db8:1::2",
-				"longest matching prefix");
-		--[[ "2001:db8:1::2" should be a care-of address and "2001:db8:3::2" a home address, we can't handle this and would fail
-			assert.are.equal(rfc6724.source(new_ip("2001:db8:1::1", "IPv6"),
-					{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3::2", "IPv6")}).addr,
-				"2001:db8:3::2",
-				"prefer home address");
-		]]
-			assert.are.equal(rfc6724.source(new_ip("2002:c633:6401::1", "IPv6"),
-					{new_ip("2002:c633:6401::d5e3:7953:13eb:22e8", "IPv6"), new_ip("2001:db8:1::2", "IPv6")}).addr,
-				"2002:c633:6401::d5e3:7953:13eb:22e8",
-				"prefer matching label"); -- "2002:c633:6401::d5e3:7953:13eb:22e8" should be marked "temporary" here, we don't handle that right now
-			assert.are.equal(rfc6724.source(new_ip("2001:db8:1::d5e3:0:0:1", "IPv6"),
-					{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:1::d5e3:7953:13eb:22e8", "IPv6")}).addr,
-				"2001:db8:1::d5e3:7953:13eb:22e8",
-				"prefer temporary address") -- "2001:db8:1::2" should be marked "public" and "2001:db8:1::d5e3:7953:13eb:22e8" should be marked "temporary" here, we don't handle that right now
-		end);
-	end);
-	describe("#destination()", function()
-		it("should work", function()
-			local order;
-			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("198.51.100.121", "IPv4")},
-				{new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::1", "IPv6"), new_ip("169.254.13.78", "IPv4")})
-			assert.are.equal(order[1].addr, "2001:db8:1::1", "prefer matching scope");
-			assert.are.equal(order[2].addr, "198.51.100.121", "prefer matching scope");
-
-			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("198.51.100.121", "IPv4")},
-				{new_ip("fe80::1", "IPv6"), new_ip("198.51.100.117", "IPv4")})
-			assert.are.equal(order[1].addr, "198.51.100.121", "prefer matching scope");
-			assert.are.equal(order[2].addr, "2001:db8:1::1", "prefer matching scope");
-
-			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("10.1.2.3", "IPv4")},
-				{new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::1", "IPv6"), new_ip("10.1.2.4", "IPv4")})
-			assert.are.equal(order[1].addr, "2001:db8:1::1", "prefer higher precedence");
-			assert.are.equal(order[2].addr, "10.1.2.3", "prefer higher precedence");
-
-			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("fe80::1", "IPv6")},
-				{new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::2", "IPv6")})
-			assert.are.equal(order[1].addr, "fe80::1", "prefer smaller scope");
-			assert.are.equal(order[2].addr, "2001:db8:1::1", "prefer smaller scope");
-
-		--[[ "2001:db8:1::2" and "fe80::2" should be marked "care-of address", while "2001:db8:3::1" should be marked "home address", we can't currently handle this and would fail the test
-			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("fe80::1", "IPv6")},
-				{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3::1", "IPv6"), new_ip("fe80::2", "IPv6")})
-			assert.are.equal(order[1].addr, "2001:db8:1::1", "prefer home address");
-			assert.are.equal(order[2].addr, "fe80::1", "prefer home address");
-		]]
-
-		--[[ "fe80::2" should be marked "deprecated", we can't currently handle this and would fail the test
-			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("fe80::1", "IPv6")},
-				{new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::2", "IPv6")})
-			assert.are.equal(order[1].addr, "2001:db8:1::1", "avoid deprecated addresses");
-			assert.are.equal(order[2].addr, "fe80::1", "avoid deprecated addresses");
-		]]
-
-			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("2001:db8:3ffe::1", "IPv6")},
-				{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3f44::2", "IPv6"), new_ip("fe80::2", "IPv6")})
-			assert.are.equal(order[1].addr, "2001:db8:1::1", "longest matching prefix");
-			assert.are.equal(order[2].addr, "2001:db8:3ffe::1", "longest matching prefix");
-
-			order = rfc6724.destination({new_ip("2002:c633:6401::1", "IPv6"), new_ip("2001:db8:1::1", "IPv6")},
-				{new_ip("2002:c633:6401::2", "IPv6"), new_ip("fe80::2", "IPv6")})
-			assert.are.equal(order[1].addr, "2002:c633:6401::1", "prefer matching label");
-			assert.are.equal(order[2].addr, "2001:db8:1::1", "prefer matching label");
-
-			order = rfc6724.destination({new_ip("2002:c633:6401::1", "IPv6"), new_ip("2001:db8:1::1", "IPv6")},
-				{new_ip("2002:c633:6401::2", "IPv6"), new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::2", "IPv6")})
-			assert.are.equal(order[1].addr, "2001:db8:1::1", "prefer higher precedence");
-			assert.are.equal(order[2].addr, "2002:c633:6401::1", "prefer higher precedence");
-		end);
-	end);
-end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_roles_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,134 @@
+describe("util.roles", function ()
+	randomize(false);
+	local roles;
+	it("can be loaded", function ()
+		roles = require "util.roles";
+	end);
+	local test_role;
+	it("can create a new role", function ()
+		test_role = roles.new();
+		assert.is_not_nil(test_role);
+		assert.is_truthy(roles.is_role(test_role));
+	end);
+	describe("role object", function ()
+		it("can be initialized with permissions", function ()
+			local test_role_2 = roles.new({
+				permissions = {
+					perm1 = true;
+					perm2 = false;
+				};
+			});
+			assert.truthy(test_role_2:may("perm1"));
+			assert.falsy(test_role_2:may("perm2"));
+		end);
+		it("has a sensible tostring", function ()
+			local test_role_2 = roles.new({
+				id = "test-role-2";
+				name = "Test Role 2";
+			});
+			assert.truthy(tostring(test_role_2):find(test_role_2.id, 1, true));
+			assert.truthy(tostring(test_role_2):find("Test Role 2", 1, true));
+		end);
+		it("is restrictive by default", function ()
+			assert.falsy(test_role:may("my-permission"));
+		end);
+		it("allows you to set permissions", function ()
+			test_role:set_permission("my-permission", true);
+			assert.truthy(test_role:may("my-permission"));
+		end);
+		it("allows you to set negative permissions", function ()
+			test_role:set_permission("my-other-permission", false);
+			assert.falsy(test_role:may("my-other-permission"));
+		end);
+		it("does not allows you to override previously set permissions by default", function ()
+			local ok, err = test_role:set_permission("my-permission", false);
+			assert.falsy(ok);
+			assert.is_equal("policy-already-exists", err);
+			-- Confirm old permission still in place
+			assert.truthy(test_role:may("my-permission"));
+		end);
+		it("allows you to explicitly override previously set permissions", function ()
+			assert.truthy(test_role:set_permission("my-permission", false, true));
+			assert.falsy(test_role:may("my-permission"));
+		end);
+		describe("inheritance", function ()
+			local child_role;
+			it("works", function ()
+				test_role:set_permission("inherited-permission", true);
+				child_role = roles.new({
+					inherits = { test_role };
+				});
+				assert.truthy(child_role:may("inherited-permission"));
+				assert.falsy(child_role:may("my-permission"));
+			end);
+			it("allows listing policies", function ()
+				local expected = {
+					["my-permission"] = false;
+					["my-other-permission"] = false;
+					["inherited-permission"] = true;
+				};
+				local received = {};
+				for permission_name, permission_policy in child_role:policies() do
+					received[permission_name] = permission_policy;
+				end
+				assert.same(expected, received);
+			end);
+			it("supports multiple depths of inheritance", function ()
+				local grandchild_role = roles.new({
+					inherits = { child_role };
+				});
+				assert.truthy(grandchild_role:may("inherited-permission"));
+			end);
+			describe("supports ordered inheritance from multiple roles", function ()
+				local parent_role = roles.new();
+				local final_role = roles.new({
+					-- Yes, the names are getting confusing.
+					-- btw, test_role is inherited through child_role.
+					inherits = { parent_role, child_role };
+				});
+
+				local test_cases = {
+					-- { <final_role policy>, <parent_role policy>, <test_role policy> }
+					{ true,   nil, false, result = true };
+					{  nil, false,  true, result = false };
+					{  nil,  true, false, result = true };
+					{  nil,  nil,  false, result = false };
+					{  nil,  nil,   true, result = true };
+				};
+
+				for n, test_case in ipairs(test_cases) do
+					it("(case "..n..")", function ()
+						local perm_name = ("multi-inheritance-perm-%d"):format(n);
+						assert.truthy(final_role:set_permission(perm_name, test_case[1]));
+						assert.truthy(parent_role:set_permission(perm_name, test_case[2]));
+						assert.truthy(test_role:set_permission(perm_name, test_case[3]));
+						assert.equal(test_case.result, final_role:may(perm_name));
+					end);
+				end
+			end);
+			it("updates child roles when parent roles change", function ()
+				assert.truthy(child_role:may("inherited-permission"));
+				assert.truthy(test_role:set_permission("inherited-permission", false, true));
+				assert.falsy(child_role:may("inherited-permission"));
+			end);
+		end);
+		describe("cloning", function ()
+			local cloned_role;
+			it("works", function ()
+				assert.truthy(test_role:set_permission("perm-1", true));
+				cloned_role = test_role:clone();
+				assert.truthy(cloned_role:may("perm-1"));
+			end);
+			it("isolates changes", function ()
+				-- After cloning, changes in either the original or the clone
+				-- should not appear in the other.
+				assert.truthy(test_role:set_permission("perm-1", false, true));
+				assert.truthy(test_role:set_permission("perm-2", true));
+				assert.truthy(cloned_role:set_permission("perm-3", true));
+				assert.truthy(cloned_role:may("perm-1"));
+				assert.falsy(cloned_role:may("perm-2"));
+				assert.falsy(test_role:may("perm-3"));
+			end);
+		end);
+	end);
+end);
--- a/spec/util_sasl_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_sasl_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -39,5 +39,37 @@
 
 		-- TODO SCRAM
 	end);
+
+	describe("oauthbearer profile", function()
+		local profile = {
+			oauthbearer = function(_, token, _realm, _authzid)
+				if token == "example-bearer-token" then
+					return "user", true, {};
+				else
+					return nil, nil, {}
+				end
+			end;
+		}
+
+		it("works with OAUTHBEARER", function()
+			local bearer = sasl.new("sasl.test", profile);
+
+			assert.truthy(bearer:select("OAUTHBEARER"));
+			assert.equals("success", bearer:process("n,,\1auth=Bearer example-bearer-token\1\1"));
+			assert.equals("user", bearer.username);
+		end)
+
+
+		it("returns extras with OAUTHBEARER", function()
+			local bearer = sasl.new("sasl.test", profile);
+
+			assert.truthy(bearer:select("OAUTHBEARER"));
+			local status, extra = bearer:process("n,,\1auth=Bearer unknown\1\1");
+			assert.equals("challenge", status);
+			assert.equals("{\"status\":\"invalid_token\"}", extra);
+			assert.equals("failure", bearer:process("\1"));
+		end)
+
+	end)
 end);
 
--- a/spec/util_smqueue_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_smqueue_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -5,6 +5,9 @@
 
 	describe("#new()", function()
 		it("should work", function()
+			assert.has_error(function () smqueue.new(-1) end);
+			assert.has_error(function () smqueue.new(0) end);
+			assert.not_has_error(function () smqueue.new(1) end);
 			local q = smqueue.new(10);
 			assert.truthy(q);
 		end)
--- a/spec/util_stanza_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_stanza_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -314,6 +314,20 @@
 		end)
 	end)
 
+	describe("#add_error()", function ()
+		describe("basics", function ()
+			local s = st.stanza("custom", { xmlns = "urn:example:foo" });
+			local e = s:add_error("cancel", "not-acceptable", "UNACCEPTABLE!!!! ONE MILLION YEARS DUNGEON!")
+				:tag("dungeon", { xmlns = "urn:uuid:c9026187-5b05-4e70-b265-c3b6338a7d0f", period="1000000years"});
+			assert.equal(s, e);
+			local typ, cond, text, extra = e:get_error();
+			assert.equal("cancel", typ);
+			assert.equal("not-acceptable", cond);
+			assert.equal("UNACCEPTABLE!!!! ONE MILLION YEARS DUNGEON!", text);
+			assert.is_nil(extra);
+		end)
+	end)
+
 	describe("should reject #invalid", function ()
 		local invalid_names = {
 			["empty string"] = "", ["characters"] = "<>";
--- a/spec/util_strbitop.lua	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,41 +0,0 @@
-local strbitop = require "util.strbitop";
-describe("util.strbitop", function ()
-	describe("sand()", function ()
-		it("works", function ()
-			assert.equal(string.rep("Aa", 100), strbitop.sand(string.rep("a", 200), "Aa"));
-		end);
-		it("returns empty string if first argument is empty", function ()
-			assert.equal("", strbitop.sand("", ""));
-			assert.equal("", strbitop.sand("", "key"));
-		end);
-		it("returns initial string if key is empty", function ()
-			assert.equal("hello", strbitop.sand("hello", ""));
-		end);
-	end);
-
-	describe("sor()", function ()
-		it("works", function ()
-			assert.equal(string.rep("a", 200), strbitop.sor(string.rep("Aa", 100), "a"));
-		end);
-		it("returns empty string if first argument is empty", function ()
-			assert.equal("", strbitop.sor("", ""));
-			assert.equal("", strbitop.sor("", "key"));
-		end);
-		it("returns initial string if key is empty", function ()
-			assert.equal("hello", strbitop.sor("hello", ""));
-		end);
-	end);
-
-	describe("sxor()", function ()
-		it("works", function ()
-			assert.equal(string.rep("Aa", 100), strbitop.sxor(string.rep("a", 200), " \0"));
-		end);
-		it("returns empty string if first argument is empty", function ()
-			assert.equal("", strbitop.sxor("", ""));
-			assert.equal("", strbitop.sxor("", "key"));
-		end);
-		it("returns initial string if key is empty", function ()
-			assert.equal("hello", strbitop.sxor("hello", ""));
-		end);
-	end);
-end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_strbitop_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,85 @@
+local strbitop = require "util.strbitop";
+describe("util.strbitop", function ()
+	describe("sand()", function ()
+		it("works", function ()
+			assert.equal(string.rep("Aa", 100), strbitop.sand(string.rep("a", 200), "Aa"));
+		end);
+		it("returns empty string if first argument is empty", function ()
+			assert.equal("", strbitop.sand("", ""));
+			assert.equal("", strbitop.sand("", "key"));
+		end);
+		it("returns initial string if key is empty", function ()
+			assert.equal("hello", strbitop.sand("hello", ""));
+		end);
+	end);
+
+	describe("sor()", function ()
+		it("works", function ()
+			assert.equal(string.rep("a", 200), strbitop.sor(string.rep("Aa", 100), "a"));
+		end);
+		it("returns empty string if first argument is empty", function ()
+			assert.equal("", strbitop.sor("", ""));
+			assert.equal("", strbitop.sor("", "key"));
+		end);
+		it("returns initial string if key is empty", function ()
+			assert.equal("hello", strbitop.sor("hello", ""));
+		end);
+	end);
+
+	describe("sxor()", function ()
+		it("works", function ()
+			assert.equal(string.rep("Aa", 100), strbitop.sxor(string.rep("a", 200), " \0"));
+		end);
+		it("returns empty string if first argument is empty", function ()
+			assert.equal("", strbitop.sxor("", ""));
+			assert.equal("", strbitop.sxor("", "key"));
+		end);
+		it("returns initial string if key is empty", function ()
+			assert.equal("hello", strbitop.sxor("hello", ""));
+		end);
+	end);
+
+	describe("common_prefix_bits()", function ()
+		local function B(s)
+			assert(#s%8==0, "Invalid test input: B(s): s should be a multiple of 8 bits in length");
+			local byte = 0;
+			local out_str = {};
+			for i = 1, #s do
+				local bit_ascii = s:byte(i);
+				if bit_ascii == 49 then -- '1'
+					byte = byte + 2^((7-(i-1))%8);
+				elseif bit_ascii ~= 48 then
+					error("Invalid test input: B(s): s should contain only '0' or '1' characters");
+				end
+				if (i-1)%8 == 7 then
+					table.insert(out_str, string.char(byte));
+					byte = 0;
+				end
+			end
+			return table.concat(out_str);
+		end
+
+		local _cpb = strbitop.common_prefix_bits;
+		local function test(a, b)
+			local Ba, Bb = B(a), B(b);
+			local ret1 = _cpb(Ba, Bb);
+			local ret2 = _cpb(Bb, Ba);
+			assert(ret1 == ret2, ("parameter order should not make a difference to the result (%s, %s) = %d, reversed = %d"):format(a, b, ret1, ret2));
+			return ret1;
+		end
+
+		it("works on single bytes", function ()
+			assert.equal(0, test("00000000", "11111111"));
+			assert.equal(1, test("10000000", "11111111"));
+			assert.equal(0, test("01000000", "11111111"));
+			assert.equal(0, test("01000000", "11111111"));
+			assert.equal(8, test("11111111", "11111111"));
+		end);
+
+		it("works on multiple bytes", function ()
+			for i = 0, 16 do
+				assert.equal(i, test(string.rep("1", i)..string.rep("0", 16-i), "1111111111111111"));
+			end
+		end);
+	end);
+end);
--- a/spec/util_table_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_table_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -12,6 +12,48 @@
 			assert.same({ "lorem", "ipsum", "dolor", "sit", "amet", n = 5 }, u_table.pack("lorem", "ipsum", "dolor", "sit", "amet"));
 		end);
 	end);
+
+	describe("move()", function ()
+		it("works", function ()
+			local t1 = { "apple", "banana", "carrot" };
+			local t2 = { "cat", "donkey", "elephant" };
+			local t3 = {};
+			u_table.move(t1, 1, 3, 1, t3);
+			u_table.move(t2, 1, 3, 3, t3);
+			assert.same({ "apple", "banana", "cat", "donkey", "elephant" }, t3);
+		end);
+		it("supports overlapping regions", function ()
+			do
+				local t1 = { "apple", "banana", "carrot", "date", "endive", "fig", "grapefruit" };
+				u_table.move(t1, 1, 3, 3);
+				assert.same({ "apple", "banana", "apple", "banana", "carrot", "fig", "grapefruit" }, t1);
+			end
+
+			do
+				local t1 = { "apple", "banana", "carrot", "date", "endive", "fig", "grapefruit" };
+				u_table.move(t1, 1, 3, 2);
+				assert.same({ "apple", "apple", "banana", "carrot", "endive", "fig", "grapefruit" }, t1);
+			end
+
+			do
+				local t1 = { "apple", "banana", "carrot", "date", "endive", "fig", "grapefruit" };
+				u_table.move(t1, 3, 5, 2);
+				assert.same({ "apple", "carrot", "date", "endive", "endive", "fig", "grapefruit" }, t1);
+			end
+
+			do
+				local t1 = { "apple", "banana", "carrot", "date", "endive", "fig", "grapefruit" };
+				u_table.move(t1, 3, 5, 6);
+				assert.same({ "apple", "banana", "carrot", "date", "endive", "carrot", "date", "endive" }, t1);
+			end
+
+			do
+				local t1 = { "apple", "banana", "carrot", "date", "endive", "fig", "grapefruit" };
+				u_table.move(t1, 3, 1, 3);
+				assert.same({ "apple", "banana", "carrot", "date", "endive", "fig", "grapefruit" }, t1);
+			end
+		end);
+	end);
 end);
 
 
--- a/spec/util_throttle_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_throttle_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,4 +1,5 @@
 
+-- luacheck: ignore 411/a
 
 -- Mock util.time
 local now = 0; -- wibbly-wobbly... timey-wimey... stuff
--- a/spec/util_uuid_spec.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/spec/util_uuid_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -5,7 +5,7 @@
 describe("util.uuid", function()
 	describe("#generate()", function()
 		it("should work follow the UUID pattern", function()
-			-- https://tools.ietf.org/html/rfc4122#section-4.4
+			-- https://www.rfc-editor.org/rfc/rfc4122.html#section-4.4
 
 			local pattern = "^" .. table.concat({
 				string.rep("%x", 8),
@@ -20,6 +20,28 @@
 			for _ = 1, 100 do
 				assert.is_string(uuid.generate():match(pattern));
 			end
+
+			assert.truthy(uuid.generate() ~= uuid.generate(), "does not generate the same UUIDv4 twice")
+		end);
+	end);
+	describe("#v7", function()
+		it("should also follow the UUID pattern", function()
+			local pattern = "^" .. table.concat({
+					string.rep("%x", 8),
+					string.rep("%x", 4),
+					"7" .. -- version
+					string.rep("%x", 3),
+					"[89ab]" .. -- reserved bits of 1 and 0
+					string.rep("%x", 3),
+					string.rep("%x", 12),
+				}, "%-") .. "$";
+
+			local one = uuid.v7(); -- one before the loop to ensure some time passes
+			for _ = 1, 100 do
+				assert.is_string(uuid.v7():match(pattern));
+			end
+			-- one after the loop when some time should have passed
+			assert.truthy(one < uuid.v7(), "should be ordererd")
 		end);
 	end);
 end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_xtemplate_spec.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,43 @@
+local st = require "prosody.util.stanza";
+local xtemplate = require "prosody.util.xtemplate";
+
+describe("util.xtemplate", function ()
+	describe("render()", function ()
+		it("works", function ()
+			assert.same("Hello", xtemplate.render("{greeting}", st.stanza("root"):text_tag("greeting", "Hello")), "regular text content")
+			assert.same("Hello", xtemplate.render("{#}", st.stanza("root"):text("Hello")), "top tag text content")
+			assert.same("Hello", xtemplate.render("{greeting/@en}", st.stanza("root"):tag("greeting", { en = "Hello" })), "attribute")
+		end)
+		it("supports conditionals", function ()
+			local atom_tmpl = "{@pubsub:title|and{*{@pubsub:title}*\n\n}}{summary|or{{author/name|and{{author/name} posted }}{title}}}";
+			local atom_data = st.stanza("entry", { xmlns = "http://www.w3.org/2005/Atom" });
+			assert.same("", xtemplate.render(atom_tmpl, atom_data));
+
+			atom_data:text_tag("title", "an Entry")
+			assert.same("an Entry", xtemplate.render(atom_tmpl, atom_data));
+
+			atom_data:tag("author"):text_tag("name","Juliet"):up();
+			assert.same("Juliet posted an Entry", xtemplate.render(atom_tmpl, atom_data));
+
+			atom_data:text_tag("summary", "Juliet just posted a new entry");
+			assert.same("Juliet just posted a new entry", xtemplate.render(atom_tmpl, atom_data));
+
+			atom_data.attr["xmlns:pubsub"] = "http://jabber.org/protocol/pubsub";
+			atom_data.attr["pubsub:title"] = "Juliets musings";
+			assert.same("*Juliets musings*\n\nJuliet just posted a new entry", xtemplate.render(atom_tmpl, atom_data));
+		end)
+		it("can strip surrounding whitespace", function ()
+			assert.same("Hello ", xtemplate.render(" {-greeting} ", st.stanza("root"):text_tag("greeting", "Hello")))
+			assert.same(" Hello", xtemplate.render(" {greeting-} ", st.stanza("root"):text_tag("greeting", "Hello")))
+			assert.same("Hello", xtemplate.render(" {-greeting-} ", st.stanza("root"):text_tag("greeting", "Hello")))
+		end)
+		describe("each", function ()
+			it("makes sense", function ()
+				local x = st.stanza("root"):tag("foo"):tag("bar")
+				for i = 1, 5 do x:text_tag("i", tostring(i)); end
+				x:reset();
+				assert.same("12345", xtemplate.render("{foo/bar|each(i){{#}}}", x));
+			end)
+		end)
+	end)
+end)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/README.md	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,48 @@
+# Teal definitions and sources
+
+This directory contains files written in the
+[Teal](https://github.com/teal-language/tl) language, a typed dialect of
+Lua.  There are two kinds of files, `.tl` Teal source code and `.d.tl`
+type definitions files for modules written in Lua. The later allows
+writing type-aware Teal using regular Lua or C code.
+
+## Setup
+
+The Teal compiler can be installed from LuaRocks using:
+
+```bash
+luarocks install tl
+```
+
+## Checking types
+
+```bash
+tl check teal-src/prosody/util/example.tl
+```
+
+Some editors and IDEs also have support, see [text editor
+support](https://github.com/teal-language/tl#text-editor-support)
+
+
+## Compiling to Lua
+
+`GNUmakefile` contains a rule for building Lua files from Teal sources.
+It also applies [LuaFormat](https://github.com/Koihik/LuaFormatter) to
+make the resulting code more readable, albeit this makes the line
+numbers no longer match the original Teal source.  Sometimes minor
+`luacheck` issues remain, such as types being represented as unused
+tables, which can be removed.
+
+```bash
+sensible-editor teal-src/prosody/util/example.tl
+# Write some code, remember to run tl check
+make util/example.lua
+sensible-editor util/example.lua
+# Apply any minor tweaks that may be needed
+```
+
+## Files of note
+
+`module.d.tl`
+:	Describes the module environment.
+
--- a/teal-src/module.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ b/teal-src/module.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -1,4 +1,4 @@
-local st = require"util.stanza"
+local st = require "prosody.util.stanza"
 
 global record moduleapi
 	get_name : function (moduleapi) : string
@@ -34,8 +34,11 @@
 	get_option : config_getter<any>
 	get_option_scalar : config_getter<nil | boolean | number | string>
 	get_option_string : config_getter<string>
-	get_option_number : config_getter<number>
+	get_option_number : function (moduleapi, string, number, number, number) : number
+	get_option_integer : function (moduleapi, string, integer, integer, integer) : integer
 	get_option_boolean : config_getter<boolean>
+	get_option_enum : function<A> (moduleapi, string, ... : A) : A
+	get_option_period : function (moduleapi, string|number, string|number, string|number, string|number) : number
 	record util_array
 		-- TODO import def
 		{ any }
@@ -62,7 +65,12 @@
 	send_iq : function (moduleapi, st.stanza_t, util_session, number)
 	broadcast : function (moduleapi, { string }, st.stanza_t, function)
 	type timer_callback = function (number, ... : any) : number
-	add_timer : function (moduleapi, number, timer_callback, ... : any)
+	record timer_wrapper
+		stop : function (timer_wrapper)
+		disarm : function (timer_wrapper)
+		reschedule : function (timer_wrapper, number)
+	end
+	add_timer : function (moduleapi, number, timer_callback, ... : any) : timer_wrapper
 	get_directory : function (moduleapi) : string
 	enum file_mode
 		"r" "w" "a" "r+" "w+" "a+"
@@ -70,6 +78,7 @@
 	load_resource : function (moduleapi, string, file_mode) : FILE
 	enum store_type
 		"keyval"
+		"keyval+"
 		"map"
 		"archive"
 	end
@@ -121,12 +130,20 @@
 	path : string
 	resource_path : string
 
+	-- access control
+	may : function (moduleapi, string, table|string)
+	default_permission : function (string, string)
+	default_permissions : function (string, { string })
+
 	-- methods the module can add
 	load : function ()
 	add_host : function (moduleapi)
 	save : function () : any
 	restore : function (any)
 	unload : function ()
+
+	-- added by mod_http
+	http_url : function (moduleapi, string, string, string) : string
 end
 
 global module : moduleapi
--- a/teal-src/plugins/mod_cron.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,106 +0,0 @@
-module:set_global();
-
-local async = require "util.async";
-local datetime = require "util.datetime";
-
-local record map_store<K,V>
-	-- TODO move to somewhere sensible
-	get : function (map_store<K,V>, string, K) : V
-	set : function (map_store<K,V>, string, K, V)
-end
-
-local enum frequency
-	"hourly"
-	"daily"
-	"weekly"
-end
-
-local record task_spec
-	id : string -- unique id
-	name : string -- name or short description
-	when : frequency
-	last : integer
-	run : function (task_spec, integer)
-	save : function (task_spec, integer)
-end
-
-local record task_event
-	source : module
-	item : task_spec
-end
-
-local periods : { frequency : integer } = { hourly = 3600, daily = 86400, weekly = 7*86400 }
-
-local active_hosts : { string : boolean } = {  }
-
-function module.add_host(host_module : moduleapi)
-
-	local last_run_times = host_module:open_store("cron", "map") as map_store<string,integer>;
-	active_hosts[host_module.host] = true;
-
-	local function save_task(task : task_spec, started_at : integer)
-		last_run_times:set(nil, task.id, started_at);
-	end
-
-	local function task_added(event : task_event) : boolean
-		local task = event.item;
-		if task.name == nil then
-			task.name = task.when;
-		end
-		if task.id == nil then
-			task.id = event.source.name .. "/" .. task.name:gsub("%W", "_"):lower();
-		end
-		if task.last == nil then
-			task.last = last_run_times:get(nil, task.id);
-		end
-		task.save = save_task;
-		module:log("debug", "%s task %s added, last run %s", task.when, task.id,
-			task.last and datetime.datetime(task.last) or "never");
-		if task.last == nil then
-			-- initialize new tasks so e.g. daily tasks run at ~midnight UTC for now
-			local now = os.time();
-			task.last = now - now % periods[task.when];
-		end
-		return true;
-	end
-
-	local function task_removed(event : task_event) : boolean
-		local task = event.item;
-		host_module:log("debug", "Task %s removed", task.id);
-		return true;
-	end
-
-	host_module:handle_items("task", task_added, task_removed, true);
-
-	function host_module.unload()
-		active_hosts[host_module.host]=nil;
-	end
-end
-
-local function should_run(when : frequency, last : integer) : boolean
-	return not last or last + periods[when]*0.995 <= os.time();
-end
-
-local function run_task(task : task_spec)
-	local started_at = os.time();
-	task:run(started_at);
-	task.last = started_at;
-	task:save(started_at);
-end
-
-local task_runner = async.runner(run_task);
-module:add_timer(1, function() : integer
-	module:log("info", "Running periodic tasks");
-	local delay = 3600;
-	for host in pairs(active_hosts) do
-		module:log("debug", "Running periodic tasks for host %s", host);
-		for _, task in ipairs(module:context(host):get_host_items("task") as { task_spec } ) do
-			module:log("debug", "Considering %s task %s (%s)", task.when, task.id, task.run);
-			if should_run(task.when, task.last) then task_runner:run(task); end
-		end
-	end
-	module:log("debug", "Wait %ds", delay);
-	return delay;
-end);
-
--- TODO measure load, pick a good time to do stuff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/core/storagemanager.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,74 @@
+-- Storage local record API Description
+--
+-- This is written as a TypedLua description
+
+-- Key-Value stores (the default)
+
+local stanza = require"prosody.util.stanza".stanza_t
+
+local record keyval_store
+	get : function ( keyval_store, string ) : any , string
+	set : function ( keyval_store, string, any ) : boolean, string
+end
+
+-- Map stores (key-key-value stores)
+
+local record map_store
+	get : function ( map_store, string, any ) : any, string
+	set : function ( map_store, string, any, any ) : boolean, string
+	set_keys : function ( map_store, string, { any : any }) : boolean, string
+	remove : table
+end
+
+-- Archive stores
+
+local record archive_query
+	start  : number -- timestamp
+	["end"]: number -- timestamp
+	with   : string
+	after  : string -- archive id
+	before : string -- archive id
+	total  : boolean
+end
+
+local record archive_store
+	-- Optional set of capabilities
+	caps   : {
+		-- Optional total count of matching items returned as second return value from :find()
+		string : any
+	}
+
+	-- Add to the archive
+	append : function ( archive_store, string, string, any, number, string ) : string, string
+
+	-- Iterate over archive
+	type iterator = function () : string, any, number, string
+	find   : function ( archive_store, string, archive_query ) : iterator, integer
+
+	-- Removal of items. API like find. Optional
+	delete : function ( archive_store, string, archive_query ) : boolean | number, string
+
+	-- Array of dates which do have messages (Optional)
+	dates  : function ( archive_store, string ) : { string }, string
+
+	-- Map of counts per "with" field
+	summary : function ( archive_store, string, archive_query ) : { string : integer }, string
+
+	-- Map-store API
+	get    : function ( archive_store, string, string ) : stanza, number, string
+	get    : function ( archive_store, string, string ) : nil, string
+	set    : function ( archive_store, string, string, stanza, number, string ) : boolean, string
+end
+
+-- This represents moduleapi
+local record coremodule
+	-- If the first string is omitted then the name of the module is used
+	-- The second string is one of "keyval" (default), "map" or "archive"
+	open_store : function (archive_store, string, string) : keyval_store, string
+	open_store : function (archive_store, string, string) : map_store, string
+	open_store : function (archive_store, string, string) : archive_store, string
+
+	-- Other module methods omitted
+end
+
+return coremodule
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/core/usermanager.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,46 @@
+local Role = require "prosody.util.roles".Role;
+
+local record usermanager
+	record AuthProvider
+		-- TODO
+	end
+	record AccountInfo
+		created : number
+		password_updated : any
+		enabled : boolean
+	end
+
+	-- Users
+	test_password : function (username : string, host : string, password : string) : boolean
+	get_password : function (username : string, host : string) : string, string
+	set_password : function (username : string, host : string, password : string) : boolean, string
+	get_account_info : function (username : string, host : string) : AccountInfo
+	user_exists : function (username : string, host : string) : boolean
+	create_user : function (username : string, password : string, host : string) : boolean, string
+	delete_user : function (username : string, host : string) : boolean, string
+	user_is_enabled : function (username : string, host : string) : boolean, string
+	enable_user : function (username : string, host : string) : boolean, string
+	disable_user : function (username : string, host : string) : boolean, string
+	users : function (host : string) : function () : string
+
+	-- Roles
+	get_user_role : function (username : string, host : string) : Role
+	set_user_role : function (username : string, host : string, role_name : string) : boolean, string
+	user_can_assume_role : function (username : string, host : string, role_name : string) : boolean
+	add_user_secondary_role : function (username : string, host: string, role_name : string) : boolean, string
+	remove_user_secondary_role : function (username : string, host: string, role_name : string) : boolean, string
+	get_user_secondary_roles : function (username : string, host : string) : { string : Role }
+	get_users_with_role : function (role : string, host : string) : { string }
+	get_jid_role : function (jid : string, host : string) : Role
+	set_jid_role : function (jid : string, host : string, role_name : string) : boolean
+	get_jids_with_role : function (role : string, host : string) : { string }
+	get_role_by_name : function (role_name : string) : Role
+
+	-- Etc
+	get_provider : function (host : string) : AuthProvider
+	get_sasl_handler : function (host : string, session : table) : table
+	initialize_host : function (host : string)
+	new_null_provider : function () : AuthProvider
+end
+
+return usermanager
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/net/http.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,86 @@
+local Promise = require "prosody.util.promise".Promise;
+
+local record sslctx -- from LuaSec
+end
+
+local record lib
+
+	enum http_method
+		"GET"
+		"HEAD"
+		"POST"
+		"PUT"
+		"OPTIONS"
+		"DELETE"
+		-- etc?
+	end
+
+	record http_client_options
+		sslctx : sslctx
+	end
+
+	record http_options
+		id : string
+		onlystatus : boolean
+		body : string
+		method : http_method
+		headers : { string : string }
+		insecure : boolean
+		suppress_errors : boolean
+		streaming_handler : function
+		suppress_url : boolean
+		sslctx : sslctx
+	end
+
+	record http_request
+		host : string
+		port : string
+		enum Scheme
+			"http"
+			"https"
+		end
+		scheme : Scheme
+		url : string
+		userinfo : string
+		path : string
+
+		method : http_method
+		headers : { string : string }
+
+		insecure : boolean
+		suppress_errors : boolean
+		streaming_handler : function
+		http : http_client
+		time : integer
+		id : string
+		callback : http_callback
+	end
+
+	record http_response
+	end
+
+	type http_callback = function (string, number, http_response, http_request)
+
+	record http_client
+		options : http_client_options
+		request : function (http_client, string, http_options, http_callback)
+	end
+
+	request : function (string, http_options, http_callback) : Promise, string
+	default : http_client
+	new : function (http_client_options) : http_client
+	events : table
+	-- COMPAT
+	urlencode : function (string) : string
+	urldecode : function (string) : string
+	formencode : function ({ string : string }) : string
+	formdecode : function (string) : { string : string }
+	destroy_request : function (http_request)
+
+	enum available_features
+		"sni"
+	end
+	features : { available_features : boolean }
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/net/http/codes.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,2 @@
+local type response_codes = { integer : string }
+return response_codes
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/net/http/errors.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,22 @@
+local record http_errors
+	enum known_conditions
+		"cancelled"
+		"connection-closed"
+		"certificate-chain-invalid"
+		"certificate-verify-failed"
+		"connection failed"
+		"invalid-url"
+		"unable to resolve service"
+	end
+	type registry_keys = known_conditions | integer
+	record error
+		type : string
+		condition : string
+		code : integer
+		text : string
+	end
+	registry : { registry_keys : error }
+	new : function (integer, known_conditions, table)
+	new : function (integer, string, table)
+end
+return http_errors
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/net/http/files.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,14 @@
+local record serve_options
+	path : string
+	mime_map : { string : string }
+	cache_size : integer
+	cache_max_file_size : integer
+	index_files : { string }
+	directory_index : boolean
+end
+
+local record http_files
+	serve : function(serve_options|string) : function
+end
+
+return http_files
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/net/http/parser.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,58 @@
+local record httpstream
+	feed : function(httpstream, string)
+end
+
+local type sink_cb = function ()
+
+local record httppacket
+	enum http_method
+		"HEAD"
+		"GET"
+		"POST"
+		"PUT"
+		"DELETE"
+		"OPTIONS"
+		-- etc
+	end
+	method : http_method
+	record url_details
+		path : string
+		query : string
+	end
+	url : url_details
+	path : string
+	enum http_version
+		"1.0"
+		"1.1"
+	end
+	httpversion : http_version
+	headers : { string : string }
+	body : string | boolean
+	body_sink : sink_cb
+	chunked : boolean
+	partial : boolean
+end
+
+local enum error_conditions
+	"cancelled"
+	"connection-closed"
+	"certificate-chain-invalid"
+	"certificate-verify-failed"
+	"connection failed"
+	"invalid-url"
+	"unable to resolve service"
+end
+
+local type success_cb = function (httppacket)
+local type error_cb = function (error_conditions)
+
+local enum stream_mode
+	"client"
+	"server"
+end
+
+local record lib
+	new : function (success_cb, error_cb, stream_mode) : httpstream
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/net/http/server.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,6 @@
+
+local record http_server
+	-- TODO
+end
+
+return http_server
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/net/server.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,65 @@
+local record server
+	record LuaSocketTCP
+	end
+	record LuaSecCTX
+	end
+
+	record extra_settings
+	end
+
+	record interface
+	end
+	enum socket_type
+		"tcp"
+		"tcp6"
+		"tcp4"
+	end
+
+	record listeners
+		onconnect : function (interface)
+		ondetach : function (interface)
+		onattach : function (interface, string)
+		onincoming : function (interface, string, string)
+		ondrain : function (interface)
+		onreadtimeout : function (interface)
+		onstarttls : function (interface)
+		onstatus : function (interface, string)
+		ondisconnect : function (interface, string)
+	end
+
+	get_backend : function () : string
+
+	type port = string | integer
+	enum read_mode
+		"*a"
+		"*l"
+	end
+	type read_size = read_mode | integer
+	addserver : function (string, port, listeners, read_size, LuaSecCTX) : interface
+	addclient : function (string, port, listeners, read_size, LuaSecCTX, socket_type, extra_settings) : interface
+	record listen_config
+		read_size : read_size
+		tls_ctx : LuaSecCTX
+		tls_direct : boolean
+		sni_hosts : { string : LuaSecCTX }
+	end
+	listen : function (string, port, listeners, listen_config) : interface
+	enum quitting
+		"quitting"
+	end
+	loop : function () : quitting
+	closeall : function ()
+	setquitting : function (boolean | quitting)
+
+	wrapclient : function (LuaSocketTCP, string, port, listeners, read_size, LuaSecCTX, extra_settings) : interface
+	wrapserver : function (LuaSocketTCP, string, port, listeners, listen_config) : interface
+	watchfd : function (integer | LuaSocketTCP, function (interface), function (interface)) : interface
+	link : function ()
+
+	record config
+	end
+	set_config : function (config)
+
+end
+
+return server
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/plugins/mod_cron.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,141 @@
+module:set_global();
+
+local async = require "prosody.util.async";
+
+local cron_initial_delay = module:get_option_number("cron_initial_delay", 1);
+local cron_check_delay = module:get_option_number("cron_check_delay", 3600);
+local cron_spread_factor = module:get_option_number("cron_spread_factor", 0);
+
+local record map_store<K,V>
+	-- TODO move to somewhere sensible
+	get : function (map_store<K,V>, string, K) : V
+	set : function (map_store<K,V>, string, K, V)
+end
+
+local enum frequency
+	"hourly"
+	"daily"
+	"weekly"
+end
+
+local record task_spec
+	id : string -- unique id
+	name : string -- name or short description
+	when : frequency
+	period : number
+	last : integer
+	run : function (task_spec, integer)
+	save : function (task_spec, integer)
+	restore : function (task_spec, integer)
+end
+
+local record task_event
+	source : module
+	item : task_spec
+end
+
+local active_hosts : { string : boolean } = {  }
+
+function module.add_host(host_module : moduleapi)
+
+	local last_run_times = host_module:open_store("cron", "map") as map_store<string,integer>;
+	active_hosts[host_module.host] = true;
+
+	local function save_task(task : task_spec, started_at : integer)
+		last_run_times:set(nil, task.id, started_at);
+	end
+
+	local function restore_task(task : task_spec)
+		if task.last == nil then
+			task.last = last_run_times:get(nil, task.id);
+		end
+	end
+
+	local function task_added(event : task_event) : boolean
+		local task = event.item;
+		if task.name == nil then
+			task.name = task.when;
+		end
+		if task.id == nil then
+			task.id = event.source.name .. "/" .. task.name:gsub("%W", "_"):lower();
+		end
+		task.period = host_module:get_option_period(task.id:gsub("/", "_") .. "_period", "1" .. task.when, 60, 86400*7*53);
+		task.restore = restore_task;
+		task.save = save_task;
+		module:log("debug", "%s task %s added", task.when, task.id);
+		return true;
+	end
+
+	local function task_removed(event : task_event) : boolean
+		local task = event.item;
+		host_module:log("debug", "Task %s removed", task.id);
+		return true;
+	end
+
+	host_module:handle_items("task", task_added, task_removed, true);
+
+	function host_module.unload()
+		active_hosts[host_module.host]=nil;
+	end
+end
+
+local function should_run(task : task_spec, last : integer) : boolean
+	return not last or last + task.period * 0.995 <= os.time();
+end
+
+local function run_task(task : task_spec)
+	task:restore();
+	if not should_run(task, task.last) then
+		return;
+	end
+	local started_at = os.time();
+	task:run(started_at);
+	task.last = started_at;
+	task:save(started_at);
+end
+
+local function spread(t : number, factor : number) : number
+	return t * (1 - factor + 2*factor*math.random());
+end
+
+local task_runner : async.runner_t<task_spec> = async.runner(run_task);
+scheduled = module:add_timer(cron_initial_delay, function() : number
+	module:log("info", "Running periodic tasks");
+	local delay = spread(cron_check_delay, cron_spread_factor);
+	for host in pairs(active_hosts) do
+		module:log("debug", "Running periodic tasks for host %s", host);
+		for _, task in ipairs(module:context(host):get_host_items("task") as { task_spec } ) do
+			task_runner:run(task);
+		end
+	end
+	module:log("debug", "Wait %gs", delay);
+	return delay;
+end);
+
+-- TODO measure load, pick a good time to do stuff
+
+module:add_item("shell-command", {
+	section = "cron";
+	section_desc = "View and manage recurring tasks";
+	name = "tasks";
+	desc = "View registered tasks";
+	args = {};
+	handler = function (self, filter_host : string)
+		local format_table = require "prosody.util.human.io".table;
+		local it = require "util.iterators";
+		local row = format_table({
+			{ title = "Host", width = "2p" };
+			{ title = "Task", width = "3p" };
+			{ title = "Desc", width = "3p" };
+			{ title = "When", width = "1p" };
+			{ title = "Last run", width = "20" };
+		}, self.session.width);
+		local print = self.session.print;
+		print(row());
+		for host in it.sorted_pairs(filter_host and { [filter_host]=true } or active_hosts) do
+			for _, task in ipairs(module:context(host):get_host_items("task")) do
+				print(row { host, task.id, task.name, task.when, task.last and os.date("%Y-%m-%d %R:%S", task.last) or "never" });
+			end
+		end
+	end;
+});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/plugins/muc/muc.lib.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,178 @@
+local Stanza = require "prosody.util.stanza".stanza_t
+
+local record Room
+	jid : string
+
+	enum Affiliation
+		"outcast"
+		"none"
+		"member"
+		"admin"
+		"owner"
+	end
+
+	enum Role
+		"none"
+		"visitor"
+		"participant"
+		"moderator"
+	end
+
+	record Occupant
+		bare_jid : string
+		nick : string
+		sessions : { string : Stanza }
+		role : Role
+		jid : string
+
+		choose_new_primary : function (Occupant) : string
+		set_session : function (Occupant, string, Stanza, boolean)
+		remove_session : function (Occupant, string)
+		each_session : function (Occupant) -- TODO Iterator
+
+	end
+
+	-- Private properties
+	_jid_nick : { string : string }
+	_occupants : { string : Occupant }
+	_data : { string : any }
+	_affiliations : { string : Affiliation }
+	_affiliation_data : { string : { string : any } }
+
+	-- Occupant methods
+	get_occupant_jid : function (Room, real_jid : string) : string
+	new_occupant : function (Room, bare_real_jid : string, nick : string) : Occupant
+	get_occupant_by_nick : function (Room, nick : string) : Occupant
+	type OccupantIterator = function ({string:Occupant}, occupant_jid : string) : string, Occupant
+	each_occupant : function (Room, read_only : boolean) : OccupantIterator, {string:Occupant}, nil
+	has_occupant : function (Room) : boolean
+	get_occupant_by_real_jid : function (Room, real_jid : string) : Occupant
+	save_occupant :function (Room, Occupant) : Occupant
+
+	-- Affiliation methods
+	type AffiliationIterator = function (any, jid : string) : string, Affiliation
+	get_affiliation : function (Room, jid : string) : Affiliation
+	each_affiliation : function (Room, Affiliation) : AffiliationIterator, nil, nil
+	set_affiliation : function (Room, jid : string, Affiliation, reason : string, data : { string : any }) : boolean, string, string, string -- ok + error tripplet
+	get_affiliation_data : function (Room, jid : string, key : string) : any
+	set_affiliation_data : function (Room, jid : string, key : string, value : any) : boolean
+	get_registered_nick : function (Room, jid : string) : string
+	get_registered_jid : function (Room, nick : string) : string
+
+	-- Role methods
+	get_default_role : function (Room, Affiliation) : Role, integer
+	get_role : function (Room, nick : string) : Role
+	may_set_role : function (Room, actor : string, Occupant, Role) : boolean
+	set_role : function (Room, actor : string, occupant_jid : string, Role, reason : string) : boolean, string, string, string
+
+	-- Routing input, generally handled by mod_muc and hooked up to Prosody routing events
+	handle_first_presence : function (Room, table, Stanza) : boolean
+	handle_normal_presence : function (Room, table, Stanza) : boolean
+	handle_presence_to_room : function (Room, table, Stanza) : boolean
+	handle_presence_to_occupant : function (Room, table, Stanza) : boolean
+	handle_message_to_room : function (Room, table, Stanza) : boolean
+	handle_message_to_occupant : function (Room, table, Stanza) : boolean
+	handle_groupchat_to_room : function (Room, table, Stanza) : boolean
+	handle_iq_to_occupant : function (Room, table, Stanza) : boolean
+	handle_disco_info_get_query : function (Room, table, Stanza) : boolean
+	handle_disco_items_get_query : function (Room, table, Stanza) : boolean
+	handle_admin_query_set_command : function (Room, table, Stanza) : boolean
+	handle_admin_query_get_command : function (Room, table, Stanza) : boolean
+	handle_owner_query_get_to_room : function (Room, table, Stanza) : boolean
+	handle_owner_query_set_to_room : function (Room, table, Stanza) : boolean
+	handle_mediated_invite : function (Room, table, Stanza) : boolean
+	handle_mediated_decline : function (Room, table, Stanza) : boolean
+	handle_role_request : function (Room, table, Stanza) : boolean
+	handle_register_iq : function (Room, table, Stanza) : boolean
+	handle_kickable : function (Room, table, Stanza) : boolean
+
+	-- Routing output
+	broadcast : function (Room, Stanza, function (nick : string, Occupant) : boolean)
+	broadcast_message : function (Room, Stanza) : boolean
+	route_stanza : function (Room, Stanza)
+	route_to_occupant : function (Room, Occupant, Stanza)
+
+	-- Sending things to someone joining
+	publicise_occupant_status :  function (Room, Occupant, x : Stanza, nick : string, actor : string, reason : string, prev_role : Role, force_unavailable : boolean, recipient : Occupant)
+	send_occupant_list : function (Room, to : string, filter : function (occupant_jid : string, Occupant) : boolean)
+	send_history : function (Room, Stanza)
+	send_subject : function (Room, to : string, time : number)
+
+	respond_to_probe : function (Room, table, Stanza, Occupant)
+
+	-- Constructors for various answer stanzas
+	get_disco_info : function (Room, Stanza) : Stanza
+	get_disco_items : function (Room, Stanza) : Stanza
+
+	build_item_list : function (Room, Occupant, Stanza, is_anonymous : boolean, nick : string, actor_nick : string, actor_jid : string, reason : string) : Stanza
+	build_unavailable_presence : function (Room, from_muc_jid : string, to_jid : string) : Stanza
+
+	-- Form handling
+	send_form : function (Room, table, Stanza)
+	get_form_layout : function (Room, actor : string) : table
+	process_form : function (Room, table, Stanza) : boolean
+
+	-- Properties and configuration
+	get_name : function (Room) : string
+	set_name : function (Room, string) : boolean
+	get_description : function (Room) : string
+	set_description : function (Room, string) : boolean
+	get_language : function (Room) : string
+	set_language : function (Room, string) : boolean
+	get_hidden : function (Room) : boolean
+	set_hidden : function (Room, boolean)
+	get_public : function (Room) : boolean
+	set_public : function (Room, boolean)
+	get_password : function (Room) : string
+	set_password : function (Room, string) : boolean
+	get_members_only : function (Room) : boolean
+	set_members_only : function (Room, boolean) : boolean
+	get_allow_member_invites : function (Room) : boolean
+	set_allow_member_invites : function (Room, boolean) : boolean
+	get_moderated : function (Room) : boolean
+	set_moderated : function (Room, boolean) : boolean
+	get_persistent : function (Room) : boolean
+	set_persistent : function (Room, boolean) : boolean
+	get_changesubject : function (Room) : boolean
+	set_changesubject : function (Room, boolean) : boolean
+	get_subject : function (Room) : string
+	set_subject : function (Room, string) : boolean
+	get_historylength : function (Room) : integer
+	set_historylength : function (Room, integer) : boolean
+	get_presence_broadcast : function (Room) : { Role : boolean }
+	set_presence_broadcast : function (Room, { Role : boolean }) : boolean
+
+	is_anonymous_for : function (Room, jid : string) : boolean
+	get_salt : function (Room) : string
+	get_occupant_id : function (Room, Occupant)
+
+	-- Room teardown
+	clear : function (Room, x : Stanza)
+	destroy : function (Room, newjid : string, reason : string, password : string) : boolean
+
+	-- Room state persistence
+	record FrozenRoom
+		_jid : string
+		_data : { string : any }
+		_affiliation_data : { string : { string : any } }
+		-- { string : Affiliation }
+	end
+
+	record StateEntry
+		bare_jid : string
+		role : Role
+		jid : string
+	end
+
+	save : function (Room, forced : boolean, savestate : boolean) : boolean
+	freeze : function (Room, live : boolean) : FrozenRoom, { string : StateEntry }
+end
+
+local record lib
+	new_room : function (jid : string, config : { string : any }) : Room
+	restore_room : function (Room.FrozenRoom, { string : Room.StateEntry }) : Room
+
+	room_mt : metatable
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/array.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,9 @@
+local record array_t<T>
+	{ T }
+end
+
+local record lib
+	metamethod __call : function () : array_t
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/async.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,42 @@
+local record lib
+	ready : function () : boolean
+	waiter : function (num : integer, allow_many : boolean) : function (), function ()
+	guarder : function () : function (id : function ()) : function () | nil
+	record runner_t<T>
+		func : function (T)
+		thread : thread
+		enum state_e
+			-- from Lua manual
+			"running"
+			"suspended"
+			"normal"
+			"dead"
+
+			-- from util.async
+			"ready"
+			"error"
+		end
+		state : state_e
+		notified_state : state_e
+		queue : { T }
+		type watcher_t = function (runner_t<T>, ... : any)
+		type watchers_t = { state_e : watcher_t }
+		data : any
+		id : string
+
+		run : function (runner_t<T>, T) : boolean, state_e, integer
+		enqueue : function (runner_t<T>, T) : runner_t<T>
+		log : function (runner_t<T>, string, string, ... : any)
+		onready : function (runner_t<T>, function) : runner_t<T>
+		onready : function (runner_t<T>, function) : runner_t<T>
+		onwaiting : function (runner_t<T>, function) : runner_t<T>
+		onerror : function (runner_t<T>, function) : runner_t<T>
+	end
+	runner : function <T>(function (T), runner_t.watchers_t, any) : runner_t<T>
+	wait_for : function (any) : any, any
+	sleep : function (t:number)
+
+	-- set_nexttick = function(new_next_tick) next_tick = new_next_tick; end;
+	-- set_schedule_function = function (new_schedule_function) schedule_task = new_schedule_function; end;
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/bitcompat.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,8 @@
+local record lib
+	band : function (integer, integer, ... : integer) : integer
+	bor : function (integer, integer, ... : integer) : integer
+	bxor : function (integer, integer, ... : integer) : integer
+	lshift : function (integer, integer) : integer
+	rshift : function (integer, integer) : integer
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/compat.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,4 @@
+local record lib
+	xpcall : function (function, function, ...:any):boolean, any
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/crand.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,6 @@
+local record lib
+	bytes : function (n : integer) : string
+	enum sourceid "OpenSSL" "arc4random()" "Linux" end
+	_source : sourceid
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/crypto.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,57 @@
+local record lib
+	record key
+		private_pem : function (key) : string
+		public_pem : function (key) : string
+		get_type : function (key) : string
+	end
+
+	type base_evp_sign = function (key, message : string) : string
+	type base_evp_verify = function (key, message : string, signature : string) : boolean
+
+	ed25519_sign : base_evp_sign
+	ed25519_verify : base_evp_verify
+
+	ecdsa_sha256_sign : base_evp_sign
+	ecdsa_sha256_verify : base_evp_verify
+	ecdsa_sha384_sign : base_evp_sign
+	ecdsa_sha384_verify : base_evp_verify
+	ecdsa_sha512_sign : base_evp_sign
+	ecdsa_sha512_verify : base_evp_verify
+
+	rsassa_pkcs1_sha256_sign : base_evp_sign
+	rsassa_pkcs1_sha256_verify : base_evp_verify
+	rsassa_pkcs1_sha384_sign : base_evp_sign
+	rsassa_pkcs1_sha384_verify : base_evp_verify
+	rsassa_pkcs1_sha512_sign : base_evp_sign
+	rsassa_pkcs1_sha512_verify : base_evp_verify
+
+	rsassa_pss_sha256_sign : base_evp_sign
+	rsassa_pss_sha256_verify : base_evp_verify
+	rsassa_pss_sha384_sign : base_evp_sign
+	rsassa_pss_sha384_verify : base_evp_verify
+	rsassa_pss_sha512_sign : base_evp_sign
+	rsassa_pss_sha512_verify : base_evp_verify
+
+	type Levp_encrypt = function (key : string, iv : string, plaintext : string) : string
+	type Levp_decrypt = function (key : string, iv : string, ciphertext : string) : string, string
+
+	aes_128_gcm_encrypt : Levp_encrypt
+	aes_128_gcm_decrypt : Levp_decrypt
+	aes_256_gcm_encrypt : Levp_encrypt
+	aes_256_gcm_decrypt : Levp_decrypt
+
+	aes_256_ctr_encrypt : Levp_encrypt
+	aes_256_ctr_decrypt : Levp_decrypt
+
+	generate_ed25519_keypair : function () : key
+
+	import_private_pem : function (string) : key
+	import_public_pem : function (string) : key
+
+	parse_ecdsa_signature : function (string, integer) : string, string
+	build_ecdsa_signature : function (r : string, s : string) : string
+
+	version : string
+	_LIBCRYPTO_VERSION : string
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/dataforms.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,54 @@
+local stanza_t = require "prosody.util.stanza".stanza_t
+
+local record lib
+	record dataform
+		title : string
+		instructions : string
+
+		record form_field
+
+			enum field_type
+				"boolean"
+				"fixed"
+				"hidden"
+				"jid-multi"
+				"jid-single"
+				"list-multi"
+				"list-single"
+				"text-multi"
+				"text-private"
+				"text-single"
+			end
+
+			type : field_type
+			var : string -- protocol name
+			name :  string -- internal name
+
+			label : string
+			desc : string
+
+			datatype : string
+			range_min : number
+			range_max : number
+
+			value : any -- depends on field_type
+			options : table
+		end
+
+		{ form_field }
+
+		enum form_type
+			"form"
+			"submit"
+			"cancel"
+			"result"
+		end
+
+		form : function ( dataform, { string : any }, form_type ) : stanza_t
+		data : function ( dataform, stanza_t ) : { string : any }
+	end
+
+	new : function ( dataform ) : dataform
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/datamapper.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,379 @@
+-- Copyright (C) 2021 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- Based on
+-- https://json-schema.org/draft/2020-12/json-schema-core.html
+-- https://json-schema.org/draft/2020-12/json-schema-validation.html
+-- http://spec.openapis.org/oas/v3.0.1#xmlObject
+-- https://github.com/OAI/OpenAPI-Specification/issues/630 (text:true)
+--
+-- XML Object Extensions:
+-- text to refer to the text content at the same time as attributes
+-- x_name_is_value for enum fields where the <tag-name/> is the value
+-- x_single_attribute for <tag attr="this"/>
+--
+-- TODO pointers
+-- TODO cleanup / refactor
+-- TODO s/number/integer/ once we have appropriate math.type() compat
+--
+
+if not math.type then require "prosody.util.mathcompat" end
+
+local st = require "prosody.util.stanza";
+local json = require "prosody.util.json"
+local pointer = require "prosody.util.jsonpointer";
+
+local json_type_name = json.json_type_name;
+local json_schema_object = require "prosody.util.jsonschema"
+local type schema_t = boolean | json_schema_object
+
+local function toboolean ( s : string ) : boolean
+	if s == "true" or s == "1" then
+		return true
+	elseif s == "false" or s == "0" then
+		return false
+	elseif s then
+		return true
+	end
+end
+
+local function totype(t : json_type_name, s : string) : any
+	if not s then return nil end
+	if t == "string" then
+		return s;
+	elseif t == "boolean" then
+		return toboolean(s)
+	elseif t == "number" or t == "integer" then
+		return tonumber(s)
+	end
+end
+
+local enum value_goes
+	"in_tag_name"
+	"in_text"
+	"in_text_tag"
+	"in_attribute"
+	"in_single_attribute"
+	"in_children"
+	"in_wrapper"
+end
+
+local function resolve_schema(schema  : schema_t, root : json_schema_object) : schema_t
+	if schema is json_schema_object then
+		if schema["$ref"] and schema["$ref"]:sub(1, 1) == "#" then
+			return pointer.resolve(root as table, schema["$ref"]:sub(2)) as schema_t;
+		end
+	end
+	return schema;
+end
+
+local function guess_schema_type(schema : json_schema_object) : json_type_name
+	local schema_types = schema.type
+	if schema_types is json_type_name then
+		return schema_types
+	elseif schema_types ~= nil then
+		error "schema has unsupported 'type' property"
+	elseif schema.properties then
+		return "object"
+	elseif schema.items then
+		return "array"
+	end
+	return "string" -- default assumption
+end
+
+local function unpack_propschema( propschema : schema_t, propname : string, current_ns : string )
+		: json_type_name, value_goes, string, string, string, string, { any }
+	local proptype : json_type_name = "string"
+	local value_where : value_goes = propname and "in_text_tag" or "in_text"
+	local name = propname
+	local namespace : string
+	local prefix : string
+	local single_attribute : string
+	local enums : { any }
+
+	if propschema is json_schema_object then
+		proptype = guess_schema_type(propschema);
+	elseif propschema is string then -- Teal says this can never be a string, but it could before so best be sure
+		error("schema as string is not supported: "..propschema.." {"..current_ns.."}"..propname)
+	end
+
+	if proptype == "object" or proptype == "array" then
+		value_where = "in_children"
+	end
+
+	if propschema is json_schema_object then
+		local xml = propschema.xml
+		if xml then
+			if xml.name then
+				name = xml.name
+			end
+			if xml.namespace and xml.namespace ~= current_ns then
+				namespace = xml.namespace
+			end
+			if xml.prefix then
+				prefix = xml.prefix
+			end
+			if proptype == "array" and xml.wrapped then
+				value_where = "in_wrapper"
+			elseif xml.attribute then
+				value_where = "in_attribute"
+			elseif xml.text then
+				value_where = "in_text"
+			elseif xml.x_name_is_value then
+				value_where = "in_tag_name"
+			elseif xml.x_single_attribute then
+				single_attribute = xml.x_single_attribute
+				value_where = "in_single_attribute"
+			end
+		end
+		if propschema["const"] then
+			enums = { propschema["const"] }
+		elseif propschema["enum"] then
+			enums = propschema["enum"]
+		end
+	end
+
+	return proptype, value_where, name, namespace, prefix, single_attribute, enums
+end
+
+local parse_object : function (schema : schema_t, s : st.stanza_t, root : json_schema_object) : { string : any }
+local parse_array : function (schema : schema_t, s : st.stanza_t, root : json_schema_object) : { any }
+
+local function extract_value (s : st.stanza_t, value_where : value_goes, proptype : json.json_type_name, name : string, namespace : string, prefix : string, single_attribute : string, enums : { any }) : string
+	if value_where == "in_tag_name" then
+		local c : st.stanza_t
+		if proptype == "boolean" then
+			c = s:get_child(name, namespace);
+		elseif enums and proptype == "string" then
+			-- XXX O(n²) ?
+			-- Probably better to flip the table and loop over :childtags(nil, ns), should be 2xO(n)
+			-- BUT works first, optimize later
+			for i = 1, #enums do
+				c = s:get_child(enums[i] as string, namespace);
+				if c then break end
+			end
+		else
+			c = s:get_child(nil, namespace);
+		end
+		if c then
+			return c.name;
+		end
+	elseif value_where == "in_attribute" then
+		local attr = name
+		if prefix then
+			attr = prefix .. ':' .. name
+		elseif namespace and namespace ~= s.attr.xmlns then
+			attr = namespace .. "\1" .. name
+		end
+		return s.attr[attr]
+
+	elseif value_where == "in_text" then
+		return s:get_text()
+
+	elseif value_where == "in_single_attribute" then
+		local c = s:get_child(name, namespace)
+		return c and c.attr[single_attribute]
+	elseif value_where == "in_text_tag" then
+		return s:get_child_text(name, namespace)
+	end
+end
+
+function parse_object (schema : schema_t, s : st.stanza_t, root : json_schema_object) : { string : any }
+	local out : { string : any } = {}
+	schema = resolve_schema(schema, root)
+	if schema is json_schema_object and schema.properties then
+		for prop, propschema in pairs(schema.properties) do
+			propschema = resolve_schema(propschema, root)
+
+			local proptype, value_where, name, namespace, prefix, single_attribute, enums = unpack_propschema(propschema, prop, s.attr.xmlns)
+
+			if value_where == "in_children" and propschema is json_schema_object then
+				if proptype == "object" then
+					local c = s:get_child(name, namespace)
+					if c then
+						out[prop] = parse_object(propschema, c, root);
+					end
+				elseif proptype == "array" then
+					local a = parse_array(propschema, s, root);
+					if a and a[1] ~= nil then
+						out[prop] = a;
+					end
+				else
+					error "unreachable"
+				end
+			elseif value_where == "in_wrapper" and propschema is json_schema_object and proptype == "array" then
+				local wrapper = s:get_child(name, namespace);
+				if wrapper then
+					out[prop] = parse_array(propschema, wrapper, root);
+				end
+			else
+				local value : string = extract_value (s, value_where, proptype, name, namespace, prefix, single_attribute, enums)
+
+				out[prop] = totype(proptype, value)
+			end
+		end
+	end
+
+	return out
+end
+
+function parse_array (schema : json_schema_object, s : st.stanza_t, root : json_schema_object) : { any }
+	local itemschema : schema_t = resolve_schema(schema.items, root);
+	local proptype, value_where, child_name, namespace, prefix, single_attribute, enums = unpack_propschema(itemschema, nil, s.attr.xmlns)
+	local attr_name : string
+	if value_where == "in_single_attribute" then -- FIXME this shouldn't be needed
+		value_where = "in_attribute";
+		attr_name = single_attribute;
+	end
+	local out : { any } = {}
+
+	if proptype == "object" then
+		if itemschema is json_schema_object then
+			for c in s:childtags(child_name, namespace) do
+				table.insert(out, parse_object(itemschema, c, root));
+			end
+		else
+			error "array items must be schema object"
+		end
+	elseif proptype == "array" then
+		if itemschema is json_schema_object then
+			for c in s:childtags(child_name, namespace) do
+				table.insert(out, parse_array(itemschema, c, root));
+			end
+		end
+	else
+		for c in s:childtags(child_name, namespace) do
+			local value : string = extract_value (c, value_where, proptype, attr_name or child_name, namespace, prefix, single_attribute, enums)
+
+			table.insert(out, totype(proptype, value));
+		end
+	end
+	return out;
+end
+
+local function parse (schema : json_schema_object, s : st.stanza_t) : table
+	local s_type = guess_schema_type(schema)
+	if s_type == "object" then
+		return parse_object(schema, s, schema)
+	elseif s_type == "array" then
+		return parse_array(schema, s, schema)
+	else
+		error "top-level scalars unsupported"
+	end
+end
+
+local function toxmlstring(proptype : json_type_name, v : any) : string
+	if proptype == "string" and v is string then
+		return  v
+	elseif proptype == "number" and v is number then
+		return  string.format("%g", v)
+	elseif proptype == "integer" and v is number then -- TODO is integer
+		return  string.format("%d", v)
+	elseif proptype == "boolean" then
+		return  v and "1" or "0"
+	end
+end
+
+local unparse : function (json_schema_object, table, string, string, st.stanza_t, json_schema_object) : st.stanza_t
+
+local function unparse_property(out : st.stanza_t, v : any, proptype : json_type_name, propschema : schema_t, value_where : value_goes, name : string, namespace : string, current_ns : string, prefix : string, single_attribute : string, root : json_schema_object)
+
+	if value_where == "in_attribute" then
+		local attr = name
+		if prefix then
+			attr = prefix .. ':' .. name
+		elseif namespace and namespace ~= current_ns then
+			attr = namespace .. "\1" .. name
+		end
+
+		out.attr[attr] = toxmlstring(proptype, v)
+	elseif value_where == "in_text" then
+		out:text(toxmlstring(proptype, v))
+	elseif value_where == "in_single_attribute" then
+		assert(single_attribute)
+		local propattr : { string : string } = {}
+
+		if namespace and namespace ~= current_ns then
+			propattr.xmlns = namespace
+		end
+
+		propattr[single_attribute] = toxmlstring(proptype, v)
+		out:tag(name, propattr):up();
+
+	else
+		local propattr : { string : string }
+		if namespace ~= current_ns then
+			propattr = { xmlns = namespace }
+		end
+		if value_where == "in_tag_name" then
+			if proptype == "string" and v is string then
+				out:tag(v, propattr):up();
+			elseif proptype == "boolean" and v == true then
+				out:tag(name, propattr):up();
+			end
+		elseif proptype == "object" and propschema is json_schema_object and v is table then
+			local c = unparse(propschema, v, name, namespace, nil, root);
+			if c then
+				out:add_direct_child(c);
+			end
+		elseif proptype == "array" and propschema is json_schema_object and v is table then
+			if value_where == "in_wrapper" then
+				local c = unparse(propschema, v, name, namespace, nil, root);
+				if c then
+					out:add_direct_child(c);
+				end
+			else
+				unparse(propschema, v, name, namespace, out, root);
+			end
+		else
+			out:text_tag(name, toxmlstring(proptype, v), propattr)
+		end
+	end
+end
+
+function unparse ( schema : json_schema_object, t : table, current_name : string, current_ns : string, ctx : st.stanza_t, root : json_schema_object ) : st.stanza_t
+
+	if root == nil then root = schema end
+
+	if schema.xml then
+		if schema.xml.name then
+			current_name = schema.xml.name
+		end
+		if schema.xml.namespace then
+			current_ns = schema.xml.namespace
+		end
+		-- TODO prefix?
+	end
+
+	local out = ctx or st.stanza(current_name, { xmlns = current_ns })
+
+	local s_type = guess_schema_type(schema)
+	if s_type == "object" then
+
+		for prop, propschema in pairs(schema.properties) do
+			propschema = resolve_schema(propschema, root)
+			local v = t[prop]
+
+			if v ~= nil then
+				local proptype, value_where, name, namespace, prefix, single_attribute = unpack_propschema(propschema, prop, current_ns)
+				unparse_property(out, v, proptype, propschema, value_where, name, namespace, current_ns, prefix, single_attribute, root)
+			end
+		end
+		return out;
+
+	elseif s_type == "array" then
+		local itemschema = resolve_schema(schema.items, root)
+		local proptype, value_where, name, namespace, prefix, single_attribute = unpack_propschema(itemschema, current_name, current_ns)
+		for _, item in ipairs(t as { string }) do
+			unparse_property(out, item, proptype, itemschema, value_where, name, namespace, current_ns, prefix, single_attribute, root)
+		end
+		return out;
+	end
+end
+
+return {
+	parse = parse,
+	unparse = unparse,
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/datetime.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,9 @@
+local record lib
+	date     : function (t : number) : string
+	datetime : function (t : number) : string
+	time     : function (t : number) : string
+	legacy   : function (t : number) : string
+	parse    : function (t : string) : number
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/encodings.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,27 @@
+-- TODO many actually return Maybe(String)
+local record lib
+	record base64
+		encode : function (s : string) : string
+		decode : function (s : string) : string
+	end
+	record stringprep
+		nameprep : function (s : string, strict : boolean) : string
+		nodeprep : function (s : string, strict : boolean) : string
+		resourceprep : function (s : string, strict : boolean) : string
+		saslprep : function (s : string, strict : boolean) : string
+	end
+	record idna
+		to_ascii : function (s : string) : string
+		to_unicode : function (s : string) : string
+	end
+	record utf8
+		valid : function (s : string) : boolean
+		length : function (s : string) : integer
+	end
+	record confusable
+		skeleton : function (s : string) : string
+	end
+	version : string
+end
+return lib
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/error.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,78 @@
+local enum error_type
+	"auth"
+	"cancel"
+	"continue"
+	"modify"
+	"wait"
+end
+
+local enum error_condition
+	"bad-request"
+	"conflict"
+	"feature-not-implemented"
+	"forbidden"
+	"gone"
+	"internal-server-error"
+	"item-not-found"
+	"jid-malformed"
+	"not-acceptable"
+	"not-allowed"
+	"not-authorized"
+	"policy-violation"
+	"recipient-unavailable"
+	"redirect"
+	"registration-required"
+	"remote-server-not-found"
+	"remote-server-timeout"
+	"resource-constraint"
+	"service-unavailable"
+	"subscription-required"
+	"undefined-condition"
+	"unexpected-request"
+end
+
+local record protoerror
+	type : error_type
+	condition : error_condition
+	text : string
+	code : integer
+end
+
+local record Error
+	type : error_type
+	condition : error_condition
+	text : string
+	code : integer
+	context : { any : any }
+	source : string
+end
+
+local type compact_registry_item = { string, string, string, string }
+local type compact_registry = { compact_registry_item }
+local type registry = { string : protoerror }
+local type context = { string : any }
+
+local record error_registry_wrapper
+	source : string
+	registry : registry
+	new : function (string, context) : Error
+	coerce : function (any, string) : any, Error
+	wrap : function (Error) : Error
+	wrap : function (string, context) : Error
+	is_error : function (any) : boolean
+end
+
+local record lib
+	record configure_opt
+		auto_inject_traceback : boolean
+	end
+	new : function (protoerror, context, { string : protoerror }, string) : Error
+	init : function (string, string, registry | compact_registry) : error_registry_wrapper
+	init : function (string, registry | compact_registry) : error_registry_wrapper
+	is_error : function (any) : boolean
+	coerce : function (any, string) : any, Error
+	from_stanza : function (table, context, string) : Error
+	configure : function
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/format.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,4 @@
+local record lib
+	format : function (string, ... : any) : string
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/hashes.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,35 @@
+local type hash = function (msg : string, hex : boolean) : string
+local type hmac = function (key : string, msg : string, hex : boolean) : string
+local type kdf = function (pass : string, salt : string, i : integer) : string
+
+local record lib
+	sha1 : hash
+	sha224 : hash
+	sha256 : hash
+	sha384 : hash
+	sha512 : hash
+	md5 : hash
+	sha3_256 : hash
+	sha3_512 : hash
+	blake2s256 : hash
+	blake2b512 : hash
+	hmac_sha1 : hmac
+	hmac_sha224 : hmac
+	hmac_sha256 : hmac
+	hmac_sha384  :hmac
+	hmac_sha512 : hmac
+	hmac_md5 : hmac
+	hmac_sha3_256 : hmac
+	hmac_sha3_512 : hmac
+	hmac_blake2s256 : hmac
+	hmac_blake2b512 : hmac
+	scram_Hi_sha1 : kdf
+	pbkdf2_hmac_sha1 : kdf
+	pbkdf2_hmac_sha256 : kdf
+	hkdf_hmac_sha256 : kdf
+	hkdf_hmac_sha384 : kdf
+	equals : function (string, string) : boolean
+	version : string
+	_LIBCRYPTO_VERSION : string
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/hex.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,8 @@
+local type s2s = function (s : string) : string
+local record lib
+	to : s2s
+	from : s2s
+	encode : s2s
+	decode : s2s
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/http.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,9 @@
+local record lib
+	urlencode : function (s : string) : string 
+	urldecode : function (s : string) : string 
+	formencode : function (f : { string : string }) : string 
+	formdecode : function (s : string) : { string : string } 
+	contains_token : function (field : string, token : string) : boolean 
+	normalize_path : function (path : string) : string 
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/human/io.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,28 @@
+local record lib
+	getchar : function (n : integer) : string
+	getline : function () : string
+	getpass : function () : string
+	show_yesno : function (prompt : string) : boolean
+	read_password : function () : string
+	show_prompt : function (prompt : string) : boolean
+	printf : function (fmt : string, ... : any)
+	padleft : function (s : string, width : integer) : string
+	padright : function (s : string, width : integer) : string
+
+	-- {K:V} vs T ?
+	record tablerow<K,V>
+		width : integer | string -- generate an 1..100 % enum?
+		title : string
+		mapper : function (V, {K:V}) : string
+		key : K
+		enum alignments
+			"left"
+			"right"
+		end
+		align : alignments
+	end
+	type getrow = function<K,V> ({ K : V }) : string
+	table : function<K,V> ({ tablerow<K,V> }, width : integer) : getrow<K,V>
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/human/units.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,8 @@
+local lib = record
+	enum logbase
+		"b" -- 1024
+	end
+	adjust : function (number, string) : number, string
+	format : function (number, string, logbase) : string
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/id.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,9 @@
+local record lib
+	short : function () : string
+	medium : function () : string
+	long : function () : string
+	custom : function (integer) : function () : string
+
+end
+return lib
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/interpolation.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,6 @@
+local type renderer = function (string, { string : any }) : string
+local type filter = function (string, any) : string
+local record lib
+	new : function (string, string, funcs : { string : filter }) : renderer
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/ip.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,20 @@
+local record iplib
+	enum protocol
+		"IPv6"
+		"IPv4"
+	end
+	record ip_t
+		addr : string
+		packed : string
+		proto : protocol
+		zone : string
+	end
+
+	new_ip : function (string, protocol) : ip_t
+	commonPrefixLength : function (ip_t, ip_t) : integer
+	parse_cidr : function (string) : ip_t, integer
+	match : function (ip_t, ip_t, integer) : boolean
+	is_ip : function (any) : boolean
+	truncate : function (ip_t, integer) : ip_t
+end
+return iplib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/jid.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,15 @@
+local record lib
+	split : function (string) : string, string, string
+	bare : function (string) : string
+	prepped_split : function (string, boolean) : string, string, string
+	join : function (string, string, string) : string
+	prep : function (string, boolean) : string
+	compare : function (string, string) : boolean
+	node : function (string) : string
+	host : function (string) : string
+	resource : function (string) : string
+	escape : function (string) : string
+	unescape : function (string) : string
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/json.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,18 @@
+local record lib
+	encode : function (any) : string
+	decode : function (string) : any, string
+
+	enum json_type_name
+		"null"
+		"boolean"
+		"object"
+		"array"
+		"number"
+		"string"
+		"integer"
+	end
+
+	type null_type = (nil)
+	null : null_type
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/jsonpointer.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,46 @@
+
+local enum ptr_error
+	"invalid-table"
+	"invalid-path"
+end
+
+local function unescape_token(escaped_token : string) : string
+	local unescaped = escaped_token:gsub("~1", "/"):gsub("~0", "~")
+	return unescaped
+end
+
+local function resolve_json_pointer(ref : any, path : string) : any, ptr_error
+	local ptr_len = #path+1
+	for part, pos in path:gmatch("/([^/]*)()") do
+		local token = unescape_token(part)
+		if not ref is table then
+			return nil
+		end
+		local idx = next(ref)
+		local new_ref : any
+
+		if idx is string then
+			new_ref = ref[token]
+		elseif idx is integer then
+			local i = tonumber(token)
+			if token == "-" then i = #(ref as {any}) + 1 end
+			new_ref = ref[i+1]
+		else
+			return nil, "invalid-table"
+		end
+
+		if pos as integer == ptr_len then
+			return new_ref
+		elseif new_ref is table then
+			ref = new_ref
+		elseif not ref is table then
+			return nil, "invalid-path"
+		end
+
+	end
+	return ref
+end
+
+return {
+	resolve = resolve_json_pointer,
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/jsonschema.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,505 @@
+-- Copyright (C) Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- Based on
+-- https://json-schema.org/draft/2020-12/json-schema-core.html
+-- https://json-schema.org/draft/2020-12/json-schema-validation.html
+--
+
+if not math.type then require "prosody.util.mathcompat" end
+
+
+local utf8_enc = rawget(_G, "utf8") or require"prosody.util.encodings".utf8;
+local utf8_len = utf8_enc.len or function(s : string) : integer
+	local _, count = s:gsub("[%z\001-\127\194-\253][\128-\191]*", "");
+	return count;
+end;
+
+local json = require "prosody.util.json"
+local null = json.null;
+
+local pointer = require "prosody.util.jsonpointer"
+
+local type json_type_name = json.json_type_name
+
+-- json_type_name here is non-standard
+local type schema_t = boolean | json_schema_object
+
+local record json_schema_object
+	type json_type_name = json.json_type_name
+	type schema_object = json_schema_object
+
+	-- json-schema-core meta stuff
+	["$schema"] : string
+	["$vocabulary"] : { string : boolean }
+	["$id"] : string
+	["$comment"] : string
+	["$defs"] : { string : schema_t }
+	["$anchor"] : string -- NYI
+	["$dynamicAnchor"] : string -- NYI
+	["$ref"] : string
+	["$dynamicRef"] : string -- NYI
+
+	-- combinations
+	allOf : { schema_t }
+	anyOf : { schema_t }
+	oneOf : { schema_t }
+
+	-- conditional logic
+	["not"] : schema_t
+	["if"] : schema_t
+	["then"] : schema_t
+	["else"] : schema_t
+
+	dependentRequired : { string : { string } }
+
+	-- arrays
+	prefixItems : { schema_t }
+	items : schema_t
+	contains : schema_t
+
+	-- objects
+	properties : { string : schema_t }
+	patternProperties: { string : schema_t } -- NYI
+	additionalProperties: schema_t
+	propertyNames : schema_t
+
+	-- unevaluated
+	unevaluatedItems : schema_t -- NYI
+	unevaluatedProperties : schema_t -- NYI
+
+	-- json-schema-validation
+	type : json_type_name | { json_type_name }
+	enum : { any }
+	const : any
+
+	-- numbers
+	multipleOf : number
+	maximum : number
+	exclusiveMaximum : number
+	minimum : number
+	exclusiveMinimum : number
+
+	-- strings
+	maxLength : integer
+	minLength : integer
+	pattern : string -- NYI
+
+	-- arrays
+	maxItems : integer
+	minItems : integer
+	uniqueItems : boolean
+	maxContains : integer
+	minContains : integer
+
+	-- objects
+	maxProperties : integer -- NYI
+	minProperties : integer -- NYI
+	required : { string }
+	dependentSchemas : { string : schema_t }
+
+	-- semantic format
+	format : string
+
+	-- for Lua
+	luaPatternProperties: { string : schema_t }
+	luaPattern : string
+
+	-- xml
+	record xml_t
+		name : string
+		namespace : string
+		prefix : string
+		attribute : boolean
+		wrapped : boolean
+
+		-- nonstantard, maybe in the future
+		text : boolean
+		x_name_is_value : boolean
+		x_single_attribute : string
+	end
+
+	xml : xml_t
+
+	-- descriptive
+	title : string
+	description : string
+	deprecated : boolean
+	readOnly : boolean
+	writeOnly : boolean
+
+	-- methods
+	validate : function (schema : schema_t, data : any, root : json_schema_object, sloc : string, iloc : string, errs:errors) : boolean, errors
+end
+
+-- TODO validator function per schema property
+
+local function simple_validate(schema : json_type_name | { json_type_name }, data : any) : boolean
+	if schema == nil then
+		return true
+	elseif schema == "object" and data is table then
+		return type(data) == "table" and (next(data)==nil or type((next(data, nil))) == "string")
+	elseif schema == "array" and data is table then
+		return type(data) == "table" and (next(data)==nil or type((next(data, nil))) == "number")
+	elseif schema == "integer" then
+		return math.type(data) == schema
+	elseif schema == "null" then
+		return data == null
+	elseif schema is { json_type_name } then
+		for _, one in ipairs(schema as { json_type_name }) do
+			if simple_validate(one, data) then
+				return true
+			end
+		end
+		return false
+	else
+		return type(data) == schema
+	end
+end
+
+local record validation_error
+	instanceLocation : string
+	schemaLocation : string
+	error : string
+end
+local type errors = { validation_error }
+local function mkerr(sloc:string,iloc:string,err:string) : validation_error
+	return { schemaLocation = sloc; instanceLocation = iloc; error = err };
+end
+
+local function validate (schema : schema_t, data : any, root : json_schema_object, sloc : string, iloc : string, errs:errors) : boolean, errors
+	if schema is boolean then
+		return schema
+	end
+
+	if root == nil then
+		root = schema as json_schema_object
+		iloc = ""
+		sloc = ""
+		errs = {};
+	end
+
+	if schema["$ref"] and schema["$ref"]:sub(1,1) == "#" then
+		local referenced = pointer.resolve(root as table, schema["$ref"]:sub(2)) as schema_t
+		if referenced ~= nil and referenced ~= root and referenced ~= schema then
+			if not validate(referenced, data, root, schema["$ref"], iloc, errs) then
+				table.insert(errs, mkerr(sloc.."/$ref", iloc, "Subschema failed validation"))
+				return false, errs;
+			end
+		end
+	end
+
+	if not simple_validate(schema.type, data) then
+		table.insert(errs, mkerr(sloc.."/type", iloc, "unexpected type"));
+		return false, errs;
+	end
+
+	if schema.type == "object" then
+		if data is table then
+			-- just check that there the keys are all strings
+			for k in pairs(data) do
+				if not k is string then
+					table.insert(errs, mkerr(sloc.."/type", iloc, "'object' had non-string keys"));
+					return false, errs;
+				end
+			end
+		end
+	end
+
+	if schema.type == "array" then
+		if data is table then
+			-- just check that there the keys are all numbers
+			for i in pairs(data) do
+				if not i is integer then
+					table.insert(errs, mkerr(sloc.."/type", iloc, "'array' had non-integer keys"));
+					return false, errs;
+				end
+			end
+		end
+	end
+
+	if schema["enum"] ~= nil then
+		local match = false
+		for _, v in ipairs(schema["enum"]) do
+			if v == data then
+				-- FIXME supposed to do deep-compare
+				match = true
+				break
+			end
+		end
+		if not match then
+			table.insert(errs, mkerr(sloc.."/enum", iloc, "not one of the enumerated values"));
+			return false, errs;
+		end
+	end
+
+	-- XXX this is measured in byte, while JSON measures in ... bork
+	-- TODO use utf8.len?
+	if data is string then
+		if schema.maxLength and utf8_len(data) > schema.maxLength then
+			table.insert(errs, mkerr(sloc.."/maxLength", iloc, "string too long"))
+			return false, errs;
+		end
+		if schema.minLength and utf8_len(data) < schema.minLength then
+			table.insert(errs, mkerr(sloc.."/maxLength", iloc, "string too short"))
+			return false, errs;
+		end
+		if schema.luaPattern and not data:match(schema.luaPattern) then
+			table.insert(errs, mkerr(sloc.."/luaPattern", iloc, "string does not match pattern"))
+			return false, errs;
+		end
+	end
+
+	if data is number then
+		if schema.multipleOf and (data == 0 or data % schema.multipleOf ~= 0) then
+			table.insert(errs, mkerr(sloc.."/luaPattern", iloc, "not a multiple"))
+			return false, errs;
+		end
+
+		if schema.maximum and not ( data <= schema.maximum ) then
+			table.insert(errs, mkerr(sloc.."/maximum", iloc, "number exceeds maximum"))
+			return false, errs;
+		end
+
+		if schema.exclusiveMaximum and not ( data < schema.exclusiveMaximum ) then
+			table.insert(errs, mkerr(sloc.."/exclusiveMaximum", iloc, "number exceeds exclusive maximum"))
+			return false, errs;
+		end
+
+		if schema.minimum and not ( data >= schema.minimum ) then
+			table.insert(errs, mkerr(sloc.."/minimum", iloc, "number below minimum"))
+			return false, errs;
+		end
+
+		if schema.exclusiveMinimum and not ( data > schema.exclusiveMinimum ) then
+			table.insert(errs, mkerr(sloc.."/exclusiveMinimum", iloc, "number below exclusive minimum"))
+			return false, errs;
+		end
+	end
+
+	if schema.allOf then
+		for i, sub in ipairs(schema.allOf) do
+			if not validate(sub, data, root, sloc.."/allOf/"..i, iloc, errs) then
+				table.insert(errs, mkerr(sloc.."/allOf", iloc, "did not match all subschemas"))
+				return false, errs;
+			end
+		end
+	end
+
+	if schema.oneOf then
+		local valid = 0
+		for i, sub in ipairs(schema.oneOf) do
+			if validate(sub, data, root, sloc.."/oneOf"..i, iloc, errs) then
+				valid = valid + 1
+			end
+		end
+		if valid ~= 1 then
+			table.insert(errs, mkerr(sloc.."/oneOf", iloc, "did not match exactly one subschema"))
+			return false, errs;
+		end
+	end
+
+	if schema.anyOf then
+		local match = false
+		for i, sub in ipairs(schema.anyOf) do
+			if validate(sub, data, root, sloc.."/anyOf/"..i, iloc, errs) then
+				match = true
+				break
+			end
+		end
+		if not match then
+			table.insert(errs, mkerr(sloc.."/anyOf", iloc, "did not match any subschema"))
+			return false, errs;
+		end
+	end
+
+	if schema["not"] then
+		if validate(schema["not"], data, root, sloc.."/not", iloc, errs) then
+			table.insert(errs, mkerr(sloc.."/not", iloc, "did match subschema"))
+			return false, errs;
+		end
+	end
+
+	if schema["if"] ~= nil then
+		if validate(schema["if"], data, root, sloc.."/if", iloc, errs) then
+			if schema["then"] then
+				if not validate(schema["then"], data, root, sloc.."/then", iloc, errs) then
+					table.insert(errs, mkerr(sloc.."/then", iloc, "did not match subschema"))
+					return false, errs;
+				end
+			end
+		else
+			if schema["else"] then
+				if not validate(schema["else"], data, root, sloc.."/else", iloc, errs) then
+					table.insert(errs, mkerr(sloc.."/else", iloc, "did not match subschema"))
+					return false, errs;
+				end
+			end
+		end
+	end
+
+	if schema.const ~= nil and schema.const ~= data then
+		table.insert(errs, mkerr(sloc.."/const", iloc, "did not match constant value"))
+		return false, errs;
+	end
+
+	if data is table then
+		-- tables combine object and array behavior, thus we do both kinds of
+		-- validations in this block, which could be useful for validating Lua
+		-- tables
+
+		if schema.maxItems and #(data as {any}) > schema.maxItems then
+			table.insert(errs, mkerr(sloc.."/maxItems", iloc, "too many items"))
+			return false, errs;
+		end
+
+		if schema.minItems and #(data as {any}) < schema.minItems then
+			table.insert(errs, mkerr(sloc.."/minItems", iloc, "too few items"))
+			return false, errs;
+		end
+
+		if schema.required then
+			for _, k in ipairs(schema.required) do
+				if data[k] == nil then
+					table.insert(errs, mkerr(sloc.."/required", iloc.."/"..tostring(k), "missing required property"))
+					return false, errs;
+				end
+			end
+		end
+
+		if schema.dependentRequired then
+			for k, reqs in pairs(schema.dependentRequired) do
+				if data[k] ~= nil then
+					for _, req in ipairs(reqs) do
+						if data[req] == nil then
+							table.insert(errs, mkerr(sloc.."/dependentRequired", iloc, "missing dependent required property"))
+							return false, errs;
+						end
+					end
+				end
+			end
+		end
+
+		if schema.propertyNames ~= nil then
+			-- could be used to validate non-string keys of Lua tables
+			for k in pairs(data) do
+				if not validate(schema.propertyNames, k, root, sloc.."/propertyNames", iloc.."/"..tostring(k), errs) then
+					table.insert(errs, mkerr(sloc.."/propertyNames", iloc.."/"..tostring(k), "a property name did not match subschema"))
+					return false, errs;
+				end
+			end
+		end
+
+		-- additionalProperties applies to properties not validated by properties
+		-- or patternProperties, so we must keep track of properties validated by
+		-- the later
+		local seen_properties : { string : boolean } = {}
+
+		if schema.properties then
+			for k, sub in pairs(schema.properties) do
+				if data[k] ~= nil and not validate(sub, data[k], root, sloc.."/"..tostring(k), iloc.."/"..tostring(k), errs) then
+					table.insert(errs, mkerr(sloc.."/"..tostring(k), iloc.."/"..tostring(k), "a property did not match subschema"))
+					return false, errs;
+				end
+				seen_properties[k] = true
+			end
+		end
+
+		if schema.luaPatternProperties then
+			-- like patternProperties, but Lua patterns
+			for pattern, sub in pairs(schema.luaPatternProperties) do
+				for k in pairs(data) do
+					if k is string and k:match(pattern) then
+						if not validate(sub, data[k], root, sloc.."/luaPatternProperties", iloc, errs) then
+							table.insert(errs, mkerr(sloc.."/luaPatternProperties/"..pattern, iloc.."/"..tostring(k), "a property did not match subschema"))
+							return false, errs;
+						end
+						seen_properties[k] = true
+					end
+				end
+			end
+		end
+
+		if schema.additionalProperties ~= nil then
+			for k, v in pairs(data) do
+				if not seen_properties[k as string] then
+					if not validate(schema.additionalProperties, v, root, sloc.."/additionalProperties", iloc.."/"..tostring(k), errs) then
+						table.insert(errs, mkerr(sloc.."/additionalProperties", iloc.."/"..tostring(k), "additional property did not match subschema"))
+						return false, errs;
+					end
+				end
+			end
+		end
+
+		if schema.dependentSchemas then
+			for k, sub in pairs(schema.dependentSchemas) do
+				if data[k] ~= nil and not validate(sub, data, root, sloc.."/dependentSchemas/"..k, iloc, errs) then
+					table.insert(errs, mkerr(sloc.."/dependentSchemas", iloc.."/"..tostring(k), "did not match dependent subschema"))
+					return false, errs;
+				end
+			end
+		end
+
+		if schema.uniqueItems then
+			-- only works for scalars, would need to deep-compare for objects/arrays/tables
+			local values : { any : boolean } = {}
+			for _, v in pairs(data) do
+				if values[v] then
+					table.insert(errs, mkerr(sloc.."/uniqueItems", iloc, "had duplicate items"))
+					return false, errs;
+				end
+				values[v] = true
+			end
+		end
+
+		local p = 0
+		if schema.prefixItems ~= nil then
+			for i, s in ipairs(schema.prefixItems) do
+				if data[i] == nil then
+					break
+				elseif validate(s, data[i], root, sloc.."/prefixItems/"..i, iloc.."/"..i, errs) then
+					p = i
+				else
+					table.insert(errs, mkerr(sloc.."/prefixItems/"..i, iloc.."/"..tostring(i), "did not match subschema"))
+					return false, errs;
+				end
+			end
+		end
+
+		if schema.items ~= nil then
+			for i = p+1, #(data as {any}) do
+				if not validate(schema.items, data[i], root, sloc, iloc.."/"..i, errs) then
+					table.insert(errs, mkerr(sloc.."/prefixItems/"..i, iloc.."/"..i, "did not match subschema"))
+					return false, errs;
+				end
+			end
+		end
+
+		if schema.contains ~= nil then
+			local found = 0
+			for i = 1, #(data as {any}) do
+				if validate(schema.contains, data[i], root, sloc.."/contains", iloc.."/"..i, errs) then
+					found = found + 1
+				else
+					table.insert(errs, mkerr(sloc.."/contains", iloc.."/"..i, "did not match subschema"))
+				end
+			end
+			if found < (schema.minContains or 1) then
+				table.insert(errs, mkerr(sloc.."/minContains", iloc, "too few matches"))
+				return false, errs;
+			elseif found > (schema.maxContains or math.huge) then
+				table.insert(errs, mkerr(sloc.."/maxContains", iloc, "too many matches"))
+				return false, errs;
+			end
+		end
+	end
+
+	return true;
+end
+
+
+json_schema_object.validate = validate;
+
+return json_schema_object;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/jwt.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,38 @@
+local crypto = require "prosody.util.crypto"
+local record jwtlib
+	enum algorithm
+		"HS256"
+		"HS384"
+		"HS512"
+		"ES256"
+		"ES512"
+		"RS256"
+		"RS384"
+		"RS512"
+		"PS256"
+		"PS384"
+		"PS512"
+	end
+	type payload = { string : any }
+	type signer_t = function (payload : payload) : string
+	type verifier_t = function (token : string) : payload
+	enum key_type
+		"rsaEncryption"
+		"id-ecPublicKey"
+	end
+	record algorithm_t
+		sign : signer_t
+		verify : verifier_t
+		load_key : function (key : string) : crypto.key
+	end
+	init : function (algorithm, private_key : string, public_key : string, table) : signer_t, verifier_t
+	new_signer : function (algorithm, string, table) : signer_t
+	new_verifier : function (algorithm, string, table) : verifier_t
+	_algorithms : {
+		algorithm : algorithm_t
+	}
+	-- Deprecated
+	sign : function (private_key : string, payload) : string
+	verify : function (string) : payload
+end
+return jwtlib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/logger.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,18 @@
+local record util
+	enum loglevel
+		"debug"
+		"info"
+		"warn"
+		"error"
+	end
+	type logger = function ( loglevel, string, ...:any )
+	type sink = function ( string, loglevel, string, ...:any )
+	type simple_sink = function ( string, loglevel, string )
+	init : function ( string ) : logger
+	make_logger : function ( string, loglevel ) : function ( string, ...:any )
+	reset : function ()
+	add_level_sink : function ( loglevel, sink )
+	add_simple_sink : function ( simple_sink, { loglevel } )
+end
+
+return util
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/mathcompat.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,15 @@
+if not math.type then
+	local enum number_subtype
+		"float" "integer"
+	end
+	local function math_type(t:any) : number_subtype
+		if t is number then
+			if t % 1 == 0 and t ~= t+1 and t ~= t-1 then
+				return "integer"
+			else
+				return "float"
+			end
+		end
+	end
+	_G.math.type = math_type
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/net.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,13 @@
+
+local enum type_strings
+	"both"
+	"ipv4"
+	"ipv6"
+end
+
+local record lib
+	local_addresses : function (type_strings, boolean) : { string }
+	pton : function (string):string
+	ntop : function (string):string
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/poll.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,31 @@
+local record state
+	enum waiterr
+		"timeout"
+		"signal"
+	end
+	add : function (state, integer, boolean, boolean) : boolean
+	add : function (state, integer, boolean, boolean) : nil, string, integer
+	set : function (state, integer, boolean, boolean) : boolean
+	set : function (state, integer, boolean, boolean) : nil, string, integer
+	del : function (state, integer) : boolean
+	del : function (state, integer) : nil, string, integer
+	wait : function (state, integer) : integer, boolean, boolean
+	wait : function (state, integer) : nil, string, integer
+	wait : function (state, integer) : nil, waiterr
+	getfd : function (state) : integer
+end
+
+local record lib
+	new : function () : state
+	EEXIST : integer
+	EMFILE : integer
+	ENOENT : integer
+	enum api_backend
+		"epoll"
+		"poll"
+		"select"
+	end
+	api : api_backend
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/pposix.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,109 @@
+local record pposix
+	enum syslog_facility
+		"auth"
+		"authpriv"
+		"cron"
+		"daemon"
+		"ftp"
+		"kern"
+		"local0"
+		"local1"
+		"local2"
+		"local3"
+		"local4"
+		"local5"
+		"local6"
+		"local7"
+		"lpr"
+		"mail"
+		"syslog"
+		"user"
+		"uucp"
+	end
+
+	enum syslog_level
+		"debug"
+		"info"
+		"notice"
+		"warn"
+		"error"
+	end
+
+	enum ulimit_resource
+		"CORE"
+		"CPU"
+		"DATA"
+		"FSIZE"
+		"NOFILE"
+		"STACK"
+		"MEMLOCK"
+		"NPROC"
+		"RSS"
+		"NICE"
+	end
+
+	enum ulimit_unlimited
+		"unlimited"
+	end
+
+	type ulimit_limit = integer | ulimit_unlimited
+
+	record utsname
+		sysname         :  string
+		nodename        :  string
+		release         :  string
+		version         :  string
+		machine         :  string
+		domainname      :  string
+	end
+
+	record memoryinfo
+		allocated       :  integer
+		allocated_mmap  :  integer
+		used            :  integer
+		unused          :  integer
+		returnable      :  integer
+	end
+
+	abort : function ()
+
+	daemonize : function () : boolean, string
+
+	syslog_open : function (ident : string, facility : syslog_facility)
+	syslog_close : function ()
+	syslog_log : function (level : syslog_level, src : string, msg : string)
+	syslog_setminlevel : function (level : syslog_level)
+
+	getpid : function () : integer
+	getuid : function () : integer
+	getgid : function () : integer
+
+	setuid : function (uid : integer | string) : boolean, string -- string|integer
+	setgid : function (uid : integer | string) : boolean, string
+	initgroups : function (user : string, gid : integer) : boolean, string
+
+	umask : function (umask : string) : string
+
+	mkdir : function (dir : string) : boolean, string
+
+	setrlimit : function (resource : ulimit_resource, soft : ulimit_limit, hard : ulimit_limit) : boolean, string
+	getrlimit : function (resource : ulimit_resource) : boolean, ulimit_limit, ulimit_limit
+	getrlimit : function (resource : ulimit_resource) : boolean, string
+
+	uname : function () : utsname
+
+	setenv : function (key : string, value : string) : boolean
+
+	meminfo : function () : memoryinfo
+
+	atomic_append : function (f : FILE, s : string) : boolean, string, integer
+	remove_blocks : function (f : FILE, integer, integer)
+
+	isatty : function(FILE) : boolean
+
+	ENOENT : integer
+	_NAME : string
+	_VESRION : string
+end
+
+return pposix
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/promise.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,22 @@
+
+local record lib
+	type resolve_func = function (any)
+	type promise_body = function (resolve_func, resolve_func)
+
+	record Promise<A, B>
+		type on_resolved = function (A) : any
+		type on_rejected = function (B) : any
+		next : function (Promise, on_resolved, on_rejected) : Promise<any, any>
+	end
+
+	new : function (promise_body) : Promise
+	resolve : function (any) : Promise
+	reject : function (any) : Promise
+	all : function ({ Promise }) : Promise
+	all_settled : function ({ Promise }) : Promise
+	race : function ({ Promise }) : Promise
+	try : function
+	is_promise : function(any) : boolean
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/queue.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,21 @@
+local record lib
+	record queue<T>
+		size : integer
+		count : function (queue<T>) : integer
+		enum push_errors
+			"queue full"
+		end
+
+		push : function (queue<T>, T) : boolean, push_errors
+		pop : function (queue<T>) : T
+		peek : function (queue<T>) : T
+		replace : function (queue<T>, T) : boolean, push_errors
+		type iterator = function (T, integer) : integer, T
+		items : function (queue<T>) : iterator, T, integer
+		type consume_iter = function (queue<T>) : T
+		consume : function (queue<T>) : consume_iter
+	end
+
+	new : function<T> (size:integer, allow_wrapping:boolean) : queue<T>
+end
+return lib;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/random.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,4 @@
+local record lib
+	bytes : function (n:integer):string
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/ringbuffer.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,20 @@
+local record lib
+	record ringbuffer
+		find : function (ringbuffer, string) : integer
+		discard : function (ringbuffer, integer) : boolean
+		read : function (ringbuffer, integer, boolean) : string
+		readuntil : function (ringbuffer, string) : string
+		write : function (ringbuffer, string) : integer
+		size : function (ringbuffer) : integer
+		length : function (ringbuffer) : integer
+		sub : function (ringbuffer, integer, integer) : string
+		byte : function (ringbuffer, integer, integer) : integer...
+		free : function (ringbuffer) : integer
+	end
+
+	new : function (integer) : ringbuffer
+end
+
+return lib
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/roles.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,32 @@
+local record util_roles
+
+	type context = any
+
+	record Role
+		id : string
+		name : string
+		description : string
+		default : boolean
+		priority : number -- or integer?
+		permissions : { string : boolean }
+
+		may : function (Role, string, context)
+		clone : function (Role, role_config)
+		set_permission : function (Role, string, boolean, boolean)
+	end
+
+	is_role : function (any) : boolean
+
+	record role_config
+		name : string
+		description : string
+		default : boolean
+		priority : number -- or integer?
+		inherits : { Role }
+		permissions : { string : boolean }
+	end
+
+	new : function (role_config, Role) : Role
+end
+
+return util_roles
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/serialization.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,34 @@
+local record _M
+	enum preset
+		"debug"
+		"oneline"
+		"compact"
+		"pretty"
+	end
+	type fallback = function (any, string) : string
+	record config
+		preset : preset
+		fallback :  fallback
+		fatal : boolean
+		keywords : { string : boolean }
+		indentwith : string
+		itemstart : string
+		itemsep : string
+		itemlast : string
+		tstart : string
+		tend : string
+		kstart : string
+		kend : string
+		equals : string
+		unquoted : boolean | string
+		hex : string
+		freeze : boolean
+		maxdepth : integer
+		multirefs : boolean
+		table_pairs : function
+	end
+	type serializer = function (any) : string
+	new : function (config|preset) : serializer
+	serialize : function (any, config|preset) : string
+end
+return _M
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/set.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,22 @@
+local record lib
+	record Set<T>
+		add : function<T> (Set<T>, T)
+		contains : function<T> (Set<T>, T) : boolean
+		contains_set : function<T> (Set<T>, Set<T>) : boolean
+		items :  function<T> (Set<T>) : function<T> (Set<T>, T) : T
+		remove : function<T> (Set<T>, T)
+		add_list : function<T> (Set<T>, { T })
+		include : function<T> (Set<T>, Set<T>)
+		exclude : function<T> (Set<T>, Set<T>)
+		empty : function<T> (Set<T>) : boolean
+	end
+
+	new : function<T> ({ T }) : Set<T>
+	is_set : function (any) : boolean
+	union : function<T> (Set<T>, Set<T>) : Set <T>
+	difference : function<T> (Set<T>, Set<T>) : Set <T>
+	intersection : function<T> (Set<T>, Set<T>) : Set <T>
+	xor : function<T> (Set<T>, Set<T>) : Set <T>
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/signal.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,41 @@
+local record lib
+	enum Signal
+		"SIGABRT"
+		"SIGALRM"
+		"SIGBUS"
+		"SIGCHLD"
+		"SIGCLD"
+		"SIGCONT"
+		"SIGFPE"
+		"SIGHUP"
+		"SIGILL"
+		"SIGINT"
+		"SIGIO"
+		"SIGIOT"
+		"SIGKILL"
+		"SIGPIPE"
+		"SIGPOLL"
+		"SIGPROF"
+		"SIGQUIT"
+		"SIGSEGV"
+		"SIGSTKFLT"
+		"SIGSTOP"
+		"SIGSYS"
+		"SIGTERM"
+		"SIGTRAP"
+		"SIGTTIN"
+		"SIGTTOU"
+		"SIGURG"
+		"SIGUSR1"
+		"SIGUSR2"
+		"SIGVTALRM"
+		"SIGWINCH"
+		"SIGXCPU"
+		"SIGXFSZ"
+	end
+	signal : function (integer | Signal, function, boolean) : boolean
+	raise : function (integer | Signal)
+	kill : function (integer, integer | Signal)
+	-- enum : integer
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/smqueue.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,99 @@
+local queue = require "prosody.util.queue";
+
+local record lib
+	-- T would typically be util.stanza
+	record smqueue<T>
+		_queue : queue.queue<T>
+		_head : integer
+		_tail : integer
+
+		enum ack_errors
+			"tail"
+			"head"
+			"pop"
+		end
+		push : function (smqueue, T)
+		ack : function (smqueue, integer) : { T }, ack_errors
+		resumable : function (smqueue<T>) : boolean
+		resume : function (smqueue<T>)  : queue.queue.iterator, any, integer
+		type consume_iter = function (smqueue<T>) : T
+		consume : function (smqueue<T>) : consume_iter
+
+		table : function (smqueue<T>) : { T }
+	end
+	new : function <T>(integer) : smqueue<T>
+end
+
+local type smqueue = lib.smqueue;
+
+function smqueue:push(v)
+	self._head = self._head + 1;
+	-- Wraps instead of errors
+	assert(self._queue:push(v));
+end
+
+function smqueue:ack(h : integer) : { any }, smqueue.ack_errors
+	if h < self._tail then
+		return nil, "tail";
+	elseif h > self._head then
+		return nil, "head";
+	end
+	-- TODO optimize? cache table fields
+	local acked = {};
+	self._tail = h;
+	local expect = self._head - self._tail;
+	while expect < self._queue:count() do
+		local v = self._queue:pop();
+		if not v then return nil, "pop"; end
+		table.insert(acked, v);
+	end
+	return acked;
+end
+
+function smqueue:count_unacked() : integer
+	return self._head - self._tail;
+end
+
+function smqueue:count_acked() : integer
+	return self._tail;
+end
+
+function smqueue:resumable() : boolean
+	return self._queue:count() >= (self._head - self._tail);
+end
+
+function smqueue:resume() : queue.queue.iterator, any, integer
+	return self._queue:items();
+end
+
+function smqueue:consume() : queue.queue.consume_iter
+	return self._queue:consume()
+end
+
+-- Compatibility layer, plain ol' table
+function smqueue:table() : { any }
+	local t : { any } = {};
+	for i, v in self:resume() do
+		t[i] = v;
+	end
+	return t;
+end
+
+local function freeze(q : smqueue<any>) : { string:integer }
+	return { head = q._head, tail = q._tail }
+end
+
+local queue_mt = {
+	--
+	__name = "smqueue";
+	__index = smqueue;
+	__len = smqueue.count_unacked;
+	__freeze = freeze;
+}
+
+function lib.new<T>(size : integer) : queue.queue<T>
+	assert(size>0);
+	return setmetatable({ _head = 0; _tail = 0; _queue = queue.new(size, true) }, queue_mt);
+end
+
+return lib;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/stanza.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,145 @@
+local record lib
+
+	type children_iter = function ( stanza_t ) : stanza_t
+	type childtags_iter = function () : stanza_t
+	type maptags_cb = function ( stanza_t ) : stanza_t
+
+
+	enum stanza_error_type
+		"auth"
+		"cancel"
+		"continue"
+		"modify"
+		"wait"
+	end
+	enum stanza_error_condition
+		"bad-request"
+		"conflict"
+		"feature-not-implemented"
+		"forbidden"
+		"gone"
+		"internal-server-error"
+		"item-not-found"
+		"jid-malformed"
+		"not-acceptable"
+		"not-allowed"
+		"not-authorized"
+		"policy-violation"
+		"recipient-unavailable"
+		"redirect"
+		"registration-required"
+		"remote-server-not-found"
+		"remote-server-timeout"
+		"resource-constraint"
+		"service-unavailable"
+		"subscription-required"
+		"undefined-condition"
+		"unexpected-request"
+	end
+
+	record stanza_t
+		name : string
+		attr : { string : string }
+		{ stanza_t | string }
+		tags : { stanza_t }
+
+		query : function ( stanza_t, string ) : stanza_t
+		body : function ( stanza_t, string, { string : string } ) : stanza_t
+		text_tag : function ( stanza_t, string, string, { string : string } ) : stanza_t
+		tag : function ( stanza_t, string, { string : string } ) : stanza_t
+		text : function ( stanza_t, string ) : stanza_t
+		up : function ( stanza_t ) : stanza_t
+		at_top : function ( stanza_t ) : boolean
+		reset : function ( stanza_t ) : stanza_t
+		add_direct_child : function ( stanza_t, stanza_t )
+		add_child : function ( stanza_t, stanza_t )
+		remove_children : function ( stanza_t, string, string ) : stanza_t
+
+		get_child : function ( stanza_t, string, string ) : stanza_t
+		get_text : function ( stanza_t ) : string
+		get_child_text : function ( stanza_t, string, string ) : string
+		get_child_attr : function ( stanza_t, string, string ) : string
+		get_child_with_attr : function ( stanza_t, string, string, string, function (string) : boolean ) : string
+		child_with_name : function ( stanza_t, string, string ) : stanza_t
+		child_with_ns : function ( stanza_t, string, string ) : stanza_t
+		children : function ( stanza_t ) : children_iter, stanza_t, integer
+		childtags : function ( stanza_t, string, string ) : childtags_iter
+		maptags : function ( stanza_t, maptags_cb ) : stanza_t
+		find : function ( stanza_t, string ) : stanza_t | string
+
+		top_tag : function ( stanza_t ) : string
+		pretty_print : function ( stanza_t ) : string
+		pretty_top_tag : function ( stanza_t ) : string
+
+		-- FIXME Represent util.error support
+		get_error : function ( stanza_t ) : stanza_error_type, stanza_error_condition, string, stanza_t
+		add_error : function ( stanza_t, stanza_error_type, stanza_error_condition, string, string )
+		indent : function ( stanza_t, integer, string ) : stanza_t
+	end
+
+	record serialized_stanza_t
+		name : string
+		attr : { string : string }
+		{ serialized_stanza_t | string }
+	end
+
+	record message_attr
+		["xml:lang"] : string
+		from : string
+		id : string
+		to : string
+		type : message_type
+		enum message_type
+			"chat"
+			"error"
+			"groupchat"
+			"headline"
+			"normal"
+		end
+	end
+
+	record presence_attr
+		["xml:lang"] : string
+		from : string
+		id : string
+		to : string
+		type : presence_type
+		enum presence_type
+			"error"
+			"probe"
+			"subscribe"
+			"subscribed"
+			"unsubscribe"
+			"unsubscribed"
+		end
+	end
+
+	record iq_attr
+		["xml:lang"] : string
+		from : string
+		id : string
+		to : string
+		type : iq_type
+		enum iq_type
+			"error"
+			"get"
+			"result"
+			"set"
+		end
+	end
+
+	stanza : function ( string, { string : string } ) : stanza_t
+	is_stanza : function ( any ) : boolean
+	preserialize : function ( stanza_t ) : serialized_stanza_t
+	deserialize : function ( serialized_stanza_t ) : stanza_t
+	clone : function ( stanza_t, boolean ) : stanza_t
+	message : function ( message_attr, string ) : stanza_t
+	iq : function ( iq_attr ) : stanza_t
+	reply : function ( stanza_t ) : stanza_t
+	error_reply : function ( stanza_t, stanza_error_type, stanza_error_condition, string, string ) : stanza_t
+	presence : function ( presence_attr ) : stanza_t
+	xml_escape : function ( string ) : string
+	pretty_print : function ( string ) : string
+end
+
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/strbitop.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,7 @@
+local record mod
+	sand : function (string, string) : string
+	sor : function (string, string) : string
+	sxor : function (string, string) : string
+	common_prefix_bits : function (string, string) : integer
+end
+return mod
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/struct.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,6 @@
+local record lib
+	pack : function (string, ...:any) : string
+	unpack : function(string, string, integer) : any...
+	size : function(string) : integer
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/table.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,7 @@
+local record lib
+	create : function (narr:integer, nrec:integer):table
+	pack : function (...:any):{any}
+	move : function (table, integer, integer, integer, table) : table
+end
+return lib
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/termcolours.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,7 @@
+local record lib
+	getstring : function (string, string) : string
+	getstyle : function (...:string) : string
+	setstyle : function (string) : string
+	tohtml :  function (string) : string
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/time.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,6 @@
+
+local record lib
+	now : function () : number
+	monotonic : function () : number
+end
+return lib
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/timer.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,8 @@
+local record util_timer
+	record task end
+	type timer_callback = function (number) : number
+	add_task : function ( number, timer_callback, any ) : task
+	stop : function ( task )
+	reschedule : function ( task, number ) : task
+end
+return util_timer
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/uuid.d.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,8 @@
+local record lib
+	get_nibbles : function (number) : string
+	generate : function () : string
+
+	seed : function (string)
+end
+return lib
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/xtemplate.tl	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,109 @@
+-- render(template, stanza) --> string
+-- {path} --> stanza:find(path)
+-- {{ns}name/child|each({ns}name){sub-template}}
+
+--[[
+template ::= "{" path ("|" name ("(" args ")")? (template)? )* "}"
+path ::= defined by util.stanza
+name ::= %w+
+args ::= anything with balanced ( ) pairs
+]]
+
+local s_gsub = string.gsub;
+local s_match = string.match;
+local s_sub = string.sub;
+local t_concat = table.concat;
+
+local st = require "prosody.util.stanza";
+
+local type escape_t = function (string) : string
+local type filter_t = function (string, string | st.stanza_t, string) : string | st.stanza_t, boolean
+local type filter_coll = { string : filter_t }
+
+local function render(template : string, root : st.stanza_t, escape : escape_t, filters : filter_coll) : string
+	escape = escape or st.xml_escape;
+
+	return (s_gsub(template, "(%s*)(%b{})(%s*)", function(pre_blank : string, block : string, post_blank : string) : string
+		local inner = s_sub(block, 2, -2);
+		if inner:sub(1, 1) == "-" then
+			pre_blank = "";
+			inner = inner:sub(2);
+		end
+		if inner:sub(-1, -1) == "-" then
+			post_blank = "";
+			inner = inner:sub(1, -2);
+		end
+		local path, pipe, pos = s_match(inner, "^([^|]+)(|?)()");
+		if not path is string then return end
+		local value : string | st.stanza_t
+		if path == "." then
+			value = root;
+		elseif path == "#" then
+			value = root:get_text();
+		else
+			value = root:find(path);
+		end
+		local is_escaped = false;
+
+		while pipe == "|" do
+			local func, args, tmpl, p = s_match(inner, "^(%w+)(%b())(%b{})()", pos as integer);
+			if not func then func, args, p = s_match(inner, "^(%w+)(%b())()", pos as integer); end
+			if not func then func, tmpl, p = s_match(inner, "^(%w+)(%b{})()", pos as integer); end
+			if not func then func, p = s_match(inner, "^(%w+)()", pos as integer); end
+			if not func then break end
+			if tmpl then tmpl = s_sub(tmpl, 2, -2); end
+			if args then args = s_sub(args, 2, -2); end
+
+			if func == "each" and tmpl and st.is_stanza(value) then
+				if not args then value, args = root, path; end
+				local ns, name = s_match(args, "^(%b{})(.*)$");
+				if ns then ns = s_sub(ns, 2, -2); else name, ns = args, nil; end
+				if ns == "" then ns = nil; end
+				if name == "" then name = nil; end
+				local out, i = {}, 1;
+				for c in (value as st.stanza_t):childtags(name, ns) do
+					out[i], i = render(tmpl, c, escape, filters), i + 1;
+				end
+				value = t_concat(out);
+				is_escaped = true;
+			elseif func == "and" and tmpl then
+				local condition = value;
+				if args then condition = root:find(args); end
+				if condition then
+					value = render(tmpl, root, escape, filters);
+					is_escaped = true;
+				end
+			elseif func == "or" and tmpl then
+				local condition = value;
+				if args then condition = root:find(args); end
+				if not condition then
+					value = render(tmpl, root, escape, filters);
+					is_escaped = true;
+				end
+			elseif filters and filters[func] then
+				local f = filters[func];
+				if args == nil then
+					value, is_escaped = f(value, tmpl);
+				else
+					value, is_escaped = f(args, value, tmpl);
+				end
+			else
+				error("No such filter function: " .. func);
+			end
+			pipe, pos = s_match(inner, "^(|?)()", p as integer);
+		end
+
+		if value is string then
+			if not is_escaped then value = escape(value); end
+			return pre_blank .. value .. post_blank;
+		elseif st.is_stanza(value) then
+			value = value:get_text();
+			if value then
+				return pre_blank .. escape(value) .. post_blank;
+			end
+		end
+		return pre_blank .. post_blank;
+	end));
+end
+
+return { render = render };
--- a/teal-src/util/compat.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-local record lib
-	xpcall : function (function, function, ...:any):boolean, any
-end
-return lib
--- a/teal-src/util/crand.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
-local record lib
-	bytes : function (n : integer) : string
-	enum sourceid "OpenSSL" "arc4random()" "Linux" end
-	_source : sourceid
-end
-return lib
--- a/teal-src/util/dataforms.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,52 +0,0 @@
-local stanza_t = require "util.stanza".stanza_t
-
-local enum form_type
-	"form"
-	"submit"
-	"cancel"
-	"result"
-end
-
-local enum field_type
-	"boolean"
-	"fixed"
-	"hidden"
-	"jid-multi"
-	"jid-single"
-	"list-multi"
-	"list-single"
-	"text-multi"
-	"text-private"
-	"text-single"
-end
-
-local record form_field
-
-	type : field_type
-	var : string -- protocol name
-	name :  string -- internal name
-
-	label : string
-	desc : string
-
-	datatype : string
-	range_min : number
-	range_max : number
-
-	value : any -- depends on field_type
-	options : table
-end
-
-local record dataform
-	title : string
-	instructions : string
-	{ form_field } -- XXX https://github.com/teal-language/tl/pull/415
-
-	form : function ( dataform, table, form_type ) : stanza_t
-end
-
-local record lib
-	new : function ( dataform ) : dataform
-end
-
-return lib
--- a/teal-src/util/datamapper.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,381 +0,0 @@
--- Copyright (C) 2021 Kim Alvefur
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
--- Based on
--- https://json-schema.org/draft/2020-12/json-schema-core.html
--- https://json-schema.org/draft/2020-12/json-schema-validation.html
--- http://spec.openapis.org/oas/v3.0.1#xmlObject
--- https://github.com/OAI/OpenAPI-Specification/issues/630 (text:true)
---
--- XML Object Extensions:
--- text to refer to the text content at the same time as attributes
--- x_name_is_value for enum fields where the <tag-name/> is the value
--- x_single_attribute for <tag attr="this"/>
---
--- TODO pointers
--- TODO cleanup / refactor
--- TODO s/number/integer/ once we have appropriate math.type() compat
---
-
-local st = require "util.stanza";
-local json = require"util.json"
-local pointer = require"util.jsonpointer";
-
-local json_type_name = json.json_type_name;
-local json_schema_object = require "util.jsonschema"
-local type schema_t = boolean | json_schema_object
-
-local function toboolean ( s : string ) : boolean
-	if s == "true" or s == "1" then
-		return true
-	elseif s == "false" or s == "0" then
-		return false
-	elseif s then
-		return true
-	end
-end
-
-local function totype(t : json_type_name, s : string) : any
-	if not s then return nil end
-	if t == "string" then
-		return s;
-	elseif t == "boolean" then
-		return toboolean(s)
-	elseif t == "number" or t == "integer" then
-		return tonumber(s)
-	end
-end
-
-local enum value_goes
-	"in_tag_name"
-	"in_text"
-	"in_text_tag"
-	"in_attribute"
-	"in_single_attribute"
-	"in_children"
-	"in_wrapper"
-end
-
-local function resolve_schema(schema  : schema_t, root : json_schema_object) : schema_t
-	if schema is json_schema_object then
-		if schema["$ref"] and schema["$ref"]:sub(1, 1) == "#" then
-			return pointer.resolve(root as table, schema["$ref"]:sub(2)) as schema_t;
-		end
-	end
-	return schema;
-end
-
-local function guess_schema_type(schema : json_schema_object) : json_type_name
-	local schema_types = schema.type
-	if schema_types is json_type_name then
-		return schema_types
-	elseif schema_types ~= nil then
-		error "schema has unsupported 'type' property"
-	elseif schema.properties then
-		return "object"
-	elseif schema.items then
-		return "array"
-	end
-	return "string" -- default assumption
-end
-
-local function unpack_propschema( propschema : schema_t, propname : string, current_ns : string )
-		: json_type_name, value_goes, string, string, string, string, { any }
-	local proptype : json_type_name = "string"
-	local value_where : value_goes = propname and "in_text_tag" or "in_text"
-	local name = propname
-	local namespace : string
-	local prefix : string
-	local single_attribute : string
-	local enums : { any }
-
-	if propschema is json_schema_object then
-		proptype = guess_schema_type(propschema);
-	elseif propschema is string then -- Teal says this can never be a string, but it could before so best be sure
-		error("schema as string is not supported: "..propschema.." {"..current_ns.."}"..propname)
-	end
-
-	if proptype == "object" or proptype == "array" then
-		value_where = "in_children"
-	end
-
-	if propschema is json_schema_object then
-		local xml = propschema.xml
-		if xml then
-			if xml.name then
-				name = xml.name
-			end
-			if xml.namespace and xml.namespace ~= current_ns then
-				namespace = xml.namespace
-			end
-			if xml.prefix then
-				prefix = xml.prefix
-			end
-			if proptype == "array" and xml.wrapped then
-				value_where = "in_wrapper"
-			elseif xml.attribute then
-				value_where = "in_attribute"
-			elseif xml.text then
-				value_where = "in_text"
-			elseif xml.x_name_is_value then
-				value_where = "in_tag_name"
-			elseif xml.x_single_attribute then
-				single_attribute = xml.x_single_attribute
-				value_where = "in_single_attribute"
-			end
-		end
-		if propschema["const"] then
-			enums = { propschema["const"] }
-		elseif propschema["enum"] then
-			enums = propschema["enum"]
-		end
-	end
-
-	if current_ns == "urn:xmpp:reactions:0" and name == "reactions" then
-		assert(proptype=="array")
-	end
-
-	return proptype, value_where, name, namespace, prefix, single_attribute, enums
-end
-
-local parse_object : function (schema : schema_t, s : st.stanza_t, root : json_schema_object) : { string : any }
-local parse_array : function (schema : schema_t, s : st.stanza_t, root : json_schema_object) : { any }
-
-local function extract_value (s : st.stanza_t, value_where : value_goes, proptype : json.json_type_name, name : string, namespace : string, prefix : string, single_attribute : string, enums : { any }) : string
-	if value_where == "in_tag_name" then
-		local c : st.stanza_t
-		if proptype == "boolean" then
-			c = s:get_child(name, namespace);
-		elseif enums and proptype == "string" then
-			-- XXX O(n²) ?
-			-- Probably better to flip the table and loop over :childtags(nil, ns), should be 2xO(n)
-			-- BUT works first, optimize later
-			for i = 1, #enums do
-				c = s:get_child(enums[i] as string, namespace);
-				if c then break end
-			end
-		else
-			c = s:get_child(nil, namespace);
-		end
-		if c then
-			return c.name;
-		end
-	elseif value_where == "in_attribute" then
-		local attr = name
-		if prefix then
-			attr = prefix .. ':' .. name
-		elseif namespace and namespace ~= s.attr.xmlns then
-			attr = namespace .. "\1" .. name
-		end
-		return s.attr[attr]
-
-	elseif value_where == "in_text" then
-		return s:get_text()
-
-	elseif value_where == "in_single_attribute" then
-		local c = s:get_child(name, namespace)
-		return c and c.attr[single_attribute]
-	elseif value_where == "in_text_tag" then
-		return s:get_child_text(name, namespace)
-	end
-end
-
-function parse_object (schema : schema_t, s : st.stanza_t, root : json_schema_object) : { string : any }
-	local out : { string : any } = {}
-	schema = resolve_schema(schema, root)
-	if schema is json_schema_object and schema.properties then
-		for prop, propschema in pairs(schema.properties) do
-			propschema = resolve_schema(propschema, root)
-
-			local proptype, value_where, name, namespace, prefix, single_attribute, enums = unpack_propschema(propschema, prop, s.attr.xmlns)
-
-			if value_where == "in_children" and propschema is json_schema_object then
-				if proptype == "object" then
-					local c = s:get_child(name, namespace)
-					if c then
-						out[prop] = parse_object(propschema, c, root);
-					end
-				elseif proptype == "array" then
-					local a = parse_array(propschema, s, root);
-					if a and a[1] ~= nil then
-						out[prop] = a;
-					end
-				else
-					error "unreachable"
-				end
-			elseif value_where == "in_wrapper" and propschema is json_schema_object and proptype == "array" then
-				local wrapper = s:get_child(name, namespace);
-				if wrapper then
-					out[prop] = parse_array(propschema, wrapper, root);
-				end
-			else
-				local value : string = extract_value (s, value_where, proptype, name, namespace, prefix, single_attribute, enums)
-
-				out[prop] = totype(proptype, value)
-			end
-		end
-	end
-
-	return out
-end
-
-function parse_array (schema : json_schema_object, s : st.stanza_t, root : json_schema_object) : { any }
-	local itemschema : schema_t = resolve_schema(schema.items, root);
-	local proptype, value_where, child_name, namespace, prefix, single_attribute, enums = unpack_propschema(itemschema, nil, s.attr.xmlns)
-	local attr_name : string
-	if value_where == "in_single_attribute" then -- FIXME this shouldn't be needed
-		value_where = "in_attribute";
-		attr_name = single_attribute;
-	end
-	local out : { any } = {}
-
-	if proptype == "object" then
-		if itemschema is json_schema_object then
-			for c in s:childtags(child_name, namespace) do
-				table.insert(out, parse_object(itemschema, c, root));
-			end
-		else
-			error "array items must be schema object"
-		end
-	elseif proptype == "array" then
-		if itemschema is json_schema_object then
-			for c in s:childtags(child_name, namespace) do
-				table.insert(out, parse_array(itemschema, c, root));
-			end
-		end
-	else
-		for c in s:childtags(child_name, namespace) do
-			local value : string = extract_value (c, value_where, proptype, attr_name or child_name, namespace, prefix, single_attribute, enums)
-
-			table.insert(out, totype(proptype, value));
-		end
-	end
-	return out;
-end
-
-local function parse (schema : json_schema_object, s : st.stanza_t) : table
-	local s_type = guess_schema_type(schema)
-	if s_type == "object" then
-		return parse_object(schema, s, schema)
-	elseif s_type == "array" then
-		return parse_array(schema, s, schema)
-	else
-		error "top-level scalars unsupported"
-	end
-end
-
-local function toxmlstring(proptype : json_type_name, v : any) : string
-	if proptype == "string" and v is string then
-		return  v
-	elseif proptype == "number" and v is number then
-		return  string.format("%g", v)
-	elseif proptype == "integer" and v is number then -- TODO is integer
-		return  string.format("%d", v)
-	elseif proptype == "boolean" then
-		return  v and "1" or "0"
-	end
-end
-
-local unparse : function (json_schema_object, table, string, string, st.stanza_t, json_schema_object) : st.stanza_t
-
-local function unparse_property(out : st.stanza_t, v : any, proptype : json_type_name, propschema : schema_t, value_where : value_goes, name : string, namespace : string, current_ns : string, prefix : string, single_attribute : string, root : json_schema_object)
-
-	if value_where == "in_attribute" then
-		local attr = name
-		if prefix then
-			attr = prefix .. ':' .. name
-		elseif namespace and namespace ~= current_ns then
-			attr = namespace .. "\1" .. name
-		end
-
-		out.attr[attr] = toxmlstring(proptype, v)
-	elseif value_where == "in_text" then
-		out:text(toxmlstring(proptype, v))
-	elseif value_where == "in_single_attribute" then
-		assert(single_attribute)
-		local propattr : { string : string } = {}
-
-		if namespace and namespace ~= current_ns then
-			propattr.xmlns = namespace
-		end
-
-		propattr[single_attribute] = toxmlstring(proptype, v)
-		out:tag(name, propattr):up();
-
-	else
-		local propattr : { string : string }
-		if namespace ~= current_ns then
-			propattr = { xmlns = namespace }
-		end
-		if value_where == "in_tag_name" then
-			if proptype == "string" and v is string then
-				out:tag(v, propattr):up();
-			elseif proptype == "boolean" and v == true then
-				out:tag(name, propattr):up();
-			end
-		elseif proptype == "object" and propschema is json_schema_object and v is table then
-			local c = unparse(propschema, v, name, namespace, nil, root);
-			if c then
-				out:add_direct_child(c);
-			end
-		elseif proptype == "array" and propschema is json_schema_object and v is table then
-			if value_where == "in_wrapper" then
-				local c = unparse(propschema, v, name, namespace, nil, root);
-				if c then
-					out:add_direct_child(c);
-				end
-			else
-				unparse(propschema, v, name, namespace, out, root);
-			end
-		else
-			out:text_tag(name, toxmlstring(proptype, v), propattr)
-		end
-	end
-end
-
-function unparse ( schema : json_schema_object, t : table, current_name : string, current_ns : string, ctx : st.stanza_t, root : json_schema_object ) : st.stanza_t
-
-	if root == nil then root = schema end
-
-	if schema.xml then
-		if schema.xml.name then
-			current_name = schema.xml.name
-		end
-		if schema.xml.namespace then
-			current_ns = schema.xml.namespace
-		end
-		-- TODO prefix?
-	end
-
-	local out = ctx or st.stanza(current_name, { xmlns = current_ns })
-
-	local s_type = guess_schema_type(schema)
-	if s_type == "object" then
-
-		for prop, propschema in pairs(schema.properties) do
-			propschema = resolve_schema(propschema, root)
-			local v = t[prop]
-
-			if v ~= nil then
-				local proptype, value_where, name, namespace, prefix, single_attribute = unpack_propschema(propschema, prop, current_ns)
-				unparse_property(out, v, proptype, propschema, value_where, name, namespace, current_ns, prefix, single_attribute, root)
-			end
-		end
-		return out;
-
-	elseif s_type == "array" then
-		local itemschema = resolve_schema(schema.items, root)
-		local proptype, value_where, name, namespace, prefix, single_attribute = unpack_propschema(itemschema, current_name, current_ns)
-		for _, item in ipairs(t as { string }) do
-			unparse_property(out, item, proptype, itemschema, value_where, name, namespace, current_ns, prefix, single_attribute, root)
-		end
-		return out;
-	end
-end
-
-return {
-	parse = parse,
-	unparse = unparse,
-}
--- a/teal-src/util/datetime.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,11 +0,0 @@
--- TODO s/number/integer/ once Teal gets support for that
-
-local record lib
-	date     : function (t : integer) : string
-	datetime : function (t : integer) : string
-	time     : function (t : integer) : string
-	legacy   : function (t : integer) : string
-	parse    : function (t : string) : integer
-end
-
-return lib
--- a/teal-src/util/encodings.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,27 +0,0 @@
--- TODO many actually return Maybe(String)
-local record lib
-	record base64
-		encode : function (s : string) : string
-		decode : function (s : string) : string
-	end
-	record stringprep
-		nameprep : function (s : string, strict : boolean) : string
-		nodeprep : function (s : string, strict : boolean) : string
-		resourceprep : function (s : string, strict : boolean) : string
-		saslprep : function (s : string, strict : boolean) : string
-	end
-	record idna
-		to_ascii : function (s : string) : string
-		to_unicode : function (s : string) : string
-	end
-	record utf8
-		valid : function (s : string) : boolean
-		length : function (s : string) : integer
-	end
-	record confusable
-		skeleton : function (s : string) : string
-	end
-	version : string
-end
-return lib
-
--- a/teal-src/util/error.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,78 +0,0 @@
-local enum error_type
-	"auth"
-	"cancel"
-	"continue"
-	"modify"
-	"wait"
-end
-
-local enum error_condition
-	"bad-request"
-	"conflict"
-	"feature-not-implemented"
-	"forbidden"
-	"gone"
-	"internal-server-error"
-	"item-not-found"
-	"jid-malformed"
-	"not-acceptable"
-	"not-allowed"
-	"not-authorized"
-	"policy-violation"
-	"recipient-unavailable"
-	"redirect"
-	"registration-required"
-	"remote-server-not-found"
-	"remote-server-timeout"
-	"resource-constraint"
-	"service-unavailable"
-	"subscription-required"
-	"undefined-condition"
-	"unexpected-request"
-end
-
-local record protoerror
-	type : error_type
-	condition : error_condition
-	text : string
-	code : integer
-end
-
-local record error
-	type : error_type
-	condition : error_condition
-	text : string
-	code : integer
-	context : { any : any }
-	source : string
-end
-
-local type compact_registry_item = { string, string, string, string }
-local type compact_registry = { compact_registry_item }
-local type registry = { string : protoerror }
-local type context = { string : any }
-
-local record error_registry_wrapper
-	source : string
-	registry : registry
-	new : function (string, context) : error
-	coerce : function (any, string) : any, error
-	wrap : function (error) : error
-	wrap : function (string, context) : error
-	is_error : function (any) : boolean
-end
-
-local record lib
-	record configure_opt
-		auto_inject_traceback : boolean
-	end
-	new : function (protoerror, context, { string : protoerror }, string) : error
-	init : function (string, string, registry | compact_registry) : error_registry_wrapper
-	init : function (string, registry | compact_registry) : error_registry_wrapper
-	is_error : function (any) : boolean
-	coerce : function (any, string) : any, error
-	from_stanza : function (table, context, string) : error
-	configure : function
-end
-
-return lib
--- a/teal-src/util/format.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-local record lib
-	format : function (string, ... : any) : string
-end
-return lib
--- a/teal-src/util/hashes.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,23 +0,0 @@
-local type hash = function (msg : string, hex : boolean) : string
-local type hmac = function (key : string, msg : string, hex : boolean) : string
-local type kdf = function (pass : string, salt : string, i : integer) : string
-
-local record lib
-	sha1 : hash
-	sha256 : hash
-	sha224 : hash
-	sha384 : hash
-	sha512 : hash
-	md5 : hash
-	hmac_sha1 : hmac
-	hmac_sha256 : hmac
-	hmac_sha512 : hmac
-	hmac_md5 : hmac
-	scram_Hi_sha1 : kdf
-	pbkdf2_hmac_sha1 : kdf
-	pbkdf2_hmac_sha256 : kdf
-	equals : function (string, string) : boolean
-	version : string
-	_LIBCRYPTO_VERSION : string
-end
-return lib
--- a/teal-src/util/hex.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
-local type s2s = function (s : string) : string
-local record lib
-	to : s2s
-	from : s2s
-end
-return lib
--- a/teal-src/util/http.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,9 +0,0 @@
-local record lib
-	urlencode : function (s : string) : string 
-	urldecode : function (s : string) : string 
-	formencode : function (f : { string : string }) : string 
-	formdecode : function (s : string) : { string : string } 
-	contains_token : function (field : string, token : string) : boolean 
-	normalize_path : function (path : string) : string 
-end
-return lib
--- a/teal-src/util/human/units.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
-local lib = record
-	adjust : function (number, string) : number, string
-	format : function (number, string, string) : string
-end
-return lib
--- a/teal-src/util/id.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,9 +0,0 @@
-local record lib
-	short : function () : string
-	medium : function () : string
-	long : function () : string
-	custom : function (integer) : function () : string
-
-end
-return lib
-
--- a/teal-src/util/interpolation.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
-local type renderer = function (string, { string : any }) : string
-local type filter = function (string, any) : string
-local record lib
-	new : function (string, string, funcs : { string : filter }) : renderer
-end
-return lib
--- a/teal-src/util/jid.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,15 +0,0 @@
-local record lib
-	split : function (string) : string, string, string
-	bare : function (string) : string
-	prepped_split : function (string, boolean) : string, string, string
-	join : function (string, string, string) : string
-	prep : function (string, boolean) : string
-	compare : function (string, string) : boolean
-	node : function (string) : string
-	host : function (string) : string
-	resource : function (string) : string
-	escape : function (string) : string
-	unescape : function (string) : string
-end
-
-return lib
--- a/teal-src/util/json.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,18 +0,0 @@
-local record lib
-	encode : function (any) : string
-	decode : function (string) : any, string
-
-	enum json_type_name
-		"null"
-		"boolean"
-		"object"
-		"array"
-		"number"
-		"string"
-		"integer"
-	end
-
-	type null_type = (nil)
-	null : null_type
-end
-return lib
--- a/teal-src/util/jsonpointer.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,46 +0,0 @@
-
-local enum ptr_error
-	"invalid-table"
-	"invalid-path"
-end
-
-local function unescape_token(escaped_token : string) : string
-	local unescaped = escaped_token:gsub("~1", "/"):gsub("~0", "~")
-	return unescaped
-end
-
-local function resolve_json_pointer(ref : table, path : string) : any, ptr_error
-	local ptr_len = #path+1
-	for part, pos in path:gmatch("/([^/]*)()") do
-		local token = unescape_token(part)
-		if not ref is table then
-			return nil
-		end
-		local idx = next(ref)
-		local new_ref : any
-
-		if idx is string then
-			new_ref = ref[token]
-		elseif idx is integer then
-			local i = tonumber(token)
-			if token == "-" then i = #ref + 1 end
-			new_ref = ref[i+1]
-		else
-			return nil, "invalid-table"
-		end
-
-		if pos as integer == ptr_len then
-			return new_ref
-		elseif new_ref is table then
-			ref = new_ref
-		elseif not ref is table then
-			return nil, "invalid-path"
-		end
-
-	end
-	return ref
-end
-
-return {
-	resolve = resolve_json_pointer,
-}
--- a/teal-src/util/jsonschema.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,374 +0,0 @@
--- Copyright (C) 2021 Kim Alvefur
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
--- Based on
--- https://json-schema.org/draft/2020-12/json-schema-core.html
--- https://json-schema.org/draft/2020-12/json-schema-validation.html
---
-
-local json = require"util.json"
-local null = json.null;
-
-local pointer = require "util.jsonpointer"
-
-local type json_type_name = json.json_type_name
-
--- json_type_name here is non-standard
-local type schema_t = boolean | json_schema_object
-
-local record json_schema_object
-	type json_type_name = json.json_type_name
-	type schema_object = json_schema_object
-
-	type : json_type_name | { json_type_name }
-	enum : { any }
-	const : any
-
-	allOf : { schema_t }
-	anyOf : { schema_t }
-	oneOf : { schema_t }
-
-	["not"] : schema_t
-	["if"] : schema_t
-	["then"] : schema_t
-	["else"] : schema_t
-
-	["$ref"] : string
-
-	-- numbers
-	multipleOf : number
-	maximum : number
-	exclusiveMaximum : number
-	minimum : number
-	exclusiveMinimum : number
-
-	-- strings
-	maxLength : integer
-	minLength : integer
-	pattern : string -- NYI
-	format : string
-
-	-- arrays
-	prefixItems : { schema_t }
-	items : schema_t
-	contains : schema_t
-	maxItems : integer
-	minItems : integer
-	uniqueItems : boolean
-	maxContains : integer -- NYI
-	minContains : integer -- NYI
-
-	-- objects
-	properties : { string : schema_t }
-	maxProperties : integer -- NYI
-	minProperties : integer -- NYI
-	required : { string }
-	dependentRequired : { string : { string } }
-	additionalProperties: schema_t
-	patternProperties: schema_t -- NYI
-	propertyNames : schema_t
-
-	-- xml
-	record xml_t
-		name : string
-		namespace : string
-		prefix : string
-		attribute : boolean
-		wrapped : boolean
-
-		-- nonstantard, maybe in the future
-		text : boolean
-		x_name_is_value : boolean
-		x_single_attribute : string
-	end
-
-	xml : xml_t
-
-	-- descriptive
-	title : string
-	description : string
-	deprecated : boolean
-	readOnly : boolean
-	writeOnly : boolean
-
-	-- methods
-	validate : function ( schema_t, any, json_schema_object ) : boolean
-end
-
--- TODO validator function per schema property
-
-local function simple_validate(schema : json_type_name | { json_type_name }, data : any) : boolean
-	if schema == nil then
-		return true
-	elseif schema == "object" and data is table then
-		return type(data) == "table" and (next(data)==nil or type((next(data, nil))) == "string")
-	elseif schema == "array" and data is table then
-		return type(data) == "table" and (next(data)==nil or type((next(data, nil))) == "number")
-	elseif schema == "integer" then
-		return math.type(data) == schema
-	elseif schema == "null" then
-		return data == null
-	elseif schema is { json_type_name } then
-		for _, one in ipairs(schema as { json_type_name }) do
-			if simple_validate(one, data) then
-				return true
-			end
-		end
-		return false
-	else
-		return type(data) == schema
-	end
-end
-
-local complex_validate : function ( json_schema_object, any, json_schema_object ) : boolean
-
-local function validate (schema : schema_t, data : any, root : json_schema_object) : boolean
-	if schema is boolean then
-		return schema
-	else
-		return complex_validate(schema, data, root)
-	end
-end
-
-function complex_validate (schema : json_schema_object, data : any, root : json_schema_object) : boolean
-
-	if root == nil then
-		root = schema
-	end
-
-	if schema["$ref"] and schema["$ref"]:sub(1,1) == "#" then
-		local referenced = pointer.resolve(root as table, schema["$ref"]:sub(2)) as schema_t
-		if referenced ~= nil and referenced ~= root and referenced ~= schema then
-			if not validate(referenced, data, root) then
-				return false;
-			end
-		end
-	end
-
-	if not simple_validate(schema.type, data) then
-		return false;
-	end
-
-	if schema.type == "object" then
-		if data is table then
-			-- just check that there the keys are all strings
-			for k in pairs(data) do
-				if not k is string then
-					return false
-				end
-			end
-		end
-	end
-
-	if schema.type == "array" then
-		if data is table then
-			-- just check that there the keys are all numbers
-			for i in pairs(data) do
-				if not i is integer then
-					return false
-				end
-			end
-		end
-	end
-
-	if schema["enum"] ~= nil then
-		local match = false
-		for _, v in ipairs(schema["enum"]) do
-			if v == data then
-				-- FIXME supposed to do deep-compare
-				match = true
-				break
-			end
-		end
-		if not match then
-			return false
-		end
-	end
-
-	-- XXX this is measured in byte, while JSON measures in ... bork
-	-- TODO use utf8.len?
-	if data is string then
-		if schema.maxLength and #data > schema.maxLength then
-			return false
-		end
-		if schema.minLength and #data < schema.minLength then
-			return false
-		end
-	end
-
-	if data is number then
-		if schema.multipleOf and (data == 0 or data % schema.multipleOf ~= 0) then
-			return false
-		end
-
-		if schema.maximum and not ( data <= schema.maximum ) then
-			return false
-		end
-
-		if schema.exclusiveMaximum and not ( data < schema.exclusiveMaximum ) then
-			return false
-		end
-
-		if schema.minimum and not ( data >= schema.minimum ) then
-			return false
-		end
-
-		if schema.exclusiveMinimum and not ( data > schema.exclusiveMinimum ) then
-			return false
-		end
-	end
-
-	if schema.allOf then
-		for _, sub in ipairs(schema.allOf) do
-			if not validate(sub, data, root) then
-				return false
-			end
-		end
-	end
-
-	if schema.oneOf then
-		local valid = 0
-		for _, sub in ipairs(schema.oneOf) do
-			if validate(sub, data, root) then
-				valid = valid + 1
-			end
-		end
-		if valid ~= 1 then
-			return false
-		end
-	end
-
-	if schema.anyOf then
-		local match = false
-		for _, sub in ipairs(schema.anyOf) do
-			if validate(sub, data, root) then
-				match = true
-				break
-			end
-		end
-		if not match then
-			return false
-		end
-	end
-
-	if schema["not"] then
-		if validate(schema["not"], data, root) then
-			return false
-		end
-	end
-
-	if schema["if"] ~= nil then
-		if validate(schema["if"], data, root) then
-			if schema["then"] then
-				return validate(schema["then"], data, root)
-			end
-		else
-			if schema["else"] then
-				return validate(schema["else"], data, root)
-			end
-		end
-	end
-
-	if schema.const ~= nil and schema.const ~= data then
-		return false
-	end
-
-	if data is table then
-
-		if schema.maxItems and #data > schema.maxItems then
-			return false
-		end
-
-		if schema.minItems and #data < schema.minItems then
-			return false
-		end
-
-		if schema.required then
-			for _, k in ipairs(schema.required) do
-				if data[k] == nil then
-					return false
-				end
-			end
-		end
-
-		if schema.propertyNames ~= nil then
-			for k in pairs(data) do
-				if not validate(schema.propertyNames, k, root) then
-					return false
-				end
-			end
-		end
-
-		if schema.properties then
-			for k, sub in pairs(schema.properties) do
-				if data[k] ~= nil and not validate(sub, data[k], root) then
-					return false
-				end
-			end
-		end
-
-		if schema.additionalProperties ~= nil then
-			for k, v in pairs(data) do
-				if schema.properties == nil or schema.properties[k as string] == nil then
-					if not validate(schema.additionalProperties, v, root) then
-						return false
-					end
-				end
-			end
-		end
-
-		if schema.uniqueItems then
-			-- only works for scalars, would need to deep-compare for objects/arrays/tables
-			local values : { any : boolean } = {}
-			for _, v in pairs(data) do
-				if values[v] then
-					return false
-				end
-				values[v] = true
-			end
-		end
-
-		local p = 0
-		if schema.prefixItems ~= nil then
-			for i, s in ipairs(schema.prefixItems) do
-				if data[i] == nil then
-					break
-				elseif validate(s, data[i], root) then
-					p = i
-				else
-					return false
-				end
-			end
-		end
-
-		if schema.items ~= nil then
-			for i = p+1, #data do
-				if not validate(schema.items, data[i], root) then
-					return false
-				end
-			end
-		end
-
-		if schema.contains ~= nil then
-			local found = false
-			for i = 1, #data do
-				if validate(schema.contains, data[i], root) then
-					found = true
-					break
-				end
-			end
-			if not found then
-				return false
-			end
-		end
-	end
-
-	return true;
-end
-
-
-json_schema_object.validate = validate;
-
-return json_schema_object;
--- a/teal-src/util/net.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,13 +0,0 @@
-
-local enum type_strings
-	"both"
-	"ipv4"
-	"ipv6"
-end
-
-local record lib
-	local_addresses : function (type_strings, boolean) : { string }
-	pton : function (string):string
-	ntop : function (string):string
-end
-return lib
--- a/teal-src/util/poll.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,31 +0,0 @@
-local record state
-	enum waiterr
-		"timeout"
-		"signal"
-	end
-	add : function (state, integer, boolean, boolean) : boolean
-	add : function (state, integer, boolean, boolean) : nil, string, integer
-	set : function (state, integer, boolean, boolean) : boolean
-	set : function (state, integer, boolean, boolean) : nil, string, integer
-	del : function (state, integer) : boolean
-	del : function (state, integer) : nil, string, integer
-	wait : function (state, integer) : integer, boolean, boolean
-	wait : function (state, integer) : nil, string, integer
-	wait : function (state, integer) : nil, waiterr
-	getfd : function (state) : integer
-end
-
-local record lib
-	new : function () : state
-	EEXIST : integer
-	EMFILE : integer
-	ENOENT : integer
-	enum api_backend
-		"epoll"
-		"poll"
-		"select"
-	end
-	api : api_backend
-end
-
-return lib
--- a/teal-src/util/pposix.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,108 +0,0 @@
-local record pposix
-	enum syslog_facility
-		"auth"
-		"authpriv"
-		"cron"
-		"daemon"
-		"ftp"
-		"kern"
-		"local0"
-		"local1"
-		"local2"
-		"local3"
-		"local4"
-		"local5"
-		"local6"
-		"local7"
-		"lpr"
-		"mail"
-		"syslog"
-		"user"
-		"uucp"
-	end
-
-	enum syslog_level
-		"debug"
-		"info"
-		"notice"
-		"warn"
-		"error"
-	end
-
-	enum ulimit_resource
-		"CORE"
-		"CPU"
-		"DATA"
-		"FSIZE"
-		"NOFILE"
-		"STACK"
-		"MEMLOCK"
-		"NPROC"
-		"RSS"
-		"NICE"
-	end
-
-	enum ulimit_unlimited
-		"unlimited"
-	end
-
-	type ulimit_limit = integer | ulimit_unlimited
-
-	record utsname
-		sysname         :  string
-		nodename        :  string
-		release         :  string
-		version         :  string
-		machine         :  string
-		domainname      :  string
-	end
-
-	record memoryinfo
-		allocated       :  integer
-		allocated_mmap  :  integer
-		used            :  integer
-		unused          :  integer
-		returnable      :  integer
-	end
-
-	abort : function ()
-
-	daemonize : function () : boolean, string
-
-	syslog_open : function (ident : string, facility : syslog_facility)
-	syslog_close : function ()
-	syslog_log : function (level : syslog_level, src : string, msg : string)
-	syslog_setminlevel : function (level : syslog_level)
-
-	getpid : function () : integer
-	getuid : function () : integer
-	getgid : function () : integer
-
-	setuid : function (uid : integer | string) : boolean, string -- string|integer
-	setgid : function (uid : integer | string) : boolean, string
-	initgroups : function (user : string, gid : integer) : boolean, string
-
-	umask : function (umask : string) : string
-
-	mkdir : function (dir : string) : boolean, string
-
-	setrlimit : function (resource : ulimit_resource, soft : ulimit_limit, hard : ulimit_limit) : boolean, string
-	getrlimit : function (resource : ulimit_resource) : boolean, ulimit_limit, ulimit_limit
-	getrlimit : function (resource : ulimit_resource) : boolean, string
-
-	uname : function () : utsname
-
-	setenv : function (key : string, value : string) : boolean
-
-	meminfo : function () : memoryinfo
-
-	atomic_append : function (f : FILE, s : string) : boolean, string, integer
-
-	isatty : function(FILE) : boolean
-
-	ENOENT : integer
-	_NAME : string
-	_VESRION : string
-end
-
-return pposix
--- a/teal-src/util/random.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-local record lib
-	bytes : function (n:integer):string
-end
-return lib
--- a/teal-src/util/ringbuffer.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,20 +0,0 @@
-local record lib
-	record ringbuffer
-		find : function (ringbuffer, string) : integer
-		discard : function (ringbuffer, integer) : boolean
-		read : function (ringbuffer, integer, boolean) : string
-		readuntil : function (ringbuffer, string) : string
-		write : function (ringbuffer, string) : integer
-		size : function (ringbuffer) : integer
-		length : function (ringbuffer) : integer
-		sub : function (ringbuffer, integer, integer) : string
-		byte : function (ringbuffer, integer, integer) : integer...
-		free : function (ringbuffer) : integer
-	end
-
-	new : function (integer) : ringbuffer
-end
-
-return lib
-
-
--- a/teal-src/util/signal.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,41 +0,0 @@
-local record lib
-	enum signal
-		"SIGABRT"
-		"SIGALRM"
-		"SIGBUS"
-		"SIGCHLD"
-		"SIGCLD"
-		"SIGCONT"
-		"SIGFPE"
-		"SIGHUP"
-		"SIGILL"
-		"SIGINT"
-		"SIGIO"
-		"SIGIOT"
-		"SIGKILL"
-		"SIGPIPE"
-		"SIGPOLL"
-		"SIGPROF"
-		"SIGQUIT"
-		"SIGSEGV"
-		"SIGSTKFLT"
-		"SIGSTOP"
-		"SIGSYS"
-		"SIGTERM"
-		"SIGTRAP"
-		"SIGTTIN"
-		"SIGTTOU"
-		"SIGURG"
-		"SIGUSR1"
-		"SIGUSR2"
-		"SIGVTALRM"
-		"SIGWINCH"
-		"SIGXCPU"
-		"SIGXFSZ"
-	end
-	signal : function (integer | signal, function, boolean) : boolean
-	raise : function (integer | signal)
-	kill : function (integer, integer | signal)
-	-- enum : integer
-end
-return lib
--- a/teal-src/util/smqueue.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,99 +0,0 @@
-local queue = require "util.queue";
-
-local record lib
-	-- T would typically be util.stanza
-	record smqueue<T>
-		_queue : queue.queue<T>
-		_head : integer
-		_tail : integer
-
-		enum ack_errors
-			"tail"
-			"head"
-			"pop"
-		end
-		push : function (smqueue, T)
-		ack : function (smqueue, integer) : { T }, ack_errors
-		resumable : function (smqueue<T>) : boolean
-		resume : function (smqueue<T>)  : queue.queue.iterator, any, integer
-		type consume_iter = function (smqueue<T>) : T
-		consume : function (smqueue<T>) : consume_iter
-
-		table : function (smqueue<T>) : { T }
-	end
-	new : function <T>(integer) : smqueue<T>
-end
-
-local type smqueue = lib.smqueue;
-
-function smqueue:push(v)
-	self._head = self._head + 1;
-	-- Wraps instead of errors
-	assert(self._queue:push(v));
-end
-
-function smqueue:ack(h : integer) : { any }, smqueue.ack_errors
-	if h < self._tail then
-		return nil, "tail";
-	elseif h > self._head then
-		return nil, "head";
-	end
-	-- TODO optimize? cache table fields
-	local acked = {};
-	self._tail = h;
-	local expect = self._head - self._tail;
-	while expect < self._queue:count() do
-		local v = self._queue:pop();
-		if not v then return nil, "pop"; end
-		table.insert(acked, v);
-	end
-	return acked;
-end
-
-function smqueue:count_unacked() : integer
-	return self._head - self._tail;
-end
-
-function smqueue:count_acked() : integer
-	return self._tail;
-end
-
-function smqueue:resumable() : boolean
-	return self._queue:count() >= (self._head - self._tail);
-end
-
-function smqueue:resume() : queue.queue.iterator, any, integer
-	return self._queue:items();
-end
-
-function smqueue:consume() : queue.queue.consume_iter
-	return self._queue:consume()
-end
-
--- Compatibility layer, plain ol' table
-function smqueue:table() : { any }
-	local t : { any } = {};
-	for i, v in self:resume() do
-		t[i] = v;
-	end
-	return t;
-end
-
-local function freeze(q : smqueue<any>) : { string:integer }
-	return { head = q._head, tail = q._tail }
-end
-
-local queue_mt = {
-	--
-	__name = "smqueue";
-	__index = smqueue;
-	__len = smqueue.count_unacked;
-	__freeze = freeze;
-}
-
-function lib.new<T>(size : integer) : queue.queue<T>
-	assert(size>0);
-	return setmetatable({ _head = 0; _tail = 0; _queue = queue.new(size, true) }, queue_mt);
-end
-
-return lib;
--- a/teal-src/util/stanza.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,62 +0,0 @@
-local record lib
-
-	type children_iter = function ( stanza_t ) : stanza_t
-	type childtags_iter = function () : stanza_t
-	type maptags_cb = function ( stanza_t ) : stanza_t
-
-	record stanza_t
-		name : string
-		attr : { string : string }
-		{ stanza_t | string }
-		tags : { stanza_t }
-
-		query : function ( stanza_t, string ) : stanza_t
-		body : function ( stanza_t, string, { string : string } ) : stanza_t
-		text_tag : function ( stanza_t, string, string, { string : string } ) : stanza_t
-		tag : function ( stanza_t, string, { string : string } ) : stanza_t
-		text : function ( stanza_t, string ) : stanza_t
-		up : function ( stanza_t ) : stanza_t
-		reset : function ( stanza_t ) : stanza_t
-		add_direct_child : function ( stanza_t, stanza_t )
-		add_child : function ( stanza_t, stanza_t )
-		remove_children : function ( stanza_t, string, string ) : stanza_t
-
-		get_child : function ( stanza_t, string, string ) : stanza_t
-		get_text : function ( stanza_t ) : string
-		get_child_text : function ( stanza_t, string, string ) : string
-		child_with_name : function ( stanza_t, string, string ) : stanza_t
-		child_with_ns : function ( stanza_t, string, string ) : stanza_t
-		children : function ( stanza_t ) : children_iter, stanza_t, integer
-		childtags : function ( stanza_t, string, string ) : childtags_iter
-		maptags : function ( stanza_t, maptags_cb ) : stanza_t
-		find : function ( stanza_t, string ) : stanza_t | string
-
-		top_tag : function ( stanza_t ) : string
-		pretty_print : function ( stanza_t ) : string
-		pretty_top_tag : function ( stanza_t ) : string
-
-		get_error : function ( stanza_t ) : string, string, string, stanza_t
-		indent : function ( stanza_t, integer, string ) : stanza_t
-	end
-
-	record serialized_stanza_t
-		name : string
-		attr : { string : string }
-		{ serialized_stanza_t | string }
-	end
-
-	stanza : function ( string, { string : string } ) : stanza_t
-	is_stanza : function ( any ) : boolean
-	preserialize : function ( stanza_t ) : serialized_stanza_t
-	deserialize : function ( serialized_stanza_t ) : stanza_t
-	clone : function ( stanza_t, boolean ) : stanza_t
-	message : function ( { string : string }, string ) : stanza_t
-	iq : function ( { string : string } ) : stanza_t
-	reply : function ( stanza_t ) : stanza_t
-	error_reply : function ( stanza_t, string, string, string, string )
-	presence : function ( { string : string } ) : stanza_t
-	xml_escape : function ( string ) : string
-	pretty_print : function ( string ) : string
-end
-
-return lib
--- a/teal-src/util/strbitop.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
-local record mod
-	sand : function (string, string) : string
-	sor : function (string, string) : string
-	sxor : function (string, string) : string
-end
-return mod
--- a/teal-src/util/table.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
-local record lib
-	create : function (narr:integer, nrec:integer):table
-	pack : function (...:any):{any}
-end
-return lib
-
--- a/teal-src/util/time.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
-
-local record lib
-	now : function () : number
-	monotonic : function () : number
-end
-return lib
--- a/teal-src/util/uuid.d.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,8 +0,0 @@
-local record lib
-	get_nibbles : (number) : string
-	generate : function () : string
-
-	seed : function (string)
-end
-return lib
-
--- a/teal-src/util/xtemplate.tl	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,101 +0,0 @@
--- render(template, stanza) --> string
--- {path} --> stanza:find(path)
--- {{ns}name/child|each({ns}name){sub-template}}
-
---[[
-template ::= "{" path ("|" name ("(" args ")")? (template)? )* "}"
-path ::= defined by util.stanza
-name ::= %w+
-args ::= anything with balanced ( ) pairs
-]]
-
-local s_gsub = string.gsub;
-local s_match = string.match;
-local s_sub = string.sub;
-local t_concat = table.concat;
-
-local st = require "util.stanza";
-
-local type escape_t = function (string) : string
-local type filter_t = function (string, string | st.stanza_t, string) : string | st.stanza_t, boolean
-local type filter_coll = { string : filter_t }
-
-local function render(template : string, root : st.stanza_t, escape : escape_t, filters : filter_coll) : string
-	escape = escape or st.xml_escape;
-
-	return (s_gsub(template, "%b{}", function(block : string) : string
-		local inner = s_sub(block, 2, -2);
-		local path, pipe, pos = s_match(inner, "^([^|]+)(|?)()");
-		if not path is string then return end
-		local value : string | st.stanza_t
-		if path == "." then
-			value = root;
-		elseif path == "#" then
-			value = root:get_text();
-		else
-			value = root:find(path);
-		end
-		local is_escaped = false;
-
-		while pipe == "|" do
-			local func, args, tmpl, p = s_match(inner, "^(%w+)(%b())(%b{})()", pos as integer);
-			if not func then func, args, p = s_match(inner, "^(%w+)(%b())()", pos as integer); end
-			if not func then func, tmpl, p = s_match(inner, "^(%w+)(%b{})()", pos as integer); end
-			if not func then func, p = s_match(inner, "^(%w+)()", pos as integer); end
-			if not func then break end
-			if tmpl then tmpl = s_sub(tmpl, 2, -2); end
-			if args then args = s_sub(args, 2, -2); end
-
-			if func == "each" and tmpl and st.is_stanza(value) then
-				if not args then value, args = root, path; end
-				local ns, name = s_match(args, "^(%b{})(.*)$");
-				if ns then ns = s_sub(ns, 2, -2); else name, ns = args, nil; end
-				if ns == "" then ns = nil; end
-				if name == "" then name = nil; end
-				local out, i = {}, 1;
-				for c in (value as st.stanza_t):childtags(name, ns) do
-					out[i], i = render(tmpl, c, escape, filters), i + 1;
-				end
-				value = t_concat(out);
-				is_escaped = true;
-			elseif func == "and" and tmpl then
-				local condition = value;
-				if args then condition = root:find(args); end
-				if condition then
-					value = render(tmpl, root, escape, filters);
-					is_escaped = true;
-				end
-			elseif func == "or" and tmpl then
-				local condition = value;
-				if args then condition = root:find(args); end
-				if not condition then
-					value = render(tmpl, root, escape, filters);
-					is_escaped = true;
-				end
-			elseif filters and filters[func] then
-				local f = filters[func];
-				if args == nil then
-					value, is_escaped = f(value, tmpl);
-				else
-					value, is_escaped = f(args, value, tmpl);
-				end
-			else
-				error("No such filter function: " .. func);
-			end
-			pipe, pos = s_match(inner, "^(|?)()", p as integer);
-		end
-
-		if value is string then
-			if not is_escaped then value = escape(value); end
-			return value;
-		elseif st.is_stanza(value) then
-			value = value:get_text();
-			if value then
-				return escape(value);
-			end
-		end
-		return "";
-	end));
-end
-
-return { render = render };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/build-env/Containerfile	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,31 @@
+ARG os
+ARG dist
+FROM ${os:-debian}:${dist:-sid}
+ENV DEBIAN_FRONTEND noninteractive
+RUN set -ex; \
+	apt-get update; \
+	apt-get install -y --no-install-recommends \
+	ccache dh-lua libicu-dev libidn11-dev libssl-dev \
+	lua-bitop lua-dbi-mysql lua-dbi-postgresql lua-dbi-sqlite3 \
+	lua-event lua-expat lua-filesystem lua-ldap lua-sec lua-socket \
+	luarocks shellcheck mercurial; \
+	apt-get install -y ca-certificates dns-root-data; \
+	apt-get install -y lua-bit32 || true; \
+	apt-get install -y lua-busted || true; \
+	apt-get install -y lua-check || true; \
+	apt-get install -y lua-readline || true; \
+	apt-get install -y lua-unbound || true; \
+	update-alternatives --set lua-interpreter /usr/bin/lua5.4 || true \
+	apt-get clean
+
+# Place this file in an empty directory and build the image with
+# podman build . -t prosody.im/build-env
+#
+# Substituting podman for docker should work, where that is what's available.
+#
+# Then in a source directory, run:
+# podman run -it --rm -v "$PWD:$PWD" -w "$PWD" --entrypoint /bin/bash \
+#            --userns=keep-id --network host prosody.im/build-env
+#
+# In the resulting environment everything required to compile and run prosody
+# is available, so e.g. `./configure; make; ./prosody` should Just Work!
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/build-env/build.sh	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,16 @@
+#!/bin/sh -eux
+
+cd "$(dirname "$0")"
+
+containerify="$(command -v podman || command -v docker)"
+
+if [ -z "$containerify" ]; then
+	echo "podman or docker required" >&2
+	exit 1
+fi
+
+$containerify build -f ./Containerfile --squash \
+	--build-arg os="${2:-debian}" \
+	--build-arg dist="${1:-testing}" \
+	-t "prosody.im/build-env:${1:-testing}"
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/build-env/here.sh	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,19 @@
+#!/bin/sh -eux
+
+tag="testing"
+
+if [ "$#" -gt 0 ]; then
+	tag="$1"
+	shift
+fi
+
+containerify="$(command -v podman docker)"
+
+$containerify run -it --rm \
+	-v "$PWD:$PWD" \
+	-w "$PWD" \
+	-v "$HOME/.cache:$PWD/.cache" \
+	--entrypoint /bin/bash \
+	--userns=keep-id \
+	--network \
+	host "prosody.im/build-env:$tag" "$@"
--- a/tools/cfgdump.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/tools/cfgdump.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,21 +1,24 @@
 #!/usr/bin/env lua
 
 -- cfgdump.lua prosody.cfg.lua [[host] option]
+if not pcall(require, "prosody.loader") then
+	pcall(require, "loader");
+end
 
 local s_format, print = string.format, print;
 local printf = function(fmt, ...) return print(s_format(fmt, ...)); end
-local it = require "util.iterators";
+local it = require "prosody.util.iterators";
 local function sort_anything(a, b)
 	local typeof_a, typeof_b = type(a), type(b);
 	if typeof_a ~= typeof_b then return typeof_a < typeof_b end
 	return a < b -- should work for everything in a config file
 end
-local serialization = require "util.serialization";
+local serialization = require "prosody.util.serialization";
 local serialize = serialization.new and serialization.new({
 	unquoted = true, table_iterator = function(t) return it.sorted_pairs(t, sort_anything); end,
 }) or serialization.serialize;
-local configmanager = require"core.configmanager";
-local startup = require "util.startup";
+local configmanager = require"prosody.core.configmanager";
+local startup = require "prosody.util.startup";
 
 startup.set_function_metatable();
 local config_filename, onlyhost, onlyoption = ...;
--- a/tools/dnsregistry.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/tools/dnsregistry.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,5 +1,8 @@
 -- Generate util/dnsregistry.lua from IANA HTTP status code registry
-local xml = require "util.xml";
+if not pcall(require, "prosody.loader") then
+	pcall(require, "loader");
+end
+local xml = require "prosody.util.xml";
 local registries = xml.parse(io.read("*a"), { allow_processing_instructions = true });
 
 print("-- Source: https://www.iana.org/assignments/dns-parameters/dns-parameters.xml");
@@ -22,7 +25,7 @@
 			local record_desc = record:get_child_text("description");
 			local record_code = tonumber(record:get_child_text("value"));
 
-			if tostring(record):lower():match("reserved") or tostring(record):lower():match("reserved") then
+			if tostring(record):lower():match("reserved") or tostring(record):lower():match("unassigned") then
 				record_code = nil;
 			end
 
--- a/tools/ejabberdsql2prosody.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/tools/ejabberdsql2prosody.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -16,13 +16,16 @@
 	package.path = package.path..";"..my_name:gsub("[^/\\]+$", "../?.lua");
 	package.cpath = package.cpath..";"..my_name:gsub("[^/\\]+$", "../?.so");
 end
+if not pcall(require, "prosody.loader") then
+	pcall(require, "loader");
+end
 
 
-local serialize = require "util.serialization".serialize;
-local st = require "util.stanza";
-local parse_xml = require "util.xml".parse;
-package.loaded["util.logger"] = {init = function() return function() end; end}
-local dm = require "util.datamanager"
+local serialize = require "prosody.util.serialization".serialize;
+local st = require "prosody.util.stanza";
+local parse_xml = require "prosody.util.xml".parse;
+package.loaded["prosody.util.logger"] = {init = function() return function() end; end}
+local dm = require "prosody.util.datamanager"
 dm.set_data_path("data");
 
 function parseFile(filename)
--- a/tools/form2table.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/tools/form2table.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,4 +1,7 @@
 -- Read an XML dataform and spit out a serialized Lua table of it
+if not pcall(require, "prosody.loader") then
+	pcall(require, "loader");
+end
 
 local function from_stanza(stanza)
 	local layout = {
@@ -45,4 +48,5 @@
 	return layout;
 end
 
-print("dataforms.new " .. require "util.serialization".serialize(from_stanza(require "util.xml".parse(io.read("*a"))), { unquoted = true }))
+print("dataforms.new " .. require"prosody.util.serialization".serialize(from_stanza(require"prosody.util.xml".parse(io.read("*a"))),
+	{ unquoted = true }))
--- a/tools/http-status-codes.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/tools/http-status-codes.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,9 @@
 -- Generate net/http/codes.lua from IANA HTTP status code registry
+if not pcall(require, "prosody.loader") then
+	pcall(require, "loader");
+end
 
-local xml = require "util.xml";
+local xml = require "prosody.util.xml";
 local registry = xml.parse(io.read("*a"), { allow_processing_instructions = true });
 
 io.write([[
--- a/tools/jabberd14sql2prosody.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/tools/jabberd14sql2prosody.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,4 +1,7 @@
 #!/usr/bin/env lua
+if not pcall(require, "prosody.loader") then
+	pcall(require, "loader");
+end
 
 
 do
@@ -447,13 +450,13 @@
 end
 package.loaded["util.logger"] = {init = function() return function() end; end}
 
-local dm = require "util.datamanager";
+local dm = require "prosody.util.datamanager";
 dm.set_data_path("data");
 
-local datetime = require "util.datetime";
+local datetime = require "prosody.util.datetime";
 
-local st = require "util.stanza";
-local parse_xml = require "util.xml".parse;
+local st = require "prosody.util.stanza";
+local parse_xml = require "prosody.util.xml".parse;
 
 function store_password(username, host, password)
 	-- create or update account for username@host
--- a/tools/migration/migrator.cfg.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/tools/migration/migrator.cfg.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -3,6 +3,7 @@
 local vhost = {
 	"accounts",
 	"account_details",
+	"account_roles",
 	"roster",
 	"vcard",
 	"private",
@@ -12,18 +13,27 @@
 	"offline-archive",
 	"pubsub_nodes-pubsub",
 	"pep-pubsub",
+	"cron",
+	"smacks_h",
 }
 local muc = {
 	"persistent",
 	"config",
 	"state",
 	"muc_log-archive",
+	"cron",
 };
+local upload = {
+	"uploads-archive",
+	"upload_stats",
+	"cron",
+}
 
 input {
 	hosts = {
 		["example.com"] = vhost;
 		["conference.example.com"] = muc;
+		["share.example.com"] = upload;
 	};
 	type = "internal";
 	path = data_path;
--- a/tools/migration/migrator/jabberd14.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/tools/migration/migrator/jabberd14.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,7 +1,10 @@
 
+if not pcall(require, "prosody.loader") then
+	pcall(require, "loader");
+end
 local lfs = require "lfs";
-local st = require "util.stanza";
-local parse_xml = require "util.xml".parse;
+local st = require "prosody.util.stanza";
+local parse_xml = require "prosody.util.xml".parse;
 local os_getenv = os.getenv;
 local io_open = io.open;
 local assert = assert;
--- a/tools/migration/prosody-migrator.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/tools/migration/prosody-migrator.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -43,7 +43,11 @@
 	print("If no stores are specified, 'input' and 'output' are used.");
 end
 
-local startup = require "util.startup";
+if not pcall(require, "prosody.loader") then
+	pcall(require, "loader");
+end
+
+local startup = require "prosody.util.startup";
 do
 	startup.parse_args({
 		short_params = { v = "verbose", h = "help", ["?"] = "help" };
@@ -79,7 +83,7 @@
 -- Command-line parsing
 local options = prosody.opts;
 
-local envloadfile = require "util.envload".envloadfile;
+local envloadfile = require "prosody.util.envload".envloadfile;
 
 local config_file = options.config or default_config;
 local from_store = arg[1] or "input";
@@ -132,8 +136,8 @@
 	os.exit(1);
 end
 
-local async = require "util.async";
-local server = require "net.server";
+local async = require "prosody.util.async";
+local server = require "prosody.net.server";
 local watchers = {
 	error = function (_, err)
 		error(err);
@@ -143,10 +147,10 @@
 	end;
 };
 
-local cm = require "core.configmanager";
-local hm = require "core.hostmanager";
-local sm = require "core.storagemanager";
-local um = require "core.usermanager";
+local cm = require "prosody.core.configmanager";
+local hm = require "prosody.core.hostmanager";
+local sm = require "prosody.core.storagemanager";
+local um = require "prosody.core.usermanager";
 
 local function users(store, host)
 	if store.users then
@@ -164,6 +168,11 @@
 	elseif conf.type == "sql" then
 		cm.set(host, "sql", conf);
 	end
+	if type(conf.config) == "table" then
+		for option, value in pairs(conf.config) do
+			cm.set(host, option, value);
+		end
+	end
 end
 
 local function get_driver(host, conf)
@@ -200,7 +209,7 @@
 end
 
 if options["keep-going"] then
-	local xpcall = require "util.xpcall".xpcall;
+	local xpcall = require "prosody.util.xpcall".xpcall;
 	for t, f in pairs(migrate_once) do
 		migrate_once[t] = function (origin, destination, user, ...)
 			local function log_err(err)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/mod2spec.sh	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,4 @@
+#!/bin/bash
+set -eu
+
+echo "spec/${1//./_}_spec.lua"
--- a/tools/modtrace.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/tools/modtrace.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,9 +8,9 @@
 --   local dbuffer = require "tools.modtrace".trace("util.dbuffer");
 --
 
-local t_pack = require "util.table".pack;
+local t_pack = table.pack;
 local serialize = require "util.serialization".serialize;
-local unpack = table.unpack or unpack; --luacheck: ignore 113
+local unpack = table.unpack;
 local set = require "util.set";
 
 local serialize_cfg = {
--- a/tools/openfire2prosody.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/tools/openfire2prosody.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -15,6 +15,10 @@
 	package.cpath = package.cpath..";"..my_name:gsub("[^/\\]+$", "../?.so");
 end
 
+if not pcall(require, "prosody.loader") then
+	pcall(require, "loader");
+end
+
 -- ugly workaround for getting datamanager to work outside of prosody :(
 prosody = { };
 prosody.platform = "unknown";
@@ -24,12 +28,12 @@
 	prosody.platform = "posix";
 end
 
-local parse_xml = require "util.xml".parse;
+local parse_xml = require "prosody.util.xml".parse;
 
 -----------------------------------------------------------------------
 
 package.loaded["util.logger"] = {init = function() return function() end; end}
-local dm = require "util.datamanager"
+local dm = require "prosody.util.datamanager"
 dm.set_data_path("data");
 
 local arg = ...;
--- a/tools/tb2err	Wed Mar 27 15:35:15 2024 +0000
+++ b/tools/tb2err	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,7 @@
-#!/usr/bin/env lua-any
--- Lua-Versions: 5.3 5.2 5.1
+#!/usr/bin/env lua
 -- traceback to errors.err for vim -q
+-- e.g. curl https://prosody.im/paste/xxx | tb2err > errors.err && vim -q
+
 local path_sep = package.config:sub(1,1);
 for line in io.lines() do
 	local src, err = line:match("%s*(%S+)(:%d+: .*)")
@@ -10,11 +11,13 @@
 			or src:match("/()net/")
 			or src:match("/()util/")
 			or src:match("/()modules/")
+			or src:match("/()prosody%-modules/")
 			or src:match("/()plugins/")
-			or src:match("/()prosody[ctl]*$") 
+			or src:match("/()prosody[ctl]*$")
 		if cut then
 			src = src:sub(cut);
 		end
+		src = src:gsub("prosody%-modules/", "../modules/")
 		src = src:gsub("^modules/", "plugins/")
 		io.write(src, err, "\n");
 	end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/test_mutants.sh.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,217 @@
+#!/bin/bash
+
+POLYGLOT=1--[===[
+
+set -o pipefail
+
+if [[ "$#" == "0" ]]; then
+	echo "Lua mutation testing tool"
+	echo
+	echo "Usage:"
+	echo "    $BASH_SOURCE MODULE_NAME SPEC_FILE"
+	echo
+	echo "Requires 'lua', 'ltokenp' and 'busted' in PATH"
+	exit 1;
+fi
+
+MOD_NAME="$1"
+MOD_FILE="$(lua "$BASH_SOURCE" resolve "$MOD_NAME")"
+
+if [[ "$MOD_FILE" == "" || ! -f "$MOD_FILE" ]]; then
+	echo "EE: Failed to locate module '$MOD_NAME' ($MOD_FILE)";
+	exit 1;
+fi
+
+SPEC_FILE="$2"
+
+if [[ "$SPEC_FILE" == "" ]]; then
+	SPEC_FILE="spec/${MOD_NAME/./_}_spec.lua"
+fi
+
+if [[ "$SPEC_FILE" == "" || ! -f "$SPEC_FILE" ]]; then
+	echo "EE: Failed to find test spec file ($SPEC_FILE)"
+	exit 1;
+fi
+
+if ! busted --helper=loader "$SPEC_FILE"; then
+	echo "EE: Tests fail on original source. Fix it"\!;
+	exit 1;
+fi
+
+export MUTANT_N=0
+LIVING_MUTANTS=0
+
+FILE_PREFIX="${MOD_FILE%.*}.mutant-"
+FILE_SUFFIX=".${MOD_FILE##*.}"
+
+gen_mutant () {
+	echo "Generating mutant $2 to $3..."
+	ltokenp -s "$BASH_SOURCE" "$1" > "$3"
+	return "$?"
+}
+
+# $1 = MOD_NAME, $2 = MUTANT_N, $3 = SPEC_FILE
+test_mutant () {
+	(
+		ulimit -m 131072 # 128MB
+		ulimit -t 16     # 16s
+		ulimit -f 32768  # 128MB (?)
+		exec busted --helper="$BASH_SOURCE" -Xhelper mutate="$1":"$2" "$3"
+	) >/dev/null
+	return "$?";
+}
+
+MUTANT_FILE="${FILE_PREFIX}${MUTANT_N}${FILE_SUFFIX}"
+
+gen_mutant "$MOD_FILE" "$MUTANT_N" "$MUTANT_FILE"
+while [[ "$?" == "0" ]]; do
+	if ! test_mutant "$MOD_NAME" "$MUTANT_N" "$SPEC_FILE"; then
+		echo "Tests successfully killed mutant $MUTANT_N";
+		rm "$MUTANT_FILE";
+	else
+		echo "Mutant $MUTANT_N lives on"\!
+		LIVING_MUTANTS=$((LIVING_MUTANTS+1))
+	fi
+	MUTANT_N=$((MUTANT_N+1))
+	MUTANT_FILE="${FILE_PREFIX}${MUTANT_N}${FILE_SUFFIX}"
+	gen_mutant "$MOD_FILE" "$MUTANT_N" "$MUTANT_FILE"
+done
+
+if [[ "$?" != "2" ]]; then
+	echo "Failed: $?"
+	exit "$?";
+fi
+
+MUTANT_SCORE="$(lua -e "print(('%0.2f'):format((1-($LIVING_MUTANTS/$MUTANT_N))*100))")"
+if test -f mutant-scores.txt; then
+	echo "$MOD_NAME $MUTANT_SCORE" >> mutant-scores.txt
+fi
+echo "$MOD_NAME: All $MUTANT_N mutants generated, $LIVING_MUTANTS survived (score: $MUTANT_SCORE%)"
+rm "$MUTANT_FILE"; # Last file is always unmodified
+exit 0;
+]===]
+
+-- busted helper that runs mutations
+if arg then
+	if arg[1] == "resolve" then
+		local filename = package.searchpath(assert(arg[2], "no module name given"), package.path);
+		if filename then
+			print(filename);
+		end
+		os.exit(filename and 0 or 1);
+	end
+	local mutants = {};
+
+	for i = 1, #arg do
+		local opt = arg[i];
+		print("LOAD", i, opt)
+		local module_name, mutant_n = opt:match("^mutate=([^:]+):(%d+)");
+		if module_name then
+			mutants[module_name] = tonumber(mutant_n);
+		end
+	end
+
+	local orig_lua_searcher = package.searchers[2];
+
+	local function mutant_searcher(module_name)
+		local mutant_n = mutants[module_name];
+		if not mutant_n then
+			return orig_lua_searcher(module_name);
+		end
+		local base_file, err = package.searchpath(module_name, package.path);
+		if not base_file then
+			return base_file, err;
+		end
+		local mutant_file = base_file:gsub("%.lua$", (".mutant-%d.lua"):format(mutant_n));
+		return loadfile(mutant_file), mutant_file;
+	end
+
+	if next(mutants) then
+		table.insert(package.searchers, 1, mutant_searcher);
+	end
+end
+
+-- filter for ltokenp to mutate scripts
+do
+	local last_output = {};
+	local function emit(...)
+		last_output = {...};
+		io.write(...)
+		io.write(" ")
+		return true;
+	end
+
+	local did_mutate = false;
+	local count = -1;
+	local threshold = tonumber(os.getenv("MUTANT_N")) or 0;
+	local function should_mutate()
+		count = count + 1;
+		return count == threshold;
+	end
+
+	local function mutate(name, value)
+		if name == "if" then
+			-- Bypass conditionals
+			if should_mutate() then
+				return emit("if true or");
+			elseif should_mutate() then
+				return emit("if false and");
+			end
+		elseif name == "<integer>" then
+			-- Introduce off-by-one errors
+			if should_mutate() then
+				return emit(("%d"):format(tonumber(value)+1));
+			elseif should_mutate() then
+				return emit(("%d"):format(tonumber(value)-1));
+			end
+		elseif name == "and" then
+			if should_mutate() then
+				return emit("or");
+			end
+		elseif name == "or" then
+			if should_mutate() then
+				return emit("and");
+			end
+		end
+	end
+
+	local current_line_n, current_line_input, current_line_output = 0, {}, {};
+	function FILTER(line_n,token,name,value)
+		if current_line_n ~= line_n then -- Finished a line, moving to the next?
+			if did_mutate and did_mutate.line == current_line_n then
+				-- The line we finished was mutated. Store the original and modified outputs.
+				did_mutate.line_original_src = table.concat(current_line_input, " ");
+				did_mutate.line_modified_src = table.concat(current_line_output, " ");
+			end
+			current_line_input = {};
+			current_line_output = {};
+		end
+		current_line_n = line_n;
+		if name == "<file>" then return; end
+		if name == "<eof>" then
+			if not did_mutate then
+				return os.exit(2);
+			else
+				emit(("\n-- Mutated line %d (changed '%s' to '%s'):\n"):format(did_mutate.line, did_mutate.original, did_mutate.modified))
+				emit(  ("--   Original: %s\n"):format(did_mutate.line_original_src))
+				emit(  ("--   Modified: %s\n"):format(did_mutate.line_modified_src));
+				return;
+			end
+		end
+		if name == "<string>" then
+			value = string.format("%q",value);
+		end
+		if mutate(name, value) then
+			did_mutate = {
+				original = value;
+				modified = table.concat(last_output);
+				line = line_n;
+			};
+		else
+			emit(value);
+		end
+		table.insert(current_line_input, value);
+		table.insert(current_line_output, table.concat(last_output));
+	end
+end
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/xepchanges.sh	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,14 @@
+#!/bin/sh -eu
+
+wget -N https://xmpp.org/extensions/xeplist.xml
+xml2 <xeplist.xml |
+	2csv xep-infos/xep number version |
+	grep -v ^xxxx,|
+	sort -g > xepinfos.csv
+
+xml2 < doc/doap.xml |
+	2csv -d '	' xmpp:SupportedXep @rdf:resource xmpp:version |
+	sed -r 's/https?:\/\/xmpp\.org\/extensions\/xep-0*([1-9][0-9]*)\.html/\1/' |
+	while read -r xep ver ; do
+		grep "^$xep," xepinfos.csv | awk -F, "\$2 != \"$ver\" { print (\"XEP-\"\$1\" updated to \"\$2\" from $ver\") }"
+	done
--- a/util-src/GNUmakefile	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/GNUmakefile	Wed Mar 27 15:39:03 2024 +0000
@@ -8,7 +8,7 @@
 
 ALL=encodings.so hashes.so net.so pposix.so signal.so table.so \
     ringbuffer.so time.so poll.so compat.so strbitop.so \
-    struct.so
+    struct.so crypto.so
 
 ifdef RANDOM
 ALL+=crand.so
@@ -28,7 +28,7 @@
 encodings.o: CFLAGS+=$(IDNA_FLAGS)
 encodings.so: LDLIBS+=$(IDNA_LIBS)
 
-hashes.so: LDLIBS+=$(OPENSSL_LIBS)
+crypto.so hashes.so: LDLIBS+=$(OPENSSL_LIBS)
 
 crand.o: CFLAGS+=-DWITH_$(RANDOM)
 crand.so: LDLIBS+=$(RANDOM_LIBS)
--- a/util-src/compat.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/compat.c	Wed Mar 27 15:39:03 2024 +0000
@@ -19,7 +19,7 @@
   return lua_gettop(L);
 }
 
-int luaopen_util_compat(lua_State *L) {
+int luaopen_prosody_util_compat(lua_State *L) {
 	lua_createtable(L, 0, 2);
 	{
 		lua_pushcfunction(L, lc_xpcall);
@@ -27,3 +27,7 @@
 	}
 	return 1;
 }
+
+int luaopen_util_compat(lua_State *L) {
+	return luaopen_prosody_util_compat(L);
+}
--- a/util-src/crand.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/crand.c	Wed Mar 27 15:39:03 2024 +0000
@@ -45,7 +45,7 @@
 #endif
 
 /* This wasn't present before glibc 2.25 */
-int getrandom(void *buf, size_t buflen, unsigned int flags) {
+static int getrandom(void *buf, size_t buflen, unsigned int flags) {
 	return syscall(SYS_getrandom, buf, buflen, flags);
 }
 #else
@@ -66,7 +66,7 @@
 #define SMALLBUFSIZ 32
 #endif
 
-int Lrandom(lua_State *L) {
+static int Lrandom(lua_State *L) {
 	char smallbuf[SMALLBUFSIZ];
 	char *buf = &smallbuf[0];
 	const lua_Integer l = luaL_checkinteger(L, 1);
@@ -123,10 +123,8 @@
 	return 1;
 }
 
-int luaopen_util_crand(lua_State *L) {
-#if (LUA_VERSION_NUM > 501)
+int luaopen_prosody_util_crand(lua_State *L) {
 	luaL_checkversion(L);
-#endif
 
 	lua_createtable(L, 0, 2);
 	lua_pushcfunction(L, Lrandom);
@@ -144,3 +142,6 @@
 	return 1;
 }
 
+int luaopen_util_crand(lua_State *L) {
+	return luaopen_prosody_util_crand(L);
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util-src/crypto.c	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,622 @@
+/* Prosody IM
+-- Copyright (C) 2022 Matthew Wild
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+*/
+
+/*
+* crypto.c
+* Lua library for cryptographic operations using OpenSSL
+*/
+
+#include <string.h>
+#include <stdlib.h>
+
+#ifdef _MSC_VER
+typedef unsigned __int32 uint32_t;
+#else
+#include <inttypes.h>
+#endif
+
+#include "lua.h"
+#include "lauxlib.h"
+#include <openssl/crypto.h>
+#include <openssl/ecdsa.h>
+#include <openssl/err.h>
+#include <openssl/evp.h>
+#include <openssl/obj_mac.h>
+#include <openssl/pem.h>
+
+#if (LUA_VERSION_NUM == 501)
+#define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
+#endif
+
+/* The max size of an encoded 'R' or 'S' value. P-521 = 521 bits = 66 bytes */
+#define MAX_ECDSA_SIG_INT_BYTES 66
+
+#include "managed_pointer.h"
+
+#define PKEY_MT_TAG "util.crypto key"
+
+static BIO* new_memory_BIO(void) {
+	return BIO_new(BIO_s_mem());
+}
+
+MANAGED_POINTER_ALLOCATOR(new_managed_EVP_MD_CTX, EVP_MD_CTX*, EVP_MD_CTX_new, EVP_MD_CTX_free)
+MANAGED_POINTER_ALLOCATOR(new_managed_BIO_s_mem, BIO*, new_memory_BIO, BIO_free)
+MANAGED_POINTER_ALLOCATOR(new_managed_EVP_CIPHER_CTX, EVP_CIPHER_CTX*, EVP_CIPHER_CTX_new, EVP_CIPHER_CTX_free)
+
+#define CRYPTO_KEY_TYPE_ERR "unexpected key type: got '%s', expected '%s'"
+
+static EVP_PKEY* pkey_from_arg(lua_State *L, int idx, const int type, const int require_private) {
+	EVP_PKEY *pkey = *(EVP_PKEY**)luaL_checkudata(L, idx, PKEY_MT_TAG);
+	int got_type;
+	if(type || require_private) {
+		lua_getuservalue(L, idx);
+		if(type != 0) {
+			lua_getfield(L, -1, "type");
+			got_type = lua_tointeger(L, -1);
+			if(got_type != type) {
+				const char *got_key_type_name = OBJ_nid2sn(got_type);
+				const char *want_key_type_name = OBJ_nid2sn(type);
+				lua_pushfstring(L, CRYPTO_KEY_TYPE_ERR, got_key_type_name, want_key_type_name);
+				luaL_argerror(L, idx, lua_tostring(L, -1));
+			}
+			lua_pop(L, 1);
+		}
+		if(require_private != 0) {
+			lua_getfield(L, -1, "private");
+			if(lua_toboolean(L, -1) != 1) {
+				luaL_argerror(L, idx, "private key expected, got public key only");
+			}
+			lua_pop(L, 1);
+		}
+		lua_pop(L, 1);
+	}
+	return pkey;
+}
+
+static int Lpkey_finalizer(lua_State *L) {
+	EVP_PKEY *pkey = pkey_from_arg(L, 1, 0, 0);
+	EVP_PKEY_free(pkey);
+	return 0;
+}
+
+static int Lpkey_meth_get_type(lua_State *L) {
+	EVP_PKEY *pkey = pkey_from_arg(L, 1, 0, 0);
+
+	int key_type = EVP_PKEY_id(pkey);
+	lua_pushstring(L, OBJ_nid2sn(key_type));
+	return 1;
+}
+
+static int base_evp_sign(lua_State *L, const int key_type, const EVP_MD *digest_type) {
+	EVP_PKEY *pkey = pkey_from_arg(L, 1, (key_type!=NID_rsassaPss)?key_type:NID_rsaEncryption, 1);
+	luaL_Buffer sigbuf;
+
+	size_t msg_len;
+	const unsigned char* msg = (unsigned char*)lua_tolstring(L, 2, &msg_len);
+
+	size_t sig_len;
+	unsigned char *sig = NULL;
+	EVP_MD_CTX *md_ctx = new_managed_EVP_MD_CTX(L);
+
+	if(EVP_DigestSignInit(md_ctx, NULL, digest_type, NULL, pkey) != 1) {
+		lua_pushnil(L);
+		return 1;
+	}
+	if(key_type == NID_rsassaPss) {
+		EVP_PKEY_CTX_set_rsa_padding(EVP_MD_CTX_pkey_ctx(md_ctx), RSA_PKCS1_PSS_PADDING);
+	}
+	if(EVP_DigestSign(md_ctx, NULL, &sig_len, msg, msg_len) != 1) {
+		lua_pushnil(L);
+		return 1;
+	}
+
+	// COMPAT w/ Lua 5.1
+	luaL_buffinit(L, &sigbuf);
+	sig = memset(luaL_prepbuffer(&sigbuf), 0, sig_len);
+
+	if(EVP_DigestSign(md_ctx, sig, &sig_len, msg, msg_len) != 1) {
+		lua_pushnil(L);
+	}
+	else {
+		luaL_addsize(&sigbuf, sig_len);
+		luaL_pushresult(&sigbuf);
+		return 1;
+	}
+
+	return 1;
+}
+
+static int base_evp_verify(lua_State *L, const int key_type, const EVP_MD *digest_type) {
+	EVP_PKEY *pkey = pkey_from_arg(L, 1, (key_type!=NID_rsassaPss)?key_type:NID_rsaEncryption, 0);
+
+	size_t msg_len;
+	const unsigned char *msg = (unsigned char*)luaL_checklstring(L, 2, &msg_len);
+
+	size_t sig_len;
+	const unsigned char *sig = (unsigned char*)luaL_checklstring(L, 3, &sig_len);
+
+	EVP_MD_CTX *md_ctx = EVP_MD_CTX_new();
+
+	if(EVP_DigestVerifyInit(md_ctx, NULL, digest_type, NULL, pkey) != 1) {
+		lua_pushnil(L);
+		goto cleanup;
+	}
+	if(key_type == NID_rsassaPss) {
+		EVP_PKEY_CTX_set_rsa_padding(EVP_MD_CTX_pkey_ctx(md_ctx), RSA_PKCS1_PSS_PADDING);
+	}
+	int result = EVP_DigestVerify(md_ctx, sig, sig_len, msg, msg_len);
+	if(result == 0) {
+		lua_pushboolean(L, 0);
+	} else if(result != 1) {
+		lua_pushnil(L);
+	}
+	else {
+		lua_pushboolean(L, 1);
+	}
+cleanup:
+	EVP_MD_CTX_free(md_ctx);
+	return 1;
+}
+
+static int Lpkey_meth_public_pem(lua_State *L) {
+	char *data;
+	size_t bytes;
+	EVP_PKEY *pkey = pkey_from_arg(L, 1, 0, 0);
+	BIO *bio = new_managed_BIO_s_mem(L);
+	if(PEM_write_bio_PUBKEY(bio, pkey)) {
+		bytes = BIO_get_mem_data(bio, &data);
+		if (bytes > 0) {
+			lua_pushlstring(L, data, bytes);
+		}
+		else {
+			lua_pushnil(L);
+		}
+	}
+	else {
+		lua_pushnil(L);
+	}
+	return 1;
+}
+
+static int Lpkey_meth_private_pem(lua_State *L) {
+	char *data;
+	size_t bytes;
+	EVP_PKEY *pkey = pkey_from_arg(L, 1, 0, 1);
+	BIO *bio = new_managed_BIO_s_mem(L);
+
+	if(PEM_write_bio_PrivateKey(bio, pkey, NULL, NULL, 0, NULL, NULL)) {
+		bytes = BIO_get_mem_data(bio, &data);
+		if (bytes > 0) {
+			lua_pushlstring(L, data, bytes);
+		}
+		else {
+			lua_pushnil(L);
+		}
+	}
+	else {
+		lua_pushnil(L);
+	}
+	return 1;
+}
+
+static int push_pkey(lua_State *L, EVP_PKEY *pkey, const int type, const int privkey) {
+	EVP_PKEY **ud = lua_newuserdata(L, sizeof(EVP_PKEY*));
+	*ud = pkey;
+	luaL_newmetatable(L, PKEY_MT_TAG);
+	lua_setmetatable(L, -2);
+
+	/* Set some info about the key and attach it as a user value */
+	lua_newtable(L);
+	if(type != 0) {
+		lua_pushinteger(L, type);
+		lua_setfield(L, -2, "type");
+	}
+	if(privkey != 0) {
+		lua_pushboolean(L, 1);
+		lua_setfield(L, -2, "private");
+	}
+	lua_setuservalue(L, -2);
+	return 1;
+}
+
+static int Lgenerate_ed25519_keypair(lua_State *L) {
+	EVP_PKEY *pkey = NULL;
+	EVP_PKEY_CTX *pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_ED25519, NULL);
+
+	/* Generate key */
+	EVP_PKEY_keygen_init(pctx);
+	EVP_PKEY_keygen(pctx, &pkey);
+	EVP_PKEY_CTX_free(pctx);
+
+	push_pkey(L, pkey, NID_ED25519, 1);
+	return 1;
+}
+
+static int Limport_private_pem(lua_State *L) {
+	EVP_PKEY *pkey = NULL;
+
+	size_t privkey_bytes;
+	const char* privkey_data;
+	BIO *bio = new_managed_BIO_s_mem(L);
+
+	privkey_data = luaL_checklstring(L, 1, &privkey_bytes);
+	BIO_write(bio, privkey_data, privkey_bytes);
+	pkey = PEM_read_bio_PrivateKey(bio, NULL, NULL, NULL);
+	if (pkey) {
+		push_pkey(L, pkey, EVP_PKEY_id(pkey), 1);
+	}
+	else {
+		lua_pushnil(L);
+	}
+
+	return 1;
+}
+
+static int Limport_public_pem(lua_State *L) {
+	EVP_PKEY *pkey = NULL;
+
+	size_t pubkey_bytes;
+	const char* pubkey_data;
+	BIO *bio = new_managed_BIO_s_mem(L);
+
+	pubkey_data = luaL_checklstring(L, 1, &pubkey_bytes);
+	BIO_write(bio, pubkey_data, pubkey_bytes);
+	pkey = PEM_read_bio_PUBKEY(bio, NULL, NULL, NULL);
+	if (pkey) {
+		push_pkey(L, pkey, EVP_PKEY_id(pkey), 0);
+	}
+	else {
+		lua_pushnil(L);
+	}
+
+	return 1;
+}
+
+static int Led25519_sign(lua_State *L) {
+	return base_evp_sign(L, NID_ED25519, NULL);
+}
+
+static int Led25519_verify(lua_State *L) {
+	return base_evp_verify(L, NID_ED25519, NULL);
+}
+
+/* encrypt(key, iv, plaintext) */
+static int Levp_encrypt(lua_State *L, const EVP_CIPHER *cipher, const unsigned char expected_key_len, const unsigned char expected_iv_len, const size_t tag_len) {
+	EVP_CIPHER_CTX *ctx;
+	luaL_Buffer ciphertext_buffer;
+
+	size_t key_len, iv_len, plaintext_len;
+	int ciphertext_len, final_len;
+
+	const unsigned char *key = (unsigned char*)luaL_checklstring(L, 1, &key_len);
+	const unsigned char *iv = (unsigned char*)luaL_checklstring(L, 2, &iv_len);
+	const unsigned char *plaintext = (unsigned char*)luaL_checklstring(L, 3, &plaintext_len);
+
+	if(key_len != expected_key_len) {
+		return luaL_error(L, "key must be %d bytes", expected_key_len);
+	}
+	if(iv_len != expected_iv_len) {
+		return luaL_error(L, "iv must be %d bytes", expected_iv_len);
+	}
+	if(lua_gettop(L) > 3) {
+		return luaL_error(L, "Expected 3 arguments, got %d", lua_gettop(L));
+	}
+
+	// Create and initialise the context
+	ctx = new_managed_EVP_CIPHER_CTX(L);
+
+	// Initialise the encryption operation
+	if(1 != EVP_EncryptInit_ex(ctx, cipher, NULL, NULL, NULL)) {
+		return luaL_error(L, "Error while initializing encryption engine");
+	}
+
+	// Initialise key and IV
+	if(1 != EVP_EncryptInit_ex(ctx, NULL, NULL, key, iv)) {
+		return luaL_error(L, "Error while initializing key/iv");
+	}
+
+	luaL_buffinit(L, &ciphertext_buffer);
+	unsigned char *ciphertext = (unsigned char*)luaL_prepbuffsize(&ciphertext_buffer, plaintext_len+tag_len);
+
+	if(1 != EVP_EncryptUpdate(ctx, ciphertext, &ciphertext_len, plaintext, plaintext_len)) {
+		return luaL_error(L, "Error while encrypting data");
+	}
+
+	/*
+	* Finalise the encryption. Normally ciphertext bytes may be written at
+	* this stage, but this does not occur in GCM mode
+	*/
+	if(1 != EVP_EncryptFinal_ex(ctx, ciphertext + ciphertext_len, &final_len)) {
+		return luaL_error(L, "Error while encrypting final data");
+	}
+	if(final_len != 0) {
+		return luaL_error(L, "Non-zero final data");
+	}
+
+	if(tag_len > 0) {
+		/* Get the tag */
+		if(1 != EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_GET_TAG, tag_len, ciphertext + ciphertext_len)) {
+			return luaL_error(L, "Unable to read AEAD tag of encrypted data");
+		}
+		/* Append tag */
+		luaL_addsize(&ciphertext_buffer, ciphertext_len + tag_len);
+	} else {
+		luaL_addsize(&ciphertext_buffer, ciphertext_len);
+	}
+	luaL_pushresult(&ciphertext_buffer);
+
+	return 1;
+}
+
+static int Laes_128_gcm_encrypt(lua_State *L) {
+	return Levp_encrypt(L, EVP_aes_128_gcm(), 16, 12, 16);
+}
+
+static int Laes_256_gcm_encrypt(lua_State *L) {
+	return Levp_encrypt(L, EVP_aes_256_gcm(), 32, 12, 16);
+}
+
+static int Laes_256_ctr_encrypt(lua_State *L) {
+	return Levp_encrypt(L, EVP_aes_256_ctr(), 32, 16, 0);
+}
+
+/* decrypt(key, iv, ciphertext) */
+static int Levp_decrypt(lua_State *L, const EVP_CIPHER *cipher, const unsigned char expected_key_len, const unsigned char expected_iv_len, const size_t tag_len) {
+	EVP_CIPHER_CTX *ctx;
+	luaL_Buffer plaintext_buffer;
+
+	size_t key_len, iv_len, ciphertext_len;
+	int plaintext_len, final_len;
+
+	const unsigned char *key = (unsigned char*)luaL_checklstring(L, 1, &key_len);
+	const unsigned char *iv = (unsigned char*)luaL_checklstring(L, 2, &iv_len);
+	const unsigned char *ciphertext = (unsigned char*)luaL_checklstring(L, 3, &ciphertext_len);
+
+	if(key_len != expected_key_len) {
+		return luaL_error(L, "key must be %d bytes", expected_key_len);
+	}
+	if(iv_len != expected_iv_len) {
+		return luaL_error(L, "iv must be %d bytes", expected_iv_len);
+	}
+	if(ciphertext_len <= tag_len) {
+		return luaL_error(L, "ciphertext must be at least %d bytes (including tag)", tag_len);
+	}
+	if(lua_gettop(L) > 3) {
+		return luaL_error(L, "Expected 3 arguments, got %d", lua_gettop(L));
+	}
+
+	/* Create and initialise the context */
+	ctx = new_managed_EVP_CIPHER_CTX(L);
+
+	/* Initialise the decryption operation. */
+	if(!EVP_DecryptInit_ex(ctx, cipher, NULL, NULL, NULL)) {
+		return luaL_error(L, "Error while initializing decryption engine");
+	}
+
+	/* Initialise key and IV */
+	if(!EVP_DecryptInit_ex(ctx, NULL, NULL, key, iv)) {
+		return luaL_error(L, "Error while initializing key/iv");
+	}
+
+	luaL_buffinit(L, &plaintext_buffer);
+	unsigned char *plaintext = (unsigned char*)luaL_prepbuffsize(&plaintext_buffer, ciphertext_len);
+
+	/*
+	* Provide the message to be decrypted, and obtain the plaintext output.
+	* EVP_DecryptUpdate can be called multiple times if necessary
+	*/
+	if(!EVP_DecryptUpdate(ctx, plaintext, &plaintext_len, ciphertext, ciphertext_len-tag_len)) {
+		return luaL_error(L, "Error while decrypting data");
+	}
+
+	if(tag_len > 0) {
+		/* Set expected tag value. Works in OpenSSL 1.0.1d and later */
+		if(!EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_AEAD_SET_TAG, tag_len, (unsigned char*)ciphertext + (ciphertext_len-tag_len))) {
+			return luaL_error(L, "Error while processing authentication tag");
+		}
+	}
+
+	/*
+	* Finalise the decryption. A positive return value indicates success,
+	* anything else is a failure - the plaintext is not trustworthy.
+	*/
+	int ret = EVP_DecryptFinal_ex(ctx, plaintext + plaintext_len, &final_len);
+
+	if(ret <= 0) {
+		/* Verify failed */
+		lua_pushnil(L);
+		lua_pushliteral(L, "verify-failed");
+		return 2;
+	}
+
+	luaL_addsize(&plaintext_buffer, plaintext_len + final_len);
+	luaL_pushresult(&plaintext_buffer);
+	return 1;
+}
+
+static int Laes_128_gcm_decrypt(lua_State *L) {
+	return Levp_decrypt(L, EVP_aes_128_gcm(), 16, 12, 16);
+}
+
+static int Laes_256_gcm_decrypt(lua_State *L) {
+	return Levp_decrypt(L, EVP_aes_256_gcm(), 32, 12, 16);
+}
+
+static int Laes_256_ctr_decrypt(lua_State *L) {
+	return Levp_decrypt(L, EVP_aes_256_ctr(), 32, 16, 0);
+}
+
+/* r, s = parse_ecdsa_sig(sig_der) */
+static int Lparse_ecdsa_signature(lua_State *L) {
+	ECDSA_SIG *sig;
+	size_t sig_der_len;
+	const unsigned char *sig_der = (unsigned char*)luaL_checklstring(L, 1, &sig_der_len);
+	const size_t sig_int_bytes = luaL_checkinteger(L, 2);
+	const BIGNUM *r, *s;
+	int rlen, slen;
+	unsigned char rb[MAX_ECDSA_SIG_INT_BYTES];
+	unsigned char sb[MAX_ECDSA_SIG_INT_BYTES];
+
+	if(sig_int_bytes > MAX_ECDSA_SIG_INT_BYTES) {
+		luaL_error(L, "requested signature size exceeds supported limit");
+	}
+
+	sig = d2i_ECDSA_SIG(NULL, &sig_der, sig_der_len);
+
+	if(sig == NULL) {
+		lua_pushnil(L);
+		return 1;
+	}
+
+	ECDSA_SIG_get0(sig, &r, &s);
+
+	rlen = BN_bn2binpad(r, rb, sig_int_bytes);
+	slen = BN_bn2binpad(s, sb, sig_int_bytes);
+
+	if (rlen == -1 || slen == -1) {
+		ECDSA_SIG_free(sig);
+		luaL_error(L, "encoded integers exceed requested size");
+	}
+
+	ECDSA_SIG_free(sig);
+
+	lua_pushlstring(L, (const char*)rb, rlen);
+	lua_pushlstring(L, (const char*)sb, slen);
+
+	return 2;
+}
+
+/* sig_der = build_ecdsa_signature(r, s) */
+static int Lbuild_ecdsa_signature(lua_State *L) {
+	ECDSA_SIG *sig = ECDSA_SIG_new();
+	BIGNUM *r, *s;
+	luaL_Buffer sigbuf;
+
+	size_t rlen, slen;
+	const unsigned char *rbin, *sbin;
+
+	rbin = (unsigned char*)luaL_checklstring(L, 1, &rlen);
+	sbin = (unsigned char*)luaL_checklstring(L, 2, &slen);
+
+	r = BN_bin2bn(rbin, (int)rlen, NULL);
+	s = BN_bin2bn(sbin, (int)slen, NULL);
+
+	ECDSA_SIG_set0(sig, r, s);
+
+	luaL_buffinit(L, &sigbuf);
+
+	/* DER structure of an ECDSA signature has 7 bytes plus the integers themselves,
+	   which may gain an extra byte once encoded */
+	unsigned char *buffer = (unsigned char*)luaL_prepbuffsize(&sigbuf, (rlen+1)+(slen+1)+7);
+	int len = i2d_ECDSA_SIG(sig, &buffer);
+	luaL_addsize(&sigbuf, len);
+	luaL_pushresult(&sigbuf);
+
+	ECDSA_SIG_free(sig);
+
+	return 1;
+}
+
+#define REG_SIGN_VERIFY(algorithm, digest) \
+	{ #algorithm "_" #digest "_sign",       L ## algorithm ## _ ## digest ## _sign    },\
+	{ #algorithm "_" #digest "_verify",     L ## algorithm ## _ ## digest ## _verify  },
+
+#define IMPL_SIGN_VERIFY(algorithm, key_type, digest) \
+  static int L ## algorithm ## _ ## digest ## _sign(lua_State *L) {   \
+  	return base_evp_sign(L, key_type, EVP_ ## digest());          \
+  }                                                                   \
+  static int L ## algorithm ## _ ## digest ## _verify(lua_State *L) { \
+  	return base_evp_verify(L, key_type, EVP_ ## digest());        \
+  }
+
+IMPL_SIGN_VERIFY(ecdsa, NID_X9_62_id_ecPublicKey, sha256)
+IMPL_SIGN_VERIFY(ecdsa, NID_X9_62_id_ecPublicKey, sha384)
+IMPL_SIGN_VERIFY(ecdsa, NID_X9_62_id_ecPublicKey, sha512)
+
+IMPL_SIGN_VERIFY(rsassa_pkcs1, NID_rsaEncryption, sha256)
+IMPL_SIGN_VERIFY(rsassa_pkcs1, NID_rsaEncryption, sha384)
+IMPL_SIGN_VERIFY(rsassa_pkcs1, NID_rsaEncryption, sha512)
+
+IMPL_SIGN_VERIFY(rsassa_pss, NID_rsassaPss, sha256)
+IMPL_SIGN_VERIFY(rsassa_pss, NID_rsassaPss, sha384)
+IMPL_SIGN_VERIFY(rsassa_pss, NID_rsassaPss, sha512)
+
+static const luaL_Reg Reg[] = {
+	{ "ed25519_sign",                Led25519_sign             },
+	{ "ed25519_verify",              Led25519_verify           },
+
+	REG_SIGN_VERIFY(ecdsa, sha256)
+	REG_SIGN_VERIFY(ecdsa, sha384)
+	REG_SIGN_VERIFY(ecdsa, sha512)
+
+	REG_SIGN_VERIFY(rsassa_pkcs1, sha256)
+	REG_SIGN_VERIFY(rsassa_pkcs1, sha384)
+	REG_SIGN_VERIFY(rsassa_pkcs1, sha512)
+
+	REG_SIGN_VERIFY(rsassa_pss, sha256)
+	REG_SIGN_VERIFY(rsassa_pss, sha384)
+	REG_SIGN_VERIFY(rsassa_pss, sha512)
+
+	{ "aes_128_gcm_encrypt",         Laes_128_gcm_encrypt      },
+	{ "aes_128_gcm_decrypt",         Laes_128_gcm_decrypt      },
+	{ "aes_256_gcm_encrypt",         Laes_256_gcm_encrypt      },
+	{ "aes_256_gcm_decrypt",         Laes_256_gcm_decrypt      },
+
+	{ "aes_256_ctr_encrypt",         Laes_256_ctr_encrypt      },
+	{ "aes_256_ctr_decrypt",         Laes_256_ctr_decrypt      },
+
+	{ "generate_ed25519_keypair",    Lgenerate_ed25519_keypair },
+
+	{ "import_private_pem",          Limport_private_pem       },
+	{ "import_public_pem",           Limport_public_pem        },
+
+	{ "parse_ecdsa_signature",       Lparse_ecdsa_signature    },
+	{ "build_ecdsa_signature",       Lbuild_ecdsa_signature    },
+	{ NULL,                          NULL                      }
+};
+
+static const luaL_Reg KeyMethods[] = {
+	{ "private_pem",            Lpkey_meth_private_pem       },
+	{ "public_pem",             Lpkey_meth_public_pem        },
+	{ "get_type",               Lpkey_meth_get_type          },
+	{ NULL,                     NULL                         }
+};
+
+static const luaL_Reg KeyMetatable[] = {
+	{ "__gc",               Lpkey_finalizer },
+	{ NULL,                 NULL            }
+};
+
+LUALIB_API int luaopen_prosody_util_crypto(lua_State *L) {
+#if (LUA_VERSION_NUM > 501)
+	luaL_checkversion(L);
+#endif
+
+	/* Initialize pkey metatable */
+	luaL_newmetatable(L, PKEY_MT_TAG);
+	luaL_setfuncs(L, KeyMetatable, 0);
+	lua_newtable(L);
+	luaL_setfuncs(L, KeyMethods, 0);
+	lua_setfield(L, -2, "__index");
+	lua_pop(L, 1);
+
+	/* Initialize lib table */
+	lua_newtable(L);
+	luaL_setfuncs(L, Reg, 0);
+	lua_pushliteral(L, "-3.14");
+	lua_setfield(L, -2, "version");
+#ifdef OPENSSL_VERSION
+	lua_pushstring(L, OpenSSL_version(OPENSSL_VERSION));
+	lua_setfield(L, -2, "_LIBCRYPTO_VERSION");
+#endif
+	return 1;
+}
+
+LUALIB_API int luaopen_util_crypto(lua_State *L) {
+	return luaopen_prosody_util_crypto(L);
+}
--- a/util-src/encodings.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/encodings.c	Wed Mar 27 15:39:03 2024 +0000
@@ -21,9 +21,6 @@
 #include "lua.h"
 #include "lauxlib.h"
 
-#if (LUA_VERSION_NUM == 501)
-#define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
-#endif
 #if (LUA_VERSION_NUM < 504)
 #define luaL_pushfail lua_pushnil
 #endif
@@ -615,10 +612,8 @@
 
 /***************** end *****************/
 
-LUALIB_API int luaopen_util_encodings(lua_State *L) {
-#if (LUA_VERSION_NUM > 501)
+LUALIB_API int luaopen_prosody_util_encodings(lua_State *L) {
 	luaL_checkversion(L);
-#endif
 #ifdef USE_STRINGPREP_ICU
 	init_icu();
 #endif
@@ -651,3 +646,6 @@
 	lua_setfield(L, -2, "version");
 	return 1;
 }
+LUALIB_API int luaopen_util_encodings(lua_State *L) {
+	return luaopen_prosody_util_encodings(L);
+}
--- a/util-src/hashes.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/hashes.c	Wed Mar 27 15:39:03 2024 +0000
@@ -28,13 +28,16 @@
 #include <openssl/md5.h>
 #include <openssl/hmac.h>
 #include <openssl/evp.h>
+#include <openssl/kdf.h>
+#include <openssl/err.h>
 
-#if (LUA_VERSION_NUM == 501)
-#define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
-#endif
 
-#define HMAC_IPAD 0x36363636
-#define HMAC_OPAD 0x5c5c5c5c
+/* Semi-arbitrary limit here. The actual theoretical limit
+*  is (255*(hash output octets)), but allocating 16KB on the
+*  stack when in practice we only ever request a few dozen
+*  bytes seems excessive.
+*/
+#define MAX_HKDF_OUTPUT 256
 
 static const char *hex_tab = "0123456789abcdef";
 static void toHex(const unsigned char *in, int length, unsigned char *out) {
@@ -46,94 +49,228 @@
 	}
 }
 
-#define MAKE_HASH_FUNCTION(myFunc, func, size) \
-static int myFunc(lua_State *L) { \
-	size_t len; \
-	const char *s = luaL_checklstring(L, 1, &len); \
-	int hex_out = lua_toboolean(L, 2); \
-	unsigned char hash[size], result[size*2]; \
-	func((const unsigned char*)s, len, hash);  \
-	if (hex_out) { \
-		toHex(hash, size, result); \
-		lua_pushlstring(L, (char*)result, size*2); \
-	} else { \
-		lua_pushlstring(L, (char*)hash, size);\
-	} \
-	return 1; \
+static int Levp_hash(lua_State *L, const EVP_MD *evp) {
+	size_t len;
+	unsigned int size = EVP_MAX_MD_SIZE;
+	const char *s = luaL_checklstring(L, 1, &len);
+	int hex_out = lua_toboolean(L, 2);
+
+	unsigned char hash[EVP_MAX_MD_SIZE], result[EVP_MAX_MD_SIZE * 2];
+
+	EVP_MD_CTX *ctx = EVP_MD_CTX_new();
+
+	if(ctx == NULL) {
+		goto fail;
+	}
+
+	if(!EVP_DigestInit_ex(ctx, evp, NULL)) {
+		goto fail;
+	}
+
+	if(!EVP_DigestUpdate(ctx, s, len)) {
+		goto fail;
+	}
+
+	if(!EVP_DigestFinal_ex(ctx, hash, &size)) {
+		goto fail;
+	}
+
+	EVP_MD_CTX_free(ctx);
+
+	if(hex_out) {
+		toHex(hash, size, result);
+		lua_pushlstring(L, (char *)result, size * 2);
+	} else {
+		lua_pushlstring(L, (char *)hash, size);
+	}
+
+	return 1;
+
+fail:
+	EVP_MD_CTX_free(ctx);
+	return luaL_error(L, ERR_error_string(ERR_get_error(), NULL));
+}
+
+static int Lsha1(lua_State *L) {
+	return Levp_hash(L, EVP_sha1());
+}
+
+static int Lsha224(lua_State *L) {
+	return Levp_hash(L, EVP_sha224());
+}
+
+static int Lsha256(lua_State *L) {
+	return Levp_hash(L, EVP_sha256());
+}
+
+static int Lsha384(lua_State *L) {
+	return Levp_hash(L, EVP_sha384());
+}
+
+static int Lsha512(lua_State *L) {
+	return Levp_hash(L, EVP_sha512());
+}
+
+static int Lmd5(lua_State *L) {
+	return Levp_hash(L, EVP_md5());
+}
+
+static int Lblake2s256(lua_State *L) {
+	return Levp_hash(L, EVP_blake2s256());
+}
+
+static int Lblake2b512(lua_State *L) {
+	return Levp_hash(L, EVP_blake2b512());
 }
 
-MAKE_HASH_FUNCTION(Lsha1, SHA1, SHA_DIGEST_LENGTH)
-MAKE_HASH_FUNCTION(Lsha224, SHA224, SHA224_DIGEST_LENGTH)
-MAKE_HASH_FUNCTION(Lsha256, SHA256, SHA256_DIGEST_LENGTH)
-MAKE_HASH_FUNCTION(Lsha384, SHA384, SHA384_DIGEST_LENGTH)
-MAKE_HASH_FUNCTION(Lsha512, SHA512, SHA512_DIGEST_LENGTH)
-MAKE_HASH_FUNCTION(Lmd5, MD5, MD5_DIGEST_LENGTH)
+static int Lsha3_256(lua_State *L) {
+	return Levp_hash(L, EVP_sha3_256());
+}
+
+static int Lsha3_512(lua_State *L) {
+	return Levp_hash(L, EVP_sha3_512());
+}
 
-struct hash_desc {
-	int (*Init)(void *);
-	int (*Update)(void *, const void *, size_t);
-	int (*Final)(unsigned char *, void *);
-	size_t digestLength;
-	void *ctx, *ctxo;
-};
+static int Levp_hmac(lua_State *L, const EVP_MD *evp) {
+	unsigned char hash[EVP_MAX_MD_SIZE], result[EVP_MAX_MD_SIZE * 2];
+	size_t key_len, msg_len;
+	unsigned int out_len = EVP_MAX_MD_SIZE;
+	const char *key = luaL_checklstring(L, 1, &key_len);
+	const char *msg = luaL_checklstring(L, 2, &msg_len);
+	const int hex_out = lua_toboolean(L, 3);
 
-#define MAKE_HMAC_FUNCTION(myFunc, evp, size, type) \
-static int myFunc(lua_State *L) { \
-	unsigned char hash[size], result[2*size]; \
-	size_t key_len, msg_len; \
-	unsigned int out_len; \
-	const char *key = luaL_checklstring(L, 1, &key_len); \
-	const char *msg = luaL_checklstring(L, 2, &msg_len); \
-	const int hex_out = lua_toboolean(L, 3); \
-	HMAC(evp(), key, key_len, (const unsigned char*)msg, msg_len, (unsigned char*)hash, &out_len); \
-	if (hex_out) { \
-		toHex(hash, out_len, result); \
-		lua_pushlstring(L, (char*)result, out_len*2); \
-	} else { \
-		lua_pushlstring(L, (char*)hash, out_len); \
-	} \
-	return 1; \
+	if(HMAC(evp, key, key_len, (const unsigned char*)msg, msg_len, (unsigned char*)hash, &out_len) == NULL) {
+		goto fail;
+	}
+
+	if(hex_out) {
+		toHex(hash, out_len, result);
+		lua_pushlstring(L, (char *)result, out_len * 2);
+	} else {
+		lua_pushlstring(L, (char *)hash, out_len);
+	}
+
+	return 1;
+
+fail:
+	return luaL_error(L, ERR_error_string(ERR_get_error(), NULL));
+}
+
+static int Lhmac_sha1(lua_State *L) {
+	return Levp_hmac(L, EVP_sha1());
 }
 
-MAKE_HMAC_FUNCTION(Lhmac_sha1, EVP_sha1, SHA_DIGEST_LENGTH, SHA_CTX)
-MAKE_HMAC_FUNCTION(Lhmac_sha256, EVP_sha256, SHA256_DIGEST_LENGTH, SHA256_CTX)
-MAKE_HMAC_FUNCTION(Lhmac_sha512, EVP_sha512, SHA512_DIGEST_LENGTH, SHA512_CTX)
-MAKE_HMAC_FUNCTION(Lhmac_md5, EVP_md5, MD5_DIGEST_LENGTH, MD5_CTX)
+static int Lhmac_sha224(lua_State *L) {
+	return Levp_hmac(L, EVP_sha224());
+}
+
+static int Lhmac_sha256(lua_State *L) {
+	return Levp_hmac(L, EVP_sha256());
+}
+
+static int Lhmac_sha384(lua_State *L) {
+	return Levp_hmac(L, EVP_sha384());
+}
+
+static int Lhmac_sha512(lua_State *L) {
+	return Levp_hmac(L, EVP_sha512());
+}
+
+static int Lhmac_md5(lua_State *L) {
+	return Levp_hmac(L, EVP_md5());
+}
 
-static int Lpbkdf2_sha1(lua_State *L) {
-	unsigned char out[SHA_DIGEST_LENGTH];
+static int Lhmac_sha3_256(lua_State *L) {
+	return Levp_hmac(L, EVP_sha3_256());
+}
+
+static int Lhmac_sha3_512(lua_State *L) {
+	return Levp_hmac(L, EVP_sha3_512());
+}
+
+static int Lhmac_blake2s256(lua_State *L) {
+	return Levp_hmac(L, EVP_blake2s256());
+}
+
+static int Lhmac_blake2b512(lua_State *L) {
+	return Levp_hmac(L, EVP_blake2b512());
+}
+
+
+static int Levp_pbkdf2(lua_State *L, const EVP_MD *evp, size_t out_len) {
+	unsigned char out[EVP_MAX_MD_SIZE];
 
 	size_t pass_len, salt_len;
 	const char *pass = luaL_checklstring(L, 1, &pass_len);
 	const unsigned char *salt = (unsigned char *)luaL_checklstring(L, 2, &salt_len);
 	const int iter = luaL_checkinteger(L, 3);
 
-	if(PKCS5_PBKDF2_HMAC(pass, pass_len, salt, salt_len, iter, EVP_sha1(), SHA_DIGEST_LENGTH, out) == 0) {
-		return luaL_error(L, "PKCS5_PBKDF2_HMAC() failed");
+	if(PKCS5_PBKDF2_HMAC(pass, pass_len, salt, salt_len, iter, evp, out_len, out) == 0) {
+		return luaL_error(L, ERR_error_string(ERR_get_error(), NULL));
 	}
 
-	lua_pushlstring(L, (char *)out, SHA_DIGEST_LENGTH);
+	lua_pushlstring(L, (char *)out, out_len);
 
 	return 1;
 }
 
+static int Lpbkdf2_sha1(lua_State *L) {
+	return Levp_pbkdf2(L, EVP_sha1(), SHA_DIGEST_LENGTH);
+}
 
 static int Lpbkdf2_sha256(lua_State *L) {
-	unsigned char out[SHA256_DIGEST_LENGTH];
+	return Levp_pbkdf2(L, EVP_sha256(), SHA256_DIGEST_LENGTH);
+}
+
+
+/* HKDF(length, input, salt, info) */
+static int Levp_hkdf(lua_State *L, const EVP_MD *evp) {
+	unsigned char out[MAX_HKDF_OUTPUT];
+
+	size_t input_len, salt_len, info_len;
+	size_t actual_out_len = luaL_checkinteger(L, 1);
+	const unsigned char *input = (unsigned char *)luaL_checklstring(L, 2, &input_len);
+	const unsigned char *salt = (unsigned char *)luaL_optlstring(L, 3, NULL, &salt_len);
+	const unsigned char *info = (unsigned char *)luaL_checklstring(L, 4, &info_len);
 
-	size_t pass_len, salt_len;
-	const char *pass = luaL_checklstring(L, 1, &pass_len);
-	const unsigned char *salt = (unsigned char *)luaL_checklstring(L, 2, &salt_len);
-	const int iter = luaL_checkinteger(L, 3);
+	if(actual_out_len > MAX_HKDF_OUTPUT)
+		return luaL_error(L, "desired output length %ul exceeds internal limit %ul", actual_out_len, MAX_HKDF_OUTPUT);
+
+	EVP_PKEY_CTX *pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_HKDF, NULL);
 
-	if(PKCS5_PBKDF2_HMAC(pass, pass_len, salt, salt_len, iter, EVP_sha256(), SHA256_DIGEST_LENGTH, out) == 0) {
-		return luaL_error(L, "PKCS5_PBKDF2_HMAC() failed");
+	if (EVP_PKEY_derive_init(pctx) <= 0)
+		return luaL_error(L, ERR_error_string(ERR_get_error(), NULL));
+
+	if (EVP_PKEY_CTX_set_hkdf_md(pctx, evp) <= 0)
+		return luaL_error(L, ERR_error_string(ERR_get_error(), NULL));
+
+	if(salt != NULL) {
+		if (EVP_PKEY_CTX_set1_hkdf_salt(pctx, salt, salt_len) <= 0)
+			return luaL_error(L, ERR_error_string(ERR_get_error(), NULL));
 	}
 
-	lua_pushlstring(L, (char *)out, SHA256_DIGEST_LENGTH);
+	if (EVP_PKEY_CTX_set1_hkdf_key(pctx, input, input_len) <= 0)
+		return luaL_error(L, ERR_error_string(ERR_get_error(), NULL));
+
+	if (EVP_PKEY_CTX_add1_hkdf_info(pctx, info, info_len) <= 0)
+		return luaL_error(L, ERR_error_string(ERR_get_error(), NULL));
+
+	if (EVP_PKEY_derive(pctx, out, &actual_out_len) <= 0)
+		return luaL_error(L, ERR_error_string(ERR_get_error(), NULL));
+
+	lua_pushlstring(L, (char *)out, actual_out_len);
+
 	return 1;
 }
 
+static int Lhkdf_sha256(lua_State *L) {
+	return Levp_hkdf(L, EVP_sha256());
+}
+
+static int Lhkdf_sha384(lua_State *L) {
+	return Levp_hkdf(L, EVP_sha384());
+}
+
 static int Lhash_equals(lua_State *L) {
 	size_t len1, len2;
 	const char *s1 = luaL_checklstring(L, 1, &len1);
@@ -153,21 +290,31 @@
 	{ "sha384",		Lsha384		},
 	{ "sha512",		Lsha512		},
 	{ "md5",		Lmd5		},
+	{ "sha3_256",		Lsha3_256	},
+	{ "sha3_512",		Lsha3_512	},
+	{ "blake2s256",		Lblake2s256	},
+	{ "blake2b512",		Lblake2b512	},
 	{ "hmac_sha1",		Lhmac_sha1	},
+	{ "hmac_sha224",	Lhmac_sha224	},
 	{ "hmac_sha256",	Lhmac_sha256	},
+	{ "hmac_sha384",	Lhmac_sha384	},
 	{ "hmac_sha512",	Lhmac_sha512	},
 	{ "hmac_md5",		Lhmac_md5	},
+	{ "hmac_sha3_256",	Lhmac_sha3_256	},
+	{ "hmac_sha3_512",	Lhmac_sha3_512	},
+	{ "hmac_blake2s256",	Lhmac_blake2s256	},
+	{ "hmac_blake2b512",	Lhmac_blake2b512	},
 	{ "scram_Hi_sha1",	Lpbkdf2_sha1	}, /* COMPAT */
 	{ "pbkdf2_hmac_sha1",	Lpbkdf2_sha1	},
 	{ "pbkdf2_hmac_sha256",	Lpbkdf2_sha256	},
+	{ "hkdf_hmac_sha256",   Lhkdf_sha256    },
+	{ "hkdf_hmac_sha384",   Lhkdf_sha384    },
 	{ "equals",             Lhash_equals    },
 	{ NULL,			NULL		}
 };
 
-LUALIB_API int luaopen_util_hashes(lua_State *L) {
-#if (LUA_VERSION_NUM > 501)
+LUALIB_API int luaopen_prosody_util_hashes(lua_State *L) {
 	luaL_checkversion(L);
-#endif
 	lua_newtable(L);
 	luaL_setfuncs(L, Reg, 0);
 	lua_pushliteral(L, "-3.14");
@@ -178,3 +325,6 @@
 #endif
 	return 1;
 }
+LUALIB_API int luaopen_util_hashes(lua_State *L) {
+	return luaopen_prosody_util_hashes(L);
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util-src/managed_pointer.h	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,61 @@
+/* managed_pointer.h
+
+These macros allow wrapping an allocator/deallocator into an object that is
+owned and managed by the Lua garbage collector.
+
+Why? It is too easy to leak objects that need to be manually released, especially
+when dealing with the Lua API which can throw errors from many operations.
+
+USAGE
+-----
+
+For example, given an object that can be created or released with the following
+functions:
+
+  fancy_buffer* new_buffer();
+  void free_buffer(fancy_buffer* p_buffer)
+
+You could declare a managed version like so:
+
+  MANAGED_POINTER_ALLOCATOR(new_managed_buffer, fancy_buffer*, new_buffer, free_buffer)
+
+And then, when you need to create a new fancy_buffer in your code:
+
+  fancy_buffer *my_buffer = new_managed_buffer(L);
+
+NOTES
+-----
+
+Managed objects MUST NOT be freed manually. They will automatically be
+freed during the next GC sweep after your function exits (even if via an error).
+
+The managed object is pushed onto the stack, but should generally be ignored,
+but you'll need to bear this in mind when creating managed pointers in the
+middle of a sequence of stack operations.
+*/
+
+#define MANAGED_POINTER_MT(wrapped_type) #wrapped_type "_managedptr_mt"
+
+#define MANAGED_POINTER_ALLOCATOR(name, wrapped_type, wrapped_alloc, wrapped_free) \
+  static int _release_ ## name(lua_State *L) {                                \
+  	wrapped_type *p = (wrapped_type*)lua_topointer(L, 1);                 \
+  	if(*p != NULL) {                                                      \
+	  	wrapped_free(*p);                                             \
+	}                                                                     \
+  	return 0;                                                             \
+  }                                                                           \
+  static wrapped_type name(lua_State *L) {                                    \
+  	wrapped_type *p = (wrapped_type*)lua_newuserdata(L, sizeof(wrapped_type)); \
+  	if(luaL_newmetatable(L, MANAGED_POINTER_MT(wrapped_type)) != 0) {     \
+  		lua_pushcfunction(L, _release_ ## name);                      \
+  		lua_setfield(L, -2, "__gc");                                  \
+  	}                                                                     \
+  	lua_setmetatable(L, -2);                                              \
+  	*p = wrapped_alloc();                                                 \
+  	if(*p == NULL) {                                                      \
+  		lua_pushliteral(L, "not enough memory");                      \
+  		lua_error(L);                                                 \
+  	}                                                                     \
+  	return *p;                                                            \
+  }
+
--- a/util-src/net.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/net.c	Wed Mar 27 15:39:03 2024 +0000
@@ -30,9 +30,6 @@
 #include <lua.h>
 #include <lauxlib.h>
 
-#if (LUA_VERSION_NUM == 501)
-#define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
-#endif
 #if (LUA_VERSION_NUM < 504)
 #define luaL_pushfail lua_pushnil
 #endif
@@ -192,10 +189,8 @@
 	return 1;
 }
 
-int luaopen_util_net(lua_State *L) {
-#if (LUA_VERSION_NUM > 501)
+int luaopen_prosody_util_net(lua_State *L) {
 	luaL_checkversion(L);
-#endif
 	luaL_Reg exports[] = {
 		{ "local_addresses", lc_local_addresses },
 		{ "pton", lc_pton },
@@ -207,3 +202,6 @@
 	luaL_setfuncs(L, exports, 0);
 	return 1;
 }
+int luaopen_util_net(lua_State *L) {
+	return luaopen_prosody_util_net(L);
+}
--- a/util-src/poll.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/poll.c	Wed Mar 27 15:39:03 2024 +0000
@@ -8,7 +8,6 @@
  *
  */
 
-#include <unistd.h>
 #include <string.h>
 #include <errno.h>
 
@@ -24,15 +23,18 @@
 #endif
 
 #ifdef USE_EPOLL
+#include <unistd.h>
 #include <sys/epoll.h>
 #ifndef MAX_EVENTS
-#define MAX_EVENTS 64
+/* Maximum number of returned events, retrieved into Lpoll_state */
+#define MAX_EVENTS 256
 #endif
 #endif
 #ifdef USE_POLL
 #include <poll.h>
-#ifndef MAX_EVENTS
-#define MAX_EVENTS 10000
+#ifndef MAX_WATCHED
+/* Maximum number of watched sockets, kept in Lpoll_state */
+#define MAX_WATCHED 10000
 #endif
 #endif
 #ifdef USE_SELECT
@@ -44,9 +46,6 @@
 
 #define STATE_MT "util.poll<" POLL_BACKEND ">"
 
-#if (LUA_VERSION_NUM == 501)
-#define luaL_setmetatable(L, tname) luaL_getmetatable(L, tname); lua_setmetatable(L, -2)
-#endif
 #if (LUA_VERSION_NUM < 504)
 #define luaL_pushfail lua_pushnil
 #endif
@@ -62,7 +61,7 @@
 #endif
 #ifdef USE_POLL
 	nfds_t count;
-	struct pollfd events[MAX_EVENTS];
+	struct pollfd events[MAX_WATCHED];
 #endif
 #ifdef USE_SELECT
 	fd_set wantread;
@@ -123,7 +122,7 @@
 		}
 	}
 
-	if(state->count >= MAX_EVENTS) {
+	if(state->count >= MAX_WATCHED) {
 		luaL_pushfail(L);
 		lua_pushstring(L, strerror(EMFILE));
 		lua_pushinteger(L, EMFILE);
@@ -414,6 +413,12 @@
 	lua_Number timeout = luaL_checknumber(L, 2);
 	luaL_argcheck(L, timeout >= 0, 1, "positive number expected");
 
+	if(timeout == 0.0) {
+		lua_pushnil(L);
+		lua_pushstring(L, "timeout");
+		return 2;
+	}
+
 #ifdef USE_EPOLL
 	ret = epoll_wait(state->epoll_fd, state->events, MAX_EVENTS, timeout * 1000);
 #endif
@@ -540,7 +545,7 @@
 	state->processed = -1;
 	state->count = 0;
 
-	for(nfds_t i = 0; i < MAX_EVENTS; i++) {
+	for(nfds_t i = 0; i < MAX_WATCHED; i++) {
 		state->events[i].fd = -1;
 		state->events[i].events = 0;
 		state->events[i].revents = 0;
@@ -563,10 +568,8 @@
 /*
  * Open library
  */
-int luaopen_util_poll(lua_State *L) {
-#if (LUA_VERSION_NUM > 501)
+int luaopen_prosody_util_poll(lua_State *L) {
 	luaL_checkversion(L);
-#endif
 
 	luaL_newmetatable(L, STATE_MT);
 	{
@@ -619,3 +622,8 @@
 	return 1;
 }
 
+/* COMPAT */
+int luaopen_util_poll(lua_State *L) {
+	return luaopen_prosody_util_poll(L);
+}
+
--- a/util-src/pposix.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/pposix.c	Wed Mar 27 15:39:03 2024 +0000
@@ -58,9 +58,6 @@
 #include "lualib.h"
 #include "lauxlib.h"
 
-#if (LUA_VERSION_NUM == 501)
-#define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
-#endif
 #if (LUA_VERSION_NUM < 503)
 #define lua_isinteger(L, n) lua_isnumber(L, n)
 #endif
@@ -510,55 +507,43 @@
  *	Example usage:
  *	pposix.setrlimit("NOFILE", 1000, 2000)
  */
-static int string2resource(const char *s) {
-	if(!strcmp(s, "CORE")) {
-		return RLIMIT_CORE;
-	}
 
-	if(!strcmp(s, "CPU")) {
-		return RLIMIT_CPU;
-	}
-
-	if(!strcmp(s, "DATA")) {
-		return RLIMIT_DATA;
-	}
-
-	if(!strcmp(s, "FSIZE")) {
-		return RLIMIT_FSIZE;
-	}
-
-	if(!strcmp(s, "NOFILE")) {
-		return RLIMIT_NOFILE;
-	}
-
-	if(!strcmp(s, "STACK")) {
-		return RLIMIT_STACK;
-	}
-
+static const char *const resource_strings[] = {
+	/* Defined by POSIX */
+	"CORE",
+	"CPU",
+	"DATA",
+	"FSIZE",
+	"NOFILE",
+	"STACK",
 #if !(defined(sun) || defined(__sun) || defined(__APPLE__))
-
-	if(!strcmp(s, "MEMLOCK")) {
-		return RLIMIT_MEMLOCK;
-	}
-
-	if(!strcmp(s, "NPROC")) {
-		return RLIMIT_NPROC;
-	}
-
-	if(!strcmp(s, "RSS")) {
-		return RLIMIT_RSS;
-	}
-
+	"MEMLOCK",
+	"NPROC",
+	"RSS",
 #endif
 #ifdef RLIMIT_NICE
+	"NICE",
+#endif
+	NULL
+};
 
-	if(!strcmp(s, "NICE")) {
-		return RLIMIT_NICE;
-	}
-
+static int resource_constants[] =	{
+	RLIMIT_CORE,
+	RLIMIT_CPU,
+	RLIMIT_DATA,
+	RLIMIT_FSIZE,
+	RLIMIT_NOFILE,
+	RLIMIT_STACK,
+#if !(defined(sun) || defined(__sun) || defined(__APPLE__))
+	RLIMIT_MEMLOCK,
+	RLIMIT_NPROC,
+	RLIMIT_RSS,
 #endif
-	return -1;
-}
+#ifdef RLIMIT_NICE
+	RLIMIT_NICE,
+#endif
+	-1,
+};
 
 static rlim_t arg_to_rlimit(lua_State *L, int idx, rlim_t current) {
 	switch(lua_type(L, idx)) {
@@ -592,7 +577,7 @@
 		return 2;
 	}
 
-	rid = string2resource(luaL_checkstring(L, 1));
+	rid = resource_constants[luaL_checkoption(L, 1,NULL, resource_strings)];
 
 	if(rid == -1) {
 		lua_pushboolean(L, 0);
@@ -622,7 +607,6 @@
 
 static int lc_getrlimit(lua_State *L) {
 	int arguments = lua_gettop(L);
-	const char *resource = NULL;
 	int rid = -1;
 	struct rlimit lim;
 
@@ -632,8 +616,7 @@
 		return 2;
 	}
 
-	resource = luaL_checkstring(L, 1);
-	rid = string2resource(resource);
+	rid = resource_constants[luaL_checkoption(L, 1, NULL, resource_strings)];
 
 	if(rid != -1) {
 		if(getrlimit(rid, &lim)) {
@@ -819,6 +802,41 @@
 	return 3;
 }
 
+static int lc_remove_blocks(lua_State *L) {
+#if defined(__linux__)
+	int err;
+
+	FILE *f = *(FILE **) luaL_checkudata(L, 1, LUA_FILEHANDLE);
+	off_t offset = (off_t)luaL_checkinteger(L, 2);
+	off_t length = (off_t)luaL_checkinteger(L, 3);
+
+	errno = 0;
+
+	if((err = fallocate(fileno(f), FALLOC_FL_COLLAPSE_RANGE, offset, length))) {
+		if(errno != 0) {
+			/* Some old versions of Linux apparently use the return value instead of errno */
+			err = errno;
+		}
+
+		switch(err) {
+			default: /* Other issues */
+				luaL_pushfail(L);
+				lua_pushstring(L, strerror(err));
+				lua_pushinteger(L, err);
+				return 3;
+		}
+	}
+
+	lua_pushboolean(L, err == 0);
+	return 1;
+#else
+	luaL_pushfail(L);
+	lua_pushstring(L, strerror(EOPNOTSUPP));
+	lua_pushinteger(L, EOPNOTSUPP);
+	return 3;
+#endif
+}
+
 static int lc_isatty(lua_State *L) {
 	FILE *f = *(FILE **) luaL_checkudata(L, 1, LUA_FILEHANDLE);
 	const int fd = fileno(f);
@@ -828,10 +846,8 @@
 
 /* Register functions */
 
-int luaopen_util_pposix(lua_State *L) {
-#if (LUA_VERSION_NUM > 501)
+int luaopen_prosody_util_pposix(lua_State *L) {
 	luaL_checkversion(L);
-#endif
 	luaL_Reg exports[] = {
 		{ "abort", lc_abort },
 
@@ -866,6 +882,7 @@
 #endif
 
 		{ "atomic_append", lc_atomic_append },
+		{ "remove_blocks", lc_remove_blocks },
 
 		{ "isatty", lc_isatty },
 
@@ -888,3 +905,6 @@
 
 	return 1;
 }
+int luaopen_util_pposix(lua_State *L) {
+	return luaopen_prosody_util_pposix(L);
+}
--- a/util-src/ringbuffer.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/ringbuffer.c	Wed Mar 27 15:39:03 2024 +0000
@@ -313,10 +313,8 @@
 	return 1;
 }
 
-int luaopen_util_ringbuffer(lua_State *L) {
-#if (LUA_VERSION_NUM > 501)
+int luaopen_prosody_util_ringbuffer(lua_State *L) {
 	luaL_checkversion(L);
-#endif
 
 	if(luaL_newmetatable(L, "ringbuffer_mt")) {
 		lua_pushcfunction(L, rb_tostring);
@@ -355,3 +353,7 @@
 	lua_setfield(L, -2, "new");
 	return 1;
 }
+
+int luaopen_util_ringbuffer(lua_State *L) {
+	return luaopen_prosody_util_ringbuffer(L);
+}
--- a/util-src/signal.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/signal.c	Wed Mar 27 15:39:03 2024 +0000
@@ -32,13 +32,14 @@
 
 #include <signal.h>
 #include <stdlib.h>
+#ifdef __linux__
+#include <unistd.h>
+#include <sys/signalfd.h>
+#endif
 
 #include "lua.h"
 #include "lauxlib.h"
 
-#if (LUA_VERSION_NUM == 501)
-#define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
-#endif
 #if (LUA_VERSION_NUM < 503)
 #define lua_isinteger(L, n) lua_isnumber(L, n)
 #endif
@@ -371,20 +372,104 @@
 
 #endif
 
+#ifdef __linux__
+struct lsignalfd {
+	int fd;
+	sigset_t mask;
+};
+
+static int l_signalfd(lua_State *L) {
+	struct lsignalfd *sfd = lua_newuserdata(L, sizeof(struct lsignalfd));
+
+	sigemptyset(&sfd->mask);
+	sigaddset(&sfd->mask, luaL_checkinteger(L, 1));
+
+	if (sigprocmask(SIG_BLOCK, &sfd->mask, NULL) != 0) {
+		lua_pushnil(L);
+		return 1;
+	};
+
+	sfd->fd = signalfd(-1, &sfd->mask, SFD_NONBLOCK);
+
+	if(sfd->fd == -1) {
+		lua_pushnil(L);
+		return 1;
+	}
+
+	luaL_setmetatable(L, "signalfd");
+	return 1;
+}
+
+static int l_signalfd_getfd(lua_State *L) {
+	struct lsignalfd *sfd = luaL_checkudata(L, 1, "signalfd");
+
+	if (sfd->fd == -1) {
+		lua_pushnil(L);
+		return 1;
+	}
+
+	lua_pushinteger(L, sfd->fd);
+	return 1;
+}
+
+static int l_signalfd_read(lua_State *L) {
+	struct lsignalfd *sfd = luaL_checkudata(L, 1, "signalfd");
+	struct signalfd_siginfo siginfo;
+
+	if(read(sfd->fd, &siginfo, sizeof(siginfo)) < 0) {
+		return 0;
+	}
+
+	lua_pushinteger(L, siginfo.ssi_signo);
+	return 1;
+}
+
+static int l_signalfd_close(lua_State *L) {
+	struct lsignalfd *sfd = luaL_checkudata(L, 1, "signalfd");
+
+	if(close(sfd->fd) != 0) {
+		lua_pushboolean(L, 0);
+		return 1;
+	}
+
+	sfd->fd = -1;
+	lua_pushboolean(L, 1);
+	return 1;
+}
+#endif
+
 static const struct luaL_Reg lsignal_lib[] = {
 	{"signal", l_signal},
 	{"raise", l_raise},
 #if defined(__unix__) || defined(__APPLE__)
 	{"kill", l_kill},
 #endif
+#ifdef __linux__
+	{"signalfd", l_signalfd},
+#endif
 	{NULL, NULL}
 };
 
-int luaopen_util_signal(lua_State *L) {
-#if (LUA_VERSION_NUM > 501)
+int luaopen_prosody_util_signal(lua_State *L) {
 	luaL_checkversion(L);
+	int i = 0;
+
+#ifdef __linux__
+	luaL_newmetatable(L, "signalfd");
+	lua_pushcfunction(L, l_signalfd_close);
+	lua_setfield(L, -2, "__gc");
+	lua_createtable(L, 0, 1);
+	{
+		lua_pushcfunction(L, l_signalfd_getfd);
+		lua_setfield(L, -2, "getfd");
+		lua_pushcfunction(L, l_signalfd_read);
+		lua_setfield(L, -2, "read");
+		lua_pushcfunction(L, l_signalfd_close);
+		lua_setfield(L, -2, "close");
+	}
+	lua_setfield(L, -2, "__index");
+	lua_pop(L, 1);
 #endif
-	int i = 0;
 
 	/* add the library */
 	lua_newtable(L);
@@ -413,3 +498,6 @@
 
 	return 1;
 }
+int luaopen_util_signal(lua_State *L) {
+	return luaopen_prosody_util_signal(L);
+}
--- a/util-src/strbitop.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/strbitop.c	Wed Mar 27 15:39:03 2024 +0000
@@ -8,13 +8,12 @@
 #include <lua.h>
 #include <lauxlib.h>
 
-#if (LUA_VERSION_NUM == 501)
-#define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
-#endif
+#include <sys/param.h>
+#include <limits.h>
 
 /* TODO Deduplicate code somehow */
 
-int strop_and(lua_State *L) {
+static int strop_and(lua_State *L) {
 	luaL_Buffer buf;
 	size_t a, b, i;
 	const char *str_a = luaL_checklstring(L, 1, &a);
@@ -35,7 +34,7 @@
 	return 1;
 }
 
-int strop_or(lua_State *L) {
+static int strop_or(lua_State *L) {
 	luaL_Buffer buf;
 	size_t a, b, i;
 	const char *str_a = luaL_checklstring(L, 1, &a);
@@ -56,7 +55,7 @@
 	return 1;
 }
 
-int strop_xor(lua_State *L) {
+static int strop_xor(lua_State *L) {
 	luaL_Buffer buf;
 	size_t a, b, i;
 	const char *str_a = luaL_checklstring(L, 1, &a);
@@ -77,11 +76,46 @@
 	return 1;
 }
 
-LUA_API int luaopen_util_strbitop(lua_State *L) {
+unsigned int clz(unsigned char c) {
+#if __GNUC__
+	return __builtin_clz((unsigned int) c) - ((sizeof(int)-1)*CHAR_BIT);
+#else
+	if(c & 0x80) return 0;
+	if(c & 0x40) return 1;
+	if(c & 0x20) return 2;
+	if(c & 0x10) return 3;
+	if(c & 0x08) return 4;
+	if(c & 0x04) return 5;
+	if(c & 0x02) return 6;
+	if(c & 0x01) return 7;
+	return 8;
+#endif
+}
+
+LUA_API int strop_common_prefix_bits(lua_State *L) {
+	size_t a, b, i;
+	const char *str_a = luaL_checklstring(L, 1, &a);
+	const char *str_b = luaL_checklstring(L, 2, &b);
+
+	size_t min_len = MIN(a, b);
+
+	for(i=0; i<min_len; i++) {
+		if(str_a[i] != str_b[i]) {
+			lua_pushinteger(L, i*8 + (clz(str_a[i] ^ str_b[i])));
+			return 1;
+		}
+	}
+
+	lua_pushinteger(L, i*8);
+	return 1;
+}
+
+LUA_API int luaopen_prosody_util_strbitop(lua_State *L) {
 	luaL_Reg exports[] = {
 		{ "sand", strop_and },
 		{ "sor",  strop_or },
 		{ "sxor", strop_xor },
+		{ "common_prefix_bits", strop_common_prefix_bits },
 		{ NULL, NULL }
 	};
 
@@ -89,3 +123,7 @@
 	luaL_setfuncs(L, exports, 0);
 	return 1;
 }
+
+LUA_API int luaopen_util_strbitop(lua_State *L) {
+	return luaopen_prosody_util_strbitop(L);
+}
--- a/util-src/struct.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/struct.c	Wed Mar 27 15:39:03 2024 +0000
@@ -36,12 +36,6 @@
 #include "lauxlib.h"
 
 
-#if (LUA_VERSION_NUM >= 502)
-
-#define luaL_register(L,n,f)	luaL_newlib(L,f)
-
-#endif
-
 
 /* basic integer type */
 #if !defined(STRUCT_INT)
@@ -140,7 +134,7 @@
 
 
 /*
-** options to control endianess and alignment
+** options to control endianness and alignment
 */
 static void controloptions (lua_State *L, int opt, const char **fmt,
                             Header *h) {
@@ -391,9 +385,13 @@
 
 LUALIB_API int luaopen_util_struct (lua_State *L);
 
+LUALIB_API int luaopen_prosody_util_struct (lua_State *L) {
+  luaL_newlib(L, thislib);
+  return 1;
+}
+
 LUALIB_API int luaopen_util_struct (lua_State *L) {
-  luaL_register(L, "struct", thislib);
-  return 1;
+	return luaopen_prosody_util_struct(L);
 }
 
 
--- a/util-src/table.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/table.c	Wed Mar 27 15:39:03 2024 +0000
@@ -1,11 +1,17 @@
 #include <lua.h>
 #include <lauxlib.h>
 
+#ifndef LUA_MAXINTEGER
+#include <stdint.h>
+#define LUA_MAXINTEGER PTRDIFF_MAX
+#endif
+
 static int Lcreate_table(lua_State *L) {
 	lua_createtable(L, luaL_checkinteger(L, 1), luaL_checkinteger(L, 2));
 	return 1;
 }
 
+/* COMPAT: w/ Lua pre-5.2 */
 static int Lpack(lua_State *L) {
 	unsigned int n_args = lua_gettop(L);
 	lua_createtable(L, n_args, 1);
@@ -20,14 +26,52 @@
 	return 1;
 }
 
-int luaopen_util_table(lua_State *L) {
-#if (LUA_VERSION_NUM > 501)
+/* COMPAT: w/ Lua pre-5.4 */
+static int Lmove (lua_State *L) {
+	lua_Integer f = luaL_checkinteger(L, 2);
+	lua_Integer e = luaL_checkinteger(L, 3);
+	lua_Integer t = luaL_checkinteger(L, 4);
+
+	int tt = !lua_isnoneornil(L, 5) ? 5 : 1;  /* destination table */
+	luaL_checktype(L, 1, LUA_TTABLE);
+	luaL_checktype(L, tt, LUA_TTABLE);
+
+	if (e >= f) {  /* otherwise, nothing to move */
+		lua_Integer n, i;
+		luaL_argcheck(L, f > 0 || e < LUA_MAXINTEGER + f, 3,
+		  "too many elements to move");
+		n = e - f + 1;  /* number of elements to move */
+		luaL_argcheck(L, t <= LUA_MAXINTEGER - n + 1, 4,
+		"destination wrap around");
+		if (t > e || t <= f || (tt != 1 && !lua_compare(L, 1, tt, LUA_OPEQ))) {
+			for (i = 0; i < n; i++) {
+				lua_rawgeti(L, 1, f + i);
+				lua_rawseti(L, tt, t + i);
+			}
+		} else {
+			for (i = n - 1; i >= 0; i--) {
+				lua_rawgeti(L, 1, f + i);
+				lua_rawseti(L, tt, t + i);
+			}
+		}
+	}
+
+	lua_pushvalue(L, tt);  /* return destination table */
+	return 1;
+}
+
+int luaopen_prosody_util_table(lua_State *L) {
 	luaL_checkversion(L);
-#endif
 	lua_createtable(L, 0, 2);
 	lua_pushcfunction(L, Lcreate_table);
 	lua_setfield(L, -2, "create");
 	lua_pushcfunction(L, Lpack);
 	lua_setfield(L, -2, "pack");
+	lua_pushcfunction(L, Lmove);
+	lua_setfield(L, -2, "move");
 	return 1;
 }
+
+int luaopen_util_table(lua_State *L) {
+	return luaopen_prosody_util_table(L);
+}
--- a/util-src/time.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/time.c	Wed Mar 27 15:39:03 2024 +0000
@@ -23,7 +23,7 @@
 	return 1;
 }
 
-int luaopen_util_time(lua_State *L) {
+int luaopen_prosody_util_time(lua_State *L) {
 	lua_createtable(L, 0, 2);
 	{
 		lua_pushcfunction(L, lc_time_realtime);
@@ -33,3 +33,6 @@
 	}
 	return 1;
 }
+int luaopen_util_time(lua_State *L) {
+	return luaopen_prosody_util_time(L);
+}
--- a/util-src/windows.c	Wed Mar 27 15:35:15 2024 +0000
+++ b/util-src/windows.c	Wed Mar 27 15:39:03 2024 +0000
@@ -19,9 +19,6 @@
 #include "lua.h"
 #include "lauxlib.h"
 
-#if (LUA_VERSION_NUM == 501)
-#define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
-#endif
 #if (LUA_VERSION_NUM < 504)
 #define luaL_pushfail lua_pushnil
 #endif
@@ -106,9 +103,7 @@
 };
 
 LUALIB_API int luaopen_util_windows(lua_State *L) {
-#if (LUA_VERSION_NUM > 501)
 	luaL_checkversion(L);
-#endif
 	lua_newtable(L);
 	luaL_setfuncs(L, Reg, 0);
 	lua_pushliteral(L, "-3.14");
--- a/util/adminstream.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/adminstream.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,15 +1,15 @@
-local st = require "util.stanza";
-local new_xmpp_stream = require "util.xmppstream".new;
-local sessionlib = require "util.session";
-local gettime = require "util.time".now;
-local runner = require "util.async".runner;
-local add_task = require "util.timer".add_task;
-local events = require "util.events";
-local server = require "net.server";
+local st = require "prosody.util.stanza";
+local new_xmpp_stream = require "prosody.util.xmppstream".new;
+local sessionlib = require "prosody.util.session";
+local gettime = require "prosody.util.time".now;
+local runner = require "prosody.util.async".runner;
+local add_task = require "prosody.util.timer".add_task;
+local events = require "prosody.util.events";
+local server = require "prosody.net.server";
 
 local stream_close_timeout = 5;
 
-local log = require "util.logger".init("adminstream");
+local log = require "prosody.util.logger".init("adminstream");
 
 local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams";
 
@@ -145,7 +145,7 @@
 		-- constructor was exported instead of a module table. Due to the lack of a
 		-- proper release of LuaSocket, distros have settled on shipping either the
 		-- last RC tag or some commit since then.
-		-- Here we accomodate both variants.
+		-- Here we accommodate both variants.
 		unix = { stream = unix };
 	end
 	if type(unix) ~= "table" then
--- a/util/argparse.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/argparse.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,7 @@
 local function parse(arg, config)
 	local short_params = config and config.short_params or {};
 	local value_params = config and config.value_params or {};
+	local array_params = config and config.array_params or {};
 
 	local parsed_opts = {};
 
@@ -30,7 +31,7 @@
 		end
 
 		local param_k, param_v;
-		if value_params[param] then
+		if value_params[param] or array_params[param] then
 			param_k, param_v = param, table.remove(arg, 1);
 			if not param_v then
 				return nil, "missing-value", raw_param;
@@ -44,8 +45,17 @@
 					param_k, param_v = param, true;
 				end
 			end
+			param_k = param_k:gsub("%-", "_");
 		end
-		parsed_opts[param_k] = param_v;
+		if array_params[param] then
+			if parsed_opts[param_k] then
+				table.insert(parsed_opts[param_k], param_v);
+			else
+				parsed_opts[param_k] = { param_v };
+			end
+		else
+			parsed_opts[param_k] = param_v;
+		end
 	end
 	for i = 1, #arg do
 		parsed_opts[i] = arg[i];
--- a/util/array.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/array.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,6 +8,7 @@
 
 local t_insert, t_sort, t_remove, t_concat
     = table.insert, table.sort, table.remove, table.concat;
+local t_move = require "prosody.util.table".move;
 
 local setmetatable = setmetatable;
 local getmetatable = getmetatable;
@@ -23,7 +24,7 @@
 local array_mt = {
 	__index = array_methods;
 	__name = "array";
-	__tostring = function (self) return "{"..self:concat(", ").."}"; end;
+	__tostring = function (self) return "["..self:concat(", ").."]"; end;
 };
 
 function array_mt:__freeze() return self; end
@@ -141,13 +142,11 @@
 		return outa;
 	end
 
-	for idx = 1, 1+j-i do
-		outa[idx] = ina[i+(idx-1)];
-	end
+
+	t_move(ina, i, j, 1, outa);
 	if ina == outa then
-		for idx = 2+j-i, #outa do
-			outa[idx] = nil;
-		end
+		-- Clear (nil) remainder of range
+		t_move(ina, #outa+1, #outa*2, 2+j-i, ina);
 	end
 	return outa;
 end
@@ -213,10 +212,7 @@
 end
 
 function array_methods:append(ina)
-	local len, len2 = #self, #ina;
-	for i = 1, len2 do
-		self[len+i] = ina[i];
-	end
+	t_move(ina, 1, #ina, #self+1, self);
 	return self;
 end
 
--- a/util/async.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/async.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,7 +1,8 @@
-local logger = require "util.logger";
+local logger = require "prosody.util.logger";
 local log = logger.init("util.async");
-local new_id = require "util.id".short;
-local xpcall = require "util.xpcall".xpcall;
+local new_id = require "prosody.util.id".short;
+local xpcall = require "prosody.util.xpcall".xpcall;
+local time_now = require "prosody.util.time".now;
 
 local function checkthread()
 	local thread, main = coroutine.running();
@@ -45,7 +46,9 @@
 local function runner_continue(thread)
 	-- ASSUMPTION: runner is in 'waiting' state (but we don't have the runner to know for sure)
 	if coroutine.status(thread) ~= "suspended" then -- This should suffice
-		log("error", "unexpected async state: thread not suspended");
+		log("error", "unexpected async state: thread not suspended (%s, %s)", thread, coroutine.status(thread));
+		-- Fetching the traceback is likely to *crash* if a C library is calling us while suspended
+		--log("error", "coroutine stack: %s", debug.traceback());
 		return false;
 	end
 	local ok, state, runner = coroutine.resume(thread);
@@ -138,6 +141,8 @@
 local runner_mt = {};
 runner_mt.__index = runner_mt;
 
+local waiting_runners = {};
+
 local function runner_create_thread(func, self)
 	local thread = coroutine.create(function (self) -- luacheck: ignore 432/self
 		while true do
@@ -195,6 +200,8 @@
 		-- Loop through queue items, and attempt to run them
 		for i = 1,n do
 			local queued_input = q[i];
+			self:log("Resuming thread with new item [%s]", thread);
+			self.current_item = queued_input;
 			local ok, new_state = coroutine.resume(thread, queued_input);
 			if not ok then
 				-- There was an error running the coroutine, save the error, mark runner as ready to begin again
@@ -221,8 +228,13 @@
 	end
 	-- Runner processed all items it can, so save current runner state
 	self.state = state;
+	if state == "ready" and self.current_item then
+		self.current_item = nil;
+	end
+
 	if err or state ~= self.notified_state then
-		self:log("debug", "changed state from %s to %s", self.notified_state, err and ("error ("..state..")") or state);
+		self:log("debug", "changed state from %s to %s [%s %s]", self.notified_state, err and ("error (" .. state .. ")") or state, self.thread,
+			self.thread and coroutine.status(self.thread));
 		if err then
 			state = "error"
 		else
@@ -234,6 +246,7 @@
 	if n > 0 then
 		return self:run();
 	end
+	waiting_runners[self] = state == "waiting" and time_now() or nil;
 	return true, state, n;
 end
 
@@ -293,4 +306,7 @@
 
 	set_nexttick = function(new_next_tick) next_tick = new_next_tick; end;
 	set_schedule_function = function (new_schedule_function) schedule_task = new_schedule_function; end;
+
+	waiting_runners = waiting_runners;
+	default_runner_func = default_func;
 };
--- a/util/bit53.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/bit53.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -27,6 +27,9 @@
 		end
 		return ret;
 	end;
+	bnot   = function (x)
+		return ~x;
+	end;
 	rshift = function (a, n) return a >> n end;
 	lshift = function (a, n) return a << n end;
 };
--- a/util/bitcompat.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/bitcompat.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -5,25 +5,11 @@
 -- Lua 5.2 has it by default
 if _G.bit32 then
 	return _G.bit32;
-else
-	-- Lua 5.1 may have it as a standalone module that can be installed
-	local ok, bitop = pcall(require, "bit32")
-	if ok then
-		return bitop;
-	end
 end
 
 do
 	-- Lua 5.3 and 5.4 would be able to use native infix operators
-	local ok, bitop = pcall(require, "util.bit53")
-	if ok then
-		return bitop;
-	end
-end
-
-do
-	-- Lastly, try the LuaJIT bitop library
-	local ok, bitop = pcall(require, "bit")
+	local ok, bitop = pcall(require, "prosody.util.bit53")
 	if ok then
 		return bitop;
 	end
--- a/util/cache.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/cache.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -54,12 +54,17 @@
 	if self._count == self.size then
 		local tail = self._tail;
 		local on_evict, evicted_key, evicted_value = self._on_evict, tail.key, tail.value;
-		if on_evict ~= nil and (on_evict == false or on_evict(evicted_key, evicted_value) == false) then
+
+		local do_evict = on_evict and on_evict(evicted_key, evicted_value, self);
+
+		if do_evict == false then
 			-- Cache is full, and we're not allowed to evict
 			return false;
+		elseif self._count == self.size then
+			-- Cache wasn't grown
+			_remove(self, tail);
+			self._data[evicted_key] = nil;
 		end
-		_remove(self, tail);
-		self._data[evicted_key] = nil;
 	end
 
 	m = { key = k, value = v, prev = nil, next = nil };
@@ -124,7 +129,7 @@
 	while self._count > new_size do
 		local tail = self._tail;
 		local evicted_key, evicted_value = tail.key, tail.value;
-		if on_evict ~= nil and (on_evict == false or on_evict(evicted_key, evicted_value) == false) then
+		if on_evict ~= nil and (on_evict == false or on_evict(evicted_key, evicted_value, self) == false) then
 			-- Cache is full, and we're not allowed to evict
 			return false;
 		end
--- a/util/caps.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/caps.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,8 +6,8 @@
 -- COPYING file in the source package for more information.
 --
 
-local base64 = require "util.encodings".base64.encode;
-local sha1 = require "util.hashes".sha1;
+local base64 = require "prosody.util.encodings".base64.encode;
+local sha1 = require "prosody.util.hashes".sha1;
 
 local t_insert, t_sort, t_concat = table.insert, table.sort, table.concat;
 local ipairs = ipairs;
--- a/util/dataforms.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/dataforms.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -12,9 +12,9 @@
 local tonumber = tonumber;
 local tostring = tostring;
 local t_concat = table.concat;
-local st = require "util.stanza";
-local jid_prep = require "util.jid".prep;
-local datetime = require "util.datetime";
+local st = require "prosody.util.stanza";
+local jid_prep = require "prosody.util.jid".prep;
+local datetime = require "prosody.util.datetime";
 
 local _ENV = nil;
 -- luacheck: std none
--- a/util/datamanager.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/datamanager.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,35 +7,42 @@
 --
 
 
+local string = string;
 local format = string.format;
 local setmetatable = setmetatable;
 local ipairs = ipairs;
 local char = string.char;
 local pcall = pcall;
-local log = require "util.logger".init("datamanager");
+local log = require "prosody.util.logger".init("datamanager");
 local io_open = io.open;
 local os_remove = os.remove;
 local os_rename = os.rename;
 local tonumber = tonumber;
+local floor = math.floor;
 local next = next;
 local type = type;
 local t_insert = table.insert;
 local t_concat = table.concat;
-local envloadfile = require"util.envload".envloadfile;
-local serialize = require "util.serialization".serialize;
+local envloadfile = require"prosody.util.envload".envloadfile;
+local envload = require"prosody.util.envload".envload;
+local serialize = require "prosody.util.serialization".serialize;
 local lfs = require "lfs";
 -- Extract directory separator from package.config (an undocumented string that comes with lua)
 local path_separator = assert ( package.config:match ( "^([^\n]+)" ) , "package.config not in standard form" )
 
 local prosody = prosody;
 
+--luacheck: ignore 211/blocksize 211/remove_blocks
+local blocksize = 0x1000;
 local raw_mkdir = lfs.mkdir;
 local atomic_append;
+local remove_blocks;
 local ENOENT = 2;
 pcall(function()
-	local pposix = require "util.pposix";
+	local pposix = require "prosody.util.pposix";
 	raw_mkdir = pposix.mkdir or raw_mkdir; -- Doesn't trample on umask
 	atomic_append = pposix.atomic_append;
+	-- remove_blocks = pposix.remove_blocks;
 	ENOENT = pposix.ENOENT or ENOENT;
 end);
 
@@ -239,6 +246,14 @@
 	end
 
 	local pos = f:seek("end");
+	--[[ TODO needs tests
+	if (blocksize-(pos%blocksize)) < (#data%blocksize) then
+		-- pad to blocksize with newlines so that the next item is both on a new
+		-- block and a new line
+		atomic_append(f, ("\n"):rep(blocksize-(pos%blocksize)));
+		pos = f:seek("end");
+	end
+	--]]
 
 	local ok, msg = atomic_append(f, data);
 
@@ -255,6 +270,13 @@
 	return true, pos;
 end
 
+local index_fmt, index_item_size, index_magic;
+if string.packsize then
+	index_fmt = "T"; -- offset to the end of the item, length can be derived from two index items
+	index_item_size = string.packsize(index_fmt);
+	index_magic = string.pack(index_fmt, 7767639 + 1); -- Magic string: T9 for "prosody", version number
+end
+
 local function list_append(username, host, datastore, data)
 	if not data then return; end
 	if callback(username, host, datastore) == false then return true; end
@@ -267,6 +289,22 @@
 			datastore, msg, where, username or "nil", host or "nil");
 		return ok, msg;
 	end
+	if string.packsize then
+		local offset = type(msg) == "number" and msg or 0;
+		local index_entry = string.pack(index_fmt, offset + #data);
+		if offset == 0 then
+			index_entry = index_magic .. index_entry;
+		end
+		local ok, off = append(username, host, datastore, "lidx", index_entry);
+		off = off or 0;
+		-- If this was the first item, then both the data and index offsets should
+		-- be zero, otherwise there's some kind of mismatch and we should drop the
+		-- index and recreate it from scratch
+		-- TODO Actually rebuild the index in this case?
+		if not ok or (off == 0 and offset ~= 0) or (off ~= 0 and offset == 0) then
+			os_remove(getpath(username, host, datastore, "lidx"));
+		end
+	end
 	return true;
 end
 
@@ -280,6 +318,7 @@
 	for i, item in ipairs(data) do
 		d[i] = "item(" .. serialize(item) .. ");\n";
 	end
+	os_remove(getpath(username, host, datastore, "lidx"));
 	local ok, msg = atomic_store(getpath(username, host, datastore, "list", true), t_concat(d));
 	if not ok then
 		log("error", "Unable to write to %s storage ('%s') for user: %s@%s", datastore, msg, username or "nil", host or "nil");
@@ -294,6 +333,160 @@
 	return true;
 end
 
+local function build_list_index(username, host, datastore, items)
+	log("debug", "Building index for (%s@%s/%s)", username, host, datastore);
+	local filename = getpath(username, host, datastore, "list");
+	local fh, err, errno = io_open(filename);
+	if not fh then
+		return fh, err, errno;
+	end
+	local prev_pos = 0; -- position before reading
+	local last_item_start = nil;
+
+	if items and items[1] then
+		local last_item = items[#items];
+		last_item_start = fh:seek("set", last_item.start + last_item.length);
+	else
+		items = {};
+	end
+
+	for line in fh:lines() do
+		if line:sub(1, 4) == "item" then
+			if prev_pos ~= 0 and last_item_start then
+				t_insert(items, { start = last_item_start; length = prev_pos - last_item_start });
+			end
+			last_item_start = prev_pos
+		end
+		-- seek position is at the start of the next line within each loop iteration
+		-- so we need to collect the "current" position at the end of the previous
+		prev_pos = fh:seek()
+	end
+	fh:close();
+	if prev_pos ~= 0 then
+		t_insert(items, { start = last_item_start; length = prev_pos - last_item_start });
+	end
+	return items;
+end
+
+local function store_list_index(username, host, datastore, index)
+	local data = { index_magic };
+	for i, v in ipairs(index) do
+		data[i + 1] = string.pack(index_fmt, v.start + v.length);
+	end
+	local filename = getpath(username, host, datastore, "lidx");
+	return atomic_store(filename, t_concat(data));
+end
+
+local index_mt = {
+	__index = function(t, i)
+		if type(i) ~= "number" or i % 1 ~= 0 or i < 0 then
+			return
+		end
+		if i <= 0 then
+			return 0
+		end
+		local fh = t.file;
+		local pos = (i - 1) * index_item_size;
+		if fh:seek("set", pos) ~= pos then
+			return nil
+		end
+		local data = fh:read(index_item_size * 2);
+		if not data or #data ~= index_item_size * 2 then
+			return nil
+		end
+		local start, next_pos = string.unpack(index_fmt .. index_fmt, data);
+		if pos == 0 then
+			start = 0
+		end
+		local length = next_pos - start;
+		local v = { start = start; length = length };
+		t[i] = v;
+		return v;
+	end;
+	__len = function(t)
+		-- Account for both the header and the fence post error
+		return floor(t.file:seek("end") / index_item_size) - 1;
+	end;
+}
+
+local function get_list_index(username, host, datastore)
+	log("debug", "Loading index for (%s@%s/%s)", username, host, datastore);
+	local index_filename = getpath(username, host, datastore, "lidx");
+	local ih = io_open(index_filename);
+	if ih then
+		local magic = ih:read(#index_magic);
+		if magic ~= index_magic then
+			log("debug", "Index %q has wrong version number (got %q, expected %q), rebuilding...", index_filename, magic, index_magic);
+			-- wrong version or something
+			ih:close();
+			ih = nil;
+		end
+	end
+
+	if ih then
+		local first_length = string.unpack(index_fmt, ih:read(index_item_size));
+		return setmetatable({ file = ih; { start = 0; length = first_length } }, index_mt);
+	end
+
+	local index, err = build_list_index(username, host, datastore);
+	if not index then
+		return index, err
+	end
+
+	-- TODO How to handle failure to store the index?
+	local dontcare = store_list_index(username, host, datastore, index); -- luacheck: ignore 211/dontcare
+	return index;
+end
+
+local function list_load_one(fh, start, length)
+	if fh:seek("set", start) ~= start then
+		return nil
+	end
+	local raw_data = fh:read(length)
+	if not raw_data or #raw_data ~= length then
+		return
+	end
+	local item;
+	local data, err, errno = envload(raw_data, "@list", {
+		item = function(i)
+			item = i;
+		end;
+	});
+	if not data then
+		return data, err, errno
+	end
+	local success, ret = pcall(data);
+	if not success then
+		return success, ret;
+	end
+	return item;
+end
+
+local function list_close(list)
+	if list.index and list.index.file then
+		list.index.file:close();
+	end
+	return list.file:close();
+end
+
+local indexed_list_mt = {
+	__index = function(t, i)
+		if type(i) ~= "number" or i % 1 ~= 0 or i < 1 then
+			return
+		end
+		local ix = t.index[i];
+		if not ix then
+			return
+		end
+		local item = list_load_one(t.file, ix.start, ix.length);
+		return item;
+	end;
+	__len = function(t)
+		return #t.index;
+	end;
+	__close = list_close;
+}
+
 local function list_load(username, host, datastore)
 	local items = {};
 	local data, err, errno = envloadfile(getpath(username, host, datastore, "list"), {item = function(i) t_insert(items, i); end});
@@ -314,6 +507,123 @@
 	return items;
 end
 
+local function list_open(username, host, datastore)
+	if not index_magic then
+		log("debug", "Falling back from lazy loading to to loading full list for %s storage for user: %s@%s", datastore, username or "nil", host or "nil");
+		return list_load(username, host, datastore);
+	end
+	local filename = getpath(username, host, datastore, "list");
+	local file, err, errno = io_open(filename);
+	if not file then
+		if errno == ENOENT then
+			return nil;
+		end
+		return file, err, errno;
+	end
+	local index, err = get_list_index(username, host, datastore);
+	if not index then
+		file:close()
+		return index, err;
+	end
+	return setmetatable({ file = file; index = index; close = list_close }, indexed_list_mt);
+end
+
+local function shift_index(index_filename, index, trim_to, offset) -- luacheck: ignore 212
+	os_remove(index_filename);
+	return "deleted";
+	-- TODO move and recalculate remaining items
+end
+
+local function list_shift(username, host, datastore, trim_to)
+	if trim_to == 1 then
+		return true
+	end
+	if type(trim_to) ~= "number" or trim_to < 1 then
+		return nil, "invalid-argument";
+	end
+	local list_filename = getpath(username, host, datastore, "list");
+	local index_filename = getpath(username, host, datastore, "lidx");
+	local index, err = get_list_index(username, host, datastore);
+	if not index then
+		return nil, err;
+	end
+
+	local new_first = index[trim_to];
+	if not new_first then
+		os_remove(index_filename);
+		return os_remove(list_filename);
+	end
+
+	local offset = new_first.start;
+	if offset == 0 then
+		return true;
+	end
+
+	--[[
+	if remove_blocks then
+		local f, err = io_open(list_filename, "r+");
+		if not f then
+			return f, err;
+		end
+
+		local diff = 0;
+		local block_offset = 0;
+		if offset % 0x1000 ~= 0 then
+			-- Not an even block boundary, we will have to overwrite
+			diff = offset % 0x1000;
+			block_offset = offset - diff;
+		end
+
+		if block_offset == 0 then
+			log("debug", "")
+		else
+			local ok, err = remove_blocks(f, 0, block_offset);
+			log("debug", "remove_blocks(%s, 0, %d)", f, block_offset);
+			if not ok then
+				log("warn", "Could not remove blocks from %q[%d, %d]: %s", list_filename, 0, block_offset, err);
+			else
+				if diff ~= 0 then
+					-- overwrite unaligned leftovers
+					if f:seek("set", 0) then
+						local wrote, err = f:write(string.rep("\n", diff));
+						if not wrote then
+							log("error", "Could not blank out %q[%d, %d]: %s", list_filename, 0, diff, err);
+						end
+					end
+				end
+				local ok, err = f:close();
+				shift_index(index_filename, index, trim_to, offset); -- Shift or delete the index
+				return ok, err;
+			end
+		end
+	end
+	--]]
+
+	local r, err = io_open(list_filename, "r");
+	if not r then
+		return nil, err;
+	end
+	local w, err = io_open(list_filename .. "~", "w");
+	if not w then
+		return nil, err;
+	end
+	r:seek("set", offset);
+	for block in r:lines(0x1000) do
+		local ok, err = w:write(block);
+		if not ok then
+			return nil, err;
+		end
+	end
+	r:close();
+	local ok, err = w:close();
+	if not ok then
+		return nil, err;
+	end
+	shift_index(index_filename, index, trim_to, offset)
+	return os_rename(list_filename .. "~", list_filename);
+end
+
+
 local type_map = {
 	keyval = "dat";
 	list = "list";
@@ -392,6 +702,8 @@
 
 			local ok, err = do_remove(getpath(username, host, store_name, "list"));
 			if not ok then errs[#errs+1] = err; end
+			local ok, err = do_remove(getpath(username, host, store_name, "lidx"));
+			if not ok then errs[#errs+1] = err; end
 		end
 	end
 	return #errs == 0, t_concat(errs, ", ");
@@ -414,4 +726,8 @@
 	purge = purge;
 	path_decode = decode;
 	path_encode = encode;
+
+	build_list_index = build_list_index;
+	list_open = list_open;
+	list_shift = list_shift;
 };
--- a/util/datamapper.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/datamapper.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,7 +1,11 @@
 -- This file is generated from teal-src/util/datamapper.lua
 
-local st = require("util.stanza");
-local pointer = require("util.jsonpointer");
+if not math.type then
+	require("prosody.util.mathcompat")
+end
+
+local st = require("prosody.util.stanza");
+local pointer = require("prosody.util.jsonpointer");
 
 local schema_t = {}
 
--- a/util/datetime.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/datetime.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -12,31 +12,41 @@
 local os_date = os.date;
 local os_time = os.time;
 local os_difftime = os.difftime;
+local floor = math.floor;
 local tonumber = tonumber;
 
 local _ENV = nil;
 -- luacheck: std none
 
 local function date(t)
-	return os_date("!%Y-%m-%d", t);
+	return os_date("!%Y-%m-%d", t and floor(t) or nil);
 end
 
 local function datetime(t)
-	return os_date("!%Y-%m-%dT%H:%M:%SZ", t);
+	if t == nil or t % 1 == 0 then
+		return os_date("!%Y-%m-%dT%H:%M:%SZ", t);
+	end
+	local m = t % 1;
+	local s = floor(t);
+	return os_date("!%Y-%m-%dT%H:%M:%S.%%06dZ", s):format(floor(m * 1000000));
 end
 
 local function time(t)
-	return os_date("!%H:%M:%S", t);
+	if t == nil or t % 1 == 0 then
+		return os_date("!%H:%M:%S", t);
+	end
+	local m = t % 1;
+	local s = floor(t);
+	return os_date("!%H:%M:%S.%%06d", s):format(floor(m * 1000000));
 end
 
 local function legacy(t)
-	return os_date("!%Y%m%dT%H:%M:%S", t);
+	return os_date("!%Y%m%dT%H:%M:%S", t and floor(t) or nil);
 end
 
 local function parse(s)
 	if s then
-		local year, month, day, hour, min, sec, tzd;
-		year, month, day, hour, min, sec, tzd = s:match("^(%d%d%d%d)%-?(%d%d)%-?(%d%d)T(%d%d):(%d%d):(%d%d)%.?%d*([Z+%-]?.*)$");
+		local year, month, day, hour, min, sec, tzd = s:match("^(%d%d%d%d)%-?(%d%d)%-?(%d%d)T(%d%d):(%d%d):(%d%d%.?%d*)([Z+%-]?.*)$");
 		if year then
 			local now = os_time();
 			local time_offset = os_difftime(os_time(os_date("*t", now)), os_time(os_date("!*t", now))); -- to deal with local timezone
@@ -49,8 +59,9 @@
 				tzd_offset = h * 60 * 60 + m * 60;
 				if sign == "-" then tzd_offset = -tzd_offset; end
 			end
-			sec = (sec + time_offset) - tzd_offset;
-			return os_time({year=year, month=month, day=day, hour=hour, min=min, sec=sec, isdst=false});
+			local prec = sec%1;
+			sec = floor(sec + time_offset) - tzd_offset;
+			return os_time({year=year, month=month, day=day, hour=hour, min=min, sec=sec, isdst=false})+prec;
 		end
 	end
 end
--- a/util/dbuffer.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/dbuffer.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,4 +1,4 @@
-local queue = require "util.queue";
+local queue = require "prosody.util.queue";
 
 local s_byte, s_sub = string.byte, string.sub;
 local dbuffer_methods = {};
@@ -91,18 +91,18 @@
 end
 
 function dbuffer_methods:discard(requested_bytes)
-	if requested_bytes > self._length then
-		return nil;
+	if self._length == 0 then return true; end
+	if not requested_bytes or requested_bytes >= self._length then
+		self.front_consumed = 0;
+		self._length = 0;
+		for _ in self.items:consume() do end
+		return true;
 	end
 
 	local chunk, read_bytes = self:read_chunk(requested_bytes);
-	if chunk then
-		requested_bytes = requested_bytes - read_bytes;
-		if requested_bytes == 0 then -- Already read everything we need
-			return true;
-		end
-	else
-		return nil;
+	requested_bytes = requested_bytes - read_bytes;
+	if requested_bytes == 0 then -- Already read everything we need
+		return true;
 	end
 
 	while chunk do
--- a/util/debug.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/debug.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -12,7 +12,7 @@
 };
 local optimal_line_length = 65;
 
-local termcolours = require "util.termcolours";
+local termcolours = require "prosody.util.termcolours";
 local getstring = termcolours.getstring;
 local styles;
 do
--- a/util/dependencies.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/dependencies.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,7 +7,6 @@
 --
 
 local function softreq(...) local ok, lib =  pcall(require, ...); if ok then return lib; else return nil, lib; end end
-local platform_table = require "util.human.io".table({ { width = 15, align = "right" }, { width = "100%" } });
 
 -- Required to be able to find packages installed with luarocks
 if not softreq "luarocks.loader" then -- LuaRocks 2.x
@@ -22,7 +21,7 @@
 	print("This package can be obtained in the following ways:");
 	print("");
 	for _, row in ipairs(sources) do
-		print(platform_table(row));
+		print(string.format("%15s | %s", table.unpack(row)));
 	end
 	print("");
 	print(msg or (name.." is required for Prosody to run, so we will now exit."));
@@ -32,10 +31,10 @@
 end
 
 local function check_dependencies()
-	if _VERSION < "Lua 5.1" then
+	if _VERSION < "Lua 5.2" then
 		print "***********************************"
 		print("Unsupported Lua version: ".._VERSION);
-		print("At least Lua 5.1 is required.");
+		print("At least Lua 5.2 is required.");
 		print "***********************************"
 		return false;
 	end
@@ -88,7 +87,7 @@
 			}, nil, err);
 	end
 
-	local bit, err = softreq"util.bitcompat";
+	local bit, err = softreq"prosody.util.bitcompat";
 
 	if not bit then
 		missingdep("lua-bitops", {
@@ -106,16 +105,16 @@
 				{ "Source", "https://www.zash.se/luaunbound.html" };
 			}, "Old DNS resolver library will be used", err);
 	else
-		package.preload["net.adns"] = function ()
-			local ub = require "net.unbound";
+		package.preload["prosody.net.adns"] = function ()
+			local ub = require "prosody.net.unbound";
 			return ub;
 		end
 	end
 
-	local encodings, err = softreq "util.encodings"
+	local encodings, err = softreq "prosody.util.encodings"
 	if not encodings then
 		if err:match("module '[^']*' not found") then
-			missingdep("util.encodings", {
+			missingdep("prosody.util.encodings", {
 				{ "Windows", "Make sure you have encodings.dll from the Prosody distribution in util/" };
 				{ "GNU/Linux", "Run './configure' and 'make' in the Prosody source directory to build util/encodings.so" };
 			});
@@ -130,10 +129,10 @@
 		fatal = true;
 	end
 
-	local hashes, err = softreq "util.hashes"
+	local hashes, err = softreq "prosody.util.hashes"
 	if not hashes then
 		if err:match("module '[^']*' not found") then
-			missingdep("util.hashes", {
+			missingdep("prosody.util.hashes", {
 				{ "Windows", "Make sure you have hashes.dll from the Prosody distribution in util/" };
 				{ "GNU/Linux", "Run './configure' and 'make' in the Prosody source directory to build util/hashes.so" };
 			});
@@ -155,7 +154,7 @@
 	if _VERSION > "Lua 5.4" then
 		prosody.log("warn", "Support for %s is experimental, please report any issues", _VERSION);
 	elseif _VERSION < "Lua 5.2" then
-		prosody.log("warn", "%s has several issues and support is being phased out, consider upgrading", _VERSION);
+		prosody.log("warn", "%s support is deprecated, upgrade as soon as possible", _VERSION);
 	end
 	local ssl = softreq"ssl";
 	if ssl then
--- a/util/dns.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/dns.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -12,9 +12,9 @@
 local s_format = string.format;
 local s_sub = string.sub;
 
-local iana_data = require "util.dnsregistry";
-local tohex = require "util.hex".encode;
-local inet_ntop = require "util.net".ntop;
+local iana_data = require "prosody.util.dnsregistry";
+local tohex = require "prosody.util.hex".encode;
+local inet_ntop = require "prosody.util.net".ntop;
 
 -- Simplified versions of Waqas DNS parsers
 -- Only the per RR parsers are needed and only feed a single RR
--- a/util/dnsregistry.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/dnsregistry.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,5 +1,5 @@
 -- Source: https://www.iana.org/assignments/dns-parameters/dns-parameters.xml
--- Generated on 2022-02-02
+-- Generated on 2023-01-20
 return {
 	classes = {
 		["IN"] = 1; [1] = "IN";
@@ -61,7 +61,6 @@
 		["NSEC3PARAM"] = 51; [51] = "NSEC3PARAM";
 		["TLSA"] = 52; [52] = "TLSA";
 		["SMIMEA"] = 53; [53] = "SMIMEA";
-		["Unassigned"] = 54; [54] = "Unassigned";
 		["HIP"] = 55; [55] = "HIP";
 		["NINFO"] = 56; [56] = "NINFO";
 		["RKEY"] = 57; [57] = "RKEY";
--- a/util/envload.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/envload.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,38 +6,19 @@
 --
 -- luacheck: ignore 113/setfenv 113/loadstring
 
-local load, loadstring, setfenv = load, loadstring, setfenv;
+local load = load;
 local io_open = io.open;
-local envload;
-local envloadfile;
 
-if setfenv then
-	function envload(code, source, env)
-		local f, err = loadstring(code, source);
-		if f and env then setfenv(f, env); end
-		return f, err;
-	end
+local function envload(code, source, env)
+	return load(code, source, nil, env);
+end
 
-	function envloadfile(file, env)
-		local fh, err, errno = io_open(file);
-		if not fh then return fh, err, errno; end
-		local f, err = load(function () return fh:read(2048); end, "@"..file);
-		fh:close();
-		if f and env then setfenv(f, env); end
-		return f, err;
-	end
-else
-	function envload(code, source, env)
-		return load(code, source, nil, env);
-	end
-
-	function envloadfile(file, env)
-		local fh, err, errno = io_open(file);
-		if not fh then return fh, err, errno; end
-		local f, err = load(fh:lines(2048), "@"..file, nil, env);
-		fh:close();
-		return f, err;
-	end
+local function envloadfile(file, env)
+	local fh, err, errno = io_open(file);
+	if not fh then return fh, err, errno; end
+	local f, err = load(fh:lines(2048), "@" .. file, nil, env);
+	fh:close();
+	return f, err;
 end
 
 return { envload = envload, envloadfile = envloadfile };
--- a/util/error.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/error.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,4 +1,4 @@
-local id = require "util.id";
+local id = require "prosody.util.id";
 
 local util_debug; -- only imported on-demand
 
@@ -19,7 +19,7 @@
 	if opt.auto_inject_traceback ~= nil then
 		auto_inject_traceback = opt.auto_inject_traceback;
 		if auto_inject_traceback then
-			util_debug = require "util.debug";
+			util_debug = require "prosody.util.debug";
 		end
 	end
 end
--- a/util/format.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/format.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,14 +6,20 @@
 -- Provides some protection from e.g. CAPEC-135, CWE-117, CWE-134, CWE-93
 
 local tostring = tostring;
-local unpack = table.unpack or unpack; -- luacheck: ignore 113/unpack
-local pack = require "util.table".pack; -- TODO table.pack in 5.2+
-local valid_utf8 = require "util.encodings".utf8.valid;
+local unpack = table.unpack;
+local pack = table.pack;
+local valid_utf8 = require "prosody.util.encodings".utf8.valid;
 local type = type;
-local dump = require "util.serialization".new("debug");
-local num_type = math.type or function (n)
-	return n % 1 == 0 and n <= 9007199254740992 and n >= -9007199254740992 and "integer" or "float";
-end
+local dump = require"prosody.util.serialization".new({
+	preset = "compact";
+	fallback = function(v, why)
+		return "_[[" .. (why or tostring(v)) .. "]] ";
+	end;
+	freeze = true;
+	fatal = false;
+	maxdepth = 5;
+});
+local num_type = math.type;
 
 -- In Lua 5.3+ these formats throw an error if given a float
 local expects_integer = { c = true, d = true, i = true, o = true, u = true, X = true, x = true, };
@@ -35,7 +41,6 @@
 	["\030"] = "\226\144\158", ["\031"] = "\226\144\159", ["\127"] = "\226\144\161",
 };
 local supports_p = pcall(string.format, "%p", ""); -- >= Lua 5.4
-local supports_a = pcall(string.format, "%a", 0.0); -- > Lua 5.1
 
 local function format(formatstring, ...)
 	local args = pack(...);
@@ -93,8 +98,6 @@
 			elseif expects_positive[option] and arg < 0 then
 				args[i] = tostring(arg);
 				return "[%s]";
-			elseif (option == "a" or option == "A") and not supports_a then
-				return "%x";
 			else
 				return -- acceptable number
 			end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/fsm.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,154 @@
+local events = require "prosody.util.events";
+
+local fsm_methods = {};
+local fsm_mt = { __index = fsm_methods };
+
+local function is_fsm(o)
+	local mt = getmetatable(o);
+	return mt == fsm_mt;
+end
+
+local function notify_transition(fire_event, transition_event)
+	local ret;
+	ret = fire_event("transition", transition_event);
+	if ret ~= nil then return ret; end
+	if transition_event.from ~= transition_event.to then
+		ret = fire_event("leave/"..transition_event.from, transition_event);
+		if ret ~= nil then return ret; end
+	end
+	ret = fire_event("transition/"..transition_event.name, transition_event);
+	if ret ~= nil then return ret; end
+end
+
+local function notify_transitioned(fire_event, transition_event)
+	if transition_event.to ~= transition_event.from then
+		fire_event("enter/"..transition_event.to, transition_event);
+	end
+	if transition_event.name then
+		fire_event("transitioned/"..transition_event.name, transition_event);
+	end
+	fire_event("transitioned", transition_event);
+end
+
+local function do_transition(name)
+	return function (self, attr)
+		local new_state = self.fsm.states[self.state][name] or self.fsm.states["*"][name];
+		if not new_state then
+			return error(("Invalid state transition: %s cannot %s"):format(self.state, name));
+		end
+
+		local transition_event = {
+			instance = self;
+
+			name = name;
+			to = new_state;
+			to_attr = attr;
+
+			from = self.state;
+			from_attr = self.state_attr;
+		};
+
+		local fire_event = self.fsm.events.fire_event;
+		local ret = notify_transition(fire_event, transition_event);
+		if ret ~= nil then return nil, ret; end
+
+		self.state = new_state;
+		self.state_attr = attr;
+
+		notify_transitioned(fire_event, transition_event);
+		return true;
+	end;
+end
+
+local function new(desc)
+	local self = setmetatable({
+		default_state = desc.default_state;
+		events = events.new();
+	}, fsm_mt);
+
+	-- states[state_name][transition_name] = new_state_name
+	local states = { ["*"] = {} };
+	if desc.default_state then
+		states[desc.default_state] = {};
+	end
+	self.states = states;
+
+	local instance_methods = {};
+	self._instance_mt = { __index = instance_methods };
+
+	for _, transition in ipairs(desc.transitions or {}) do
+		local from_states = transition.from;
+		if type(from_states) ~= "table" then
+			from_states = { from_states };
+		end
+		for _, from in ipairs(from_states) do
+			if not states[from] then
+				states[from] = {};
+			end
+			if not states[transition.to] then
+				states[transition.to] = {};
+			end
+			if states[from][transition.name] then
+				return error(("Duplicate transition in FSM specification: %s from %s"):format(transition.name, from));
+			end
+			states[from][transition.name] = transition.to;
+		end
+
+		-- Add public method to trigger this transition
+		instance_methods[transition.name] = do_transition(transition.name);
+	end
+
+	if desc.state_handlers then
+		for state_name, handler in pairs(desc.state_handlers) do
+			self.events.add_handler("enter/"..state_name, handler);
+		end
+	end
+
+	if desc.transition_handlers then
+		for transition_name, handler in pairs(desc.transition_handlers) do
+			self.events.add_handler("transition/"..transition_name, handler);
+		end
+	end
+
+	if desc.handlers then
+		self.events.add_handlers(desc.handlers);
+	end
+
+	return self;
+end
+
+function fsm_methods:init(state_name, state_attr)
+	local initial_state = assert(state_name or self.default_state, "no initial state specified");
+	if not self.states[initial_state] then
+		return error("Invalid initial state: "..initial_state);
+	end
+	local instance = setmetatable({
+		fsm = self;
+		state = initial_state;
+		state_attr = state_attr;
+	}, self._instance_mt);
+
+	if initial_state ~= self.default_state then
+		local fire_event = self.events.fire_event;
+		notify_transitioned(fire_event, {
+			instance = instance;
+
+			to = initial_state;
+			to_attr = state_attr;
+
+			from = self.default_state;
+		});
+	end
+
+	return instance;
+end
+
+function fsm_methods:is_instance(o)
+	local mt = getmetatable(o);
+	return mt == self._instance_mt;
+end
+
+return {
+	new = new;
+	is_fsm = is_fsm;
+};
--- a/util/gc.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/gc.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,4 +1,4 @@
-local set = require "util.set";
+local set = require "prosody.util.set";
 
 local known_options = {
 	incremental = set.new { "mode", "threshold", "speed", "step_size" };
--- a/util/hashring.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/hashring.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,3 +1,5 @@
+local it = require "prosody.util.iterators";
+
 local function generate_ring(nodes, num_replicas, hash)
 	local new_ring = {};
 	for _, node_name in ipairs(nodes) do
@@ -28,18 +30,22 @@
 	return setmetatable({ nodes = {}, num_replicas = num_replicas, hash = hash_function }, hashring_mt);
 end;
 
-function hashring_methods:add_node(name)
+function hashring_methods:add_node(name, value)
 	self.ring = nil;
-	self.nodes[name] = true;
+	self.nodes[name] = value == nil and true or value;
 	table.insert(self.nodes, name);
 	return true;
 end
 
 function hashring_methods:add_nodes(nodes)
 	self.ring = nil;
-	for _, node_name in ipairs(nodes) do
-		if not self.nodes[node_name] then
-			self.nodes[node_name] = true;
+	local iter = pairs;
+	if nodes[1] then -- simple array?
+		iter = it.values;
+	end
+	for node_name, node_value in iter(nodes) do
+		if self.nodes[node_name] == nil then
+			self.nodes[node_name] = node_value == nil and true or node_value;
 			table.insert(self.nodes, node_name);
 		end
 	end
@@ -48,7 +54,7 @@
 
 function hashring_methods:remove_node(node_name)
 	self.ring = nil;
-	if self.nodes[node_name] then
+	if self.nodes[node_name] ~= nil then
 		for i, stored_node_name in ipairs(self.nodes) do
 			if node_name == stored_node_name then
 				self.nodes[node_name] = nil;
@@ -69,18 +75,26 @@
 
 function hashring_methods:clone()
 	local clone_hashring = new(self.num_replicas, self.hash);
-	clone_hashring:add_nodes(self.nodes);
+	for node_name, node_value in pairs(self.nodes) do
+		clone_hashring.nodes[node_name] = node_value;
+	end
+	clone_hashring.ring = nil;
 	return clone_hashring;
 end
 
 function hashring_methods:get_node(key)
+	local node;
 	local key_hash = self.hash(key);
 	for _, replica_hash in ipairs(self.ring) do
 		if key_hash < replica_hash then
-			return self.ring[replica_hash];
+			node = self.ring[replica_hash];
+			break;
 		end
 	end
-	return self.ring[self.ring[1]];
+	if not node then
+		node = self.ring[self.ring[1]];
+	end
+	return node, self.nodes[node];
 end
 
 return {
--- a/util/helpers.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/helpers.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,11 +6,11 @@
 -- COPYING file in the source package for more information.
 --
 
-local debug = require "util.debug";
+local debug = require "prosody.util.debug";
 
 -- Helper functions for debugging
 
-local log = require "util.logger".init("util.debug");
+local log = require "prosody.util.logger".init("util.debug");
 
 local function log_events(events, name, logger)
 	local f = events.fire_event;
--- a/util/hmac.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/hmac.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,11 +8,15 @@
 
 -- COMPAT: Only for external pre-0.9 modules
 
-local hashes = require "util.hashes"
+local hashes = require "prosody.util.hashes"
 
 return {
 	md5 = hashes.hmac_md5,
 	sha1 = hashes.hmac_sha1,
+	sha224 = hashes.hmac_sha224,
 	sha256 = hashes.hmac_sha256,
+	sha384 = hashes.hmac_sha384,
 	sha512 = hashes.hmac_sha512,
+	blake2s256 = hashes.hmac_blake2s256,
+	blake2b512 = hashes.hmac_blake2b512,
 };
--- a/util/http.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/http.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -69,9 +69,42 @@
 	return path;
 end
 
+--- Parse the RFC 7239 Forwarded header into array of key-value pairs.
+local function parse_forwarded(forwarded)
+	if type(forwarded) ~= "string" then
+		return nil;
+	end
+
+	local fwd = {}; -- array
+	local cur = {}; -- map, to which we add the next key-value pair
+	for key, quoted, value, delim in forwarded:gmatch("(%w+)%s*=%s*(\"?)([^,;\"]+)%2%s*(.?)") do
+		-- FIXME quoted quotes like "foo\"bar"
+		-- unlikely when only dealing with IP addresses
+		if quoted == '"' then
+			value = value:gsub("\\(.)", "%1");
+		end
+
+		cur[key:lower()] = value;
+		if delim == "" or delim == "," then
+			t_insert(fwd, cur)
+			if delim == "" then
+				-- end of the string
+				break;
+			end
+			cur = {};
+		elseif delim ~= ";" then
+			-- misparsed
+			return false;
+		end
+	end
+
+	return fwd;
+end
+
 return {
 	urlencode = urlencode, urldecode = urldecode;
 	formencode = formencode, formdecode = formdecode;
 	contains_token = contains_token;
 	normalize_path = normalize_path;
+	parse_forwarded = parse_forwarded;
 };
--- a/util/human/io.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/human/io.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,5 +1,6 @@
-local array = require "util.array";
-local utf8 = rawget(_G, "utf8") or require"util.encodings".utf8;
+local array = require "prosody.util.array";
+local pposix = require "prosody.util.pposix";
+local utf8 = rawget(_G, "utf8") or require"prosody.util.encodings".utf8;
 local len = utf8.len or function(s)
 	local _, count = s:gsub("[%z\001-\127\194-\253][\128-\191]*", "");
 	return count;
@@ -8,7 +9,7 @@
 local function getchar(n)
 	local stty_ret = os.execute("stty raw -echo 2>/dev/null");
 	local ok, char;
-	if stty_ret == true or stty_ret == 0 then
+	if stty_ret then
 		ok, char = pcall(io.read, n or 1);
 		os.execute("stty sane");
 	else
@@ -30,15 +31,12 @@
 end
 
 local function getpass()
-	local stty_ret, _, status_code = os.execute("stty -echo 2>/dev/null");
-	if status_code then -- COMPAT w/ Lua 5.1
-		stty_ret = status_code;
-	end
-	if stty_ret ~= 0 then
+	local stty_ret = os.execute("stty -echo 2>/dev/null");
+	if not stty_ret then
 		io.write("\027[08m"); -- ANSI 'hidden' text attribute
 	end
 	local ok, pass = pcall(io.read, "*l");
-	if stty_ret == 0 then
+	if stty_ret then
 		os.execute("stty sane");
 	else
 		io.write("\027[00m");
@@ -111,14 +109,30 @@
 	end
 end
 
+local function term_width(default)
+	local env_cols = tonumber(os.getenv "COLUMNS");
+	if env_cols then return env_cols; end
+	if not pposix.isatty(io.stdout) then
+		return default;
+	end
+	local stty = io.popen("stty -a");
+	if not stty then return default; end
+	local result = stty:read("*a");
+	if result then
+		result = result:match("%f[%w]columns[ =]*(%d+)");
+	end
+	stty:close();
+	return tonumber(result or default);
+end
+
 local function ellipsis(s, width)
 	if len(s) <= width then return s; end
-	if width == 1 then return "…"; end
+	if width <= 1 then return "…"; end
 	return utf8_cut(s, width - 1) .. "…";
 end
 
 local function new_table(col_specs, max_width)
-	max_width = max_width or tonumber(os.getenv("COLUMNS")) or 80;
+	max_width = max_width or term_width(80);
 	local separator = " | ";
 
 	local widths = {};
@@ -127,21 +141,28 @@
 	-- Calculate width of fixed-size columns
 	for i = 1, #col_specs do
 		local width = col_specs[i].width or "0";
-		if not(type(width) == "string" and width:sub(-1) == "%") then
+		if not (type(width) == "string" and width:match("[p%%]$")) then
 			local title = col_specs[i].title;
 			width = math.max(tonumber(width), title and (#title+1) or 0);
 			widths[i] = width;
 			free_width = free_width - width;
-			if i > 1 then
-				free_width = free_width - #separator;
-			end
 		end
 	end
-	-- Calculate width of %-based columns
+
+	-- Calculate width of proportional columns
+	local total_proportional_width = 0;
 	for i = 1, #col_specs do
 		if not widths[i] then
-			local pc_width = tonumber((col_specs[i].width:gsub("%%$", "")));
-			widths[i] = math.floor(free_width*(pc_width/100));
+			local width_spec = col_specs[i].width:match("([%d%.]+)[p%%]");
+			total_proportional_width = total_proportional_width + tonumber(width_spec);
+		end
+	end
+
+	for i = 1, #col_specs do
+		if not widths[i] then
+			local width_spec = col_specs[i].width:match("([%d%.]+)[p%%]");
+			local rel_width = tonumber(width_spec);
+			widths[i] = math.floor(free_width*(rel_width/total_proportional_width));
 		end
 	end
 
@@ -155,7 +176,7 @@
 			local width = widths[i];
 			local v = row[not titles and column.key or i];
 			if not titles and column.mapper then
-				v = column.mapper(v, row);
+				v = column.mapper(v, row, width, column);
 			end
 			if v == nil then
 				v = column.default or "";
@@ -169,12 +190,36 @@
 					v = padright(v, width);
 				end
 			elseif len(v) > width then
-				v = ellipsis(v, width);
+				v = (column.ellipsis or ellipsis)(v, width);
 			end
 			table.insert(output, v);
 		end
 		return table.concat(output, separator);
-	end;
+	end, max_width;
+end
+
+local day = 86400;
+local multipliers = {
+	d = day, w = day * 7, mon = 31 * day, y = 365.2425 * day;
+	s = 1, min = 60, h = 3600, ho = 3600
+};
+
+local function parse_duration(duration_string)
+	local n, m = duration_string:lower():match("(%d+)%s*([smhdwy]?[io]?n?)");
+	if not n or not multipliers[m] then return nil; end
+	return tonumber(n) * ( multipliers[m] or 1 );
+end
+
+local multipliers_lax = setmetatable({
+	m = multipliers.mon;
+	mo = multipliers.mon;
+	mi = multipliers.min;
+}, { __index = multipliers });
+
+local function parse_duration_lax(duration_string)
+	local n, m = duration_string:lower():match("(%d+)%s*([smhdwy]?[io]?)");
+	if not n then return nil; end
+	return tonumber(n) * ( multipliers_lax[m] or 1 );
 end
 
 return {
@@ -187,6 +232,9 @@
 	printf = printf;
 	padleft = padleft;
 	padright = padright;
+	term_width = term_width;
 	ellipsis = ellipsis;
 	table = new_table;
+	parse_duration = parse_duration;
+	parse_duration_lax = parse_duration_lax;
 };
--- a/util/human/units.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/human/units.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -4,15 +4,7 @@
 local math_log = math.log;
 local math_max = math.max;
 local math_min = math.min;
-local unpack = table.unpack or unpack; --luacheck: ignore 113
-
-if math_log(10, 10) ~= 1 then
-	-- Lua 5.1 COMPAT
-	local log10 = math.log10;
-	function math_log(n, base)
-		return log10(n) / log10(base);
-	end
-end
+local unpack = table.unpack;
 
 local large = {
 	"k", 1000,
--- a/util/id.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/id.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,8 +8,8 @@
 --
 
 local s_gsub = string.gsub;
-local random_bytes = require "util.random".bytes;
-local base64_encode = require "util.encodings".base64.encode;
+local random_bytes = require "prosody.util.random".bytes;
+local base64_encode = require "prosody.util.encodings".base64.encode;
 
 local b64url = { ["+"] = "-", ["/"] = "_", ["="] = "" };
 local function b64url_random(len)
--- a/util/import.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/import.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -8,7 +8,7 @@
 
 
 
-local unpack = table.unpack or unpack; --luacheck: ignore 113
+local unpack = table.unpack;
 local t_insert = table.insert;
 function _G.import(module, ...)
 	local m = package.loaded[module] or require(module);
--- a/util/ip.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/ip.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -5,8 +5,8 @@
 -- COPYING file in the source package for more information.
 --
 
-local net = require "util.net";
-local hex = require "util.hex";
+local net = require "prosody.util.net";
+local strbit = require "prosody.util.strbitop";
 
 local ip_methods = {};
 
@@ -28,13 +28,6 @@
 	return ipA.packed == ipB.packed;
 end
 
-local hex2bits = {
-	["0"] = "0000", ["1"] = "0001", ["2"] = "0010", ["3"] = "0011",
-	["4"] = "0100", ["5"] = "0101", ["6"] = "0110", ["7"] = "0111",
-	["8"] = "1000", ["9"] = "1001", ["A"] = "1010", ["B"] = "1011",
-	["C"] = "1100", ["D"] = "1101", ["E"] = "1110", ["F"] = "1111",
-};
-
 local function new_ip(ipStr, proto)
 	local zone;
 	if (not proto or proto == "IPv6") and ipStr:find('%', 1, true) then
@@ -66,27 +59,18 @@
 	return net.ntop(self.packed);
 end
 
-function ip_methods.bits(ip)
-	return hex.encode(ip.packed):upper():gsub(".", hex2bits);
-end
-
-function ip_methods.bits_full(ip)
+-- Returns the longest packed representation, i.e. IPv4 will be mapped
+function ip_methods.packed_full(ip)
 	if ip.proto == "IPv4" then
 		ip = ip.toV4mapped;
 	end
-	return ip.bits;
+	return ip.packed;
 end
 
 local match;
 
 local function commonPrefixLength(ipA, ipB)
-	ipA, ipB = ipA.bits_full, ipB.bits_full;
-	for i = 1, 128 do
-		if ipA:sub(i,i) ~= ipB:sub(i,i) then
-			return i-1;
-		end
-	end
-	return 128;
+	return strbit.common_prefix_bits(ipA.packed_full, ipB.packed_full);
 end
 
 -- Instantiate once
@@ -238,7 +222,22 @@
 			bits = bits + (128 - 32);
 		end
 	end
-	return ipA.bits:sub(1, bits) == ipB.bits:sub(1, bits);
+	return strbit.common_prefix_bits(ipA.packed, ipB.packed) >= bits;
+end
+
+local function is_ip(obj)
+	return getmetatable(obj) == ip_mt;
+end
+
+local function truncate(ip, n_bits)
+	if n_bits % 8 ~= 0 then
+		return error("ip.truncate() only supports multiples of 8 bits");
+	end
+	local n_octets = n_bits / 8;
+	if not is_ip(ip) then
+		ip = new_ip(ip);
+	end
+	return new_ip(net.ntop(ip.packed:sub(1, n_octets)..("\0"):rep(#ip.packed-n_octets)))
 end
 
 return {
@@ -246,4 +245,6 @@
 	commonPrefixLength = commonPrefixLength,
 	parse_cidr = parse_cidr,
 	match = match,
+	is_ip = is_ip;
+	truncate = truncate;
 };
--- a/util/iterators.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/iterators.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -12,8 +12,8 @@
 
 local t_insert = table.insert;
 local next = next;
-local unpack = table.unpack or unpack; --luacheck: ignore 113
-local pack = table.pack or require "util.table".pack;
+local unpack = table.unpack;
+local pack = table.pack;
 local type = type;
 local table, setmetatable = table, setmetatable;
 
@@ -240,7 +240,8 @@
 end
 
 function it.join(f, s, var)
-	return setmetatable({ {f, s, var} }, join_mt);
+	local t = setmetatable({ {f, s, var} }, join_mt);
+	return t, { t, 1 };
 end
 
 return it;
--- a/util/jid.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/jid.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -10,9 +10,9 @@
 
 local select = select;
 local match, sub = string.match, string.sub;
-local nodeprep = require "util.encodings".stringprep.nodeprep;
-local nameprep = require "util.encodings".stringprep.nameprep;
-local resourceprep = require "util.encodings".stringprep.resourceprep;
+local nodeprep = require "prosody.util.encodings".stringprep.nodeprep;
+local nameprep = require "prosody.util.encodings".stringprep.nameprep;
+local resourceprep = require "prosody.util.encodings".stringprep.resourceprep;
 
 local escapes = {
 	[" "] = "\\20"; ['"'] = "\\22";
@@ -35,8 +35,7 @@
 	if jid == nil then return; end
 	local node, nodepos = match(jid, "^([^@/]+)@()");
 	local host, hostpos = match(jid, "^([^@/]+)()", nodepos);
-	if node ~= nil and host == nil then return nil, nil, nil; end
-	local resource = match(jid, "^/(.+)$", hostpos);
+	local resource = host and match(jid, "^/(.+)$", hostpos);
 	if (host == nil) or ((resource == nil) and #jid >= hostpos) then return nil, nil, nil; end
 	return node, host, resource;
 end
@@ -91,9 +90,9 @@
 	-- TODO compare to table of rules?
 	local jid_node, jid_host, jid_resource = split(jid);
 	local acl_node, acl_host, acl_resource = split(acl);
-	if ((acl_node ~= nil and acl_node == jid_node) or acl_node == nil) and
-		((acl_host ~= nil and acl_host == jid_host) or acl_host == nil) and
-		((acl_resource ~= nil and acl_resource == jid_resource) or acl_resource == nil) then
+	if (acl_node == nil or acl_node == jid_node) and
+		(acl_host == nil or acl_host == jid_host) and
+		(acl_resource == nil or acl_resource == jid_resource) then
 		return true
 	end
 	return false
@@ -111,6 +110,7 @@
 	return (select(3, split(jid)));
 end
 
+-- TODO Forbid \20 at start and end of escaped output per XEP-0106 v1.1
 local function escape(s) return s and (s:gsub("\\%x%x", backslash_escapes):gsub("[\"&'/:<>@ ]", escapes)); end
 local function unescape(s) return s and (s:gsub("\\%x%x", unescapes)); end
 
--- a/util/json.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/json.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -10,12 +10,12 @@
 local t_insert, t_concat, t_remove = table.insert, table.concat, table.remove;
 local s_char = string.char;
 local tostring, tonumber = tostring, tonumber;
-local pairs, ipairs, spairs = pairs, ipairs, require "util.iterators".sorted_pairs;
+local pairs, ipairs, spairs = pairs, ipairs, require "prosody.util.iterators".sorted_pairs;
 local next = next;
 local getmetatable, setmetatable = getmetatable, setmetatable;
 local print = print;
 
-local has_array, array = pcall(require, "util.array");
+local has_array, array = pcall(require, "prosody.util.array");
 local array_mt = has_array and getmetatable(array()) or {};
 
 --module("json")
--- a/util/jsonpointer.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/jsonpointer.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,4 @@
-local m_type = math.type or function (n)
-	return n % 1 == 0 and n <= 9007199254740992 and n >= -9007199254740992 and "integer" or "float";
-end;
+local m_type = math.type;
 
 local function unescape_token(escaped_token)
 	local unescaped = escaped_token:gsub("~1", "/"):gsub("~0", "~")
--- a/util/jsonschema.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/jsonschema.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,16 +1,19 @@
 -- This file is generated from teal-src/util/jsonschema.lua
 
-local m_type = function(n)
-	return type(n) == "number" and n % 1 == 0 and n <= 9007199254740992 and n >= -9007199254740992 and "integer" or "float";
+if not math.type then
+	require("prosody.util.mathcompat")
+end
+
+local utf8_enc = rawget(_G, "utf8") or require("prosody.util.encodings").utf8;
+local utf8_len = utf8_enc.len or function(s)
+	local _, count = s:gsub("[%z\001-\127\194-\253][\128-\191]*", "");
+	return count
 end;
-local json = require("util.json")
+
+local json = require("prosody.util.json")
 local null = json.null;
 
-local pointer = require("util.jsonpointer")
-
-local json_type_name = json.json_type_name
-
-local schema_t = {}
+local pointer = require("prosody.util.jsonpointer")
 
 local json_schema_object = { xml_t = {} }
 
@@ -22,7 +25,7 @@
 	elseif schema == "array" and type(data) == "table" then
 		return type(data) == "table" and (next(data) == nil or type((next(data, nil))) == "number")
 	elseif schema == "integer" then
-		return m_type(data) == schema
+		return math.type(data) == schema
 	elseif schema == "null" then
 		return data == null
 	elseif type(schema) == "table" then
@@ -37,33 +40,35 @@
 	end
 end
 
-local complex_validate
+local function mkerr(sloc, iloc, err)
+	return { schemaLocation = sloc; instanceLocation = iloc; error = err }
+end
 
-local function validate(schema, data, root)
+local function validate(schema, data, root, sloc, iloc, errs)
 	if type(schema) == "boolean" then
 		return schema
-	else
-		return complex_validate(schema, data, root)
 	end
-end
-
-function complex_validate(schema, data, root)
 
 	if root == nil then
 		root = schema
+		iloc = ""
+		sloc = ""
+		errs = {};
 	end
 
 	if schema["$ref"] and schema["$ref"]:sub(1, 1) == "#" then
 		local referenced = pointer.resolve(root, schema["$ref"]:sub(2))
 		if referenced ~= nil and referenced ~= root and referenced ~= schema then
-			if not validate(referenced, data, root) then
-				return false
+			if not validate(referenced, data, root, schema["$ref"], iloc, errs) then
+				table.insert(errs, mkerr(sloc .. "/$ref", iloc, "Subschema failed validation"))
+				return false, errs
 			end
 		end
 	end
 
 	if not simple_validate(schema.type, data) then
-		return false
+		table.insert(errs, mkerr(sloc .. "/type", iloc, "unexpected type"));
+		return false, errs
 	end
 
 	if schema.type == "object" then
@@ -71,7 +76,8 @@
 
 			for k in pairs(data) do
 				if not (type(k) == "string") then
-					return false
+					table.insert(errs, mkerr(sloc .. "/type", iloc, "'object' had non-string keys"));
+					return false, errs
 				end
 			end
 		end
@@ -81,8 +87,9 @@
 		if type(data) == "table" then
 
 			for i in pairs(data) do
-				if not (m_type(i) == "integer") then
-					return false
+				if not (math.type(i) == "integer") then
+					table.insert(errs, mkerr(sloc .. "/type", iloc, "'array' had non-integer keys"));
+					return false, errs
 				end
 			end
 		end
@@ -98,146 +105,217 @@
 			end
 		end
 		if not match then
-			return false
+			table.insert(errs, mkerr(sloc .. "/enum", iloc, "not one of the enumerated values"));
+			return false, errs
 		end
 	end
 
 	if type(data) == "string" then
-		if schema.maxLength and #data > schema.maxLength then
-			return false
+		if schema.maxLength and utf8_len(data) > schema.maxLength then
+			table.insert(errs, mkerr(sloc .. "/maxLength", iloc, "string too long"))
+			return false, errs
 		end
-		if schema.minLength and #data < schema.minLength then
-			return false
+		if schema.minLength and utf8_len(data) < schema.minLength then
+			table.insert(errs, mkerr(sloc .. "/maxLength", iloc, "string too short"))
+			return false, errs
+		end
+		if schema.luaPattern and not data:match(schema.luaPattern) then
+			table.insert(errs, mkerr(sloc .. "/luaPattern", iloc, "string does not match pattern"))
+			return false, errs
 		end
 	end
 
 	if type(data) == "number" then
 		if schema.multipleOf and (data == 0 or data % schema.multipleOf ~= 0) then
-			return false
+			table.insert(errs, mkerr(sloc .. "/luaPattern", iloc, "not a multiple"))
+			return false, errs
 		end
 
 		if schema.maximum and not (data <= schema.maximum) then
-			return false
+			table.insert(errs, mkerr(sloc .. "/maximum", iloc, "number exceeds maximum"))
+			return false, errs
 		end
 
 		if schema.exclusiveMaximum and not (data < schema.exclusiveMaximum) then
-			return false
+			table.insert(errs, mkerr(sloc .. "/exclusiveMaximum", iloc, "number exceeds exclusive maximum"))
+			return false, errs
 		end
 
 		if schema.minimum and not (data >= schema.minimum) then
-			return false
+			table.insert(errs, mkerr(sloc .. "/minimum", iloc, "number below minimum"))
+			return false, errs
 		end
 
 		if schema.exclusiveMinimum and not (data > schema.exclusiveMinimum) then
-			return false
+			table.insert(errs, mkerr(sloc .. "/exclusiveMinimum", iloc, "number below exclusive minimum"))
+			return false, errs
 		end
 	end
 
 	if schema.allOf then
-		for _, sub in ipairs(schema.allOf) do
-			if not validate(sub, data, root) then
-				return false
+		for i, sub in ipairs(schema.allOf) do
+			if not validate(sub, data, root, sloc .. "/allOf/" .. i, iloc, errs) then
+				table.insert(errs, mkerr(sloc .. "/allOf", iloc, "did not match all subschemas"))
+				return false, errs
 			end
 		end
 	end
 
 	if schema.oneOf then
 		local valid = 0
-		for _, sub in ipairs(schema.oneOf) do
-			if validate(sub, data, root) then
+		for i, sub in ipairs(schema.oneOf) do
+			if validate(sub, data, root, sloc .. "/oneOf" .. i, iloc, errs) then
 				valid = valid + 1
 			end
 		end
 		if valid ~= 1 then
-			return false
+			table.insert(errs, mkerr(sloc .. "/oneOf", iloc, "did not match exactly one subschema"))
+			return false, errs
 		end
 	end
 
 	if schema.anyOf then
 		local match = false
-		for _, sub in ipairs(schema.anyOf) do
-			if validate(sub, data, root) then
+		for i, sub in ipairs(schema.anyOf) do
+			if validate(sub, data, root, sloc .. "/anyOf/" .. i, iloc, errs) then
 				match = true
 				break
 			end
 		end
 		if not match then
-			return false
+			table.insert(errs, mkerr(sloc .. "/anyOf", iloc, "did not match any subschema"))
+			return false, errs
 		end
 	end
 
 	if schema["not"] then
-		if validate(schema["not"], data, root) then
-			return false
+		if validate(schema["not"], data, root, sloc .. "/not", iloc, errs) then
+			table.insert(errs, mkerr(sloc .. "/not", iloc, "did match subschema"))
+			return false, errs
 		end
 	end
 
 	if schema["if"] ~= nil then
-		if validate(schema["if"], data, root) then
+		if validate(schema["if"], data, root, sloc .. "/if", iloc, errs) then
 			if schema["then"] then
-				return validate(schema["then"], data, root)
+				if not validate(schema["then"], data, root, sloc .. "/then", iloc, errs) then
+					table.insert(errs, mkerr(sloc .. "/then", iloc, "did not match subschema"))
+					return false, errs
+				end
 			end
 		else
 			if schema["else"] then
-				return validate(schema["else"], data, root)
+				if not validate(schema["else"], data, root, sloc .. "/else", iloc, errs) then
+					table.insert(errs, mkerr(sloc .. "/else", iloc, "did not match subschema"))
+					return false, errs
+				end
 			end
 		end
 	end
 
 	if schema.const ~= nil and schema.const ~= data then
-		return false
+		table.insert(errs, mkerr(sloc .. "/const", iloc, "did not match constant value"))
+		return false, errs
 	end
 
 	if type(data) == "table" then
 
-		if schema.maxItems and #data > schema.maxItems then
-			return false
+		if schema.maxItems and #(data) > schema.maxItems then
+			table.insert(errs, mkerr(sloc .. "/maxItems", iloc, "too many items"))
+			return false, errs
 		end
 
-		if schema.minItems and #data < schema.minItems then
-			return false
+		if schema.minItems and #(data) < schema.minItems then
+			table.insert(errs, mkerr(sloc .. "/minItems", iloc, "too few items"))
+			return false, errs
 		end
 
 		if schema.required then
 			for _, k in ipairs(schema.required) do
 				if data[k] == nil then
-					return false
+					table.insert(errs, mkerr(sloc .. "/required", iloc .. "/" .. tostring(k), "missing required property"))
+					return false, errs
+				end
+			end
+		end
+
+		if schema.dependentRequired then
+			for k, reqs in pairs(schema.dependentRequired) do
+				if data[k] ~= nil then
+					for _, req in ipairs(reqs) do
+						if data[req] == nil then
+							table.insert(errs, mkerr(sloc .. "/dependentRequired", iloc, "missing dependent required property"))
+							return false, errs
+						end
+					end
 				end
 			end
 		end
 
 		if schema.propertyNames ~= nil then
+
 			for k in pairs(data) do
-				if not validate(schema.propertyNames, k, root) then
-					return false
+				if not validate(schema.propertyNames, k, root, sloc .. "/propertyNames", iloc .. "/" .. tostring(k), errs) then
+					table.insert(errs, mkerr(sloc .. "/propertyNames", iloc .. "/" .. tostring(k), "a property name did not match subschema"))
+					return false, errs
 				end
 			end
 		end
 
+		local seen_properties = {}
+
 		if schema.properties then
 			for k, sub in pairs(schema.properties) do
-				if data[k] ~= nil and not validate(sub, data[k], root) then
-					return false
+				if data[k] ~= nil and not validate(sub, data[k], root, sloc .. "/" .. tostring(k), iloc .. "/" .. tostring(k), errs) then
+					table.insert(errs, mkerr(sloc .. "/" .. tostring(k), iloc .. "/" .. tostring(k), "a property did not match subschema"))
+					return false, errs
+				end
+				seen_properties[k] = true
+			end
+		end
+
+		if schema.luaPatternProperties then
+
+			for pattern, sub in pairs(schema.luaPatternProperties) do
+				for k in pairs(data) do
+					if type(k) == "string" and k:match(pattern) then
+						if not validate(sub, data[k], root, sloc .. "/luaPatternProperties", iloc, errs) then
+							table.insert(errs, mkerr(sloc .. "/luaPatternProperties/" .. pattern, iloc .. "/" .. tostring(k), "a property did not match subschema"))
+							return false, errs
+						end
+						seen_properties[k] = true
+					end
 				end
 			end
 		end
 
 		if schema.additionalProperties ~= nil then
 			for k, v in pairs(data) do
-				if schema.properties == nil or schema.properties[k] == nil then
-					if not validate(schema.additionalProperties, v, root) then
-						return false
+				if not seen_properties[k] then
+					if not validate(schema.additionalProperties, v, root, sloc .. "/additionalProperties", iloc .. "/" .. tostring(k), errs) then
+						table.insert(errs, mkerr(sloc .. "/additionalProperties", iloc .. "/" .. tostring(k), "additional property did not match subschema"))
+						return false, errs
 					end
 				end
 			end
 		end
 
+		if schema.dependentSchemas then
+			for k, sub in pairs(schema.dependentSchemas) do
+				if data[k] ~= nil and not validate(sub, data, root, sloc .. "/dependentSchemas/" .. k, iloc, errs) then
+					table.insert(errs, mkerr(sloc .. "/dependentSchemas", iloc .. "/" .. tostring(k), "did not match dependent subschema"))
+					return false, errs
+				end
+			end
+		end
+
 		if schema.uniqueItems then
 
 			local values = {}
 			for _, v in pairs(data) do
 				if values[v] then
-					return false
+					table.insert(errs, mkerr(sloc .. "/uniqueItems", iloc, "had duplicate items"))
+					return false, errs
 				end
 				values[v] = true
 			end
@@ -248,32 +326,39 @@
 			for i, s in ipairs(schema.prefixItems) do
 				if data[i] == nil then
 					break
-				elseif validate(s, data[i], root) then
+				elseif validate(s, data[i], root, sloc .. "/prefixItems/" .. i, iloc .. "/" .. i, errs) then
 					p = i
 				else
-					return false
+					table.insert(errs, mkerr(sloc .. "/prefixItems/" .. i, iloc .. "/" .. tostring(i), "did not match subschema"))
+					return false, errs
 				end
 			end
 		end
 
 		if schema.items ~= nil then
-			for i = p + 1, #data do
-				if not validate(schema.items, data[i], root) then
-					return false
+			for i = p + 1, #(data) do
+				if not validate(schema.items, data[i], root, sloc, iloc .. "/" .. i, errs) then
+					table.insert(errs, mkerr(sloc .. "/prefixItems/" .. i, iloc .. "/" .. i, "did not match subschema"))
+					return false, errs
 				end
 			end
 		end
 
 		if schema.contains ~= nil then
-			local found = false
-			for i = 1, #data do
-				if validate(schema.contains, data[i], root) then
-					found = true
-					break
+			local found = 0
+			for i = 1, #(data) do
+				if validate(schema.contains, data[i], root, sloc .. "/contains", iloc .. "/" .. i, errs) then
+					found = found + 1
+				else
+					table.insert(errs, mkerr(sloc .. "/contains", iloc .. "/" .. i, "did not match subschema"))
 				end
 			end
-			if not found then
-				return false
+			if found < (schema.minContains or 1) then
+				table.insert(errs, mkerr(sloc .. "/minContains", iloc, "too few matches"))
+				return false, errs
+			elseif found > (schema.maxContains or math.huge) then
+				table.insert(errs, mkerr(sloc .. "/maxContains", iloc, "too many matches"))
+				return false, errs
 			end
 		end
 	end
--- a/util/jwt.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/jwt.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,9 +1,10 @@
 local s_gsub = string.gsub;
-local json = require "util.json";
-local hashes = require "util.hashes";
-local base64_encode = require "util.encodings".base64.encode;
-local base64_decode = require "util.encodings".base64.decode;
-local secure_equals = require "util.hashes".equals;
+local crypto = require "prosody.util.crypto";
+local json = require "prosody.util.json";
+local hashes = require "prosody.util.hashes";
+local base64_encode = require "prosody.util.encodings".base64.encode;
+local base64_decode = require "prosody.util.encodings".base64.decode;
+local secure_equals = require "prosody.util.hashes".equals;
 
 local b64url_rep = { ["+"] = "-", ["/"] = "_", ["="] = "", ["-"] = "+", ["_"] = "/" };
 local function b64url(data)
@@ -13,17 +14,8 @@
 	return base64_decode(s_gsub(data, "[-_]", b64url_rep).."==");
 end
 
-local static_header = b64url('{"alg":"HS256","typ":"JWT"}') .. '.';
-
-local function sign(key, payload)
-	local encoded_payload = json.encode(payload);
-	local signed = static_header .. b64url(encoded_payload);
-	local signature = hashes.hmac_sha256(key, signed);
-	return signed .. "." .. b64url(signature);
-end
-
 local jwt_pattern = "^(([A-Za-z0-9-_]+)%.([A-Za-z0-9-_]+))%.([A-Za-z0-9-_]+)$"
-local function verify(key, blob)
+local function decode_jwt(blob, expected_alg)
 	local signed, bheader, bpayload, signature = string.match(blob, jwt_pattern);
 	if not signed then
 		return nil, "invalid-encoding";
@@ -31,21 +23,197 @@
 	local header = json.decode(unb64url(bheader));
 	if not header or type(header) ~= "table" then
 		return nil, "invalid-header";
-	elseif header.alg ~= "HS256" then
+	elseif header.alg ~= expected_alg then
 		return nil, "unsupported-algorithm";
 	end
-	if not secure_equals(b64url(hashes.hmac_sha256(key, signed)), signature) then
-		return false, "signature-mismatch";
-	end
-	local payload, err = json.decode(unb64url(bpayload));
+	return signed, signature, bpayload;
+end
+
+local function new_static_header(algorithm_name)
+	return b64url('{"alg":"'..algorithm_name..'","typ":"JWT"}') .. '.';
+end
+
+local function decode_raw_payload(raw_payload)
+	local payload, err = json.decode(unb64url(raw_payload));
 	if err ~= nil then
 		return nil, "json-decode-error";
+	elseif type(payload) ~= "table" then
+		return nil, "invalid-payload-type";
 	end
 	return true, payload;
 end
 
-return {
-	sign = sign;
-	verify = verify;
+-- HS*** family
+local function new_hmac_algorithm(name)
+	local static_header = new_static_header(name);
+
+	local hmac = hashes["hmac_sha"..name:sub(-3)];
+
+	local function sign(key, payload)
+		local encoded_payload = json.encode(payload);
+		local signed = static_header .. b64url(encoded_payload);
+		local signature = hmac(key, signed);
+		return signed .. "." .. b64url(signature);
+	end
+
+	local function verify(key, blob)
+		local signed, signature, raw_payload = decode_jwt(blob, name);
+		if not signed then return nil, signature; end -- nil, err
+
+		if not secure_equals(b64url(hmac(key, signed)), signature) then
+			return false, "signature-mismatch";
+		end
+
+		return decode_raw_payload(raw_payload);
+	end
+
+	local function load_key(key)
+		assert(type(key) == "string", "key must be string (long, random, secure)");
+		return key;
+	end
+
+	return { sign = sign, verify = verify, load_key = load_key };
+end
+
+local function new_crypto_algorithm(name, key_type, c_sign, c_verify, sig_encode, sig_decode)
+	local static_header = new_static_header(name);
+
+	return {
+		sign = function (private_key, payload)
+			local encoded_payload = json.encode(payload);
+			local signed = static_header .. b64url(encoded_payload);
+
+			local signature = c_sign(private_key, signed);
+			if sig_encode then
+				signature = sig_encode(signature);
+			end
+
+			return signed.."."..b64url(signature);
+		end;
+
+		verify = function (public_key, blob)
+			local signed, signature, raw_payload = decode_jwt(blob, name);
+			if not signed then return nil, signature; end -- nil, err
+
+			signature = unb64url(signature);
+			if sig_decode and signature then
+				signature = sig_decode(signature);
+			end
+			if not signature then
+				return false, "signature-mismatch";
+			end
+
+			local verify_ok = c_verify(public_key, signed, signature);
+			if not verify_ok then
+				return false, "signature-mismatch";
+			end
+
+			return decode_raw_payload(raw_payload);
+		end;
+
+		load_public_key = function (public_key_pem)
+			local key = assert(crypto.import_public_pem(public_key_pem));
+			assert(key:get_type() == key_type, "incorrect key type");
+			return key;
+		end;
+
+		load_private_key = function (private_key_pem)
+			local key = assert(crypto.import_private_pem(private_key_pem));
+			assert(key:get_type() == key_type, "incorrect key type");
+			return key;
+		end;
+	};
+end
+
+-- RS***, PS***
+local rsa_sign_algos = { RS = "rsassa_pkcs1", PS = "rsassa_pss" };
+local function new_rsa_algorithm(name)
+	local family, digest_bits = name:match("^(..)(...)$");
+	local c_sign = crypto[rsa_sign_algos[family].."_sha"..digest_bits.."_sign"];
+	local c_verify = crypto[rsa_sign_algos[family].."_sha"..digest_bits.."_verify"];
+	return new_crypto_algorithm(name, "rsaEncryption", c_sign, c_verify);
+end
+
+-- ES***
+local function new_ecdsa_algorithm(name, c_sign, c_verify, sig_bytes)
+	local function encode_ecdsa_sig(der_sig)
+		local r, s = crypto.parse_ecdsa_signature(der_sig, sig_bytes);
+		return r..s;
+	end
+
+	local expected_sig_length = sig_bytes*2;
+	local function decode_ecdsa_sig(jwk_sig)
+		if #jwk_sig ~= expected_sig_length then
+			return nil;
+		end
+		return crypto.build_ecdsa_signature(jwk_sig:sub(1, sig_bytes), jwk_sig:sub(sig_bytes+1));
+	end
+	return new_crypto_algorithm(name, "id-ecPublicKey", c_sign, c_verify, encode_ecdsa_sig, decode_ecdsa_sig);
+end
+
+local algorithms = {
+	HS256 = new_hmac_algorithm("HS256"), HS384 = new_hmac_algorithm("HS384"), HS512 = new_hmac_algorithm("HS512");
+	ES256 = new_ecdsa_algorithm("ES256", crypto.ecdsa_sha256_sign, crypto.ecdsa_sha256_verify, 32);
+	ES512 = new_ecdsa_algorithm("ES512", crypto.ecdsa_sha512_sign, crypto.ecdsa_sha512_verify, 66);
+	RS256 = new_rsa_algorithm("RS256"), RS384 = new_rsa_algorithm("RS384"), RS512 = new_rsa_algorithm("RS512");
+	PS256 = new_rsa_algorithm("PS256"), PS384 = new_rsa_algorithm("PS384"), PS512 = new_rsa_algorithm("PS512");
 };
 
+local function new_signer(algorithm, key_input, options)
+	local impl = assert(algorithms[algorithm], "Unknown JWT algorithm: "..algorithm);
+	local key = (impl.load_private_key or impl.load_key)(key_input);
+	local sign = impl.sign;
+	local default_ttl = (options and options.default_ttl) or 3600;
+	return function (payload)
+		local issued_at;
+		if not payload.iat then
+			issued_at = os.time();
+			payload.iat = issued_at;
+		end
+		if not payload.exp then
+			payload.exp = (issued_at or os.time()) + default_ttl;
+		end
+		return sign(key, payload);
+	end
+end
+
+local function new_verifier(algorithm, key_input, options)
+	local impl = assert(algorithms[algorithm], "Unknown JWT algorithm: "..algorithm);
+	local key = (impl.load_public_key or impl.load_key)(key_input);
+	local verify = impl.verify;
+	local check_expiry = not (options and options.accept_expired);
+	local claim_verifier = options and options.claim_verifier;
+	return function (token)
+		local ok, payload = verify(key, token);
+		if ok then
+			local expires_at = check_expiry and payload.exp;
+			if expires_at then
+				if type(expires_at) ~= "number" then
+					return nil, "invalid-expiry";
+				elseif expires_at < os.time() then
+					return nil, "token-expired";
+				end
+			end
+			if claim_verifier and not claim_verifier(payload) then
+				return nil, "incorrect-claims";
+			end
+		end
+		return ok, payload;
+	end
+end
+
+local function init(algorithm, private_key, public_key, options)
+	return new_signer(algorithm, private_key, options), new_verifier(algorithm, public_key or private_key, options);
+end
+
+return {
+	init = init;
+	new_signer = new_signer;
+	new_verifier = new_verifier;
+	-- Exported mainly for tests
+	_algorithms = algorithms;
+	-- Deprecated
+	sign = algorithms.HS256.sign;
+	verify = algorithms.HS256.verify;
+};
+
--- a/util/logger.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/logger.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -10,6 +10,7 @@
 local pairs = pairs;
 local ipairs = ipairs;
 local require = require;
+local t_remove = table.remove;
 
 local _ENV = nil;
 -- luacheck: std none
@@ -71,13 +72,27 @@
 end
 
 local function add_simple_sink(simple_sink_function, levels)
-	local format = require "util.format".format;
+	local format = require "prosody.util.format".format;
 	local function sink_function(name, level, msg, ...)
 		return simple_sink_function(name, level, format(msg, ...));
 	end
 	for _, level in ipairs(levels or {"debug", "info", "warn", "error"}) do
 		add_level_sink(level, sink_function);
 	end
+	return sink_function;
+end
+
+local function remove_sink(sink_function)
+	local removed;
+	for level, sinks in pairs(level_sinks) do
+		for i = #sinks, 1, -1 do
+			if sinks[i] == sink_function then
+				t_remove(sinks, i);
+				removed = true;
+			end
+		end
+	end
+	return removed;
 end
 
 return {
@@ -87,4 +102,5 @@
 	add_level_sink = add_level_sink;
 	add_simple_sink = add_simple_sink;
 	new = make_logger;
+	remove_sink = remove_sink;
 };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/mathcompat.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,13 @@
+if not math.type then
+
+	local function math_type(t)
+		if type(t) == "number" then
+			if t % 1 == 0 and t ~= t + 1 and t ~= t - 1 then
+				return "integer"
+			else
+				return "float"
+			end
+		end
+	end
+	_G.math.type = math_type
+end
--- a/util/multitable.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/multitable.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -9,7 +9,7 @@
 local select = select;
 local t_insert = table.insert;
 local pairs, next, type = pairs, next, type;
-local unpack = table.unpack or unpack; --luacheck: ignore 113
+local unpack = table.unpack;
 
 local _ENV = nil;
 -- luacheck: std none
--- a/util/openmetrics.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/openmetrics.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,7 +1,7 @@
 --[[
 This module implements a subset of the OpenMetrics Internet Draft version 00.
 
-URL: https://tools.ietf.org/html/draft-richih-opsawg-openmetrics-00
+URL: https://datatracker.ietf.org/doc/html/draft-richih-opsawg-openmetrics-00
 
 The following metric types are supported:
 
@@ -19,14 +19,14 @@
 -- metric constructor interface:
 -- metric_ctor(..., family_name, labels, extra)
 
-local time = require "util.time".now;
+local time = require "prosody.util.time".now;
 local select = select;
-local array = require "util.array";
-local log = require "util.logger".init("util.openmetrics");
-local new_multitable = require "util.multitable".new;
-local iter_multitable = require "util.multitable".iter;
+local array = require "prosody.util.array";
+local log = require "prosody.util.logger".init("util.openmetrics");
+local new_multitable = require "prosody.util.multitable".new;
+local iter_multitable = require "prosody.util.multitable".iter;
 local t_concat, t_insert = table.concat, table.insert;
-local t_pack, t_unpack = require "util.table".pack, table.unpack or unpack; --luacheck: ignore 113/unpack
+local t_pack, t_unpack = table.pack, table.unpack;
 
 -- BEGIN of Utility: "metric proxy"
 -- This allows to wrap a MetricFamily in a proxy which only provides the
@@ -35,6 +35,7 @@
 -- `with_partial_label` by the moduleapi in order to pre-set the `host` label
 -- on metrics created in non-global modules.
 local metric_proxy_mt = {}
+metric_proxy_mt.__name = "metric_proxy"
 metric_proxy_mt.__index = metric_proxy_mt
 
 local function new_metric_proxy(metric_family, with_labels_proxy_fun)
@@ -128,6 +129,7 @@
 -- BEGIN of generic MetricFamily implementation
 
 local metric_family_mt = {}
+metric_family_mt.__name = "metric_family"
 metric_family_mt.__index = metric_family_mt
 
 local function histogram_metric_ctor(orig_ctor, buckets)
@@ -278,6 +280,7 @@
 end
 
 local metric_registry_mt = {}
+metric_registry_mt.__name = "metric_registry"
 metric_registry_mt.__index = metric_registry_mt
 
 local function new_metric_registry(backend)
--- a/util/openssl.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/openssl.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -5,7 +5,7 @@
 local oid_xmppaddr = "1.3.6.1.5.5.7.8.5"; -- [XMPP-CORE]
 local oid_dnssrv   = "1.3.6.1.5.5.7.8.7"; -- [SRV-ID]
 
-local idna_to_ascii = require "util.encodings".idna.to_ascii;
+local idna_to_ascii = require "prosody.util.encodings".idna.to_ascii;
 
 local _M = {};
 local config = {};
@@ -166,8 +166,7 @@
 	setmetatable(_M, {
 		__index = function(_, command)
 			return function(opts)
-				local ret = os_execute(serialize(command, type(opts) == "table" and opts or {}));
-				return ret == true or ret == 0;
+				return os_execute(serialize(command, type(opts) == "table" and opts or {}));
 			end;
 		end;
 	});
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/paseto.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,218 @@
+local crypto = require "prosody.util.crypto";
+local json = require "prosody.util.json";
+local hashes = require "prosody.util.hashes";
+local base64_encode = require "prosody.util.encodings".base64.encode;
+local base64_decode = require "prosody.util.encodings".base64.decode;
+local secure_equals = require "prosody.util.hashes".equals;
+local bit = require "prosody.util.bitcompat";
+local hex = require "prosody.util.hex";
+local rand = require "prosody.util.random";
+local s_pack = require "prosody.util.struct".pack;
+
+local s_gsub = string.gsub;
+
+local v4_public = {};
+
+local b64url_rep = { ["+"] = "-", ["/"] = "_", ["="] = "", ["-"] = "+", ["_"] = "/" };
+local function b64url(data)
+	return (s_gsub(base64_encode(data), "[+/=]", b64url_rep));
+end
+
+local valid_tails = {
+	nil; -- Always invalid
+	"^.[AQgw]$"; -- b??????00
+	"^..[AQgwEUk0IYo4Mcs8]$"; -- b????0000
+}
+
+local function unb64url(data)
+	local rem = #data%4;
+	if data:sub(-1,-1) == "=" or rem == 1 or (rem > 1 and not data:sub(-rem):match(valid_tails[rem])) then
+		return nil;
+	end
+	return base64_decode(s_gsub(data, "[-_]", b64url_rep).."==");
+end
+
+local function le64(n)
+	return s_pack("<I8", bit.band(n, 0x7F));
+end
+
+local function pae(parts)
+	if type(parts) ~= "table" then
+		error("bad argument #1 to 'pae' (table expected, got "..type(parts)..")");
+	end
+	local o = { le64(#parts) };
+	for _, part in ipairs(parts) do
+		table.insert(o, le64(#part)..part);
+	end
+	return table.concat(o);
+end
+
+function v4_public.sign(m, sk, f, i)
+	if type(m) ~= "table" then
+		return nil, "PASETO payloads must be a table";
+	end
+	m = json.encode(m);
+	local h = "v4.public.";
+	local m2 = pae({ h, m, f or "", i or "" });
+	local sig = crypto.ed25519_sign(sk, m2);
+	if not f or f == "" then
+		return h..b64url(m..sig);
+	else
+		return h..b64url(m..sig).."."..b64url(f);
+	end
+end
+
+function v4_public.verify(tok, pk, expected_f, i)
+	local h, sm, f = tok:match("^(v4%.public%.)([^%.]+)%.?(.*)$");
+	if not h then
+		return nil, "invalid-token-format";
+	end
+	f = f and unb64url(f) or nil;
+	if expected_f then
+		if not f or not secure_equals(expected_f, f) then
+			return nil, "invalid-footer";
+		end
+	end
+	local raw_sm = unb64url(sm);
+	if not raw_sm or #raw_sm <= 64 then
+		return nil, "invalid-token-format";
+	end
+	local s, m = raw_sm:sub(-64), raw_sm:sub(1, -65);
+	local m2 = pae({ h, m, f or "", i or "" });
+	local ok = crypto.ed25519_verify(pk, m2, s);
+	if not ok then
+		return nil, "invalid-token";
+	end
+	local payload, err = json.decode(m);
+	if err ~= nil or type(payload) ~= "table" then
+		return nil, "json-decode-error";
+	end
+	return payload;
+end
+
+v4_public.import_private_key = crypto.import_private_pem;
+v4_public.import_public_key = crypto.import_public_pem;
+function v4_public.new_keypair()
+	return crypto.generate_ed25519_keypair();
+end
+
+function v4_public.init(private_key_pem, public_key_pem, options)
+	local sign, verify = v4_public.sign, v4_public.verify;
+	local public_key = public_key_pem and v4_public.import_public_key(public_key_pem);
+	local private_key = private_key_pem and v4_public.import_private_key(private_key_pem);
+	local default_footer = options and options.default_footer;
+	local default_assertion = options and options.default_implicit_assertion;
+	return private_key and function (token, token_footer, token_assertion)
+		return sign(token, private_key, token_footer or default_footer, token_assertion or default_assertion);
+	end, public_key and function (token, expected_footer, token_assertion)
+		return verify(token, public_key, expected_footer or default_footer, token_assertion or default_assertion);
+	end;
+end
+
+function v4_public.new_signer(private_key_pem, options)
+	return (v4_public.init(private_key_pem, nil, options));
+end
+
+function v4_public.new_verifier(public_key_pem, options)
+	return (select(2, v4_public.init(nil, public_key_pem, options)));
+end
+
+local v3_local = { _key_mt = {} };
+
+local function v3_local_derive_keys(k, n)
+	local tmp = hashes.hkdf_hmac_sha384(48, k, nil, "paseto-encryption-key"..n);
+	local Ek = tmp:sub(1, 32);
+	local n2 = tmp:sub(33);
+	local Ak = hashes.hkdf_hmac_sha384(48, k, nil, "paseto-auth-key-for-aead"..n);
+	return Ek, Ak, n2;
+end
+
+function v3_local.encrypt(m, k, f, i)
+	assert(#k == 32)
+	if type(m) ~= "table" then
+		return nil, "PASETO payloads must be a table";
+	end
+	m = json.encode(m);
+	local h = "v3.local.";
+	local n = rand.bytes(32);
+	local Ek, Ak, n2 = v3_local_derive_keys(k, n);
+
+	local c = crypto.aes_256_ctr_encrypt(Ek, n2, m);
+	local m2 = pae({ h, n, c, f or "", i or "" });
+	local t = hashes.hmac_sha384(Ak, m2);
+
+	if not f or f == "" then
+		return h..b64url(n..c..t);
+	else
+		return h..b64url(n..c..t).."."..b64url(f);
+	end
+end
+
+function v3_local.decrypt(tok, k, expected_f, i)
+	assert(#k == 32)
+
+	local h, sm, f = tok:match("^(v3%.local%.)([^%.]+)%.?(.*)$");
+	if not h then
+		return nil, "invalid-token-format";
+	end
+	f = f and unb64url(f) or nil;
+	if expected_f then
+		if not f or not secure_equals(expected_f, f) then
+			return nil, "invalid-footer";
+		end
+	end
+	local m = unb64url(sm);
+	if not m or #m <= 80 then
+		return nil, "invalid-token-format";
+	end
+	local n, c, t = m:sub(1, 32), m:sub(33, -49), m:sub(-48);
+	local Ek, Ak, n2 = v3_local_derive_keys(k, n);
+	local preAuth = pae({ h, n, c, f or "", i or "" });
+	local t2 = hashes.hmac_sha384(Ak, preAuth);
+	if not secure_equals(t, t2) then
+		return nil, "invalid-token";
+	end
+	local m2 = crypto.aes_256_ctr_decrypt(Ek, n2, c);
+	if not m2 then
+		return nil, "invalid-token";
+	end
+
+	local payload, err = json.decode(m2);
+	if err ~= nil or type(payload) ~= "table" then
+		return nil, "json-decode-error";
+	end
+	return payload;
+end
+
+function v3_local.new_key()
+	return "secret-token:paseto.v3.local:"..hex.encode(rand.bytes(32));
+end
+
+function v3_local.init(key, options)
+	local encoded_key = key:match("^secret%-token:paseto%.v3%.local:(%x+)$");
+	if not encoded_key or #encoded_key ~= 64 then
+		return error("invalid key for v3.local");
+	end
+	local raw_key = hex.decode(encoded_key);
+	local default_footer = options and options.default_footer;
+	local default_assertion = options and options.default_implicit_assertion;
+	return function (token, token_footer, token_assertion)
+		return v3_local.encrypt(token, raw_key, token_footer or default_footer, token_assertion or default_assertion);
+	end, function (token, token_footer, token_assertion)
+		return v3_local.decrypt(token, raw_key, token_footer or default_footer, token_assertion or default_assertion);
+	end;
+end
+
+function v3_local.new_signer(key, options)
+	return (v3_local.init(key, options));
+end
+
+function v3_local.new_verifier(key, options)
+	return (select(2, v3_local.init(key, options)));
+end
+
+return {
+	pae = pae;
+	v3_local = v3_local;
+	v4_public = v4_public;
+};
--- a/util/pluginloader.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/pluginloader.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -17,7 +17,7 @@
 end
 
 local io_open = io.open;
-local envload = require "util.envload".envload;
+local envload = require "prosody.util.envload".envload;
 
 local pluginloader_methods = {};
 local pluginloader_mt = { __index = pluginloader_methods };
--- a/util/promise.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/promise.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,8 +1,8 @@
 local promise_methods = {};
 local promise_mt = { __name = "promise", __index = promise_methods };
 
-local xpcall = require "util.xpcall".xpcall;
-local unpack = table.unpack or unpack; --luacheck: ignore 113
+local xpcall = require "prosody.util.xpcall".xpcall;
+local unpack = table.unpack;
 
 function promise_mt:__tostring()
 	return  "promise (" .. (self._state or "invalid") .. ")";
@@ -57,10 +57,7 @@
 end
 
 local function new_resolve_functions(p)
-	local resolved = false;
 	local function _resolve(v)
-		if resolved then return; end
-		resolved = true;
 		if is_promise(v) then
 			v:next(new_resolve_functions(p));
 		elseif promise_settle(p, "fulfilled", next_fulfilled, p._pending_on_fulfilled, v) then
@@ -69,8 +66,6 @@
 
 	end
 	local function _reject(e)
-		if resolved then return; end
-		resolved = true;
 		if promise_settle(p, "rejected", next_rejected, p._pending_on_rejected, e) then
 			p.reason = e;
 		end
--- a/util/prosodyctl.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/prosodyctl.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,14 +7,14 @@
 --
 
 
-local config = require "core.configmanager";
-local encodings = require "util.encodings";
+local config = require "prosody.core.configmanager";
+local encodings = require "prosody.util.encodings";
 local stringprep = encodings.stringprep;
-local storagemanager = require "core.storagemanager";
-local usermanager = require "core.usermanager";
-local interpolation = require "util.interpolation";
-local signal = require "util.signal";
-local set = require "util.set";
+local storagemanager = require "prosody.core.storagemanager";
+local usermanager = require "prosody.core.usermanager";
+local interpolation = require "prosody.util.interpolation";
+local signal = require "prosody.util.signal";
+local set = require "prosody.util.set";
 local lfs = require "lfs";
 local type = type;
 
@@ -42,7 +42,7 @@
 		}, { __index = function (_,k) return "Error: "..(tostring(k):gsub("%-", " "):gsub("^.", string.upper)); end });
 
 -- UI helpers
-local show_message = require "util.human.io".printf;
+local show_message = require "prosody.util.human.io".printf;
 
 local function show_usage(usage, desc)
 	print("Usage: ".._G.arg[0].." "..usage);
@@ -224,8 +224,7 @@
 	local ok, _, code = os.execute(render_cli("luarocks --lua-version={luav} {op} --tree={dir} {server&--server={server}} {mod?}", {
 				dir = dir; op = operation; mod = mod; server = server; luav = _VERSION:match("5%.%d");
 		}));
-	if type(ok) == "number" then code = ok; end
-	return code;
+	return ok and code;
 end
 
 return {
--- a/util/prosodyctl/cert.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/prosodyctl/cert.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,8 +1,8 @@
 local lfs = require "lfs";
 
-local pctl = require "util.prosodyctl";
-local hi = require "util.human.io";
-local configmanager = require "core.configmanager";
+local pctl = require "prosody.util.prosodyctl";
+local hi = require "prosody.util.human.io";
+local configmanager = require "prosody.core.configmanager";
 
 local openssl;
 
@@ -24,7 +24,7 @@
 	end
 end
 
-local have_pposix, pposix = pcall(require, "util.pposix");
+local have_pposix, pposix = pcall(require, "prosody.util.pposix");
 local cert_basedir = prosody.paths.data == "." and "./certs" or prosody.paths.data;
 if have_pposix and pposix.getuid() == 0 then
 	-- FIXME should be enough to check if this directory is writable
@@ -179,7 +179,7 @@
 		os.execute(("chown -c --reference=%s %s"):format(sh_esc(cert_basedir), sh_esc(to)));
 	elseif owner and group then
 		local ok = os.execute(("chown %s:%s %s"):format(sh_esc(owner), sh_esc(group), sh_esc(to)));
-		assert(ok == true or ok == 0, "Failed to change ownership of "..to);
+		assert(ok, "Failed to change ownership of "..to);
 	end
 	if old_umask then pposix.umask(old_umask); end
 	return true;
@@ -219,7 +219,7 @@
 		owner = configmanager.get("*", "prosody_user") or "prosody";
 		group = configmanager.get("*", "prosody_group") or owner;
 	end
-	local cm = require "core.certmanager";
+	local cm = require "prosody.core.certmanager";
 	local files_by_name = {}
 	for _, dir in ipairs(arg) do
 		cm.index_certs(dir, files_by_name);
@@ -271,7 +271,7 @@
 
 local function cert(arg)
 	if #arg >= 1 and arg[1] ~= "--help" then
-		openssl = require "util.openssl";
+		openssl = require "prosody.util.openssl";
 		lfs = require "lfs";
 		local cert_dir_attrs = lfs.attributes(cert_basedir);
 		if not cert_dir_attrs then
@@ -303,7 +303,7 @@
 			end
 			return cert_commands[subcmd](arg);
 		elseif subcmd == "check" then
-			return require "util.prosodyctl.check".check({"certs"});
+			return require "prosody.util.prosodyctl.check".check({"certs"});
 		end
 	end
 	pctl.show_usage("cert config|request|generate|key|import", "Helpers for generating X.509 certificates and keys.")
--- a/util/prosodyctl/check.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/prosodyctl/check.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,24 +1,24 @@
-local configmanager = require "core.configmanager";
-local moduleapi = require "core.moduleapi";
-local show_usage = require "util.prosodyctl".show_usage;
-local show_warning = require "util.prosodyctl".show_warning;
-local is_prosody_running = require "util.prosodyctl".isrunning;
-local parse_args = require "util.argparse".parse;
-local dependencies = require "util.dependencies";
+local configmanager = require "prosody.core.configmanager";
+local moduleapi = require "prosody.core.moduleapi";
+local show_usage = require "prosody.util.prosodyctl".show_usage;
+local show_warning = require "prosody.util.prosodyctl".show_warning;
+local is_prosody_running = require "prosody.util.prosodyctl".isrunning;
+local parse_args = require "prosody.util.argparse".parse;
+local dependencies = require "prosody.util.dependencies";
 local socket = require "socket";
 local socket_url = require "socket.url";
-local jid_split = require "util.jid".prepped_split;
-local modulemanager = require "core.modulemanager";
-local async = require "util.async";
-local httputil = require "util.http";
+local jid_split = require "prosody.util.jid".prepped_split;
+local modulemanager = require "prosody.core.modulemanager";
+local async = require "prosody.util.async";
+local httputil = require "prosody.util.http";
 
 local function api(host)
 	return setmetatable({ name = "prosodyctl.check"; host = host; log = prosody.log }, { __index = moduleapi })
 end
 
 local function check_ojn(check_type, target_host)
-	local http = require "net.http"; -- .new({});
-	local json = require "util.json";
+	local http = require "prosody.net.http"; -- .new({});
+	local json = require "prosody.util.json";
 
 	local response, err = async.wait_for(http.request(
 		("https://observe.jabber.network/api/v1/check/%s"):format(httputil.urlencode(check_type)),
@@ -46,7 +46,7 @@
 end
 
 local function check_probe(base_url, probe_module, target)
-	local http = require "net.http"; -- .new({});
+	local http = require "prosody.net.http"; -- .new({});
 	local params = httputil.formencode({ module = probe_module; target = target })
 	local response, err = async.wait_for(http.request(base_url .. "?" .. params));
 
@@ -67,8 +67,8 @@
 end
 
 local function check_turn_service(turn_service, ping_service)
-	local ip = require "util.ip";
-	local stun = require "net.stun";
+	local ip = require "prosody.util.ip";
+	local stun = require "prosody.net.stun";
 
 	-- Create UDP socket for communication with the server
 	local sock = assert(require "socket".udp());
@@ -160,7 +160,7 @@
 		result.error = "TURN server did not response to allocation request: "..err;
 		return result;
 	elseif alloc_response:is_err_resp() then
-		result.error = ("TURN allocation failed: %d (%s)"):format(alloc_response:get_error());
+		result.error = ("TURN server failed to create allocation: %d (%s)"):format(alloc_response:get_error());
 		return result;
 	elseif not alloc_response:is_success_resp() then
 		result.error = ("Unexpected TURN response: %d (%s)"):format(alloc_response:get_type());
@@ -309,18 +309,15 @@
 		print("Error: Unknown parameter: "..opts_info);
 		return 1;
 	end
-	local array = require "util.array";
-	local set = require "util.set";
-	local it = require "util.iterators";
+	local array = require "prosody.util.array";
+	local set = require "prosody.util.set";
+	local it = require "prosody.util.iterators";
 	local ok = true;
+	local function contains_match(hayset, needle) for member in hayset do if member:find(needle) then return true end end end
 	local function disabled_hosts(host, conf) return host ~= "*" and conf.enabled ~= false; end
 	local function enabled_hosts() return it.filter(disabled_hosts, pairs(configmanager.getconfig())); end
-	if not (what == nil or what == "disabled" or what == "config" or what == "dns" or what == "certs" or what == "connectivity" or what == "turn") then
-		show_warning("Don't know how to check '%s'. Try one of 'config', 'dns', 'certs', 'disabled', 'turn' or 'connectivity'.", what);
-		show_warning("Note: The connectivity check will connect to a remote server.");
-		return 1;
-	end
-	if not what or what == "disabled" then
+	local checks = {};
+	function checks.disabled()
 		local disabled_hosts_set = set.new();
 		for host in it.filter("*", pairs(configmanager.getconfig())) do
 			if api(host):get_option_boolean("enabled") == false then
@@ -335,7 +332,7 @@
 			print""
 		end
 	end
-	if not what or what == "config" then
+	function checks.config()
 		print("Checking config...");
 
 		if what == "config" then
@@ -510,9 +507,9 @@
 			end
 			for k, v in pairs(modules) do
 				if type(k) ~= "number" or type(v) ~= "string" then
-					print("    The " .. name .. " in the " .. host .. " section should not be a map of " .. type(k) .. " to " .. type(v)
-									.. " but a list of strings, e.g.");
+					print("    The " .. name .. " in the " .. host .. " section should be a list of strings, e.g.");
 					print("    " .. name .. " = { \"name_of_module\", \"another_plugin\", }")
+					print("    It should not contain key = value pairs, try putting them outside the {} brackets.");
 					ok = false
 					break
 				end
@@ -740,7 +737,7 @@
 
 		-- Check hostname validity
 		do
-			local idna = require "util.encodings".idna;
+			local idna = require "prosody.util.encodings".idna;
 			local invalid_hosts = {};
 			local alabel_hosts = {};
 			for host in it.filter("*", pairs(configmanager.getconfig())) do
@@ -791,14 +788,14 @@
 
 		print("Done.\n");
 	end
-	if not what or what == "dns" then
-		local dns = require "net.dns";
+	function checks.dns()
+		local dns = require "prosody.net.dns";
 		pcall(function ()
-			local unbound = require"net.unbound";
+			local unbound = require"prosody.net.unbound";
 			dns = unbound.dns;
 		end)
-		local idna = require "util.encodings".idna;
-		local ip = require "util.ip";
+		local idna = require "prosody.util.encodings".idna;
+		local ip = require "prosody.util.ip";
 		local global = api("*");
 		local c2s_ports = global:get_option_set("c2s_ports", {5222});
 		local s2s_ports = global:get_option_set("s2s_ports", {5269});
@@ -861,7 +858,7 @@
 			end
 		end
 
-		local local_addresses = require"util.net".local_addresses() or {};
+		local local_addresses = require"prosody.util.net".local_addresses() or {};
 
 		for addr in it.values(local_addresses) do
 			if not ip.new_ip(addr).private then
@@ -1028,9 +1025,6 @@
 			end
 
 			local known_http_modules = set.new { "bosh"; "http_files"; "http_file_share"; "http_openmetrics"; "websocket" };
-			local function contains_match(hayset, needle)
-				for member in hayset do if member:find(needle) then return true end end
-			end
 
 			if modules:contains("http") or not set.intersection(modules, known_http_modules):empty()
 				or contains_match(modules, "^http_") or contains_match(modules, "_web$") then
@@ -1166,11 +1160,14 @@
 			ok = false;
 		end
 	end
-	if not what or what == "certs" then
+	function checks.certs()
 		local cert_ok;
 		print"Checking certificates..."
-		local x509_verify_identity = require"util.x509".verify_identity;
-		local create_context = require "core.certmanager".create_context;
+		local x509_verify_identity = require"prosody.util.x509".verify_identity;
+		local use_dane = configmanager.get("*", "use_dane");
+		local pem2der = require"prosody.util.x509".pem2der;
+		local sha256 = require"prosody.util.hashes".sha256;
+		local create_context = require "prosody.core.certmanager".create_context;
 		local ssl = dependencies.softreq"ssl";
 		-- local datetime_parse = require"util.datetime".parse_x509;
 		local load_cert = ssl and ssl.loadcertificate;
@@ -1183,13 +1180,14 @@
 			cert_ok = false
 		else
 			for host in it.filter(skip_bare_jid_hosts, enabled_hosts()) do
+				local modules = modulemanager.get_modules_for_host(host);
 				print("Checking certificate for "..host);
 				-- First, let's find out what certificate this host uses.
 				local host_ssl_config = configmanager.rawget(host, "ssl")
 					or configmanager.rawget(host:match("%.(.*)"), "ssl");
 				local global_ssl_config = configmanager.rawget("*", "ssl");
-				local ok, err, ssl_config = create_context(host, "server", host_ssl_config, global_ssl_config);
-				if not ok then
+				local ctx_ok, err, ssl_config = create_context(host, "server", host_ssl_config, global_ssl_config);
+				if not ctx_ok then
 					print("  Error: "..err);
 					cert_ok = false
 				elseif not ssl_config.certificate then
@@ -1224,17 +1222,39 @@
 						elseif not cert:validat(os.time() + 86400*31) then
 							print("    Certificate expires within one month.")
 						end
-						if select(2, modulemanager.get_modules_for_host(host)) == nil
-							and not x509_verify_identity(host, "_xmpp-client", cert) then
+						if modules:contains("c2s") and not x509_verify_identity(host, "_xmpp-client", cert) then
 							print("    Not valid for client connections to "..host..".")
 							cert_ok = false
 						end
-						if (not (api(host):get_option_boolean("anonymous_login", false)
-							or api(host):get_option_string("authentication", "internal_hashed") == "anonymous"))
-							and not x509_verify_identity(host, "_xmpp-server", cert) then
+						local anon = api(host):get_option_string("authentication", "internal_hashed") == "anonymous";
+						local anon_s2s = api(host):get_option_boolean("allow_anonymous_s2s", false);
+						if modules:contains("s2s") and (anon_s2s or not anon) and not x509_verify_identity(host, "_xmpp-server", cert) then
 							print("    Not valid for server-to-server connections to "..host..".")
 							cert_ok = false
 						end
+
+						local known_http_modules = set.new { "bosh"; "http_files"; "http_file_share"; "http_openmetrics"; "websocket" };
+						local http_loaded = modules:contains("http")
+							or not set.intersection(modules, known_http_modules):empty()
+							or contains_match(modules, "^http_")
+							or contains_match(modules, "_web$");
+
+						local http_host = api(host):get_option_string("http_host", host);
+						if api(host):get_option_string("http_external_url") then
+							-- Assumed behind a reverse proxy
+							http_loaded = false;
+						end
+						if http_loaded and not x509_verify_identity(http_host, nil, cert) then
+							print("    Not valid for HTTPS connections to "..host..".")
+							cert_ok = false
+						end
+						if use_dane then
+							if cert.pubkey then
+								print("    DANE: TLSA 3 1 1 "..sha256(pem2der(cert:pubkey()), true))
+							elseif cert.pem then
+								print("    DANE: TLSA 3 0 1 "..sha256(pem2der(cert:pem()), true))
+							end
+						end
 					end
 				end
 			end
@@ -1247,7 +1267,7 @@
 		print("")
 	end
 	-- intentionally not doing this by default
-	if what == "connectivity" then
+	function checks.connectivity()
 		local _, prosody_is_running = is_prosody_running();
 		if api("*"):get_option_string("pidfile") and not prosody_is_running then
 			print("Prosody does not appear to be running, which is required for this test.");
@@ -1339,7 +1359,7 @@
 		print("Note: It does not ensure that the check actually reaches this specific prosody instance.")
 	end
 
-	if not what or what == "turn" then
+	function checks.turn()
 		local turn_enabled_hosts = {};
 		local turn_services = {};
 
@@ -1414,6 +1434,26 @@
 			end
 		end
 	end
+	if what == nil or what == "all" then
+		local ret;
+		ret = checks.disabled();
+		if ret ~= nil then return ret; end
+		ret = checks.config();
+		if ret ~= nil then return ret; end
+		ret = checks.dns();
+		if ret ~= nil then return ret; end
+		ret = checks.certs();
+		if ret ~= nil then return ret; end
+		ret = checks.turn();
+		if ret ~= nil then return ret; end
+	elseif checks[what] then
+		local ret = checks[what]();
+		if ret ~= nil then return ret; end
+	else
+		show_warning("Don't know how to check '%s'. Try one of 'config', 'dns', 'certs', 'disabled', 'turn' or 'connectivity'.", what);
+		show_warning("Note: The connectivity check will connect to a remote server.");
+		return 1;
+	end
 
 	if not ok then
 		print("Problems found, see above.");
--- a/util/prosodyctl/shell.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/prosodyctl/shell.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,13 +1,15 @@
-local config = require "core.configmanager";
-local server = require "net.server";
-local st = require "util.stanza";
-local path = require "util.paths";
-local parse_args = require "util.argparse".parse;
-local unpack = table.unpack or _G.unpack;
+local config = require "prosody.core.configmanager";
+local server = require "prosody.net.server";
+local st = require "prosody.util.stanza";
+local path = require "prosody.util.paths";
+local parse_args = require "prosody.util.argparse".parse;
+local tc = require "prosody.util.termcolours";
+local isatty = require "prosody.util.pposix".isatty;
+local term_width = require"prosody.util.human.io".term_width;
 
 local have_readline, readline = pcall(require, "readline");
 
-local adminstream = require "util.adminstream";
+local adminstream = require "prosody.util.adminstream";
 
 if have_readline then
 	readline.set_readline_name("prosody");
@@ -27,7 +29,7 @@
 end
 
 local function send_line(client, line)
-	client.send(st.stanza("repl-input"):text(line));
+	client.send(st.stanza("repl-input", { width = tostring(term_width()) }):text(line));
 end
 
 local function repl(client)
@@ -64,6 +66,7 @@
 local function start(arg) --luacheck: ignore 212/arg
 	local client = adminstream.client();
 	local opts, err, where = parse_args(arg);
+	local ttyout = isatty(io.stdout);
 
 	if not opts then
 		if err == "param-not-found" then
@@ -76,24 +79,36 @@
 
 	if arg[1] then
 		if arg[2] then
-			-- prosodyctl shell module reload foo bar.com --> module:reload("foo", "bar.com")
-			-- COMPAT Lua 5.1 doesn't have the separator argument to string.rep
-			arg[1] = string.format("%s:%s("..string.rep("%q, ", #arg-2):sub(1, -3)..")", unpack(arg));
+			local fmt = { "%s"; ":%s("; ")" };
+			for i = 3, #arg do
+				if arg[i]:sub(1, 1) == ":" then
+					table.insert(fmt, i, ")%s(");
+				elseif i > 3 and fmt[i - 1] == "%q" then
+					table.insert(fmt, i, ", %q");
+				else
+					table.insert(fmt, i, "%q");
+				end
+			end
+			arg[1] = string.format(table.concat(fmt), table.unpack(arg));
 		end
 
 		client.events.add_handler("connected", function()
-			client.send(st.stanza("repl-input"):text(arg[1]));
+			send_line(client, arg[1]);
 			return true;
 		end, 1);
 
 		local errors = 0; -- TODO This is weird, but works for now.
 		client.events.add_handler("received", function(stanza)
 			if stanza.name == "repl-output" or stanza.name == "repl-result" then
+				local dest = io.stdout;
 				if stanza.attr.type == "error" then
 					errors = errors + 1;
-					io.stderr:write(stanza:get_text(), "\n");
+					dest = io.stderr;
+				end
+				if stanza.attr.eol == "0" then
+					dest:write(stanza:get_text());
 				else
-					print(stanza:get_text());
+					dest:write(stanza:get_text(), "\n");
 				end
 			end
 			if stanza.name == "repl-result" then
@@ -118,7 +133,11 @@
 	client.events.add_handler("received", function (stanza)
 		if stanza.name == "repl-output" or stanza.name == "repl-result" then
 			local result_prefix = stanza.attr.type == "error" and "!" or "|";
-			print(result_prefix.." "..stanza:get_text());
+			local out = result_prefix.." "..stanza:get_text();
+			if ttyout and stanza.attr.type == "error" then
+				out = tc.getstring(tc.getstyle("red"), out);
+			end
+			print(out);
 		end
 		if stanza.name == "repl-result" then
 			repl(client);
--- a/util/pubsub.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/pubsub.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,6 @@
-local events = require "util.events";
-local cache = require "util.cache";
-local errors = require "util.error";
+local events = require "prosody.util.events";
+local cache = require "prosody.util.cache";
+local errors = require "prosody.util.error";
 
 local service_mt = {};
 
--- a/util/queue.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/queue.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -9,7 +9,7 @@
 -- Small ringbuffer library (i.e. an efficient FIFO queue with a size limit)
 -- (because unbounded dynamically-growing queues are a bad thing...)
 
-local have_utable, utable = pcall(require, "util.table"); -- For pre-allocation of table
+local have_utable, utable = pcall(require, "prosody.util.table"); -- For pre-allocation of table
 
 local function new(size, allow_wrapping)
 	-- Head is next insert, tail is next read
--- a/util/random.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/random.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,7 +6,7 @@
 -- COPYING file in the source package for more information.
 --
 
-local ok, crand = pcall(require, "util.crand");
+local ok, crand = pcall(require, "prosody.util.crand");
 if ok and pcall(crand.bytes, 1) then return crand; end
 
 local urandom, urandom_err = io.open("/dev/urandom", "r");
--- a/util/rfc6724.lua	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,141 +0,0 @@
--- Prosody IM
--- Copyright (C) 2011-2013 Florian Zeitz
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
--- This is used to sort destination addresses by preference
--- during S2S connections.
--- We can't hand this off to getaddrinfo, since it blocks
-
-local ip_commonPrefixLength = require"util.ip".commonPrefixLength
-
-local function commonPrefixLength(ipA, ipB)
-	local len = ip_commonPrefixLength(ipA, ipB);
-	return len < 64 and len or 64;
-end
-
-local function t_sort(t, comp)
-	for i = 1, (#t - 1) do
-		for j = (i + 1), #t do
-			local a, b = t[i], t[j];
-			if not comp(a,b) then
-				t[i], t[j] = b, a;
-			end
-		end
-	end
-end
-
-local function source(dest, candidates)
-	local function comp(ipA, ipB)
-		-- Rule 1: Prefer same address
-		if dest == ipA then
-			return true;
-		elseif dest == ipB then
-			return false;
-		end
-
-		-- Rule 2: Prefer appropriate scope
-		if ipA.scope < ipB.scope then
-			if ipA.scope < dest.scope then
-				return false;
-			else
-				return true;
-			end
-		elseif ipA.scope > ipB.scope then
-			if ipB.scope < dest.scope then
-				return true;
-			else
-				return false;
-			end
-		end
-
-		-- Rule 3: Avoid deprecated addresses
-		-- XXX: No way to determine this
-		-- Rule 4: Prefer home addresses
-		-- XXX: Mobility Address related, no way to determine this
-		-- Rule 5: Prefer outgoing interface
-		-- XXX: Interface to address relation. No way to determine this
-		-- Rule 6: Prefer matching label
-		if ipA.label == dest.label and ipB.label ~= dest.label then
-			return true;
-		elseif ipB.label == dest.label and ipA.label ~= dest.label then
-			return false;
-		end
-
-		-- Rule 7: Prefer temporary addresses (over public ones)
-		-- XXX: No way to determine this
-		-- Rule 8: Use longest matching prefix
-		if commonPrefixLength(ipA, dest) > commonPrefixLength(ipB, dest) then
-			return true;
-		else
-			return false;
-		end
-	end
-
-	t_sort(candidates, comp);
-	return candidates[1];
-end
-
-local function destination(candidates, sources)
-	local sourceAddrs = {};
-	local function comp(ipA, ipB)
-		local ipAsource = sourceAddrs[ipA];
-		local ipBsource = sourceAddrs[ipB];
-		-- Rule 1: Avoid unusable destinations
-		-- XXX: No such information
-		-- Rule 2: Prefer matching scope
-		if ipA.scope == ipAsource.scope and ipB.scope ~= ipBsource.scope then
-			return true;
-		elseif ipA.scope ~= ipAsource.scope and ipB.scope == ipBsource.scope then
-			return false;
-		end
-
-		-- Rule 3: Avoid deprecated addresses
-		-- XXX: No way to determine this
-		-- Rule 4: Prefer home addresses
-		-- XXX: Mobility Address related, no way to determine this
-		-- Rule 5: Prefer matching label
-		if ipAsource.label == ipA.label and ipBsource.label ~= ipB.label then
-			return true;
-		elseif ipBsource.label == ipB.label and ipAsource.label ~= ipA.label then
-			return false;
-		end
-
-		-- Rule 6: Prefer higher precedence
-		if ipA.precedence > ipB.precedence then
-			return true;
-		elseif ipA.precedence < ipB.precedence then
-			return false;
-		end
-
-		-- Rule 7: Prefer native transport
-		-- XXX: No way to determine this
-		-- Rule 8: Prefer smaller scope
-		if ipA.scope < ipB.scope then
-			return true;
-		elseif ipA.scope > ipB.scope then
-			return false;
-		end
-
-		-- Rule 9: Use longest matching prefix
-		if commonPrefixLength(ipA, ipAsource) > commonPrefixLength(ipB, ipBsource) then
-			return true;
-		elseif commonPrefixLength(ipA, ipAsource) < commonPrefixLength(ipB, ipBsource) then
-			return false;
-		end
-
-		-- Rule 10: Otherwise, leave order unchanged
-		return true;
-	end
-	for _, ip in ipairs(candidates) do
-		sourceAddrs[ip] = source(ip, sources);
-	end
-
-	t_sort(candidates, comp);
-	return candidates;
-end
-
-return {source = source,
-	destination = destination};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/roles.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,123 @@
+local array = require "prosody.util.array";
+local it = require "prosody.util.iterators";
+local new_short_id = require "prosody.util.id".short;
+
+local role_methods = {};
+local role_mt = {
+	__index = role_methods;
+	__name = "role";
+	__add = nil;
+};
+
+local function is_role(o)
+	local mt = getmetatable(o);
+	return mt == role_mt;
+end
+
+local function _new_may(permissions, inherited_mays)
+	local n_inherited = inherited_mays and #inherited_mays;
+	return function (role, action, context)
+		-- Note: 'role' may be a descendent role, not only the one we're attached to
+		local policy = permissions[action];
+		if policy ~= nil then
+			return policy;
+		end
+		if n_inherited then
+			for i = 1, n_inherited do
+				policy = inherited_mays[i](role, action, context);
+				if policy ~= nil then
+					return policy;
+				end
+			end
+		end
+		return nil;
+	end
+end
+
+local permissions_key = {};
+
+-- {
+-- Required:
+--   name = "My fancy role";
+--
+-- Optional:
+--   inherits = { role_obj... }
+--   default = true
+--   priority = 100
+--   permissions = {
+--     ["foo"] = true; -- allow
+--     ["bar"] = false; -- deny
+--   }
+-- }
+local function new(base_config, overrides)
+	local config = setmetatable(overrides or {}, { __index = base_config });
+	local permissions = {};
+	local inherited_mays;
+	if config.inherits then
+		inherited_mays = array.pluck(config.inherits, "may");
+	end
+	local new_role = {
+		id = new_short_id();
+		name = config.name;
+		description = config.description;
+		default = config.default;
+		priority = config.priority;
+		may = _new_may(permissions, inherited_mays);
+		inherits = config.inherits;
+		[permissions_key] = permissions;
+	};
+	local desired_permissions = config.permissions or config[permissions_key];
+	for k, v in pairs(desired_permissions or {}) do
+		permissions[k] = v;
+	end
+	return setmetatable(new_role, role_mt);
+end
+
+function role_mt:__freeze()
+	local t = {
+		id = self.id;
+		name = self.name;
+		description = self.description;
+		default = self.default;
+		priority = self.priority;
+		inherits = self.inherits;
+		permissions = self[permissions_key];
+	};
+	return t;
+end
+
+function role_methods:clone(overrides)
+	return new(self, overrides);
+end
+
+function role_methods:set_permission(permission_name, policy, overwrite)
+	local permissions = self[permissions_key];
+	if overwrite ~= true and permissions[permission_name] ~= nil and permissions[permission_name] ~= policy then
+		return false, "policy-already-exists";
+	end
+	permissions[permission_name] = policy;
+	return true;
+end
+
+function role_methods:policies()
+	local policy_iterator, s, v = it.join(pairs(self[permissions_key]));
+	if self.inherits then
+		for _, inherited_role in ipairs(self.inherits) do
+			policy_iterator:append(inherited_role:policies());
+		end
+	end
+	return policy_iterator, s, v;
+end
+
+function role_mt.__tostring(self)
+	return ("role<[%s] %s>"):format(self.id or "nil", self.name or "[no name]");
+end
+
+function role_mt.__pairs(self)
+	return it.filter(permissions_key, next, self);
+end
+
+return {
+	is_role = is_role;
+	new = new;
+};
--- a/util/rsm.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/rsm.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -9,7 +9,7 @@
 -- XEP-0313: Message Archive Management for Prosody
 --
 
-local stanza = require"util.stanza".stanza;
+local stanza = require"prosody.util.stanza".stanza;
 local tonumber = tonumber;
 local s_format = string.format;
 local type = type;
--- a/util/sasl.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/sasl.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -47,7 +47,7 @@
 local backend_mechanism = {};
 local mechanism_channelbindings = {};
 
--- register a new SASL mechanisms
+-- register a new SASL mechanism
 local function registerMechanism(name, backends, f, cb_backends)
 	assert(type(name) == "string", "Parameter name MUST be a string.");
 	assert(type(backends) == "string" or type(backends) == "table", "Parameter backends MUST be either a string or a table.");
@@ -133,10 +133,11 @@
 end
 
 -- load the mechanisms
-require "util.sasl.plain"     .init(registerMechanism);
-require "util.sasl.anonymous" .init(registerMechanism);
-require "util.sasl.scram"     .init(registerMechanism);
-require "util.sasl.external"  .init(registerMechanism);
+require "prosody.util.sasl.plain"       .init(registerMechanism);
+require "prosody.util.sasl.anonymous"   .init(registerMechanism);
+require "prosody.util.sasl.oauthbearer" .init(registerMechanism);
+require "prosody.util.sasl.scram"       .init(registerMechanism);
+require "prosody.util.sasl.external"    .init(registerMechanism);
 
 return {
 	registerMechanism = registerMechanism;
--- a/util/sasl/anonymous.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/sasl/anonymous.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -12,7 +12,7 @@
 --    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 
-local generate_random_id = require "util.id".medium;
+local generate_random_id = require "prosody.util.id".medium;
 
 local _ENV = nil;
 -- luacheck: std none
@@ -33,8 +33,8 @@
 	local username;
 	repeat
 		username = generate_random_id():lower();
-	until self.profile.anonymous(self, username, self.realm);
-	self.username = username;
+		self.username = username;
+	until self.profile.anonymous(self, username, self.realm, message);
 	return "success"
 end
 
--- a/util/sasl/external.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/sasl/external.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,4 +1,4 @@
-local saslprep = require "util.encodings".stringprep.saslprep;
+local saslprep = require "prosody.util.encodings".stringprep.saslprep;
 
 local _ENV = nil;
 -- luacheck: std none
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/sasl/oauthbearer.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,62 @@
+local json = require "prosody.util.json";
+local _ENV = nil;
+
+
+local function oauthbearer(self, message)
+	if not message then
+		return "failure", "malformed-request";
+	end
+
+	if message == "\001" then
+		return "failure", "not-authorized";
+	end
+
+	-- gs2-header kvsep *kvpair kvsep
+	local gs2_header, kvpairs = message:match("^(n,[^,]*,)\001(.+)\001$");
+	if not gs2_header then
+		return "failure", "malformed-request";
+	end
+	local gs2_authzid = gs2_header:match("^[^,]*,a=([^,]*),$");
+
+	-- key "=" value kvsep
+	local auth_header;
+	for k, v in kvpairs:gmatch("([a-zA-Z]+)=([\033-\126 \009\r\n]*)\001") do
+		if k == "auth" then
+			auth_header = v;
+			break;
+		end
+	end
+
+	if not auth_header then
+		return "failure", "malformed-request";
+	end
+
+	local token = auth_header:match("^Bearer (.+)$");
+
+	local username, state, token_info = self.profile.oauthbearer(self, token, self.realm, gs2_authzid);
+
+	if state == false then
+		return "failure", "account-disabled";
+	elseif state == nil or not username then
+		-- For token-level errors, RFC 7628 demands use of a JSON-encoded
+		-- challenge response upon failure. We relay additional info from
+		-- the auth backend if available.
+		return "challenge", json.encode({
+			status = token_info and token_info.status or "invalid_token";
+			scope = token_info and token_info.scope or nil;
+			["openid-configuration"] = token_info and token_info.oidc_discovery_url or nil;
+		});
+	end
+	self.username = username;
+	self.token_info = token_info;
+
+	return "success";
+end
+
+local function init(registerMechanism)
+	registerMechanism("OAUTHBEARER", {"oauthbearer"}, oauthbearer);
+end
+
+return {
+	init = init;
+}
--- a/util/sasl/plain.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/sasl/plain.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -12,9 +12,9 @@
 --    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 local s_match = string.match;
-local saslprep = require "util.encodings".stringprep.saslprep;
-local nodeprep = require "util.encodings".stringprep.nodeprep;
-local log = require "util.logger".init("sasl");
+local saslprep = require "prosody.util.encodings".stringprep.saslprep;
+local nodeprep = require "prosody.util.encodings".stringprep.nodeprep;
+local log = require "prosody.util.logger".init("sasl");
 
 local _ENV = nil;
 -- luacheck: std none
@@ -69,10 +69,10 @@
 	local correct, state = false, false;
 	if self.profile.plain then
 		local correct_password;
-		correct_password, state = self.profile.plain(self, authentication, self.realm);
+		correct_password, state = self.profile.plain(self, authentication, self.realm, authorization);
 		correct = (saslprep(correct_password) == password);
 	elseif self.profile.plain_test then
-		correct, state = self.profile.plain_test(self, authentication, password, self.realm);
+		correct, state = self.profile.plain_test(self, authentication, password, self.realm, authorization);
 	end
 
 	if state == false then
--- a/util/sasl/scram.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/sasl/scram.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -13,13 +13,13 @@
 
 local s_match = string.match;
 local type = type
-local base64 = require "util.encodings".base64;
-local hashes = require "util.hashes";
-local generate_uuid = require "util.uuid".generate;
-local saslprep = require "util.encodings".stringprep.saslprep;
-local nodeprep = require "util.encodings".stringprep.nodeprep;
-local log = require "util.logger".init("sasl");
-local	binaryXOR = require "util.strbitop".sxor;
+local base64 = require "prosody.util.encodings".base64;
+local hashes = require "prosody.util.hashes";
+local generate_uuid = require "prosody.util.uuid".generate;
+local saslprep = require "prosody.util.encodings".stringprep.saslprep;
+local nodeprep = require "prosody.util.encodings".stringprep.nodeprep;
+local log = require "prosody.util.logger".init("sasl");
+local	binaryXOR = require "prosody.util.strbitop".sxor;
 
 local _ENV = nil;
 -- luacheck: std none
@@ -101,7 +101,6 @@
 			local client_first_message = message;
 
 			-- TODO: fail if authzid is provided, since we don't support them yet
-			-- luacheck: ignore 211/authzid
 			local gs2_header, gs2_cbind_flag, gs2_cbind_name, authzid, client_first_message_bare, username, clientnonce
 				= s_match(client_first_message, "^(([pny])=?([^,]*),([^,]*),)(m?=?[^,]*,?n=([^,]*),r=([^,]*),?.*)$");
 
@@ -112,8 +111,8 @@
 			if support_channel_binding and gs2_cbind_flag == "y" then
 				-- "y" -> client does support channel binding
 				--        but thinks the server does not.
-					return "failure", "malformed-request";
-				end
+				return "failure", "malformed-request";
+			end
 
 			if gs2_cbind_flag == "n" then
 				-- "n" -> client doesn't support channel binding.
@@ -144,7 +143,7 @@
 			-- retrieve credentials
 			local stored_key, server_key, salt, iteration_count;
 			if self.profile.plain then
-				local password, status = self.profile.plain(self, username, self.realm)
+				local password, status = self.profile.plain(self, username, self.realm, authzid)
 				if status == nil then return "failure", "not-authorized"
 				elseif status == false then return "failure", "account-disabled" end
 
@@ -165,7 +164,7 @@
 				end
 			elseif self.profile[profile_name] then
 				local status;
-				stored_key, server_key, iteration_count, salt, status = self.profile[profile_name](self, username, self.realm);
+				stored_key, server_key, iteration_count, salt, status = self.profile[profile_name](self, username, self.realm, authzid);
 				if status == nil then return "failure", "not-authorized"
 				elseif status == false then return "failure", "account-disabled" end
 			end
@@ -240,7 +239,7 @@
 		-- register channel binding equivalent
 		registerMechanism("SCRAM-"..hash_name.."-PLUS",
 			{"plain", "scram_"..(hashprep(hash_name))},
-			scram_gen(hash_name:lower(), hash, hmac_hash, get_auth_db, true), {"tls-unique"});
+			scram_gen(hash_name:lower(), hash, hmac_hash, get_auth_db, true), {"tls-unique", "tls-exporter"});
 	end
 
 	registerSCRAMMechanism("SHA-1", hashes.sha1, hashes.hmac_sha1, hashes.pbkdf2_hmac_sha1);
--- a/util/serialization.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/serialization.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -16,15 +16,17 @@
 local s_match = string.match;
 local t_concat = table.concat;
 
-local to_hex = require "util.hex".to;
+local to_hex = require "prosody.util.hex".to;
 
 local pcall = pcall;
-local envload = require"util.envload".envload;
+local envload = require"prosody.util.envload".envload;
+
+if not math.type then
+	require "prosody.util.mathcompat"
+end
 
 local pos_inf, neg_inf = math.huge, -math.huge;
-local m_type = math.type or function (n)
-	return n % 1 == 0 and n <= 9007199254740992 and n >= -9007199254740992 and "integer" or "float";
-end;
+local m_type = math.type;
 
 local function rawpairs(t)
 	return next, t, nil;
@@ -94,6 +96,10 @@
 		opt.itemlast = opt.itemlast or "";
 		opt.equals = opt.equals or "=";
 		opt.unquoted = true;
+	elseif opt.preset == "pretty" then
+		opt.fatal = false;
+		opt.freeze = true;
+		opt.unquoted = true;
 	end
 
 	local fallback = opt.fallback or opt.fatal == false and nonfatal_fallback or fatal_error;
--- a/util/session.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/session.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,10 +1,12 @@
-local initialize_filters = require "util.filters".initialize;
-local logger = require "util.logger";
+local initialize_filters = require "prosody.util.filters".initialize;
+local time = require "prosody.util.time";
+local logger = require "prosody.util.logger";
 
 local function new_session(typ)
 	local session = {
 		type = typ .. "_unauthed";
 		base_type = typ;
+		since = time.now();
 	};
 	return session;
 end
@@ -57,10 +59,16 @@
 	return session;
 end
 
+local function set_role(session, role)
+	session.role = role;
+end
+
 return {
 	new = new_session;
+
 	set_id = set_id;
 	set_logger = set_logger;
 	set_conn = set_conn;
 	set_send = set_send;
+	set_role = set_role;
 }
--- a/util/set.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/set.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -186,7 +186,7 @@
 	for item in pairs(items) do
 		s[#s+1] = tostring(item);
 	end
-	return t_concat(s, ", ");
+	return "{"..t_concat(s, ", ").."}";
 end
 
 return {
--- a/util/smqueue.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/smqueue.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,4 +1,4 @@
-local queue = require("util.queue");
+local queue = require("prosody.util.queue");
 
 local lib = { smqueue = {} }
 
--- a/util/sql.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/sql.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -4,9 +4,9 @@
 local tostring = tostring;
 local type = type;
 local assert, pcall, debug_traceback = assert, pcall, debug.traceback;
-local xpcall = require "util.xpcall".xpcall;
+local xpcall = require "prosody.util.xpcall".xpcall;
 local t_concat = table.concat;
-local log = require "util.logger".init("sql");
+local log = require "prosody.util.logger".init("sql");
 
 local DBI = require "DBI";
 -- This loads all available drivers while globals are unlocked
@@ -27,8 +27,6 @@
 local function is_index(x) return getmetatable(x)==index_mt; end
 local function is_table(x) return getmetatable(x)==table_mt; end
 local function is_query(x) return getmetatable(x)==query_mt; end
-local function Integer() return "Integer()" end
-local function String() return "String()" end
 
 local function Column(definition)
 	return setmetatable(definition, column_mt);
@@ -99,6 +97,9 @@
 function engine:onconnect() -- luacheck: ignore 212/self
 	-- Override from create_engine()
 end
+function engine:ondisconnect() -- luacheck: ignore 212/self
+	-- Override from create_engine()
+end
 
 function engine:prepquery(sql)
 	if self.params.driver == "MySQL" then
@@ -224,6 +225,7 @@
 		if not conn or not conn:ping() then
 			log("debug", "Database connection was closed. Will reconnect and retry.");
 			self.conn = nil;
+			self:ondisconnect();
 			log("debug", "Retrying SQL transaction [%s]", (...));
 			ok, ret, b, c = self:_transaction(...);
 			log("debug", "SQL transaction retry %s", ok and "succeeded" or "failed");
@@ -365,8 +367,8 @@
 	};
 end
 
-local function create_engine(_, params, onconnect)
-	return setmetatable({ url = db2uri(params), params = params, onconnect = onconnect }, engine_mt);
+local function create_engine(_, params, onconnect, ondisconnect)
+	return setmetatable({ url = db2uri(params); params = params; onconnect = onconnect; ondisconnect = ondisconnect }, engine_mt);
 end
 
 return {
@@ -374,8 +376,6 @@
 	is_index = is_index;
 	is_table = is_table;
 	is_query = is_query;
-	Integer = Integer;
-	String = String;
 	Column = Column;
 	Table = Table;
 	Index = Index;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/sqlite3.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -0,0 +1,370 @@
+
+local setmetatable, getmetatable = setmetatable, getmetatable;
+local ipairs, select = ipairs, select;
+local tostring = tostring;
+local assert, xpcall, debug_traceback = assert, xpcall, debug.traceback;
+local error = error
+local type = type
+local t_concat = table.concat;
+local array = require "prosody.util.array";
+local log = require "prosody.util.logger".init("sql");
+
+local lsqlite3 = require "lsqlite3";
+local build_url = require "socket.url".build;
+
+-- from sqlite3.h, no copyright claimed
+local sqlite_errors = require"prosody.util.error".init("util.sqlite3", {
+	-- FIXME xmpp error conditions?
+	[1] = { code = 1;     type = "modify";   condition = "ERROR";      text = "Generic error" };
+	[2] = { code = 2;     type = "cancel";   condition = "INTERNAL";   text = "Internal logic error in SQLite" };
+	[3] = { code = 3;     type = "auth";     condition = "PERM";       text = "Access permission denied" };
+	[4] = { code = 4;     type = "cancel";   condition = "ABORT";      text = "Callback routine requested an abort" };
+	[5] = { code = 5;     type = "wait";     condition = "BUSY";       text = "The database file is locked" };
+	[6] = { code = 6;     type = "wait";     condition = "LOCKED";     text = "A table in the database is locked" };
+	[7] = { code = 7;     type = "wait";     condition = "NOMEM";      text = "A malloc() failed" };
+	[8] = { code = 8;     type = "cancel";   condition = "READONLY";   text = "Attempt to write a readonly database" };
+	[9] = { code = 9;     type = "cancel";   condition = "INTERRUPT";  text = "Operation terminated by sqlite3_interrupt()" };
+	[10] = { code = 10;   type = "wait";     condition = "IOERR";      text = "Some kind of disk I/O error occurred" };
+	[11] = { code = 11;   type = "cancel";   condition = "CORRUPT";    text = "The database disk image is malformed" };
+	[12] = { code = 12;   type = "modify";   condition = "NOTFOUND";   text = "Unknown opcode in sqlite3_file_control()" };
+	[13] = { code = 13;   type = "wait";     condition = "FULL";       text = "Insertion failed because database is full" };
+	[14] = { code = 14;   type = "auth";     condition = "CANTOPEN";   text = "Unable to open the database file" };
+	[15] = { code = 15;   type = "cancel";   condition = "PROTOCOL";   text = "Database lock protocol error" };
+	[16] = { code = 16;   type = "continue"; condition = "EMPTY";      text = "Internal use only" };
+	[17] = { code = 17;   type = "modify";   condition = "SCHEMA";     text = "The database schema changed" };
+	[18] = { code = 18;   type = "modify";   condition = "TOOBIG";     text = "String or BLOB exceeds size limit" };
+	[19] = { code = 19;   type = "modify";   condition = "CONSTRAINT"; text = "Abort due to constraint violation" };
+	[20] = { code = 20;   type = "modify";   condition = "MISMATCH";   text = "Data type mismatch" };
+	[21] = { code = 21;   type = "modify";   condition = "MISUSE";     text = "Library used incorrectly" };
+	[22] = { code = 22;   type = "cancel";   condition = "NOLFS";      text = "Uses OS features not supported on host" };
+	[23] = { code = 23;   type = "auth";     condition = "AUTH";       text = "Authorization denied" };
+	[24] = { code = 24;   type = "modify";   condition = "FORMAT";     text = "Not used" };
+	[25] = { code = 25;   type = "modify";   condition = "RANGE";      text = "2nd parameter to sqlite3_bind out of range" };
+	[26] = { code = 26;   type = "cancel";   condition = "NOTADB";     text = "File opened that is not a database file" };
+	[27] = { code = 27;   type = "continue"; condition = "NOTICE";     text = "Notifications from sqlite3_log()" };
+	[28] = { code = 28;   type = "continue"; condition = "WARNING";    text = "Warnings from sqlite3_log()" };
+	[100] = { code = 100; type = "continue"; condition = "ROW";        text = "sqlite3_step() has another row ready" };
+	[101] = { code = 101; type = "continue"; condition = "DONE";       text = "sqlite3_step() has finished executing" };
+});
+
+-- luacheck: ignore 411/assert
+local assert = function(cond, errno, err)
+	return assert(sqlite_errors.coerce(cond, err or errno));
+end
+local _ENV = nil;
+-- luacheck: std none
+
+local column_mt = {};
+local table_mt = {};
+local query_mt = {};
+--local op_mt = {};
+local index_mt = {};
+
+local function is_column(x) return getmetatable(x)==column_mt; end
+local function is_index(x) return getmetatable(x)==index_mt; end
+local function is_table(x) return getmetatable(x)==table_mt; end
+local function is_query(x) return getmetatable(x)==query_mt; end
+
+local function Column(definition)
+	return setmetatable(definition, column_mt);
+end
+local function Table(definition)
+	local c = {}
+	for i,col in ipairs(definition) do
+		if is_column(col) then
+			c[i], c[col.name] = col, col;
+		elseif is_index(col) then
+			col.table = definition.name;
+		end
+	end
+	return setmetatable({ __table__ = definition, c = c, name = definition.name }, table_mt);
+end
+local function Index(definition)
+	return setmetatable(definition, index_mt);
+end
+
+function table_mt:__tostring()
+	local s = { 'name="'..self.__table__.name..'"' }
+	for _, col in ipairs(self.__table__) do
+		s[#s+1] = tostring(col);
+	end
+	return 'Table{ '..t_concat(s, ", ")..' }'
+end
+table_mt.__index = {};
+function table_mt.__index:create(engine)
+	return engine:_create_table(self);
+end
+function column_mt:__tostring()
+	return 'Column{ name="'..self.name..'", type="'..self.type..'" }'
+end
+function index_mt:__tostring()
+	local s = 'Index{ name="'..self.name..'"';
+	for i=1,#self do s = s..', "'..self[i]:gsub("[\\\"]", "\\%1")..'"'; end
+	return s..' }';
+--	return 'Index{ name="'..self.name..'", type="'..self.type..'" }'
+end
+
+local engine = {};
+function engine:connect()
+	if self.conn then return true; end
+
+	local params = self.params;
+	assert(params.driver == "SQLite3", "Only sqlite3 is supported");
+	local dbh, err = sqlite_errors.coerce(lsqlite3.open(params.database));
+	if not dbh then return nil, err; end
+	self.conn = dbh;
+	self.prepared = {};
+	local ok, err = self:set_encoding();
+	if not ok then
+		return ok, err;
+	end
+	local ok, err = self:onconnect();
+	if ok == false then
+		return ok, err;
+	end
+	return true;
+end
+function engine:onconnect() -- luacheck: ignore 212/self
+	-- Override from create_engine()
+end
+function engine:ondisconnect() -- luacheck: ignore 212/self
+	-- Override from create_engine()
+end
+
+function engine:execute(sql, ...)
+	local success, err = self:connect();
+	if not success then return success, err; end
+
+	if select('#', ...) == 0 then
+		local ret = self.conn:exec(sql);
+		if ret ~= lsqlite3.OK then
+			local err = sqlite_errors.new(err);
+			err.text = self.conn:errmsg();
+			return err;
+		end
+		return true;
+	end
+
+	local stmt, err = self.conn:prepare(sql);
+	if not stmt then
+		err = sqlite_errors.new(err);
+		err.text = self.conn:errmsg();
+		return stmt, err;
+	end
+
+	local ret = stmt:bind_values(...);
+	if ret ~= lsqlite3.OK then
+		return nil, sqlite_errors.new(ret, { message = self.conn:errmsg() });
+	end
+	return stmt;
+end
+
+local function iterator(table)
+	local i = 0;
+	return function()
+		i = i + 1;
+		local item = table[i];
+		if item ~= nil then
+			return item;
+		end
+	end
+end
+
+local result_mt = {
+	__len = function(self)
+		return self.__rowcount;
+	end;
+	__index = {
+		affected = function(self)
+			return self.__affected;
+		end;
+		rowcount = function(self)
+			return self.__rowcount;
+		end;
+	};
+	__call = function(self)
+		return iterator(self.__data);
+	end;
+};
+
+local function debugquery(where, sql, ...)
+	local i = 0; local a = {...}
+	sql = sql:gsub("\n?\t+", " ");
+	log("debug", "[%s] %s", where, (sql:gsub("%?", function ()
+		i = i + 1;
+		local v = a[i];
+		if type(v) == "string" then
+			v = ("'%s'"):format(v:gsub("'", "''"));
+		end
+		return tostring(v);
+	end)));
+end
+
+function engine:execute_update(sql, ...)
+	local prepared = self.prepared;
+	local stmt = prepared[sql];
+	if stmt and stmt:isopen() then
+		prepared[sql] = nil; -- Can't be used concurrently
+	else
+		stmt = assert(self.conn:prepare(sql));
+	end
+	local ret = stmt:bind_values(...);
+	if ret ~= lsqlite3.OK then error(self.conn:errmsg()); end
+	local data = array();
+	for row in stmt:rows() do
+		data:push(array(row));
+	end
+	-- FIXME Error handling, BUSY, ERROR, MISUSE
+	if stmt:reset() == lsqlite3.OK then
+		prepared[sql] = stmt;
+	end
+	local affected = self.conn:changes();
+	return setmetatable({ __affected = affected; __rowcount = #data; __data = data }, result_mt);
+end
+
+function engine:execute_query(sql, ...)
+	return self:execute_update(sql, ...)()
+end
+
+engine.insert = engine.execute_update;
+engine.select = engine.execute_query;
+engine.delete = engine.execute_update;
+engine.update = engine.execute_update;
+local function debugwrap(name, f)
+	return function (self, sql, ...)
+		debugquery(name, sql, ...)
+		return f(self, sql, ...)
+	end
+end
+function engine:debug(enable)
+	self._debug = enable;
+	if enable then
+		engine.insert = debugwrap("insert", engine.execute_update);
+		engine.select = debugwrap("select", engine.execute_query);
+		engine.delete = debugwrap("delete", engine.execute_update);
+		engine.update = debugwrap("update", engine.execute_update);
+	else
+		engine.insert = engine.execute_update;
+		engine.select = engine.execute_query;
+		engine.delete = engine.execute_update;
+		engine.update = engine.execute_update;
+	end
+end
+function engine:_(word)
+	local ret = self.conn:exec(word);
+	if ret ~= lsqlite3.OK then return nil, self.conn:errmsg(); end
+	return true;
+end
+function engine:_transaction(func, ...)
+	if not self.conn then
+		local a,b = self:connect();
+		if not a then return a,b; end
+	end
+	--assert(not self.__transaction, "Recursive transactions not allowed");
+	local ok, err = self:_"BEGIN";
+	if not ok then return ok, err; end
+	self.__transaction = true;
+	local success, a, b, c = xpcall(func, debug_traceback, ...);
+	self.__transaction = nil;
+	if success then
+		log("debug", "SQL transaction success [%s]", tostring(func));
+		local ok, err = self:_"COMMIT";
+		if not ok then return ok, err; end -- commit failed
+		return success, a, b, c;
+	else
+		log("debug", "SQL transaction failure [%s]: %s", tostring(func), a);
+		if self.conn then self:_"ROLLBACK"; end
+		return success, a;
+	end
+end
+function engine:transaction(...)
+	local ok, ret = self:_transaction(...);
+	if not ok then
+		local conn = self.conn;
+		if not conn or not conn:isopen() then
+			self.conn = nil;
+			self:ondisconnect();
+			ok, ret = self:_transaction(...);
+		end
+	end
+	return ok, ret;
+end
+function engine:_create_index(index)
+	local sql = "CREATE INDEX IF NOT EXISTS \""..index.name.."\" ON \""..index.table.."\" (";
+	for i=1,#index do
+		sql = sql.."\""..index[i].."\"";
+		if i ~= #index then sql = sql..", "; end
+	end
+	sql = sql..");"
+	if index.unique then
+		sql = sql:gsub("^CREATE", "CREATE UNIQUE");
+	end
+	if self._debug then
+		debugquery("create", sql);
+	end
+	return self:execute(sql);
+end
+function engine:_create_table(table)
+	local sql = "CREATE TABLE IF NOT EXISTS \""..table.name.."\" (";
+	for i,col in ipairs(table.c) do
+		local col_type = col.type;
+		sql = sql.."\""..col.name.."\" "..col_type;
+		if col.nullable == false then sql = sql.." NOT NULL"; end
+		if col.primary_key == true then sql = sql.." PRIMARY KEY"; end
+		if col.auto_increment == true then
+			sql = sql.." AUTOINCREMENT";
+		end
+		if i ~= #table.c then sql = sql..", "; end
+	end
+	sql = sql.. ");"
+	if self._debug then
+		debugquery("create", sql);
+	end
+	local success,err = self:execute(sql);
+	if not success then return success,err; end
+	for _, v in ipairs(table.__table__) do
+		if is_index(v) then
+			self:_create_index(v);
+		end
+	end
+	return success;
+end
+
+function engine:set_encoding() -- to UTF-8
+	return self:transaction(function()
+		for encoding in self:select "PRAGMA encoding;" do
+			if encoding[1] == "UTF-8" then
+				self.charset = "utf8";
+			end
+		end
+	end);
+end
+local engine_mt = { __index = engine };
+
+local function db2uri(params)
+	return build_url{
+		scheme = params.driver,
+		user = params.username,
+		password = params.password,
+		host = params.host,
+		port = params.port,
+		path = params.database,
+	};
+end
+
+local function create_engine(_, params, onconnect, ondisconnect)
+	assert(params.driver == "SQLite3", "Only SQLite3 is supported without LuaDBI");
+	return setmetatable({ url = db2uri(params); params = params; onconnect = onconnect; ondisconnect = ondisconnect }, engine_mt);
+end
+
+return {
+	is_column = is_column;
+	is_index = is_index;
+	is_table = is_table;
+	is_query = is_query;
+	Column = Column;
+	Table = Table;
+	Index = Index;
+	create_engine = create_engine;
+	db2uri = db2uri;
+};
--- a/util/sslconfig.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/sslconfig.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -3,9 +3,12 @@
 local type = type;
 local pairs = pairs;
 local rawset = rawset;
+local rawget = rawget;
+local error = error;
 local t_concat = table.concat;
 local t_insert = table.insert;
 local setmetatable = setmetatable;
+local resolve_path = require"prosody.util.paths".resolve_relative_path;
 
 local _ENV = nil;
 -- luacheck: std none
@@ -34,7 +37,7 @@
 			options[value] = true;
 		end
 	end
-	config[field] = options;
+	rawset(config, field, options)
 end
 
 handlers.verifyext = handlers.options;
@@ -70,6 +73,20 @@
 -- TLS 1.3 ciphers
 finalisers.ciphersuites = finalisers.ciphers;
 
+-- Path expansion
+function finalisers.key(path, config)
+	if type(path) == "string" then
+		return resolve_path(config._basedir, path);
+	else
+		return nil
+	end
+end
+finalisers.certificate = finalisers.key;
+finalisers.cafile = finalisers.key;
+finalisers.capath = finalisers.key;
+-- XXX: copied from core/certmanager.lua, but this seems odd, because it would remove a dhparam function from the config
+finalisers.dhparam = finalisers.key;
+
 -- protocol = "x" should enable only that protocol
 -- protocol = "x+" should enable x and later versions
 
@@ -89,37 +106,81 @@
 
 -- Merge options from 'new' config into 'config'
 local function apply(config, new)
+	rawset(config, "_cache", nil);
 	if type(new) == "table" then
 		for field, value in pairs(new) do
-			(handlers[field] or rawset)(config, field, value);
+			-- exclude keys which are internal to the config builder
+			if field:sub(1, 1) ~= "_" then
+				(handlers[field] or rawset)(config, field, value);
+			end
 		end
 	end
+	return config
 end
 
 -- Finalize the config into the form LuaSec expects
 local function final(config)
 	local output = { };
 	for field, value in pairs(config) do
-		output[field] = (finalisers[field] or id)(value);
+		-- exclude keys which are internal to the config builder
+		if field:sub(1, 1) ~= "_" then
+			output[field] = (finalisers[field] or id)(value, config);
+		end
 	end
 	-- Need to handle protocols last because it adds to the options list
 	protocol(output);
 	return output;
 end
 
+local function build(config)
+	local cached = rawget(config, "_cache");
+	if cached then
+		return cached, nil
+	end
+
+	local ctx, err = rawget(config, "_context_factory")(config:final(), config);
+	if ctx then
+		rawset(config, "_cache", ctx);
+	end
+	return ctx, err
+end
+
 local sslopts_mt = {
 	__index = {
 		apply = apply;
 		final = final;
+		build = build;
 	};
+	__newindex = function()
+		error("SSL config objects cannot be modified directly. Use :apply()")
+	end;
 };
 
-local function new()
-	return setmetatable({options={}}, sslopts_mt);
+
+-- passing basedir through everything is required to avoid sslconfig depending
+-- on prosody.paths.config
+local function new(context_factory, basedir)
+	return setmetatable({
+		_context_factory = context_factory,
+		_basedir = basedir,
+		options={},
+	}, sslopts_mt);
 end
 
+local function clone(config)
+	local result = new();
+	for k, v in pairs(config) do
+		-- note that we *do* copy the internal keys on clone -- we have to carry
+		-- both the factory and the cache with us
+		rawset(result, k, v);
+	end
+	return result
+end
+
+sslopts_mt.__index.clone = clone;
+
 return {
 	apply = apply;
 	final = final;
-	new = new;
+	_new = new;
 };
--- a/util/stanza.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/stanza.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -21,12 +21,15 @@
 local s_gsub        =   string.gsub;
 local s_sub         =    string.sub;
 local s_find        =   string.find;
+local t_move        =    table.move or require "prosody.util.table".move;
+local t_create = require"prosody.util.table".create;
 
-local valid_utf8 = require "util.encodings".utf8.valid;
+local valid_utf8 = require "prosody.util.encodings".utf8.valid;
 
-local do_pretty_printing, termcolours = pcall(require, "util.termcolours");
+local do_pretty_printing, termcolours = pcall(require, "prosody.util.termcolours");
 
 local xmlns_stanzas = "urn:ietf:params:xml:ns:xmpp-stanzas";
+local xmpp_stanzas_attr = { xmlns = xmlns_stanzas };
 
 local _ENV = nil;
 -- luacheck: std none
@@ -179,6 +182,14 @@
 	return nil;
 end
 
+function stanza_mt:get_child_attr(name, xmlns, attr)
+	local tag = self:get_child(name, xmlns);
+	if tag then
+		return tag.attr[attr];
+	end
+	return nil;
+end
+
 function stanza_mt:child_with_name(name)
 	for _, child in ipairs(self.tags) do
 		if child.name == name then return child; end
@@ -283,25 +294,33 @@
 end
 
 local function _clone(stanza, only_top)
-	local attr, tags = {}, {};
+	local attr = {};
 	for k,v in pairs(stanza.attr) do attr[k] = v; end
 	local old_namespaces, namespaces = stanza.namespaces;
 	if old_namespaces then
 		namespaces = {};
 		for k,v in pairs(old_namespaces) do namespaces[k] = v; end
 	end
-	local new = { name = stanza.name, attr = attr, namespaces = namespaces, tags = tags };
+	local tags, new;
+	if only_top then
+		tags = {};
+		new = { name = stanza.name, attr = attr, namespaces = namespaces, tags = tags };
+	else
+		tags = t_create(#stanza.tags, 0);
+		new = t_create(#stanza, 4);
+		new.name = stanza.name;
+		new.attr = attr;
+		new.namespaces = namespaces;
+		new.tags = tags;
+	end
+
+	setmetatable(new, stanza_mt);
 	if not only_top then
-		for i=1,#stanza do
-			local child = stanza[i];
-			if child.name then
-				child = _clone(child);
-				t_insert(tags, child);
-			end
-			t_insert(new, child);
-		end
+		t_move(stanza, 1, #stanza, 1, new);
+		t_move(stanza.tags, 1, #stanza.tags, 1, tags);
+		new:maptags(_clone);
 	end
-	return setmetatable(new, stanza_mt);
+	return new;
 end
 
 local function clone(stanza, only_top)
@@ -387,6 +406,33 @@
 	return error_type, condition or "undefined-condition", text, extra_tag;
 end
 
+function stanza_mt.add_error(stanza, error_type, condition, error_message, error_by)
+	local extra;
+	if type(error_type) == "table" then -- an util.error or similar object
+		if type(error_type.extra) == "table" then
+			extra = error_type.extra;
+		end
+		if type(error_type.context) == "table" and type(error_type.context.by) == "string" then error_by = error_type.context.by; end
+		error_type, condition, error_message = error_type.type, error_type.condition, error_type.text;
+	end
+	if stanza.attr.from == error_by then
+		error_by = nil;
+	end
+	stanza:tag("error", {type = error_type, by = error_by}) --COMPAT: Some day xmlns:stanzas goes here
+	:tag(condition, xmpp_stanzas_attr);
+	if extra and condition == "gone" and type(extra.uri) == "string" then
+		stanza:text(extra.uri);
+	end
+	stanza:up();
+	if error_message then stanza:text_tag("text", error_message, xmpp_stanzas_attr); end
+	if extra and is_stanza(extra.tag) then
+		stanza:add_child(extra.tag);
+	elseif extra and extra.namespace and extra.condition then
+		stanza:tag(extra.condition, { xmlns = extra.namespace }):up();
+	end
+	return stanza:up();
+end
+
 local function preserialize(stanza)
 	local s = { name = stanza.name, attr = stanza.attr };
 	for _, child in ipairs(stanza) do
@@ -461,7 +507,6 @@
 		});
 end
 
-local xmpp_stanzas_attr = { xmlns = xmlns_stanzas };
 local function error_reply(orig, error_type, condition, error_message, error_by)
 	if not is_stanza(orig) then
 		error("bad argument to error_reply: expected stanza, got "..type(orig));
@@ -470,30 +515,9 @@
 	end
 	local t = reply(orig);
 	t.attr.type = "error";
-	local extra;
-	if type(error_type) == "table" then -- an util.error or similar object
-		if type(error_type.extra) == "table" then
-			extra = error_type.extra;
-		end
-		if type(error_type.context) == "table" and type(error_type.context.by) == "string" then error_by = error_type.context.by; end
-		error_type, condition, error_message = error_type.type, error_type.condition, error_type.text;
-	end
-	if t.attr.from == error_by then
-		error_by = nil;
-	end
-	t:tag("error", {type = error_type, by = error_by}) --COMPAT: Some day xmlns:stanzas goes here
-	:tag(condition, xmpp_stanzas_attr);
-	if extra and condition == "gone" and type(extra.uri) == "string" then
-		t:text(extra.uri);
-	end
-	t:up();
-	if error_message then t:text_tag("text", error_message, xmpp_stanzas_attr); end
-	if extra and is_stanza(extra.tag) then
-		t:add_child(extra.tag);
-	elseif extra and extra.namespace and extra.condition then
-		t:tag(extra.condition, { xmlns = extra.namespace }):up();
-	end
-	return t; -- stanza ready for adding app-specific errors
+	t:add_error(error_type, condition, error_message, error_by);
+	t.last_add = { t[1] }; -- ready to add application-specific errors
+	return t;
 end
 
 local function presence(attr)
--- a/util/startup.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/startup.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -2,15 +2,15 @@
 -- luacheck: ignore 113/CFG_CONFIGDIR 113/CFG_SOURCEDIR 113/CFG_DATADIR 113/CFG_PLUGINDIR
 local startup = {};
 
-local prosody = { events = require "util.events".new() };
-local logger = require "util.logger";
+local prosody = { events = require "prosody.util.events".new() };
+local logger = require "prosody.util.logger";
 local log = logger.init("startup");
-local parse_args = require "util.argparse".parse;
+local parse_args = require "prosody.util.argparse".parse;
 
-local config = require "core.configmanager";
+local config = require "prosody.core.configmanager";
 local config_warnings;
 
-local dependencies = require "util.dependencies";
+local dependencies = require "prosody.util.dependencies";
 
 local original_logging_config;
 
@@ -132,14 +132,14 @@
 function startup.load_libraries()
 	-- Load socket framework
 	-- luacheck: ignore 111/server 111/socket
-	require "util.import"
+	require "prosody.util.import"
 	socket = require "socket";
-	server = require "net.server"
+	server = require "prosody.net.server"
 end
 
 function startup.init_logging()
 	-- Initialize logging
-	local loggingmanager = require "core.loggingmanager"
+	local loggingmanager = require "prosody.core.loggingmanager"
 	loggingmanager.reload_logging();
 	prosody.events.add_handler("config-reloaded", function ()
 		prosody.events.fire_event("reopen-log-files");
@@ -233,7 +233,7 @@
 		if info.isvararg then
 			info[n_params+1] = "...";
 		end
-		return ("function<%s:%d>(%s)"):format(info.short_src:match("[^\\/]*$"), info.linedefined, table.concat(info, ", "));
+		return ("function @%s:%d(%s)"):format(info.short_src:match("[^\\/]*$"), info.linedefined, table.concat(info, ", "));
 	end
 	debug.setmetatable(function() end, mt);
 end
@@ -277,6 +277,11 @@
 	startup.detect_platform();
 	startup.detect_installed();
 	_G.prosody = prosody;
+
+	-- COMPAT Lua < 5.3
+	if not math.type then
+		require "prosody.util.mathcompat"
+	end
 end
 
 function startup.setup_datadir()
@@ -298,7 +303,7 @@
 	local installer_plugin_path = config.get("*", "installer_plugin_path") or "custom_plugins";
 	local path_sep = package.config:sub(3,3);
 	installer_plugin_path = config.resolve_relative_path(CFG_DATADIR or "data", installer_plugin_path);
-	require"util.paths".complement_lua_path(installer_plugin_path);
+	require"prosody.util.paths".complement_lua_path(installer_plugin_path);
 	-- luacheck: ignore 111
 	CFG_PLUGINDIR = installer_plugin_path..path_sep..(CFG_PLUGINDIR or "plugins");
 	prosody.paths.installer = installer_plugin_path;
@@ -360,28 +365,23 @@
 
 function startup.load_secondary_libraries()
 	--- Load and initialise core modules
-	require "util.xmppstream"
-	require "core.stanza_router"
-	require "core.statsmanager"
-	require "core.hostmanager"
-	require "core.portmanager"
-	require "core.modulemanager"
-	require "core.usermanager"
-	require "core.rostermanager"
-	require "core.sessionmanager"
-	package.loaded['core.componentmanager'] = setmetatable({},{__index=function()
-		-- COMPAT which version?
-		log("warn", "componentmanager is deprecated: %s", debug.traceback():match("\n[^\n]*\n[ \t]*([^\n]*)"));
-		return function() end
-	end});
+	require "prosody.util.xmppstream"
+	require "prosody.core.stanza_router"
+	require "prosody.core.statsmanager".metric("gauge", "prosody_info", "", "Prosody version", { "version" }):with_labels(prosody.version):set(1);
+	require "prosody.core.hostmanager"
+	require "prosody.core.portmanager"
+	require "prosody.core.modulemanager"
+	require "prosody.core.usermanager"
+	require "prosody.core.rostermanager"
+	require "prosody.core.sessionmanager"
 
-	require "util.array"
-	require "util.datetime"
-	require "util.iterators"
-	require "util.timer"
-	require "util.helpers"
+	require "prosody.util.array"
+	require "prosody.util.datetime"
+	require "prosody.util.iterators"
+	require "prosody.util.timer"
+	require "prosody.util.helpers"
 
-	pcall(require, "util.signal") -- Not on Windows
+	pcall(require, "prosody.util.signal") -- Not on Windows
 
 	-- Commented to protect us from
 	-- the second kind of people
@@ -390,43 +390,83 @@
 	if remdebug then remdebug.engine.start() end
 	]]
 
-	require "util.stanza"
-	require "util.jid"
+	require "prosody.util.stanza"
+	require "prosody.util.jid"
+
+	prosody.features = require "prosody.core.features".available;
 end
 
 function startup.init_http_client()
-	local http = require "net.http"
+	local http = require "prosody.net.http"
 	local config_ssl = config.get("*", "ssl") or {}
 	local https_client = config.get("*", "client_https_ssl")
-	http.default.options.sslctx = require "core.certmanager".create_context("client_https port 0", "client",
+	http.default.options.sslctx = require "prosody.core.certmanager".create_context("client_https port 0", "client",
 		{ capath = config_ssl.capath, cafile = config_ssl.cafile, verify = "peer", }, https_client);
 	http.default.options.use_dane = config.get("*", "use_dane")
 end
 
 function startup.init_promise()
-	local promise = require "util.promise";
+	local promise = require "prosody.util.promise";
 
-	local timer = require "util.timer";
+	local timer = require "prosody.util.timer";
 	promise.set_nexttick(function(f) return timer.add_task(0, f); end);
 end
 
 function startup.init_async()
-	local async = require "util.async";
+	local async = require "prosody.util.async";
 
-	local timer = require "util.timer";
+	local timer = require "prosody.util.timer";
 	async.set_nexttick(function(f) return timer.add_task(0, f); end);
 	async.set_schedule_function(timer.add_task);
 end
 
 function startup.init_data_store()
-	require "core.storagemanager";
+	require "prosody.core.storagemanager";
 end
 
+local running_state = require "prosody.util.fsm".new({
+	default_state = "uninitialized";
+	transitions = {
+		{ name = "begin_startup",   from = "uninitialized",           to = "starting" };
+		{ name = "finish_startup",  from = "starting",                to = "running" };
+		{ name = "begin_shutdown",  from = { "running", "starting" }, to = "stopping" };
+		{ name = "finish_shutdown", from = "stopping",                to = "stopped" };
+	};
+	handlers = {
+		transitioned = function (transition)
+			prosody.state = transition.to;
+		end;
+	};
+	state_handlers = {
+		starting = function ()
+			prosody.log("debug", "Firing server-starting event");
+			prosody.events.fire_event("server-starting");
+			prosody.start_time = os.time();
+		end;
+		running = function ()
+			prosody.log("debug", "Startup complete, firing server-started");
+			prosody.events.fire_event("server-started");
+		end;
+	};
+}):init();
+
 function startup.prepare_to_start()
 	log("info", "Prosody is using the %s backend for connection handling", server.get_backend());
+
 	-- Signal to modules that we are ready to start
-	prosody.events.fire_event("server-starting");
-	prosody.start_time = os.time();
+	prosody.started = require "prosody.util.promise".new(function (resolve)
+		if prosody.state == "running" then
+			resolve();
+		else
+			prosody.events.add_handler("server-started", function ()
+				resolve();
+			end);
+		end
+	end):catch(function (err)
+		prosody.log("error", "Prosody startup error: %s", err);
+	end);
+
+	running_state:begin_startup();
 end
 
 function startup.init_global_protection()
@@ -460,7 +500,7 @@
 			prosody.version = "hg:"..prosody.version;
 		end
 	else
-		local hg = require"util.mercurial";
+		local hg = require"prosody.util.mercurial";
 		local hgid = hg.check_id(CFG_SOURCEDIR or ".");
 		if hgid then prosody.version = "hg:" .. hgid; end
 	end
@@ -471,7 +511,7 @@
 end
 
 function startup.notify_started()
-	prosody.events.fire_event("server-started");
+	running_state:finish_startup();
 end
 
 -- Override logging config (used by prosodyctl)
@@ -491,21 +531,30 @@
 	config.set("*", "log", { { levels = { min = log_level or "info" }, to = "console" } });
 end
 
+local function check_posix()
+	if prosody.platform ~= "posix" then return end
+
+	local want_pposix_version = "0.4.0";
+	local have_pposix, pposix = pcall(require, "prosody.util.pposix");
+
+	if pposix._VERSION ~= want_pposix_version then
+		print(string.format("Unknown version (%s) of binary pposix module, expected %s",
+			tostring(pposix._VERSION), want_pposix_version));
+		os.exit(1);
+	end
+	if have_pposix and pposix then
+		return pposix;
+	end
+end
+
 function startup.switch_user()
 	-- Switch away from root and into the prosody user --
 	-- NOTE: This function is only used by prosodyctl.
 	-- The prosody process is built with the assumption that
 	-- it is already started as the appropriate user.
 
-	local want_pposix_version = "0.4.0";
-	local have_pposix, pposix = pcall(require, "util.pposix");
-
-	if have_pposix and pposix then
-		if pposix._VERSION ~= want_pposix_version then
-			print(string.format("Unknown version (%s) of binary pposix module, expected %s",
-				tostring(pposix._VERSION), want_pposix_version));
-			os.exit(1);
-		end
+	local pposix = check_posix()
+	if pposix then
 		prosody.current_uid = pposix.getuid();
 		local arg_root = prosody.opts.root;
 		if prosody.current_uid == 0 and config.get("*", "run_as_root") ~= true and not arg_root then
@@ -594,7 +643,7 @@
 
 function startup.init_gc()
 	-- Apply garbage collector settings from the config file
-	local gc = require "util.gc";
+	local gc = require "prosody.util.gc";
 	local gc_settings = config.get("*", "gc") or { mode = default_gc_params.mode };
 
 	local ok, err = gc.configure(gc_settings, default_gc_params);
@@ -606,7 +655,7 @@
 end
 
 function startup.init_errors()
-	require "util.error".configure(config.get("*", "error_library") or {});
+	require "prosody.util.error".configure(config.get("*", "error_library") or {});
 end
 
 function startup.make_host(hostname)
@@ -615,7 +664,7 @@
 		events = prosody.events,
 		modules = {},
 		sessions = {},
-		users = require "core.usermanager".new_null_provider(hostname)
+		users = require "prosody.core.usermanager".new_null_provider(hostname)
 	};
 end
 
@@ -631,17 +680,150 @@
 	end
 end
 
+function startup.posix_umask()
+	if prosody.platform ~= "posix" then return end
+	local pposix = require "prosody.util.pposix";
+	local umask = config.get("*", "umask") or "027";
+	pposix.umask(umask);
+end
+
+function startup.check_user()
+	local pposix = check_posix();
+	if not pposix then return end
+	-- Don't even think about it!
+	if pposix.getuid() == 0 and not config.get("*", "run_as_root") then
+		print("Danger, Will Robinson! Prosody doesn't need to be run as root, so don't do it!");
+		print("For more information on running Prosody as root, see https://prosody.im/doc/root");
+		os.exit(1); -- Refusing to run as root
+	end
+end
+
+local function remove_pidfile()
+	local pidfile = prosody.pidfile;
+	if prosody.pidfile_handle then
+		prosody.pidfile_handle:close();
+		os.remove(pidfile);
+		prosody.pidfile, prosody.pidfile_handle = nil, nil;
+	end
+end
+
+function startup.write_pidfile()
+	local pposix = check_posix();
+	if not pposix then return end
+	local lfs = require "lfs";
+	local stat = lfs.attributes;
+	local pidfile = config.get("*", "pidfile") or nil;
+	if not pidfile then return end
+	pidfile = config.resolve_relative_path(prosody.paths.data, pidfile);
+	local mode = stat(pidfile) and "r+" or "w+";
+	local pidfile_handle, err = io.open(pidfile, mode);
+	if not pidfile_handle then
+		log("error", "Couldn't write pidfile at %s; %s", pidfile, err);
+		prosody.shutdown("Couldn't write pidfile", 1);
+	else
+		prosody.pidfile = pidfile;
+		if not lfs.lock(pidfile_handle, "w") then -- Exclusive lock
+			local other_pid = pidfile_handle:read("*a");
+			log("error", "Another Prosody instance seems to be running with PID %s, quitting", other_pid);
+			prosody.pidfile_handle = nil;
+			prosody.shutdown("Prosody already running", 1);
+		else
+			pidfile_handle:close();
+			pidfile_handle, err = io.open(pidfile, "w+");
+			if not pidfile_handle then
+				log("error", "Couldn't write pidfile at %s; %s", pidfile, err);
+				prosody.shutdown("Couldn't write pidfile", 1);
+			else
+				if lfs.lock(pidfile_handle, "w") then
+					pidfile_handle:write(tostring(pposix.getpid()));
+					pidfile_handle:flush();
+					prosody.pidfile_handle = pidfile_handle;
+				end
+			end
+		end
+	end
+	prosody.events.add_handler("server-stopped", remove_pidfile);
+end
+
+local function remove_log_sinks()
+	local lm = require "prosody.core.loggingmanager";
+	lm.register_sink_type("console", nil);
+	lm.register_sink_type("stdout", nil);
+	lm.reload_logging();
+end
+
+function startup.posix_daemonize()
+	if not prosody.opts.daemonize then return end
+	local pposix = check_posix();
+	log("info", "Prosody is about to detach from the console, disabling further console output");
+	remove_log_sinks();
+	local ok, ret = pposix.daemonize();
+	if not ok then
+		log("error", "Failed to daemonize: %s", ret);
+	elseif ret and ret > 0 then
+		os.exit(0);
+	else
+		log("info", "Successfully daemonized to PID %d", pposix.getpid());
+	end
+end
+
+function startup.hook_posix_signals()
+	if prosody.platform ~= "posix" then return end
+	local have_signal, signal = pcall(require, "prosody.util.signal");
+	if not have_signal then
+		log("warn", "Couldn't load signal library, won't respond to SIGTERM");
+		return
+	end
+	signal.signal("SIGTERM", function()
+		log("warn", "Received SIGTERM");
+		prosody.main_thread:run(function()
+			prosody.unlock_globals();
+			prosody.shutdown("Received SIGTERM");
+			prosody.lock_globals();
+		end);
+	end);
+
+	signal.signal("SIGHUP", function()
+		log("info", "Received SIGHUP");
+		prosody.main_thread:run(function() prosody.reload_config(); end);
+		-- this also reloads logging
+	end);
+
+	signal.signal("SIGINT", function()
+		log("info", "Received SIGINT");
+		prosody.main_thread:run(function()
+			prosody.unlock_globals();
+			prosody.shutdown("Received SIGINT");
+			prosody.lock_globals();
+		end);
+	end);
+
+	signal.signal("SIGUSR1", function()
+		log("info", "Received SIGUSR1");
+		prosody.events.fire_event("signal/SIGUSR1");
+	end);
+
+	signal.signal("SIGUSR2", function()
+		log("info", "Received SIGUSR2");
+		prosody.events.fire_event("signal/SIGUSR2");
+	end);
+end
+
 function startup.cleanup()
 	prosody.log("info", "Shutdown status: Cleaning up");
 	prosody.events.fire_event("server-cleanup");
 end
 
 function startup.shutdown()
+	running_state:begin_shutdown();
+
 	prosody.log("info", "Shutting down...");
 	startup.cleanup();
 	prosody.events.fire_event("server-stopped");
+
+	running_state:finish_shutdown();
+
 	prosody.log("info", "Shutdown complete");
-
 	prosody.log("debug", "Shutdown reason was: %s", prosody.shutdown_reason or "not specified");
 	prosody.log("debug", "Exiting with status code: %d", prosody.shutdown_code or 0);
 	server.setquitting(true);
@@ -682,6 +864,7 @@
 	startup.parse_args();
 	startup.init_global_state();
 	startup.read_config();
+	startup.check_user();
 	startup.init_logging();
 	startup.init_gc();
 	startup.init_errors();
@@ -704,6 +887,9 @@
 	startup.init_http_client();
 	startup.init_data_store();
 	startup.init_global_protection();
+	startup.posix_daemonize();
+	startup.write_pidfile();
+	startup.hook_posix_signals();
 	startup.prepare_to_start();
 	startup.notify_started();
 end
--- a/util/statistics.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/statistics.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,6 @@
-local time = require "util.time".now;
-local new_metric_registry = require "util.openmetrics".new_metric_registry;
-local render_histogram_le = require "util.openmetrics".render_histogram_le;
+local time = require "prosody.util.time".now;
+local new_metric_registry = require "prosody.util.openmetrics".new_metric_registry;
+local render_histogram_le = require "prosody.util.openmetrics".render_histogram_le;
 
 -- BEGIN of Metric implementations
 
--- a/util/statsd.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/statsd.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,10 +1,10 @@
 local socket = require "socket";
-local time = require "util.time".now;
-local array = require "util.array";
+local time = require "prosody.util.time".now;
+local array = require "prosody.util.array";
 local t_concat = table.concat;
 
-local new_metric_registry = require "util.openmetrics".new_metric_registry;
-local render_histogram_le = require "util.openmetrics".render_histogram_le;
+local new_metric_registry = require "prosody.util.openmetrics".new_metric_registry;
+local render_histogram_le = require "prosody.util.openmetrics".render_histogram_le;
 
 -- BEGIN of Metric implementations
 
--- a/util/template.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/template.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,13 +1,13 @@
 -- luacheck: ignore 213/i
-local stanza_mt = require "util.stanza".stanza_mt;
+local stanza_mt = require "prosody.util.stanza".stanza_mt;
 local setmetatable = setmetatable;
 local pairs = pairs;
 local ipairs = ipairs;
 local error = error;
-local envload = require "util.envload".envload;
+local envload = require "prosody.util.envload".envload;
 local debug = debug;
 local t_remove = table.remove;
-local parse_xml = require "util.xml".parse;
+local parse_xml = require "prosody.util.xml".parse;
 
 local _ENV = nil;
 -- luacheck: std none
--- a/util/termcolours.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/termcolours.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -21,7 +21,7 @@
 
 local windows;
 if os.getenv("WINDIR") then
-	windows = require "util.windows";
+	windows = require "prosody.util.windows";
 end
 local orig_color = windows and windows.get_consolecolor and windows.get_consolecolor();
 
--- a/util/throttle.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/throttle.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,5 +1,5 @@
 
-local gettime = require "util.time".now
+local gettime = require "prosody.util.time".now
 local setmetatable = setmetatable;
 
 local _ENV = nil;
--- a/util/timer.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/timer.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,14 +6,14 @@
 -- COPYING file in the source package for more information.
 --
 
-local indexedbheap = require "util.indexedbheap";
-local log = require "util.logger".init("timer");
-local server = require "net.server";
-local get_time = require "util.time".now
+local indexedbheap = require "prosody.util.indexedbheap";
+local log = require "prosody.util.logger".init("timer");
+local server = require "prosody.net.server";
+local get_time = require "prosody.util.time".now
 local type = type;
 local debug_traceback = debug.traceback;
 local tostring = tostring;
-local xpcall = require "util.xpcall".xpcall;
+local xpcall = require "prosody.util.xpcall".xpcall;
 local math_max = math.max;
 local pairs = pairs;
 
--- a/util/uuid.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/uuid.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -6,10 +6,12 @@
 -- COPYING file in the source package for more information.
 --
 
-local random = require "util.random";
+local random = require "prosody.util.random";
 local random_bytes = random.bytes;
-local hex = require "util.hex".encode;
+local time = require "prosody.util.time";
+local hex = require "prosody.util.hex".encode;
 local m_ceil = math.ceil;
+local m_floor = math.floor;
 
 local function get_nibbles(n)
 	return hex(random_bytes(m_ceil(n/2))):sub(1, n);
@@ -24,7 +26,22 @@
 	return get_nibbles(8).."-"..get_nibbles(4).."-4"..get_nibbles(3).."-"..(get_twobits())..get_nibbles(3).."-"..get_nibbles(12);
 end
 
+local function generate_v7()
+	-- Sortable based on time and random
+	-- https://datatracker.ietf.org/doc/html/draft-peabody-dispatch-new-uuid-format-01#section-4.4
+	local t = time.now();
+	local unixts = m_floor(t);
+	local unixts_a = m_floor(unixts / 16);
+	local unixts_b = m_floor(unixts % 16);
+	local subsec = t % 1;
+	local subsec_a = m_floor(subsec * 0x1000);
+	local subsec_b = m_floor(subsec * 0x1000000) % 0x1000;
+	return ("%08x-%x%03x-7%03x-%4s-%12s"):format(unixts_a, unixts_b, subsec_a, subsec_b, get_twobits() .. get_nibbles(3), get_nibbles(12));
+end
+
 return {
+	v4 = generate;
+	v7 = generate_v7;
 	get_nibbles=get_nibbles;
 	generate = generate ;
 	-- COMPAT
--- a/util/vcard.lua	Wed Mar 27 15:35:15 2024 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,574 +0,0 @@
--- Copyright (C) 2011-2014 Kim Alvefur
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
--- TODO
--- Fix folding.
-
-local st = require "util.stanza";
-local t_insert, t_concat = table.insert, table.concat;
-local type = type;
-local pairs, ipairs = pairs, ipairs;
-
-local from_text, to_text, from_xep54, to_xep54;
-
-local line_sep = "\n";
-
-local vCard_dtd; -- See end of file
-local vCard4_dtd;
-
-local function vCard_esc(s)
-	return s:gsub("[,:;\\]", "\\%1"):gsub("\n","\\n");
-end
-
-local function vCard_unesc(s)
-	return s:gsub("\\?[\\nt:;,]", {
-		["\\\\"] = "\\",
-		["\\n"] = "\n",
-		["\\r"] = "\r",
-		["\\t"] = "\t",
-		["\\:"] = ":", -- FIXME Shouldn't need to escape : in values, just params
-		["\\;"] = ";",
-		["\\,"] = ",",
-		[":"] = "\29",
-		[";"] = "\30",
-		[","] = "\31",
-	});
-end
-
-local function item_to_xep54(item)
-	local t = st.stanza(item.name, { xmlns = "vcard-temp" });
-
-	local prop_def = vCard_dtd[item.name];
-	if prop_def == "text" then
-		t:text(item[1]);
-	elseif type(prop_def) == "table" then
-		if prop_def.types and item.TYPE then
-			if type(item.TYPE) == "table" then
-				for _,v in pairs(prop_def.types) do
-					for _,typ in pairs(item.TYPE) do
-						if typ:upper() == v then
-							t:tag(v):up();
-							break;
-						end
-					end
-				end
-			else
-				t:tag(item.TYPE:upper()):up();
-			end
-		end
-
-		if prop_def.props then
-			for _,prop in pairs(prop_def.props) do
-				if item[prop] then
-					for _, v in ipairs(item[prop]) do
-						t:text_tag(prop, v);
-					end
-				end
-			end
-		end
-
-		if prop_def.value then
-			t:text_tag(prop_def.value, item[1]);
-		elseif prop_def.values then
-			local prop_def_values = prop_def.values;
-			local repeat_last = prop_def_values.behaviour == "repeat-last" and prop_def_values[#prop_def_values];
-			for i=1,#item do
-				t:text_tag(prop_def.values[i] or repeat_last, item[i]);
-			end
-		end
-	end
-
-	return t;
-end
-
-local function vcard_to_xep54(vCard)
-	local t = st.stanza("vCard", { xmlns = "vcard-temp" });
-	for i=1,#vCard do
-		t:add_child(item_to_xep54(vCard[i]));
-	end
-	return t;
-end
-
-function to_xep54(vCards)
-	if not vCards[1] or vCards[1].name then
-		return vcard_to_xep54(vCards)
-	else
-		local t = st.stanza("xCard", { xmlns = "vcard-temp" });
-		for i=1,#vCards do
-			t:add_child(vcard_to_xep54(vCards[i]));
-		end
-		return t;
-	end
-end
-
-function from_text(data)
-	data = data -- unfold and remove empty lines
-		:gsub("\r\n","\n")
-		:gsub("\n ", "")
-		:gsub("\n\n+","\n");
-	local vCards = {};
-	local current;
-	for line in data:gmatch("[^\n]+") do
-		line = vCard_unesc(line);
-		local name, params, value = line:match("^([-%a]+)(\30?[^\29]*)\29(.*)$");
-		value = value:gsub("\29",":");
-		if #params > 0 then
-			local _params = {};
-			for k,isval,v in params:gmatch("\30([^=]+)(=?)([^\30]*)") do
-				k = k:upper();
-				local _vt = {};
-				for _p in v:gmatch("[^\31]+") do
-					_vt[#_vt+1]=_p
-					_vt[_p]=true;
-				end
-				if isval == "=" then
-					_params[k]=_vt;
-				else
-					_params[k]=true;
-				end
-			end
-			params = _params;
-		end
-		if name == "BEGIN" and value == "VCARD" then
-			current = {};
-			vCards[#vCards+1] = current;
-		elseif name == "END" and value == "VCARD" then
-			current = nil;
-		elseif current and vCard_dtd[name] then
-			local dtd = vCard_dtd[name];
-			local item = { name = name };
-			t_insert(current, item);
-			local up = current;
-			current = item;
-			if dtd.types then
-				for _, t in ipairs(dtd.types) do
-					t = t:lower();
-					if ( params.TYPE and params.TYPE[t] == true)
-							or params[t] == true then
-						current.TYPE=t;
-					end
-				end
-			end
-			if dtd.props then
-				for _, p in ipairs(dtd.props) do
-					if params[p] then
-						if params[p] == true then
-							current[p]=true;
-						else
-							for _, prop in ipairs(params[p]) do
-								current[p]=prop;
-							end
-						end
-					end
-				end
-			end
-			if dtd == "text" or dtd.value then
-				t_insert(current, value);
-			elseif dtd.values then
-				for p in ("\30"..value):gmatch("\30([^\30]*)") do
-					t_insert(current, p);
-				end
-			end
-			current = up;
-		end
-	end
-	return vCards;
-end
-
-local function item_to_text(item)
-	local value = {};
-	for i=1,#item do
-		value[i] = vCard_esc(item[i]);
-	end
-	value = t_concat(value, ";");
-
-	local params = "";
-	for k,v in pairs(item) do
-		if type(k) == "string" and k ~= "name" then
-			params = params .. (";%s=%s"):format(k, type(v) == "table" and t_concat(v,",") or v);
-		end
-	end
-
-	return ("%s%s:%s"):format(item.name, params, value)
-end
-
-local function vcard_to_text(vcard)
-	local t={};
-	t_insert(t, "BEGIN:VCARD")
-	for i=1,#vcard do
-		t_insert(t, item_to_text(vcard[i]));
-	end
-	t_insert(t, "END:VCARD")
-	return t_concat(t, line_sep);
-end
-
-function to_text(vCards)
-	if vCards[1] and vCards[1].name then
-		return vcard_to_text(vCards)
-	else
-		local t = {};
-		for i=1,#vCards do
-			t[i]=vcard_to_text(vCards[i]);
-		end
-		return t_concat(t, line_sep);
-	end
-end
-
-local function from_xep54_item(item)
-	local prop_name = item.name;
-	local prop_def = vCard_dtd[prop_name];
-
-	local prop = { name = prop_name };
-
-	if prop_def == "text" then
-		prop[1] = item:get_text();
-	elseif type(prop_def) == "table" then
-		if prop_def.value then --single item
-			prop[1] = item:get_child_text(prop_def.value) or "";
-		elseif prop_def.values then --array
-			local value_names = prop_def.values;
-			if value_names.behaviour == "repeat-last" then
-				for i=1,#item.tags do
-					t_insert(prop, item.tags[i]:get_text() or "");
-				end
-			else
-				for i=1,#value_names do
-					t_insert(prop, item:get_child_text(value_names[i]) or "");
-				end
-			end
-		elseif prop_def.names then
-			local names = prop_def.names;
-			for i=1,#names do
-				if item:get_child(names[i]) then
-					prop[1] = names[i];
-					break;
-				end
-			end
-		end
-
-		if prop_def.props_verbatim then
-			for k,v in pairs(prop_def.props_verbatim) do
-				prop[k] = v;
-			end
-		end
-
-		if prop_def.types then
-			local types = prop_def.types;
-			prop.TYPE = {};
-			for i=1,#types do
-				if item:get_child(types[i]) then
-					t_insert(prop.TYPE, types[i]:lower());
-				end
-			end
-			if #prop.TYPE == 0 then
-				prop.TYPE = nil;
-			end
-		end
-
-		-- A key-value pair, within a key-value pair?
-		if prop_def.props then
-			local params = prop_def.props;
-			for i=1,#params do
-				local name = params[i]
-				local data = item:get_child_text(name);
-				if data then
-					prop[name] = prop[name] or {};
-					t_insert(prop[name], data);
-				end
-			end
-		end
-	else
-		return nil
-	end
-
-	return prop;
-end
-
-local function from_xep54_vCard(vCard)
-	local tags = vCard.tags;
-	local t = {};
-	for i=1,#tags do
-		t_insert(t, from_xep54_item(tags[i]));
-	end
-	return t
-end
-
-function from_xep54(vCard)
-	if vCard.attr.xmlns ~= "vcard-temp" then
-		return nil, "wrong-xmlns";
-	end
-	if vCard.name == "xCard" then -- A collection of vCards
-		local t = {};
-		local vCards = vCard.tags;
-		for i=1,#vCards do
-			t[i] = from_xep54_vCard(vCards[i]);
-		end
-		return t
-	elseif vCard.name == "vCard" then -- A single vCard
-		return from_xep54_vCard(vCard)
-	end
-end
-
-local vcard4 = { }
-
-function vcard4:text(node, params, value) -- luacheck: ignore 212/params
-	self:tag(node:lower())
-	-- FIXME params
-	if type(value) == "string" then
-		self:text_tag("text", value);
-	elseif vcard4[node] then
-		vcard4[node](value);
-	end
-	self:up();
-end
-
-function vcard4.N(value)
-	for i, k in ipairs(vCard_dtd.N.values) do
-		value:text_tag(k, value[i]);
-	end
-end
-
-local xmlns_vcard4 = "urn:ietf:params:xml:ns:vcard-4.0"
-
-local function item_to_vcard4(item)
-	local typ = item.name:lower();
-	local t = st.stanza(typ, { xmlns = xmlns_vcard4 });
-
-	local prop_def = vCard4_dtd[typ];
-	if prop_def == "text" then
-		t:text_tag("text", item[1]);
-	elseif prop_def == "uri" then
-		if item.ENCODING and item.ENCODING[1] == 'b' then
-			t:text_tag("uri", "data:;base64," .. item[1]);
-		else
-			t:text_tag("uri", item[1]);
-		end
-	elseif type(prop_def) == "table" then
-		if prop_def.values then
-			for i, v in ipairs(prop_def.values) do
-				t:text_tag(v:lower(), item[i]);
-			end
-		else
-			t:tag("unsupported",{xmlns="http://zash.se/protocol/vcardlib"})
-		end
-	else
-		t:tag("unsupported",{xmlns="http://zash.se/protocol/vcardlib"})
-	end
-	return t;
-end
-
-local function vcard_to_vcard4xml(vCard)
-	local t = st.stanza("vcard", { xmlns = xmlns_vcard4 });
-	for i=1,#vCard do
-		t:add_child(item_to_vcard4(vCard[i]));
-	end
-	return t;
-end
-
-local function vcards_to_vcard4xml(vCards)
-	if not vCards[1] or vCards[1].name then
-		return vcard_to_vcard4xml(vCards)
-	else
-		local t = st.stanza("vcards", { xmlns = xmlns_vcard4 });
-		for i=1,#vCards do
-			t:add_child(vcard_to_vcard4xml(vCards[i]));
-		end
-		return t;
-	end
-end
-
--- This was adapted from http://xmpp.org/extensions/xep-0054.html#dtd
-vCard_dtd = {
-	VERSION = "text", --MUST be 3.0, so parsing is redundant
-	FN = "text",
-	N = {
-		values = {
-			"FAMILY",
-			"GIVEN",
-			"MIDDLE",
-			"PREFIX",
-			"SUFFIX",
-		},
-	},
-	NICKNAME = "text",
-	PHOTO = {
-		props_verbatim = { ENCODING = { "b" } },
-		props = { "TYPE" },
-		value = "BINVAL", --{ "EXTVAL", },
-	},
-	BDAY = "text",
-	ADR = {
-		types = {
-			"HOME",
-			"WORK",
-			"POSTAL",
-			"PARCEL",
-			"DOM",
-			"INTL",
-			"PREF",
-		},
-		values = {
-			"POBOX",
-			"EXTADD",
-			"STREET",
-			"LOCALITY",
-			"REGION",
-			"PCODE",
-			"CTRY",
-		}
-	},
-	LABEL = {
-		types = {
-			"HOME",
-			"WORK",
-			"POSTAL",
-			"PARCEL",
-			"DOM",
-			"INTL",
-			"PREF",
-		},
-		value = "LINE",
-	},
-	TEL = {
-		types = {
-			"HOME",
-			"WORK",
-			"VOICE",
-			"FAX",
-			"PAGER",
-			"MSG",
-			"CELL",
-			"VIDEO",
-			"BBS",
-			"MODEM",
-			"ISDN",
-			"PCS",
-			"PREF",
-		},
-		value = "NUMBER",
-	},
-	EMAIL = {
-		types = {
-			"HOME",
-			"WORK",
-			"INTERNET",
-			"PREF",
-			"X400",
-		},
-		value = "USERID",
-	},
-	JABBERID = "text",
-	MAILER = "text",
-	TZ = "text",
-	GEO = {
-		values = {
-			"LAT",
-			"LON",
-		},
-	},
-	TITLE = "text",
-	ROLE = "text",
-	LOGO = "copy of PHOTO",
-	AGENT = "text",
-	ORG = {
-		values = {
-			behaviour = "repeat-last",
-			"ORGNAME",
-			"ORGUNIT",
-		}
-	},
-	CATEGORIES = {
-		values = "KEYWORD",
-	},
-	NOTE = "text",
-	PRODID = "text",
-	REV = "text",
-	SORTSTRING = "text",
-	SOUND = "copy of PHOTO",
-	UID = "text",
-	URL = "text",
-	CLASS = {
-		names = { -- The item.name is the value if it's one of these.
-			"PUBLIC",
-			"PRIVATE",
-			"CONFIDENTIAL",
-		},
-	},
-	KEY = {
-		props = { "TYPE" },
-		value = "CRED",
-	},
-	DESC = "text",
-};
-vCard_dtd.LOGO = vCard_dtd.PHOTO;
-vCard_dtd.SOUND = vCard_dtd.PHOTO;
-
-vCard4_dtd = {
-	source = "uri",
-	kind = "text",
-	xml = "text",
-	fn = "text",
-	n = {
-		values = {
-			"family",
-			"given",
-			"middle",
-			"prefix",
-			"suffix",
-		},
-	},
-	nickname = "text",
-	photo = "uri",
-	bday = "date-and-or-time",
-	anniversary = "date-and-or-time",
-	gender = "text",
-	adr = {
-		values = {
-			"pobox",
-			"ext",
-			"street",
-			"locality",
-			"region",
-			"code",
-			"country",
-		}
-	},
-	tel = "text",
-	email = "text",
-	impp = "uri",
-	lang = "language-tag",
-	tz = "text",
-	geo = "uri",
-	title = "text",
-	role = "text",
-	logo = "uri",
-	org = "text",
-	member = "uri",
-	related = "uri",
-	categories = "text",
-	note = "text",
-	prodid = "text",
-	rev = "timestamp",
-	sound = "uri",
-	uid = "uri",
-	clientpidmap = "number, uuid",
-	url = "uri",
-	version = "text",
-	key = "uri",
-	fburl = "uri",
-	caladruri = "uri",
-	caluri = "uri",
-};
-
-return {
-	from_text = from_text;
-	to_text = to_text;
-
-	from_xep54 = from_xep54;
-	to_xep54 = to_xep54;
-
-	to_vcard4 = vcards_to_vcard4xml;
-};
--- a/util/watchdog.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/watchdog.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,6 +1,5 @@
-local timer = require "util.timer";
+local timer = require "prosody.util.timer";
 local setmetatable = setmetatable;
-local os_time = os.time;
 
 local _ENV = nil;
 -- luacheck: std none
@@ -9,27 +8,35 @@
 local watchdog_mt = { __index = watchdog_methods };
 
 local function new(timeout, callback)
-	local watchdog = setmetatable({ timeout = timeout, last_reset = os_time(), callback = callback }, watchdog_mt);
-	timer.add_task(timeout+1, function (current_time)
-		local last_reset = watchdog.last_reset;
-		if not last_reset then
-			return;
-		end
-		local time_left = (last_reset + timeout) - current_time;
-		if time_left < 0 then
-			return watchdog:callback();
-		end
-		return time_left + 1;
-	end);
+	local watchdog = setmetatable({
+		timeout = timeout;
+		callback = callback;
+		timer_id = nil;
+	}, watchdog_mt);
+
+	watchdog:reset(); -- Kick things off
+
 	return watchdog;
 end
 
-function watchdog_methods:reset()
-	self.last_reset = os_time();
+function watchdog_methods:reset(new_timeout)
+	if new_timeout then
+		self.timeout = new_timeout;
+	end
+	if self.timer_id then
+		timer.reschedule(self.timer_id, self.timeout+1);
+	else
+		self.timer_id = timer.add_task(self.timeout+1, function ()
+			return self:callback();
+		end);
+	end
 end
 
 function watchdog_methods:cancel()
-	self.last_reset = nil;
+	if self.timer_id then
+		timer.stop(self.timer_id);
+		self.timer_id = nil;
+	end
 end
 
 return {
--- a/util/x509.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/x509.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -11,19 +11,19 @@
 -- IDN libraries complicate that.
 
 
--- [TLS-CERTS] - http://tools.ietf.org/html/rfc6125
--- [XMPP-CORE] - http://tools.ietf.org/html/rfc6120
--- [SRV-ID]    - http://tools.ietf.org/html/rfc4985
--- [IDNA]      - http://tools.ietf.org/html/rfc5890
--- [LDAP]      - http://tools.ietf.org/html/rfc4519
--- [PKIX]      - http://tools.ietf.org/html/rfc5280
+-- [TLS-CERTS] - https://www.rfc-editor.org/rfc/rfc6125.html
+-- [XMPP-CORE] - https://www.rfc-editor.org/rfc/rfc6120.html
+-- [SRV-ID]    - https://www.rfc-editor.org/rfc/rfc4985.html
+-- [IDNA]      - https://www.rfc-editor.org/rfc/rfc5890.html
+-- [LDAP]      - https://www.rfc-editor.org/rfc/rfc4519.html
+-- [PKIX]      - https://www.rfc-editor.org/rfc/rfc5280.html
 
-local nameprep = require "util.encodings".stringprep.nameprep;
-local idna_to_ascii = require "util.encodings".idna.to_ascii;
-local idna_to_unicode = require "util.encodings".idna.to_unicode;
-local base64 = require "util.encodings".base64;
-local log = require "util.logger".init("x509");
-local mt = require "util.multitable";
+local nameprep = require "prosody.util.encodings".stringprep.nameprep;
+local idna_to_ascii = require "prosody.util.encodings".idna.to_ascii;
+local idna_to_unicode = require "prosody.util.encodings".idna.to_unicode;
+local base64 = require "prosody.util.encodings".base64;
+local log = require "prosody.util.logger".init("x509");
+local mt = require "prosody.util.multitable";
 local s_format = string.format;
 local ipairs = ipairs;
 
--- a/util/xml.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/xml.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,5 +1,5 @@
 
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 local lxp = require "lxp";
 local t_insert = table.insert;
 local t_remove = table.remove;
--- a/util/xmppstream.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/xmppstream.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -7,7 +7,7 @@
 --
 
 local lxp = require "lxp";
-local st = require "util.stanza";
+local st = require "prosody.util.stanza";
 local stanza_mt = st.stanza_mt;
 
 local error = error;
--- a/util/xpcall.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/xpcall.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -1,7 +1,7 @@
 local xpcall = xpcall;
 
 if select(2, xpcall(function (x) return x end, function () end,  "test")) ~= "test" then
-	xpcall = require"util.compat".xpcall;
+	xpcall = require"prosody.util.compat".xpcall;
 end
 
 return {
--- a/util/xtemplate.lua	Wed Mar 27 15:35:15 2024 +0000
+++ b/util/xtemplate.lua	Wed Mar 27 15:39:03 2024 +0000
@@ -3,13 +3,21 @@
 local s_sub = string.sub;
 local t_concat = table.concat;
 
-local st = require("util.stanza");
+local st = require("prosody.util.stanza");
 
 local function render(template, root, escape, filters)
 	escape = escape or st.xml_escape;
 
-	return (s_gsub(template, "%b{}", function(block)
+	return (s_gsub(template, "(%s*)(%b{})(%s*)", function(pre_blank, block, post_blank)
 		local inner = s_sub(block, 2, -2);
+		if inner:sub(1, 1) == "-" then
+			pre_blank = "";
+			inner = inner:sub(2);
+		end
+		if inner:sub(-1, -1) == "-" then
+			post_blank = "";
+			inner = inner:sub(1, -2);
+		end
 		local path, pipe, pos = s_match(inner, "^([^|]+)(|?)()");
 		if not (type(path) == "string") then return end
 		local value
@@ -74,12 +82,12 @@
 
 		if type(value) == "string" then
 			if not is_escaped then value = escape(value); end
-			return value
+			return pre_blank .. value .. post_blank
 		elseif st.is_stanza(value) then
 			value = value:get_text();
-			if value then return escape(value) end
+			if value then return pre_blank .. escape(value) .. post_blank end
 		end
-		return ""
+		return pre_blank .. post_blank
 	end))
 end