changeset 14221:565debd9c37a

Merge 13.0->trunk
author Matthew Wild <mwild1@gmail.com>
date Fri, 12 Jun 2026 13:05:10 +0100
parents 2ec517d8d43e (diff) 8f34af6a3821 (current diff)
children ad7f7b5f4792
files core/certmanager.lua core/features.lua util/human/io.lua util/prosodyctl/check.lua
diffstat 96 files changed, 2249 insertions(+), 623 deletions(-) [+]
line wrap: on
line diff
--- a/CHANGES	Fri Jun 12 13:04:54 2026 +0100
+++ b/CHANGES	Fri Jun 12 13:05:10 2026 +0100
@@ -1,6 +1,36 @@
 TRUNK
 =====
 
+## New
+
+- Support for Lua 5.5
+- Signals outgoing s2s connection failure by closing existing incoming s2s
+- Server-to-server connection attempt retry blocking
+- DNS resolver connection filters
+- pubsub: support for retrieve-affiliitations
+- HTTP errors may be returned as JSON if Accept-ed
+- mod_http_file_share warns about missing or unexpected files in storage
+- mod_offline can now optionally expire old messages (like mod_mam)
+- mod_muc_mam honours `archive_expires_after` if `muc_log_expires_after` not set
+
+### Shell
+
+- Tables printed as tab-separated values by `prosodyctl shell` when piped to commands
+- `prosodyctl shell --quiet` suppresses status message
+- `config:dump()` command showing the currently applied config
+- `debug:ping()` command shows a slice of debug logs while pinging a JID
+
+## API
+
+- `api:context()` with an unknown host no longer works
+- `request` is now included in `http-error` events
+- mod_mam has a new `archive-query-result` event
+- stanza meta api
+
+## Changes
+
+- DNSSEC support in libunbound enabled by default, requiring root DNSSEC trust anchors
+
 13.0.0
 ======
 
--- a/GNUmakefile	Fri Jun 12 13:04:54 2026 +0100
+++ b/GNUmakefile	Fri Jun 12 13:05:10 2026 +0100
@@ -141,9 +141,10 @@
 vpath %.tl teal-src/prosody
 %.lua: %.tl
 	tl -I teal-src/ --gen-compat off --gen-target 5.1 gen $^ -o $@
-	-lua-format -i $@
+	-lua-format -i --no-keep-simple-control-block-one-line --no-keep-simple-function-one-line $@
+	sed -i "1i-- This file is generated from $<" $@
 
-teal: util/jsonschema.lua util/datamapper.lua util/jsonpointer.lua
+teal: plugins/mod_cron.lua util/datamapper.lua util/jsonpointer.lua util/jsonschema.lua util/mathcompat.lua util/smqueue.lua util/xtemplate.lua
 
 util/%.so:
 	$(MAKE) install -C util-src
--- a/configure	Fri Jun 12 13:04:54 2026 +0100
+++ b/configure	Fri Jun 12 13:05:10 2026 +0100
@@ -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.2, 5.3, or 5.4
+--lua-version=VERSION       Use specific Lua version: 5.2, 5.3, 5.4, or 5.5
                             Default is auto-detected.
 --lua-suffix=SUFFIX         Versioning suffix to use in Lua filenames.
                             Default is "$LUA_SUFFIX" (lua$LUA_SUFFIX...)
@@ -174,7 +174,7 @@
       [ -n "$value" ] || die "Missing value in flag $key."
       LUA_VERSION="$value"
       [ "$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" = "5.2" ] || [ "$LUA_VERSION" = "5.3" ] || [ "$LUA_VERSION" = "5.4" ] || [ "$LUA_VERSION" = "5.5" ] || die "Invalid Lua version in flag $key."
       LUA_VERSION_SET=yes
       ;;
    --with-lua)
@@ -336,7 +336,7 @@
 fi
 
 detect_lua_version() {
-   detected_lua=$("$1" -e 'print(_VERSION:match(" (5%.[234])$"))' 2> /dev/null)
+   detected_lua=$("$1" -e 'print(_VERSION:match(" (5%.%d+)$"))' 2> /dev/null)
    if [ "$detected_lua" != "nil" ]
    then
       if [ "$LUA_VERSION_SET" != "yes" ]
@@ -399,10 +399,14 @@
    elif [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.4" ]
    then
       suffixes="5.4 54 -5.4 -54"
+   elif [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.5" ]
+   then
+      suffixes="5.5 55 -5.5 -55"
    else
       suffixes="5.2 52 -5.2 -52"
       suffixes="$suffixes 5.3 53 -5.3 -53"
       suffixes="$suffixes 5.4 54 -5.4 -54"
+      suffixes="$suffixes 5.5 55 -5.5 -55"
    fi
    for suffix in "" $suffixes
    do
@@ -508,6 +512,11 @@
 then
    echo_n "Checking if Lua header version matches that of the interpreter... "
    header_version=$(sed -n 's/.*LUA_VERSION_NUM.*5.\(.\).*/5.\1/p' "$lua_h")
+   if [ -z "$header_version" ]; then
+      header_major_version=$(sed -n 's/.*LUA_VERSION_MAJOR_N\s*.\([0-9]\).*/\1/p' "$lua_h")
+      header_minor_version=$(sed -n 's/.*LUA_VERSION_MINOR_N\s*.\([0-9]\).*/\1/p' "$lua_h")
+      header_version="$header_major_version.$header_minor_version"
+   fi
    if [ "$header_version" = "$LUA_VERSION" ]
    then
       echo "yes"
--- a/core/certmanager.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/core/certmanager.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -215,7 +215,7 @@
 		"!3DES",       -- 3DES - slow and of questionable security
 		"!aNULL",      -- Ciphers that does not authenticate the connection
 	};
-	dane = tls.features.capabilities.dane and configmanager.get("*", "use_dane") and { "no_ee_namechecks" };
+	dane = tls.features.capabilities.dane and configmanager.get("*", "use_dane") ~= false and { "no_ee_namechecks" };
 }
 
 if tls.features.curves then
@@ -329,7 +329,7 @@
 	if tls.features.options.no_compression then
 		core_defaults.options.no_compression = configmanager.get("*", "ssl_compression") ~= true;
 	end
-	if not configmanager.get("*", "use_dane") then
+	if configmanager.get("*", "use_dane") == false then
 		core_defaults.dane = false;
 	elseif tls.features.capabilities.dane then
 		core_defaults.dane = { "no_ee_namechecks" };
--- a/core/features.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/core/features.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -20,6 +20,8 @@
 		"loader";
 		-- "keyval+" store
 		"keyval+";
+		-- Support for target filter in net.resolvers.basic
+		"net-connect-filter";
 
 		"s2sout-pre-connect-event";
 
--- a/core/moduleapi.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/core/moduleapi.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -93,7 +93,14 @@
 end
 
 function api:hook(event, handler, priority)
-	return self:hook_object_event((hosts[self.host] or prosody).events, event, handler, priority);
+	local events;
+	local host = self.host;
+	if host == "*" then
+		events = prosody.events;
+	else
+		events = hosts[host].events;
+	end
+	return self:hook_object_event(events, event, handler, priority);
 end
 
 function api:hook_global(event, handler, priority)
@@ -406,7 +413,14 @@
 end
 
 function api:context(host)
-	return setmetatable({ host = host or "*", global = "*" == host }, { __index = self, __newindex = self });
+	local is_global = false;
+	if not host or host == "*" then
+		host, is_global = "*", true;
+	end
+	if not is_global and not hosts[host] then
+		error("Cannot create context for unknown host: "..host);
+	end
+	return setmetatable({ host = host, global = is_global }, { __index = self, __newindex = self });
 end
 
 function api:add_item(key, value)
--- a/core/s2smanager.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/core/s2smanager.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -91,8 +91,17 @@
 	log("debug", "Destroying %s session %s->%s%s%s", session.direction, session.from_host, session.to_host, reason and ": " or "", reason or "");
 
 	if session.direction == "outgoing" then
-		hosts[session.from_host].s2sout[session.to_host] = nil;
-		session:bounce_sendq(bounce_reason or reason);
+		if session.block_retries then
+			-- Reject any attempts to communicate with the failed domain
+			-- while we are bouncing the stanzas
+			session.send = function () return false; end
+			session.sends2s = function () return false; end
+			session:bounce_sendq(bounce_reason or reason);
+			hosts[session.from_host].s2sout[session.to_host] = nil;
+		else
+			hosts[session.from_host].s2sout[session.to_host] = nil;
+			session:bounce_sendq(bounce_reason or reason);
+		end
 	elseif session.direction == "incoming" then
 		if session.outgoing and hosts[session.to_host].s2sout[session.from_host] == session then
 			hosts[session.to_host].s2sout[session.from_host] = nil;
--- a/core/sessionmanager.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/core/sessionmanager.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -24,7 +24,7 @@
 local sessionlib = require "prosody.util.session";
 
 local initialize_filters = require "prosody.util.filters".initialize;
-local gettime = require "socket".gettime;
+local time_now = require "prosody.util.time".now;
 
 local _ENV = nil;
 -- luacheck: std none
@@ -35,7 +35,7 @@
 	sessionlib.set_logger(session);
 	sessionlib.set_conn(session, conn);
 
-	session.conntime = gettime();
+	session.conntime = time_now();
 	local filter = initialize_filters(session);
 	local w = conn.write;
 
--- a/core/usermanager.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/core/usermanager.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -98,53 +98,106 @@
 end;
 prosody.events.add_handler("host-activated", initialize_host, 100);
 
+local function provider_helpers(provider_type)
+	local function get_provider(host)
+		local host_session = hosts[host];
+		if not host_session then
+			return nil, "unknown host";
+		end
+		local provider = host_session[provider_type];
+		if not provider then
+			return nil, "no "..provider_type.." provider";
+		end
+		return provider;
+	end
+
+	local function get_provider_method(host, method_name)
+		local provider, err = get_provider(host);
+		if not provider then
+			return nil, err;
+		end
+		local method = provider[method_name];
+		if not method then
+			return nil, "method not implemented";
+		end
+		return method;
+	end
+
+	local function call_method(host, method_name, log_message, pre_event, post_event, event, ...)
+		local method, method_err = get_provider_method(host, method_name);
+		if not method then return nil, method_err; end
+		if pre_event and prosody.events.fire_event(pre_event, event) ~= nil then
+			return nil, event.reason or "prevented by module";
+		end
+		local ok, err = method(...);
+		if not ok then
+			return ok, err;
+		end
+		if log_message then
+			log("info", "%s: %s@%s", log_message, event.username, event.host);
+		end
+		if post_event then
+			prosody.events.fire_event(post_event, event);
+		end
+		return ok, err;
+	end
+
+	local function call_quiet_method(host, method_name, ...)
+		return call_method(host, method_name, nil, nil, nil, nil, ...);
+	end
+
+	return get_provider, get_provider_method, call_method, call_quiet_method;
+end
+
+local get_provider, _, call_method, call_quiet_method = provider_helpers("users");
+
 local function test_password(username, host, password)
-	return hosts[host].users.test_password(username, password);
+	return call_quiet_method(host, "test_password", username, password);
 end
 
 local function get_password(username, host)
-	return hosts[host].users.get_password(username);
+	return call_quiet_method(host, "get_password", username);
 end
 
 local function set_password(username, password, host, resource)
-	local ok, err = hosts[host].users.set_password(username, password);
-	if ok then
-		log("info", "Account password changed: %s@%s", username, host);
-		prosody.events.fire_event("user-password-changed", { username = username, host = host, resource = resource });
-	end
-	return ok, err;
+	return call_method(host, "set_password", "Account password changed", "pre-user-password-change", "user-password-changed", {
+		username = username, host = host, resource = resource
+	}, username, password);
 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);
+	return call_quiet_method(host, "get_account_info", username);
 end
 
 local function user_exists(username, host)
-	if hosts[host].sessions[username] then return true; end
-	return hosts[host].users.user_exists(username);
+	if hosts[host].sessions[username] then
+		-- Shortcut for online users, we know they exist :)
+		return true;
+	end
+	return call_quiet_method(host, "user_exists", username);
 end
 
 local function create_user(username, password, host)
-	local ok, err = hosts[host].users.create_user(username, password);
-	if ok then
-		log("info", "User account created: %s@%s", username, host);
-	end
-	return ok, err;
+	return call_method(host, "create_user", "User account created", "pre-create-user", "user-created", {
+		username = username, host = host
+	}, username, password);
 end
 
 local function delete_user(username, host)
-	local ok, err = hosts[host].users.delete_user(username);
+	local ok, err = call_method(host, "delete_user", "User account deleted", "pre-delete-user", "user-deleted", {
+		username = username, host = host
+	}, username);
 	if not ok then return nil, err; end
-	log("info", "User account deleted: %s@%s", username, host);
-	prosody.events.fire_event("user-deleted", { username = username, host = host });
 	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
+	local ret, err = call_quiet_method(host, "is_enabled", username);
+	if ret ~= nil then
+		return ret;
+	elseif err ~= "method not implemented" then
+		return nil, err;
+	end
 
 	-- Fallback
 	local info, err = get_account_info(username, host);
@@ -160,58 +213,54 @@
 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
-		log("info", "User account enabled: %s@%s", username, host);
-		prosody.events.fire_event("user-enabled", { username = username, host = host });
-	end
-	return ret, err;
+	return call_method(host, "enable", "User account enabled", "pre-enable-user", "user-enabled", {
+		username = username, host = host
+	}, username);
 end
 
 local function disable_user(username, host, meta)
-	local method = hosts[host].users.disable;
-	if not method then return nil, "method not supported"; end
-	local ret, err = method(username, meta);
-	if ret then
-		log("info", "User account disabled: %s@%s", username, host);
-		prosody.events.fire_event("user-disabled", { username = username, host = host, meta = meta });
-	end
-	return ret, err;
+	return call_method(host, "disable", "User account disabled", "pre-disable-user", "user-disabled", {
+		username = username, host = host, meta = meta
+	}, username, meta);
 end
 
+
 local function users(host)
-	return hosts[host].users.users();
+	return call_quiet_method(host, "users");
 end
 
 local function get_sasl_handler(host, session)
-	return hosts[host].users.get_sasl_handler(session);
+	return call_quiet_method(host, "get_sasl_handler", session);
 end
 
-local function get_provider(host)
-	return hosts[host].users;
-end
+local _, _, call_authz_method, call_quiet_authz_method = provider_helpers("authz");
+
 
 local function get_user_role(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_role(user);
+	return call_quiet_authz_method(host, "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 role, err = hosts[host].authz.set_user_role(user, role_name);
-	if role then
-		log("info", "Account %s@%s role changed to %s", user, host, role_name);
-		prosody.events.fire_event("user-role-changed", {
-			username = user, host = host, role = role;
-		});
+	local event = { username = user, host = host, role_name = role_name, role = nil };
+	local role, err = call_authz_method(
+		host,
+		"set_user_role",
+		"Account role changed to <"..role_name..">",
+		"pre-user-role-change",
+		nil,
+		event,
+		user,
+		role_name
+	);
+	if not role then
+		return nil, err;
 	end
-	return role, err;
+	event.role = role;
+	prosody.events.fire_event("user-role-changed", event);
+	return role;
 end
 
 local function create_user_with_role(username, password, host, role)
@@ -240,51 +289,58 @@
 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);
+	return call_quiet_authz_method(host, "user_can_assume_role", user, role_name);
 end
 
 local function add_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 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_name = role_name, role = role;
-		});
+	local event = { username = user, host = host, role_name = role_name, role = nil };
+	local role, err = call_authz_method(
+		host,
+		"add_user_secondary_role",
+		"Account gained role <"..role_name..">",
+		"pre-add-user-role",
+		"user-role-added",
+		event,
+		user,
+		role_name
+	);
+	if not role then
+		return nil, err;
 	end
-	return role, err;
+	event.role = role;
+	prosody.events.fire_event("user-role-added", event);
+	return role;
 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;
+	return call_authz_method(
+		host,
+		"remove_user_secondary_role",
+		"Account lost role <"..role_name..">",
+		"pre-remove-user-role",
+		"user-role-removed",
+		{ username = user, host = host, role_name = role_name },
+		user,
+		role_name
+	);
 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);
+	return call_quiet_authz_method(host, "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);
+		return call_quiet_authz_method(host, "get_user_role", jid_node);
 	end
-	return hosts[host].authz.get_jid_role(jid);
+	return call_quiet_authz_method(host, "get_jid_role", jid);
 end
 
 local function set_jid_role(jid, host, role_name)
@@ -292,7 +348,7 @@
 	if host == jid_host then
 		return nil, "unexpected-local-jid";
 	end
-	return hosts[host].authz.set_jid_role(jid, role_name)
+	return call_quiet_authz_method(host, "set_jid_role", jid, role_name);
 end
 
 local strict_deprecate_is_admin;
@@ -312,26 +368,23 @@
 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);
+	return call_quiet_authz_method(host, "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
-	return hosts[host].authz.get_jids_with_role(role);
+	return call_quiet_authz_method(host, "get_jids_with_role", role);
 end
 
 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);
+	return call_quiet_authz_method(host, "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();
+	return call_quiet_authz_method(host, "get_all_roles");
 end
 
 return {
--- a/doc/doap.xml	Fri Jun 12 13:04:54 2026 +0100
+++ b/doc/doap.xml	Fri Jun 12 13:05:10 2026 +0100
@@ -911,6 +911,7 @@
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0478.html"/>
         <xmpp:version>0.2.0</xmpp:version>
         <xmpp:since>13.0.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
       </xmpp:SupportedXep>
     </implements>
     <implements>
@@ -918,6 +919,7 @@
         <xmpp:xep rdf:resource="https://xmpp.org/extensions/xep-0486.html"/>
         <xmpp:version>0.1.0</xmpp:version>
         <xmpp:since>13.0.0</xmpp:since>
+        <xmpp:status>complete</xmpp:status>
       </xmpp:SupportedXep>
     </implements>
   </Project>
--- a/fallbacks/lxp.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/fallbacks/lxp.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -86,9 +86,9 @@
 		local newattr, n = {}, 0;
 		for k,v in pairs(attr) do
 			n = n+1;
-			k = apply_ns(k);
-			newattr[n] = k;
-			newattr[k] = v;
+			local k_ = apply_ns(k);
+			newattr[n] = k_;
+			newattr[k_] = v;
 		end
 		tag = apply_ns(tag, true);
 		ns[0] = tag;
--- a/net/dns.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/net/dns.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -16,6 +16,7 @@
 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 time_now = require "prosody.util.time".now;
 
 local log = require "prosody.util.logger".init("dns");
 
@@ -237,7 +238,7 @@
 
 
 local function prune(rrs, time, soft)    -- - - - - - - - - - - - - - -  prune
-	time = time or socket.gettime();
+	time = time or time_now();
 	for i,rr in ipairs(rrs) do
 		if rr.tod then
 			if rr.tod < time then
@@ -300,7 +301,7 @@
 
 local cache_metatable = {};    -- - - - - - - - - - - - - - - -  cache_metatable
 function cache_metatable.__tostring(cache)
-	local time = socket.gettime();
+	local time = time_now();
 	local t = {};
 	for class,types in pairs(cache) do
 		for type,names in pairs(types) do
@@ -318,7 +319,7 @@
 
 
 function dns.random(...)    -- - - - - - - - - - - - - - - - - - -  dns.random
-	math.randomseed(math.floor(10000*socket.gettime()) % 0x80000000);
+	math.randomseed(math.floor(10000 * time_now()) % 0x80000000);
 	dns.random = math.random;
 	return dns.random(...);
 end
@@ -701,10 +702,10 @@
 		local resolv_conf = io.open("/etc/resolv.conf");
 		if resolv_conf then
 			for line in resolv_conf:lines() do
-				line = line:gsub("#.*$", "")
+				local nameserver = line:gsub("#.*$", "")
 					:match('^%s*nameserver%s+([%x:%.]*%%?%S*)%s*$');
-				if line then
-					local ip = new_ip(line);
+				if nameserver then
+					local ip = new_ip(nameserver);
 					if ip then
 						self:addnameserver(ip.addr);
 					end
@@ -813,7 +814,7 @@
 		if not (rrs and rrs[1]) then return end
 		return self:peek(rrs[1].cname, qtype, qclass, n - 1);
 	end
-	if prune(rrs, socket.gettime()) and qtype == '*' or not next(rrs) then
+	if prune(rrs, time_now()) and qtype == '*' or not next(rrs) then
 		set(self.cache, qclass, qtype, qname, nil);
 		return nil;
 	end
@@ -824,7 +825,7 @@
 
 function resolver:purge(soft)    -- - - - - - - - - - - - - - - - - - -  purge
 	if soft == 'soft' then
-		self.time = socket.gettime();
+		self.time = time_now();
 		for class,types in pairs(self.cache or {}) do
 			for type,names in pairs(types) do
 				for name,rrs in pairs(names) do
@@ -859,7 +860,7 @@
 		packet = header..question,
 		server = self.best_server,
 		delay  = 1,
-		retry  = socket.gettime() + self.delays[1];
+		retry  = time_now() + self.delays[1];
 		qclass = qclass;
 		qtype  = qtype;
 		qname  = qname;
@@ -914,7 +915,7 @@
 	sock = self:voidsocket(sock);
 
 	-- Find all requests to the down server, and retry on the next server
-	self.time = socket.gettime();
+	self.time = time_now();
 	log("debug", "servfail %d (of %d)", num, #self.server);
 	for id,queries in pairs(self.active) do
 		for question,o in pairs(queries) do
@@ -970,7 +971,7 @@
 
 function resolver:receive(rset)    -- - - - - - - - - - - - - - - - -  receive
 	--print('receive');  print(self.socket);
-	self.time = socket.gettime();
+	self.time = time_now();
 	rset = rset or self.socket;
 
 	local response;
@@ -1019,7 +1020,7 @@
 
 function resolver:feed(sock, packet, force)
 	--print('receive'); print(self.socket);
-	self.time = socket.gettime();
+	self.time = time_now();
 
 	local response = self:decode(packet, force);
 	if response and self.active[response.header.id]
@@ -1072,7 +1073,7 @@
 	while self:receive() do end
 	if not next(self.active) then return nil; end
 
-	self.time = socket.gettime();
+	self.time = time_now();
 	for id,queries in pairs(self.active) do
 		for question,o in pairs(queries) do
 			if self.time >= o.retry then
--- a/net/http.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/net/http.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -339,7 +339,11 @@
 		end
 	end
 
-	local http_service = basic_resolver.new(host, port_number, "tcp", { servername = req.host; use_dane = use_dane });
+	local http_service = basic_resolver.new(host, port_number, "tcp", {
+		servername = req.host;
+		use_dane = use_dane;
+		filter = ex and ex.target_filter;
+	});
 	connect(http_service, listener, { sslctx = sslctx }, req);
 
 	self.events.fire_event("request", { http = self, request = req, url = u });
--- a/net/http/files.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/net/http/files.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -29,8 +29,8 @@
 	local out = {};
 
 	local c = 0;
-	for component in path:gmatch("([^/]+)") do
-		component = urldecode(component);
+	for urlcomponent in path:gmatch("([^/]+)") do
+		local component = urldecode(urlcomponent);
 		if component:find(forbidden_chars_pattern) then
 			return nil;
 		elseif component == ".." then
--- a/net/http/server.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/net/http/server.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -13,7 +13,7 @@
 local codes = require "prosody.net.http.codes";
 local promise = require "prosody.util.promise";
 local errors = require "prosody.util.error";
-local blocksize = 2^16;
+local blocksize = 65536;
 local async = require "prosody.util.async";
 local id = require"prosody.util.id";
 
@@ -287,7 +287,7 @@
 
 	if err then
 		response.status_code = err_code;
-		response:send(events.fire_event("http-error", { code = err_code, message = err, response = response }));
+		response:send(events.fire_event("http-error", { code = err_code, message = err, request = request, response = response }));
 		return;
 	end
 
@@ -312,7 +312,7 @@
 
 		if err then
 			response.status_code = err_code;
-			response:send(events.fire_event("http-error", { code = err_code, message = err, response = response }));
+			response:send(events.fire_event("http-error", { code = err_code, message = err, request = request, response = response }));
 			return;
 		end
 
@@ -365,7 +365,7 @@
 	response.conn:write(t_concat(output));
 	response:done();
 end
-function _M.send_file(response, f)
+function _M.send_file(response, f, length)
 	if response.is_head_request then
 		if f.close then f:close(); end
 		return _M.send_head_response(response);
@@ -379,14 +379,11 @@
 			incomplete[response.conn] = nil;
 			return;
 		end
-		local chunk = f:read(blocksize);
-		if chunk then
-			if chunked then
-				chunk = ("%x\r\n%s\r\n"):format(#chunk, chunk);
-			end
-			-- io.write("."); io.flush();
-			response.conn:write(chunk);
-		else
+		local chunksize = blocksize;
+		if length and length < chunksize then chunksize = length; end
+
+		local chunk = chunksize > 0 and f:read(chunksize);
+		if not chunk then
 			incomplete[response.conn] = nil;
 			if f.close then f:close(); end
 			if chunked then
@@ -394,6 +391,11 @@
 			end
 			-- io.write("\n");
 			return response:done();
+		else
+			if length then length = length - #chunk; end
+			if chunked then chunk = ("%x\r\n%s\r\n"):format(#chunk, chunk); end
+			-- io.write("."); io.flush();
+			response.conn:write(chunk);
 		end
 	end
 	_M.write_headers(response);
--- a/net/resolvers/basic.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/net/resolvers/basic.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -66,6 +66,9 @@
 			return;
 		end
 		local next_target = table.remove(self.targets, 1);
+		if self.filter and not self.filter(next_target[1], next_target[2], next_target[3], next_target[4], not not self.targets[1]) then
+			return self:next(cb);
+		end
 		cb(next_target[1], next_target[2], next_target[3], next_target[4], not not self.targets[1]);
 		return;
 	end
@@ -86,8 +89,8 @@
 	local dns_resolver = adns.resolver();
 
 	local dns_lookups = {
+		ipv6 = do_dns_lookup(self, dns_resolver, "AAAA", self.hostname, true);
 		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));
 	};
 
@@ -148,6 +151,7 @@
 		port = port;
 		conn_type = conn_type;
 		extra = extra or {};
+		filter = extra and extra.filter;
 		targets = targets;
 	}, resolver_mt);
 end
--- a/net/server_epoll.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/net/server_epoll.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -550,7 +550,7 @@
 		-- Sent the whole 'data' but there's more in the buffer
 		ok, err, partial = nil, "timeout", ok;
 	end
-	self:debug("Sent %d out of %d buffered bytes", ok and #data or partial or 0, #buffer);
+	self:noise("Sent %d out of %d buffered bytes", ok and #data or partial or 0, #buffer);
 	if ok then -- all the data we had was sent successfully
 		self:set(nil, false);
 		if cfg.keep_buffers and type(buffer) == "table" then
--- a/net/server_event.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/net/server_event.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -54,8 +54,7 @@
 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 time_now = require "prosody.util.time".now;
 
 local log = require ("prosody.util.logger").init("socket")
 
@@ -521,7 +520,7 @@
 	local interface = {
 		type = "client";
 		conn = client;
-		currenttime = socket_gettime( );  -- safe the origin
+		currenttime = time_now();  -- safe the origin
 		writebuffer = {};  -- writebuffer
 		writebufferlen = 0;  -- length of writebuffer
 		send = client.send;  -- caching table lookups
@@ -918,7 +917,7 @@
 local function add_task(delay, callback)
 	local event_handle;
 	event_handle = base:addevent(nil, 0, function ()
-		local ret = callback(socket_gettime());
+		local ret = callback(time_now());
 		if ret then
 			return 0, ret;
 		elseif event_handle then
--- a/net/server_select.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/net/server_select.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -48,7 +48,7 @@
 --// extern libs //--
 
 local luasocket = use "socket" or require "socket"
-local luasocket_gettime = luasocket.gettime
+local time_now = require "prosody.util.time".now;
 local inet = require "prosody.util.net";
 local inet_pton = inet.pton;
 local sslconfig = require "prosody.util.sslconfig";
@@ -620,6 +620,7 @@
 					_sendlistlen = ( wrote and removesocket( _sendlist, client, _sendlistlen ) ) or _sendlistlen
 					_readlistlen = ( read and removesocket( _readlist, client, _readlistlen ) ) or _readlistlen
 					read, wrote = nil, nil
+					local _
 					_, err = client:dohandshake( )
 					if not err then
 						out_put( "server.lua: ssl handshake done" )
@@ -918,7 +919,7 @@
 	local new_data = {};
 
 	function add_task(delay, callback)
-		local current_time = luasocket_gettime();
+		local current_time = time_now();
 		delay = delay + current_time;
 		if delay >= current_time then
 			table_insert(new_data, {delay, callback});
@@ -969,7 +970,7 @@
 loop = function(once) -- this is the main loop of the program
 	if quitting then return "quitting"; end
 	if once then quitting = "once"; end
-	_currenttime = luasocket_gettime( )
+	_currenttime = time_now();
 	repeat
 		-- Fire timers
 	local next_timer_time = math_huge;
@@ -1002,7 +1003,7 @@
 			handler:force_close()	 -- forced disconnect
 			_closelist[ handler ] = nil;
 		end
-		_currenttime = luasocket_gettime( )
+		_currenttime = time_now();
 
 		-- Check for socket timeouts
 		if _currenttime - _starttime > _checkinterval then
@@ -1150,7 +1151,7 @@
 use "setmetatable" ( _readtimes, { __mode = "k" } )
 use "setmetatable" ( _writetimes, { __mode = "k" } )
 
-_starttime = luasocket_gettime( )
+_starttime = time_now();
 
 local function setlogger(new_logger)
 	local old_logger = log;
--- a/net/unbound.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/net/unbound.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -25,15 +25,29 @@
 local classes, types, errors = dns_utils.classes, dns_utils.types, dns_utils.errors;
 local parsers = dns_utils.parsers;
 
-local builtin_defaults = { hoststxt = false }
+local builtin_defaults = {
+	-- Continue net.dns tradition of not reading /etc/hosts which does not fit well with SRV records anyway
+	hoststxt = false;
+	-- net.dns did read /etc/resolv.conf and we want libunbound to instead of operating in recursive mode
+	resolvconf = true;
+}
 
-local function add_defaults(conf)
+-- Path to file containing current DNSSEC trust anchors, needed to enable DNSSEC.
+-- Packagers: Adjust this path if it is different on your distribution
+local trustfile = "/usr/share/dns/root.ds";
+
+local function add_defaults(conf, use_dnssec)
 	conf = conf or {};
 	for option, default in pairs(builtin_defaults) do
 		if conf[option] == nil then
 			conf[option] = default;
 		end
 	end
+	if use_dnssec ~= false then
+		if conf["trustfile"] == nil and conf["trusted"] == nil then
+			conf["trustfile"] = trustfile;
+		end
+	end
 	for option, default in pairs(libunbound.config) do
 		if conf[option] == nil then
 			conf[option] = default;
@@ -43,7 +57,6 @@
 end
 
 local unbound_config;
--- Note: libunbound will default to using root hints if resolvconf is unset
 
 local function connect_server(unbound, server)
 	log("debug", "Setting up net.server event handling for %s", unbound);
@@ -108,6 +121,8 @@
 	return measure;
 end
 
+local count = measure;
+
 local function lookup(callback, qname, qtype, qclass)
 	if not unbound then initialize(); end
 	qtype = qtype and s_upper(qtype) or "A";
@@ -119,6 +134,7 @@
 	local function callback_wrapper(a, err)
 		m();
 		if a then
+			count(qclass, qtype, #a);
 			prep_answer(a);
 			log_query("debug", "Results for %s %s %s: %s (%s)", qname, qclass, qtype, a.rcode == 0 and (#a .. " items") or a.status,
 				a.secure and "Secure" or a.bogus or "Insecure"); -- Insecure as in unsigned
@@ -163,10 +179,11 @@
 end
 
 if prosody then
-	local config = require"prosody.core.configmanager";
-	unbound_config = add_defaults(config.get("*", "unbound"));
+	-- TODO move this glue logic into util.startup?
+	local config = require "prosody.core.configmanager";
+	unbound_config = add_defaults(config.get("*", "unbound"), config.get("*", "use_dnssec"));
 	prosody.events.add_handler("config-reloaded", function()
-		unbound_config = add_defaults(config.get("*", "unbound"));
+		unbound_config = add_defaults(config.get("*", "unbound"), config.get("*", "use_dnssec"));
 		purge();
 	end);
 	prosody.events.add_handler("server-started", initialize);
@@ -221,7 +238,7 @@
 	};
 }
 
-_M.instrument = function(measure_) measure = measure_; end;
+_M.instrument = function(measure_, count_) measure, count = measure_, count_; end;
 
 function _M.resolver() return wrapper; end
 
--- a/plugins/mod_account_activity.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_account_activity.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -51,17 +51,23 @@
 	section_desc = "View user activity data";
 	name = "list_inactive";
 	desc = "List inactive user accounts";
+	flags = {
+		bool_params = { only_unknown = true, hide_logins = true };
+	};
 	args = {
 		{ name = "host"; type = "string" };
 		{ name = "duration"; type = "string" };
 	};
 	host_selector = "host";
-	handler = function(self, host, duration) --luacheck: ignore 212/self
+	handler = function(self, host, duration, opts) --luacheck: ignore 212/self
+		opts = opts or {};
+
 		local um = require "prosody.core.usermanager";
 		local duration_sec = require "prosody.util.human.io".parse_duration(duration or "");
 		if not duration_sec then
 			return false, ("Invalid duration %q - try something like \"30d\""):format(duration);
 		end
+		local only_unknown, show_logins = opts.only_unknown, not opts.hide_logins;
 
 		local now = os.time();
 		local n_inactive, n_unknown = 0, 0;
@@ -73,20 +79,27 @@
 				local created_at;
 				if user_info then
 					created_at  = user_info.created;
-					if created_at and (now - created_at) > duration_sec then
+					if created_at and (now - created_at) > duration_sec and not only_unknown then
 						self.session.print(username, "");
 						n_inactive = n_inactive + 1;
 					end
 				end
 				if not created_at then
 					n_unknown = n_unknown + 1;
+					if only_unknown then
+						self.session.print(username, "");
+					end
 				end
-			elseif (now - last_active) > duration_sec then
+			elseif show_logins and (now - last_active) > duration_sec and not only_unknown then
 				self.session.print(username, os.date("%Y-%m-%dT%T", last_active));
 				n_inactive = n_inactive + 1;
 			end
 		end
 
+		if only_unknown then
+			return true, ("%d accounts with unknown last activity"):format(n_unknown);
+		end
+
 		if n_unknown > 0 then
 			return true, ("%d accounts inactive since %s (%d unknown)"):format(n_inactive, os.date("%Y-%m-%dT%T", now - duration_sec), n_unknown);
 		end
--- a/plugins/mod_admin_shell.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_admin_shell.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -20,6 +20,7 @@
 local schema = require "prosody.util.jsonschema";
 local st = require "prosody.util.stanza";
 local parse_args = require "prosody.util.argparse".parse;
+local dt = require "prosody.util.datetime";
 local saslprep = require "prosody.util.encodings".stringprep.saslprep;
 
 local _G = _G;
@@ -120,7 +121,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, self.session.width)
+	local row = format_table(meta_columns, self.session.width, self.session.column_separator)
 	print(row());
 	for column, spec in iterators.sorted_pairs(available_columns) do
 		print(row({ column, spec.title, spec.description }));
@@ -346,12 +347,17 @@
 
 	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;
+	session.width = tonumber(event.stanza.attr.width) or default_width;
 
 	local line = event.stanza:get_text();
 	local useglobalenv;
 
 	session.repl = event.stanza.attr.repl ~= "0";
+	if session.repl and session.width ~= -1 then
+		session.width = session.width - margin;
+	elseif session.width == -1 then
+		session.column_separator = "\t";
+	end
 
 	local result = st.stanza("repl-result");
 
@@ -492,7 +498,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%" } }, session.width)
+	local row = format_table({ { title = "Section", width = 7 }, { title = "Description", width = "100%" } }, session.width, session.column_separator)
 	print(row())
 	for section_name, section in it.sorted_pairs(def_env) do
 		local section_mt = getmetatable(section);
@@ -992,6 +998,44 @@
 	return ok, (ok and "Config reloaded (you may need to reload modules to take effect)") or tostring(err);
 end
 
+describe_command [[config:dump() - Display the current server configuration from memory]]
+function def_env.config:dump()
+	local config = require "prosody.core.configmanager".getconfig();
+	local serialize = require "prosody.util.serialization".serialize;
+
+	local print = self.session.print;
+
+	print("-- Configuration dumped from Prosody "..prosody.version);
+	print("-- at "..os.date("%Y-%m-%d %R").."\n");
+	print("-- Global options\n")
+	for opt, val in it.sorted_pairs(config["*"]) do
+		print(opt.." = "..serialize(val, "pretty").."\n");
+	end
+
+	print("-- Hosts and components\n");
+
+	local n_hosts = 0;
+	for host in it.filter("*", it.sorted_pairs(config)) do
+		n_hosts = n_hosts + 1;
+		local host_config = config[host];
+		if host_config.type ~= "component" then
+			print(("\nVirtualHost %q"):format(host));
+		else
+			print(("\nComponent %q %q"):format(host, host_config.component_module));
+		end
+
+		for opt, val in it.sorted_pairs(host_config) do
+			if opt ~= "component_module" then
+				print("\t"..opt.." = "..serialize(val, "pretty").."\n");
+			end
+		end
+	end
+
+	print("\n-- End of configuration --");
+
+	return true, "Showing server configuration with "..n_hosts.." hosts";
+end
+
 def_env.c2s = new_section("Commands to manage local client-to-server sessions");
 
 local function get_jid(session)
@@ -1311,7 +1355,7 @@
 function def_env.c2s:show(match_jid, colspec)
 	local print = self.session.print;
 	local columns = get_colspec(colspec, { "id"; "jid"; "role"; "ipv"; "status"; "secure"; "smacks"; "csi" });
-	local row = format_table(columns, self.session.width);
+	local row = format_table(columns, self.session.width, self.session.column_separator);
 
 	local function match(session)
 		local jid = get_jid(session)
@@ -1416,7 +1460,7 @@
 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, self.session.width);
+	local row = format_table(columns, self.session.width, self.session.column_separator);
 
 	local function match(session)
 		return match_s2s_jid(session, match_jid);
@@ -1773,7 +1817,7 @@
 		{ 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);
+	}, self.session.width, self.session.column_separator);
 	local occupants = array.collect(iterators.select(2, room_obj:each_occupant()));
 	local total = #occupants;
 	if filter then
@@ -1818,7 +1862,7 @@
 		{ title = "Affiliation"; width = 12 }; -- longest affiliation name
 		{ title = "JID"; width = "75%" };
 		{ title = "Nickname"; width = "25%"; key = "reserved_nickname" };
-	}, self.session.width);
+	}, self.session.width, self.session.column_separator);
 	local affiliated = array();
 	for affiliated_jid, affiliation, affiliation_data in room_obj:each_affiliation() do
 		affiliated:push(setmetatable({ affiliation; affiliated_jid }, { __index = affiliation_data }));
@@ -2178,12 +2222,12 @@
 	local output_simple = format_table({
 		{ title = "Module"; width = "1p" };
 		{ title = "External URL"; width = "6p" };
-	}, self.session.width);
+	}, self.session.width, self.session.column_separator);
 	local output_split = format_table({
 		{ title = "Module"; width = "1p" };
 		{ title = "External URL"; width = "3p" };
 		{ title = "Internal URL"; width = "3p" };
-	}, self.session.width);
+	}, self.session.width, self.session.column_separator);
 
 	for _, host in ipairs(hosts) do
 		local http_apps = modulemanager.get_items("http-provider", host);
@@ -2399,7 +2443,7 @@
 		{ title = "Function"; width = "10p" };
 		{ title = "Status"; width = "16" };
 		{ title = "Location"; width = "10p" };
-	}, self.session.width);
+	}, self.session.width, self.session.column_separator);
 	print(row())
 
 	local c = 0;
@@ -2464,7 +2508,7 @@
 		{ title = "Domain", width = max_domain };
 		{ title = "Certificate", width = "100%" };
 		{ title = "Service", width = 5 };
-	}, self.session.width);
+	}, self.session.width, self.session.column_separator);
 	print(row());
 	print(("-"):rep(self.session.width or 80));
 	for domain, certs in it.sorted_pairs(index) do
@@ -2481,6 +2525,44 @@
 	return true, ("Showing %d certificates in %s"):format(c, path);
 end
 
+describe_command [[debug:ping(localhost, remotehost) - Sends a ping to a remote XMPP server while printing debug logs]]
+function def_env.debug:ping(localjid, remotejid, timeout)
+	local localnode, localhost, localresource = jid_split(localjid);
+	local remotenode, remotehost, remoteresource = jid_split(remotejid);
+	if not localhost then
+		return nil, "Invalid sender JID";
+	elseif not prosody.hosts[localhost] then
+		return nil, "No such local host";
+	elseif localresource then
+		return nil, "Server can't send ping from local resource"; -- Would require creating a temporary session with bound resource and everything
+	end
+	if not remotehost then
+		return nil, "Invalid destination JID";
+	end
+	local iq = st.iq { from = jid_join(localnode, localhost); to = jid_join(remotenode, remotehost, remoteresource); type = "get"; id = new_id() }
+			:tag("ping", {xmlns="urn:xmpp:ping"});
+
+	local time_start = time.now();
+	local print = self.session.print;
+
+	local writing = false;
+	local sink = logger.add_simple_sink(function (source, level, message)
+		if writing then return; end
+		writing = true;
+		print(dt.time(time.now()), source, level, message);
+		writing = false;
+	end);
+
+	return module:context(localhost):send_iq(iq, nil, timeout):finally(function()
+		if not logger.remove_sink(sink) then
+			module:log("warn", "Unable to remove watch:log() sink");
+		end
+	end):next(function(pong)
+		return ("pong from %s on %s in %gs"):format(pong.stanza.attr.from, pong.origin.id, time.now() - time_start);
+	end);
+end
+
+
 def_env.role = new_section("Role and access management");
 
 describe_command [[role:list(host) - List known roles]]
--- a/plugins/mod_bosh.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_bosh.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -10,13 +10,12 @@
 
 local new_xmpp_stream = require "prosody.util.xmppstream".new;
 local sm = require "prosody.core.sessionmanager";
+local sm_new_session = sm.new_session;
 local sm_destroy_session = sm.destroy_session;
 local new_uuid = require "prosody.util.uuid".generate;
 local core_process_stanza = prosody.core_process_stanza;
 local st = require "prosody.util.stanza";
-local logger = require "prosody.util.logger";
 local log = module._log;
-local initialize_filters = require "prosody.util.filters".initialize;
 local math_min = math.min;
 local tostring, type = tostring, type;
 local traceback = debug.traceback;
@@ -26,7 +25,8 @@
 
 local xmlns_streams = "http://etherx.jabber.org/streams";
 local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams";
-local xmlns_bosh = "http://jabber.org/protocol/httpbind"; -- (hard-coded into a literal in session.send)
+local xmlns_bosh = "http://jabber.org/protocol/httpbind";
+local xmlns_xbosh = "urn:xmpp:xbosh";
 
 local stream_callbacks = {
 	stream_ns = xmlns_bosh, stream_tag = "body", default_ns = "jabber:client" };
@@ -79,9 +79,11 @@
 
 -- Used to respond to idle sessions (those with waiting requests)
 function on_destroy_request(request)
+	local log = request.log or log;
 	log("debug", "Request destroyed: %s", request);
 	local session = sessions[request.context.sid];
 	if session then
+		log = session.log or log;
 		local requests = session.requests;
 		for i, r in ipairs(requests) do
 			if r == request then
@@ -116,9 +118,8 @@
 end
 
 function handle_POST(event)
-	log("debug", "Handling new request %s: %s\n----------", event.request, event.request.body);
-
 	local request, response = event.request, event.response;
+	local log = request.log or log;
 	response.on_destroy = on_destroy_request;
 	local body = request.body;
 
@@ -224,7 +225,7 @@
 
 local function bosh_reset_stream(session) session.notopen = true; end
 
-local stream_xmlns_attr = { xmlns = "urn:ietf:params:xml:ns:xmpp-streams" };
+local stream_xmlns_attr = { xmlns = xmlns_xmpp_streams };
 local function bosh_close_stream(session, reason)
 	(session.log or log)("info", "BOSH client disconnected: %s", (reason and reason.condition or reason) or "session close");
 
@@ -267,6 +268,7 @@
 -- Handle the <body> tag in the request payload.
 function stream_callbacks.streamopened(context, attr)
 	local request, response = context.request, context.response;
+	local log = request.log or log;
 	local sid, rid = attr.sid, tonumber(attr.rid);
 	log("debug", "BOSH body open (sid: %s)", sid or "<none>");
 	context.rid = rid;
@@ -324,26 +326,29 @@
 
 		-- New session
 		sid = new_uuid();
-		-- TODO use util.session
-		local session = {
-			base_type = "c2s", type = "c2s_unauthed", conn = request.conn, sid = sid, host = attr.to,
-			rid = rid - 1, -- Hack for initial session setup, "previous" rid was $current_request - 1
-			bosh_version = attr.ver, bosh_wait = wait, streamid = sid,
-			bosh_max_inactive = bosh_max_inactivity, bosh_responses = cache.new(BOSH_HOLD+1):table();
-			requests = { }, send_buffer = {}, reset_stream = bosh_reset_stream,
-			close = bosh_close_stream, dispatch_stanza = core_process_stanza, notopen = true,
-			log = logger.init("bosh"..sid),	secure = consider_bosh_secure or request.secure,
-			ip = request.ip;
-			stanza_size_limit = unauthed_stanza_size_limit;
-		};
+		local session = sm_new_session(request.conn);
+		session.sid = sid;
+		session.host = attr.to;
+		session.rid = rid - 1; -- Hack for initial session setup, "previous" rid was $current_request - 1
+		session.bosh_version = attr.ver;
+		session.bosh_wait = wait;
+		session.streamid = sid;
+		session.bosh_max_inactive = bosh_max_inactivity;
+		session.bosh_responses = cache.new(BOSH_HOLD + 1):table();
+		session.requests = {};
+		session.send_buffer = {};
+		session.stanza_size_limit = unauthed_stanza_size_limit;
+		session.reset_stream = bosh_reset_stream;
+		session.close = bosh_close_stream;
+		session.dispatch_stanza = core_process_stanza;
+		session.notopen = true;
+		session.secure = consider_bosh_secure or request.secure;
 		sessions[sid] = session;
 
 		session.thread = runner(function (stanza)
 			session:dispatch_stanza(stanza);
 		end, runner_callbacks, session);
 
-		local filter = initialize_filters(session);
-
 		session.log("debug", "BOSH session created for request from %s", session.ip);
 		log("info", "New BOSH session, assigned it sid '%s'", sid);
 		report_new_sid();
@@ -354,13 +359,13 @@
 		local creating_session = true;
 
 		local r = session.requests;
-		function session.send(s)
+		function session.rawsend(s)
 			-- We need to ensure that outgoing stanzas have the jabber:client xmlns
 			if s.attr and not s.attr.xmlns then
 				s = st.clone(s);
 				s.attr.xmlns = "jabber:client";
 			end
-			s = filter("stanzas/out", s);
+			s = session.filter("bytes/out", tostring(s));
 			--log("debug", "Sending BOSH data: %s", s);
 			if not s then return true end
 			t_insert(session.send_buffer, tostring(s));
@@ -368,8 +373,8 @@
 			local oldest_request = r[1];
 			if oldest_request and not session.bosh_processing then
 				log("debug", "We have an open request, so sending on that");
-				local body_attr = { xmlns = "http://jabber.org/protocol/httpbind",
-					["xmlns:stream"] = "http://etherx.jabber.org/streams";
+				local body_attr = { xmlns = xmlns_bosh,
+					["xmlns:stream"] = xmlns_streams;
 					type = session.bosh_terminate and "terminate" or nil;
 					sid = sid;
 				};
@@ -382,9 +387,9 @@
 					body_attr.wait = tostring(session.bosh_wait);
 					body_attr.authid = sid;
 					body_attr.secure = "true";
-					body_attr.ver  = '1.6';
+					body_attr.ver = '1.6';
 					body_attr.from = session.host;
-					body_attr["xmlns:xmpp"] = "urn:xmpp:xbosh";
+					body_attr["xmlns:xmpp"] = xmlns_xbosh;
 					body_attr["xmpp:version"] = "1.0";
 				end
 				local response_xml = st.stanza("body", body_attr):top_tag()..t_concat(session.send_buffer).."</body>";
@@ -477,7 +482,6 @@
 
 function stream_callbacks.handlestanza(context, stanza)
 	if context.ignore then return; end
-	log("debug", "BOSH stanza received: %s\n", stanza:top_tag());
 	local session = sessions[context.sid];
 	if session then
 		if stanza.attr.xmlns == xmlns_bosh then
--- a/plugins/mod_c2s.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_c2s.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -160,15 +160,14 @@
 			limits:reset();
 		end
 		send(features);
+	elseif session.secure then
+		-- Here SASL should be offered, is mod_saslauth and mod_auth_* loaded and working correctly?
+		(session.log or log)("warn", "No stream features to offer on secure session. Check authentication settings.");
+		session:close{ condition = "undefined-condition", text = "No authentication method available" };
 	else
-		if session.secure then
-			-- Here SASL should be offered
-			(session.log or log)("warn", "No stream features to offer on secure session. Check authentication settings.");
-		else
-			-- Normally STARTTLS would be offered
-			(session.log or log)("warn", "No stream features to offer on insecure session. Check encryption and security settings.");
-		end
-		session:close{ condition = "undefined-condition", text = "No stream features to proceed with" };
+		-- Normally STARTTLS would be offered, is mod_tls loaded?
+		(session.log or log)("warn", "No stream features to offer on insecure session. Check encryption and security settings.");
+		session:close{ condition = "undefined-condition", text = "No connection encryption available" };
 	end
 end
 
@@ -221,7 +220,11 @@
 local function session_close(session, reason)
 	local log = session.log or log;
 	local close_event_payload = { session = session, reason = reason };
-	module:context(session.host):fire_event("pre-session-close", close_event_payload);
+
+	-- Fire event on host if we know it, otherwise fire globally
+	local context = hosts[session.host] and module:context(session.host) or module:context("*");
+	context:fire_event("pre-session-close", close_event_payload);
+
 	reason = close_event_payload.reason;
 	stop_session_timers(session);
 	if session.conn then
--- a/plugins/mod_carbons.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_carbons.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -149,6 +149,7 @@
 						:tag("forwarded", { xmlns = xmlns_forward })
 							:add_child(copy):reset();
 
+				carbon:set_meta("archive-id", stanza:get_meta("archive-id"));
 			end
 
 			carbon.attr.to = session.full_jid;
--- a/plugins/mod_cron.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_cron.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -1,3 +1,4 @@
+-- This file is generated from teal-src/prosody/plugins/mod_cron.tl
 module:set_global();
 
 local async = require("prosody.util.async");
@@ -9,7 +10,7 @@
 local active_hosts = {}
 
 if prosody.process_type == "prosodyctl" then
-	return; -- Yes, it happens...
+	return
 end
 
 function module.add_host(host_module)
@@ -17,14 +18,24 @@
 	local last_run_times = host_module:open_store("cron", "map");
 	active_hosts[host_module.host] = true;
 
-	local function save_task(task, started_at) last_run_times:set(nil, task.id, started_at); end
+	local function save_task(task, started_at)
+		last_run_times:set(nil, task.id, started_at);
+	end
 
-	local function restore_task(task) if task.last == nil then task.last = last_run_times:get(nil, task.id); end end
+	local function restore_task(task)
+		if task.last == nil then
+			task.last = last_run_times:get(nil, task.id);
+		end
+	end
 
 	local function task_added(event)
 		local task = event.item;
-		if task.name == nil then task.name = task.when; end
-		if task.id == nil then task.id = event.source.name .. "/" .. task.name:gsub("%W", "_"):lower(); end
+		if task.name == nil then
+			task.name = task.when;
+		end
+		if task.id == nil then
+			task.id = event.source.name .. "/" .. task.name:gsub("%W", "_"):lower();
+		end
 		task.period = host_module:get_option_period(task.id:gsub("/", "_") .. "_period", "1" .. task.when, 60, 86400 * 7 * 53);
 		task.restore = restore_task;
 		task.save = save_task;
@@ -40,14 +51,20 @@
 
 	host_module:handle_items("task", task_added, task_removed, true);
 
-	function host_module.unload() active_hosts[host_module.host] = nil; end
+	function host_module.unload()
+		active_hosts[host_module.host] = nil;
+	end
 end
 
-local function should_run(task, last) return not last or last + task.period * 0.995 <= os.time() end
+local function should_run(task, last)
+	return not last or last + task.period * 0.995 <= os.time()
+end
 
 local function run_task(task)
 	task:restore();
-	if not should_run(task, task.last) then return end
+	if not should_run(task, task.last) then
+		return
+	end
 	local started_at = os.time();
 	task:run(started_at);
 	task.last = started_at;
@@ -55,7 +72,7 @@
 end
 
 local function spread(t, factor)
-	return t * (1 - factor + 2*factor*math.random());
+	return t * (1 - factor + 2 * factor * math.random())
 end
 
 local task_runner = async.runner(run_task);
@@ -64,7 +81,9 @@
 	local delay = spread(cron_check_delay, cron_spread_factor);
 	for host in pairs(active_hosts) do
 		module:log("debug", "Running periodic tasks for host %s", host);
-		for _, task in ipairs(module:context(host):get_host_items("task")) do task_runner:run(task); end
+		for _, task in ipairs(module:context(host):get_host_items("task")) do
+			task_runner:run(task);
+		end
 	end
 	module:log("debug", "Wait %gs", delay);
 	return delay
--- a/plugins/mod_csi_simple.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_csi_simple.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -11,6 +11,7 @@
 local dt = require "prosody.util.datetime";
 local filters = require "prosody.util.filters";
 local timer = require "prosody.util.timer";
+local jid_bare = require "prosody.util.jid".bare;
 
 local queue_size = module:get_option_integer("csi_queue_size", 256, 1);
 local resume_delay = module:get_option_period("csi_resume_inactive_delay", 5);
@@ -40,13 +41,18 @@
 		-- TODO Some MUC awareness, e.g. check for the 'this relates to you' status code
 		return true, "subscription request";
 	elseif st_name == "message" then
+		if st_type == "error" then
+			return true, "delivery failure";
+		end
+		if stanza:get_child("event", "http://jabber.org/protocol/pubsub#event") then
+			if stanza.attr.from == nil or stanza.attr.from == jid_bare(stanza.attr.to) then
+				return true, "self-pep-event";
+			end
+		end
 		if st_type == "headline" then
 			-- Headline messages are ephemeral by definition
 			return false, "headline";
 		end
-		if st_type == "error" then
-			return true, "delivery failure";
-		end
 		if stanza:get_child("sent", "urn:xmpp:carbons:2") then
 			return true, "carbon";
 		end
@@ -116,6 +122,12 @@
 	{ "reason" }
 );
 
+local hold_reasons = module:metric(
+	"counter", "hold", "",
+	"CSI queue held",
+	{ "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();
 
@@ -138,6 +150,7 @@
 		module:fire_event("csi-flushing", { session = session });
 		session.conn:resume_writes();
 	else
+		hold_reasons:with_labels(why or "unimportant"):add(1);
 		session.log("debug", "Holding buffer (%s; queue size is %d)", why or "unimportant", session.csi_counter);
 		stanza = with_timestamp(stanza, jid.join(session.username, session.host))
 	end
--- a/plugins/mod_external_services.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_external_services.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -101,6 +101,9 @@
 			srv.restricted = true;
 		end
 	end
+	if item.extra and st.is_stanza(item.extra) then
+		srv.extra = item.extra;
+	end
 	return srv;
 end
 
@@ -149,7 +152,11 @@
 				password = srv.password;
 				expires = srv.expires and dt.datetime(srv.expires) or nil;
 				restricted = srv.restricted and "1" or nil;
-			}):up();
+			});
+		if srv.extra then
+			reply:add_child(srv.extra);
+		end
+		reply:up();
 	end
 
 	return reply;
@@ -242,3 +249,43 @@
 module:add_feature("urn:xmpp:extdisco:1");
 module:hook("iq-get/host/urn:xmpp:extdisco:1:services", handle_services);
 module:hook("iq-get/host/urn:xmpp:extdisco:1:credentials", handle_credentials);
+
+
+module:add_item("shell-command", {
+	section = "services";
+	section_desc = "Inspect external services";
+	name = "list";
+	desc = "List all external services for a host";
+	args = {
+		{ name = "host", type = "string" };
+		{ name = "service", type = "string" };
+	};
+	host_selector = "host";
+	handler = function (shell, host, service_type) --luacheck: ignore 212/host
+		local c = 0;
+		local print = shell.session.print;
+		local services = get_services();
+		for _, srv in ipairs(services) do
+			if not service_type or service_type:lower() == srv.type:lower() then
+				c = c + 1;
+				print(srv.type.." ("..srv.transport..")", srv.host..(srv.port and (":"..srv.port) or ""));
+				print(" auth:", srv.restricted and "required" or "none");
+				for _, prop in ipairs({ "username", "password", "expires" }) do
+					local val = srv[prop];
+					if val then
+						if prop == "expires" then
+							val = dt.datetime(val);
+						end
+						print(" "..prop..":", val);
+					end
+				end
+				if srv.extra then
+					print(" extra:");
+					print("    "..tostring(srv.extra:indent(3, "  ")));
+				end
+				print("");
+			end
+		end
+		return true, ("%d services"):format(c);
+	end;
+});
--- a/plugins/mod_http_errors.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_http_errors.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -2,9 +2,13 @@
 
 local server = require "prosody.net.http.server";
 local codes = require "prosody.net.http.codes";
+local http_errors = require "prosody.net.http.errors".registry;
+local json = require "prosody.util.json";
+local new_short_id = require "prosody.util.id".short;
 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);
 local default_message = { module:get_option_string("http_errors_default_message", "That's all I know.") };
@@ -77,6 +81,27 @@
 	return get_page(event.code, (show_private and event.private_message) or event.message or (event.error and event.error.text));
 end);
 
+-- Return error object as JSON if that's what requester prefers
+module:hook_object_event(server, "http-error", function (event)
+	if event.request and (event.request.headers.accept or ""):match("^application/json") then
+		event.response.headers.content_type = "application/json";
+		local error = event.request and event.error;
+		if not error then
+			error = http_errors[event.response.status_code];
+		end
+		local extra = error.extra;
+		return json.encode({
+			instance_id = error.instance_id or new_short_id();
+			type = error.type;
+			condition = error.condition;
+			text = error.text or (show_private and event.private_message) or event.message;
+			namespace = extra and extra.namespace;
+			subcondition = extra and extra.condition;
+			source = error.source or "mod_http_errors";
+		});
+	end
+end, 0.1);
+
 -- Way to use the template for other things so to give a consistent appearance
 module:hook("http-message", function (event)
 	if event.response then
--- a/plugins/mod_http_file_share.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_http_file_share.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -18,7 +18,9 @@
 local dt = require "prosody.util.datetime";
 local hi = require "prosody.util.human.units";
 local cache = require "prosody.util.cache";
+local join_path = require "util.paths".join;
 local lfs = require "lfs";
+local ENOENT = require "prosody.util.pposix".ENOENT;
 
 local unknown = math.abs(0/0);
 local unlimited = math.huge;
@@ -431,22 +433,22 @@
 
 	local request_range = request.headers.range;
 	local response_range;
+	local response_len = nil; -- until eof
 	-- "bytes=0-" just means "the whole file", so ignore it along with non-byte-range requests
 	if request_range and request_range ~= "bytes=0-" and request_range:find("^bytes=") then
 		local size = tonumber(filesize);
-		local last_byte = string.format("%d", size - 1);
-		local range_start, range_end = request_range:match("^bytes=(%d+)%-(%d*)$")
-		-- Only support resumption, ie ranges from somewhere in the middle until the end of the file.
-		if range_end == "" or range_end == last_byte then
-			local pos = tonumber(range_start);
-			local new_pos = pos < size and handle:seek("set", pos);
-			if new_pos and new_pos < size then
-				response_range = string.format("bytes %d-%d/%s", range_start, last_byte, filesize);
-				filesize = string.format("%d", size-pos);
-			else
-				handle:close();
-				return 416;
-			end
+		local range_start, range_end = request_range:match("^bytes=(%d*)%-(%d*)$")
+		local pos, stop = tonumber(range_start) or 0, tonumber(range_end) or size-1;
+		if range_start == "" then pos, stop = size - stop, size - 1; end
+		if pos <= stop and stop < size then
+			pos = handle:seek("set", pos);
+			response_len = stop - pos + 1;
+			response_range = string.format("bytes %d-%d/%s", pos, stop, filesize);
+			filesize = string.format("%d", response_len);
+		else
+			handle:close();
+			module:log("debug", "Invalid range requested: %q", request_range)
+			return 416;
 		end
 	end
 
@@ -477,14 +479,13 @@
 	response.headers.x_frame_options = "DENY"; -- COMPAT IE missing support for CSP frame-ancestors
 	response.headers.x_xss_protection = "1; mode=block";
 
-	return response:send_file(handle);
+	return response:send_file(handle, response_len);
 end
 
 if expiry < math.huge and not external_base_url then
 	-- TODO HTTP DELETE to the external endpoint?
 	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();
@@ -570,9 +571,40 @@
 	local iter = assert(uploads:find(nil));
 
 	local count, sum = 0, 0;
-	for _, file in iter do
-		sum = sum + tonumber(file.attr.size);
+	local expected_filenames = {};
+	for slot_id, file in iter do
 		count = count + 1;
+
+		local expected_size = tonumber(file.attr.size);
+		local filename = get_filename(slot_id);
+		local actual_size, err = lfs.attributes(filename, "size");
+		expected_filenames[filename] = true;
+		if not actual_size then
+			module:log("warn", "Missing file for upload slot %s: %s", slot_id, err);
+			count = count - 1;
+		elseif actual_size ~= expected_size then
+			module:log("warn", "Size mismatch for upload slot %s, expected %s, actual %s", slot_id, B(expected_size), B(actual_size));
+			-- TODO delete?
+			sum = sum + actual_size;
+		else
+			sum = sum + expected_size;
+		end
+	end
+
+	local upload_dir = dm.getpath("", module.host, module.name, ""):gsub("/[^/]*$", "");
+	local upload_dir_mode, err, errno = lfs.attributes(upload_dir, "mode");
+	if upload_dir_mode == "directory" then
+		for file in lfs.dir(upload_dir) do
+			local file_path = join_path(upload_dir, file);
+			if file ~= "." and file ~= ".." and not expected_filenames[file_path] then
+				local mode, err = lfs.attributes(file_path, "mode");
+				module:log("warn", "Unexpected %s in upload storage: %s", mode or "file", err or file);
+			end
+		end
+	elseif upload_dir_mode then
+		module:log("warn", "Unexpected type of upload directory: %s", upload_dir_mode);
+	elseif err and errno ~= ENOENT then
+		module:log("debug", "Could not determine type of upload directory: %s");
 	end
 
 	module:log("info", "Uploaded files total: %s in %d files", B(sum), count);
--- a/plugins/mod_http_files.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_http_files.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -11,7 +11,7 @@
 local open = io.open;
 local fileserver = require"prosody.net.http.files";
 
-local base_path = module:get_option_path("http_files_dir", module:get_option_path("http_path"));
+local base_path = assert(module:get_option_path("http_files_dir", module:get_option_path("http_path")), "missing required setting 'http_files_dir'");
 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" });
@@ -55,37 +55,12 @@
 end
 
 -- COMPAT -- TODO deprecate
-function serve(opts)
-	if type(opts) ~= "table" then -- assume path string
-		opts = { path = opts };
-	end
-	if opts.directory_index == nil then
-		opts.directory_index = directory_index;
-	end
-	if opts.mime_map == nil then
-		opts.mime_map = mime_map;
-	end
-	if opts.cache_size == nil then
-		opts.cache_size = cache_size;
-	end
-	if opts.cache_max_file_size == nil then
-		opts.cache_max_file_size = cache_max_file_size;
-	end
-	if opts.index_files == nil then
-		opts.index_files = dir_indices;
-	end
-	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);
+function serve()
+	error(string.format("%s should be updated to use 'prosody.net.http.files' instead of mod_http_files", get_calling_module()));
 end
 
-function wrap_route(routes)
-	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);
-		end
-	end
-	return routes;
+function wrap_route()
+	error(string.format("%s should be updated to use 'prosody.net.http.files' instead of mod_http_files", get_calling_module()));
 end
 
 module:provides("http", {
--- a/plugins/mod_invites.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_invites.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -77,9 +77,9 @@
 end
 
 -- Create invitation to register an account (optionally restricted to the specified username)
-function create_account(account_username, additional_data, ttl) --luacheck: ignore 131/create_account
+function create_account(account_username, additional_data, ttl, reusable) --luacheck: ignore 131/create_account
 	local jid = account_username and (account_username.."@"..module.host) or module.host;
-	return create_invite("register", jid, true, additional_data, ttl);
+	return create_invite("register", jid, true, additional_data, ttl, reusable);
 end
 
 -- Create invitation to reset the password for an account
@@ -88,8 +88,8 @@
 end
 
 -- Create invitation to become a contact of a local user
-function create_contact(username, allow_registration, additional_data, ttl) --luacheck: ignore 131/create_contact
-	return create_invite("roster", username.."@"..module.host, allow_registration, additional_data, ttl);
+function create_contact(username, allow_registration, additional_data, ttl, reusable) --luacheck: ignore 131/create_contact
+	return create_invite("roster", username.."@"..module.host, allow_registration, additional_data, ttl, reusable);
 end
 
 -- Create invitation to register an account and join a user group
@@ -261,7 +261,7 @@
 	flags = {
 		array_params = { role = true, group = have_group_invites };
 		value_params = { expires_after = true };
-		kv_params = { admin = true };
+		bool_params = { admin = true, reusable = true };
 	};
 
 	handler = function (self, user_jid, opts) --luacheck: ignore 212/self
@@ -285,7 +285,7 @@
 		local invite = assert(create_account(username, {
 			roles = roles;
 			groups = groups;
-		}, ttl));
+		}, ttl, opts.reusable));
 
 		return true, invite.landing_page or invite.uri;
 	end;
@@ -330,7 +330,7 @@
 	host_selector = "user_jid";
 	flags = {
 		value_params = { expires_after = true };
-		kv_params = { allow_registration = true };
+		bool_params = { allow_registration = true, reusable = true };
 	};
 
 	handler = function (self, user_jid, opts) --luacheck: ignore 212/self
@@ -345,7 +345,7 @@
 				return nil, "Unable to parse duration: "..opts.expires_after;
 			end
 		end
-		local invite, err = create_contact(username, opts and opts.allow_registration, nil, ttl);
+		local invite, err = create_contact(username, opts and opts.allow_registration, nil, ttl, opts.reusable);
 		if not invite then return nil, err; end
 		return true, invite.landing_page or invite.uri;
 	end;
@@ -386,6 +386,7 @@
 
 		print("Created:", os.date("%Y-%m-%d %T", invite.created_at));
 		print("Expires:", os.date("%Y-%m-%d %T", invite.expires));
+		print("Single-use:", not invite.reusable and "Yes" or "No");
 
 		print("");
 
--- a/plugins/mod_mam/mod_mam.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_mam/mod_mam.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -195,6 +195,7 @@
 	local first, last;
 	local count = 0;
 	local complete = "true";
+	local oldest, newest;
 	for id, item, when in data do
 		count = count + 1;
 		if count > qmax then
@@ -217,6 +218,13 @@
 		if not first then first = id; end
 		last = id;
 
+		if not newest or when > newest then
+			newest = when;
+		end
+		if not oldest or when < oldest then
+			oldest = when;
+		end
+
 		if flip then
 			results[count] = fwd_st;
 		else
@@ -238,6 +246,15 @@
 			:add_child(rsm.generate {
 				first = first, last = last, count = total }));
 
+	module:fire_event("archive-query-result", {
+		origin = origin;
+		stanza = stanza;
+		first_time = oldest;
+		last_time = newest;
+		first_id = reverse and last or first;
+		last_id = reverse and first or last;
+	});
+
 	-- That's all folks!
 	module:log("debug", "Archive query id=%s completed, %d items returned", qid or stanza.attr.id, complete and count or count - 1);
 	return true;
@@ -466,6 +483,7 @@
 			local clone_for_other_handlers = st.clone(stanza);
 			local id = ok;
 			clone_for_other_handlers:tag("stanza-id", { xmlns = xmlns_st_id, by = store_user.."@"..host, id = id }):up();
+			clone_for_other_handlers:set_meta("archive-id", id);
 			event.stanza = clone_for_other_handlers;
 			schedule_cleanup(store_user);
 			module:fire_event("archive-message-added", { origin = origin, stanza = clone_for_storage, for_user = store_user, id = id });
@@ -588,14 +606,11 @@
 				cleanup_map:set(cut_off, user, true);
 				module:log("error", "Could not delete messages for user '%s': %s", user, err);
 			end
-			local wait, done = async.waiter();
-			module:add_timer(0.01, done);
-			wait();
+			async.sleep(0.1);
 		end
 		module:log("info", "Deleted %d expired messages for %d users", sum, num_users);
 		cleanup_done();
 	end);
-
 else
 	module:log("debug", "Archive expiry disabled");
 	-- Don't ask the backend to count the potentially unbounded number of items,
--- a/plugins/mod_muc_mam.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_muc_mam.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -34,7 +34,7 @@
 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_period("muc_log_expires_after", "1w");
+local cleanup_after = module:get_option_period("muc_log_expires_after", module:get_option("archive_expires_after", "1w"));
 
 local default_history_length = 20;
 local max_history_length = module:get_option_integer("max_history_messages", math.huge, 0);
--- a/plugins/mod_offline.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_offline.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -9,11 +9,22 @@
 
 local datetime = require "prosody.util.datetime";
 local jid_split = require "prosody.util.jid".split;
+local datestamp = require "prosody.util.datetime".date;
+
+local cleanup_after = module:get_option_period("offline_expires_after", "never");
 
 local offline_messages = module:open_store("offline", "archive");
 
 module:add_feature("msgoffline");
 
+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
+
 module:hook("message/offline/handle", function(event)
 	local origin, stanza = event.origin, event.stanza;
 	local to = stanza.attr.to;
@@ -26,6 +37,7 @@
 
 	local ok = offline_messages:append(node, nil, stanza, os.time(), "");
 	if ok then
+		schedule_cleanup(node);
 		module:log("debug", "Saved to offline storage: %s", stanza:top_tag());
 	end
 	return ok;
@@ -49,3 +61,93 @@
 	end
 	return true;
 end, -1);
+
+if cleanup_after ~= math.huge then
+	local cleanup_storage = module:open_store("offline_cleanup");
+	local cleanup_map = module:open_store("offline_cleanup", "map");
+
+	module:log("debug", "offline_expires_after = %d -- in seconds", cleanup_after);
+
+	if not offline_messages.delete then
+		module:log("error", "offline_expires_after set but mod_%s does not support deleting", offline_messages._provided_by);
+		return false;
+	end
+
+	-- For each day, store a set of users that have new messages. To expire
+	-- messages, we collect the union of sets of users from dates that fall
+	-- outside the cleanup range.
+
+	if not (offline_messages.caps and offline_messages.caps.wildcard_delete) then
+		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
+			local ok = cleanup_map:set(date, username, true);
+			if ok then
+				last_date:set(username, date);
+			end
+		end
+	end
+
+	local cleanup_time = module:measure("cleanup", "times");
+
+	local async = require "prosody.util.async";
+	module:daily("Remove expired offline messages", function ()
+		local cleanup_done = cleanup_time();
+
+		if offline_messages.caps and offline_messages.caps.wildcard_delete then
+			local ok, err = offline_messages:delete(true, { ["end"] = os.time() - cleanup_after })
+			if ok then
+				local sum = tonumber(ok);
+				if sum then
+					module:log("info", "Deleted %d expired messages", sum);
+				else
+					-- driver did not tell
+					module:log("info", "Deleted all expired messages");
+				end
+			else
+				module:log("error", "Could not delete messages: %s", err);
+			end
+			cleanup_done();
+			return;
+		end
+
+		local users = {};
+		local cut_off = datestamp(os.time() - cleanup_after);
+		for date in cleanup_storage:users() do
+			if date <= cut_off then
+				module:log("debug", "Messages from %q should be expired", date);
+				local messages_this_day = cleanup_storage:get(date);
+				if messages_this_day then
+					for user in pairs(messages_this_day) do
+						users[user] = true;
+					end
+					if date < cut_off then
+						-- Messages from the same day as the cut-off might not have expired yet,
+						-- but all earlier will have, so clear storage for those days.
+						cleanup_storage:set(date, nil);
+					end
+				end
+			end
+		end
+		local sum, num_users = 0, 0;
+		for user in pairs(users) do
+			local ok, err = offline_messages:delete(user, { ["end"] = os.time() - cleanup_after; })
+			if ok then
+				num_users = num_users + 1;
+				sum = sum + (tonumber(ok) or 0);
+			else
+				cleanup_map:set(cut_off, user, true);
+				module:log("error", "Could not delete messages for user '%s': %s", user, err);
+			end
+			local wait, done = async.waiter();
+			module:add_timer(0.01, done);
+			wait();
+		end
+		module:log("info", "Deleted %d expired messages for %d users", sum, num_users);
+		cleanup_done();
+	end);
+
+else
+	module:log("debug", "Offline message expiry disabled");
+end
--- a/plugins/mod_pubsub/mod_pubsub.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_pubsub/mod_pubsub.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -30,6 +30,10 @@
 	return lib_pubsub.handle_pubsub_iq(event, service);
 end
 
+function handle_pubsub_message(event)
+	return lib_pubsub.handle_pubsub_message(event, service);
+end
+
 -- An itemstore supports the following methods:
 --   items(): iterator over (id, item)
 --   get(id): return item with id
@@ -43,6 +47,10 @@
 --   get(node_name)
 --   users(): iterator over (node_name)
 
+local supported_access_models = require "prosody.util.set".new({
+	"open", "whitelist", "authorize"
+});
+
 local max_max_items = module:get_option_integer("pubsub_max_items", 256, 1);
 
 local function tonumber_max_items(n)
@@ -130,8 +138,7 @@
 	if (tonumber_max_items(new_config["max_items"]) or 1) > max_max_items then
 		return false;
 	end
-	if new_config["access_model"] ~= "whitelist"
-	and new_config["access_model"] ~= "open" then
+	if not supported_access_models:contains(new_config["access_model"]) then
 		return false;
 	end
 	return true;
@@ -141,6 +148,12 @@
 	return st.is_stanza(item) and item.attr.xmlns == xmlns_pubsub and item.name == "item" and #item.tags == 1;
 end
 
+function get_item_metadata(item)
+	return {
+		publisher = item.attr.publisher;
+	};
+end
+
 -- Compose a textual representation of Atom payloads
 local summary_templates = module:get_option("pubsub_summary_templates", {
 	["http://www.w3.org/2005/Atom"] = "{@pubsub:title|and{*{@pubsub:title}*\n\n}}{summary|or{{author/name|and{{author/name} posted }}{title}}}";
@@ -167,6 +180,8 @@
 module:hook("iq/host/"..xmlns_pubsub..":pubsub", handle_pubsub_iq);
 module:hook("iq/host/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq);
 
+module:hook("message/host", handle_pubsub_message);
+
 local function add_disco_features_from_service(service) --luacheck: ignore 431/service
 	for feature in lib_pubsub.get_feature_set(service) do
 		module:add_feature(xmlns_pubsub.."#"..feature);
@@ -231,11 +246,13 @@
 	service.config.itemstore = create_simple_itemstore;
 	service.config.broadcaster = simple_broadcast;
 	service.config.itemcheck = is_item_stanza;
+	service.config.iteminfo = get_item_metadata;
 	service.config.check_node_config = check_node_config;
 	service.config.get_affiliation = get_affiliation;
 
 	module.environment.service = service;
 	add_disco_features_from_service(service);
+	lib_pubsub.add_service_hooks(service);
 end
 
 function module.save()
@@ -262,6 +279,7 @@
 		itemstore = create_simple_itemstore;
 		broadcaster = simple_broadcast;
 		itemcheck = is_item_stanza;
+		iteminfo = get_item_metadata;
 		check_node_config = check_node_config;
 		metadata_subset = {
 			"title";
--- a/plugins/mod_pubsub/pubsub.lib.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_pubsub/pubsub.lib.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -5,6 +5,7 @@
 local st = require "prosody.util.stanza";
 local it = require "prosody.util.iterators";
 local uuid_generate = require "prosody.util.uuid".generate;
+local get_form_type = require "prosody.util.dataforms".get_type;
 local dataform = require"prosody.util.dataforms".new;
 local errors = require "prosody.util.error";
 
@@ -17,6 +18,9 @@
 local handlers = {};
 _M.handlers = handlers;
 
+local form_handlers = {};
+_M.form_handlers = form_handlers;
+
 local pubsub_errors = errors.init("pubsub", xmlns_pubsub_errors, {
 	["conflict"] = { "cancel", "conflict" };
 	["invalid-jid"] = { "modify", "bad-request", nil, "invalid-jid" };
@@ -32,6 +36,7 @@
 	["precondition-not-met"] = { "cancel", "conflict", nil, "precondition-not-met" };
 	["invalid-item"] = { "modify", "bad-request", "invalid item" };
 	["persistent-items-unsupported"] = { "cancel", "feature-not-implemented", nil, "persistent-items" };
+	["already-subscribed"] = { "cancel", "bad-request" };
 });
 local function pubsub_error_reply(stanza, error, context)
 	local err = pubsub_errors.wrap(error, context);
@@ -228,11 +233,37 @@
 };
 _M.node_metadata_form = node_metadata_form;
 
+local sub_approval_form = dataform {
+	{
+		type = "hidden";
+		var = "FORM_TYPE";
+		value = "http://jabber.org/protocol/pubsub#subscribe_authorization";
+	};
+	{
+		type = "text-single";
+		name = "node";
+		var = "pubsub#node";
+		label = "Node name";
+	};
+	{
+		type = "jid-single";
+		name = "jid";
+		var = "pubsub#subscriber_jid";
+		label = "Subscriber JID";
+	};
+	{
+		type = "boolean";
+		name = "allow";
+		var = "pubsub#allow";
+		label = "Allow subscription";
+	};
+};
+
 local service_method_feature_map = {
 	add_subscription = { "subscribe", "subscription-options" };
 	create = { "create-nodes", "instant-nodes", "item-ids", "create-and-configure" };
 	delete = { "delete-nodes" };
-	get_items = { "retrieve-items" };
+	get_items = { "retrieve-items", "retrieve-affiliations" };
 	get_subscriptions = { "retrieve-subscriptions" };
 	node_defaults = { "retrieve-default" };
 	publish = { "publish", "multi-items", "publish-options" };
@@ -269,7 +300,7 @@
 	end
 
 	for affiliation in pairs(service.config.capabilities) do
-		if affiliation ~= "none" and affiliation ~= "owner" then
+		if affiliation ~= "none" and affiliation ~= "unauthorized" and affiliation ~= "owner" then
 			supported_features:add(affiliation.."-affiliation");
 		end
 	end
@@ -337,6 +368,29 @@
 	end
 end
 
+function _M.handle_pubsub_message(event, service)
+	local origin, stanza = event.origin, event.stanza;
+
+	if stanza.attr.type == "error" then
+		return;
+	end
+
+	local form = stanza:get_child("x", "jabber:x:data");
+	if form and form.attr.type == "submit" then
+		local form_type = get_form_type(form);
+		if not form_type then return; end
+
+		local form_name = form_type:match("http://jabber.org/protocol/pubsub#([%w_]+)$");
+		if not form_name then return; end
+
+		local handler = form_handlers[form_name];
+		if not handler then return; end
+
+		handler(origin, stanza, form, service);
+		return true;
+	end
+end
+
 function handlers.get_items(origin, stanza, items, service)
 	local node = items.attr.node;
 
@@ -398,7 +452,7 @@
 
 function handlers.get_subscriptions(origin, stanza, subscriptions, service)
 	local node = subscriptions.attr.node;
-	local ok, ret = service:get_subscriptions(node, stanza.attr.from, stanza.attr.from);
+	local ok, ret = service:get_subscriptions(node, stanza.attr.from, stanza.attr.from, true);
 	if not ok then
 		origin.send(pubsub_error_reply(stanza, ret));
 		return true;
@@ -407,7 +461,7 @@
 		:tag("pubsub", { xmlns = xmlns_pubsub })
 			:tag("subscriptions");
 	for _, sub in ipairs(ret) do
-		reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = 'subscribed' }):up();
+		reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = sub.subscription and 'subscribed' or 'pending' }):up();
 	end
 	origin.send(reply);
 	return true;
@@ -415,17 +469,19 @@
 
 function handlers.owner_get_subscriptions(origin, stanza, subscriptions, service)
 	local node = subscriptions.attr.node;
-	local ok, ret = service:get_subscriptions(node, stanza.attr.from);
+	local ok, ret = service:get_subscriptions(node, stanza.attr.from, nil, true);
 	if not ok then
 		origin.send(pubsub_error_reply(stanza, ret));
 		return true;
 	end
+
 	local reply = st.reply(stanza)
 		:tag("pubsub", { xmlns = xmlns_pubsub_owner })
 			:tag("subscriptions");
 	for _, sub in ipairs(ret) do
-		reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = 'subscribed' }):up();
+		reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = sub.subscription and 'subscribed' or 'pending' }):up();
 	end
+
 	origin.send(reply);
 	return true;
 end
@@ -545,7 +601,22 @@
 			return true
 		end
 	end
-	local ok, ret = service:add_subscription(node, stanza.attr.from, jid, options);
+
+	-- Test whether we can subscribe or need to request a subscription
+	-- Known issue: this test fails if we are subscribing someone else,
+	-- but it would be an unusual case to have permission to do that but
+	-- not subscribe ourselves.
+	local ok, ret, success_state;
+	if service:may(node, stanza.attr.from, "subscribe") then
+		module:log("debug", "Subscribing to %s", node);
+		success_state = "subscribed";
+		ok, ret = service:add_subscription(node, stanza.attr.from, jid, options);
+	else
+		module:log("debug", "Requesting subscription to %s (%q %q)", node, stanza.attr.from, jid);
+		success_state = "pending";
+		ok, ret = service:request_subscription(node, stanza.attr.from, jid, options);
+	end
+
 	local reply;
 	if ok then
 		reply = st.reply(stanza)
@@ -553,7 +624,7 @@
 				:tag("subscription", {
 					node = node,
 					jid = jid,
-					subscription = "subscribed"
+					subscription = success_state;
 				}):up();
 		if options_tag then
 			reply:add_child(options_tag);
@@ -793,6 +864,33 @@
 	return true;
 end
 
+function handlers.get_affiliations(origin, stanza, affiliations, service)
+	local ok, nodes = service:get_nodes(stanza.attr.from);
+	if not ok then
+		origin.send(pubsub_error_reply(stanza, nodes));
+		return true;
+	end
+	local node = affiliations.attr.node;
+	if node then
+		nodes = { [node] = nodes[node] };
+	end
+
+	local reply = st.reply(stanza)
+		:tag("pubsub", { xmlns = xmlns_pubsub })
+			:tag("affiliations");
+
+	local jid = service.config.normalize_jid(stanza.attr.from);
+	for node_, node_obj in pairs(nodes) do
+		local affiliation = node_obj.affiliations[jid];
+		if affiliation then
+			reply:tag("affiliation", { node = node_; affiliation = affiliation }):up();
+		end
+	end
+
+	origin.send(reply);
+	return true;
+end
+
 function handlers.owner_get_affiliations(origin, stanza, affiliations, service)
 	local node = affiliations.attr.node;
 	if not node then
@@ -844,7 +942,9 @@
 		local affiliation = affiliation_tag.attr.affiliation;
 
 		jid = jid_prep(jid);
-		if affiliation == "none" then affiliation = nil; end
+		if affiliation == "none" or affiliation == "unauthorized" then
+			affiliation = nil;
+		end
 
 		local ok, err = service:set_affiliation(node, stanza.attr.from, jid, affiliation);
 		if not ok then
@@ -946,4 +1046,73 @@
 end
 _M.archive_itemstore = archive_itemstore;
 
+local function notify_subscription_request(event)
+	local service, node = event.service, event.node;
+
+	local node_obj = service.nodes[node];
+	if not node_obj then
+		return;
+	end
+
+	local notify_jids = {};
+
+	for jid, affiliation in pairs(node_obj.affiliations) do
+		if affiliation == "owner" then
+			table.insert(notify_jids, jid);
+		end
+	end
+
+	if #notify_jids == 0 then
+		return;
+	end
+
+	local notification = st.message({ from = module.host })
+		:add_child(sub_approval_form:form({
+			node = node;
+			jid = event.jid;
+			allow = false;
+		}));
+
+	module:log("debug", "Sending subscription request notification to %d JIDs", #notify_jids);
+
+	if #notify_jids == 1 then
+		notification.attr.to = notify_jids[1];
+		module:send(notification);
+		return;
+	end
+
+	for _, recipient in ipairs(notify_jids) do
+		local clone = st.clone(notification);
+		clone.attr.to = recipient;
+		module:send(clone);
+	end
+end
+
+function _M.form_handlers.subscribe_authorization(origin, stanza, form, service)
+	local form_data, errs = sub_approval_form:data(form);
+	if not form_data then
+		local reply = st.error_reply(stanza, "modify", "not-acceptable", dataform_error_message(errs));
+		origin.send(reply);
+		return;
+	end
+
+	local node, jid, allow = form_data.node, form_data.jid, form_data.allow;
+
+	local ok, ret;
+	if allow then
+		ok, ret = service:add_subscription(node, stanza.attr.from, jid);
+	else
+		ok, ret = service:remove_subscription(node, stanza.attr.from, jid);
+	end
+
+	if not ok then
+		origin.send(pubsub_error_reply(stanza, ret));
+		return;
+	end
+end
+
+function _M.add_service_hooks(service)
+	module:hook_object_event(service.events, "subscription-requested", notify_subscription_request);
+end
+
 return _M;
--- a/plugins/mod_s2s.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_s2s.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -45,6 +45,7 @@
 local authed_stanza_size_limit = module:get_option_integer("s2s_stanza_size_limit", 1024*512, 10000);
 local max_child_elements = module:get_option_integer("s2s_max_child_elements", 25000, 0);
 local sendq_size = module:get_option_integer("s2s_send_queue_size", 1024*32, 1);
+local block_retries = module:get_option_boolean("s2s_block_immediate_retries", false);
 
 local advertised_idle_timeout = 14*60; -- default in all net.server implementations
 local network_settings = module:get_option("network_settings");
@@ -202,12 +203,29 @@
 			host.sendq = queue.new(sendq_size);
 		end
 		if not host.sendq:push(st.clone(stanza)) then
-			host.log("warn", "stanza [%s] not queued ", stanza.name);
-			if not event.origin or stanza.attr.type == "error" or stanza.attr.type == "result" then return false; end
-			event.origin.send(st.error_reply(stanza, "wait", "resource-constraint", "Outgoing stanza queue full"));
-			return true;
+			-- We shouldn't get here, because the last stanza
+			-- should have triggered the 'full?' check.
+			host.log("warn", "Pending connection %s->%s has full queue - unable to queue %s stanza", from_host, to_host, stanza.name);
+			return false;
+		else
+			host.log("debug", "stanza [%s] queued ", stanza.name);
 		end
-		host.log("debug", "stanza [%s] queued ", stanza.name);
+
+		if host.sendq:full() then
+			-- A pending connection with a full queue could indicate
+			-- a stalled connection (trouble authenticating, etc.).
+			-- Our choice is to keep it open, and discard any additional stanzas
+			-- (which could cause inconsistencies, e.g. in MUC joins) or
+			-- force-close the connection.
+			host.log("warn", "Pending connection %s->%s has full queue - closing connection and bouncing stanzas", from_host, to_host);
+			host:close({
+				condition = "resource-constraint",
+				text = "Too many pending stanzas for this connection",
+			}, nil, "Too many pending stanzas for this connection");
+		end
+
+		-- Although in some cases the stanza was not delivered, it
+		-- was processed (queued or bounced), so we return true
 		return true;
 	elseif host.type == "local" or host.type == "component" then
 		log("error", "Trying to send a stanza to ourselves??")
@@ -226,6 +244,9 @@
 	local from_host, to_host, stanza = event.from_host, event.to_host, event.stanza;
 	log("debug", "opening a new outgoing connection for this stanza");
 	local host_session = s2s_new_outgoing(from_host, to_host);
+	if block_retries then
+		host_session.block_retries = true;
+	end
 	host_session.version = 1;
 
 	host_session.stanza_size_limit = unauthed_stanza_size_limit;
@@ -358,11 +379,11 @@
 		end
 
 	else
-		if session.outgoing and not hosts[to].s2sout[from] then
+		local host_session = hosts[to];
+		if session.outgoing and not host_session.s2sout[from] then
 			session.log("debug", "Setting up to handle route from %s to %s", to, from);
-			hosts[to].s2sout[from] = session; -- luacheck: ignore 122
+			host_session.s2sout[from] = session; -- luacheck: ignore 122
 		end
-		local host_session = hosts[to];
 		session.send = function(stanza)
 			return host_session.events.fire_event("route/remote", { from_host = to, to_host = from, stanza = stanza });
 		end;
@@ -1075,6 +1096,17 @@
 
 module:hook("s2s-check-certificate", check_auth_policy, -1);
 
+-- When an outgoing connection fails, close a corresponding incoming connection with a stream error to indicate the problem to the remote
+module:hook("s2s-destroyed", function(event)
+	local s2sout = event.session;
+	if s2sout.type ~= "s2sout_unauthed" then return end
+	for s2sin in pairs(prosody.incoming_s2s) do
+		if s2sin.from_host == s2sout.to_host and s2sin.to_host == s2sout.from_host then
+			s2sin:close(errors.new({ condition = "reset", text = "Could not deliver return stanzas, please check your DNS and firewall" }));
+		end
+	end
+end);
+
 module:hook("server-stopping", function(event)
 	-- Close ports
 	local pm = require "prosody.core.portmanager";
--- a/plugins/mod_s2s_auth_dane_in.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_s2s_auth_dane_in.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -25,11 +25,6 @@
 	return r;
 end
 
-local function ensure_nonempty(r)
-	assert(r[1], "empty");
-	return r;
-end
-
 local function flatten(a)
 	local seen = {};
 	local ret = {};
@@ -101,7 +96,8 @@
 		return promise.all(tlsas):next(flatten);
 	end
 
-	local ret = async.wait_for(resolver:lookup_promise("_xmpp-server." .. dns_domain, "TLSA"):next(ensure_secure):next(ensure_nonempty):catch(function()
+	local ret = async.wait_for(resolver:lookup_promise("_xmpp-server." .. dns_domain, "TLSA"):next(ensure_secure):next(function(ret)
+		if ret[1] then return ret; end
 		return promise.all({
 			resolver:lookup_promise("_xmpps-server._tcp." .. dns_domain, "SRV"):next(ensure_secure):next(fetch_tlsa);
 			resolver:lookup_promise("_xmpp-server._tcp." .. dns_domain, "SRV"):next(ensure_secure):next(fetch_tlsa);
--- a/plugins/mod_storage_sql.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_storage_sql.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -10,6 +10,7 @@
 
 local is_stanza = require"prosody.util.stanza".is_stanza;
 local t_concat = table.concat;
+local t_move = require "prosody.util.table".move;
 
 local have_dbisql, dbisql = pcall(require, "prosody.util.sql");
 local have_sqlite, sqlite = pcall(require, "prosody.util.sqlite3");
@@ -161,7 +162,7 @@
 	return result;
 end
 function keyval_store:set(username, data)
-	return engine:transaction(keyval_store_set, data, username, self.store);
+	return engine:write_transaction(keyval_store_set, data, username, self.store);
 end
 function keyval_store:users()
 	local ok, result = engine:transaction(function()
@@ -219,7 +220,7 @@
 	return self:set_keys(username, { [key] = data });
 end
 function map_store:set_keys(username, keydatas)
-	local ok, result = engine:transaction(function()
+	local ok, result = engine:write_transaction(function()
 		local delete_sql = [[
 		DELETE FROM "prosody"
 		WHERE "host"=? AND "user"=? AND "store"=? AND "key"=?;
@@ -303,7 +304,7 @@
 	if type(key) ~= "string" or key == "" then
 		return nil, "delete_all only supports non-empty string keys";
 	end
-	local ok, result = engine:transaction(function()
+	local ok, result = engine:write_transaction(function()
 		local delete_sql = [[
 		DELETE FROM "prosody"
 		WHERE "host"=? AND "store"=? AND "key"=?;
@@ -333,7 +334,7 @@
 	if archive_item_limit then
 		if not item_count then
 			item_count_cache_miss();
-			local ok, ret = engine:transaction(function()
+			local ok, ret = engine:write_transaction(function()
 				local count_sql = [[
 				SELECT COUNT(*) FROM "prosodyarchive"
 				WHERE "host"=? AND "user"=? AND "store"=?;
@@ -367,7 +368,7 @@
 		when = math.floor(when);
 	end
 	with = with or "";
-	local ok, ret = engine:transaction(function()
+	local ok, ret = engine:write_transaction(function()
 		local delete_sql = [[
 		DELETE FROM "prosodyarchive"
 		WHERE "host"=? AND "user"=? AND "store"=? AND "key"=?;
@@ -430,9 +431,7 @@
 	if query.ids then
 		local nids, nargs = #query.ids, #args;
 		where[#where + 1] = "\"key\" IN (" .. string.rep("?", nids, ",") .. ")";
-		for i, id in ipairs(query.ids) do
-			args[nargs+i] = id;
-		end
+		t_move(query.ids, 1, nids, nargs + 1, args);
 	end
 end
 local function archive_where_id_range(query, args, where)
@@ -542,7 +541,7 @@
 
 function archive_store:set(username, key, new_value, new_when, new_with)
 	local user,store = username,self.store;
-	local ok, result = engine:transaction(function ()
+	local ok, result = engine:write_transaction(function ()
 
 		local update_query = [[
 		UPDATE "prosodyarchive"
@@ -621,7 +620,7 @@
 function archive_store:delete(username, query)
 	query = query or {};
 	local user,store = username,self.store;
-	local ok, stmt = engine:transaction(function()
+	local ok, stmt = engine:write_transaction(function()
 		local sql_query = "DELETE FROM \"prosodyarchive\" WHERE %s;";
 		local args = { host, user or "", store, };
 		local where = { "\"host\" = ?", "\"user\" = ?", "\"store\" = ?", };
@@ -751,7 +750,7 @@
 end
 
 function driver:purge(username)
-	return engine:transaction(function()
+	return engine:write_transaction(function()
 		engine:delete("DELETE FROM \"prosody\" WHERE \"host\"=? AND \"user\"=?", host, username);
 		engine:delete("DELETE FROM \"prosodyarchive\" WHERE \"host\"=? AND \"user\"=?", host, username);
 	end);
--- a/plugins/mod_storage_xep0227.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_storage_xep0227.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -251,10 +251,10 @@
 				usere:add_child(roster);
 				for contact_jid, item in pairs(data) do
 					if contact_jid ~= false then
-						contact_jid = jid_bare(jid_prep(contact_jid));
-						if contact_jid ~= user_jid then -- Skip self-contacts
+						local bare_contact_jid = jid_bare(jid_prep(contact_jid));
+						if bare_contact_jid ~= user_jid then -- Skip self-contacts
 							roster:tag("item", {
-								jid = contact_jid,
+								jid = bare_contact_jid,
 								subscription = item.subscription,
 								ask = item.ask,
 								name = item.name,
--- a/plugins/mod_tombstones.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/mod_tombstones.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -7,7 +7,7 @@
 
 -- 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 = module:open_store(nil, "keyval+");
 local graveyard_cache = require "prosody.util.cache".new(module:get_option_integer("tombstone_cache_size", 1024, 1));
 
 local ttl = module:get_option_period("user_tombstone_expiry", nil);
@@ -21,9 +21,26 @@
 -- TODO Attempt to deregister from MUCs based on bookmarks
 -- TODO Unsubscribe from pubsub services if a notification is received
 
+local function set_tombstone(username)
+	local time = os.time();
+	local ok, err = graveyard:set_key(nil, username, time);
+	if ok then
+		graveyard_cache:set(username, time);
+	end
+	return ok, err;
+end
+
+local function clear_tombstone(username)
+	local ok, err = graveyard:set_key(nil, username, nil);
+	if ok then
+		graveyard_cache:set(username, false);
+	end
+	return ok, err;
+end
+
 module:hook_global("user-deleted", function(event)
 	if event.host == module.host then
-		local ok, err = graveyard:set(nil, event.username, os.time());
+		local ok, err = set_tombstone(event.username);
 		if not ok then module:log("error", "Could store tombstone for %s: %s", event.username, err); end
 	end
 end);
@@ -40,7 +57,7 @@
 	elseif cached_result then
 		tombstone = cached_result;
 	else
-		local stored_result, err = graveyard:get(nil, username);
+		local stored_result, err = graveyard:get_key(nil, username);
 		if not stored_result and not err then
 			-- Cache that there is no tombstone for this user
 			graveyard_cache:set(username, false);
@@ -56,7 +73,7 @@
 	-- Check expiry
 	if ttl and tombstone + ttl < os.time() then
 		module:log("debug", "Tombstone for %s created at %s has expired", username, datetime.datetime(tombstone));
-		graveyard:set(nil, username, nil);
+		graveyard:set_key(nil, username, nil);
 		graveyard_cache:set(username, nil); -- clear cache entry (if any)
 		return nil;
 	end
@@ -108,3 +125,50 @@
 		return true;
 	end
 end, 1);
+
+--- shell commands
+module:add_item("shell-command", {
+	section = "user";
+	name = "list_deleted";
+	desc = "List deleted user accounts (tombstones)";
+	args = {
+		{ name = "host", type = "string" };
+	};
+	host_selector = "host";
+	handler = function (self, host) --luacheck: ignore 212/host
+		local c = 0;
+		for username in pairs(graveyard:get(nil)) do
+			c = c + 1;
+			self.session.print(username);
+		end
+		return true, ("Showing %d deleted accounts"):format(c);
+	end;
+});
+
+module:add_item("shell-command", {
+	section = "user";
+	name = "forget";
+	desc = "Forget a deleted user account (tombstone)";
+	args = {
+		{ name = "jid", type = "string" };
+	};
+	host_selector = "jid";
+	handler = function (self, user_jid) --luacheck: ignore 212/self
+		local username = jid_node(user_jid);
+		if not has_tombstone(username) then
+			if require "core.usermanager".user_exists(username, module.host) then
+				return nil, "User account has not been deleted";
+			else
+				return nil, "User account does not exist";
+			end
+		end
+
+		local ok, err = clear_tombstone(username);
+		if not ok then
+			return nil, ("Failed to remove tombstone: %s"):format(err);
+		end
+
+		return true, "User account forgotten";
+	end;
+});
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/gc3.lib.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -0,0 +1,57 @@
+
+local jid_bare = require "prosody.util.jid".bare;
+
+local st = require "prosody.util.stanza";
+
+local muc_util = module:require "muc/util";
+local valid_affiliations = muc_util.valid_affiliations;
+
+local xmlns_gc3 = "urn:xmpp:gc3:tmp"
+
+--luacheck: ignore 113/get_room_from_jid
+
+function fetch_participant_list(room, stanza, origin)
+	local from_jid = stanza.attr.from;
+	local from_aff = room:get_affiliation(from_jid);
+	local from_rank = valid_affiliations[from_aff or "none"];
+
+	local required_aff_rank = valid_affiliations[room:get_members_only() and "member" or "none"];
+	if from_rank < required_aff_rank then
+		origin.send(st.error_reply(stanza, "auth", "forbidden"));
+		return true;
+	end
+
+	local can_see_real_jids = not room:is_anonymous_for(from_jid);
+
+	local reply = st.reply(stanza)
+		:tag("participants", { xmlns = xmlns_gc3 });
+
+	for jid, aff in room:each_affiliation() do
+		local nick = room:get_registered_nick(jid);
+		local visible_jid = can_see_real_jids and jid or nil;
+		reply:text_tag("item", nil, {
+			affiliation = aff;
+			jid = visible_jid;
+			nick = nick;
+			id = room:get_occupant_id_from_jid(jid);
+		});
+	end
+
+	origin.send(reply);
+	return true;
+end
+
+local function room_event_handler(handler, allow_nonexistent)
+	return function (event)
+		local origin, stanza = event.origin, event.stanza;
+		local room_jid = jid_bare(stanza.attr.to);
+		local room = get_room_from_jid(room_jid);
+		if not room and allow_nonexistent ~= true then
+			origin.send(st.error_reply(stanza, "cancel", "item-not-found"));
+			return true;
+		end
+		return handler(room, stanza, origin);
+	end;
+end
+
+module:hook("iq-get/bare/"..xmlns_gc3..":participants", room_event_handler(fetch_participant_list));
--- a/plugins/muc/mod_muc.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/muc/mod_muc.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -102,6 +102,11 @@
 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;
+room_mt.get_occupant_id_from_jid = occupant_id.get_occupant_id_from_jid;
+
+if module:get_option_boolean("muc_enable_experimental_gc3", false) then
+	module:require "muc/gc3";
+end
 
 local jid_split = require "prosody.util.jid".split;
 local jid_prep = require "prosody.util.jid".prep;
--- a/plugins/muc/muc.lib.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/muc/muc.lib.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -24,6 +24,7 @@
 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 time = require "prosody.util.time";
 
 local log = module._log;
 
@@ -133,11 +134,27 @@
 	return occupant
 end
 
-function room_mt:route_to_occupant(occupant, stanza)
+function room_mt:route_to_occupant(occupant, stanza, session_filter)
 	local to = stanza.attr.to;
-	for jid in occupant:each_session() do
-		stanza.attr.to = jid;
-		self:route_stanza(stanza);
+	for session_jid, session_pres, session_meta in occupant:each_session() do
+		if session_filter then
+			stanza = session_filter(session_jid, session_pres, session_meta, stanza);
+		end
+		if stanza then
+			stanza.attr.to = session_jid;
+			self:route_stanza(stanza);
+		end
+	end
+	stanza.attr.to = to;
+end
+
+function room_mt:route_presence_to_occupant(occupant, stanza)
+	local to = stanza.attr.to;
+	for jid, _, session_meta in occupant:each_session() do
+		if not session_meta.filter_presence then
+			stanza.attr.to = jid;
+			self:route_stanza(stanza);
+		end
 	end
 	stanza.attr.to = to;
 end
@@ -297,7 +314,7 @@
 	end
 
 	if recipient then
-		return self:route_to_occupant(recipient, get_p(recipient));
+		return self:route_presence_to_occupant(recipient, get_p(recipient));
 	end
 
 	local broadcast_roles = self:get_presence_broadcast();
@@ -305,11 +322,11 @@
 	for occupant_nick, n_occupant in self:each_occupant() do
 		if occupant_nick ~= occupant.nick then
 			if broadcast_roles[occupant.role or "none"] or force_unavailable then
-				self:route_to_occupant(n_occupant, get_p(n_occupant));
+				self:route_presence_to_occupant(n_occupant, get_p(n_occupant));
 			elseif prev_role and broadcast_roles[prev_role] then
 				local pr = get_p(n_occupant);
 				pr.attr.type = 'unavailable';
-				self:route_to_occupant(n_occupant, pr);
+				self:route_presence_to_occupant(n_occupant, pr);
 			end
 
 		end
@@ -332,10 +349,14 @@
 	end
 end
 
-function room_mt:send_occupant_list(to, filter)
-	local to_bare = jid_bare(to);
+-- Send the current room list to an occupant (specifically to the real jid 'to',
+-- which is one of the occupant's sessions).
+function room_mt:send_occupant_list(to_occupant, to_jid, filter)
+	local to_session = to_occupant:get_session(to_jid);
+	if to_session.filter_presence then return; end
+	local to_bare = to_occupant.bare_jid;
 	local broadcast_roles = self:get_presence_broadcast();
-	local is_anonymous = self:is_anonymous_for(to);
+	local is_anonymous = self:is_anonymous_for(to_jid);
 	local broadcast_bare_jids = {}; -- Track which bare JIDs we have sent presence for
 	for occupant_jid, occupant in self:each_occupant() do
 		broadcast_bare_jids[occupant.bare_jid] = true;
@@ -343,7 +364,7 @@
 			local x = st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'});
 			self:build_item_list(occupant, x, is_anonymous and to_bare ~= occupant.bare_jid); -- can always see your own jids
 			local pres = st.clone(occupant:get_presence());
-			pres.attr.to = to;
+			pres.attr.to = to_jid;
 			pres:add_child(x);
 			module:fire_event("muc-build-occupant-presence", { room = self, occupant = occupant, stanza = pres });
 			self:route_stanza(pres);
@@ -356,7 +377,7 @@
 			if (nick or not is_anonymous) and not broadcast_bare_jids[affiliated_jid]
 			and (filter == nil or filter(affiliated_jid, nil)) then
 				local from = nick and (self.jid.."/"..nick) or self.jid;
-				local pres = st.presence({ to = to, from = from, type = "unavailable" })
+				local pres = st.presence({ to = to_jid, from = from, type = "unavailable" })
 					:tag("x", { xmlns = 'http://jabber.org/protocol/muc#user' })
 						:tag("item", {
 							affiliation = affiliation;
@@ -758,7 +779,7 @@
 
 		if orig_occupant == nil or muc_x then
 			-- Send occupant list to newly joined or desynced user
-			self:send_occupant_list(real_jid, function(nick, occupant) -- luacheck: ignore 212
+			self:send_occupant_list(dest_occupant, real_jid, function(nick, occupant) -- luacheck: ignore 212
 				-- Don't include self
 				return (not occupant) or occupant:get_presence(real_jid) == nil;
 			end)
@@ -1437,6 +1458,15 @@
 		end
 	end
 
+	data = data or {};
+	local old_data = self._affiliation_data[jid];
+
+	local current_time = time.now();
+	data.affiliation_created_at = old_data and old_data.affiliation_created_at or current_time;
+	data.affiliation_created_by = old_data and old_data.affiliation_created_by or actor;
+	data.affiliation_modified_at = current_time;
+	data.affiliation_modified_by = actor;
+
 	local event_data = {
 		room = self;
 		actor = actor;
@@ -1444,7 +1474,7 @@
 		affiliation = affiliation or "none";
 		reason = reason;
 		previous_affiliation = target_affiliation or "none";
-		data = data and data or nil; -- coerce false to nil
+		data = data;
 		previous_data = self._affiliation_data[jid] or nil;
 	};
 
@@ -1453,10 +1483,6 @@
 		local err = event_data.error or { type = "cancel", condition = "not-allowed" };
 		return nil, err.type, err.condition;
 	end
-	if affiliation and not data and event_data.data then
-		-- Allow handlers to add data when none was going to be set
-		data = event_data.data;
-	end
 
 	-- Set in 'database'
 	self._affiliations[jid] = affiliation;
@@ -1514,7 +1540,7 @@
 				(old_role ~= "moderator" and occupant.role == "moderator")) then -- Has gained or lost moderator status
 				-- Send everyone else's presences (as jid visibility has changed)
 				for real_jid in occupant:each_session() do
-					self:send_occupant_list(real_jid, function(occupant_jid, occupant) --luacheck: ignore 212 433
+					self:send_occupant_list(occupant, real_jid, function(occupant_jid, occupant) --luacheck: ignore 212 433
 						return (not occupant) or occupant.bare_jid ~= jid;
 					end);
 				end
--- a/plugins/muc/occupant.lib.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/muc/occupant.lib.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -23,10 +23,11 @@
 -- Deep copy an occupant
 local function copy_occupant(occupant)
 	local sessions = {};
-	for full_jid, presence_stanza in pairs(occupant.sessions) do
+	for full_jid, session in pairs(occupant.sessions) do
+		local presence_stanza = session.presence;
 		-- Don't keep unavailable presences, as they'll accumulate; unless they're the primary session
 		if presence_stanza.attr.type ~= "unavailable" or full_jid == occupant.jid then
-			sessions[full_jid] = presence_stanza;
+			sessions[full_jid] = session;
 		end
 	end
 	return setmetatable({
@@ -48,12 +49,16 @@
 	return nil;
 end
 
+function occupant_mt:get_session(real_jid)
+	return self.sessions[real_jid];
+end
+
 function occupant_mt:set_session(real_jid, presence_stanza, replace_primary)
 	local pr = get_filtered_presence(presence_stanza);
 	pr.attr.from = self.nick;
 	pr.attr.to = real_jid;
 
-	self.sessions[real_jid] = pr;
+	self.sessions[real_jid] = { presence = pr };
 	if replace_primary then
 		self.jid = real_jid;
 	elseif self.jid == nil or (pr.attr.type == "unavailable" and self.jid == real_jid) then
@@ -70,12 +75,23 @@
 	end
 end
 
+-- An iterator over self.sessions that returns (jid, presence, session)
+local function _session_iter(s, var)
+	local k = next(s, var);
+	if k then
+		local v = s[k];
+		return k, v.presence, v;
+	end
+end
+
 function occupant_mt:each_session()
-	return pairs(self.sessions)
+	return _session_iter, self.sessions;
 end
 
 function occupant_mt:get_presence(real_jid)
-	return self.sessions[real_jid or self.jid]
+	local session = self.sessions[real_jid or self.jid];
+	if not session then return; end
+	return session.presence;
 end
 
 return {
--- a/plugins/muc/occupant_id.lib.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/muc/occupant_id.lib.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -31,6 +31,11 @@
 	return occupant.stable_id;
 end
 
+local function get_occupant_id_from_jid(room, real_bare_jid)
+	local salt = get_room_salt(room)
+	return b64encode(hmac_sha256(real_bare_jid, salt));
+end
+
 local function update_occupant(event)
 	local stanza, room, occupant, dest_occupant = event.stanza, event.room, event.occupant, event.dest_occupant;
 
@@ -73,4 +78,5 @@
 return {
 	get_room_salt = get_room_salt;
 	get_occupant_id = get_occupant_id;
+	get_occupant_id_from_jid = get_occupant_id_from_jid;
 };
--- a/plugins/muc/register.lib.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/plugins/muc/register.lib.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -4,9 +4,10 @@
 local st = require "prosody.util.stanza";
 local dataforms = require "prosody.util.dataforms";
 
-local allow_unaffiliated = module:get_option_boolean("allow_unaffiliated_register", false);
+local gc3_mode = module:get_option_boolean("muc_enable_experimental_gc3", false);
 
-local enforce_nick = module:get_option_boolean("enforce_registered_nickname", false);
+local allow_unaffiliated = module:get_option_boolean("allow_unaffiliated_register", gc3_mode);
+local enforce_nick = module:get_option_boolean("enforce_registered_nickname", gc3_mode);
 
 -- Whether to include the current registration data as a dataform. Disabled
 -- by default currently as it hasn't been widely tested with clients.
@@ -125,9 +126,16 @@
 	if affiliation == "outcast" then
 		origin.send(st.error_reply(stanza, "auth", "forbidden"));
 		return true;
-	elseif not (affiliation or allow_unaffiliated) then
-		origin.send(st.error_reply(stanza, "auth", "registration-required"));
-		return true;
+	elseif not affiliation then
+		if room:get_members_only() then
+			-- Never allow unaffiliated users to register their way into a members-only room
+			origin.send(st.error_reply(stanza, "auth", "forbidden"));
+			return true;
+		elseif not allow_unaffiliated then
+			-- Not members-only, but we're not permitting registration due to config
+			origin.send(st.error_reply(stanza, "auth", "registration-required"));
+			return true;
+		end
 	end
 	local reply = st.reply(stanza);
 	local registered_nick = get_registered_nick(room, user_jid);
--- a/prosody	Fri Jun 12 13:04:54 2026 +0100
+++ b/prosody	Fri Jun 12 13:05:10 2026 +0100
@@ -85,7 +85,7 @@
 		prosody.events.fire_event("very-bad-error", {error = err, traceback = traceback});
 	end
 
-	local sleep = require"socket".sleep;
+	local sleep = require "prosody.util.time".sleep;
 	local server = require "prosody.net.server";
 
 	while select(2, xpcall(server.loop, catch_uncaught_error)) ~= "quitting" do
--- a/prosodyctl	Fri Jun 12 13:04:54 2026 +0100
+++ b/prosodyctl	Fri Jun 12 13:05:10 2026 +0100
@@ -65,13 +65,13 @@
 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 "prosody.util.dependencies";
 local lfs = dependencies.softreq "lfs";
 
 -----------------------
 
 local parse_args = require "prosody.util.argparse".parse;
+local sleep = require "prosody.util.time".sleep;
 local human_io = require "prosody.util.human.io";
 
 local show_message, show_warning = prosodyctl.show_message, prosodyctl.show_warning;
@@ -265,7 +265,7 @@
 					show_message("Prosody is still not running. Please give it some time or check your log files for errors.");
 					return 2;
 				end
-				socket.sleep(0.5);
+				sleep(0.5);
 				i = i + 1;
 			end
 			show_message("Started");
@@ -346,7 +346,7 @@
 				show_message("Prosody is still running. Please give it some time or check your log files for errors.");
 				return 2;
 			end
-			socket.sleep(0.5);
+			sleep(0.5);
 			i = i + 1;
 		end
 		show_message("Stopped");
@@ -456,6 +456,7 @@
 		-- These diverge from the module._VERSION convention
 		readline = "Version";
 	}
+	dependencies.softreq"socket";
 	local lunbound = dependencies.softreq"lunbound";
 	local lxp = dependencies.softreq"lxp";
 	local hashes = dependencies.softreq"prosody.util.hashes";
@@ -463,15 +464,15 @@
 		local version_field = alternate_version_fields[name] or "_VERSION";
 		if type(module) == "table" and rawget(module, version_field)
 			and name ~= "_G" and not name:match("%.") then
-			name = friendly_names[name] or name;
-			if #name > longest_name then
-				longest_name = #name;
+			local friendly_name = friendly_names[name] or name;
+			if #friendly_name > longest_name then
+				longest_name = #friendly_name;
 			end
 			local mod_version = module[version_field];
-			if tostring(mod_version):sub(1, #name+1) == name .. " " then
-				mod_version = mod_version:sub(#name+2);
+			if tostring(mod_version):sub(1, #friendly_name+1) == friendly_name .. " " then
+				mod_version = mod_version:sub(#friendly_name+2);
 			end
-			module_versions[name] = mod_version;
+			module_versions[friendly_name] = mod_version;
 		end
 	end
 	if lunbound then
--- a/spec/scansion/pubsub_advanced.scs	Fri Jun 12 13:04:54 2026 +0100
+++ b/spec/scansion/pubsub_advanced.scs	Fri Jun 12 13:05:10 2026 +0100
@@ -83,6 +83,22 @@
 	</iq>
 
 Balthasar sends:
+	<iq type="get" id='aff0' to='pubsub.localhost'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<affiliations node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<iq from="pubsub.localhost" id="aff0" type="result">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<affiliations>
+				<affiliation affiliation="owner" node="princely_musings"/>
+			</affiliations>
+		</pubsub>
+	</iq>
+
+Balthasar sends:
 	<iq type="get" id='aff1' to='pubsub.localhost'>
 		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
 			<affiliations node="princely_musings"/>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pubsub_authorize.scs	Fri Jun 12 13:05:10 2026 +0100
@@ -0,0 +1,158 @@
+# Pubsub: Node 'authorize' access model
+
+[Client] Balthasar
+	jid: admin@localhost
+	password: password
+
+[Client] Romeo
+	jid: romeo@localhost
+	password: password
+
+[Client] Juliet
+	jid: juliet@localhost
+	password: password
+
+---------
+
+Romeo connects
+
+Balthasar connects
+
+Balthasar sends:
+	<presence/>
+
+Balthasar receives:
+	<presence/>
+
+Balthasar sends:
+	<iq type='set' to='pubsub.localhost' id='create2'>
+		<pubsub xmlns='http://jabber.org/protocol/pubsub'>
+			<create node='secret_musings'/>
+			<configure>
+				<x xmlns="jabber:x:data" type="submit">
+					<field var="FORM_TYPE" type="hidden">
+						<value>http://jabber.org/protocol/pubsub#node_config</value>
+					</field>
+					<field var="pubsub#access_model">
+						<value>authorize</value>
+					</field>
+				</x>
+			</configure>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<iq type="result" id='create2'/>
+
+Juliet connects
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='sub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscribe node="secret_musings" jid="${Romeo's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="error" id='sub1'/>
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='sub2'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscribe node="secret_musings" jid="${Juliet's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" id='sub2'>
+		<pubsub xmlns='http://jabber.org/protocol/pubsub'>
+			<subscription jid="${Juliet's full JID}" node='secret_musings' subscription='pending'/>
+		</pubsub>
+	</iq>
+
+Juliet sends:
+	<iq type="get" to="pubsub.localhost" id="sub3">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscriptions/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" from="pubsub.localhost" id="sub3">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscriptions>
+				<subscription jid="${Juliet's full JID}" node='secret_musings' subscription='pending'/>
+			</subscriptions>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<message from="pubsub.localhost">
+		<x xmlns="jabber:x:data" type="form">
+			<field var="FORM_TYPE" type="hidden">
+				<value>http://jabber.org/protocol/pubsub#subscribe_authorization</value>
+			</field>
+			<field var="pubsub#node" type="text-single" label='Node name'>
+				<value>secret_musings</value>
+			</field>
+			<field var="pubsub#subscriber_jid" type="jid-single" label='Subscriber JID'>
+				<value>${Juliet's full JID}</value>
+			</field>
+			<field var="pubsub#allow" type="boolean" label='Allow subscription'>
+				<value>0</value>
+			</field>
+		</x>
+	</message>
+
+Balthasar sends:
+	<iq type="get" to="pubsub.localhost" id="subman0">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<subscriptions node="secret_musings"/>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<iq from="pubsub.localhost" id="subman0" type="result">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<subscriptions>
+				<subscription node='secret_musings' subscription="pending" jid="${Juliet's full JID}"/>
+			</subscriptions>
+		</pubsub>
+	</iq>
+
+Balthasar sends:
+	<message to="pubsub.localhost">
+		<x xmlns="jabber:x:data" type="submit">
+			<field var="FORM_TYPE" type="hidden">
+				<value>http://jabber.org/protocol/pubsub#subscribe_authorization</value>
+			</field>
+			<field var="pubsub#node" type="text-single">
+				<value>secret_musings</value>
+			</field>
+			<field var="pubsub#subscriber_jid" type="jid-single">
+				<value>${Juliet's full JID}</value>
+			</field>
+			<field var="pubsub#allow" type="boolean">
+				<value>true</value>
+			</field>
+		</x>
+	</message>
+
+
+Balthasar sends:
+	<iq type="get" to="pubsub.localhost" id="subman1">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<subscriptions node="secret_musings"/>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<iq from="pubsub.localhost" id="subman1" type="result">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<subscriptions>
+				<subscription subscription="subscribed" node='secret_musings' jid="${Juliet's full JID}"/>
+			</subscriptions>
+		</pubsub>
+	</iq>
+
+// vim: syntax=xml:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pubsub_retract_own.scs	Fri Jun 12 13:05:10 2026 +0100
@@ -0,0 +1,152 @@
+# Pubsub: Support for retracting own items on third-party node
+
+[Client] Romeo
+	jid: admin@localhost
+	password: password
+
+// admin@localhost is assumed to have node creation privileges
+
+[Client] Juliet
+	jid: juliet@localhost
+	password: password
+
+[Client] Mercutio
+	jid: mercutio@localhost
+	password: password
+
+---------
+
+Romeo connects
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='create1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<create node="romeos_rambles/comments"/>
+			<configure>
+				<x xmlns="jabber:x:data" type="submit">
+					<field var="FORM_TYPE" type="hidden">
+						<value>http://jabber.org/protocol/pubsub#node_config</value>
+					</field>
+					<field var="pubsub#publish_model">
+						<value>subscribers</value>
+					</field>
+				</x>
+			</configure>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id='create1'/>
+
+Juliet connects
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='jpub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<publish node="romeos_rambles/comments">
+				<item id="current">
+					<entry xmlns="http://www.w3.org/2005/Atom">
+						<title>Should fail, no permission</title>
+						<summary>Not yet a subscriber</summary>
+					</entry>
+				</item>
+			</publish>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="error" from="pubsub.localhost" id="jpub1">
+		<error type="auth">
+			<forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+	</iq>
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='sub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscribe node="romeos_rambles/comments" jid="${Juliet's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" id='sub1'/>
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='jpub2'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<publish node="romeos_rambles/comments">
+				<item id="bedd9e0e-923f-465b-9e2e-139fc6ccc7e7">
+					<entry xmlns="http://www.w3.org/2005/Atom">
+						<title>Thoughts from Juliet</title>
+						<summary>O sweet sir, thy tender speech doth pierce my soul</summary>
+					</entry>
+				</item>
+			</publish>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<message from='pubsub.localhost' type='headline'>
+	  <event xmlns='http://jabber.org/protocol/pubsub#event'>
+	    <items node='romeos_rambles/comments' xmlns='http://jabber.org/protocol/pubsub#event'>
+	      <item id='bedd9e0e-923f-465b-9e2e-139fc6ccc7e7' publisher="${Juliet's JID}" xmlns='http://jabber.org/protocol/pubsub#event'>
+	        <entry xmlns='http://www.w3.org/2005/Atom'>
+	          <title xmlns='http://www.w3.org/2005/Atom'>
+	          Thoughts from Juliet
+	          </title>
+	          <summary xmlns='http://www.w3.org/2005/Atom'>
+	          O sweet sir, thy tender speech doth pierce my soul
+	          </summary>
+	        </entry>
+	      </item>
+	    </items>
+	  </event>
+	</message>
+
+Juliet receives:
+	<iq type="result" from="pubsub.localhost" id="jpub2"/>
+
+Mercutio connects
+
+Mercutio sends:
+	<iq type='set' to='pubsub.localhost' id='retract1'>
+		<pubsub xmlns='http://jabber.org/protocol/pubsub'>
+			<retract node='romeos_rambles/comments'>
+				<item id='bedd9e0e-923f-465b-9e2e-139fc6ccc7e7'/>
+			</retract>
+		</pubsub>
+	</iq>
+
+Mercutio receives:
+	<iq type="error" from="pubsub.localhost" id="retract1">
+		<error type="auth">
+			<forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+	</iq>
+
+
+Juliet sends:
+	<iq type='set' to='pubsub.localhost' id='retract2'>
+		<pubsub xmlns='http://jabber.org/protocol/pubsub'>
+			<retract node='romeos_rambles/comments'>
+				<item id='bedd9e0e-923f-465b-9e2e-139fc6ccc7e7'/>
+			</retract>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" from="pubsub.localhost" id="retract2"/>
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='del1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<delete node="romeos_rambles/comments"/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id='del1'/>
+
+Romeo disconnects
+
+// vim: syntax=xml:
--- a/spec/util_argparse_spec.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/spec/util_argparse_spec.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -42,14 +42,14 @@
 
 		do
 			-- Same test with strict mode enabled and all parameters declared
-			local opts, err = parse({ "--foo"; "-b" }, { kv_params = { foo = true, bar = true }; short_params = { b = "bar" }, strict = true });
+			local opts, err = parse({ "--foo"; "-b" }, { bool_params = { foo = true, bar = true }; short_params = { b = "bar" }, strict = true });
 			assert.falsy(err);
 			assert.same({ foo = true; bar = true }, opts);
 		end
 	end);
 
 	it("supports value arguments", function()
-		local opts, err = parse({ "--foo"; "bar"; "--baz=moo" }, { value_params = { foo = true; bar = true } });
+		local opts, err = parse({ "--foo"; "bar"; "--baz=moo" }, { value_params = { foo = true; bar = true, baz = true } });
 		assert.falsy(err);
 		assert.same({ foo = "bar"; baz = "moo" }, opts);
 	end);
@@ -96,9 +96,9 @@
 		assert.same("--foobar", err2);
 	end);
 
-	it("accepts known kv parameters in strict mode", function ()
-		local opts, err = parse({ "--item=foo" }, { kv_params = { item = true }, strict = true });
+	it("accepts known bool parameters in strict mode", function ()
+		local opts, err = parse({ "--item" }, { bool_params = { item = true }, strict = true });
 		assert.falsy(err);
-		assert.same("foo", opts.item);
+		assert.same(true, opts.item);
 	end);
 end);
--- a/spec/util_format_spec.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/spec/util_format_spec.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -15,7 +15,7 @@
 			assert.equal("% [true]", format("%%", true));
 			assert.equal("{}", format("%q", {}));
 			assert.equal("[1.5]", format("%d", 1.5));
-			assert.equal("[7.3786976294838e+19]", format("%d", 73786976294838206464));
+			assert.matches("%[7%.3786976294838%d*e%+19%]", format("%d", 73786976294838206464));
 		end);
 
 		it("escapes ascii control stuff", function ()
@@ -262,7 +262,7 @@
 				it("works", function ()
 					assert.equal("a", format("%c", 97))
 					assert.equal("[1.5]", format("%c", 1.5))
-					assert.equal("[7.3786976294838e+19]", format("%c", 73786976294838206464))
+					assert.matches("%[7%.3786976294838%d*e%+19%]", format("%c", 73786976294838206464))
 					assert.equal("[inf]", format("%c", math.huge))
 				end);
 			end);
@@ -272,7 +272,7 @@
 					assert.equal("97", format("%d", 97))
 					assert.equal("-12345", format("%d", -12345))
 					assert.equal("[1.5]", format("%d", 1.5))
-					assert.equal("[7.3786976294838e+19]", format("%d", 73786976294838206464))
+					assert.matches("%[7%.3786976294838%d*e%+19%]", format("%d", 73786976294838206464))
 					assert.equal("[inf]", format("%d", math.huge))
 					assert.equal("2147483647", format("%d", 2147483647))
 				end);
@@ -283,7 +283,7 @@
 					assert.equal("97", format("%i", 97))
 					assert.equal("-12345", format("%i", -12345))
 					assert.equal("[1.5]", format("%i", 1.5))
-					assert.equal("[7.3786976294838e+19]", format("%i", 73786976294838206464))
+					assert.matches("%[7%.3786976294838%d*e%+19%]", format("%i", 73786976294838206464))
 					assert.equal("[inf]", format("%i", math.huge))
 					assert.equal("2147483647", format("%i", 2147483647))
 				end);
@@ -294,7 +294,7 @@
 					assert.equal("141", format("%o", 97))
 					assert.equal("[-12345]", format("%o", -12345))
 					assert.equal("[1.5]", format("%o", 1.5))
-					assert.equal("[7.3786976294838e+19]", format("%o", 73786976294838206464))
+					assert.matches("%[7%.3786976294838%d*e%+19%]", format("%o", 73786976294838206464))
 					assert.equal("[inf]", format("%o", math.huge))
 					assert.equal("17777777777", format("%o", 2147483647))
 				end);
@@ -305,7 +305,7 @@
 					assert.equal("97", format("%u", 97))
 					assert.equal("[-12345]", format("%u", -12345))
 					assert.equal("[1.5]", format("%u", 1.5))
-					assert.equal("[7.3786976294838e+19]", format("%u", 73786976294838206464))
+					assert.matches("%[7%.3786976294838%d*e%+19%]", format("%u", 73786976294838206464))
 					assert.equal("[inf]", format("%u", math.huge))
 					assert.equal("2147483647", format("%u", 2147483647))
 				end);
@@ -316,7 +316,7 @@
 					assert.equal("61", format("%x", 97))
 					assert.equal("[-12345]", format("%x", -12345))
 					assert.equal("[1.5]", format("%x", 1.5))
-					assert.equal("[7.3786976294838e+19]", format("%x", 73786976294838206464))
+					assert.matches("%[7%.3786976294838%d*e%+19%]", format("%x", 73786976294838206464))
 					assert.equal("[inf]", format("%x", math.huge))
 					assert.equal("7fffffff", format("%x", 2147483647))
 				end);
@@ -327,7 +327,7 @@
 					assert.equal("61", format("%X", 97))
 					assert.equal("[-12345]", format("%X", -12345))
 					assert.equal("[1.5]", format("%X", 1.5))
-					assert.equal("[7.3786976294838e+19]", format("%X", 73786976294838206464))
+					assert.matches("%[7%.3786976294838%d*e%+19%]", format("%X", 73786976294838206464))
 					assert.equal("[inf]", format("%X", math.huge))
 					assert.equal("7FFFFFFF", format("%X", 2147483647))
 				end);
@@ -426,7 +426,7 @@
 					assert.equal("97", format("%s", 97))
 					assert.equal("-12345", format("%s", -12345))
 					assert.equal("1.5", format("%s", 1.5))
-					assert.equal("7.3786976294838e+19", format("%s", 73786976294838206464))
+					assert.matches("7%.3786976294838%d*e%+19", format("%s", 73786976294838206464))
 					assert.equal("inf", format("%s", math.huge))
 					assert.equal("2147483647", format("%s", 2147483647))
 				end);
--- a/spec/util_smqueue_spec.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/spec/util_smqueue_spec.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -81,4 +81,85 @@
 			end
 		end)
 	end)
+
+	describe("#add_checkpoint", function()
+		it("fires the callback when ack reaches the checkpoint", function()
+			local q = smqueue.new(10);
+			for i = 1, 4 do q:push(i); end
+			local called = false;
+			q:add_checkpoint(function() called = true; end);
+			assert.falsy(called, "should not fire before ack");
+			q:ack(4);
+			assert.truthy(called, "should fire when ack reaches checkpoint");
+		end)
+
+		it("fires when ack exceeds the checkpoint", function()
+			local q = smqueue.new(10);
+			for i = 1, 4 do q:push(i); end
+			local called = false;
+			q:add_checkpoint(function() called = true; end);
+			for i = 5, 8 do q:push(i); end
+			q:ack(7); -- acks past the checkpoint at 4
+			assert.truthy(called);
+		end)
+
+		it("does not fire for acks below the checkpoint threshold", function()
+			local q = smqueue.new(10);
+			for i = 1, 5 do q:push(i); end
+			local called = false;
+			q:add_checkpoint(function() called = true; end);
+			q:ack(4);
+			assert.falsy(called, "should not fire when ack is below checkpoint");
+		end)
+
+		it("fires the callback with optional data argument", function()
+			local q = smqueue.new(10);
+			for i = 1, 4 do q:push(i); end
+			local received_data;
+			q:add_checkpoint(function(d) received_data = d; end, "hello");
+			q:ack(4);
+			assert.equal("hello", received_data);
+		end)
+
+		it("fires multiple checkpoints in a single ack call", function()
+			local q = smqueue.new(10);
+			for i = 1, 3 do q:push(i); end
+			local fired = {};
+			q:add_checkpoint(function() fired[#fired+1] = "first"; end);
+			q:push(4);
+			q:add_checkpoint(function() fired[#fired+1] = "second-1"; end);
+			q:add_checkpoint(function() fired[#fired+1] = "second-2"; end);
+			q:push(5);
+			q:add_checkpoint(function() fired[#fired+1] = "third"; end);
+			q:ack(5); -- should call all checkpoints, in order
+			assert.same({ "first", "second-1", "second-2", "third" }, fired);
+		end)
+
+		it("fires checkpoints in order across multiple acks", function()
+			local q = smqueue.new(10);
+			for i = 1, 3 do q:push(i); end
+			local order = {};
+			q:add_checkpoint(function() order[#order+1] = 1; end);
+			q:push(4);
+			q:add_checkpoint(function() order[#order+1] = 2; end);
+			q:push(5);
+			q:add_checkpoint(function() order[#order+1] = 3; end);
+			q:ack(3); -- fires only first
+			assert.same({ 1 }, order);
+			q:ack(5); -- fires second and third
+			assert.same({ 1, 2, 3 }, order);
+		end)
+
+		it("fires each checkpoint exactly once", function()
+			local q = smqueue.new(10);
+			for i = 1, 4 do q:push(i); end
+			local count = 0;
+			q:add_checkpoint(function() count = count + 1; end);
+			q:ack(4);
+			q:ack(4); -- duplicate ack
+			for i = 5, 8 do q:push(i); end
+			q:ack(8);
+			assert.equal(1, count);
+		end)
+	end)
 end);
--- a/teal-src/prosody/net/http.d.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/net/http.d.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -17,6 +17,9 @@
 
 	record http_client_options
 		sslctx : sslctx
+		suppress_errors : boolean
+		connection_pooling : boolean
+		use_dane : boolean
 	end
 
 	record http_options
--- a/teal-src/prosody/plugins/mod_cron.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/plugins/mod_cron.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -36,6 +36,10 @@
 
 local active_hosts : { string : boolean } = {  }
 
+if prosody.process_type == "prosodyctl" then
+	return -- Yes, it happens...
+end
+
 function module.add_host(host_module : moduleapi)
 
 	local last_run_times = host_module:open_store("cron", "map") as map_store<string,integer>;
@@ -63,13 +67,13 @@
 		task.restore = restore_task;
 		task.save = save_task;
 		module:log("debug", "%s task %s added", task.when, task.id);
-		return true;
+		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;
+		return true
 	end
 
 	host_module:handle_items("task", task_added, task_removed, true);
@@ -80,13 +84,13 @@
 end
 
 local function should_run(task : task_spec, last : integer) : boolean
-	return not last or last + task.period * 0.995 <= os.time();
+	return not last or last + task.period * 0.995 <= os.time()
 end
 
 local function run_task(task : task_spec)
 	task:restore();
 	if not should_run(task, task.last) then
-		return;
+		return
 	end
 	local started_at = os.time();
 	task:run(started_at);
@@ -95,7 +99,7 @@
 end
 
 local function spread(t : number, factor : number) : number
-	return t * (1 - factor + 2*factor*math.random());
+	return t * (1 - factor + 2*factor*math.random())
 end
 
 local task_runner : async.runner_t<task_spec> = async.runner(run_task);
@@ -109,7 +113,7 @@
 		end
 	end
 	module:log("debug", "Wait %gs", delay);
-	return delay;
+	return delay
 end);
 
 -- TODO measure load, pick a good time to do stuff
@@ -122,7 +126,7 @@
 	args = {};
 	handler = function (self, filter_host : string)
 		local format_table = require "prosody.util.human.io".table;
-		local it = require "util.iterators";
+		local it = require "prosody.util.iterators";
 		local row = format_table({
 			{ title = "Host", width = "2p" };
 			{ title = "Task", width = "3p" };
--- a/teal-src/prosody/util/array.d.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/array.d.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -1,9 +1,10 @@
-local record array_t<T>
-	{ T }
+local record array_t<T> is { T }
+	-- TODO methods
 end
 
 local record lib
 	metamethod __call : function () : array_t
+	-- TODO library functions
 end
 
 return lib
--- a/teal-src/prosody/util/crypto.d.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/crypto.d.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -2,7 +2,9 @@
 	record key
 		private_pem : function (key) : string
 		public_pem : function (key) : string
+		public_raw : function (key) : string
 		get_type : function (key) : string
+		derive : function (key, key) : string
 	end
 
 	type base_evp_sign = function (key, message : string) : string
@@ -44,9 +46,11 @@
 	aes_256_ctr_decrypt : Levp_decrypt
 
 	generate_ed25519_keypair : function () : key
+	generate_p256_keypair : function () : key
 
 	import_private_pem : function (string) : key
 	import_public_pem : function (string) : key
+	import_public_ec_raw : function (string, string) : key
 
 	parse_ecdsa_signature : function (string, integer) : string, string
 	build_ecdsa_signature : function (r : string, s : string) : string
--- a/teal-src/prosody/util/dataforms.d.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/dataforms.d.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -5,7 +5,7 @@
 		title : string
 		instructions : string
 
-		record form_field
+		record form_field is { form_field }
 
 			enum field_type
 				"boolean"
@@ -35,7 +35,6 @@
 			options : table
 		end
 
-		{ form_field }
 
 		enum form_type
 			"form"
--- a/teal-src/prosody/util/datamapper.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/datamapper.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -25,7 +25,7 @@
 local json = require "prosody.util.json"
 local pointer = require "prosody.util.jsonpointer";
 
-local json_type_name = json.json_type_name;
+local type json_type_name = json.json_type_name;
 local json_schema_object = require "prosody.util.jsonschema"
 local type schema_t = boolean | json_schema_object
 
@@ -42,7 +42,7 @@
 local function totype(t : json_type_name, s : string) : any
 	if not s then return nil end
 	if t == "string" then
-		return s;
+		return s
 	elseif t == "boolean" then
 		return toboolean(s)
 	elseif t == "number" or t == "integer" then
@@ -63,10 +63,10 @@
 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;
+			return pointer.resolve(root as table, schema["$ref"]:sub(2)) as schema_t
 		end
 	end
-	return schema;
+	return schema
 end
 
 local function guess_schema_type(schema : json_schema_object) : json_type_name
@@ -95,8 +95,6 @@
 
 	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
@@ -158,7 +156,7 @@
 			c = s:get_child(nil, namespace);
 		end
 		if c then
-			return c.name;
+			return c.name
 		end
 	elseif value_where == "in_attribute" then
 		local attr = name
@@ -250,7 +248,7 @@
 			table.insert(out, totype(proptype, value));
 		end
 	end
-	return out;
+	return out
 end
 
 local function parse (schema : json_schema_object, s : st.stanza_t) : table
@@ -269,7 +267,7 @@
 		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
+	elseif proptype == "integer" and v is integer then
 		return  string.format("%d", v)
 	elseif proptype == "boolean" then
 		return  v and "1" or "0"
@@ -361,7 +359,7 @@
 				unparse_property(out, v, proptype, propschema, value_where, name, namespace, current_ns, prefix, single_attribute, root)
 			end
 		end
-		return out;
+		return out
 
 	elseif s_type == "array" then
 		local itemschema = resolve_schema(schema.items, root)
@@ -369,7 +367,7 @@
 		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;
+		return out
 	end
 end
 
--- a/teal-src/prosody/util/jsonschema.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/jsonschema.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -10,12 +10,8 @@
 
 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;
+-- XXX util.encodings seems to count differently from the Lua builtin
+local utf8_len = rawget(_G, "utf8") and utf8.len or require"prosody.util.encodings".utf8.length;
 
 local json = require "prosody.util.json"
 local null = json.null;
@@ -166,7 +162,7 @@
 end
 local type errors = { validation_error }
 local function mkerr(sloc:string,iloc:string,err:string) : validation_error
-	return { schemaLocation = sloc; instanceLocation = iloc; error = err };
+	return { schemaLocation = sloc; instanceLocation = iloc; error = err }
 end
 
 local function validate (schema : schema_t, data : any, root : json_schema_object, sloc : string, iloc : string, errs:errors) : boolean, errors
@@ -186,14 +182,14 @@
 		if referenced ~= nil and referenced ~= root and referenced ~= schema then
 			if not validate(referenced, data, root, schema["$ref"], iloc, errs) then
 				table.insert(errs, mkerr(sloc.."/$ref", iloc, "Subschema failed validation"))
-				return false, errs;
+				return false, errs
 			end
 		end
 	end
 
 	if not simple_validate(schema.type, data) then
 		table.insert(errs, mkerr(sloc.."/type", iloc, "unexpected type"));
-		return false, errs;
+		return false, errs
 	end
 
 	if schema.type == "object" then
@@ -202,7 +198,7 @@
 			for k in pairs(data) do
 				if not k is string then
 					table.insert(errs, mkerr(sloc.."/type", iloc, "'object' had non-string keys"));
-					return false, errs;
+					return false, errs
 				end
 			end
 		end
@@ -214,7 +210,7 @@
 			for i in pairs(data) do
 				if not i is integer then
 					table.insert(errs, mkerr(sloc.."/type", iloc, "'array' had non-integer keys"));
-					return false, errs;
+					return false, errs
 				end
 			end
 		end
@@ -231,7 +227,7 @@
 		end
 		if not match then
 			table.insert(errs, mkerr(sloc.."/enum", iloc, "not one of the enumerated values"));
-			return false, errs;
+			return false, errs
 		end
 	end
 
@@ -240,42 +236,42 @@
 	if data is string then
 		if schema.maxLength and utf8_len(data) > schema.maxLength then
 			table.insert(errs, mkerr(sloc.."/maxLength", iloc, "string too long"))
-			return false, errs;
+			return false, errs
 		end
 		if schema.minLength and utf8_len(data) < schema.minLength then
 			table.insert(errs, mkerr(sloc.."/maxLength", iloc, "string too short"))
-			return false, errs;
+			return false, errs
 		end
 		if schema.luaPattern and not data:match(schema.luaPattern) then
 			table.insert(errs, mkerr(sloc.."/luaPattern", iloc, "string does not match pattern"))
-			return false, errs;
+			return false, errs
 		end
 	end
 
 	if data is number then
 		if schema.multipleOf and (data == 0 or data % schema.multipleOf ~= 0) then
 			table.insert(errs, mkerr(sloc.."/luaPattern", iloc, "not a multiple"))
-			return false, errs;
+			return false, errs
 		end
 
 		if schema.maximum and not ( data <= schema.maximum ) then
 			table.insert(errs, mkerr(sloc.."/maximum", iloc, "number exceeds maximum"))
-			return false, errs;
+			return false, errs
 		end
 
 		if schema.exclusiveMaximum and not ( data < schema.exclusiveMaximum ) then
 			table.insert(errs, mkerr(sloc.."/exclusiveMaximum", iloc, "number exceeds exclusive maximum"))
-			return false, errs;
+			return false, errs
 		end
 
 		if schema.minimum and not ( data >= schema.minimum ) then
 			table.insert(errs, mkerr(sloc.."/minimum", iloc, "number below minimum"))
-			return false, errs;
+			return false, errs
 		end
 
 		if schema.exclusiveMinimum and not ( data > schema.exclusiveMinimum ) then
 			table.insert(errs, mkerr(sloc.."/exclusiveMinimum", iloc, "number below exclusive minimum"))
-			return false, errs;
+			return false, errs
 		end
 	end
 
@@ -283,7 +279,7 @@
 		for i, sub in ipairs(schema.allOf) do
 			if not validate(sub, data, root, sloc.."/allOf/"..i, iloc, errs) then
 				table.insert(errs, mkerr(sloc.."/allOf", iloc, "did not match all subschemas"))
-				return false, errs;
+				return false, errs
 			end
 		end
 	end
@@ -297,7 +293,7 @@
 		end
 		if valid ~= 1 then
 			table.insert(errs, mkerr(sloc.."/oneOf", iloc, "did not match exactly one subschema"))
-			return false, errs;
+			return false, errs
 		end
 	end
 
@@ -311,14 +307,14 @@
 		end
 		if not match then
 			table.insert(errs, mkerr(sloc.."/anyOf", iloc, "did not match any subschema"))
-			return false, errs;
+			return false, errs
 		end
 	end
 
 	if schema["not"] ~= nil then
 		if validate(schema["not"], data, root, sloc.."/not", iloc, errs) then
 			table.insert(errs, mkerr(sloc.."/not", iloc, "did match subschema"))
-			return false, errs;
+			return false, errs
 		end
 	end
 
@@ -327,14 +323,14 @@
 			if schema["then"] ~= nil then
 				if not validate(schema["then"], data, root, sloc.."/then", iloc, errs) then
 					table.insert(errs, mkerr(sloc.."/then", iloc, "did not match subschema"))
-					return false, errs;
+					return false, errs
 				end
 			end
 		else
 			if schema["else"] ~= nil then
 				if not validate(schema["else"], data, root, sloc.."/else", iloc, errs) then
 					table.insert(errs, mkerr(sloc.."/else", iloc, "did not match subschema"))
-					return false, errs;
+					return false, errs
 				end
 			end
 		end
@@ -342,7 +338,7 @@
 
 	if schema.const ~= nil and schema.const ~= data then
 		table.insert(errs, mkerr(sloc.."/const", iloc, "did not match constant value"))
-		return false, errs;
+		return false, errs
 	end
 
 	if data is table then
@@ -352,19 +348,19 @@
 
 		if schema.maxItems and #(data as {any}) > schema.maxItems then
 			table.insert(errs, mkerr(sloc.."/maxItems", iloc, "too many items"))
-			return false, errs;
+			return false, errs
 		end
 
 		if schema.minItems and #(data as {any}) < schema.minItems then
 			table.insert(errs, mkerr(sloc.."/minItems", iloc, "too few items"))
-			return false, errs;
+			return false, errs
 		end
 
 		if schema.required then
 			for _, k in ipairs(schema.required) do
 				if data[k] == nil then
 					table.insert(errs, mkerr(sloc.."/required", iloc.."/"..tostring(k), "missing required property"))
-					return false, errs;
+					return false, errs
 				end
 			end
 		end
@@ -375,7 +371,7 @@
 					for _, req in ipairs(reqs) do
 						if data[req] == nil then
 							table.insert(errs, mkerr(sloc.."/dependentRequired", iloc, "missing dependent required property"))
-							return false, errs;
+							return false, errs
 						end
 					end
 				end
@@ -387,7 +383,7 @@
 			for k in pairs(data) do
 				if not validate(schema.propertyNames, k, root, sloc.."/propertyNames", iloc.."/"..tostring(k), errs) then
 					table.insert(errs, mkerr(sloc.."/propertyNames", iloc.."/"..tostring(k), "a property name did not match subschema"))
-					return false, errs;
+					return false, errs
 				end
 			end
 		end
@@ -401,7 +397,7 @@
 			for k, sub in pairs(schema.properties) do
 				if data[k] ~= nil and not validate(sub, data[k], root, sloc.."/"..tostring(k), iloc.."/"..tostring(k), errs) then
 					table.insert(errs, mkerr(sloc.."/"..tostring(k), iloc.."/"..tostring(k), "a property did not match subschema"))
-					return false, errs;
+					return false, errs
 				end
 				seen_properties[k] = true
 			end
@@ -414,7 +410,7 @@
 					if k is string and k:match(pattern) then
 						if not validate(sub, data[k], root, sloc.."/luaPatternProperties", iloc, errs) then
 							table.insert(errs, mkerr(sloc.."/luaPatternProperties/"..pattern, iloc.."/"..tostring(k), "a property did not match subschema"))
-							return false, errs;
+							return false, errs
 						end
 						seen_properties[k] = true
 					end
@@ -427,7 +423,7 @@
 				if not seen_properties[k as string] then
 					if not validate(schema.additionalProperties, v, root, sloc.."/additionalProperties", iloc.."/"..tostring(k), errs) then
 						table.insert(errs, mkerr(sloc.."/additionalProperties", iloc.."/"..tostring(k), "additional property did not match subschema"))
-						return false, errs;
+						return false, errs
 					end
 				end
 			end
@@ -437,7 +433,7 @@
 			for k, sub in pairs(schema.dependentSchemas) do
 				if data[k] ~= nil and not validate(sub, data, root, sloc.."/dependentSchemas/"..k, iloc, errs) then
 					table.insert(errs, mkerr(sloc.."/dependentSchemas", iloc.."/"..tostring(k), "did not match dependent subschema"))
-					return false, errs;
+					return false, errs
 				end
 			end
 		end
@@ -448,7 +444,7 @@
 			for _, v in pairs(data) do
 				if values[v] then
 					table.insert(errs, mkerr(sloc.."/uniqueItems", iloc, "had duplicate items"))
-					return false, errs;
+					return false, errs
 				end
 				values[v] = true
 			end
@@ -463,7 +459,7 @@
 					p = i
 				else
 					table.insert(errs, mkerr(sloc.."/prefixItems/"..i, iloc.."/"..tostring(i), "did not match subschema"))
-					return false, errs;
+					return false, errs
 				end
 			end
 		end
@@ -472,7 +468,7 @@
 			for i = p+1, #(data as {any}) do
 				if not validate(schema.items, data[i], root, sloc, iloc.."/"..i, errs) then
 					table.insert(errs, mkerr(sloc.."/prefixItems/"..i, iloc.."/"..i, "did not match subschema"))
-					return false, errs;
+					return false, errs
 				end
 			end
 		end
@@ -488,18 +484,18 @@
 			end
 			if found < (schema.minContains or 1) then
 				table.insert(errs, mkerr(sloc.."/minContains", iloc, "too few matches"))
-				return false, errs;
+				return false, errs
 			elseif found > (schema.maxContains or math.huge) then
 				table.insert(errs, mkerr(sloc.."/maxContains", iloc, "too many matches"))
-				return false, errs;
+				return false, errs
 			end
 		end
 	end
 
-	return true;
+	return true
 end
 
 
 json_schema_object.validate = validate;
 
-return json_schema_object;
+return json_schema_object
--- a/teal-src/prosody/util/pposix.d.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/pposix.d.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -86,6 +86,14 @@
 
 	mkdir : function (dir : string) : boolean, string
 
+	enum pipe_flag_names
+		"cloexec"
+		"direct"
+		"nonblock"
+	end
+	pipe : function (... : pipe_flag_names) : integer, integer
+	fdopen : function (integer, string) : FILE, 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
@@ -103,7 +111,7 @@
 
 	ENOENT : integer
 	_NAME : string
-	_VESRION : string
+	_VERSION : string
 end
 
 return pposix
--- a/teal-src/prosody/util/queue.d.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/queue.d.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -18,4 +18,4 @@
 
 	new : function<T> (size:integer, allow_wrapping:boolean) : queue<T>
 end
-return lib;
+return lib
--- a/teal-src/prosody/util/set.d.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/set.d.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -1,22 +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
+		add : function (Set, T)
+		contains : function (Set, T) : boolean
+		contains_set : function (Set, Set) : boolean
+		items :  function (Set) : function (Set, T) : T
+		remove : function (Set, T)
+		add_list : function (Set, { T })
+		include : function (Set, Set)
+		exclude : function (Set, Set)
+		empty : function (Set) : 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>
+	union : function (Set, Set) : Set
+	difference : function (Set, Set) : Set
+	intersection : function (Set, Set) : Set
+	xor : function (Set, Set) : Set
 end
 
 return lib
--- a/teal-src/prosody/util/signal.d.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/signal.d.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -36,6 +36,7 @@
 	signal : function (integer | Signal, function, boolean) : boolean
 	raise : function (integer | Signal)
 	kill : function (integer, integer | Signal)
+	signalfd : function (integer) : FILE
 	-- enum : integer
 end
 return lib
--- a/teal-src/prosody/util/smqueue.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/smqueue.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -6,18 +6,21 @@
 		_queue : queue.queue<T>
 		_head : integer
 		_tail : integer
+		_next_checkpoint : integer
+		type checkpoint = { integer, function(any), any }
+		_checkpoints : { checkpoint }
 
 		enum ack_errors
 			"tail"
 			"head"
 			"pop"
 		end
-		push : function (smqueue, T)
-		ack : function (smqueue, integer) : { T }, ack_errors
+		push : function (smqueue<T>, T)
+		ack : function (smqueue<T>, 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
+		consume : function (smqueue<T>) : function() : T
+		_call_checkpoints : function(smqueue<T>, integer)
 
 		table : function (smqueue<T>) : { T }
 	end
@@ -26,17 +29,22 @@
 
 local type smqueue = lib.smqueue;
 
-function smqueue:push(v)
+function smqueue:push(v : T)
 	self._head = self._head + 1;
 	-- Wraps instead of errors
 	assert(self._queue:push(v));
 end
 
-function smqueue:ack(h : integer) : { any }, smqueue.ack_errors
+-- Call when receiver has acknowledged some stanzas
+-- `h` (sent by the receiver) is the running count of
+-- successfully received stanzas
+function smqueue:ack(h : integer) : { T }, smqueue.ack_errors
 	if h < self._tail then
-		return nil, "tail";
+		-- h is less than a previous h
+		return nil, "tail"
 	elseif h > self._head then
-		return nil, "head";
+		-- h is greater than the number of stanzas we sent
+		return nil, "head"
 	end
 	-- TODO optimize? cache table fields
 	local acked = {};
@@ -44,56 +52,94 @@
 	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
+		if not v then return nil, "pop" end
 		table.insert(acked, v);
 	end
-	return acked;
+
+	local c = self._next_checkpoint;
+	if c and h >= c then
+		self:_call_checkpoints(h);
+	end
+	return acked
 end
 
 function smqueue:count_unacked() : integer
-	return self._head - self._tail;
+	return self._head - self._tail
 end
 
 function smqueue:count_acked() : integer
-	return self._tail;
+	return self._tail
 end
 
 function smqueue:resumable() : boolean
-	return self._queue:count() >= (self._head - self._tail);
+	return self._queue:count() >= (self._head - self._tail)
 end
 
 function smqueue:resume() : queue.queue.iterator, any, integer
-	return self._queue:items();
+	return self._queue:items()
 end
 
-function smqueue:consume() : queue.queue.consume_iter
-	return self._queue:consume()
+function smqueue:consume() : (function() : T)
+	return self._queue:consume() as (function() : T)
 end
 
 -- Compatibility layer, plain ol' table
-function smqueue:table() : { any }
-	local t : { any } = {};
+function smqueue:table() : { T }
+	local t : { T } = {};
 	for i, v in self:resume() do
 		t[i] = v;
 	end
-	return t;
+	return t
+end
+
+-- Add a 'checkpoint' which will call a function when all the stanzas
+-- currently in the queue have been successfully delivered and acknowledged
+function smqueue:add_checkpoint(cb : function (any), ud : any)
+	table.insert(self._checkpoints, { self._head, cb, ud });
+	if not self._next_checkpoint then
+		self._next_checkpoint = self._head;
+	end
+end
+
+function smqueue:_call_checkpoints(h : integer)
+	local checkpoints = self._checkpoints;
+
+	-- Pop the checkpoint info and update the next checkpoint info
+	local c = table.remove(checkpoints, 1);
+	self._next_checkpoint = checkpoints[1] and checkpoints[1][1] or nil;
+
+	-- Call the callback
+	local cb, ud = c[2], c[3];
+	cb(ud);
+
+	-- In case we passed over multiple checkpoints, call them also
+	if h >= self._next_checkpoint then
+		return self:_call_checkpoints(h)
+	end
 end
 
 local function freeze(q : smqueue<any>) : { string:integer }
 	return { head = q._head, tail = q._tail }
 end
 
-local queue_mt = {
+local smqueue_mt = {
 	--
 	__name = "smqueue";
 	__index = smqueue;
 	__len = smqueue.count_unacked;
+	-- Return a simple object for serialization
+	-- This is forbidden by Teal. Thanks Teal!
 	__freeze = freeze;
 }
 
-function lib.new<T>(size : integer) : queue.queue<T>
+function lib.new<T>(size : integer) : smqueue<T>
 	assert(size>0);
-	return setmetatable({ _head = 0; _tail = 0; _queue = queue.new(size, true) }, queue_mt);
+	return setmetatable({
+		_head = 0;
+		_tail = 0;
+		_queue = queue.new(size, true);
+		_checkpoints = {};
+	}, smqueue_mt)
 end
 
-return lib;
+return lib
--- a/teal-src/prosody/util/stanza.d.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/stanza.d.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -37,10 +37,9 @@
 		"unexpected-request"
 	end
 
-	record stanza_t
+	record stanza_t is { stanza_t | string }
 		name : string
 		attr : { string : string }
-		{ stanza_t | string }
 		tags : { stanza_t }
 
 		query : function ( stanza_t, string ) : stanza_t
@@ -77,10 +76,9 @@
 		indent : function ( stanza_t, integer, string ) : stanza_t
 	end
 
-	record serialized_stanza_t
+	record serialized_stanza_t is { serialized_stanza_t | string }
 		name : string
 		attr : { string : string }
-		{ serialized_stanza_t | string }
 	end
 
 	record message_attr
--- a/teal-src/prosody/util/time.d.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/time.d.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -2,5 +2,6 @@
 local record lib
 	now : function () : number
 	monotonic : function () : number
+	sleep : function (number)
 end
 return lib
--- a/teal-src/prosody/util/xtemplate.tl	Fri Jun 12 13:04:54 2026 +0100
+++ b/teal-src/prosody/util/xtemplate.tl	Fri Jun 12 13:05:10 2026 +0100
@@ -56,7 +56,7 @@
 
 			if func == "each" and tmpl then
 				if not st.is_stanza(value) then
-					return pre_blank..post_blank;
+					return pre_blank..post_blank
 				end
 				if not args then value, args = root, path; end
 				local ns, name = s_match(args, "^(%b{})(.*)$");
@@ -94,15 +94,15 @@
 
 		if value is string then
 			if not is_escaped then value = escape(value); end
-			return pre_blank .. value .. post_blank;
+			return pre_blank .. (value as string) .. post_blank
 		elseif st.is_stanza(value) then
 			value = value:get_text();
 			if value then
-				return pre_blank .. escape(value) .. post_blank;
+				return pre_blank .. escape(value) .. post_blank
 			end
 		end
-		return pre_blank .. post_blank;
-	end));
+		return pre_blank .. post_blank
+	end))
 end
 
-return { render = render };
+return { render = render }
--- a/util-src/crypto.c	Fri Jun 12 13:04:54 2026 +0100
+++ b/util-src/crypto.c	Fri Jun 12 13:05:10 2026 +0100
@@ -29,6 +29,7 @@
 #include <openssl/obj_mac.h>
 #include <openssl/param_build.h>
 #include <openssl/pem.h>
+#include <openssl/rsa.h>
 
 #if (LUA_VERSION_NUM == 501)
 #define luaL_setfuncs(L, R, N) luaL_register(L, NULL, R)
--- a/util-src/pposix.c	Fri Jun 12 13:04:54 2026 +0100
+++ b/util-src/pposix.c	Fri Jun 12 13:05:10 2026 +0100
@@ -664,13 +664,17 @@
 }
 
 const char *pipe_flag_names[] = {
+#if !defined(__APPLE__)
 	"cloexec",
 	"direct",
+#endif
 	"nonblock"
 };
 const int pipe_flag_values[] = {
+#if !defined(__APPLE__)
 	O_CLOEXEC,
 	O_DIRECT,
+#endif
 	O_NONBLOCK
 };
 
--- a/util-src/time.c	Fri Jun 12 13:04:54 2026 +0100
+++ b/util-src/time.c	Fri Jun 12 13:05:10 2026 +0100
@@ -4,6 +4,7 @@
 
 #include <time.h>
 #include <lua.h>
+#include <lauxlib.h>
 
 static lua_Number tv2number(struct timespec *tv) {
 	return tv->tv_sec + tv->tv_nsec * 1e-9;
@@ -23,6 +24,23 @@
 	return 1;
 }
 
+/* This function has been imported from luasocket */
+static int lc_sleep(lua_State *L) {
+	double n = luaL_checknumber(L, 1);
+	struct timespec t, r;
+	if (n < 0.0) n = 0.0;
+	if (n > INT_MAX) n = INT_MAX;
+	t.tv_sec = (int) n;
+	n -= t.tv_sec;
+	t.tv_nsec = (int) (n * 1000000000);
+	if (t.tv_nsec >= 1000000000) t.tv_nsec = 999999999;
+	while (nanosleep(&t, &r) != 0) {
+		t.tv_sec = r.tv_sec;
+		t.tv_nsec = r.tv_nsec;
+	}
+	return 0;
+}
+
 int luaopen_prosody_util_time(lua_State *L) {
 	lua_createtable(L, 0, 2);
 	{
@@ -30,6 +48,8 @@
 		lua_setfield(L, -2, "now");
 		lua_pushcfunction(L, lc_time_monotonic);
 		lua_setfield(L, -2, "monotonic");
+		lua_pushcfunction(L, lc_sleep);
+		lua_setfield(L, -2, "sleep");
 	}
 	return 1;
 }
--- a/util/adminstream.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/adminstream.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -1,7 +1,7 @@
 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 time_now = 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";
@@ -197,7 +197,7 @@
 		sessionlib.set_logger(session);
 		sessionlib.set_conn(session, conn);
 
-		session.conntime = gettime();
+		session.conntime = time_now();
 		session.type = "admin";
 
 		local stream = new_xmpp_stream(session, stream_callbacks);
@@ -254,7 +254,7 @@
 	function s.listeners.ondisconnect(conn, err)
 		local session = sessions[conn];
 		if session then
-			session.log("info", "Admin client disconnected: %s", err or "connection closed");
+			session.log("debug", "Admin client disconnected: %s", err or "connection closed");
 			session.conn = nil;
 			sessions[conn]  = nil;
 			s.events.fire_event("disconnected", { session = session });
@@ -327,7 +327,7 @@
 	end
 
 	function listeners.ondisconnect(conn, err) --luacheck: ignore 212/conn
-		client.log("info", "Admin client disconnected: %s", err or "connection closed");
+		client.log("debug", "Admin client disconnected: %s", err or "connection closed");
 		client.conn = nil;
 		client.events.fire_event("disconnected");
 	end
--- a/util/argparse.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/argparse.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -2,7 +2,7 @@
 	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 kv_params = config and config.kv_params or {};
+	local bool_params = config and (config.bool_params or config.kv_params) or {}; -- COMPAT: kv_params in 13.0 and earlier
 	local strict = config and config.strict;
 	local stop_on_positional = not config or config.stop_on_positional ~= false;
 
@@ -48,16 +48,13 @@
 					end
 				end
 			else
-				param_k, param_v = param:match("^([^=]+)=(.+)$");
-				if not param_k then
-					if param:match("^no%-") then
-						param_k, param_v = param:sub(4), false;
-					else
-						param_k, param_v = param, true;
-					end
+				if param:match("^no%-") then
+					param_k, param_v = param:sub(4), false;
+				else
+					param_k, param_v = param, true;
 				end
 				param_k = param_k:gsub("%-", "_");
-				if strict and not kv_params[param_k] then
+				if strict and not bool_params[param_k] then
 					return nil, "param-not-found", raw_param;
 				end
 			end
--- a/util/datamapper.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/datamapper.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -1,13 +1,13 @@
--- This file is generated from teal-src/util/datamapper.lua
-
+-- This file is generated from teal-src/prosody/util/datamapper.tl
 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 schema_t = {}
+local json_schema_object = require("prosody.util.jsonschema")
 
 local function toboolean(s)
 	if s == "true" or s == "1" then
@@ -32,8 +32,6 @@
 	end
 end
 
-local value_goes = {}
-
 local function resolve_schema(schema, root)
 	if type(schema) == "table" then
 		if schema["$ref"] and schema["$ref"]:sub(1, 1) == "#" then
@@ -69,8 +67,6 @@
 
 	if type(propschema) == "table" then
 		proptype = guess_schema_type(propschema);
-	elseif type(propschema) == "string" then
-		error("schema as string is not supported: " .. propschema .. " {" .. current_ns .. "}" .. propname)
 	end
 
 	if proptype == "object" or proptype == "array" then
@@ -103,7 +99,7 @@
 			end
 		end
 		if propschema["const"] then
-			enums = {propschema["const"]}
+			enums = { propschema["const"] }
 		elseif propschema["enum"] then
 			enums = propschema["enum"]
 		end
@@ -137,7 +133,7 @@
 	elseif value_where == "in_attribute" then
 		local attr = name
 		if prefix then
-			attr = prefix .. ":" .. name
+			attr = prefix .. ':' .. name
 		elseif namespace and namespace ~= s.attr.xmlns then
 			attr = namespace .. "\1" .. name
 		end
@@ -243,7 +239,7 @@
 		return v
 	elseif proptype == "number" and type(v) == "number" then
 		return string.format("%g", v)
-	elseif proptype == "integer" and type(v) == "number" then
+	elseif proptype == "integer" and math.type(v) == "integer" then
 		return string.format("%d", v)
 	elseif proptype == "boolean" then
 		return v and "1" or "0"
@@ -252,13 +248,12 @@
 
 local unparse
 
-local function unparse_property(out, v, proptype, propschema, value_where, name, namespace, current_ns, prefix,
-	single_attribute, root)
+local function unparse_property(out, v, proptype, propschema, value_where, name, namespace, current_ns, prefix, single_attribute, root)
 
 	if value_where == "in_attribute" then
 		local attr = name
 		if prefix then
-			attr = prefix .. ":" .. name
+			attr = prefix .. ':' .. name
 		elseif namespace and namespace ~= current_ns then
 			attr = namespace .. "\1" .. name
 		end
@@ -280,7 +275,7 @@
 	else
 		local propattr
 		if namespace ~= current_ns then
-			propattr = {xmlns = namespace}
+			propattr = { xmlns = namespace }
 		end
 		if value_where == "in_tag_name" then
 			if proptype == "string" and type(v) == "string" then
@@ -324,7 +319,7 @@
 
 	end
 
-	local out = ctx or st.stanza(current_name, {xmlns = current_ns})
+	local out = ctx or st.stanza(current_name, { xmlns = current_ns })
 
 	local s_type = guess_schema_type(schema)
 	if s_type == "object" then
@@ -350,4 +345,4 @@
 	end
 end
 
-return {parse = parse; unparse = unparse}
+return { parse = parse; unparse = unparse }
--- a/util/debug.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/debug.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -145,7 +145,8 @@
 	local last_source_desc;
 
 	local lines = {};
-	for nlevel, current_level in ipairs(levels) do
+	for n, current_level in ipairs(levels) do
+		local nlevel = n-1;
 		local info = current_level.info;
 		local line;
 		local func_type = info.namewhat.." ";
@@ -171,7 +172,6 @@
 			last_source_desc = source_desc;
 			table.insert(lines, "\t "..build_source_boundary_marker(last_source_desc));
 		end
-		nlevel = nlevel-1;
 		table.insert(lines, "\t"..(nlevel==0 and ">" or " ")..getstring(styles.level_num, "("..nlevel..") ")..line);
 		local npadding = (" "):rep(#tostring(nlevel));
 		if current_level.locals then
--- a/util/gc.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/gc.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -5,7 +5,9 @@
 	generational = set.new { "mode", "minor_threshold", "major_threshold" };
 };
 
-if _VERSION ~= "Lua 5.4" then
+if _VERSION == "Lua 5.5" then
+	known_options.generational = set.new { "mode", "minor_multiplier", "minor_major_threshold", "major_minor_threshold"  };
+elseif _VERSION ~= "Lua 5.4" then
 	known_options.generational = nil;
 	known_options.incremental:remove("step_size");
 end
@@ -31,15 +33,27 @@
 				user.speed or defaults.speed,
 				user.step_size or defaults.step_size
 			);
+		elseif _VERSION == "Lua 5.5" then
+			collectgarbage(mode);
+			collectgarbage("param", "pause", user.threshold or defaults.threshold);
+			collectgarbage("param", "stepmul", user.speed or defaults.speed);
+			collectgarbage("param", "stepsize", user.step_size or defaults.step_size);
 		else
 			collectgarbage("setpause", user.threshold or defaults.threshold);
 			collectgarbage("setstepmul", user.speed or defaults.speed);
 		end
 	elseif mode == "generational" then
-		collectgarbage(mode,
-			user.minor_threshold or defaults.minor_threshold,
-			user.major_threshold or defaults.major_threshold
-		);
+		if _VERSION == "Lua 5.4" then
+			collectgarbage(mode,
+				user.minor_threshold or defaults.minor_threshold,
+				user.major_threshold or defaults.major_threshold
+			);
+		elseif _VERSION == "Lua 5.5" then
+			collectgarbage(mode);
+			collectgarbage("param", "minormul", user.minor_multiplier or defaults.minor_multiplier);
+			collectgarbage("param", "majorminor", user.major_minor_threshold or defaults.major_minor_threshold);
+			collectgarbage("param", "minormajor", user.minor_major_threshold or defaults.minor_major_threshold);
+		end
 	end
 	return true;
 end
--- a/util/http.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/http.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -45,8 +45,8 @@
 local function formdecode(s)
 	if not s:match("=") then return urldecode(s); end
 	local r = {};
-	for k, v in s:gmatch("([^=&]*)=([^&]*)") do
-		k, v = k:gsub("%+", "%%20"), v:gsub("%+", "%%20");
+	for uk, uv in s:gmatch("([^=&]*)=([^&]*)") do
+		local k, v = uk:gsub("%+", "%%20"), uv:gsub("%+", "%%20");
 		k, v = urldecode(k), urldecode(v);
 		t_insert(r, { name = k, value = v });
 		r[k] = v;
--- a/util/human/io.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/human/io.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -131,10 +131,7 @@
 	return utf8_cut(s, width - 1) .. "…";
 end
 
-local function new_table(col_specs, max_width)
-	max_width = max_width or term_width(80);
-	local separator = " | ";
-
+local function calculate_column_widths(col_specs, separator, max_width)
 	local widths = {};
 	local total_width = max_width - #separator * (#col_specs-1);
 	local free_width = total_width;
@@ -166,6 +163,20 @@
 		end
 	end
 
+	return widths;
+end
+
+local function new_table(col_specs, max_width, separator)
+	separator = separator or " | ";
+
+	local widths;
+	if max_width ~= -1 then
+		max_width = max_width or term_width(80);
+		widths = calculate_column_widths(col_specs, separator, max_width)
+	else
+		widths = {};
+	end
+
 	return function (row)
 		local titles;
 		if not row then
@@ -183,14 +194,16 @@
 			else
 				v = tostring(v);
 			end
-			if len(v) < width then
-				if column.align == "right" then
-					v = padleft(v, width);
-				else
-					v = padright(v, width);
+			if width then
+				if len(v) < width then
+					if column.align == "right" then
+						v = padleft(v, width);
+					else
+						v = padright(v, width);
+					end
+				elseif len(v) > width then
+					v = (column.ellipsis or ellipsis)(v, width);
 				end
-			elseif len(v) > width then
-				v = (column.ellipsis or ellipsis)(v, width);
 			end
 			table.insert(output, v);
 		end
--- a/util/jsonpointer.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/jsonpointer.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -1,5 +1,4 @@
-local m_type = math.type;
-
+-- This file is generated from teal-src/prosody/util/jsonpointer.tl
 local function unescape_token(escaped_token)
 	local unescaped = escaped_token:gsub("~1", "/"):gsub("~0", "~")
 	return unescaped
@@ -17,10 +16,10 @@
 
 		if type(idx) == "string" then
 			new_ref = ref[token]
-		elseif m_type(idx) == "integer" then
+		elseif math.type(idx) == "integer" then
 			local i = tonumber(token)
 			if token == "-" then
-				i = #ref + 1
+				i = #(ref) + 1
 			end
 			new_ref = ref[i + 1]
 		else
--- a/util/jsonschema.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/jsonschema.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -1,14 +1,9 @@
--- This file is generated from teal-src/util/jsonschema.lua
-
+-- This file is generated from teal-src/prosody/util/jsonschema.tl
 if not math.type then
 	require("prosody.util.mathcompat")
 end
 
-local utf8_enc = rawget(_G, "utf8") or require("prosody.util.encodings").utf8;
-local utf8_len = utf8_enc.len or function(s)
-	local _, count = s:gsub("[%z\001-\127\194-\253][\128-\191]*", "");
-	return count
-end;
+local utf8_len = rawget(_G, "utf8") and utf8.len or require("prosody.util.encodings").utf8.length;
 
 local json = require("prosody.util.json")
 local null = json.null;
--- a/util/pluginloader.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/pluginloader.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -10,10 +10,9 @@
 local dir_sep, path_sep = package.config:match("^(%S+)%s(%S+)");
 local lua_version = _VERSION:match(" (.+)$");
 local plugin_dir = {};
-for path in (CFG_PLUGINDIR or "./plugins/"):gsub("[/\\]", dir_sep):gmatch("[^"..path_sep.."]+") do
-	path = path..dir_sep; -- add path separator to path end
-	path = path:gsub(dir_sep..dir_sep.."+", dir_sep); -- coalesce multiple separators
-	plugin_dir[#plugin_dir + 1] = path;
+for path in (CFG_PLUGINDIR or "./plugins/"):gsub("[/\\]", dir_sep):gmatch("[^" .. path_sep .. "]+") do
+	plugin_dir[#plugin_dir + 1] = (path .. dir_sep) -- add path separator to path end
+		:gsub(dir_sep .. dir_sep .. "+", dir_sep); -- coalesce multiple separators
 end
 
 local io_open = io.open;
--- a/util/prosodyctl/check.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/prosodyctl/check.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -12,6 +12,7 @@
 local async = require "prosody.util.async";
 local httputil = require "prosody.util.http";
 local human_units = require "prosody.util.human.units";
+local have_psl, publicsuffix = pcall(require, "psl");
 
 local function api(host)
 	return setmetatable({ name = "prosodyctl.check"; host = host; log = prosody.log }, { __index = moduleapi })
@@ -74,7 +75,7 @@
 	local result = { warnings = {} };
 
 	-- Create UDP socket for communication with the server
-	local sock = assert(require "socket".udp());
+	local sock = assert(socket.udp());
 	do
 		local ok, err = sock:setsockname("*", 0);
 		if not ok then
@@ -468,6 +469,7 @@
 			"trusted_proxies",
 			"umask",
 			"use_dane",
+			"use_dnssec",
 			"use_ipv4",
 			"use_ipv6",
 			"websocket_frame_buffer_limit",
@@ -589,14 +591,19 @@
 				print("    You need to move the following option"..(n>1 and "s" or "")..": "..table.concat(it.to_array(misplaced_options), ", "));
 			end
 		end
+		local psl = have_psl and publicsuffix.latest(api("*"):get_option_string("public_suffix_file", nil));
 		for host, options in enabled_hosts() do
+			-- This is meant to prod people away from xmpp.example.com towards example.com,
+			-- but will not do the right thing with second level hierarchies like .co.uk
+			-- unless lua-psl is installed
 			local host_options = set.new(it.to_array(it.keys(options)));
 			local subdomain = host:match("^[^.]+");
+			local suggestion = host:gsub("^[^.]+%.", "");
 			if not(host_options:contains("component_module")) and (subdomain == "jabber" or subdomain == "xmpp"
-			   or subdomain == "chat" or subdomain == "im") then
+			   or subdomain == "chat" or subdomain == "im") and (suggestion:match("%.") or psl and not psl:is_public_suffix(suggestion)) then
 				print("");
 				print("    Suggestion: If "..host.. " is a new host with no real users yet, consider renaming it now to");
-				print("     "..host:gsub("^[^.]+%.", "")..". You can use SRV records to redirect XMPP clients and servers to "..host..".");
+				print("     "..suggestion..". You can use SRV records to redirect XMPP clients and servers to "..host..".");
 				print("     For more information see: https://prosody.im/doc/dns");
 			end
 		end
@@ -1161,8 +1168,7 @@
 				local http_internal_host = http_host;
 				local http_url = api(host):get_option_string("http_external_url");
 				if http_url then
-					local url_parse = require "socket.url".parse;
-					local external_url_parts = url_parse(http_url);
+					local external_url_parts = socket_url.parse(http_url);
 					if external_url_parts then
 						http_host = external_url_parts.host;
 					else
--- a/util/prosodyctl/shell.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/prosodyctl/shell.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -30,7 +30,7 @@
 end
 
 local function send_line(client, line, interactive)
-	client.send(st.stanza("repl-input", { width = tostring(term_width()), repl = interactive == false and "0" or "1" }):text(line));
+	client.send(st.stanza("repl-input", { width = tostring(term_width(-1)), repl = interactive == false and "0" or "1" }):text(line));
 end
 
 local function repl(client)
@@ -82,7 +82,7 @@
 		elseif err == "missing-value" then
 			print("Expected a value to follow command-line option: "..where);
 		end
-		os.exit(1);
+		os.exit(64);
 	end
 
 	if arg[1] then
@@ -97,7 +97,7 @@
 
 		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
+			if stanza.name == "repl-output" or (stanza.name == "repl-result" and not opts.quiet) then
 				local dest = io.stdout;
 				if stanza.attr.type == "error" then
 					errors = errors + 1;
@@ -110,7 +110,7 @@
 				end
 			end
 			if stanza.name == "repl-result" then
-				os.exit(errors);
+				os.exit(errors > 0 and 1 or 0);
 			end
 			return true;
 		end, 1);
@@ -141,7 +141,7 @@
 			}):text(password or ""));
 		else
 			io.stderr:write("Internal error - unexpected input request type "..tostring(stanza.attr.type).."\n");
-			os.exit(1);
+			os.exit(76);
 		end
 		return true;
 	end, 2);
@@ -174,7 +174,7 @@
 			print("** Unable to connect to server - is it running? Is mod_admin_shell enabled?");
 			print("** Connection error: "..err);
 		end
-		os.exit(1);
+		os.exit(69);
 	end
 	server.loop();
 end
--- a/util/pubsub.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/pubsub.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -9,6 +9,7 @@
 	broadcaster = function () end;
 	subscriber_filter = function (subs) return subs end;
 	itemcheck = function () return true; end;
+	iteminfo = function () return nil; end;
 	get_affiliation = function () end;
 	normalize_jid = function (jid) return jid; end;
 	metadata_subset = {};
@@ -33,8 +34,39 @@
 			be_subscribed = false;
 			be_unsubscribed = true;
 
+			request_subscribe = false;
+			request_subscribe_other = false;
+
 			set_affiliation = false;
 		};
+		-- 'unauthorized' is the default for unaffiliated in access_model 'authorize'
+		unauthorized = {
+			create = false;
+			publish = false;
+			retract = false;
+			get_nodes = true;
+
+			subscribe = false;
+			unsubscribe = true;
+			get_subscription = true;
+			get_subscriptions = true;
+			get_items = false;
+			get_metadata = true;
+
+			subscribe_other = false;
+			unsubscribe_other = false;
+			get_subscription_other = false;
+			get_subscriptions_other = false;
+
+			be_subscribed = true;
+			be_unsubscribed = true;
+
+			request_subscribe = true;
+			request_subscribe_other = false;
+
+			set_affiliation = false;
+		};
+		-- default for unaffiliated in open access_model
 		none = {
 			create = false;
 			publish = false;
@@ -56,6 +88,9 @@
 			be_subscribed = true;
 			be_unsubscribed = true;
 
+			request_subscribe = true;
+			request_subscribe_other = false;
+
 			set_affiliation = false;
 		};
 		member = {
@@ -79,6 +114,9 @@
 			be_subscribed = true;
 			be_unsubscribed = true;
 
+			request_subscribe = true;
+			request_subscribe_other = false;
+
 			set_affiliation = false;
 		};
 		publisher = {
@@ -103,6 +141,9 @@
 			be_subscribed = true;
 			be_unsubscribed = true;
 
+			request_subscribe = true;
+			request_subscribe_other = false;
+
 			set_affiliation = false;
 		};
 		owner = {
@@ -127,9 +168,14 @@
 			get_subscription_other = true;
 			get_subscriptions_other = true;
 
+			get_pending_subscriptions = true;
+
 			be_subscribed = true;
 			be_unsubscribed = true;
 
+			request_subscribe = true;
+			request_subscribe_other = true;
+
 			set_affiliation = true;
 		};
 	};
@@ -150,6 +196,7 @@
 local function load_node_from_store(service, node_name)
 	local node = service.config.nodestore:get(node_name);
 	node.config = setmetatable(node.config or {}, {__index=service.node_defaults});
+	node.pending_subscribers = node.pending_subscribers or {};
 	return node;
 end
 
@@ -158,6 +205,7 @@
 		name = node.name;
 		config = node.config;
 		subscribers = node.subscribers;
+		pending_subscribers = node.pending_subscribers;
 		affiliations = node.affiliations;
 	});
 end
@@ -262,6 +310,8 @@
 		return "member";
 	elseif access_model == "whitelist" then
 		return "outcast";
+	elseif access_model == "authorize" then
+		return "unauthorized";
 	end
 
 	if self.config.access_models then
@@ -313,6 +363,66 @@
 	return true;
 end
 
+function service:request_subscription(node, actor, jid, options) --> ok, err
+	-- Access checking
+	local cap;
+	if actor == true or jid == actor or self:jids_equal(actor, jid) then
+		cap = "request_subscribe";
+	else
+		cap = "request_subscribe_other";
+	end
+	if not self:may(node, actor, cap) then
+		return false, "forbidden";
+	end
+	if not self:may(node, jid, "be_subscribed") then
+		return false, "forbidden";
+	end
+	--
+	local node_obj = self.nodes[node];
+	if not node_obj then
+		if not self.config.autocreate_on_subscribe then
+			return false, "item-not-found";
+		else
+			local ok, err = self:create(node, true);
+			if not ok then
+				return ok, err;
+			end
+			node_obj = self.nodes[node];
+		end
+	end
+	local old_subscription = node_obj.subscribers[jid];
+	if old_subscription then
+		return false, "already-subscribed";
+	end
+	node_obj.pending_subscribers[jid] = options or true;
+
+	local normal_jid = self.config.normalize_jid(jid);
+	local subs = self.subscriptions[normal_jid];
+	if subs then
+		if not subs[jid] then
+			subs[jid] = { [node] = false };
+		else
+			subs[jid][node] = false;
+		end
+	else
+		self.subscriptions[normal_jid] = { [jid] = { [node] = false } };
+	end
+
+	if self.config.nodestore then
+		-- TODO pass the error from storage to caller eg wrapped in an util.error
+		local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err
+		if not ok then
+			-- Store failed, so clear in-memory structures
+			node_obj.pending_subscribers[jid] = nil;
+			self.subscriptions[normal_jid][jid][node] = nil;
+			return ok, "internal-server-error";
+		end
+	end
+
+	self.events.fire_event("subscription-requested", { service = self, node = node, jid = jid, normalized_jid = normal_jid, options = options });
+	return true;
+end
+
 function service:add_subscription(node, actor, jid, options) --> ok, err
 	-- Access checking
 	local cap;
@@ -341,7 +451,18 @@
 		end
 	end
 	local old_subscription = node_obj.subscribers[jid];
+	local pending_subscription = node_obj.pending_subscribers[jid];
+
+	if not options and pending_subscription then
+		-- Use the pending subscription request's options now we
+		-- are upgrading to a real subscription.
+		options = pending_subscription;
+	end
 	node_obj.subscribers[jid] = options or true;
+
+	-- Clear pending subscription request, if any
+	node_obj.pending_subscribers[jid] = nil;
+
 	local normal_jid = self.config.normalize_jid(jid);
 	local subs = self.subscriptions[normal_jid];
 	if subs then
@@ -358,8 +479,10 @@
 		-- TODO pass the error from storage to caller eg wrapped in an util.error
 		local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err
 		if not ok then
+			-- Store failed, revert in-memory to old subscription
 			node_obj.subscribers[jid] = old_subscription;
 			self.subscriptions[normal_jid][jid][node] = old_subscription and true or nil;
+			node_obj.pending_subscribers[normal_jid] = pending_subscription;
 			return ok, "internal-server-error";
 		end
 	end
@@ -387,11 +510,17 @@
 	if not node_obj then
 		return false, "item-not-found";
 	end
-	if not node_obj.subscribers[jid] then
+
+	local old_subscription = node_obj.subscribers[jid];
+	local pending_subscription = node_obj.pending_subscribers[jid];
+
+	if not old_subscription and not pending_subscription then
 		return false, "not-subscribed";
 	end
-	local old_subscription = node_obj.subscribers[jid];
+
 	node_obj.subscribers[jid] = nil;
+	node_obj.pending_subscribers[jid] = nil;
+
 	local normal_jid = self.config.normalize_jid(jid);
 	local subs = self.subscriptions[normal_jid];
 	if subs then
@@ -411,8 +540,20 @@
 		-- TODO pass the error from storage to caller eg wrapped in an util.error
 		local ok, err = save_node_to_store(self, node_obj); -- luacheck: ignore 211/err
 		if not ok then
+			-- Restore previous in-memory state
 			node_obj.subscribers[jid] = old_subscription;
-			self.subscriptions[normal_jid][jid][node] = old_subscription and true or nil;
+			node_obj.pending_subscribers[jid] = pending_subscription;
+			if not self.subscriptions[normal_jid] then
+				self.subscriptions[normal_jid] = {};
+			end
+			if not self.subscriptions[normal_jid][jid] then
+				self.subscriptions[normal_jid][jid] = {};
+			end
+			if pending_subscription then
+				self.subscriptions[normal_jid][jid][node] = false;
+			elseif old_subscription then
+				self.subscriptions[normal_jid][jid][node] = true;
+			end
 			return ok, "internal-server-error";
 		end
 	end
@@ -440,6 +581,21 @@
 	return true, node_obj.subscribers[jid];
 end
 
+function service:get_pending_subscriptions(node, actor)
+	-- Access checking
+	if not self:may(node, actor, "get_pending_subscriptions") then
+		return false, "forbidden";
+	end
+	--
+
+	local node_obj = self.nodes[node];
+	if not node_obj then
+		return false, "item-not-found";
+	end
+
+	return true, node_obj.pending_subscribers;
+end
+
 function service:create(node, actor, options) --> ok, err
 	-- Access checking
 	if not self:may(node, actor, "create") then
@@ -462,6 +618,7 @@
 	self.nodes[node] = {
 		name = node;
 		subscribers = {};
+		pending_subscribers = {};
 		config = config;
 		affiliations = {};
 	};
@@ -596,8 +753,21 @@
 
 function service:retract(node, actor, id, retract) --> ok, err
 	-- Access checking
-	if not self:may(node, actor, "retract") then
-		return false, "forbidden";
+	local may_retract = self:may(node, actor, "retract");
+
+	if not may_retract then
+		local node_obj = self.nodes[node];
+		local publish_model = node_obj and node_obj.config.publish_model;
+		local may_publish = (
+			publish_model == "open"
+			or (publish_model == "subscribers" and node_obj.subscribers[actor])
+		);
+		if not may_publish then
+			return false, "forbidden";
+		end
+		-- Otherwise, we continue on, with may_retract = false, which
+		-- indicates they cannot retract items published by *others*
+		-- but may still retract their own items
 	end
 	--
 	local node_obj = self.nodes[node];
@@ -605,9 +775,20 @@
 		return false, "item-not-found";
 	end
 	if self.data[node] then
-		if not self.data[node]:get(id) then
+		local item = self.data[node]:get(id);
+		if not item then
 			return false, "item-not-found";
 		end
+
+		if not may_retract then
+			-- May not generally retract all items, so check if they are the publisher
+			local info = self.config.iteminfo(item);
+			local is_publisher = info and info.publisher and self:jids_equal(info.publisher, actor);
+			if not is_publisher then
+				return false, "forbidden";
+			end
+		end
+
 		local ok = self.data[node]:set(id, nil);
 		if not ok then
 			return nil, "internal-server-error";
@@ -714,10 +895,11 @@
 	return true, self.nodes;
 end
 
-local function flatten_subscriptions(ret, serv, subs, node, node_obj)
+local function flatten_subscriptions(ret, serv, subs, node, node_obj, inc_pending)
 	for subscribed_jid, subscribed_nodes in pairs(subs) do
 		if node then -- Return only subscriptions to this node
-			if subscribed_nodes[node] then
+			local state = subscribed_nodes[node];
+			if state or (state == false and inc_pending) then
 				ret[#ret+1] = {
 					node = node;
 					jid = subscribed_jid;
@@ -726,18 +908,20 @@
 			end
 		else -- Return subscriptions to all nodes
 			local nodes = serv.nodes;
-			for subscribed_node in pairs(subscribed_nodes) do
-				ret[#ret+1] = {
-					node = subscribed_node;
-					jid = subscribed_jid;
-					subscription = nodes[subscribed_node].subscribers[subscribed_jid];
-				};
+			for subscribed_node, state in pairs(subscribed_nodes) do
+				if state or inc_pending then
+					ret[#ret+1] = {
+						node = subscribed_node;
+						jid = subscribed_jid;
+						subscription = nodes[subscribed_node].subscribers[subscribed_jid];
+					};
+				end
 			end
 		end
 	end
 end
 
-function service:get_subscriptions(node, actor, jid) --> (true, array) or (false, err)
+function service:get_subscriptions(node, actor, jid, include_pending) --> (true, array) or (false, err)
 	-- Access checking
 	local cap;
 	if actor == true or jid == actor or self:jids_equal(actor, jid) then
@@ -759,7 +943,7 @@
 	local ret = {};
 	if jid == nil then
 		for _, subs in pairs(self.subscriptions) do
-			flatten_subscriptions(ret, self, subs, node, node_obj)
+			flatten_subscriptions(ret, self, subs, node, node_obj, include_pending)
 		end
 		return true, ret;
 	end
@@ -768,7 +952,7 @@
 	-- We return the subscription object from the node to save
 	-- a get_subscription() call for each node.
 	if subs then
-		flatten_subscriptions(ret, self, subs, node, node_obj)
+		flatten_subscriptions(ret, self, subs, node, node_obj, include_pending)
 	end
 	return true, ret;
 end
--- a/util/queue.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/queue.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -21,6 +21,8 @@
 		_items = t;
 		size = size;
 		count = function (self) return items; end;
+		empty = function () return items == 0; end;
+		full = function () return items == size; end;
 		push = function (self, item)
 			if items >= size then
 				if allow_wrapping then
--- a/util/serialization.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/serialization.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -62,6 +62,7 @@
 	["or"] = true; ["nil"] = true; ["true"] = true; ["until"] = true;
 	["elseif"] = true; ["function"] = true; ["not"] = true;
 	["repeat"] = true; ["return"] = true; ["while"] = true;
+	["global"] = true;
 };
 
 local function new(opt)
--- a/util/smqueue.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/smqueue.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -1,3 +1,4 @@
+-- This file is generated from teal-src/prosody/util/smqueue.tl
 local queue = require("prosody.util.queue");
 
 local lib = { smqueue = {} }
@@ -12,8 +13,10 @@
 
 function smqueue:ack(h)
 	if h < self._tail then
+
 		return nil, "tail"
 	elseif h > self._head then
+
 		return nil, "head"
 	end
 
@@ -22,35 +25,70 @@
 	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
+		if not v then
+			return nil, "pop"
+		end
 		table.insert(acked, v);
 	end
+
+	self:_call_checkpoints(h);
+
 	return acked
 end
 
-function smqueue:count_unacked() return self._head - self._tail end
+function smqueue:count_unacked()
+	return self._head - self._tail
+end
 
-function smqueue:count_acked() return self._tail end
+function smqueue:count_acked()
+	return self._tail
+end
 
-function smqueue:resumable() return self._queue:count() >= (self._head - self._tail) end
+function smqueue:resumable()
+	return self._queue:count() >= (self._head - self._tail)
+end
 
-function smqueue:resume() return self._queue:items() end
+function smqueue:resume()
+	return self._queue:items()
+end
 
-function smqueue:consume() return self._queue:consume() end
+function smqueue:consume()
+	return self._queue:consume()
+end
 
 function smqueue:table()
 	local t = {};
-	for i, v in self:resume() do t[i] = v; end
+	for i, v in self:resume() do
+		t[i] = v;
+	end
 	return t
 end
 
-local function freeze(q) return { head = q._head; tail = q._tail } end
+function smqueue:add_checkpoint(cb, ud)
+	table.insert(self._checkpoints, { self._head; cb; ud });
+end
+
+function smqueue:_call_checkpoints(h)
+	local checkpoints = self._checkpoints;
+	local c = checkpoints[1];
 
-local queue_mt = { __name = "smqueue"; __index = smqueue; __len = smqueue.count_unacked; __freeze = freeze }
+	while c and h >= c[1] do
+		table.remove(checkpoints, 1);
+		local cb, ud = c[2], c[3];
+		cb(ud);
+		c = checkpoints[1];
+	end
+end
+
+local function freeze(q)
+	return { head = q._head; tail = q._tail }
+end
+
+local smqueue_mt = { __name = "smqueue"; __index = smqueue; __len = smqueue.count_unacked; __freeze = freeze }
 
 function lib.new(size)
 	assert(size > 0);
-	return setmetatable({ _head = 0; _tail = 0; _queue = queue.new(size, true) }, queue_mt)
+	return setmetatable({ _head = 0; _tail = 0; _queue = queue.new(size, true); _checkpoints = {} }, smqueue_mt)
 end
 
 return lib
--- a/util/sql.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/sql.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -244,6 +244,7 @@
 	end
 	return ok, ret, b, c;
 end
+engine.write_transaction = engine.transaction; -- TODO can you signal intent to write upfront in pg or mysql?
 function engine:_create_index(index)
 	local sql = "CREATE INDEX \""..index.name.."\" ON \""..index.table.."\" (";
 	if self.params.driver ~= "MySQL" then
--- a/util/sqlite3.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/sqlite3.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -261,36 +261,49 @@
 	if ret ~= lsqlite3.OK then return nil, self.conn:errmsg(); end
 	return true;
 end
-function engine:_transaction(func, ...)
+function engine:_transaction(rw, 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";
+	local begin = rw and "BEGIN IMMEDIATE" or "BEGIN";
+	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));
+		log("debug", "SQL %s transaction success [%s]", rw and "write" or "read", 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);
+		log("debug", "SQL %s transaction failure [%s]: %s", rw and "write" or "read", tostring(func), a);
 		if self.conn then self:_"ROLLBACK"; end
 		return success, a;
 	end
 end
 function engine:transaction(...)
-	local ok, ret = self:_transaction(...);
+	local ok, ret = self:_transaction(nil, ...);
 	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(...);
+			ok, ret = self:_transaction(nil, ...);
+		end
+	end
+	return ok, ret;
+end
+function engine:write_transaction(...)
+	local ok, ret = self:_transaction(true, ...);
+	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(true, ...);
 		end
 	end
 	return ok, ret;
--- a/util/stanza.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/stanza.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -300,6 +300,27 @@
 	until not self
 end
 
+function stanza_mt:set_meta(k, v)
+	local meta = self.meta;
+	if not meta then
+		if v == nil then
+			return; -- no need to unset something that isn't set
+		end
+		meta = {};
+		self.meta = meta;
+	end
+	meta[k] = v;
+	return self;
+end
+
+function stanza_mt:get_meta(k)
+	local meta = self.meta;
+	if not meta then
+		return nil;
+	end
+	return meta[k];
+end
+
 local function _clone(stanza, only_top)
 	local attr = {};
 	for k,v in pairs(stanza.attr) do attr[k] = v; end
--- a/util/startup.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/startup.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -20,6 +20,10 @@
 	threshold = 105, speed = 500;
 	-- Generational mode defaults
 	minor_threshold = 20, major_threshold = 50;
+	-- Lua 5.5
+	minor_multiplier = 20;
+	major_minor_threshold = 50;
+	minor_major_threshold = 68;
 };
 
 local arg_settigs = {
@@ -459,9 +463,13 @@
 	local adns = require "prosody.net.adns";
 	if adns.instrument then
 		local m = statsmanager.metric("histogram", "prosody_dns", "seconds", "DNS lookups", { "qclass"; "qtype" }, {
-				buckets = { 1 / 1024; 1 / 256; 1 / 64; 1 / 16; 1 / 4; 1; 4 };
+			buckets = { 1 / 1024; 1 / 256; 1 / 64; 1 / 16; 1 / 4; 1; 4; 16 };
 		});
-		adns.instrument(function(qclass, qtype) return timed(m:with_labels(qclass, qtype)); end);
+		local c = statsmanager.metric("histogram", "prosody_dns_response", "records", "DNS response records", { "qclass"; "qtype" }, {
+			buckets = { 0; 1; 2; 4; 8; 16 } })
+		local function m_times(qclass, qtype) return timed(m:with_labels(qclass, qtype)); end
+		local function m_counts(qclass, qtype, num) return c:with_labels(qclass, qtype):sample(num); end
+		adns.instrument(m_times, m_counts);
 	end
 end
 
--- a/util/termcolours.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/termcolours.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -18,13 +18,6 @@
 local type = type;
 local setmetatable = setmetatable;
 local pairs = pairs;
-
-local windows;
-if os.getenv("WINDIR") then
-	windows = require "prosody.util.windows";
-end
-local orig_color = windows and windows.get_consolecolor and windows.get_consolecolor();
-
 local _ENV = nil;
 -- luacheck: std none
 
@@ -36,13 +29,6 @@
 			bold = 1, dark = 2, underline = 4, underlined = 4, normal = 0;
 		}
 
-local winstylemap = {
-	["0"] = orig_color, -- reset
-	["1"] = 7+8, -- bold
-	["1;33"] = 2+4+8, -- bold yellow
-	["1;31"] = 4+8 -- bold red
-}
-
 local cssmap = {
 	[1] = "font-weight: bold", [2] = "opacity: 0.5", [4] = "text-decoration: underline", [8] = "visibility: hidden",
 	[30] = "color:black", [31] = "color:red", [32]="color:green", [33]="color:#FFD700",
@@ -95,8 +81,7 @@
 }
 for colorname, rgb in pairs(csscolors) do
 	stylemap[colorname] = stylemap[colorname] or stylemap[rgb];
-	colorname, rgb = colorname .. " background", rgb .. " background"
-	stylemap[colorname] = stylemap[colorname] or stylemap[rgb];
+	stylemap[colorname .. " background"] = stylemap[colorname .. " background"] or stylemap[rgb .. " background"];
 end
 
 local function getstyle(...)
@@ -119,19 +104,6 @@
 	end
 end
 
-if windows then
-	function setstyle(style)
-		style = style or "0";
-		if style ~= last then
-			windows.set_consolecolor(winstylemap[style] or orig_color);
-			last = style;
-		end
-	end
-	if not orig_color then
-		function setstyle() end
-	end
-end
-
 local function ansi2css(ansi_codes)
 	if ansi_codes == "0" then return "</span>"; end
 	local css = {};
--- a/util/throttle.lua	Fri Jun 12 13:04:54 2026 +0100
+++ b/util/throttle.lua	Fri Jun 12 13:05:10 2026 +0100
@@ -1,5 +1,5 @@
 
-local gettime = require "prosody.util.time".now
+local time_now = require "prosody.util.time".now;
 local setmetatable = setmetatable;
 
 local _ENV = nil;
@@ -9,7 +9,7 @@
 local throttle_mt = { __index = throttle };
 
 function throttle:update()
-	local newt = gettime();
+	local newt = time_now();
 	local elapsed = newt - self.t;
 	self.t = newt;
 	local balance = (self.rate * elapsed) + self.balance;
@@ -40,7 +40,7 @@
 end
 
 local function create(max, period)
-	return setmetatable({ rate = max / period, max = max, t = gettime(), balance = max }, throttle_mt);
+	return setmetatable({ rate = max / period, max = max, t = time_now(), balance = max }, throttle_mt);
 end
 
 return {