Merge 0.12->trunk
authorKim Alvefur <zash@zash.se>
Tue, 25 Jul 2023 12:35:11 +0200
changeset 13252 db433ed3135c
parent 13250 2e04d54fb013 (diff)
parent 13251 1bb4aa803b32 (current diff)
child 13253 5884d58707fa
Merge 0.12->trunk
util/array.lua
--- a/.editorconfig	Sat Jul 22 16:31:05 2023 +0200
+++ b/.editorconfig	Tue Jul 25 12:35:11 2023 +0200
@@ -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
--- a/.luacheckrc	Sat Jul 22 16:31:05 2023 +0200
+++ b/.luacheckrc	Tue Jul 25 12:35:11 2023 +0200
@@ -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 = {
@@ -62,6 +62,8 @@
 		"module.broadcast",
 		"module.context",
 		"module.depends",
+		"module.default_permission",
+		"module.default_permissions",
 		"module.fire_event",
 		"module.get_directory",
 		"module.get_host",
@@ -71,9 +73,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,6 +91,7 @@
 		"module.load_resource",
 		"module.log",
 		"module.log_status",
+		"module.may",
 		"module.measure",
 		"module.metric",
 		"module.open_store",
@@ -149,10 +155,6 @@
 		"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";
@@ -171,6 +173,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/.semgrep.yml	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/CHANGES	Tue Jul 25 12:35:11 2023 +0200
@@ -1,3 +1,62 @@
+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
+
+### Security and authentication
+
+- Advertise supported SASL Channel-Binding types (XEP-0440)
+- Implement RFC 9266 'tls-exporter' channel binding with TLS 1.3
+- New role and permissions framework and API
+- Ability to disable and enable user accounts
+
+### 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
+
+## 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
+
+## Removed
+
+- Lua 5.1 support
+- XEP-0090 support removed from mod_time
+
 0.12.0
 ======
 
--- a/GNUmakefile	Sat Jul 22 16:31:05 2023 +0200
+++ b/GNUmakefile	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/certs/openssl.cnf	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/configure	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/certmanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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);
@@ -353,34 +324,7 @@
 		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"
@@ -419,7 +363,7 @@
 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;
--- a/core/configmanager.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/configmanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -11,14 +11,14 @@
       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 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 +40,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 +62,7 @@
 	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"
-	end
+function _M.set(host, key, value)
 	return set(config, host, key, value);
 end
 
--- a/core/features.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/features.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -1,10 +1,25 @@
-local set = require "util.set";
+local set = require "prosody.util.set";
 
 return {
 	available = set.new{
 		-- mod_bookmarks bundled
 		"mod_bookmarks";
+		-- 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";
 	};
 };
--- a/core/hostmanager.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/hostmanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/loggingmanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/moduleapi.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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,83 @@
 	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
+			self:log("error", "Config option '%s' not understood, expecting a period (e.g. \"2 days\")", name);
+		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 +382,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 +414,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 +623,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 +643,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 +665,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 +688,95 @@
 	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
+	if type(context) == "string" then -- check JID permissions
+		local role;
+		local node, host = jid_split(context);
+		if host == self.host then
+			role = hosts[host].authz.get_user_role(node);
+		else
+			role = hosts[self.host].authz.get_jid_role(context);
+		end
+		if not role then
+			if not peek then
+				self:log("debug", "Access denied: JID <%s> may not %s (no role found)", context, 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)", context, action, role.name);
+			end
+		end
+		return permit;
+	end
+
+	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:once(f) --luacheck: ignore 212/self
+	return prosody.started:next(f);
+end
+
 return api;
--- a/core/modulemanager.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/modulemanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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;
--- a/core/portmanager.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/portmanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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;
@@ -240,21 +240,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 +278,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);
--- a/core/rostermanager.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/rostermanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/s2smanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/sessionmanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/stanza_router.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/statsmanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/storagemanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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;
@@ -203,6 +203,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 +244,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/core/usermanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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)
+	local method = hosts[host].users.disable;
+	if not method then return nil, "method not supported"; end
+	local ret, err = method(username);
+	if ret then
+		prosody.events.fire_event("user-disabled", { username = username, host = host });
+	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	Sat Jul 22 16:31:05 2023 +0200
+++ b/doc/doap.xml	Tue Jul 25 12:35:11 2023 +0200
@@ -56,10 +56,14 @@
     <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>
@@ -67,7 +71,7 @@
     <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 +123,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.3</xmpp:version>
         <xmpp:since>0.3.0</xmpp:since>
         <xmpp:status>partial</xmpp:status>
       </xmpp:SupportedXep>
@@ -172,7 +176,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 +244,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 +273,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 +303,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 +361,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 +396,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 +567,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 +629,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 +663,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 +689,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.0.1</xmpp:version>
         <xmpp:status>complete</xmpp:status>
         <xmpp:since>0.10.0</xmpp:since>
         <xmpp:note>mod_mam, mod_muc_mam</xmpp:note>
@@ -737,7 +743,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 +769,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 +778,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 +795,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 +845,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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/makefile	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/adns.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/connect.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/cqueues.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/dns.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/http.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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
--- a/net/http/codes.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/http/codes.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/http/errors.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/http/files.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/http/parser.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/http/server.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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 = {};
 
@@ -128,6 +130,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 +236,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 +255,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 +289,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 +312,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 +325,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 +383,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
--- a/net/httpserver.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/httpserver.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/resolvers/basic.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/resolvers/manual.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/resolvers/service.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/server.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/server_epoll.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -15,21 +15,22 @@
 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 poller = require "util.poll"
+local poller = require "prosody.util.poll"
 local EEXIST = poller.EEXIST;
 local ENOENT = poller.ENOENT;
 
@@ -91,6 +92,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;
 
@@ -614,6 +621,42 @@
 	self._sslctx = sslctx;
 end
 
+function interface:sslctx()
+	return self.tls_ctx
+end
+
+function interface:ssl_info()
+	local sock = self.conn;
+	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.getpeercertificate then return nil, "not-implemented"; end
+	return sock:getpeercertificate();
+end
+
+function interface:ssl_peerverification()
+	local sock = self.conn;
+	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.getpeerfinished then return nil, "not-implemented"; end
+	return sock:getpeerfinished();
+end
+
+function interface:ssl_exportkeyingmaterial(label, len, context)
+	local sock = self.conn;
+	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 +684,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 +695,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 +780,6 @@
 		end
 	end
 
-	conn:updatenames();
 	return conn;
 end
 
@@ -767,6 +805,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 +924,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 +953,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 +987,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 +1082,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,12 +1126,12 @@
 				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
 
@@ -1085,6 +1161,10 @@
 		cfg = setmetatable(newconfig, default_config);
 	end;
 
+	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 };
 	addevent = function (fd, mode, callback)
--- a/net/server_event.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/server_event.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/server_select.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/stun.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/unbound.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/websocket.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/net/websocket/frames.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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);
--- a/plugins/adhoc/adhoc.lib.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/adhoc/adhoc.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/adhoc/mod_adhoc.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_admin_adhoc.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_admin_shell.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -10,38 +10,39 @@
 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 server = require "prosody.net.server";
+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
@@ -83,8 +84,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 +100,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 +133,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;
 
@@ -213,7 +225,7 @@
 		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%" } })
+		local row = format_table({ { title = "Section", width = 7 }, { title = "Description", width = "100%" } }, session.width)
 		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" })
@@ -229,6 +241,7 @@
 		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 { "watch"; "Commands for watching live logs from 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" })
@@ -256,28 +269,33 @@
 		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:create(jid, password, role) - 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:setrole(jid, host, role) - Set primary role of a user (see 'help roles')]]
+		print [[user:addrole(jid, host, role) - Add a secondary role to a user]]
+		print [[user:delrole(jid, host, role) - Remove a secondary role from a user]]
+		print [[user:disable(jid) - Disable the specified user account, preventing login]]
+		print [[user:enable(jid) - Enable the specified user account, restoring login access]]
 		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 [[  prosody:guest      - Guest/anonymous user]]
+		print [[  prosody:registered - Registered user]]
+		print [[  prosody:member     - Provisioned user]]
+		print [[  prosody:admin      - Host administrator]]
+		print [[  prosody:operator - Server administrator]]
 		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}]]
+		print [[Roles can be assigned using the user management commands (see 'help user').]]
 	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]]
+		print [[muc:occupants(roomjid, filter) - List room occupants, optionally filtered on substring or role]]
+		print [[muc:affiliations(roomjid, filter) - List affiliated members of the room, optionally filtered on substring or affiliation]]
 	elseif section == "server" then
 		print [[server:version() - Show the server's version number]]
 		print [[server:uptime() - Show how long the server has been running]]
@@ -297,6 +315,7 @@
 	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.]]
+		print [[config:set([host,] option, value) - Update the value of a config option without writing to the config file.]]
 	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]]
@@ -305,6 +324,9 @@
 		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 == "watch" then
+		print [[watch:log() - Follow debug logs]]
+		print [[watch:stanzas(target, filter) - Watch live stanzas matching the specified target and filter]]
 	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'.]]
@@ -335,7 +357,7 @@
 			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)
+		local row = format_table(meta_columns, session.width)
 		print(row());
 		for column, spec in iterators.sorted_pairs(available_columns) do
 			print(row({ column, spec.title, spec.description }));
@@ -350,8 +372,11 @@
 -- 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 = {};
 function def_env.output:configure(opts)
@@ -481,6 +506,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 +533,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 +578,36 @@
 		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)
+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 +620,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
@@ -573,6 +637,8 @@
 	-- 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 +647,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
@@ -644,7 +714,7 @@
 
 def_env.config = {};
 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";
@@ -656,10 +726,17 @@
 	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
 
+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
+
 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);
@@ -719,7 +796,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 +804,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 +812,7 @@
 	remote = {
 		title = "Remote";
 		description = "Remote hostname";
-		width = 22;
+		width = "1p";
 		mapper = function(_, session)
 			return select(2, get_s2s_hosts(session));
 		end;
@@ -743,7 +820,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 +832,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 +840,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 +870,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 +894,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 +917,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 +945,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 +955,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 +966,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 +1001,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 +1015,16 @@
 			end
 		end
 	};
+	role = {
+		title = "Role";
+		description = "Session role with 'prosody:' prefix removed";
+		width = #"admin";
+		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 +1032,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
@@ -935,12 +1045,12 @@
 
 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;
@@ -1016,14 +1126,30 @@
 	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
+
 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;
@@ -1090,7 +1216,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;
@@ -1193,12 +1319,11 @@
 	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");
@@ -1208,7 +1333,7 @@
 	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
@@ -1229,18 +1354,18 @@
 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";
@@ -1307,6 +1432,20 @@
 	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";
+
 function def_env.muc:create(room_jid, config)
 	local room_name, host = check_muc(room_jid);
 	if not room_name then
@@ -1319,13 +1458,9 @@
 end
 
 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
@@ -1344,33 +1479,150 @@
 	return true, c.." rooms";
 end
 
-local um = require"core.usermanager";
+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
 
+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 = {};
-function def_env.user:create(jid, password, roles)
+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
+
+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
+
+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
 
@@ -1404,41 +1656,64 @@
 	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
+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)
+-- 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
+
+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
+
+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
 
 -- TODO switch to table view, include roles
@@ -1462,7 +1737,7 @@
 
 def_env.xmpp = {};
 
-local new_id = require "util.id".medium;
+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 +1784,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";
+local adns = require"prosody.net.adns";
 
 local function get_resolver(session)
 	local resolver = session.dns_resolver;
@@ -1558,10 +1833,15 @@
 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 +1852,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,10 +1874,71 @@
 	return true;
 end
 
+def_env.watch = {};
+
+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
+
+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 = {};
 
 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
 
@@ -1603,7 +1946,7 @@
 	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
@@ -1617,7 +1960,7 @@
 
 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;
@@ -1914,7 +2257,7 @@
 end
 
 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
@@ -1935,6 +2278,10 @@
 end
 
 
+function module.unload()
+	stanza_watchers.cleanup();
+end
+
 
 -------------
 
--- a/plugins/mod_admin_socket.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_admin_socket.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_admin_telnet.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_announce.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_auth_anonymous.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_auth_insecure.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_auth_internal_hashed.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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,35 @@
 	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)
+	local account = accounts:get(username);
+	account.disabled = true;
+	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 +151,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 +162,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 +180,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_auth_internal_plain.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_auth_ldap.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_authz_internal.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_blocklist.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -9,16 +9,16 @@
 -- 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;
@@ -35,8 +35,8 @@
 -- 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", 64, 1);
+local cache2 = require"prosody.util.cache".new(cache_size);
 
 local null_blocklist = {};
 
@@ -54,6 +54,7 @@
 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,6 +78,13 @@
 	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);
 	if not blocklist then
--- a/plugins/mod_bookmarks.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_bookmarks.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_bosh.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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");
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_c2s.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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
@@ -260,8 +264,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
@@ -295,8 +309,7 @@
 		session.encrypted = true;
 
 		-- 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,7 +367,7 @@
 		end
 	end
 
-	if c2s_timeout then
+	if c2s_timeout < math.huge then
 		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);
@@ -426,7 +439,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_carbons.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_component.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_cron.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -1,7 +1,7 @@
 module:set_global();
 
-local async = require("util.async");
-local datetime = require("util.datetime");
+local async = require("prosody.util.async");
+local datetime = require("prosody.util.datetime");
 
 local periods = { hourly = 3600; daily = 86400; weekly = 7 * 86400 }
 
--- a/plugins/mod_csi.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_csi.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_csi_simple.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_dialback.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_disco.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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});
 			if not reply.attr.from then reply.attr.from = origin.username.."@"..origin.host; end -- COMPAT To satisfy Psi when querying own account
@@ -185,7 +186,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_external_services.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_groups.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_http.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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,11 @@
 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
 		local url = {
 			scheme = external_url.scheme;
 			host = external_url.host;
@@ -112,12 +114,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 +298,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 +320,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 +366,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_http_errors.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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);
--- a/plugins/mod_http_file_share.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_http_file_share.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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,14 +34,16 @@
 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", {});
 
@@ -169,16 +170,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 +247,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 +452,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_http_files.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_http_openmetrics.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_invites.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -1,10 +1,11 @@
-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 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
@@ -205,7 +206,7 @@
 	-- 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 get_module = require "prosody.core.modulemanager".get_module;
 
 	local console_env = module:shared("/*/admin_shell/env");
 
@@ -230,24 +231,62 @@
 	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
+	return subcommands[cmd](arg);
+end
+
+function subcommands.generate(arg)
 
-	local sm = require "core.storagemanager";
-	local mm = require "core.modulemanager";
+	local sm = require "prosody.core.storagemanager";
+	local mm = require "prosody.core.modulemanager";
 
-	local host = arg[1];
+	local host = table.remove(arg, 1); -- pop host
 	assert(prosody.hosts[host], "Host "..tostring(host).." does not exist");
 	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
+		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("--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
+
 	-- Load mod_invites
 	local invites = module:depends("invites");
 	-- Optional community module that if used, needs to be loaded here
@@ -257,71 +296,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
--- a/plugins/mod_invites_adhoc.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_invites_adhoc.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_invites_register.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_iq.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_lastactivity.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_legacyauth.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_limits.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_mam/mamprefs.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_mam/mamprefsxml.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_mam/mod_mam.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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.
@@ -437,7 +441,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 +506,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 +522,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 +535,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_message.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_mimicking.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_motd.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_muc_mam.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_muc_unique.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_net_multiplex.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_offline.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_pep.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_pep_simple.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_ping.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_posix.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -9,13 +9,13 @@
 
 local want_pposix_version = "0.4.0";
 
-local pposix = assert(require "util.pposix");
+local pposix = assert(require "prosody.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");
+local have_signal, signal = pcall(require, "prosody.util.signal");
 if not have_signal then
 	module:log("warn", "Couldn't load signal library, won't respond to SIGTERM");
 end
@@ -97,7 +97,7 @@
 end
 
 local function remove_log_sinks()
-	local lm = require "core.loggingmanager";
+	local lm = require "prosody.core.loggingmanager";
 	lm.register_sink_type("console", nil);
 	lm.register_sink_type("stdout", nil);
 	lm.reload_logging();
--- a/plugins/mod_presence.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_presence.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_private.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_proxy65.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_pubsub/mod_pubsub.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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);
@@ -176,10 +179,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 +196,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 +223,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;
--- a/plugins/mod_pubsub/pubsub.lib.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_pubsub/pubsub.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_register_ibr.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_register_limits.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_roster.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_s2s.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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", "",
@@ -146,17 +146,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 +182,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 +211,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 +251,26 @@
 	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_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 +296,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 +341,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 +406,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 +421,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 +450,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 +539,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 +779,7 @@
 	local w = conn.write;
 
 	if conn:ssl() then
+		-- Direct TLS was used
 		session_secure(session);
 	end
 
@@ -770,6 +790,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
@@ -939,6 +964,16 @@
 			elseif cert_errors:contains("self signed certificate") then
 				return "is self-signed";
 			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
 	elseif session.cert_identity_status == "invalid" then
@@ -976,7 +1011,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_s2s_auth_certs.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -1,6 +1,6 @@
 module:set_global();
 
-local cert_verify_identity = require "util.x509".verify_identity;
+local cert_verify_identity = require "prosody.util.x509".verify_identity;
 local NULL = {};
 local log = module._log;
 
@@ -9,17 +9,19 @@
 
 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();
+	if conn.ssl_peerverification then
+		chain_valid, errors = conn:ssl_peerverification();
 	else
 		chain_valid, errors = false, { { "Chain verification not supported by this version of LuaSec" } };
 	end
@@ -45,6 +47,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);
--- a/plugins/mod_s2s_bidi.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_s2s_bidi.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_saslauth.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -8,14 +8,14 @@
 -- 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 usermanager_get_sasl_handler = require "core.usermanager".get_sasl_handler;
+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)
@@ -52,8 +52,9 @@
 		module:fire_event("authentication-failure", { session = session, condition = ret, text = err_msg });
 		session.sasl_handler = session.sasl_handler:clean_clone();
 	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();
@@ -242,7 +243,16 @@
 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 mechanisms_attr = { xmlns='urn:ietf:params:xml:ns:xmpp-sasl' };
@@ -258,22 +268,29 @@
 		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
 				sasl_handler["userdata"] = {
-					["tls-unique"] = socket;
+					["tls-unique"] = origin.conn;
+					["tls-exporter"] = origin.conn;
 				};
 			else
 				log("debug", "Channel binding not supported by SASL handler");
@@ -306,6 +323,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 +353,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 +375,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_scansion_record.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_server_contact_info.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -6,20 +6,21 @@
 -- 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 dataforms = require "prosody.util.dataforms";
+local jid = require "prosody.util.jid";
 local url = require "socket.url";
 
 -- 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 form_layout = dataforms.new({
+	{ var = "FORM_TYPE"; type = "hidden"; value = "http://jabber.org/network/serverinfo" };
+	{ type = "list-multi"; 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" };
 });
 
 -- JIDs of configured service admins are used as fallback
--- a/plugins/mod_smacks.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_smacks.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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;
@@ -44,17 +45,17 @@
 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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_stanza_debug.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_storage_internal.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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
+		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, exact = binary_search(list, function(item)
+					local when = item.when or datetime.parse(item.attr.stamp);
+					return query.start - when;
+				end);
+				if exact then
+					i = wi - 1;
+				elseif wi then
+					i = wi;
+				end
+			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
+			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,32 @@
 	return datamanager.users(host, self.store, "list");
 end
 
+function archive:trim(username, to_when)
+	local list, err = datamanager.list_open(username, host, self.store);
+	if not list then return list,err;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);
+	-- 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
+	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);
 		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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_storage_memory.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_storage_sql.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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,8 +360,9 @@
 		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
@@ -343,7 +370,9 @@
 		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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_storage_xep0227.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_time.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_tls.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_tokenauth.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -1,82 +1,323 @@
-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 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 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;
+	if password_updated_at and grant.created < password_updated_at then
+		module:log("debug", "Token grant issued before last password change, invalidating it now");
+		token_store:set_key(username, grant.id, nil);
+		return nil, "not-authorized";
+	elseif grant.expires and grant.expires < os.time() then
+		module:log("debug", "Token grant expired, cleaning up");
+		token_store:set_key(username, grant.id, nil);
+		return nil, "expired";
+	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";
-	end
-	if token_host ~= module.host then
-		return nil, "invalid-host";
-	end
-	return token_store:set(token_user, token_id, nil);
+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
--- a/plugins/mod_tombstones.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_tombstones.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_turn_external.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_uptime.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_user_account_management.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -7,11 +7,11 @@
 --
 
 
-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_set_password = require "prosody.core.usermanager".set_password;
+local usermanager_delete_user = require "prosody.core.usermanager".delete_user;
+local nodeprep = require "prosody.util.encodings".stringprep.nodeprep;
+local jid_bare = require "prosody.util.jid".bare;
 
 local compat = module:get_option_boolean("registration_compat", true);
 
--- a/plugins/mod_vcard.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_vcard.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_vcard4.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_vcard_legacy.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_version.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_watchregistrations.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_websocket.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/mod_welcome.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/hats.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/hidden.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/history.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/lock.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/members_only.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/mod_muc.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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,23 @@
 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";
+});
 
 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:may(":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:may(":automatic-ownership", jid) then return nil, "modify", "not-acceptable"; end
 		return _set_affiliation(self, actor, jid, affiliation, reason, data);
 	end
 end
@@ -158,8 +159,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 +282,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)
@@ -388,7 +388,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 +412,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 +454,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 +505,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/muc.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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;
 
--- a/plugins/muc/occupant.lib.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/occupant.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/occupant_id.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/password.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/persistent.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/presence_broadcast.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/register.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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);
 
--- a/plugins/muc/request.lib.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/request.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/plugins/muc/subject.lib.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/prosody	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/prosodyctl	Tue Jul 25 12:35:11 2023 +0200
@@ -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_storagemanager_spec.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/core_storagemanager_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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);
@@ -466,7 +599,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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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/mam_extended.scs	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/scansion/mam_extended.scs	Tue Jul 25 12:35:11 2023 +0200
@@ -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="2008-08-22T21:09:04.500000Z" xmlns="urn:xmpp:mam:2" id="{scansion:any}"/>
+			<end timestamp="2008-08-22T21:09:04.500000Z" xmlns="urn:xmpp:mam:2" id="{scansion:any}"/>
 		</metadata>
 	</iq>
 
@@ -59,7 +59,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="2008-08-22T21:09:04.500000Z" 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>
@@ -71,7 +71,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="2008-08-22T21:09:04.500000Z" 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="2008-08-22T21:09:04.500000Z" 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="2008-08-22T21:09:04.500000Z" 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/pep_itemreply.scs	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/scansion/pep_nickname.scs	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/scansion/pep_publish_subscribe.scs	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/scansion/prosody.cfg.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -1,15 +1,14 @@
 --luacheck: ignore
 
--- Mock time functions to simplify tests
+-- Mock time functions to simplify tests --
+local time = require "util.time";
 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;
-	}
+function time.now()
+	return 1219439344.5;
 end
+-------------------------------------------
 
 admins = { "admin@localhost" }
 
@@ -66,6 +65,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/scansion/pubsub_config.scs	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/scansion/pubsub_max_items.scs	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/scansion/pubsub_multi_items.scs	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/scansion/pubsub_preconditions.scs	Tue Jul 25 12:35:11 2023 +0200
@@ -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/vcard_temp.scs	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/scansion/vcard_temp.scs	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_argparse_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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_cache_spec.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_cache_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_dataforms_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_datamapper_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_datetime_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_dbuffer_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_error_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_format_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_hashes_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_hashring_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_http_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -108,4 +108,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_human_io_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -42,6 +42,33 @@
 			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
+		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("two weeks"), "\"2 weeks\" -> nil");
+		end);
+	end);
 end);
 
 
--- a/spec/util_ip_spec.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_ip_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -100,4 +100,26 @@
 			assert_cpl6("abcd::abcd", "abcd::abcd:abcd", 96);
 		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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_iterators_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_jid_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_jsonpointer_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_jsonschema_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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,6 +49,11 @@
 	["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:3:4"] = "distinguishing objects from arrays",
@@ -64,11 +64,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_jwt_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_poll_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_promise_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_roles_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_sasl_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_smqueue_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_stanza_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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_table_spec.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_table_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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_uuid_spec.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/spec/util_uuid_spec.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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),
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/README.md	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/teal-src/module.d.tl	Tue Jul 25 12:35:11 2023 +0200
@@ -1,4 +1,4 @@
-local st = require"util.stanza"
+local st = require "prosody.util.stanza"
 
 global record moduleapi
 	get_name : function (moduleapi) : string
@@ -62,7 +62,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 +75,7 @@
 	load_resource : function (moduleapi, string, file_mode) : FILE
 	enum store_type
 		"keyval"
+		"keyval+"
 		"map"
 		"archive"
 	end
@@ -121,12 +127,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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -0,0 +1,106 @@
+module:set_global();
+
+local async = require "prosody.util.async";
+local datetime = require "prosody.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_t<task_spec> = async.runner(run_task);
+scheduled = 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/plugins/muc/muc.lib.d.tl	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -0,0 +1,29 @@
+local record lib
+	record key
+		private_pem : function (key) : string
+		public_pem : function (key) : string
+		get_type : function (key) : string
+	end
+
+	generate_ed25519_keypair : function () : key
+	ed25519_sign : function (key, string) : string
+	ed25519_verify : function (key, string, string) : boolean
+
+	ecdsa_sha256_sign : function (key, string) : string
+	ecdsa_sha256_verify : function (key, string, string) : boolean
+	parse_ecdsa_signature : function (string) : string, string
+	build_ecdsa_signature : function (string, string) : string
+
+	import_private_pem : function (string) : key
+	import_public_pem : function (string) : key
+
+	aes_128_gcm_encrypt : function (key, string, string) : string
+	aes_128_gcm_decrypt : function (key, string, string) : string
+	aes_256_gcm_encrypt : function (key, string, string) : string
+	aes_256_gcm_decrypt : function (key, string, 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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -0,0 +1,31 @@
+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
+	sha3_256 : hash
+	sha3_512 : hash
+	blake2s256 : hash
+	blake2b512 : hash
+	hmac_sha1 : hmac
+	hmac_sha256 : hmac
+	hmac_sha224 : hmac
+	hmac_sha384  :hmac
+	hmac_sha512 : hmac
+	hmac_md5 : hmac
+	hmac_sha3_256 : hmac
+	hmac_sha3_512 : 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
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/hex.d.tl	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -0,0 +1,450 @@
+-- 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_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 function validate (schema : schema_t, data : any, root : json_schema_object) : boolean
+	if schema is boolean then
+		return schema
+	end
+
+	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 utf8_len(data) > schema.maxLength then
+			return false
+		end
+		if schema.minLength and utf8_len(data) < schema.minLength then
+			return false
+		end
+		if schema.luaPattern and not data:match(schema.luaPattern) 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
+		-- 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
+			return false
+		end
+
+		if schema.minItems and #(data as {any}) < 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.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
+							return false
+						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) then
+					return false
+				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) then
+					return false
+				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) then
+							return false
+						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) then
+						return false
+					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) then
+					return false
+				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 as {any}) do
+				if not validate(schema.items, data[i], root) then
+					return false
+				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) then
+					found = found + 1
+				end
+			end
+			if found < (schema.minContains or 1) or found > (schema.maxContains or math.huge) then
+				return false
+			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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -0,0 +1,6 @@
+local record mod
+	sand : function (string, string) : string
+	sor : function (string, string) : string
+	sxor : function (string, string) : string
+end
+return mod
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/teal-src/prosody/util/struct.d.tl	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -0,0 +1,101 @@
+-- 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, "%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 };
--- a/teal-src/util/compat.d.tl	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ /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 };
--- a/tools/cfgdump.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/tools/cfgdump.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/tools/dnsregistry.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/tools/ejabberdsql2prosody.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/tools/form2table.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/tools/http-status-codes.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/tools/jabberd14sql2prosody.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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/jabberd14.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/tools/migration/migrator/jabberd14.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/tools/migration/prosody-migrator.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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
@@ -200,7 +204,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	Tue Jul 25 12:35:11 2023 +0200
@@ -0,0 +1,4 @@
+#!/bin/sh
+set -eu
+
+echo "spec/${1//./_}_spec.lua"
--- a/tools/modtrace.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/tools/modtrace.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/tools/openfire2prosody.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/tools/tb2err	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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 "$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
+
--- a/util-src/GNUmakefile	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/GNUmakefile	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/compat.c	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/crand.c	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/encodings.c	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/hashes.c	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/net.c	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/poll.c	Tue Jul 25 12:35:11 2023 +0200
@@ -8,7 +8,6 @@
  *
  */
 
-#include <unistd.h>
 #include <string.h>
 #include <errno.h>
 
@@ -24,6 +23,7 @@
 #endif
 
 #ifdef USE_EPOLL
+#include <unistd.h>
 #include <sys/epoll.h>
 #ifndef MAX_EVENTS
 #define MAX_EVENTS 64
@@ -44,9 +44,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
@@ -563,10 +560,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 +614,8 @@
 	return 1;
 }
 
+/* COMPAT */
+int luaopen_util_poll(lua_State *L) {
+	return luaopen_prosody_util_poll(L);
+}
+
--- a/util-src/pposix.c	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/pposix.c	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/ringbuffer.c	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/signal.c	Tue Jul 25 12:35:11 2023 +0200
@@ -36,9 +36,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 < 503)
 #define lua_isinteger(L, n) lua_isnumber(L, n)
 #endif
@@ -380,10 +377,8 @@
 	{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);
-#endif
 	int i = 0;
 
 	/* add the library */
@@ -413,3 +408,6 @@
 
 	return 1;
 }
+int luaopen_util_signal(lua_State *L) {
+	return luaopen_prosody_util_signal(L);
+}
--- a/util-src/strbitop.c	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/strbitop.c	Tue Jul 25 12:35:11 2023 +0200
@@ -8,13 +8,10 @@
 #include <lua.h>
 #include <lauxlib.h>
 
-#if (LUA_VERSION_NUM == 501)
-#define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
-#endif
 
 /* 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 +32,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 +53,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,7 +74,7 @@
 	return 1;
 }
 
-LUA_API int luaopen_util_strbitop(lua_State *L) {
+LUA_API int luaopen_prosody_util_strbitop(lua_State *L) {
 	luaL_Reg exports[] = {
 		{ "sand", strop_and },
 		{ "sor",  strop_or },
@@ -89,3 +86,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/struct.c	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/table.c	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/time.c	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util-src/windows.c	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/adminstream.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/argparse.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/array.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/async.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -1,7 +1,7 @@
-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 function checkthread()
 	local thread, main = coroutine.running();
--- a/util/bitcompat.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/bitcompat.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/cache.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/caps.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/dataforms.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/datamanager.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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,159 @@
 	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
+		return setmetatable({ file = ih }, 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 +506,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";
@@ -414,4 +723,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/datamapper.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/datetime.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/dbuffer.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/debug.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/dependencies.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/dns.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/dnsregistry.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/envload.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/error.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/format.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/gc.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/hashring.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/helpers.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/hmac.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/http.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/human/io.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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,23 @@
 					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, m = 31 * day, mo = 31 * day, y = 365.2425 * day;
+	s = 1, mi = 60, h = 3600, ho = 3600
+};
+local function parse_duration(duration_string)
+	local n, m = duration_string:lower():match("(%d+)%s*([smhdwy]?[io]?)");
+	if not n then return nil; end
+	return tonumber(n) * ( multipliers[m] or 1 );
 end
 
 return {
@@ -187,6 +219,8 @@
 	printf = printf;
 	padleft = padleft;
 	padright = padright;
+	term_width = term_width;
 	ellipsis = ellipsis;
 	table = new_table;
+	parse_duration = parse_duration;
 };
--- a/util/human/units.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/human/units.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/id.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/import.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/ip.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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 hex = require "prosody.util.hex";
 
 local ip_methods = {};
 
@@ -241,9 +241,26 @@
 	return ipA.bits:sub(1, bits) == ipB.bits:sub(1, 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 {
 	new_ip = new_ip,
 	commonPrefixLength = commonPrefixLength,
 	parse_cidr = parse_cidr,
 	match = match,
+	is_ip = is_ip;
+	truncate = truncate;
 };
--- a/util/iterators.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/iterators.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/jid.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/json.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/jsonpointer.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/jsonschema.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -3,10 +3,16 @@
 local m_type = function(n)
 	return type(n) == "number" and n % 1 == 0 and n <= 9007199254740992 and n >= -9007199254740992 and "integer" or "float";
 end;
-local json = require("util.json")
+
+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("prosody.util.json")
 local null = json.null;
 
-local pointer = require("util.jsonpointer")
+local pointer = require("prosody.util.jsonpointer")
 
 local json_type_name = json.json_type_name
 
@@ -37,17 +43,10 @@
 	end
 end
 
-local complex_validate
-
 local function validate(schema, data, root)
 	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
@@ -103,10 +102,13 @@
 	end
 
 	if type(data) == "string" then
-		if schema.maxLength and #data > schema.maxLength then
+		if schema.maxLength and utf8_len(data) > schema.maxLength then
 			return false
 		end
-		if schema.minLength and #data < schema.minLength then
+		if schema.minLength and utf8_len(data) < schema.minLength then
+			return false
+		end
+		if schema.luaPattern and not data:match(schema.luaPattern) then
 			return false
 		end
 	end
@@ -190,11 +192,11 @@
 
 	if type(data) == "table" then
 
-		if schema.maxItems and #data > schema.maxItems then
+		if schema.maxItems and #(data) > schema.maxItems then
 			return false
 		end
 
-		if schema.minItems and #data < schema.minItems then
+		if schema.minItems and #(data) < schema.minItems then
 			return false
 		end
 
@@ -206,6 +208,18 @@
 			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
+							return false
+						end
+					end
+				end
+			end
+		end
+
 		if schema.propertyNames ~= nil then
 			for k in pairs(data) do
 				if not validate(schema.propertyNames, k, root) then
@@ -214,17 +228,34 @@
 			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
 				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) then
+							return false
+						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 seen_properties[k] then
 					if not validate(schema.additionalProperties, v, root) then
 						return false
 					end
@@ -232,6 +263,14 @@
 			end
 		end
 
+		if schema.dependentSchemas then
+			for k, sub in pairs(schema.dependentSchemas) do
+				if data[k] ~= nil and not validate(sub, data, root) then
+					return false
+				end
+			end
+		end
+
 		if schema.uniqueItems then
 
 			local values = {}
@@ -257,7 +296,7 @@
 		end
 
 		if schema.items ~= nil then
-			for i = p + 1, #data do
+			for i = p + 1, #(data) do
 				if not validate(schema.items, data[i], root) then
 					return false
 				end
@@ -265,14 +304,13 @@
 		end
 
 		if schema.contains ~= nil then
-			local found = false
-			for i = 1, #data do
+			local found = 0
+			for i = 1, #(data) do
 				if validate(schema.contains, data[i], root) then
-					found = true
-					break
+					found = found + 1
 				end
 			end
-			if not found then
+			if found < (schema.minContains or 1) or found > (schema.maxContains or math.huge) then
 				return false
 			end
 		end
--- a/util/jwt.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/jwt.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/logger.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/multitable.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/openmetrics.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/openssl.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/pluginloader.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/promise.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/prosodyctl.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/prosodyctl/cert.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/prosodyctl/check.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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,9 +309,9 @@
 		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 disabled_hosts(host, conf) return host ~= "*" and conf.enabled ~= false; end
 	local function enabled_hosts() return it.filter(disabled_hosts, pairs(configmanager.getconfig())); end
@@ -741,13 +741,13 @@
 		print("Done.\n");
 	end
 	if not what or what == "dns" then
-		local dns = require "net.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});
@@ -810,7 +810,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
@@ -1118,8 +1118,8 @@
 	if not what or what == "certs" then
 		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 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;
--- a/util/prosodyctl/shell.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/prosodyctl/shell.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -1,13 +1,16 @@
-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 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 unpack = table.unpack or _G.unpack;
+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 +30,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 +67,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
@@ -77,23 +81,26 @@
 	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));
+			arg[1] = string.format("%s:%s("..string.rep("%q", #arg-2,", ")..")", 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 +125,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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/pubsub.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/queue.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/random.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/rfc6724.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -9,7 +9,7 @@
 -- during S2S connections.
 -- We can't hand this off to getaddrinfo, since it blocks
 
-local ip_commonPrefixLength = require"util.ip".commonPrefixLength
+local ip_commonPrefixLength = require"prosody.util.ip".commonPrefixLength
 
 local function commonPrefixLength(ipA, ipB)
 	local len = ip_commonPrefixLength(ipA, ipB);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/roles.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/rsm.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/sasl.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/sasl/anonymous.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/sasl/external.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/sasl/plain.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/sasl/scram.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/serialization.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/session.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/set.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/smqueue.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -1,4 +1,4 @@
-local queue = require("util.queue");
+local queue = require("prosody.util.queue");
 
 local lib = { smqueue = {} }
 
--- a/util/sql.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/sql.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/sslconfig.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/stanza.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/startup.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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,51 @@
 	if remdebug then remdebug.engine.start() end
 	]]
 
-	require "util.stanza"
-	require "util.jid"
+	require "prosody.util.stanza"
+	require "prosody.util.jid"
 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
 
 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)
+		prosody.events.add_handler("server-started", function ()
+			resolve();
+		end);
+		prosody.log("debug", "Firing server-starting event");
+		prosody.events.fire_event("server-starting");
+		prosody.start_time = os.time();
+	end):catch(function (err)
+		prosody.log("error", "Prosody startup error: %s", err);
+	end);
 end
 
 function startup.init_global_protection()
@@ -460,7 +468,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 +479,10 @@
 end
 
 function startup.notify_started()
-	prosody.events.fire_event("server-started");
+	require "prosody.util.timer".add_task(0, function ()
+		prosody.log("debug", "Firing server-started event");
+		prosody.events.fire_event("server-started");
+	end);
 end
 
 -- Override logging config (used by prosodyctl)
@@ -498,7 +509,7 @@
 	-- it is already started as the appropriate user.
 
 	local want_pposix_version = "0.4.0";
-	local have_pposix, pposix = pcall(require, "util.pposix");
+	local have_pposix, pposix = pcall(require, "prosody.util.pposix");
 
 	if have_pposix and pposix then
 		if pposix._VERSION ~= want_pposix_version then
@@ -594,7 +605,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 +617,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 +626,7 @@
 		events = prosody.events,
 		modules = {},
 		sessions = {},
-		users = require "core.usermanager".new_null_provider(hostname)
+		users = require "prosody.core.usermanager".new_null_provider(hostname)
 	};
 end
 
--- a/util/statistics.lua	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/statistics.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/statsd.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/template.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/termcolours.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/throttle.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/timer.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/uuid.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -6,9 +6,9 @@
 -- 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 hex = require "prosody.util.hex".encode;
 local m_ceil = math.ceil;
 
 local function get_nibbles(n)
--- a/util/vcard.lua	Sat Jul 22 16:31:05 2023 +0200
+++ /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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/watchdog.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/x509.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/xml.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/xmppstream.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/xpcall.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -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	Sat Jul 22 16:31:05 2023 +0200
+++ b/util/xtemplate.lua	Tue Jul 25 12:35:11 2023 +0200
@@ -3,7 +3,7 @@
 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;