Changeset

5593:04f36a470dca

Update from upstream
author Trần H. Trung <xmpp:trần.h.trung@trung.fun>
date Sun, 09 Jul 2023 01:31:29 +0700
parents 5591:680fb3344357 (current diff) 5592:59acf7f540c1 (diff)
children 5594:14480ca9576e
files
diffstat 48 files changed, 1446 insertions(+), 690 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/misc/lnav/README.md	Sun Jul 09 01:31:29 2023 +0700
@@ -0,0 +1,6 @@
+% Prosody log format for lnav
+
+This is a format definition that allows <https://lnav.org/> to better
+handle Prosody logs.
+
+Install it using `lnav -i ./prosody.json`
--- a/misc/lnav/prosody.json	Fri May 26 02:15:45 2023 +0700
+++ b/misc/lnav/prosody.json	Sun Jul 09 01:31:29 2023 +0700
@@ -14,7 +14,7 @@
       "ordered-by-time" : true,
       "regex" : {
          "standard" : {
-            "pattern" : "^(?<timestamp>\\w{3} \\d{2} \\d{2}:\\d{2}:\\d{2})\\s+(?<loggername>\\S+)\\s+(?<loglevel>debug|info|warn|error)\\s+(?<message>.+)$"
+            "pattern" : "^(?<timestamp>\\w{3} \\d{2} \\d{2}:\\d{2}:\\d{2}\\s+)(?<loggername>\\S+)\\s+(?<loglevel>debug|info|warn|error)\\s+(?<message>.+)$"
          }
       },
       "sample" : [
@@ -23,7 +23,9 @@
          }
       ],
       "timestamp-field" : "timestamp",
-      "timestamp-format" : "%b %d %H:%M:%S ",
+      "timestamp-format" : [
+         "%b %d %H:%M:%S "
+      ],
       "title" : "Prosody log",
       "url" : "https://prosody.im/doc/logging",
       "value" : {
--- a/mod_auth_oauth_external/README.md	Fri May 26 02:15:45 2023 +0700
+++ b/mod_auth_oauth_external/README.md	Sun Jul 09 01:31:29 2023 +0700
@@ -79,7 +79,7 @@
     owner password grant.
 
 `oauth_external_scope`
-:   String. Defaults to `"oauth"`. Included in request for resource
+:   String. Defaults to `"openid"`. Included in request for resource
     owner password grant.
 
 # Compatibility
--- a/mod_client_management/mod_client_management.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_client_management/mod_client_management.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -10,8 +10,8 @@
 
 local strict = module:get_option_boolean("enforce_client_ids", false);
 
-module:default_permission("prosody:user", ":list-clients");
-module:default_permission("prosody:user", ":manage-clients");
+module:default_permission("prosody:registered", ":list-clients");
+module:default_permission("prosody:registered", ":manage-clients");
 
 local tokenauth = module:depends("tokenauth");
 local mod_fast = module:depends("sasl2_fast");
@@ -35,6 +35,8 @@
 	if not (sasl_agent or token_agent) then return; end
 	return {
 		software = sasl_agent and sasl_agent.software or token_agent and token_agent.name or nil;
+		software_id = token_agent and token_agent.id or nil;
+		software_version = token_agent and token_agent.version or nil;
 		uri = token_agent and token_agent.uri or nil;
 		device = sasl_agent and sasl_agent.device or nil;
 	};
@@ -348,7 +350,7 @@
 		local user_agent = st.stanza("user-agent");
 		if client.user_agent then
 			if client.user_agent.software then
-				user_agent:text_tag("software", client.user_agent.software);
+				user_agent:text_tag("software", client.user_agent.software, { id = client.user_agent.software_id; version = client.user_agent.software_version });
 			end
 			if client.user_agent.device then
 				user_agent:text_tag("device", client.user_agent.device);
--- a/mod_cloud_notify_extensions/README.markdown	Fri May 26 02:15:45 2023 +0700
+++ b/mod_cloud_notify_extensions/README.markdown	Sun Jul 09 01:31:29 2023 +0700
@@ -38,13 +38,10 @@
 There is no configuration for this module, just add it to
 modules\_enabled as normal.
 
-Compatibility
-=============
+# Compatibility
 
-  ----- -------
-  0.12  Works
-  ----- -------
-  0.11  Should work
-  ----- -------
-  trunk Works
-  ----- -------
+  ------- -------------
+  0.12    Works
+  0.11    Should work
+  trunk   Works
+  ------- -------------
--- a/mod_compat_roles/mod_compat_roles.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_compat_roles/mod_compat_roles.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -33,8 +33,12 @@
 
 local role_inheritance = {
 	["prosody:operator"] = "prosody:admin";
-	["prosody:admin"] = "prosody:user";
-	["prosody:user"] = "prosody:restricted";
+	["prosody:admin"] = "prosody:member";
+	["prosody:member"] = "prosody:registered";
+	["prosody:registered"] = "prosody:guest";
+
+	-- COMPAT
+	["prosody:user"] = "prosody:registered";
 };
 
 local function role_may(host, role_name, permission)
--- a/mod_firewall/README.markdown	Fri May 26 02:15:45 2023 +0700
+++ b/mod_firewall/README.markdown	Sun Jul 09 01:31:29 2023 +0700
@@ -10,6 +10,8 @@
       mod_firewall.definitions: definitions.lib.lua
       mod_firewall.marks: marks.lib.lua
       mod_firewall.test: test.lib.lua
+    copy_directories:
+      - scripts
 ---
 
 ------------------------------------------------------------------------
@@ -253,12 +255,13 @@
 
 ### Sender/recipient matching
 
-  Condition     Matches
-  ------------- -------------------------------------------------------
-  `FROM`        The JID in the 'from' attribute matches the given JID.
-  `TO`          The JID in the 'to' attribute matches the given JID.
-  `TO SELF`     The stanza is sent by any of a user's resources to their own bare JID.
-  `TO FULL JID` The stanza is addressed to a valid full JID on the local server (full JIDs include a resource at the end, and only exist for the lifetime of a single session, therefore the recipient must be online, or this check will not match).
+  Condition       Matches
+  --------------- -------------------------------------------------------
+  `FROM`          The JID in the 'from' attribute matches the given JID.
+  `TO`            The JID in the 'to' attribute matches the given JID.
+  `TO SELF`       The stanza is sent by any of a user's resources to their own bare JID.
+  `TO FULL JID`   The stanza is addressed to a **valid** full JID on the local server (full JIDs include a resource at the end, and only exist for the lifetime of a single session, therefore the recipient **must be online**, or this check will not match).
+  `FROM FULL JID` The stanza is from a full JID (unlike `TO FULL JID` this check is on the format of the JID only).
 
 The TO and FROM conditions both accept wildcards in the JID when it is
 enclosed in angle brackets ('\<...\>'). For example:
--- a/mod_firewall/actions.lib.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_firewall/actions.lib.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -220,11 +220,29 @@
 end
 
 function action_handlers.MARK_USER(name)
-	return [[if session.firewall_marks then session.firewall_marks.]]..idsafe(name)..[[ = current_timestamp; end]], { "timestamp" };
+	return ([[if session.username and session.host == current_host then
+			fire_event("firewall/marked/user", {
+				username = session.username;
+				mark = %q;
+				timestamp = current_timestamp;
+			});
+		else
+			log("warn", "Attempt to MARK a remote user - only local users may be marked");
+		end]]):format(assert(idsafe(name), "Invalid characters in mark name: "..name)), {
+			"current_host";
+			"timestamp";
+		};
 end
 
 function action_handlers.UNMARK_USER(name)
-	return [[if session.firewall_marks then session.firewall_marks.]]..idsafe(name)..[[ = nil; end]], { "timestamp" };
+	return ([[if session.username and session.host == current_host then
+			fire_event("firewall/unmarked/user", {
+				username = session.username;
+				mark = %q;
+			});
+		else
+			log("warn", "Attempt to UNMARK a remote user - only local users may be marked");
+		end]]):format(assert(idsafe(name), "Invalid characters in mark name: "..name));
 end
 
 function action_handlers.ADD_TO(spec)
--- a/mod_firewall/conditions.lib.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_firewall/conditions.lib.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -67,6 +67,10 @@
 	return compile_jid_match("from", from), { "split_from" };
 end
 
+function condition_handlers.FROM_FULL_JID()
+	return "not "..compile_jid_match_part("from_resource", nil), { "split_from" };
+end
+
 function condition_handlers.FROM_EXACTLY(from)
 	local metadeps = {};
 	return ("from == %s"):format(metaq(from, metadeps)), { "from", unpack(metadeps) };
@@ -310,7 +314,9 @@
 		error("Error parsing mark name, see documentation for usage examples");
 	end
 	if time then
-		return ("(current_timestamp - (session.firewall_marks and session.firewall_marks.%s or 0)) < %d"):format(idsafe(name), tonumber(time)), { "timestamp" };
+		return ([[(
+			current_timestamp - (session.firewall_marks and session.firewall_marks.%s or 0)
+		) < %d]]):format(idsafe(name), tonumber(time)), { "timestamp" };
 	end
 	return ("not not (session.firewall_marks and session.firewall_marks."..idsafe(name)..")");
 end
@@ -341,7 +347,13 @@
 	if not (search_name) then
 		error("Error parsing SCAN expression, syntax: SEARCH for PATTERN in LIST");
 	end
-	return ("scan_list(list_%s, %s)"):format(list_name, "tokens_"..search_name.."_"..pattern_name), { "scan_list", "tokens:"..search_name.."-"..pattern_name, "list:"..list_name };
+	return ("scan_list(list_%s, %s)"):format(
+		list_name,
+		"tokens_"..search_name.."_"..pattern_name
+	), {
+			"scan_list",
+			"tokens:"..search_name.."-"..pattern_name, "list:"..list_name
+	};
 end
 
 -- COUNT: lines in body < 10
@@ -361,7 +373,12 @@
 	end
 	local comp_op = comparator_expression:gsub("%s+", "");
 	assert(valid_comp_ops[comp_op], "Error parsing COUNT expression, unknown comparison operator: "..comp_op);
-	return ("it_count(search_%s:gmatch(pattern_%s)) %s %d"):format(search_name, pattern_name, comp_op, value), { "it_count", "search:"..search_name, "pattern:"..pattern_name };
+	return ("it_count(search_%s:gmatch(pattern_%s)) %s %d"):format(
+		search_name, pattern_name, comp_op, value
+	), {
+		"it_count",
+		"search:"..search_name, "pattern:"..pattern_name
+	};
 end
 
 return condition_handlers;
--- a/mod_firewall/marks.lib.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_firewall/marks.lib.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -1,23 +1,35 @@
 local mark_storage = module:open_store("firewall_marks");
+local mark_map_storage = module:open_store("firewall_marks", "map");
 
 local user_sessions = prosody.hosts[module.host].sessions;
 
-module:hook("resource-bind", function (event)
-	local session = event.session;
-	local username = session.username;
-	local user = user_sessions[username];
-	local marks = user.firewall_marks;
-	if not marks then
-		marks = mark_storage:get(username) or {};
-		user.firewall_marks = marks; -- luacheck: ignore 122
+module:hook("firewall/marked/user", function (event)
+	local user = user_sessions[event.username];
+	local marks = user and user.firewall_marks;
+	if user and not marks then
+		-- Load marks from storage to cache on the user object
+		marks = mark_storage:get(event.username) or {};
+		user.firewall_marks = marks; --luacheck: ignore 122
+	end
+	if marks then
+		marks[event.mark] = event.timestamp;
+	end
+	local ok, err = mark_map_storage:set(event.username, event.mark, event.timestamp);
+	if not ok then
+		module:log("error", "Failed to mark user %q with %q: %s", event.username, event.mark, err);
 	end
-	session.firewall_marks = marks;
-end);
+	return true;
+end, -1);
 
-module:hook("resource-unbind", function (event)
-	local session = event.session;
-	local username = session.username;
-	local marks = session.firewall_marks;
-	mark_storage:set(username, marks);
-end);
-
+module:hook("firewall/unmarked/user", function (event)
+	local user = user_sessions[event.username];
+	local marks = user and user.firewall_marks;
+	if marks then
+		marks[event.mark] = nil;
+	end
+	local ok, err = mark_map_storage:set(event.username, event.mark, nil);
+	if not ok then
+		module:log("error", "Failed to unmark user %q with %q: %s", event.username, event.mark, err);
+	end
+	return true;
+end, -1);
--- a/mod_firewall/mod_firewall.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_firewall/mod_firewall.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -316,7 +316,7 @@
 local condition_handlers = module:require("conditions");
 local action_handlers = module:require("actions");
 
-if module:get_option_boolean("firewall_experimental_user_marks", false) then
+if module:get_option_boolean("firewall_experimental_user_marks", true) then
 	module:require"marks";
 end
 
@@ -742,3 +742,43 @@
 		print("end -- End of file "..filename);
 	end
 end
+
+
+-- Console
+
+local console_env = module:shared("/*/admin_shell/env");
+
+console_env.firewall = {};
+
+function console_env.firewall:mark(user_jid, mark_name)
+	local username, host = jid.split(user_jid);
+	if not username or not hosts[host] then
+		return nil, "Invalid JID supplied";
+	elseif not idsafe(mark_name) then
+		return nil, "Invalid characters in mark name";
+	end
+	if not module:context(host):fire_event("firewall/marked/user", {
+		username = session.username;
+		mark = mark_name;
+		timestamp = os.time();
+	}) then
+		return nil, "Mark not set - is mod_firewall loaded on that host?";
+	end
+	return true, "User marked";
+end
+
+function console_env.firewall:unmark(jid, mark_name)
+	local username, host = jid.split(user_jid);
+	if not username or not hosts[host] then
+		return nil, "Invalid JID supplied";
+	elseif not idsafe(mark_name) then
+		return nil, "Invalid characters in mark name";
+	end
+	if not module:context(host):fire_event("firewall/unmarked/user", {
+		username = session.username;
+		mark = mark_name;
+	}) then
+		return nil, "Mark not removed - is mod_firewall loaded on that host?";
+	end
+	return true, "User unmarked";
+end
--- a/mod_firewall/scripts/spam-blocking.pfw	Fri May 26 02:15:45 2023 +0700
+++ b/mod_firewall/scripts/spam-blocking.pfw	Sun Jul 09 01:31:29 2023 +0700
@@ -97,6 +97,12 @@
 TYPE: groupchat
 PASS.
 
+# Mediated MUC invitations are naturally from 'strangers' and have special
+# handling. We lean towards accepting them, unless overridden by custom rules.
+NOT FROM FULL JID?
+INSPECT: {http://jabber.org/protocol/muc#user}x/invite
+JUMP CHAIN=user/spam_check_muc_invite
+
 # Non-chat message types often generate pop-ups in clients,
 # so we won't accept them from strangers
 NOT TYPE: chat
@@ -138,6 +144,18 @@
 
 ##################################################################
 
+#### Rules for MUC invitations ###################################
+
+::user/spam_check_muc_invite
+
+# This chain can be used to inspect the invitation and determine
+# the appropriate action. Otherwise, we proceed with the default
+# action below.
+JUMP CHAIN=user/spam_check_muc_invite_custom
+
+# Allow mediated MUC invitations by default
+PASS.
+
 #### Stanzas reaching this chain will be rejected ################
 ::user/spam_reject
 
@@ -151,7 +169,7 @@
 
 ##################################################################
 
-#### Stanzas that may be spam, but we're not sure either way######
+#### Stanzas that may be spam, but we're not sure either way #####
 ::user/spam_handle_unknown
 
 # This chain can be used by other scripts
--- a/mod_firewall/scripts/spam-blocklists.pfw	Fri May 26 02:15:45 2023 +0700
+++ b/mod_firewall/scripts/spam-blocklists.pfw	Sun Jul 09 01:31:29 2023 +0700
@@ -8,3 +8,13 @@
 
 CHECK LIST: blocklist contains $<@from|host>
 BOUNCE=policy-violation (Your server is blocked due to spam)
+
+::user/spam_check_muc_invite_custom
+
+# Check the server we received the invitation from
+CHECK LIST: blocklist contains $<@from|host>
+BOUNCE=policy-violation (Your server is blocked due to spam)
+
+# Check the inviter's JID against the blocklist, too
+CHECK LIST: blocklist contains $<{http://jabber.org/protocol/muc#user}x/invite@from|host>
+BOUNCE=policy-violation (Your server is blocked due to spam)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_groups_oidc/README.md	Sun Jul 09 01:31:29 2023 +0700
@@ -0,0 +1,12 @@
+---
+summary: OIDC group membership in UserInfo
+labels:
+- Stage-Alpha
+rockspec:
+  dependencies:
+  - mod_http_oauth2 >= 200
+  - mod_groups_internal
+---
+
+This module exposes [mod_groups_internal] groups to
+[OAuth 2.0][mod_http_oauth2] clients via a `groups` scope/claim.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_groups_oidc/mod_groups_oidc.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -0,0 +1,15 @@
+local array = require "util.array";
+
+module:add_item("openid-claim", "groups");
+
+local group_memberships = module:open_store("groups", "map");
+local function user_groups(username)
+	return pairs(group_memberships:get_all(username) or {});
+end
+
+module:hook("token/userinfo", function(event)
+	local userinfo = event.userinfo;
+	if event.claims:contains("groups") then
+		userinfo.groups = array(user_groups(event.username));
+	end
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_debug/README.md	Sun Jul 09 01:31:29 2023 +0700
@@ -0,0 +1,40 @@
+---
+summary: HTTP module returning info about requests for debugging
+---
+
+This module returns some info about HTTP requests as Prosody sees them
+from an endpoint like `http://xmpp.example.net:5281/debug`.  This can be
+used to validate [reverse-proxy configuration][doc:http] and similar use
+cases.
+
+# Example
+
+```
+$ curl -sSf  https://xmpp.example.net:5281/debug | json_pp
+{
+   "body" : "",
+   "headers" : {
+      "accept" : "*/*",
+      "host" : "xmpp.example.net:5281",
+      "user_agent" : "curl/7.74.0"
+   },
+   "httpversion" : "1.1",
+   "id" : "jmFROQKoduU3",
+   "ip" : "127.0.0.1",
+   "method" : "GET",
+   "path" : "/debug",
+   "secure" : true,
+   "url" : {
+      "path" : "/debug"
+   }
+}
+```
+
+# Configuration
+
+HTTP Methods handled can be configured via the `http_debug_methods`
+setting. By default, the most common methods are already enabled.
+
+```lua
+http_debug_methods = { "GET"; "HEAD"; "DELETE"; "OPTIONS"; "PATCH"; "POST"; "PUT" };
+```
--- a/mod_http_debug/mod_http_debug.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_http_debug/mod_http_debug.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -1,26 +1,34 @@
 local json = require "util.json"
 
 module:depends("http")
+local function handle_request(event)
+	local request = event.request;
+	(request.log or module._log)("debug", "%s -- %s %q HTTP/%s -- %q -- %s", request.ip, request.method, request.url, request.httpversion, request.headers, request.body);
+	return {
+		status_code = 200;
+		headers = { content_type = "application/json" };
+		host = module.host;
+		body = json.encode {
+			body = request.body;
+			headers = request.headers;
+			httpversion = request.httpversion;
+			id = request.id;
+			ip = request.ip;
+			method = request.method;
+			path = request.path;
+			secure = request.secure;
+			url = request.url;
+		};
+	}
+end
+
+local methods = module:get_option_set("http_debug_methods", { "GET"; "HEAD"; "DELETE"; "OPTIONS"; "PATCH"; "POST"; "PUT" });
+local route = {};
+for method in methods do
+	route[method] = handle_request;
+	route[method .. " /*"] = handle_request;
+end
+
 module:provides("http", {
-		route = {
-			GET = function(event)
-				local request = event.request;
-				return {
-					status_code = 200;
-					headers = {
-						content_type = "application/json",
-					},
-					body = json.encode {
-						body = request.body;
-						headers = request.headers;
-						httpversion = request.httpversion;
-						ip = request.ip;
-						method = request.method;
-						path = request.path;
-						secure = request.secure;
-						url = request.url;
-					}
-				}
-			end;
-		}
-	})
+	route = route;
+})
--- a/mod_http_dir_listing/README.markdown	Fri May 26 02:15:45 2023 +0700
+++ b/mod_http_dir_listing/README.markdown	Sun Jul 09 01:31:29 2023 +0700
@@ -2,9 +2,9 @@
 rockspec:
   build:
     copy_directories:
-    - mod_http_dir_listing/http_dir_listing/resources
+    - http_dir_listing/resources
     modules:
-      mod_http_dir_listing: mod_http_dir_listing/http_dir_listing/mod_http_dir_listing.lua
+      mod_http_dir_listing: http_dir_listing/mod_http_dir_listing.lua
 summary: HTTP directory listing
 ...
 
--- a/mod_http_dir_listing2/README.markdown	Fri May 26 02:15:45 2023 +0700
+++ b/mod_http_dir_listing2/README.markdown	Sun Jul 09 01:31:29 2023 +0700
@@ -1,6 +1,10 @@
 ---
 summary: HTTP directory listing
-...
+rockspec:
+  build:
+    copy_directories:
+      - resources
+---
 
 Introduction
 ============
--- a/mod_http_muc_log/mod_http_muc_log.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_http_muc_log/mod_http_muc_log.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -128,17 +128,42 @@
 
 local presence_logged = module:get_option_boolean("muc_log_presences", false);
 
-local function hide_presence(request)
+local function show_presence(request) --> boolean|nil
+	-- boolean -> yes or no
+	-- nil -> dunno
 	if not presence_logged then
-		return false;
+		-- No presence stored, skip
+		return nil;
 	end
 	if request.url.query then
 		local data = httplib.formdecode(request.url.query);
-		if data then
-			return data.p == "h"
+		if type(data) == "table" then
+			if data.p == "s" or data.p == "h" then
+				return data.p == "s";
+			end
 		end
 	end
-	return false;
+end
+
+local function presence_with(request)
+	local show = show_presence(request);
+	if show == true then
+		return nil; -- no filter, everything
+	elseif show == false or show == nil then
+		-- only messages
+		return "message<groupchat";
+	end
+end
+
+local function presence_query(request) -- > ?p=[sh]
+	local show = show_presence(request);
+	if show == true then
+		return { p = "s" }
+	elseif show == false then
+		return { p = "h" }
+	else
+		return nil;
+	end
 end
 
 local function get_dates(room) --> { integer, ... }
@@ -254,7 +279,8 @@
 		room = room_obj._data;
 		jid = room_obj.jid;
 		jid_node = jid_split(room_obj.jid);
-		hide_presence = hide_presence(request);
+		q = presence_query(request);
+		show_presence = show_presence(request);
 		presence_available = presence_logged;
 		dates = date_list;
 		links = {
@@ -300,7 +326,7 @@
 	local iter, err = archive:find(room, {
 		["start"] = day_start;
 		["end"]   = day_start + 86399;
-		["with"]  = hide_presence(request) and "message<groupchat" or nil;
+		["with"]  = presence_with(request);
 	});
 	if not iter then
 		module:log("warn", "Could not search archive: %s", err or "no error");
@@ -475,7 +501,8 @@
 		room = room_obj._data;
 		jid = room_obj.jid;
 		jid_node = jid_split(room_obj.jid);
-		hide_presence = hide_presence(request);
+		q = presence_query(request);
+		show_presence = show_presence(request);
 		presence_available = presence_logged;
 		lang = room_obj.get_language and room_obj:get_language();
 		lines = logs;
@@ -524,7 +551,8 @@
 		static = "./@static";
 		title = module:get_option_string("name", "Prosody Chatrooms");
 		jid = module.host;
-		hide_presence = hide_presence(request);
+		q = presence_query(request);
+		show_presence = show_presence(request);
 		presence_available = presence_logged;
 		rooms = room_list;
 		dates = {}; -- COMPAT util.interpolation {nil|func#...} bug
--- a/mod_http_muc_log/res/http_muc_log.html	Fri May 26 02:15:45 2023 +0700
+++ b/mod_http_muc_log/res/http_muc_log.html	Sun Jul 09 01:31:29 2023 +0700
@@ -19,7 +19,7 @@
 <li class="button"><a href="{room.webchat_url}">Join via web</a></li>
 }
 {links#
-<li><a class="{item.rel?}" href="{item.href}{hide_presence&?p=h}" rel="{item.rel?}">{item.text}</a></li>}
+<li><a class="{item.rel?}" href="{item.href}{q&?{q%{idx}={item}}}" rel="{item.rel?}">{item.text}</a></li>}
 </ul>
 </nav>
 </header>
@@ -28,7 +28,7 @@
 <nav>
 <dl class="room-list">
 {rooms#
-<dt {item.lang&lang="{item.lang}"} class="name"><a href="{item.href}{hide_presence&?p=h}">{item.name}</a></dt>
+<dt {item.lang&lang="{item.lang}"} class="name"><a href="{item.href}{q&?{q%{idx}={item}}}">{item.name}</a></dt>
 <dd {item.lang&lang="{item.lang}"} class="description">{item.description?}</dd>}
 </dl>
 {dates|calendarize#
@@ -38,7 +38,7 @@
 <caption>{item.month}</caption>
 <thead><tr><th>Mon</th><th>Tue</th><th>Wed</th><th>Thu</th><th>Fri</th><th>Sat</th><th>Sun</th></tr></thead>
 <tbody>{item.weeks#
-<tr>{item.days#<td>{item.href&<a href="{item.href}{hide_presence&?p=h}">}<span>{item.day?&nbsp;}</span>{item.href&</a>}</td>}</tr>}
+<tr>{item.days#<td>{item.href&<a href="{item.href}{q&?{q%{idx}={item}}}">}<span>{item.day?&nbsp;}</span>{item.href&</a>}</td>}</tr>}
 </tbody>
 </table>
 }
@@ -48,8 +48,8 @@
 <div>
 {presence_available&<form>
 <label>
-<input name="p" value="h" type="checkbox"{hide_presence& checked}>
-<span>Hide joins and parts</span>
+	<input name="p" value="s" type="checkbox"{show_presence& checked}>
+<span>show joins and parts</span>
 </label>
 <noscript>
 <button type="submit">Apply</button>
@@ -72,7 +72,7 @@
 <footer>
 <nav>
 <ul>{links#
-<li><a class="{item.rel?}" href="{item.href}{hide_presence&?p=h}" rel="{item.rel?}">{item.text}</a></li>}
+<li><a class="{item.rel?}" href="{item.href}{q&?{q%{idx}={item}}}" rel="{item.rel?}">{item.text}</a></li>}
 </ul>
 </nav>
 <br>
--- a/mod_http_oauth2/README.markdown	Fri May 26 02:15:45 2023 +0700
+++ b/mod_http_oauth2/README.markdown	Sun Jul 09 01:31:29 2023 +0700
@@ -1,12 +1,12 @@
 ---
 labels:
 - Stage-Alpha
-summary: 'OAuth2 API'
 rockspec:
   build:
     copy_directories:
     - html
-...
+summary: OAuth 2.0 Authorization Server API
+---
 
 ## Introduction
 
@@ -18,9 +18,10 @@
 third-party applications limited access to your account, without sharing your
 password with them.
 
-With this module deployed, software that supports OAuth can obtain "access
-tokens" from Prosody which can then be used to connect to XMPP accounts using
-the 'OAUTHBEARER' SASL mechanism or via non-XMPP interfaces such as [mod_rest].
+With this module deployed, software that supports OAuth can obtain
+"access tokens" from Prosody which can then be used to connect to XMPP
+accounts using the [OAUTHBEARER SASL mechanism][rfc7628] or via non-XMPP
+interfaces such as [mod_rest].
 
 Although this module has been around for some time, it has recently been
 significantly extended and largely rewritten to support OAuth/OIDC more fully.
@@ -36,9 +37,10 @@
 -   [example shell script for mod_rest](https://hg.prosody.im/prosody-modules/file/tip/mod_rest/example/rest.sh)
 -   *(we need you!)*
 
-Support for OAUTHBEARER has been added to the Lua XMPP library, [verse](https://code.matthewwild.co.uk/verse).
-If you know of additional implementations, or are motivated to work on one,
-please let us know! We'd be happy to help (e.g. by providing a test server).
+Support for [OAUTHBEARER][rfc7628] has been added to the Lua XMPP
+library, [verse](https://code.matthewwild.co.uk/verse).  If you know of
+additional implementations, or are motivated to work on one, please let
+us know! We'd be happy to help (e.g. by providing a test server).
 
 ## Standards support
 
@@ -49,6 +51,7 @@
 - [RFC 7591: OAuth 2.0 Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591.html)
 - [RFC 7628: A Set of Simple Authentication and Security Layer (SASL) Mechanisms for OAuth](https://www.rfc-editor.org/rfc/rfc7628)
 - [RFC 7636: Proof Key for Code Exchange by OAuth Public Clients](https://www.rfc-editor.org/rfc/rfc7636)
+- [RFC 9207: OAuth 2.0 Authorization Server Issuer Identification](https://www.rfc-editor.org/rfc/rfc9207.html)
 - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
 - [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html) (_partial, e.g. missing JWKS_)
 - [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) 
@@ -61,7 +64,7 @@
 a client requests access. Built-in pages are provided, but you may also theme
 or entirely override them.
 
-This module honours the 'site_name' configuration option that is also used by
+This module honours the `site_name` configuration option that is also used by
 a number of other modules:
 
 ```lua
@@ -75,7 +78,7 @@
 ```
 
 Some templates support additional variables, that can be provided by the
-'oauth2_template_style' option:
+`oauth2_template_style` option:
 
 ```lua
 oauth2_template_style = {
@@ -83,6 +86,13 @@
 }
 ```
 
+If you know what features your templates use use you can adjust the
+`Content-Security-Policy` header to only allow what is needed:
+
+```lua
+oauth2_security_policy = "default-src 'self'" -- this is the default
+```
+
 ### Token parameters
 
 The following options configure the lifetime of tokens issued by the module.
@@ -107,12 +117,109 @@
 oauth2_registration_ttl = nil -- unlimited by default
 ```
 
+Registering a client is described in
+[RFC7591](https://www.rfc-editor.org/rfc/rfc7591.html).
+
+In addition to the requirements in the RFC, the following requirements
+are enforced:
+
+`client_name`
+:   **MUST** be present, is shown to users in consent screen.
+
+`client_uri`
+:   **MUST** be present and **MUST** be a `https://` URL.
+
+`redirect_uris`
+
+:   **MUST** contain at least one valid URI. Different rules apply
+    depending on the value of `application_type`, see below.
+
+`application_type`
+
+:   Optional, defaults to `web`. Determines further restrictions for
+    `redirect_uris`. The following values are supported:
+
+    `web` *(default)*
+    :   For web clients. With this, `redirect_uris` **MUST** be
+        `https://` URIs and **MUST** use the same hostname part as the
+        `client_uri`.
+
+    `native`
+
+    `native`
+
+    :   For native e.g. desktop clients etc. `redirect_uris` **MUST**
+        match one of:
+
+        -   Loopback HTTP URI, e.g. `http://127.0.0.1/` or
+            `http://[::1]`
+        -   Application-specific scheme, e.g. `com.example.app:/`
+        -   The special OOB URI `urn:ietf:wg:oauth:2.0:oob`
+
+`tos_uri`, `policy_uri`
+:   Informative URLs pointing to Terms of Service and Service Policy
+    document **MUST** use the same scheme (i.e. `https://`) and hostname
+    as the `client_uri`.
+
+#### Registration Examples
+
+In short registration works by POST-ing a JSON structure describing your
+client to an endpoint:
+
+``` bash
+curl -sSf https://xmpp.example.net/oauth2/register \
+    -H Content-Type:application/json \
+    -H Accept:application/json \
+    --data '
+{
+   "client_name" : "My Application",
+   "client_uri" : "https://app.example.com/",
+   "redirect_uris" : [
+      "https://app.example.com/redirect"
+   ]
+}
+'
+```
+
+Another example with more fields:
+
+``` bash
+curl -sSf https://xmpp.example.net/oauth2/register \
+    -H Content-Type:application/json \
+    -H Accept:application/json \
+    --data '
+{
+   "application_type" : "native",
+   "client_name" : "Desktop Chat App",
+   "client_uri" : "https://app.example.org/",
+   "contacts" : [
+      "support@example.org"
+   ],
+   "policy_uri" : "https://app.example.org/about/privacy",
+   "redirect_uris" : [
+      "http://localhost:8080/redirect",
+      "org.example.app:/redirect"
+   ],
+   "scope" : "xmpp",
+   "software_id" : "32a0a8f3-4016-5478-905a-c373156eca73",
+   "software_version" : "3.4.1",
+   "tos_uri" : "https://app.example.org/about/terms"
+}
+'
+```
+
 ### Supported flows
 
+-   Authorization Code grant, optionally with Proof Key for Code Exchange
+-   Resource owner password grant
+-   Implicit flow *(disabled by default)*
+-   Refresh Token grants
+
 Various flows can be disabled and enabled with
 `allowed_oauth2_grant_types` and `allowed_oauth2_response_types`:
 
 ```lua
+-- These examples reflect the defaults
 allowed_oauth2_grant_types = {
 	"authorization_code"; -- authorization code grant
 	"password"; -- resource owner password grant
@@ -124,16 +231,17 @@
 }
 ```
 
-The [Proof Key for Code Exchange][RFC 7636] mitigation method can be
-made required:
+The [Proof Key for Code Exchange][RFC 7636] mitigation method is
+optional by default but can be made required:
 
 ```lua
-oauth2_require_code_challenge = true
+oauth2_require_code_challenge = true -- default is false
 ```
 
 Further, individual challenge methods can be enabled or disabled:
 
 ```lua
+-- These reflects the default
 allowed_oauth2_code_challenge_methods = {
     "plain"; -- the insecure one
     "S256";
@@ -148,6 +256,7 @@
 ```lua
 oauth2_terms_url = "https://example.com/terms-of-service.html"
 oauth2_policy_url = "https://example.com/service-policy.pdf"
+-- These are unset by default
 ```
 
 ## Deployment notes
@@ -157,7 +266,7 @@
 This module does not provide an interface for users to manage what they have
 granted access to their account! (e.g. to view and revoke clients they have
 previously authorized). It is recommended to join this module with
-mod_client_management to provide such access. However, at the time of writing,
+[mod_client_management] to provide such access. However, at the time of writing,
 no XMPP clients currently support the protocol used by that module. We plan to
 work on additional interfaces in the future.
 
--- a/mod_http_oauth2/html/consent.html	Fri May 26 02:15:45 2023 +0700
+++ b/mod_http_oauth2/html/consent.html	Sun Jul 09 01:31:29 2023 +0700
@@ -14,6 +14,7 @@
 
 	<h1>{site_name}</h1>
 	<fieldset>
+	<form method="post">
 	<legend>Authorize new application</legend>
 	<p>A new application wants to connect to your account.</p>
 	<dl>
@@ -29,6 +30,11 @@
 		{client.policy_uri&
 		<dt>Policy</dt>
 		<dd><a href="{client.policy_uri}">View policy</a></dd>}
+
+		<dt>Requested permissions</dt>
+		<dd>{scopes#
+			<input class="scope" type="checkbox" id="scope_{idx}" name="scope" value="{item}" checked><label class="scope" for="scope_{idx}">{item}</label>}
+		</dd>
 	</dl>
 
 	<p>To allow <em>{client.client_name}</em> to access your account
@@ -36,10 +42,6 @@
 	   select 'Allow'. Otherwise, select 'Deny'.
 	</p>
 
-	<form method="post">
-		<details><summary>Requested permissions</summary>{scopes#
-			<input class="scope" type="checkbox" id="scope_{idx}" name="scope" value="{item}" checked><label class="scope" for="scope_{idx}">{item}</label>}
-		</details>
 		<input type="hidden" name="user_token" value="{state.user.token}">
 		<button type="submit" name="consent" value="denied">Deny</button>
 		<button type="submit" name="consent" value="granted">Allow</button>
--- a/mod_http_oauth2/html/error.html	Fri May 26 02:15:45 2023 +0700
+++ b/mod_http_oauth2/html/error.html	Sun Jul 09 01:31:29 2023 +0700
@@ -8,7 +8,7 @@
 </head>
 <body>
 	<main>
-	<h1>{site_name}<h1>
+	<h1>{site_name}</h1>
 	<h2>Authentication error</h2>
 	<p>There was a problem with the authentication request. If you were trying to sign in to a
 	   third-party application, you may want to report this issue to the developers.</p>
--- a/mod_http_oauth2/html/login.html	Fri May 26 02:15:45 2023 +0700
+++ b/mod_http_oauth2/html/login.html	Sun Jul 09 01:31:29 2023 +0700
@@ -16,7 +16,7 @@
 		<p>{state.error}</p>
 	</div>}
 	<form method="post">
-		<input type="text" name="username" placeholder="Username" aria-label="Username" required {extra.no_username_hint&autofocus}{extra.username_hint& value="{extra.username_hint?}"}><br/>
+		<input type="text" name="username" placeholder="Username" aria-label="Username" required {extra.username_hint~autofocus}{extra.username_hint& value="{extra.username_hint?}"}><br/>
 		<input type="password" name="password" placeholder="Password" aria-label="Password" autocomplete="current-password" required {extra.username_hint&autofocus}><br/>
 		<input type="submit" value="Sign in">
 	</form>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/oob.html	Sun Jul 09 01:31:29 2023 +0700
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>{site_name} - Authorization Code</title>
+<link rel="stylesheet" href="style.css">
+</head>
+<body>
+	<main>
+	<h1>{site_name}</h1>
+	<h2>Your Authorization Code</h2>
+	<p>Here’s your authorization code, copy and paste it into {client.client_name}</p>
+	<div class="oob">
+		<p><input readonly name="authorization_code" value="{authorization_code}"></p>
+	</div>
+	</main>
+</body>
+</html>
--- a/mod_http_oauth2/html/style.css	Fri May 26 02:15:45 2023 +0700
+++ b/mod_http_oauth2/html/style.css	Sun Jul 09 01:31:29 2023 +0700
@@ -27,6 +27,22 @@
 	border: solid 1px #f5c2c7;
 }
 
+.oob
+{
+	background-color: #d7daf8;
+	border: solid 1px #c2c7f5;
+	color: #202984;
+	margin: 0.75em;
+}
+.oob input {
+	font-size: xx-large;
+	font-family: monospace;
+	background-color: inherit;
+	color: inherit;
+	border: none;
+	padding: 1ex 2em;
+}
+
 input {
 	margin: 0.3rem;
 	padding: 0.2rem;
@@ -71,6 +87,10 @@
 		color: #f8d7da;
 		background-color: #842029;
 	}
+	.oob {
+		color: #d7daf8;
+		background-color: #202984;
+	}
 
 
 	:link
--- a/mod_http_oauth2/mod_http_oauth2.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -1,22 +1,23 @@
-local hashes = require "util.hashes";
+local usermanager = require "core.usermanager";
+local url = require "socket.url";
+local array = require "util.array";
 local cache = require "util.cache";
+local encodings = require "util.encodings";
+local errors = require "util.error";
+local hashes = require "util.hashes";
 local http = require "util.http";
+local id = require "util.id";
+local it = require "util.iterators";
 local jid = require "util.jid";
 local json = require "util.json";
-local usermanager = require "core.usermanager";
-local errors = require "util.error";
-local url = require "socket.url";
-local id = require "util.id";
-local encodings = require "util.encodings";
-local base64 = encodings.base64;
+local schema = require "util.jsonschema";
+local jwt = require "util.jwt";
 local random = require "util.random";
-local schema = require "util.jsonschema";
 local set = require "util.set";
-local jwt = require"util.jwt";
-local it = require "util.iterators";
-local array = require "util.array";
 local st = require "util.stanza";
 
+local base64 = encodings.base64;
+
 local function b64url(s)
 	return (base64.encode(s):gsub("[+/=]", { ["+"] = "-", ["/"] = "_", ["="] = "" }))
 end
@@ -27,6 +28,24 @@
 	end
 end
 
+local function strict_formdecode(query)
+	if not query then
+		return nil;
+	end
+	local params = http.formdecode(query);
+	if type(params) ~= "table" then
+		return nil, "no-pairs";
+	end
+	local dups = {};
+	for _, pair in ipairs(params) do
+		if dups[pair.name] then
+			return nil, "duplicate";
+		end
+		dups[pair.name] = true;
+	end
+	return params;
+end
+
 local function read_file(base_path, fn, required)
 	local f, err = io.open(base_path .. "/" .. fn);
 	if not f then
@@ -41,10 +60,14 @@
 	return data;
 end
 
+local allowed_locales = module:get_option_array("allowed_oauth2_locales", {});
+-- TODO Allow translations or per-locale templates somehow.
+
 local template_path = module:get_option_path("oauth2_template_path", "html");
 local templates = {
 	login = read_file(template_path, "login.html", true);
 	consent = read_file(template_path, "consent.html", true);
+	oob = read_file(template_path, "oob.html", true);
 	error = read_file(template_path, "error.html", true);
 	css = read_file(template_path, "style.css");
 	js = read_file(template_path, "script.js");
@@ -52,7 +75,9 @@
 
 local site_name = module:get_option_string("site_name", module.host);
 
-local _render_html = require"util.interpolation".new("%b{}", st.xml_escape);
+local security_policy = module:get_option_string("oauth2_security_policy", "default-src 'self'");
+
+local render_html = require"util.interpolation".new("%b{}", st.xml_escape);
 local function render_page(template, data, sensitive)
 	data = data or {};
 	data.site_name = site_name;
@@ -60,16 +85,19 @@
 		status_code = data.error and data.error.code or 200;
 		headers = {
 			["Content-Type"] = "text/html; charset=utf-8";
-			["Content-Security-Policy"] = "default-src 'self'";
+			["Content-Security-Policy"] = security_policy;
 			["Referrer-Policy"] = "no-referrer";
 			["X-Frame-Options"] = "DENY";
 			["Cache-Control"] = (sensitive and "no-store" or "no-cache")..", private";
+			["Pragma"] = "no-cache";
 		};
-		body = _render_html(template, data);
+		body = render_html(template, data);
 	};
 	return resp;
 end
 
+local authorization_server_metadata = nil;
+
 local tokens = module:depends("tokenauth");
 
 local default_access_ttl = module:get_option_number("oauth2_access_token_ttl", 86400);
@@ -92,6 +120,21 @@
 	sign_client, verify_client = jwt.init(registration_algo, registration_key, registration_key, registration_options);
 end
 
+-- verify and prepare client structure
+local function check_client(client_id)
+	if not verify_client then
+		return nil, "client-registration-not-enabled";
+	end
+
+	local ok, client = verify_client(client_id);
+	if not ok then
+		return ok, client;
+	end
+
+	client.client_hash = b64url(hashes.sha256(client_id));
+	return client;
+end
+
 -- scope : string | array | set
 --
 -- at each step, allow the same or a subset of scopes
@@ -103,7 +146,16 @@
 	return array(scope_string:gmatch("%S+"));
 end
 
-local openid_claims = set.new({ "openid"; "profile"; "email"; "address"; "phone" });
+local openid_claims = set.new();
+module:add_item("openid-claim", "openid");
+
+module:handle_items("openid-claim", function(event)
+	authorization_server_metadata = nil;
+	openid_claims:add(event.item);
+end, function()
+	authorization_server_metadata = nil;
+	openid_claims = set.new(module:get_host_items("openid-claim"));
+end, true);
 
 -- array -> array, array, array
 local function split_scopes(scope_list)
@@ -196,7 +248,13 @@
 -- properties that are deemed useful e.g. in case tokens issued to a certain
 -- client needs to be revoked
 local function client_subset(client)
-	return { name = client.client_name; uri = client.client_uri; id = client.software_id; version = client.software_version };
+	return {
+		name = client.client_name;
+		uri = client.client_uri;
+		id = client.software_id;
+		version = client.software_version;
+		hash = client.client_hash;
+	};
 end
 
 local function new_access_token(token_jid, role, scope_string, client, id_token, refresh_token_info)
@@ -328,24 +386,14 @@
 
 	local redirect_uri = get_redirect_uri(client, params.redirect_uri);
 	if redirect_uri == oob_uri then
-		-- TODO some nicer template page
-		-- mod_http_errors will set content-type to text/html if it catches this
-		-- event, if not text/plain is kept for the fallback text.
-		local response = { status_code = 200; headers = { content_type = "text/plain" } }
-		response.body = module:context("*"):fire_event("http-message", {
-			response = response;
-			title = "Your authorization code";
-			message = "Here's your authorization code, copy and paste it into " .. (client.client_name or "your client");
-			extra = code;
-		}) or ("Here's your authorization code:\n%s\n"):format(code);
-		return response;
+		return render_page(templates.oob, { client = client; authorization_code = code }, true);
 	elseif not redirect_uri then
 		return oauth_error("invalid_redirect_uri");
 	end
 
 	local redirect = url.parse(redirect_uri);
 
-	local query = http.formdecode(redirect.query or "");
+	local query = strict_formdecode(redirect.query);
 	if type(query) ~= "table" then query = {}; end
 	table.insert(query, { name = "code", value = code });
 	table.insert(query, { name = "iss", value = get_issuer() });
@@ -357,6 +405,8 @@
 	return {
 		status_code = 303;
 		headers = {
+			cache_control = "no-store";
+			pragma = "no-cache";
 			location = url.build(redirect);
 		};
 	}
@@ -379,6 +429,8 @@
 	return {
 		status_code = 303;
 		headers = {
+			cache_control = "no-store";
+			pragma = "no-cache";
 			location = url.build(redirect);
 		};
 	}
@@ -401,8 +453,8 @@
 		return oauth_error("invalid_scope", "unknown scope requested");
 	end
 
-	local client_ok, client = verify_client(params.client_id);
-	if not client_ok then
+	local client = check_client(params.client_id);
+	if not client then
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
 
@@ -414,6 +466,7 @@
 	if err then error(err); end
 	-- MUST NOT use the authorization code more than once, so remove it to
 	-- prevent a second attempted use
+	-- TODO if a second attempt *is* made, revoke any tokens issued
 	codes:set(params.client_id .. "#" .. params.code, nil);
 	if not code or type(code) ~= "table" or code_expired(code) then
 		module:log("debug", "authorization_code invalid or expired: %q", code);
@@ -436,8 +489,8 @@
 	if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end
 	if not params.refresh_token then return oauth_error("invalid_request", "missing 'refresh_token'"); end
 
-	local client_ok, client = verify_client(params.client_id);
-	if not client_ok then
+	local client = check_client(params.client_id);
+	if not client then
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
 
@@ -451,6 +504,13 @@
 		return oauth_error("invalid_grant", "invalid refresh token");
 	end
 
+	local refresh_token_client = refresh_token_info.grant.data.oauth2_client;
+	if not refresh_token_client.hash or refresh_token_client.hash ~= client.client_hash then
+		module:log("warn", "OAuth client %q (%s) tried to use refresh token belonging to %q (%s)", client.client_name, client.client_hash,
+			refresh_token_client.name, refresh_token_client.hash);
+		return oauth_error("unauthorized_client", "incorrect credentials");
+	end
+
 	local refresh_scopes = refresh_token_info.grant.data.oauth2_scopes;
 
 	if params.scope then
@@ -514,7 +574,7 @@
 			user = {
 				username = username;
 				host = module.host;
-				token = new_user_token({ username = username, host = module.host });
+				token = new_user_token({ username = username; host = module.host; auth_time = os.time() });
 			};
 		};
 	elseif form.user_token and form.consent then
@@ -607,7 +667,7 @@
 	if not redirect_uri or redirect_uri == oob_uri then
 		return render_error(err);
 	end
-	local q = request.url.query and http.formdecode(request.url.query);
+	local q = strict_formdecode(request.url.query);
 	local redirect_query = url.parse(redirect_uri);
 	local sep = redirect_query.query and "&" or "?";
 	redirect_uri = redirect_uri
@@ -617,12 +677,18 @@
 	return {
 		status_code = 303;
 		headers = {
+			cache_control = "no-store";
+			pragma = "no-cache";
 			location = redirect_uri;
 		};
 	};
 end
 
-local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {"authorization_code", "password", "refresh_token"})
+local allowed_grant_type_handlers = module:get_option_set("allowed_oauth2_grant_types", {
+	"authorization_code";
+	"password"; -- TODO Disable. The resource owner password credentials grant [RFC6749] MUST NOT be used.
+	"refresh_token";
+})
 for handler_type in pairs(grant_type_handlers) do
 	if not allowed_grant_type_handlers:contains(handler_type) then
 		module:log("debug", "Grant type %q disabled", handler_type);
@@ -657,7 +723,9 @@
 	local credentials = get_request_credentials(event.request);
 
 	event.response.headers.content_type = "application/json";
-	local params = http.formdecode(event.request.body);
+	event.response.headers.cache_control = "no-store";
+	event.response.headers.pragma = "no-cache";
+	local params = strict_formdecode(event.request.body);
 	if not params then
 		return oauth_error("invalid_request");
 	end
@@ -683,7 +751,7 @@
 	if not request.url.query then
 		return render_error(oauth_error("invalid_request", "Missing query parameters"));
 	end
-	local params = http.formdecode(request.url.query);
+	local params = strict_formdecode(request.url.query);
 	if not params then
 		return render_error(oauth_error("invalid_request", "Invalid query parameters"));
 	end
@@ -692,9 +760,9 @@
 		return render_error(oauth_error("invalid_request", "Missing 'client_id' parameter"));
 	end
 
-	local ok, client = verify_client(params.client_id);
+	local client = check_client(params.client_id);
 
-	if not ok then
+	if not client then
 		return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter"));
 	end
 
@@ -718,13 +786,31 @@
 		end);
 	end
 
+	-- The 'prompt' parameter from OpenID Core
+	local prompt = set.new(parse_scopes(params.prompt or "select_account login consent"));
+	if prompt:contains("none") then
+		-- Client wants no interaction, only confirmation of prior login and
+		-- consent, but this is not implemented.
+		return error_response(request, redirect_uri, oauth_error("interaction_required"));
+	elseif not prompt:contains("select_account") then
+		-- TODO If the login page is split into account selection followed by login
+		-- (e.g. password), and then the account selection could be skipped iff the
+		-- 'login_hint' parameter is present.
+		return error_response(request, redirect_uri, oauth_error("account_selection_required"));
+	elseif not prompt:contains("login") then
+		-- Currently no cookies or such are used, so login is required every time.
+		return error_response(request, redirect_uri, oauth_error("login_required"));
+	elseif not prompt:contains("consent") then
+		-- Are there any circumstances when consent would be implied or assumed?
+		return error_response(request, redirect_uri, oauth_error("consent_required"));
+	end
+
 	local auth_state = get_auth_state(request);
 	if not auth_state.user then
 		-- Render login page
 		local extra = {};
 		if params.login_hint then
 			extra.username_hint = (jid.prepped_split(params.login_hint));
-			extra.no_username_hint = not extra.username_hint;
 		end
 		return render_page(templates.login, { state = auth_state; client = client; extra = extra });
 	elseif auth_state.consent == nil then
@@ -755,6 +841,7 @@
 		iss = get_issuer();
 		sub = url.build({ scheme = "xmpp"; path = user_jid });
 		aud = params.client_id;
+		auth_time = auth_state.user.auth_time;
 		nonce = params.nonce;
 	});
 	local response_type = params.response_type;
@@ -771,6 +858,8 @@
 
 local function handle_revocation_request(event)
 	local request, response = event.request, event.response;
+	response.headers.cache_control = "no-store";
+	response.headers.pragma = "no-cache";
 	if request.headers.authorization then
 		local credentials = get_request_credentials(request);
 		if not credentials or credentials.type ~= "basic" then
@@ -783,7 +872,7 @@
 		end
 	end
 
-	local form_data = http.formdecode(event.request.body or "");
+	local form_data = strict_formdecode(event.request.body);
 	if not form_data or not form_data.token then
 		response.headers.accept = "application/x-www-form-urlencoded";
 		return 415;
@@ -839,24 +928,31 @@
 			default = { "code" };
 		};
 		client_name = { type = "string" };
-		client_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
-		logo_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
+		client_uri = { type = "string"; format = "uri"; pattern = "^https:" };
+		logo_uri = { type = "string"; format = "uri"; pattern = "^https:" };
 		scope = { type = "string" };
 		contacts = { type = "array"; minItems = 1; items = { type = "string"; format = "email" } };
-		tos_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
-		policy_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
-		jwks_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
-		jwks = { type = "object"; description = "JSON Web Key Set, RFC 7517" };
+		tos_uri = { type = "string"; format = "uri"; pattern = "^https:" };
+		policy_uri = { type = "string"; format = "uri"; pattern = "^https:" };
 		software_id = { type = "string"; format = "uuid" };
 		software_version = { type = "string" };
 	};
-	luaPatternProperties = {
-		-- Localized versions of descriptive properties and URIs
-		["^client_name#"] = { description = "Localized version of 'client_name'"; type = "string" };
-		["^[a-z_]+_uri#"] = { type = "string"; format = "uri"; luaPattern = "^https:" };
-	};
 }
 
+-- Limit per-locale fields to allowed locales, partly to keep size of client_id
+-- down, partly because we don't yet use them for anything.
+-- Only relevant for user-visible strings and URIs.
+if allowed_locales[1] then
+	local props = registration_schema.properties;
+	for _, locale in ipairs(allowed_locales) do
+		props["client_name#" .. locale] = props["client_name"];
+		props["client_uri#" .. locale] = props["client_uri"];
+		props["logo_uri#" .. locale] = props["logo_uri"];
+		props["tos_uri#" .. locale] = props["tos_uri"];
+		props["policy_uri#" .. locale] = props["policy_uri"];
+	end
+end
+
 local function redirect_uri_allowed(redirect_uri, client_uri, app_type)
 	local uri = url.parse(redirect_uri);
 	if not uri.scheme then
@@ -881,6 +977,13 @@
 		end
 	end
 
+	-- MUST ignore any metadata that it does not understand
+	for propname in pairs(client_metadata) do
+		if not registration_schema.properties[propname] then
+			client_metadata[propname] = nil;
+		end
+	end
+
 	local client_uri = url.parse(client_metadata.client_uri);
 	if not client_uri or client_uri.scheme ~= "https" or loopbacks:contains(client_uri.host) then
 		return nil, oauth_error("invalid_client_metadata", "Missing, invalid or insecure client_uri");
@@ -900,19 +1003,6 @@
 		end
 	end
 
-	for k, v in pairs(client_metadata) do
-		local base_k = k:match"^([^#]+)#" or k;
-		if not registration_schema.properties[base_k] or k:find"^client_uri#" then
-			-- Ignore and strip unknown extra properties
-			client_metadata[k] = nil;
-		elseif k:find"_uri#" then
-			-- Localized URIs should be secure too
-			if not redirect_uri_allowed(v, client_uri, "web") then
-				return nil, oauth_error("invalid_client_metadata", "Invalid, insecure or inappropriate informative URI");
-			end
-		end
-	end
-
 	local grant_types = set.new(client_metadata.grant_types);
 	local response_types = set.new(client_metadata.response_types);
 
@@ -928,10 +1018,6 @@
 		return nil, oauth_error("invalid_client_metadata", "No allowed 'response_types' specified");
 	end
 
-	-- Ensure each signed client_id JWT is unique, short ID and issued at
-	-- timestamp should be sufficient to rule out brute force attacks
-	client_metadata.nonce = id.short();
-
 	-- Do we want to keep everything?
 	local client_id = sign_client(client_metadata);
 
@@ -939,7 +1025,14 @@
 	client_metadata.client_id_issued_at = os.time();
 
 	if client_metadata.token_endpoint_auth_method ~= "none" then
-		local client_secret = make_client_secret(client_id);
+		-- Ensure that each client_id JWT with a client_secret is unique.
+		-- A short ID along with the issued at timestamp should be sufficient to
+		-- rule out brute force attacks.
+		-- Not needed for public clients without a secret, but those are expected
+		-- to be uncommon since they can only do the insecure implicit flow.
+		client_metadata.nonce = id.short();
+
+		local client_secret = make_client_secret(client_id, client_metadata);
 		client_metadata.client_secret = client_secret;
 		client_metadata.client_secret_expires_at = 0;
 
@@ -963,7 +1056,11 @@
 
 	return {
 		status_code = 201;
-		headers = { content_type = "application/json" };
+		headers = {
+			cache_control = "no-store";
+			pragma = "no-cache";
+			content_type = "application/json";
+		};
 		body = json.encode(response);
 	};
 end
@@ -1036,6 +1133,7 @@
 		-- Step 2. User-facing login and consent view
 		["GET /authorize"] = handle_authorization_request;
 		["POST /authorize"] = handle_authorization_request;
+		["OPTIONS /authorize"] = { status_code = 403; body = "" };
 
 		-- Step 3. User is redirected to the 'redirect_uri' along with an
 		-- authorization code.  In the insecure 'implicit' flow, the access token
@@ -1057,7 +1155,7 @@
 			headers = {
 				["Content-Type"] = "text/css";
 			};
-			body = _render_html(templates.css, module:get_option("oauth2_template_style"));
+			body = render_html(templates.css, module:get_option("oauth2_template_style"));
 		} or nil;
 		["GET /script.js"] = templates.js and {
 			headers = {
@@ -1087,39 +1185,53 @@
 
 -- OIDC Discovery
 
+function get_authorization_server_metadata()
+	if authorization_server_metadata then
+		return authorization_server_metadata;
+	end
+	authorization_server_metadata = {
+		-- RFC 8414: OAuth 2.0 Authorization Server Metadata
+		issuer = get_issuer();
+		authorization_endpoint = handle_authorization_request and module:http_url() .. "/authorize" or nil;
+		token_endpoint = handle_token_grant and module:http_url() .. "/token" or nil;
+		registration_endpoint = handle_register_request and module:http_url() .. "/register" or nil;
+		scopes_supported = usermanager.get_all_roles
+			and array(it.keys(usermanager.get_all_roles(module.host))):push("xmpp"):append(array(openid_claims:items()));
+		response_types_supported = array(it.keys(response_type_handlers));
+		token_endpoint_auth_methods_supported = array({ "client_secret_post"; "client_secret_basic" });
+		op_policy_uri = module:get_option_string("oauth2_policy_url", nil);
+		op_tos_uri = module:get_option_string("oauth2_terms_url", nil);
+		revocation_endpoint = handle_revocation_request and module:http_url() .. "/revoke" or nil;
+		revocation_endpoint_auth_methods_supported = array({ "client_secret_basic" });
+		code_challenge_methods_supported = array(it.keys(verifier_transforms));
+		grant_types_supported = array(it.keys(response_type_handlers)):map(tmap {
+			token = "implicit";
+			code = "authorization_code";
+		});
+		response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" });
+		authorization_response_iss_parameter_supported = true;
+		service_documentation = module:get_option_string("oauth2_service_documentation", "https://modules.prosody.im/mod_http_oauth2.html");
+		ui_locales_supported = allowed_locales[1] and allowed_locales;
+
+		-- OpenID
+		userinfo_endpoint = handle_register_request and module:http_url() .. "/userinfo" or nil;
+		jwks_uri = nil; -- REQUIRED in OpenID Discovery but not in OAuth 2.0 Metadata
+		id_token_signing_alg_values_supported = { "HS256" }; -- The algorithm RS256 MUST be included, but we use HS256 and client_secret as shared key.
+	}
+	return authorization_server_metadata;
+end
+
 module:provides("http", {
 	name = "oauth2-discovery";
 	default_path = "/.well-known/oauth-authorization-server";
 	cors = { enabled = true };
 	route = {
-		["GET"] = {
-			headers = { content_type = "application/json" };
-			body = json.encode {
-				-- RFC 8414: OAuth 2.0 Authorization Server Metadata
-				issuer = get_issuer();
-				authorization_endpoint = handle_authorization_request and module:http_url() .. "/authorize" or nil;
-				token_endpoint = handle_token_grant and module:http_url() .. "/token" or nil;
-				registration_endpoint = handle_register_request and module:http_url() .. "/register" or nil;
-				scopes_supported = usermanager.get_all_roles
-					and array(it.keys(usermanager.get_all_roles(module.host))):push("xmpp"):append(array(openid_claims:items()));
-				response_types_supported = array(it.keys(response_type_handlers));
-				token_endpoint_auth_methods_supported = array({ "client_secret_post"; "client_secret_basic" });
-				op_policy_uri = module:get_option_string("oauth2_policy_url", nil);
-				op_tos_uri = module:get_option_string("oauth2_terms_url", nil);
-				revocation_endpoint = handle_revocation_request and module:http_url() .. "/revoke" or nil;
-				revocation_endpoint_auth_methods_supported = array({ "client_secret_basic" });
-				code_challenge_methods_supported = array(it.keys(verifier_transforms));
-				grant_types_supported = array(it.keys(response_type_handlers)):map(tmap { token = "implicit"; code = "authorization_code" });
-				response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" });
-				authorization_response_iss_parameter_supported = true;
-				service_documentation = module:get_option_string("oauth2_service_documentation", "https://modules.prosody.im/mod_http_oauth2.html");
-
-				-- OpenID
-				userinfo_endpoint = handle_register_request and module:http_url() .. "/userinfo" or nil;
-				jwks_uri = nil; -- REQUIRED in OpenID Discovery but not in OAuth 2.0 Metadata
-				id_token_signing_alg_values_supported = { "HS256" }; -- The algorithm RS256 MUST be included, but we use HS256 and client_secret as shared key.
-			};
-		};
+		["GET"] = function()
+			return {
+				headers = { content_type = "application/json" };
+				body = json.encode(get_authorization_server_metadata());
+			}
+		end
 	};
 });
 
--- a/mod_invites_adhoc/mod_invites_adhoc.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_invites_adhoc/mod_invites_adhoc.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -19,7 +19,11 @@
 
 if module.may then
 	if allow_user_invites then
-		module:default_permission("prosody:user", ":invite-new-users");
+		if require "core.features".available:contains("split-user-roles") then
+			module:default_permission("prosody:registered", ":invite-new-users");
+		else -- COMPAT
+			module:default_permission("prosody:user", ":invite-new-users");
+		end
 	end
 	if not allow_user_invite_roles:empty() or not deny_user_invite_roles:empty() then
 		return error("allow_user_invites_by_roles and deny_user_invites_by_roles are deprecated options");
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_measure_lua/README.md	Sun Jul 09 01:31:29 2023 +0700
@@ -0,0 +1,19 @@
+This module provides two [metrics][doc:statistics]:
+
+`lua_heap_bytes`
+:   Bytes of memory as reported by `collectgarbage("count")`{.lua}
+
+`lua_info`
+:   Provides the current Lua version as a label
+
+``` openmetrics
+# HELP lua_info Lua runtime version
+# UNIT lua_info
+# TYPE lua_info gauge
+lua_info{version="Lua 5.4"} 1
+# HELP lua_heap_bytes Memory used by objects under control of the Lua
+garbage collector
+# UNIT lua_heap_bytes bytes
+# TYPE lua_heap_bytes gauge
+lua_heap_bytes 8613218
+```
--- a/mod_muc_defaults/README.markdown	Fri May 26 02:15:45 2023 +0700
+++ b/mod_muc_defaults/README.markdown	Sun Jul 09 01:31:29 2023 +0700
@@ -4,7 +4,7 @@
 
 ## Configuration
 
-Under your MUC component, add a `muc_defaults` option with the relevant settings.
+Under your MUC component, add a `default_mucs` option with the relevant settings.
 
 ```
 Component "conference.example.org" "muc"
@@ -12,7 +12,7 @@
             "muc_defaults";
    }
 
-   muc_defaults = {
+   default_mucs = {
       {
          jid_node = "trollbox",
          affiliations = {
--- a/mod_muc_limits/README.markdown	Fri May 26 02:15:45 2023 +0700
+++ b/mod_muc_limits/README.markdown	Sun Jul 09 01:31:29 2023 +0700
@@ -35,11 +35,13 @@
 
 You can define (globally or per-MUC component) the following options:
 
-  Name                     Default value   Description
-  ------------------------ --------------- ----------------------------------------------
-  muc\_event\_rate         0.5             The maximum number of events per second.
-  muc\_burst\_factor       6               Allow temporary bursts of this multiple.
-  muc\_max\_nick\_length   23              The maximum allowed length of user nicknames
+  Name                  Default value   Description
+  --------------------- --------------- --------------------------------------------------
+  muc_event_rate        0.5             The maximum number of events per second.
+  muc_burst_factor      6               Allow temporary bursts of this multiple.
+  muc_max_nick_length   23              The maximum allowed length of user nicknames
+  muc_max_char_count    5664            The maximum allowed number of bytes in a message
+  muc_max_line_count    23              The maximum allowed number of lines in a message
 
 For more understanding of how these values are used, see the algorithm
 section below.
--- a/mod_muc_limits/mod_muc_limits.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_muc_limits/mod_muc_limits.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -13,6 +13,9 @@
 local burst = math.max(module:get_option_number("muc_burst_factor", 6), 1);
 
 local max_nick_length = module:get_option_number("muc_max_nick_length", 23); -- Default chosen through scientific methods
+local max_line_count = module:get_option_number("muc_max_line_count", 23); -- Default chosen through s/scientific methods/copy and paste/
+local max_char_count = module:get_option_number("muc_max_char_count", 5664); -- Default chosen by multiplying a number by 23
+
 local join_only = module:get_option_boolean("muc_limit_joins_only", false);
 local dropped_count = 0;
 local dropped_jids;
@@ -46,7 +49,25 @@
 		throttle = new_throttle(period*burst, burst);
 		room.throttle = throttle;
 	end
-	if not throttle:poll(1) then
+	local cost = 1;
+	local body = stanza:get_child_text("body");
+	if body then
+		-- TODO calculate a text diagonal cross-section or some mathemagical
+		-- number, maybe some cost multipliers
+		if #body > max_char_count then
+			origin.send(st.error_reply(stanza, "modify", "policy-violation", "Your message is too long, please write a shorter one")
+				:up():tag("x", { xmlns = xmlns_muc }));
+			return true;
+		end
+		local body_lines = select(2, body:gsub("\n[^\n]*", ""));
+		if body_lines > max_line_count then
+			origin.send(st.error_reply(stanza, "modify", "policy-violation", "Your message is too long, please write a shorter one"):up()
+				:tag("x", { xmlns = xmlns_muc; }));
+			return true;
+		end
+		cost = cost + body_lines;
+	end
+	if not throttle:poll(cost) then
 		module:log("debug", "Dropping stanza for %s@%s from %s, over rate limit", dest_room, dest_host, from_jid);
 		if not dropped_jids then
 			dropped_jids = { [from_jid] = true, from_jid };
@@ -60,7 +81,6 @@
 			return true;
 		end
 		local reply = st.error_reply(stanza, "wait", "policy-violation", "The room is currently overactive, please try again later");
-		local body = stanza:get_child_text("body");
 		if body then
 			reply:up():tag("body"):text(body):up();
 		end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_muc_members_json/README.md	Sun Jul 09 01:31:29 2023 +0700
@@ -0,0 +1,81 @@
+---
+labels:
+- 'Stage-Beta'
+summary: 'Import MUC membership info from a JSON file'
+...
+
+Introduction
+============
+
+This module allows you to import MUC membership information from an external
+URL in JSON format.
+
+Details
+=======
+
+If you have an organization or community and lots of members and/or channels,
+it can be frustrating to manage MUC affiliations manually. This module will
+fetch a JSON file from a configured URL, and use that to automatically set the
+MUC affiliations.
+
+It also supports hats/badges.
+
+Configuration
+=============
+
+Add the module to the MUC host (not the global modules\_enabled):
+
+        Component "conference.example.com" "muc"
+            modules_enabled = { "muc_members_json" }
+
+You can define (globally or per-MUC component) the following options:
+
+  Name                  Description
+  --------------------- --------------------------------------------------
+  muc_members_json_url  The URL to the JSON file describing memberships
+  muc_members_json_mucs The MUCs to manage, and their associated configuration
+
+The `muc_members_json_mucs` setting determines which rooms will be managed by
+the plugin, and how to map roles to hats (if desired).
+
+```
+muc_members_json_mucs = {
+	myroom = {
+		member_hat = {
+			id = "urn:uuid:6a1b143a-1c5c-11ee-80aa-4ff1ce4867dc";
+			title = "Cool Member";
+		};
+	};
+}
+```
+
+JSON format
+===========
+
+```
+{
+  "members": [
+    {
+      "jids": ["user@example.com"]
+    },
+    {
+      "jids": ["user2@example.com"]
+    },
+    {
+      "jids": ["user3@example.com"],
+      roles: ["janitor"]
+    }
+  ]
+}
+```
+
+Each member must have a `jids` field, and optionally a `roles` field.
+
+Compatibility
+=============
+
+  ------- ------------------
+  trunk   Works
+  0.12    Works
+  ------- ------------------
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_muc_members_json/mod_muc_members_json.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -0,0 +1,93 @@
+local http = require "net.http";
+local json = require "util.json";
+
+local json_url = assert(module:get_option_string("muc_members_json_url"), "muc_members_json_url required");
+local managed_mucs = module:get_option("muc_members_json_mucs");
+
+local mod_muc = module:depends("muc");
+
+--[[
+{
+	xsf = {
+		team_hats = {
+			board = {
+				id = "xmpp:xmpp.org/hats/board";
+				title = "Board";
+			};
+		};
+		member_hat = {
+			id = "xmpp:xmpp.org/hats/member";
+			title = "XSF member";
+		};
+	};
+	iteam = {
+		team_hats = {
+			iteam = {
+				id = "xmpp:xmpp.org/hats/iteam";
+				title = "Infra team";
+			};
+		};
+	};
+}
+--]]
+
+local function get_hats(member_info, muc_config)
+	local hats = {};
+	if muc_config.member_hat then
+		hats[muc_config.member_hat.id] = {
+			title = muc_config.member_hat.title;
+			active = true;
+		};
+	end
+	if muc_config.team_hats and member_info.roles then
+		for _, role in ipairs(member_info.roles) do
+			local hat = muc_config.team_hats[role];
+			if hat then
+				hats[hat.id] = {
+					title = hat.title;
+					active = true;
+				};
+			end
+		end
+	end
+	return hats;
+end
+
+function module.load()
+	http.request(json_url)
+		:next(function (result)
+			return json.decode(result.body);
+		end)
+		:next(function (data)
+			module:log("debug", "DATA: %s", require "util.serialization".serialize(data, "debug"));
+
+			for name, muc_config in pairs(managed_mucs) do
+				local muc_jid = name.."@"..module.host;
+				local muc = mod_muc.get_room_from_jid(muc_jid);
+				module:log("warn", "%s -> %s -> %s", name, muc_jid, muc);
+				if muc then
+					local jids = {};
+					for _, member_info in ipairs(data.members) do
+						for _, member_jid in ipairs(member_info.jids) do
+							jids[member_jid] = true;
+							local affiliation = muc:get_affiliation(member_jid);
+							if not affiliation then
+								muc:set_affiliation(true, member_jid, "member", "imported membership");
+								muc:set_affiliation_data(member_jid, "source", module.name);
+							end
+							muc:set_affiliation_data(member_jid, "hats", get_hats(member_info, muc_config));
+						end
+					end
+					-- Remove affiliation from folk who weren't in the source data but previously were
+					for jid, aff, data in muc:each_affiliation() do
+						if not jids[jid] and data.source == module.name then
+							muc:set_affiliation(true, jid, "none", "imported membership lost");
+						end
+					end
+				end
+			end
+
+		end):catch(function (err)
+			module:log("error", "FAILED: %s", err);
+		end);
+end
--- a/mod_oidc_userinfo_vcard4/README.md	Fri May 26 02:15:45 2023 +0700
+++ b/mod_oidc_userinfo_vcard4/README.md	Sun Jul 09 01:31:29 2023 +0700
@@ -4,7 +4,7 @@
 - Stage-Alpha
 rockspec:
   dependencies:
-  - mod_http_oauth2
+  - mod_http_oauth2 >= 200
 ---
 
 This module extracts profile details from the user's [vcard4][XEP-0292]
--- a/mod_oidc_userinfo_vcard4/mod_oidc_userinfo_vcard4.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_oidc_userinfo_vcard4/mod_oidc_userinfo_vcard4.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -1,11 +1,14 @@
 -- Provide OpenID UserInfo data to mod_http_oauth2
 -- Alternatively, separate module for the whole HTTP endpoint?
 --
-local nodeprep = require "util.encodings".stringprep.nodeprep;
+module:add_item("openid-claim", "address");
+module:add_item("openid-claim", "email");
+module:add_item("openid-claim", "phone");
+module:add_item("openid-claim", "profile");
 
 local mod_pep = module:depends "pep";
 
-local gender_map = { M = "male"; F = "female"; O = "other"; N = "nnot applicable"; U = "unknown" }
+local gender_map = { M = "male"; F = "female"; O = "other"; N = "not applicable"; U = "unknown" }
 
 module:hook("token/userinfo", function(event)
 	local pep_service = mod_pep.get_pep_service(event.username);
--- a/mod_pubsub_alertmanager/README.md	Fri May 26 02:15:45 2023 +0700
+++ b/mod_pubsub_alertmanager/README.md	Sun Jul 09 01:31:29 2023 +0700
@@ -93,3 +93,21 @@
 
 `alertmanager_node_template`
 :   Template for the pubsub node name, defaults to `"{path?alerts}"`
+
+`alertmanager_path_configs`
+:   Per-path configuration variables (see below).
+
+### Per-path configuration
+
+It's possible to override configuration options based on the path suffix. For
+example, if a request is made to `http://prosody/pubsub_alertmanager/foo` the
+path suffix is `foo`. You can then supply the following configuration:
+
+``` lua
+alertmanager_path_configs = {
+    foo = {
+        node_template = "alerts/{alert.labels.severity}";
+        publisher = "user@example.net";
+    };
+}
+```
--- a/mod_pubsub_alertmanager/mod_pubsub_alertmanager.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_pubsub_alertmanager/mod_pubsub_alertmanager.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -29,11 +29,16 @@
 	return 202;
 end
 
-local node_template = module:get_option_string("alertmanager_node_template", "{path?alerts}");
+local global_node_template = module:get_option_string("alertmanager_node_template", "{path?alerts}");
+local path_configs = module:get_option("alertmanager_path_configs", {});
 
 function handle_POST(event, path)
 	local request = event.request;
 
+	local config = path_configs[path] or {};
+	local node_template = config.node_template or global_node_template;
+	local publisher = config.publisher or request.ip;
+
 	local payload = json.decode(event.request.body);
 	if type(payload) ~= "table" then return 400; end
 	if payload.version ~= "4" then return 501; end
@@ -55,7 +60,7 @@
 		end
 
 		local node = render(node_template, {alert = alert, path = path, payload = payload, request = request});
-		local ret = publish_payload(node, request.ip, uuid_generate(), item);
+		local ret = publish_payload(node, publisher, uuid_generate(), item);
 		if ret ~= 202 then
 			return ret
 		end
--- a/mod_pubsub_feeds/README.markdown	Fri May 26 02:15:45 2023 +0700
+++ b/mod_pubsub_feeds/README.markdown	Sun Jul 09 01:31:29 2023 +0700
@@ -35,27 +35,27 @@
 [XEP-0060](http://xmpp.org/extensions/xep-0060.html). Results are in
 [ATOM 1.0 format](http://atomenabled.org/) for easy consumption.
 
-# PubSubHubbub
+# WebSub {#pubsubhubbub}
 
-This module also implements a
-[PubSubHubbub](http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html)
-subscriber. This allows feeds that have an associated "hub" to push
-updates when they are published.
+This module also implements [WebSub](https://www.w3.org/TR/websub/),
+formerly known as
+[PubSubHubbub](http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.3.html).
+This allows "feed hubs" to instantly push feed updates to subscribers.
 
-Not all feeds support this.
-
-It needs to expose a HTTP callback endpoint to work.
+This may be removed in the future since it does not seem to be oft used
+anymore.
 
 # Option summary
 
-  Option                 Description
-  ---------------------- -------------------------------------------------------------------------
-  `feeds`                A list of virtual nodes to create and their associated Atom or RSS URL.
-  `feed_pull_interval`   Number of minutes between polling for new results (default 15)
-  `use_pubsubhubub`      Set to `false` to disable PubSubHubbub
+  Option                         Description
+  ------------------------------ --------------------------------------------------------------------------
+  `feeds`                        A list of virtual nodes to create and their associated Atom or RSS URL.
+  `feed_pull_interval_seconds`   Number of seconds between polling for new results (default 15 *minutes*)
+  `use_pubsubhubub`              Set to `true` to enable WebSub
 
 # Compatibility
 
-  ----- -------
-  0.9   Works
-  ----- -------
+  ------ -------
+  0.12    Works
+  0.11    Works
+  ------ -------
--- a/mod_pubsub_feeds/mod_pubsub_feeds.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_pubsub_feeds/mod_pubsub_feeds.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -1,17 +1,4 @@
 -- Fetches Atom feeds and publishes to PubSub nodes
---
--- Config:
--- Component "pubsub.example.com" "pubsub"
--- modules_enabled = {
---   "pubsub_feeds";
--- }
--- feeds = { -- node -> url
---   prosody_blog = "http://blog.prosody.im/feed/atom.xml";
--- }
--- feed_pull_interval = 20 -- minutes
---
--- Reference
--- http://pubsubhubbub.googlecode.com/svn/trunk/pubsubhubbub-core-0.4.html
 
 local pubsub = module:depends"pubsub";
 
@@ -36,7 +23,7 @@
 	return nil, "unsupported-format";
 end
 
-local use_pubsubhubub = module:get_option_boolean("use_pubsubhubub", true);
+local use_pubsubhubub = module:get_option_boolean("use_pubsubhubub", false);
 if use_pubsubhubub then
 	module:depends"http";
 end
@@ -46,7 +33,8 @@
 local formencode = http.formencode;
 
 local feed_list = module:shared("feed_list");
-local refresh_interval = module:get_option_number("feed_pull_interval", 15) * 60;
+local legacy_refresh_interval = module:get_option_number("feed_pull_interval", 15);
+local refresh_interval = module:get_option_number("feed_pull_interval_seconds", legacy_refresh_interval*60);
 local lease_length = tostring(math.floor(module:get_option_number("feed_lease_length", 86400)));
 
 function module.load()
@@ -60,7 +48,12 @@
 		end
 		new_feed_list[node] = true;
 		if not feed_list[node] then
-			feed_list[node] = { url = url; node = node; last_update = 0 };
+			local ok, err = pubsub.service:create(node, true);
+			if ok or err == "conflict" then
+				feed_list[node] = { url = url; node = node; last_update = 0 };
+			else
+				module:log("error", "Could not create node %s: %s", node, err);
+			end
 		else
 			feed_list[node].url = url;
 		end
@@ -75,58 +68,68 @@
 	end
 end
 
-function update_entry(item)
+function update_entry(item, data)
 	local node = item.node;
-	module:log("debug", "parsing %d bytes of data in node %s", #item.data or 0, node)
-	local feed, err = parse_feed(item.data);
+	module:log("debug", "parsing %d bytes of data in node %s", #data or 0, node)
+	local feed, err = parse_feed(data);
 	if not feed then
 		module:log("error", "Could not parse feed %q: %s", item.url, err);
-		module:log("debug", "Feed data:\n%s\n.", item.data);
+		module:log("debug", "Feed data:\n%s\n.", data);
 		return;
 	end
 	local entries = {};
 	for entry in feed:childtags("entry") do
 		table.insert(entries, entry);
 	end
-	local ok, items = pubsub.service:get_items(node, true);
+	local ok, last_id = pubsub.service:get_last_item(node, true);
 	if not ok then
-		local ok, err = pubsub.service:create(node, true);
-		if not ok then
-			module:log("error", "Could not create node %s: %s", node, err);
-			return;
+		module:log("error", "PubSub node %q missing: %s", node, last_id);
+		return
+	end
+
+	local start_from = #entries;
+	for i, entry in ipairs(entries) do
+		local id = entry:get_child_text("id");
+		if not id then
+			local link = entry:get_child("link");
+			if link then
+				module:log("debug", "Feed %q item %s is missing an id, using <link> instead", item.url, entry:top_tag());
+				id = link and link.attr.href;
+			else
+				module:log("error", "Feed %q item %s is missing both id and link, this feed is unusable", item.url, entry:top_tag());
+				return;
+			end
+			entry:text_tag("id", id);
 		end
-		items = {};
+
+		if last_id == id then
+			-- This should be the first item that we already have.
+			start_from = i-1;
+			break
+		end
 	end
-	for i = #entries, 1, -1 do -- Feeds are usually in reverse order
+
+	for i = start_from, 1, -1 do -- Feeds are usually in reverse order
 		local entry = entries[i];
 		entry.attr.xmlns = xmlns_atom;
 
-		local e_published = entry:get_child_text("published");
-		e_published = e_published and dt_parse(e_published);
-		local e_updated = entry:get_child_text("updated");
-		e_updated = e_updated and dt_parse(e_updated);
+		local id = entry:get_child_text("id");
 
-		local timestamp = e_updated or e_published or nil;
-		--module:log("debug", "timestamp is %s, item.last_update is %s", tostring(timestamp), tostring(item.last_update));
+		local timestamp = dt_parse(entry:get_child_text("published"));
+		if not timestamp then
+			timestamp = time();
+			entry:text_tag("published", dt_datetime(timestamp));
+		end
+
 		if not timestamp or not item.last_update or timestamp > item.last_update then
-			local id = entry:get_child_text("id");
-			if not id then
-				local link = entry:get_child("link");
-				id = link and link.attr.href;
-			end
-			if not id then
-				-- Sigh, no link?
-				id = feed.url .. "#" .. hmac_sha1(feed.url, tostring(entry), true) .. "@" .. dt_datetime(timestamp);
-			end
-			if not items[id] then
-				local xitem = st.stanza("item", { id = id, xmlns = "http://jabber.org/protocol/pubsub" }):add_child(entry);
-				-- TODO Put data from /feed into item/source
+			local xitem = st.stanza("item", { id = id, xmlns = "http://jabber.org/protocol/pubsub" }):add_child(entry);
+			-- TODO Put data from /feed into item/source
 
-				--module:log("debug", "publishing to %s, id %s", node, id);
-				local ok, err = pubsub.service:publish(node, true, id, xitem);
-				if not ok then
-					module:log("error", "Publishing to node %s failed: %s", node, err);
-				end
+			local ok, err = pubsub.service:publish(node, true, id, xitem);
+			if not ok then
+				module:log("error", "Publishing to node %s failed: %s", node, err);
+			elseif timestamp then
+				item.last_update = timestamp;
 			end
 		end
 	end
@@ -148,20 +151,18 @@
 end
 
 function fetch(item, callback) -- HTTP Pull
-	local headers = { };
-	if item.data and item.etag then
-		headers["If-None-Match"] = item.etag;
-	end
+	local headers = {
+		["If-None-Match"] = item.etag;
+		["Accept"] = "application/atom+xml, application/x-rss+xml, application/xml";
+	};
 	http.request(item.url, { headers = headers }, function(data, code, resp)
 		if code == 200 then
-			item.data = data;
-			if callback then callback(item) end
-			item.last_update = time();
+			if callback then callback(item, data) end
 			if resp.headers then
 				item.etag = resp.headers.etag
 			end
 		elseif code == 304 then
-			item.last_update = time();
+			module:log("debug", "No updates to %q", item.url);
 		elseif code == 301 and resp.headers.location then
 			module:log("info", "Feed %q has moved to %q", item.url, resp.headers.location);
 		elseif code <= 100 then
@@ -268,9 +269,7 @@
 				end
 				module:log("debug", "Valid signature");
 			end
-			feed.data = body;
-			update_entry(feed);
-			feed.last_update = time();
+			update_entry(feed, body);
 			return 202;
 		end
 		return 400;
--- a/mod_rest/example/prosody_oauth.py	Fri May 26 02:15:45 2023 +0700
+++ b/mod_rest/example/prosody_oauth.py	Sun Jul 09 01:31:29 2023 +0700
@@ -16,6 +16,9 @@
                 "client_name": client_name,
                 "client_uri": client_uri,
                 "redirect_uris": [redirect_uri],
+                "application_type": redirect_uri[:8] == "https://"
+                and "web"
+                or "native",
             },
         ).json()
 
--- a/mod_rest/mod_rest.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_rest/mod_rest.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -294,6 +294,7 @@
 
 local function handle_request(event, path)
 	local request, response = event.request, event.response;
+	local log = request.log or module._log;
 	local from;
 	local origin;
 	local echo = path == "echo";
@@ -308,8 +309,9 @@
 			return post_errors.new("unauthz");
 		end
 		from = jid.join(origin.username, origin.host, origin.resource);
+		origin.full_jid = from;
 		origin.type = "c2s";
-		origin.log = module._log;
+		origin.log = log;
 	end
 	local payload, err = parse_request(request, path);
 	if not payload then
@@ -352,7 +354,7 @@
 		["xml:lang"] = payload.attr["xml:lang"],
 	};
 
-	module:log("debug", "Received[rest]: %s", payload:top_tag());
+	log("debug", "Received[rest]: %s", payload:top_tag());
 	local send_type = decide_type((request.headers.accept or "") ..",".. (request.headers.content_type or ""), supported_outputs)
 
 	if echo then
@@ -395,7 +397,7 @@
 
 		local p = module:send_iq(payload, origin, iq_timeout):next(
 			function (result)
-				module:log("debug", "Sending[rest]: %s", result.stanza:top_tag());
+				log("debug", "Sending[rest]: %s", result.stanza:top_tag());
 				response.headers.content_type = send_type;
 				if responses[1] then
 					local tail = responses[#responses];
@@ -410,11 +412,11 @@
 			end,
 			function (error)
 				if not errors.is_err(error) then
-					module:log("error", "Uncaught native error: %s", error);
+					log("error", "Uncaught native error: %s", error);
 					return select(2, errors.coerce(nil, error));
 				elseif error.context and error.context.stanza then
 					response.headers.content_type = send_type;
-					module:log("debug", "Sending[rest]: %s", error.context.stanza:top_tag());
+					log("debug", "Sending[rest]: %s", error.context.stanza:top_tag());
 					return encode(send_type, error.context.stanza);
 				else
 					return error;
@@ -430,7 +432,7 @@
 		return p;
 	else
 		function origin.send(stanza)
-			module:log("debug", "Sending[rest]: %s", stanza:top_tag());
+			log("debug", "Sending[rest]: %s", stanza:top_tag());
 			response.headers.content_type = send_type;
 			response:send(encode(send_type, stanza));
 			return true;
--- a/mod_rest/res/openapi.yaml	Fri May 26 02:15:45 2023 +0700
+++ b/mod_rest/res/openapi.yaml	Sun Jul 09 01:31:29 2023 +0700
@@ -1,6 +1,5 @@
 ---
 openapi: 3.0.1
-
 info:
   title: mod_rest API
   version: 0.3.2
@@ -10,14 +9,12 @@
     and a simplified JSON mapping.
   license:
     name: MIT
-
 paths:
-
   /rest:
     post:
       summary: Send stanzas and receive responses. Webhooks work the same way.
       tags:
-      - generic
+        - generic
       security:
         - basic: []
         - token: []
@@ -25,35 +22,33 @@
       requestBody:
         $ref: '#/components/requestBodies/common'
       responses:
-        '200':
+        "200":
           $ref: '#/components/responses/success'
-        '202':
+        "202":
           $ref: '#/components/responses/sent'
-
   /rest/{kind}/{type}/{to}:
     post:
       summary: Even more RESTful mapping with certain components in the path.
       tags:
-      - generic
+        - generic
       security:
-      - basic: []
-      - token: []
-      - oauth2: []
+        - basic: []
+        - token: []
+        - oauth2: []
       parameters:
-      - $ref: '#/components/parameters/kind'
-      - $ref: '#/components/parameters/type'
-      - $ref: '#/components/parameters/to'
+        - $ref: '#/components/parameters/kind'
+        - $ref: '#/components/parameters/type'
+        - $ref: '#/components/parameters/to'
       requestBody:
         $ref: '#/components/requestBodies/common'
       responses:
-        '200':
+        "200":
           $ref: '#/components/responses/success'
-
   /rest/echo:
     post:
       summary: Build as stanza and return it for inspection.
       tags:
-      - debug
+        - debug
       security:
         - basic: []
         - token: []
@@ -61,22 +56,21 @@
       requestBody:
         $ref: '#/components/requestBodies/common'
       responses:
-        '200':
+        "200":
           $ref: '#/components/responses/success'
-
   /rest/ping/{to}:
     get:
       tags:
-      - query
+        - query
       summary: Ping a local or remote server or other entity
       security:
-      - basic: []
-      - token: []
-      - oauth2: []
+        - basic: []
+        - token: []
+        - oauth2: []
       parameters:
-      - $ref: '#/components/parameters/to'
+        - $ref: '#/components/parameters/to'
       responses:
-        '200':
+        "200":
           description: Test reachability of some address
           content:
             application/json:
@@ -85,21 +79,19 @@
             application/xmpp+xml:
               schema:
                 $ref: '#/components/schemas/iq_pong'
-
-
   /rest/version/{to}:
     get:
       tags:
-      - query
+        - query
       summary: Ask what software version is used.
       security:
-      - basic: []
-      - token: []
-      - oauth2: []
+        - basic: []
+        - token: []
+        - oauth2: []
       parameters:
-      - $ref: '#/components/parameters/to'
+        - $ref: '#/components/parameters/to'
       responses:
-        '200':
+        "200":
           description: Version query response
           content:
             application/json:
@@ -108,155 +100,146 @@
             application/xmpp+xml:
               schema:
                 $ref: '#/components/schemas/iq_result_version'
-
   /rest/disco/{to}:
     get:
       tags:
-      - query
+        - query
       summary: Query a remote entity for supported features
       security:
-      - basic: []
-      - token: []
-      - oauth2: []
+        - basic: []
+        - token: []
+        - oauth2: []
       parameters:
-      - $ref: '#/components/parameters/to'
+        - $ref: '#/components/parameters/to'
       responses:
-        '200':
+        "200":
           $ref: '#/components/responses/success'
-
   /rest/items/{to}:
     get:
       tags:
-      - query
+        - query
       summary: Query an entity for related services, chat rooms or other items
       security:
-      - basic: []
-      - token: []
-      - oauth2: []
+        - basic: []
+        - token: []
+        - oauth2: []
       parameters:
-      - $ref: '#/components/parameters/to'
+        - $ref: '#/components/parameters/to'
       responses:
-        '200':
+        "200":
           $ref: '#/components/responses/success'
-
   /rest/extdisco/{to}:
     get:
       tags:
-      - query
+        - query
       summary: Query for external services (usually STUN and TURN)
       security:
-      - basic: []
-      - token: []
-      - oauth2: []
+        - basic: []
+        - token: []
+        - oauth2: []
       parameters:
-      - $ref: '#/components/parameters/to'
-      - name: type
-        in: query
-        schema:
-          type: string
-          example: stun
+        - $ref: '#/components/parameters/to'
+        - name: type
+          in: query
+          schema:
+            type: string
+            example: stun
       responses:
-        '200':
+        "200":
           $ref: '#/components/responses/success'
-
-
   /rest/archive/{to}:
     get:
       tags:
-      - query
+        - query
       summary: Query a message archive
       security:
-      - basic: []
-      - token: []
-      - oauth2: []
+        - basic: []
+        - token: []
+        - oauth2: []
       parameters:
-      - $ref: '#/components/parameters/to'
-      - name: with
-        in: query
-        schema:
-          type: string
-      - name: start
-        in: query
-        schema:
-          type: string
-      - name: end
-        in: query
-        schema:
-          type: string
-      - name: before-id
-        in: query
-        schema:
-          type: string
-      - name: after-id
-        in: query
-        schema:
-          type: string
-      - name: ids
-        in: query
-        schema:
-          type: string
-        description: comma-separated list of archive ids
-      - name: after
-        in: query
-        schema:
-          type: string
-      - name: before
-        in: query
-        schema:
-          type: string
-      - name: max
-        in: query
-        schema:
-          type: integer
+        - $ref: '#/components/parameters/to'
+        - name: with
+          in: query
+          schema:
+            type: string
+        - name: start
+          in: query
+          schema:
+            type: string
+        - name: end
+          in: query
+          schema:
+            type: string
+        - name: before-id
+          in: query
+          schema:
+            type: string
+        - name: after-id
+          in: query
+          schema:
+            type: string
+        - name: ids
+          in: query
+          schema:
+            type: string
+          description: comma-separated list of archive ids
+        - name: after
+          in: query
+          schema:
+            type: string
+        - name: before
+          in: query
+          schema:
+            type: string
+        - name: max
+          in: query
+          schema:
+            type: integer
       responses:
-        '200':
+        "200":
           $ref: '#/components/responses/success'
-
   /rest/lastactivity/{to}:
     get:
       tags:
-      - query
+        - query
       summary: Query last activity of an entity. Sometimes used as "uptime" for servers.
       security:
-      - basic: []
-      - token: []
-      - oauth2: []
+        - basic: []
+        - token: []
+        - oauth2: []
       parameters:
-      - $ref: '#/components/parameters/to'
+        - $ref: '#/components/parameters/to'
       responses:
-        '200':
+        "200":
           $ref: '#/components/responses/success'
-
   /rest/stats/{to}:
     get:
       tags:
-      - query
+        - query
       summary: Query an entity for statistics
       security:
-      - basic: []
-      - token: []
-      - oauth2: []
+        - basic: []
+        - token: []
+        - oauth2: []
       parameters:
-      - $ref: '#/components/parameters/to'
+        - $ref: '#/components/parameters/to'
       responses:
-        '200':
+        "200":
           $ref: '#/components/responses/success'
-
   /rest/upload_request/{to}:
     get:
       tags:
-      - query
+        - query
       summary: Lorem ipsum
       security:
-      - basic: []
-      - token: []
-      - oauth2: []
+        - basic: []
+        - token: []
+        - oauth2: []
       parameters:
-      - $ref: '#/components/parameters/to'
+        - $ref: '#/components/parameters/to'
       responses:
-        '200':
+        "200":
           $ref: '#/components/responses/success'
-
 components:
   schemas:
     stanza:
@@ -271,7 +254,6 @@
         - $ref: '#/components/schemas/message'
         - $ref: '#/components/schemas/presence'
         - $ref: '#/components/schemas/iq'
-
     message:
       type: object
       xml:
@@ -281,18 +263,17 @@
           description: Which kind of stanza
           type: string
           enum:
-          - message
+            - message
         type:
           type: string
           enum:
-          - chat
-          - error
-          - groupchat
-          - headline
-          - normal
+            - chat
+            - error
+            - groupchat
+            - headline
+            - normal
           xml:
             attribute: true
-
         to:
           $ref: '#/components/schemas/to'
         from:
@@ -301,7 +282,6 @@
           $ref: '#/components/schemas/id'
         lang:
           $ref: '#/components/schemas/lang'
-
         body:
           $ref: '#/components/schemas/body'
         subject:
@@ -310,7 +290,6 @@
           $ref: '#/components/schemas/thread'
         invite:
           $ref: '#/components/schemas/invite'
-
         state:
           $ref: '#/components/schemas/state'
         nick:
@@ -319,7 +298,6 @@
           $ref: '#/components/schemas/delay'
         replace:
           $ref: '#/components/schemas/replace'
-
         html:
           $ref: '#/components/schemas/html'
         oob:
@@ -344,19 +322,14 @@
           $ref: '#/components/schemas/displayed'
         encryption:
           $ref: '#/components/schemas/encryption'
-
         archive:
           $ref: '#/components/schemas/archive_result'
-
         dataform:
           $ref: '#/components/schemas/dataform'
-
         forwarded:
           $ref: '#/components/schemas/forwarded'
-
         error:
           $ref: '#/components/schemas/error'
-
     presence:
       type: object
       properties:
@@ -364,7 +337,7 @@
           description: Which kind of stanza
           type: string
           enum:
-          - presence
+            - presence
         type:
           type: string
           enum:
@@ -385,14 +358,12 @@
           $ref: '#/components/schemas/id'
         lang:
           $ref: '#/components/schemas/lang'
-
         show:
           $ref: '#/components/schemas/show'
         status:
           $ref: '#/components/schemas/status'
         priority:
           $ref: '#/components/schemas/priority'
-
         caps:
           $ref: '#/components/schemas/caps'
         nick:
@@ -403,13 +374,10 @@
           $ref: '#/components/schemas/vcard_update'
         idle_since:
           $ref: '#/components/schemas/idle_since'
-
         muc:
           $ref: '#/components/schemas/muc'
-
         error:
           $ref: '#/components/schemas/error'
-
     iq:
       type: object
       properties:
@@ -417,14 +385,14 @@
           description: Which kind of stanza
           type: string
           enum:
-          - iq
+            - iq
         type:
           type: string
           enum:
-          - get
-          - set
-          - result
-          - error
+            - get
+            - set
+            - result
+            - error
           xml:
             attribute: true
         to:
@@ -435,7 +403,6 @@
           $ref: '#/components/schemas/id'
         lang:
           $ref: '#/components/schemas/lang'
-
         ping:
           $ref: '#/components/schemas/ping'
         version:
@@ -448,7 +415,6 @@
           $ref: '#/components/schemas/items'
         command:
           $ref: '#/components/schemas/command'
-
         stats:
           $ref: '#/components/schemas/stats'
         payload:
@@ -463,10 +429,8 @@
           $ref: '#/components/schemas/upload_request'
         upload_slot:
           $ref: '#/components/schemas/upload_slot'
-
         error:
           $ref: '#/components/schemas/error'
-
     iq_pong:
       description: Test reachability of some XMPP address
       type: object
@@ -476,10 +440,9 @@
         type:
           type: string
           enum:
-          - result
+            - result
           xml:
             attribute: true
-
     iq_result_version:
       description: Version query response
       type: object
@@ -489,60 +452,56 @@
         type:
           type: string
           enum:
-          - result
+            - result
           xml:
             attribute: true
         version:
           $ref: '#/components/schemas/version'
-
     kind:
       description: Which kind of stanza
       type: string
       enum:
-      - message
-      - presence
-      - iq
-
+        - message
+        - presence
+        - iq
     type:
       description: Stanza type
       type: string
       enum:
-      - chat
-      - normal
-      - headline
-      - groupchat
-      - get
-      - set
-      - result
-      - available
-      - unavailable
-      - subscribe
-      - subscribed
-      - unsubscribe
-      - unsubscribed
+        - chat
+        - normal
+        - headline
+        - groupchat
+        - get
+        - set
+        - result
+        - available
+        - unavailable
+        - subscribe
+        - subscribed
+        - unsubscribe
+        - unsubscribed
       xml:
         attribute: true
-
     to:
-      description: recipient
+      description: the intended recipient for the stanza
       example: alice@example.com
+      format: xmpp-jid
       type: string
       xml:
         attribute: true
-
     from:
-      description: the sender
-      example: bob@localhost.example
+      description: the sender of the stanza
+      example: bob@example.net
+      format: xmpp-jid
       type: string
       xml:
         attribute: true
-
     id:
       description: Reasonably unique id. mod_rest generates one if left out.
       type: string
       xml:
         attribute: true
-
     lang:
       description: Language code
       example: en
@@ -550,17 +509,14 @@
         prefix: xml
         attribute: true
       type: string
-
     body:
       description: Human-readable chat message
       example: Hello, World!
       type: string
-
     subject:
       description: Subject of message or group chat
       example: Talking about stuff
       type: string
-
     thread:
       description: Message thread identifier
       properties:
@@ -572,26 +528,22 @@
           type: string
           xml:
             text: true
-
     show:
       description: indicator of availability, ie away or not
       type: string
       enum:
-      - away
-      - chat
-      - dnd
-      - xa
-
+        - away
+        - chat
+        - dnd
+        - xa
     status:
       description: Textual status message.
       type: string
-
     priority:
       description: Presence priority
       type: integer
       maximum: 127
       minimum: -128
-
     state:
       description: Chat state notifications, e.g. "is typing..."
       type: string
@@ -599,30 +551,27 @@
         namespace: http://jabber.org/protocol/chatstates
         x_name_is_value: true
       enum:
-      - active
-      - inactive
-      - gone
-      - composing
-      - paused
+        - active
+        - inactive
+        - gone
+        - composing
+        - paused
       example: composing
-
     nick:
       type: string
       description: Nickname of the sender
       xml:
         name: nick
         namespace: http://jabber.org/protocol/nick
-
     delay:
       type: string
       format: date-time
-      description: Timestamp of when a stanza was delayed, in ISO 8601 / XEP-0082
-        format.
+      description: Timestamp of when a stanza was delayed, in ISO 8601 / XEP-0082 format.
+      title: 'XEP-0203: Delayed Delivery'
       xml:
         name: delay
         namespace: urn:xmpp:delay
         x_single_attribute: stamp
-
     replace:
       type: string
       description: ID of message being replaced (e.g. for corrections)
@@ -630,7 +579,6 @@
         name: replace
         namespace: urn:xmpp:message-correct:0
         x_single_attribute: id
-
     muc:
       description: Multi-User-Chat related
       type: object
@@ -661,14 +609,12 @@
               format: date-time
               xml:
                 attribute: true
-
-
     invite:
       description: Invite to a group chat
-      title: "XEP-0249: Direct MUC Invitations"
+      title: 'XEP-0249: Direct MUC Invitations'
       type: object
       required:
-      - jid
+        - jid
       xml:
         name: x
         namespace: jabber:x:conference
@@ -698,21 +644,18 @@
           description: Whether the group chat continues a one-to-one chat
           xml:
             attribute: true
-
     html:
       description: HTML version of 'body'
       example: <body><p>Hello!</p></body>
       type: string
-
     ping:
       description: A ping.
       type: boolean
       enum:
-      - true
+        - true
       xml:
         name: ping
         namespace: urn:xmpp:ping
-
     version:
       type: object
       description: Software version query
@@ -727,116 +670,111 @@
           type: string
           example: Linux
       required:
-      - name
-      - version
+        - name
+        - version
       xml:
         name: query
         namespace: jabber:iq:version
-
     disco:
       description: Discover supported features
       oneOf:
-      - description: A full response
-        type: object
-        properties:
-          features:
-            description: List of URIs indicating supported features
-            type: array
-            items:
+        - description: A full response
+          type: object
+          properties:
+            features:
+              description: List of URIs indicating supported features
+              type: array
+              items:
+                type: string
+            identities:
+              description: List of abstract identities or types that describe the entity
+              type: array
+              example:
+                - name: Prosody
+                  type: im
+                  category: server
+              items:
+                type: object
+                properties:
+                  name:
+                    type: string
+                  type:
+                    type: string
+                  category:
+                    type: string
+            node:
               type: string
-          identities:
-            description: List of abstract identities or types that describe the
-              entity
-            type: array
-            example:
-            - name: Prosody
-              type: im
-              category: server
-            items:
+            extensions:
               type: object
-              properties:
-                name:
-                  type: string
-                type:
-                  type: string
-                category:
-                  type: string
-          node:
-            type: string
-          extensions:
-            type: object
-      - description: A query with a node, or an empty response with a node
-        type: string
-      - description: Either a query, or an empty response
-        type: boolean
-
+        - description: A query with a node, or an empty response with a node
+          type: string
+        - description: Either a query, or an empty response
+          type: boolean
     items:
       description: List of references to other entities
       oneOf:
-      - description: List of items referenced
-        type: array
-        items:
-          properties:
-            jid:
-              type: string
-              description: Address of item
-            node:
-              type: string
-            name:
-              type: string
-              description: Descriptive name
-          required:
-          - jid
-          type: object
-      - type: string
-        description: A query with a node, or an empty reply list with a node
-      - description: An items query or empty list
-        type: boolean
-        enum:
-        - true
-
+        - description: List of items referenced
+          type: array
+          items:
+            properties:
+              jid:
+                type: string
+                description: Address of item
+              node:
+                type: string
+              name:
+                type: string
+                description: Descriptive name
+            required:
+              - jid
+            type: object
+        - type: string
+          description: A query with a node, or an empty reply list with a node
+        - description: An items query or empty list
+          type: boolean
+          enum:
+            - true
     command:
       description: Ad-hoc commands.
       oneOf:
-      - type: object
-        properties:
-          data:
-            $ref: '#/components/schemas/formdata'
-          action:
-            type: string
-          note:
-            type: object
-            properties:
-              text:
-                type: string
-              type:
-                type: string
-                enum:
-                - info
-                - warn
-                - error
-          form:
-            $ref: '#/components/schemas/dataform'
-          sessionid:
-            type: string
-          status:
-            type: string
-          node:
-            type: string
-          actions:
-            type: object
-            properties:
-              complete:
-                type: boolean
-              prev:
-                type: boolean
-              next:
-                type: boolean
-              execute:
-                type: string
-      - type: string
-        description: Call a command by 'node' id, without arguments
-
+        - type: object
+          properties:
+            data:
+              $ref: '#/components/schemas/formdata'
+            action:
+              type: string
+            note:
+              type: object
+              properties:
+                text:
+                  type: string
+                type:
+                  type: string
+                  enum:
+                    - info
+                    - warn
+                    - error
+            form:
+              $ref: '#/components/schemas/dataform'
+            sessionid:
+              type: string
+            status:
+              type: string
+            node:
+              type: string
+            actions:
+              type: object
+              properties:
+                complete:
+                  type: boolean
+                prev:
+                  type: boolean
+                next:
+                  type: boolean
+                execute:
+                  type: string
+        - type: string
+          description: Call a command by 'node' id, without arguments
     oob:
       type: object
       description: Reference a media file
@@ -852,7 +790,6 @@
         desc:
           description: Optional description
           type: string
-
     payload:
       title: 'XEP-0335: JSON Containers'
       description: A piece of arbitrary JSON with a type field attached
@@ -870,7 +807,6 @@
         datatype:
           example: urn:example:my-json#payload
           type: string
-
     rsm:
       title: 'XEP-0059: Result Set Management'
       xml:
@@ -892,7 +828,6 @@
           type: string
         first:
           type: string
-
     archive_query:
       title: 'XEP-0313: Message Archive Management'
       type: object
@@ -908,7 +843,6 @@
       xml:
         name: query
         namespace: urn:xmpp:mam:2
-
     archive_result:
       title: 'XEP-0313: Message Archive Management'
       xml:
@@ -922,7 +856,6 @@
             attribute: true
         forward:
           $ref: '#/components/schemas/forwarded'
-
     forwarded:
       title: 'XEP-0297: Stanza Forwarding'
       xml:
@@ -934,7 +867,6 @@
           $ref: '#/components/schemas/message'
         delay:
           $ref: '#/components/schemas/delay'
-
     dataform:
       description: Data form
       type: object
@@ -952,10 +884,10 @@
               value:
                 description: Field value
                 oneOf:
-                - type: string
-                - type: array
-                  items:
-                    type: string
+                  - type: string
+                  - type: array
+                    items:
+                      type: string
               type:
                 description: Type of form field
                 type: string
@@ -974,23 +906,21 @@
         type:
           type: string
           enum:
-          - form
-          - submit
-          - cancel
-          - result
+            - form
+            - submit
+            - cancel
+            - result
         instructions:
           type: string
-
     formdata:
       description: Simplified data form carrying only values
       type: object
       additionalProperties:
         oneOf:
-        - type: string
-        - type: array
-          items:
-            type: string
-
+          - type: string
+          - type: array
+            items:
+              type: string
     stats:
       description: Statistics
       type: array
@@ -1013,7 +943,6 @@
             type: string
             xml:
               attribute: true
-
     lastactivity:
       type: object
       xml:
@@ -1029,7 +958,6 @@
           type: string
           xml:
             text: true
-
     caps:
       type: object
       xml:
@@ -1052,7 +980,6 @@
           type: string
           xml:
             attribute: true
-
     vcard_update:
       type: object
       xml:
@@ -1062,7 +989,6 @@
         photo:
           type: string
           example: adc83b19e793491b1c6ea0fd8b46cd9f32e592fc
-
     reactions:
       type: object
       xml:
@@ -1081,35 +1007,31 @@
           xml:
             wrapped: false
             name: reactions
-
     occupant_id:
       type: string
       xml:
         namespace: urn:xmpp:occupant-id:0
         x_single_attribute: id
         name: occupant-id
-
     attach_to:
       type: string
       xml:
         namespace: urn:xmpp:message-attaching:1
         x_single_attribute: id
         name: attach-to
-
     fallback:
       type: boolean
       xml:
         namespace: urn:xmpp:fallback:0
         x_name_is_value: true
         name: fallback
-
     stanza_ids:
       type: array
       items:
         type: object
         required:
-        - id
-        - by
+          - id
+          - by
         xml:
           namespace: urn:xmpp:sid:0
           name: stanza-id
@@ -1123,7 +1045,6 @@
               attribute: true
             format: xmpp-jid
             type: string
-
     reference:
       type: object
       xml:
@@ -1149,9 +1070,8 @@
             attribute: true
           type: string
       required:
-      - type
-      - uri
-
+        - type
+        - uri
     reply:
       title: 'XEP-0461: Message Replies'
       description: Reference a message being replied to
@@ -1168,20 +1088,17 @@
           type: string
           xml:
             attribute: true
-
     markable:
       type: boolean
       xml:
         namespace: urn:xmpp:chat-markers:0
         x_name_is_value: true
-
     displayed:
       type: string
       description: Message ID of a message that has been displayed
       xml:
         namespace: urn:xmpp:chat-markers:0
         x_single_attribute: id
-
     idle_since:
       type: string
       xml:
@@ -1189,7 +1106,6 @@
         x_single_attribute: since
         name: idle
       format: date-time
-
     gateway:
       type: object
       xml:
@@ -1202,7 +1118,6 @@
           type: string
         jid:
           type: string
-
     extdisco:
       type: object
       xml:
@@ -1219,8 +1134,8 @@
             xml:
               name: service
             required:
-            - type
-            - host
+              - type
+              - host
             properties:
               transport:
                 xml:
@@ -1260,7 +1175,6 @@
                   attribute: true
                 type: string
           type: array
-
     register:
       type: object
       description: Register with a service
@@ -1313,9 +1227,8 @@
         name:
           type: string
       required:
-      - username
-      - password
-
+        - username
+        - password
     upload_slot:
       type: object
       xml:
@@ -1335,17 +1248,17 @@
               items:
                 type: object
                 required:
-                - name
-                - value
+                  - name
+                  - value
                 xml:
                   name: header
                 properties:
                   name:
                     type: string
                     enum:
-                    - Authorization
-                    - Cookie
-                    - Expires
+                      - Authorization
+                      - Cookie
+                      - Expires
                     xml:
                       attribute: true
                   value:
@@ -1363,8 +1276,8 @@
     upload_request:
       type: object
       required:
-      - filename
-      - size
+        - filename
+        - size
       xml:
         name: request
         namespace: urn:xmpp:http:upload:0
@@ -1381,7 +1294,6 @@
           type: integer
           xml:
             attribute: true
-
     encryption:
       title: 'XEP-0380: Explicit Message Encryption'
       type: string
@@ -1389,7 +1301,6 @@
         x_single_attribute: namespace
         name: encryption
         namespace: urn:xmpp:eme:0
-
     error:
       description: Description of something gone wrong. See the Stanza Errors section in RFC 6120.
       type: object
@@ -1398,22 +1309,48 @@
           description: General category of error
           type: string
           enum:
-          - auth
-          - cancel
-          - continue
-          - modify
-          - wait
+            - auth
+            - cancel
+            - continue
+            - modify
+            - wait
         condition:
           description: Specific error condition.
           type: string
-          # enum: [ full list available in RFC 6120 ]
+          enum:
+            - bad-request
+            - conflict
+            - feature-not-implemented
+            - forbidden
+            - gone
+            - internal-server-error
+            - item-not-found
+            - jid-malformed
+            - not-acceptable
+            - not-allowed
+            - not-authorized
+            - policy-violation
+            - recipient-unavailable
+            - redirect
+            - registration-required
+            - remote-server-not-found
+            - remote-server-timeout
+            - resource-constraint
+            - service-unavailable
+            - subscription-required
+            - undefined-condition
+            - unexpected-request
         code:
           description: Legacy numeric error code. Similar to HTTP status codes.
           type: integer
         text:
           description: Description of error intended for human eyes.
           type: string
-
+        by:
+          description: Originator of the error, when different from the stanza @from attribute
+          type: string
+          xml:
+            attribute: true
   securitySchemes:
     token:
       description: Tokens from mod_http_oauth2.
@@ -1435,7 +1372,6 @@
             prosody:user: Regular user privileges
             prosody:admin: Administrator privileges
             prosody:operator: Server operator privileges
-
   requestBodies:
     common:
       required: true
@@ -1449,7 +1385,6 @@
         application/x-www-form-urlencoded:
           schema:
             description: A subset of the JSON schema, only top level string fields.
-
   responses:
     success:
       description: The stanza was sent and returned a response.
@@ -1471,9 +1406,7 @@
             example: Hello
             type: string
     sent:
-      description: The stanza was sent without problem, and without response,
-        so an empty reply.
-
+      description: The stanza was sent without problem, and without response, so an empty reply.
   parameters:
     to:
       name: to
@@ -1493,5 +1426,3 @@
       required: true
       schema:
         $ref: '#/components/schemas/type'
-
-...
--- a/mod_rest/res/schema-xmpp.json	Fri May 26 02:15:45 2023 +0700
+++ b/mod_rest/res/schema-xmpp.json	Sun Jul 09 01:31:29 2023 +0700
@@ -108,6 +108,7 @@
          }
       },
       "delay" : {
+         "description" : "Timestamp of when a stanza was delayed, in ISO 8601 / XEP-0082 format.",
          "format" : "date-time",
          "title" : "XEP-0203: Delayed Delivery",
          "type" : "string",
@@ -204,7 +205,7 @@
       },
       "to" : {
          "description" : "the intended recipient for the stanza",
-         "example" : "alice@another.example",
+         "example" : "alice@example.com",
          "format" : "xmpp-jid",
          "type" : "string",
          "xml" : {
@@ -697,6 +698,12 @@
                   "forward" : {
                      "$ref" : "#/properties/message/properties/forwarded"
                   },
+                  "id" : {
+                     "type" : "string",
+                     "xml" : {
+                        "attribute" : true
+                     }
+                  },
                   "queryid" : {
                      "type" : "string",
                      "xml" : {
--- a/mod_restrict_xmpp/mod_restrict_xmpp.lua	Fri May 26 02:15:45 2023 +0700
+++ b/mod_restrict_xmpp/mod_restrict_xmpp.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -3,7 +3,18 @@
 local set = require "util.set";
 local st = require "util.stanza";
 
-module:default_permission("prosody:user", "xmpp:federate");
+local normal_user_role = "prosody:registered";
+local limited_user_role = "prosody:guest";
+
+local features = require "core.features";
+
+-- COMPAT
+if not features.available:contains("split-user-roles") then
+	normal_user_role = "prosody:user";
+	limited_user_role = "prosody:restricted";
+end
+
+module:default_permission(normal_user_role, "xmpp:federate");
 module:hook("route/remote", function (event)
 	if not module:may("xmpp:federate", event) then
 		if event.stanza.attr.type ~= "result" and event.stanza.attr.type ~= "error" then
@@ -93,12 +104,12 @@
 
 --module:default_permission("prosody:restricted", "xmpp:account:read");
 --module:default_permission("prosody:restricted", "xmpp:account:write");
-module:default_permission("prosody:restricted", "xmpp:account:messages:read");
-module:default_permission("prosody:restricted", "xmpp:account:messages:write");
+module:default_permission(limited_user_role, "xmpp:account:messages:read");
+module:default_permission(limited_user_role, "xmpp:account:messages:write");
 for _, property_list in ipairs({ iq_namespaces, legacy_storage_nodes, pep_nodes }) do
 	for account_property in set.new(array.collect(it.values(property_list))) do
-		module:default_permission("prosody:restricted", "xmpp:account:"..account_property..":read");
-		module:default_permission("prosody:restricted", "xmpp:account:"..account_property..":write");
+		module:default_permission(limited_user_role, "xmpp:account:"..account_property..":read");
+		module:default_permission(limited_user_role, "xmpp:account:"..account_property..":write");
 	end
 end
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_s2sout_override/README.md	Sun Jul 09 01:31:29 2023 +0700
@@ -0,0 +1,41 @@
+---
+summary: Override s2s connection targets
+---
+
+This module replaces [mod_s2soutinjection] and uses more modern and
+reliable methods for overriding connection targets.
+
+# Configuration
+
+Enable the module as usual, then specify a map of XMPP remote hostnames
+to URIs like `"tcp://host.example:port"`, to have Prosody connect there
+instead of doing normal DNS SRV resolution.
+
+Currently supported schemes are `tcp://` and `tls://`.  A future version
+could support more methods including alternate SRV lookup targets or
+even UNIX sockets.
+
+URIs with IP addresses like `tcp://127.0.0.1:9999` will bypass A/AAAA
+DNS lookups.
+
+```lua
+-- Global section
+modules_enabled = {
+    -- other global modules
+    "s2sout_override";
+}
+
+s2sout_override = {
+    ["example.com"] = "tcp://other.host.example:5299";
+    ["xmpp.example.net"] = "tcp://localhost:5999";
+    ["secure.example"] = = "tls://127.0.0.1:5270";
+}
+```
+
+# Compatibility
+
+Prosody version   status
+---------------   ----------
+0.12.4            Will work
+0.12.3            Will not work
+0.11              Will not work
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_s2sout_override/mod_s2sout_override.lua	Sun Jul 09 01:31:29 2023 +0700
@@ -0,0 +1,19 @@
+--% requires: s2sout-pre-connect-event
+
+local url = require"socket.url";
+local basic_resolver = require "net.resolvers.basic";
+
+local override_for = module:get_option(module.name, {}); -- map of host to "tcp://example.com:5269"
+
+module:hook("s2sout-pre-connect", function(event)
+	local override = override_for[event.session.to_host];
+	if type(override) == "string" then
+		override = url.parse(override);
+	end
+	if type(override) == "table" and override.scheme == "tcp" and type(override.host) == "string" then
+		event.resolver = basic_resolver.new(override.host, tonumber(override.port) or 5269, override.scheme, {});
+	elseif type(override) == "table" and override.scheme == "tls" and type(override.host) == "string" then
+		event.resolver = basic_resolver.new(override.host, tonumber(override.port) or 5270, "tcp",
+			{ servername = event.session.to_host; sslctx = event.session.ssl_ctx });
+	end
+end);