Changeset

5676:62c6e17a5e9d

Merge
author Stephen Paul Weber <singpolyma@singpolyma.net>
date Mon, 18 Sep 2023 08:24:19 -0500
parents 5675:eade7ff9f52c (current diff) 5671:c217f4edfc4f (diff)
children 5677:31e56562f9bd
files
diffstat 65 files changed, 2335 insertions(+), 1026 deletions(-) [+]
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.editorconfig	Mon Sep 18 08:24:19 2023 -0500
@@ -0,0 +1,34 @@
+# https://editorconfig.org/
+
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+indent_style = tab
+insert_final_newline = true
+trim_trailing_whitespace = true
+max_line_length = 150
+
+[*.json]
+# json_pp -json_opt canonical,pretty
+indent_size = 3
+indent_style = space
+
+[{README,COPYING,CONTRIBUTING,TODO}{,.markdown,.md}]
+# pandoc -s -t markdown
+indent_size = 4
+indent_style = space
+
+[*.py]
+indent_size = 4
+indent_style = space
+
+[*.{xml,svg}]
+# xmllint --nsclean --encode UTF-8 --noent --format -
+indent_size = 2
+indent_style = space
+
+[*.yaml]
+indent_size = 2
+indent_style = space
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/misc/lnav/README.md	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/misc/lnav/prosody.json	Mon Sep 18 08:24:19 2023 -0500
@@ -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" : {
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/misc/mtail/prosody.mtail	Mon Sep 18 08:24:19 2023 -0500
@@ -0,0 +1,13 @@
+counter prosody_log_messages by level
+
+/^(?P<date>(?P<legacy_date>\w+\s+\d+\s+\d+:\d+:\d+)|(?P<rfc3339_date>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d+[+-]\d{2}:\d{2})) (?P<sink>\S+)\s(?P<loglevel>\w+)\s(?P<message>.*)/ {
+	len($legacy_date) > 0 {
+		strptime($2, "Jan _2 15:04:05")
+	}
+	len($rfc3339_date) > 0 {
+		strptime($rfc3339_date, "2006-01-02T03:04:05-0700")
+	}
+	$loglevel != "" {
+		prosody_log_messages[$loglevel]++
+	}
+}
--- a/mod_auth_oauth_external/README.md	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_auth_oauth_external/README.md	Mon Sep 18 08:24:19 2023 -0500
@@ -50,6 +50,8 @@
     logging in the field specified by `oauth_external_username_field`.
     Commonly the [OpenID `UserInfo`
     endpoint](https://openid.net/specs/openid-connect-core-1_0.html#UserInfo)
+    If left unset, only `SASL PLAIN` is supported and the username
+    provided there is assumed correct.
 
 `oauth_external_username_field`
 :   String. Default is `"preferred_username"`. Field in the JSON
@@ -72,21 +74,30 @@
 :   String. Client ID used to identify Prosody during the resource owner
     password grant.
 
+`oauth_external_client_secret`
+:   String. Client secret used to identify Prosody during the resource
+    owner password grant.
+
+`oauth_external_scope`
+:   String. Defaults to `"openid"`. Included in request for resource
+    owner password grant.
+
 # Compatibility
 
 ## Prosody
 
   Version   Status
-  --------- ---------------
+  --------- -----------------------------------------------
   trunk     works
-  0.12.x    does not work
-  0.11.x    does not work
+  0.12.x    OAUTHBEARER will not work, otherwise untested
+  0.11.x    OAUTHBEARER will not work, otherwise untested
 
 ## Identity Provider
 
 Tested with
 
 -   [KeyCloak](https://www.keycloak.org/)
+-   [Mastodon](https://joinmastodon.org/)
 
 # Future work
 
--- a/mod_auth_oauth_external/mod_auth_oauth_external.lua	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_auth_oauth_external/mod_auth_oauth_external.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -1,5 +1,6 @@
 local http = require "net.http";
 local async = require "util.async";
+local jid = require "util.jid";
 local json = require "util.json";
 local sasl = require "util.sasl";
 
@@ -15,7 +16,8 @@
 -- XXX Hold up, does whatever done here even need any of these things? Are we
 -- the OAuth client? Is the XMPP client the OAuth client? What are we???
 local client_id = module:get_option_string("oauth_external_client_id");
--- TODO -- local client_secret = module:get_option_string("oauth_external_client_secret");
+local client_secret = module:get_option_string("oauth_external_client_secret");
+local scope = module:get_option_string("oauth_external_scope", "openid");
 
 --[[ More or less required endpoints
 digraph "oauth endpoints" {
@@ -28,6 +30,32 @@
 local host = module.host;
 local provider = {};
 
+local function not_implemented()
+	return nil, "method not implemented"
+end
+
+-- With proper OAuth 2, most of these should be handled at the atuhorization
+-- server, no there.
+provider.test_password = not_implemented;
+provider.get_password = not_implemented;
+provider.set_password = not_implemented;
+provider.create_user = not_implemented;
+provider.delete_user = not_implemented;
+
+function provider.user_exists(_username)
+	-- Can this even be done in a generic way in OAuth 2?
+	-- OIDC and WebFinger perhaps?
+	return true;
+end
+
+function provider.users()
+	-- TODO this could be done by recording known users locally
+	return function ()
+		module:log("debug", "User iteration not supported");
+		return nil;
+	end
+end
+
 function provider.get_sasl_handler()
 	local profile = {};
 	profile.http_client = http.default; -- TODO configurable
@@ -35,14 +63,16 @@
 	if token_endpoint and allow_plain then
 		local map_username = function (username, _realm) return username; end; --jid.join; -- TODO configurable
 		function profile:plain_test(username, password, realm)
+			username = jid.unescape(username); -- COMPAT Mastodon
 			local tok, err = async.wait_for(self.profile.http_client:request(token_endpoint, {
 				headers = { ["Content-Type"] = "application/x-www-form-urlencoded; charset=utf-8"; ["Accept"] = "application/json" };
 				body = http.formencode({
 					grant_type = "password";
 					client_id = client_id;
+					client_secret = client_secret;
 					username = map_username(username, realm);
 					password = password;
-					scope = "openid";
+					scope = scope;
 				});
 			}))
 			if err or not (tok.code >= 200 and tok.code < 300) then
@@ -52,6 +82,12 @@
 			if not token_resp or string.lower(token_resp.token_type or "") ~= "bearer" then
 				return false, nil;
 			end
+			if not validation_endpoint then
+				-- We're not going to get more info, only the username
+				self.username = jid.escape(username);
+				self.token_info = token_resp;
+				return true, true;
+			end
 			local ret, err = async.wait_for(self.profile.http_client:request(validation_endpoint,
 				{ headers = { ["Authorization"] = "Bearer " .. token_resp.access_token; ["Accept"] = "application/json" } }));
 			if err then
@@ -61,36 +97,38 @@
 				return false, nil;
 			end
 			local response = json.decode(ret.body);
-			if type(response) ~= "table" or (response[username_field]) ~= username then
+			if type(response) ~= "table" then
+				return false, nil, nil;
+			elseif type(response[username_field]) ~= "string" then
 				return false, nil, nil;
 			end
-			if response.jid then
-				self.username, self.realm, self.resource = jid.prepped_split(response.jid, true);
-			end
-			self.role = response.role;
+			self.username = jid.escape(response[username_field]);
 			self.token_info = response;
 			return true, true;
 		end
 	end
-	function profile:oauthbearer(token)
-		if token == "" then
-			return false, nil, extra;
-		end
+	if validation_endpoint then
+		function profile:oauthbearer(token)
+			if token == "" then
+				return false, nil, extra;
+			end
 
-		local ret, err = async.wait_for(self.profile.http_client:request(validation_endpoint,
-			{ headers = { ["Authorization"] = "Bearer " .. token; ["Accept"] = "application/json" } }));
-		if err then
-			return false, nil, extra;
+			local ret, err = async.wait_for(self.profile.http_client:request(validation_endpoint, {
+				headers = { ["Authorization"] = "Bearer " .. token; ["Accept"] = "application/json" };
+			}));
+			if err then
+				return false, nil, extra;
+			end
+			local response = ret and json.decode(ret.body);
+			if not (ret.code >= 200 and ret.code < 300) then
+				return false, nil, response or extra;
+			end
+			if type(response) ~= "table" or type(response[username_field]) ~= "string" then
+				return false, nil, nil;
+			end
+
+			return jid.escape(response[username_field]), true, response;
 		end
-		local response = ret and json.decode(ret.body);
-		if not (ret.code >= 200 and ret.code < 300) then
-			return false, nil, response or extra;
-		end
-		if type(response) ~= "table" or type(response[username_field]) ~= "string" then
-			return false, nil, nil;
-		end
-
-		return response[username_field], true, response;
 	end
 	return sasl.new(host, profile);
 end
--- a/mod_bidi/README.markdown	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_bidi/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -1,11 +1,15 @@
 ---
 labels:
-- 'Stage-Stable'
-summary: 'XEP-0288: Bidirectional Server-to-Server Connections'
-...
+- Stage-Stable
+summary: "XEP-0288: Bidirectional Server-to-Server Connections"
+---
 
-Introduction
-============
+::: {.alert .alert-warning}
+This module is unreliable when used with Prosody 0.12, switch to
+[mod_s2s_bidi][doc:modules:mod_s2s_bidi]
+:::
+
+# Introduction
 
 This module implements [XEP-0288: Bidirectional Server-to-Server
 Connections](http://xmpp.org/extensions/xep-0288.html). It allows
@@ -14,13 +18,9 @@
 
 Install and enable it like any other module. It has no configuration.
 
-Compatibility
-=============
+# Compatibility
 
-  ------- --------------------------
-  trunk   Bidi available natively with [mod_s2s_bidi][doc:modules:mod_s2s_bidi]
-  0.11    Works
-  0.10    Works
-  0.9     Works
-  0.8     Works (use the 0.8 repo)
-  ------- --------------------------
+  ------ -------------------------------------------
+  0.12   Bidi available natively with [mod_s2s_bidi][doc:modules:mod_s2s_bidi]
+  0.11   Works
+  ------ -------------------------------------------
--- a/mod_client_management/README.md	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_client_management/README.md	Mon Sep 18 08:24:19 2023 -0500
@@ -35,6 +35,12 @@
 prosodyctl shell user clients user@example.com
 ```
 
+To revoke access from particular client:
+
+```shell
+prosodyctl shell user revoke_client user@example.com grant/xxxxx
+```
+
 ## Compatibility
 
 Requires Prosody trunk (as of 2023-03-29). Not compatible with Prosody 0.12
--- a/mod_client_management/mod_client_management.lua	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_client_management/mod_client_management.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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;
 	};
@@ -250,6 +252,7 @@
 				type = "access";
 				first_seen = grant.created;
 				last_seen = grant.accessed;
+				expires = grant.expires;
 				active = {
 					grant = grant;
 				};
@@ -276,6 +279,17 @@
 	return active_clients;
 end
 
+local function user_agent_tostring(user_agent)
+	if user_agent then
+		if user_agent.software then
+			if user_agent.software_version then
+				return user_agent.software .. "/" .. user_agent.software_version;
+			end
+			return user_agent.software;
+		end
+	end
+end
+
 function revoke_client_access(username, client_selector)
 	if client_selector then
 		local c_type, c_id = client_selector:match("^(%w+)/(.+)$");
@@ -309,6 +323,13 @@
 			local ok = tokenauth.revoke_grant(username, c_id);
 			if not ok then return nil, "internal-server-error"; end
 			return true;
+		elseif c_type == "software" then
+			local active_clients = get_active_clients(username);
+			for _, client in ipairs(active_clients) do
+				if client.user_agent and client.user_agent.software == c_id or user_agent_tostring(client.user_agent) == c_id then
+					return revoke_client_access(username, client.id);
+				end
+			end
 		end
 	end
 
@@ -348,7 +369,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);
@@ -417,23 +438,40 @@
 			return true, "No clients associated with this account";
 		end
 
+		local function date_or_time(last_seen)
+			return last_seen and os.date(math.abs(os.difftime(os.time(), last_seen)) >= 86400 and "%Y-%m-%d" or "%H:%M:%S", last_seen);
+		end
+
+		local date_or_time_width = math.max(#os.date("%Y-%m-%d"), #os.date("%H:%M:%S"));
+
 		local colspec = {
+			{ title = "ID"; key = "id"; width = "1p" };
 			{
 				title = "Software";
 				key = "user_agent";
 				width = "1p";
-				mapper = function(user_agent)
-					return user_agent and user_agent.software;
-				end;
+				mapper = user_agent_tostring;
+			};
+			{
+				title = "First seen";
+				key = "first_seen";
+				width = date_or_time_width;
+				align = "right";
+				mapper = date_or_time;
 			};
 			{
 				title = "Last seen";
 				key = "last_seen";
-				width = math.max(#os.date("%Y-%m-%d"), #os.date("%H:%M:%S"));
+				width = date_or_time_width;
 				align = "right";
-				mapper = function(last_seen)
-					return os.date(os.difftime(os.time(), last_seen) >= 86400 and "%Y-%m-%d" or "%H:%M:%S", last_seen);
-				end;
+				mapper = date_or_time;
+			};
+			{
+				title = "Expires";
+				key = "expires";
+				width = date_or_time_width;
+				align = "right";
+				mapper = date_or_time;
 			};
 			{
 				title = "Authentication";
@@ -456,4 +494,18 @@
 		print(string.rep("-", self.session.width));
 		return true, ("%d clients"):format(#clients);
 	end
+
+	function console_env.user:revoke_client(user_jid, selector) -- luacheck: ignore 212/self
+		local username, host = jid.split(user_jid);
+		local mod = prosody.hosts[host] and prosody.hosts[host].modules.client_management;
+		if not mod then
+			return false, ("Host does not exist on this server, or does not have mod_client_management loaded");
+		end
+
+		local revoked, err = revocation_errors.coerce(mod.revoke_client_access(username, selector));
+		if not revoked then
+			return false, err.text or err;
+		end
+		return true, "Client access revoked";
+	end
 end);
--- a/mod_cloud_notify_extensions/README.markdown	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_cloud_notify_extensions/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_compat_roles/mod_compat_roles.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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_default_bookmarks/README.markdown	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_default_bookmarks/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -31,13 +31,15 @@
 
 Then add a list of the default rooms you want:
 
-    default_bookmarks = {
-        { jid = "room@conference.example.com", name = "The Room" };
-        -- Specifying a password is supported:
-        { jid = "secret-room@conference.example.com", name = "A Secret Room", password = "secret" };
-        -- You can also use this compact syntax:
-        "yetanother@conference.example.com"; -- this will get "yetanother" as name
-    };
+``` lua
+default_bookmarks = {
+    { jid = "room@conference.example.com"; name = "The Room"; autojoin = true };
+    -- Specifying a password is supported:
+    { jid = "secret-room@conference.example.com"; name = "A Secret Room"; password = "secret"; autojoin = true };
+    -- You can also use this compact syntax:
+    "yetanother@conference.example.com"; -- this will get "yetanother" as name
+};
+```
 
 Compatibility
 -------------
--- a/mod_firewall/README.markdown	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_firewall/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_firewall/actions.lib.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_firewall/conditions.lib.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_firewall/marks.lib.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_firewall/mod_firewall.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_firewall/scripts/spam-blocking.pfw	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_firewall/scripts/spam-blocklists.pfw	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_http_debug/mod_http_debug.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_http_dir_listing/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_http_dir_listing2/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -1,6 +1,10 @@
 ---
 summary: HTTP directory listing
-...
+rockspec:
+  build:
+    copy_directories:
+      - resources
+---
 
 Introduction
 ============
--- a/mod_http_muc_log/mod_http_muc_log.lua	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_http_muc_log/mod_http_muc_log.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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 = {
@@ -268,10 +294,16 @@
 local function logs_page(event, path)
 	local request, response = event.request, event.response;
 
-	local room, date = path:match("^([^/]+)/([^/]*)/?$");
-	if not room then
+	-- /room --> 303 /room/
+	-- /room/ --> calendar view
+	-- /room/yyyy-mm-dd --> logs view
+	-- /room/yyyy-mm-dd/* --> 404
+	local room, date = path:match("^([^/]+)/([^/]*)$");
+	if not room and not path:find"/" then
 		response.headers.location = url.build({ path = path .. "/" });
 		return 303;
+	elseif not room then
+		return 404;
 	end
 	room = nodeprep(room);
 	if not room then
@@ -300,7 +332,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 +507,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 +557,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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_http_muc_log/res/http_muc_log.html	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_http_oauth2/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -1,26 +1,27 @@
 ---
 labels:
 - Stage-Alpha
-summary: 'OAuth2 API'
 rockspec:
   build:
     copy_directories:
     - html
-...
+summary: OAuth 2.0 Authorization Server API
+---
 
 ## Introduction
 
 This module implements an [OAuth2](https://oauth.net/2/)/[OpenID Connect
-(OIDC)](https://openid.net/connect/) provider HTTP frontend on top of
+(OIDC)](https://openid.net/connect/) Authorization Server on top of
 Prosody's usual internal authentication backend.
 
 OAuth and OIDC are web standards that allow you to provide clients and
 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
 
@@ -46,11 +48,14 @@
 
 - [RFC 6749: The OAuth 2.0 Authorization Framework](https://www.rfc-editor.org/rfc/rfc6749)
 - [RFC 7009: OAuth 2.0 Token Revocation](https://www.rfc-editor.org/rfc/rfc7009)
+- [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 8628: OAuth 2.0 Device Authorization Grant](https://www.rfc-editor.org/rfc/rfc8628)
+- [RFC 9207: OAuth 2.0 Authorization Server Issuer Identification](https://www.rfc-editor.org/rfc/rfc9207.html)
 - [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
-- [OpenID Connect Dynamic Client Registration 1.0](https://openid.net/specs/openid-connect-registration-1_0.html) & [RFC 7591: OAuth 2.0 Dynamic Client Registration](https://www.rfc-editor.org/rfc/rfc7591.html)
-- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-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)
 
 ## Configuration
 
@@ -60,7 +65,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
@@ -73,13 +78,11 @@
 oauth2_template_path = "/etc/prosody/custom-oauth2-templates"
 ```
 
-Some templates support additional variables, that can be provided by the
-'oauth2_template_style' option:
+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_template_style = {
-  background_colour = "#ffffff";
-}
+oauth2_security_policy = "default-src 'self'" -- this is the default
 ```
 
 ### Token parameters
@@ -88,8 +91,8 @@
 The defaults are recommended.
 
 ```lua
-oauth2_access_token_ttl = 86400 -- 24 hours
-oauth2_refresh_token_ttl = nil -- unlimited unless revoked by the user
+oauth2_access_token_ttl = 3600 -- one hour
+oauth2_refresh_token_ttl = 604800 -- one week
 ```
 
 ### Dynamic client registration
@@ -106,14 +109,110 @@
 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`
+    :   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
+-   Device Authorization Grant
+-   Resource owner password grant *(likely to be phased out in the future)*
+-   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
+	"device_code";
 	"password"; -- resource owner password grant
 }
 
@@ -123,16 +222,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";
@@ -147,6 +247,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
@@ -156,7 +257,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.
 
@@ -164,12 +265,22 @@
 
 OAuth supports "scopes" as a way to grant clients limited access.
 
-There are currently no standard scopes defined for XMPP. This is something
-that we intend to change, e.g. by definitions provided in a future XEP. This
-means that clients you authorize currently have unrestricted access to your
-account (including the ability to change your password and lock you out!). So,
-for now, while using OAuth clients can prevent leaking your password to them,
-it is not currently suitable for connecting untrusted clients to your account.
+There are currently no standard scopes defined for XMPP. This is
+something that we intend to change, e.g. by definitions provided in a
+future XEP. This means that clients you authorize currently have to
+choose between unrestricted access to your account (including the
+ability to change your password and lock you out!) and zero access. So,
+for now, while using OAuth clients can prevent leaking your password to
+them, it is not currently suitable for connecting untrusted clients to
+your account.
+
+As a first step, the `xmpp` scope is supported, and corresponds to
+whatever permissions the user would have when logged in over XMPP.
+
+Further, known Prosody roles can be used as scopes.
+
+OpenID scopes such as `openid` and `profile` can be used for "Login
+with XMPP" without granting access to more than limited profile details.
 
 ## Compatibility
 
--- a/mod_http_oauth2/html/consent.html	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_http_oauth2/html/consent.html	Mon Sep 18 08:24:19 2023 -0500
@@ -1,21 +1,25 @@
 <!DOCTYPE html>
-<html>
+<html lang="en">
 <head>
-<meta charset="utf-8">
+<meta charset="utf-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1" />
 <title>{site_name} - Authorize {client.client_name}</title>
-<link rel="stylesheet" href="style.css">
+<link rel="stylesheet" href="style.css" />
 </head>
 <body>
-	<main>
-	{state.error&<div class="error">
+{state.error&
+	<dialog open="" class="error">
 		<p>{state.error}</p>
-	</div>}
-
+		<form method="dialog"><button>dismiss</button></form>
+	</dialog>}
+	<header>
 	<h1>{site_name}</h1>
+	</header>
+	<main>
 	<fieldset>
 	<legend>Authorize new application</legend>
 	<p>A new application wants to connect to your account.</p>
+	<form method="post">
 	<dl>
 		<dt>Name</dt>
 		<dd>{client.client_name}</dd>
@@ -29,23 +33,21 @@
 		{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
-	   <em>{state.user.username}@{state.user.host}</em> and associated data,
-	   select 'Allow'. Otherwise, select 'Deny'.
+	<em>{state.user.username}@{state.user.host}</em> and associated data,
+	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>}{roles&
-			<select name="role">{roles#
-				<option value="{item.name}"{item.selected& selected}>{item.name}</option>}
-			</select>}
-		</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>
+	<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>
 	</form>
 	</fieldset>
 	</main>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/device.html	Mon Sep 18 08:24:19 2023 -0500
@@ -0,0 +1,33 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1" />
+<title>{site_name} - Authorize{client&d} Device</title>
+<link rel="stylesheet" href="style.css" />
+</head>
+<body>
+{error&
+	<dialog open="" class="error">
+		<p>{error.text}</p>
+		<form method="dialog"><button>dismiss</button></form>
+	</dialog>}
+	<header>
+	<h1>{site_name}</h1>
+	</header>
+	<main>
+	<fieldset>
+	<legend>Device Authorization</legend>
+{client&
+	<p>Authorization completed. You can go back to
+	<em>{client.client_name}</em>.</p>}
+{client~
+	<p>Enter the code to continue.</p>
+	<form method="get">
+		<input type="text" name="user_code" placeholder="XXXX-XXXX" aria-label="Code" required="" />
+		<button type="submit">Continue</button>
+	</form>}
+	</fieldset>
+	</main>
+</body>
+</html>
--- a/mod_http_oauth2/html/error.html	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_http_oauth2/html/error.html	Mon Sep 18 08:24:19 2023 -0500
@@ -1,14 +1,16 @@
 <!DOCTYPE html>
-<html>
+<html lang="en">
 <head>
-<meta charset="utf-8">
+<meta charset="utf-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1" />
 <title>{site_name} - Error</title>
-<link rel="stylesheet" href="style.css">
+<link rel="stylesheet" href="style.css" />
 </head>
 <body>
+	<header>
+	<h1>{site_name}</h1>
+	</header>
 	<main>
-	<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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_http_oauth2/html/login.html	Mon Sep 18 08:24:19 2023 -0500
@@ -1,24 +1,30 @@
 <!DOCTYPE html>
-<html>
+<html lang="en">
 <head>
-<meta charset="utf-8">
+<meta charset="utf-8" />
 <meta name="viewport" content="width=device-width, initial-scale=1" />
 <title>{site_name} - Sign in</title>
-<link rel="stylesheet" href="style.css">
+<link rel="stylesheet" href="style.css" />
 </head>
 <body>
+{state.error&
+	<dialog open="" class="error">
+		<p>{state.error}</p>
+		<form method="dialog"><button>dismiss</button></form>
+	</dialog>}
+	<header>
+	<h1>{site_name}</h1>
+	</header>
 	<main>
-	<h1>{site_name}</h1>
 	<fieldset>
 	<legend>Sign in</legend>
 	<p>Sign in to your account to continue.</p>
-	{state.error&<div class="error">
-		<p>{state.error}</p>
-	</div>}
 	<form method="post">
-		<input type="text" name="username" placeholder="Username" aria-label="Username" required autofocus><br/>
-		<input type="password" name="password" placeholder="Password" aria-label="Password" autocomplete="current-password" required><br/>
-		<input type="submit" value="Sign in">
+		<input type="text" name="username" placeholder="Username" aria-label="Username"
+			autocomplete="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>
 	</fieldset>
 	</main>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_http_oauth2/html/oob.html	Mon Sep 18 08:24:19 2023 -0500
@@ -0,0 +1,21 @@
+<!DOCTYPE html>
+<html lang="en">
+<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>
+	<header>
+	<h1>{site_name}</h1>
+	</header>
+	<main>
+	<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}" aria-label="Authorization Code"></p>
+	</div>
+	</main>
+</body>
+</html>
--- a/mod_http_oauth2/html/style.css	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_http_oauth2/html/style.css	Mon Sep 18 08:24:19 2023 -0500
@@ -1,6 +1,5 @@
 body
 {
-	margin-top:14%;
 	text-align:center;
 	background-color:#f8f8f8;
 	font-family:sans-serif
@@ -21,12 +20,28 @@
 
 .error
 {
-	margin: 0.75em;
+	margin: 0.75em auto;
 	background-color: #f8d7da;
 	color: #842029;
 	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;
@@ -37,7 +52,7 @@
 	text-align: left;
 }
 
-main {
+header, main, footer {
 	max-width: 600px;
 	padding: 0 1.5em 1.5em 1.5em;
 }
@@ -71,6 +86,10 @@
 		color: #f8d7da;
 		background-color: #842029;
 	}
+	.oob {
+		color: #d7daf8;
+		background-color: #202984;
+	}
 
 
 	:link
@@ -86,7 +105,10 @@
 
 @media(min-width: 768px)
 {
-	main
+	body {
+		margin-top:14vh;
+	}
+	header, main, footer
 	{
 		margin-left: auto;
 		margin-right: auto;
--- a/mod_http_oauth2/mod_http_oauth2.lua	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_http_oauth2/mod_http_oauth2.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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,15 @@
 	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);
+	device = read_file(template_path, "device.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,27 +76,33 @@
 
 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;
 	local resp = {
-		status_code = 200;
+		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);
-local default_refresh_ttl = module:get_option_number("oauth2_refresh_token_ttl", nil);
+local default_access_ttl = module:get_option_number("oauth2_access_token_ttl", 3600);
+local default_refresh_ttl = module:get_option_number("oauth2_refresh_token_ttl", 604800);
 
 -- Used to derive client_secret from client_id, set to enable stateless dynamic registration.
 local registration_key = module:get_option_string("oauth2_registration_key");
@@ -84,26 +114,60 @@
 local pkce_required = module:get_option_boolean("oauth2_require_code_challenge", false);
 
 local verification_key;
-local jwt_sign, jwt_verify;
+local sign_client, verify_client;
 if registration_key then
 	-- Tie it to the host if global
 	verification_key = hashes.hmac_sha256(registration_key, module.host);
-	jwt_sign, jwt_verify = jwt.init(registration_algo, registration_key, registration_key, registration_options);
+	sign_client, verify_client = jwt.init(registration_algo, registration_key, registration_key, registration_options);
 end
 
+local new_device_token, verify_device_token = jwt.init("HS256", random.bytes(32), nil, { default_ttl = 600 });
+
+-- 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
+-- (all ( client ( grant ( token ) ) ))
+-- preserve order since it determines role if more than one granted
+
+-- string -> array
 local function parse_scopes(scope_string)
 	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)
 	local claims, roles, unknown = array(), array(), array();
 	local all_roles = usermanager.get_all_roles(module.host);
 	for _, scope in ipairs(scope_list) do
 		if openid_claims:contains(scope) then
 			claims:push(scope);
-		elseif all_roles[scope] then
+		elseif scope == "xmpp" or all_roles[scope] then
 			roles:push(scope);
 		else
 			unknown:push(scope);
@@ -113,32 +177,29 @@
 end
 
 local function can_assume_role(username, requested_role)
-	return usermanager.user_can_assume_role(username, module.host, requested_role);
+	return requested_role == "xmpp" or usermanager.user_can_assume_role(username, module.host, requested_role);
+end
+
+-- function (string) : function(string) : boolean
+local function role_assumable_by(username)
+	return function(role)
+		return can_assume_role(username, role);
+	end
 end
 
-local function select_role(username, requested_roles)
-	if requested_roles then
-		for _, requested_role in ipairs(requested_roles) do
-			if can_assume_role(username, requested_role) then
-				return requested_role;
-			end
-		end
-	end
-	-- otherwise the default role
-	return usermanager.get_user_role(username, module.host).name;
+-- string, array --> array
+local function user_assumable_roles(username, requested_roles)
+	return array.filter(requested_roles, role_assumable_by(username));
 end
 
+-- string, string|nil --> string, string
 local function filter_scopes(username, requested_scope_string)
-	local granted_scopes, requested_roles;
+	local requested_scopes, requested_roles = split_scopes(parse_scopes(requested_scope_string or ""));
 
-	if requested_scope_string then -- Specific role(s) requested
-		granted_scopes, requested_roles = split_scopes(parse_scopes(requested_scope_string));
-	else
-		granted_scopes = array();
-	end
+	local granted_roles = user_assumable_roles(username, requested_roles);
+	local granted_scopes = requested_scopes + granted_roles;
 
-	local selected_role = select_role(username, requested_roles);
-	granted_scopes:push(selected_role);
+	local selected_role = granted_roles[1];
 
 	return granted_scopes:concat(" "), selected_role;
 end
@@ -155,9 +216,8 @@
 	return code_expired(code)
 end);
 
--- Periodically clear out unredeemed codes.  Does not need to be exact, expired
--- codes are rejected if tried. Mostly just to keep memory usage in check.
-module:hourly("Clear expired authorization codes", function()
+-- Clear out unredeemed codes so they don't linger in memory.
+module:daily("Clear expired authorization codes", function()
 	local k, code = codes:tail();
 	while code and code_expired(code) do
 		codes:set(k, nil);
@@ -169,11 +229,13 @@
 	return (module:http_url(nil, "/"):gsub("/$", ""));
 end
 
+-- Non-standard special redirect URI that has the AS show the authorization
+-- code to the user for them to copy-paste into the client, which can then
+-- continue as if it received it via redirect.
+local oob_uri = "urn:ietf:wg:oauth:2.0:oob";
+local device_uri = "urn:ietf:params:oauth:grant-type:device_code";
+
 local loopbacks = set.new({ "localhost", "127.0.0.1", "::1" });
-local function is_secure_redirect(uri)
-	local u = url.parse(uri);
-	return u.scheme ~= "http" or loopbacks:contains(u.host);
-end
 
 local function oauth_error(err_name, err_desc)
 	return errors.new({
@@ -189,7 +251,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)
@@ -201,21 +269,30 @@
 		token_data = nil;
 	end
 
-	local refresh_token;
 	local grant = refresh_token_info and refresh_token_info.grant;
 	if not grant then
 		-- No existing grant, create one
-		grant = tokens.create_grant(token_jid, token_jid, default_refresh_ttl, token_data);
-		-- Create refresh token for the grant if desired
-		refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant, nil, nil, "oauth2-refresh");
-	else
-		-- Grant exists, reuse existing refresh token
-		refresh_token = refresh_token_info.token;
-
-		refresh_token_info.grant = nil; -- Prevent reference loop
+		grant = tokens.create_grant(token_jid, token_jid, nil, token_data);
 	end
 
-	local access_token, access_token_info = tokens.create_token(token_jid, grant, role, default_access_ttl, "oauth2");
+	if refresh_token_info then
+		-- out with the old refresh tokens
+		local ok, err = tokens.revoke_token(refresh_token_info.token);
+		if not ok then
+			module:log("error", "Could not revoke refresh token: %s", err);
+			return 500;
+		end
+	end
+	-- in with the new refresh token
+	local refresh_token = refresh_token_info ~= false and tokens.create_token(token_jid, grant.id, nil, default_refresh_ttl, "oauth2-refresh");
+
+	if role == "xmpp" then
+		-- Special scope meaning the users default role.
+		local user_default_role = usermanager.get_user_role(jid.node(token_jid), module.host);
+		role = user_default_role and user_default_role.name;
+	end
+
+	local access_token, access_token_info = tokens.create_token(token_jid, grant.id, role, default_access_ttl, "oauth2");
 
 	local expires_at = access_token_info.expires;
 	return {
@@ -228,6 +305,17 @@
 	};
 end
 
+local function normalize_loopback(uri)
+	local u = url.parse(uri);
+	if u.scheme == "http" and loopbacks:contains(u.host) then
+		u.authority = nil;
+		u.host = "::1";
+		u.port = nil;
+		return url.build(u);
+	end
+	-- else, not a valid loopback uri
+end
+
 local function get_redirect_uri(client, query_redirect_uri) -- record client, string : string
 	if not query_redirect_uri then
 		if #client.redirect_uris ~= 1 then
@@ -237,18 +325,47 @@
 		-- When only a single URI is registered, that's the default
 		return client.redirect_uris[1];
 	end
+	if query_redirect_uri == device_uri and client.grant_types then
+		for _, grant_type in ipairs(client.grant_types) do
+			if grant_type == device_uri then
+				return query_redirect_uri;
+			end
+		end
+		-- Tried to use device authorization flow without registering it.
+		return;
+	end
 	-- Verify the client-provided URI matches one previously registered
 	for _, redirect_uri in ipairs(client.redirect_uris) do
 		if query_redirect_uri == redirect_uri then
 			return redirect_uri
 		end
 	end
+	-- The authorization server MUST allow any port to be specified at the time
+	-- of the request for loopback IP redirect URIs, to accommodate clients that
+	-- obtain an available ephemeral port from the operating system at the time
+	-- of the request.
+	-- https://www.ietf.org/archive/id/draft-ietf-oauth-v2-1-08.html#section-8.4.2
+	local loopback_redirect_uri = normalize_loopback(query_redirect_uri);
+	if loopback_redirect_uri then
+		for _, redirect_uri in ipairs(client.redirect_uris) do
+			if loopback_redirect_uri == normalize_loopback(redirect_uri) then
+				return query_redirect_uri;
+			end
+		end
+	end
 end
 
 local grant_type_handlers = {};
 local response_type_handlers = {};
 local verifier_transforms = {};
 
+function grant_type_handlers.implicit()
+	-- Placeholder to make discovery work correctly.
+	-- Access tokens are delivered via redirect when using the implict flow, not
+	-- via the token endpoint, so how did you get here?
+	return oauth_error("invalid_request");
+end
+
 function grant_type_handlers.password(params)
 	local request_jid = assert(params.username, oauth_error("invalid_request", "missing 'username' (JID)"));
 	local request_password = assert(params.password, oauth_error("invalid_request", "missing 'password'"));
@@ -277,8 +394,19 @@
 		return oauth_error("invalid_request", "PKCE required");
 	end
 
+	local prefix = "authorization_code:";
 	local code = id.medium();
-	local ok = codes:set(params.client_id .. "#" .. code, {
+	if params.redirect_uri == device_uri then
+		local is_device, device_state = verify_device_token(params.state);
+		if is_device then
+			-- reconstruct the device_code
+			prefix = "device_code:";
+			code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code));
+		else
+			return oauth_error("invalid_request");
+		end
+	end
+	local ok = codes:set(prefix.. params.client_id .. "#" .. code, {
 		expires = os.time() + 600;
 		granted_jid = granted_jid;
 		granted_scopes = granted_scopes;
@@ -288,29 +416,21 @@
 		id_token = id_token;
 	});
 	if not ok then
-		return {status_code = 429};
+		return oauth_error("temporarily_unavailable");
 	end
 
 	local redirect_uri = get_redirect_uri(client, params.redirect_uri);
-	if redirect_uri == "urn:ietf:wg:oauth:2.0:oob" 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;
+	if redirect_uri == oob_uri then
+		return render_page(templates.oob, { client = client; authorization_code = code }, true);
+	elseif redirect_uri == device_uri then
+		return render_page(templates.device, { client = client }, true);
 	elseif not redirect_uri then
-		return 400;
+		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() });
@@ -322,6 +442,8 @@
 	return {
 		status_code = 303;
 		headers = {
+			cache_control = "no-store";
+			pragma = "no-cache";
 			location = url.build(redirect);
 		};
 	}
@@ -337,13 +459,15 @@
 	local token_info = new_access_token(granted_jid, granted_role, granted_scopes, client, nil);
 
 	local redirect = url.parse(get_redirect_uri(client, params.redirect_uri));
-	if not redirect then return 400; end
+	if not redirect then return oauth_error("invalid_redirect_uri"); end
 	token_info.state = params.state;
 	redirect.fragment = http.formencode(token_info);
 
 	return {
 		status_code = 303;
 		headers = {
+			cache_control = "no-store";
+			pragma = "no-cache";
 			location = url.build(redirect);
 		};
 	}
@@ -362,11 +486,12 @@
 	if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end
 	if not params.code then return oauth_error("invalid_request", "missing 'code'"); end
 	if params.scope and params.scope ~= "" then
+		-- FIXME allow a subset of granted scopes
 		return oauth_error("invalid_scope", "unknown scope requested");
 	end
 
-	local client_ok, client = jwt_verify(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
 
@@ -374,11 +499,12 @@
 		module:log("debug", "client_secret mismatch");
 		return oauth_error("invalid_client", "incorrect credentials");
 	end
-	local code, err = codes:get(params.client_id .. "#" .. params.code);
+	local code, err = codes:get("authorization_code:" .. params.client_id .. "#" .. params.code);
 	if err then error(err); end
 	-- MUST NOT use the authorization code more than once, so remove it to
 	-- prevent a second attempted use
-	codes:set(params.client_id .. "#" .. params.code, nil);
+	-- TODO if a second attempt *is* made, revoke any tokens issued
+	codes:set("authorization_code:" .. 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);
 		return oauth_error("invalid_client", "incorrect credentials");
@@ -400,8 +526,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 = jwt_verify(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
 
@@ -415,12 +541,58 @@
 		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
+		local granted_scopes = set.new(parse_scopes(refresh_scopes));
+		local requested_scopes = parse_scopes(params.scope);
+		refresh_scopes = array.filter(requested_scopes, function(scope)
+			return granted_scopes:contains(scope);
+		end):concat(" ");
+	end
+
+	local username = jid.split(refresh_token_info.jid);
+	local new_scopes, role = filter_scopes(username, refresh_scopes);
+
 	-- new_access_token() requires the actual token
 	refresh_token_info.token = params.refresh_token;
 
-	return json.encode(new_access_token(
-		refresh_token_info.jid, refresh_token_info.role, refresh_token_info.grant.data.oauth2_scopes, client, nil, refresh_token_info
-	));
+	return json.encode(new_access_token(refresh_token_info.jid, role, new_scopes, client, nil, refresh_token_info));
+end
+
+grant_type_handlers[device_uri] = function(params)
+	if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
+	if not params.client_secret then return oauth_error("invalid_request", "missing 'client_secret'"); end
+	if not params.device_code then return oauth_error("invalid_request", "missing 'device_code'"); end
+
+	local client = check_client(params.client_id);
+	if not client then
+		return oauth_error("invalid_client", "incorrect credentials");
+	end
+
+	if not verify_client_secret(params.client_id, params.client_secret) then
+		module:log("debug", "client_secret mismatch");
+		return oauth_error("invalid_client", "incorrect credentials");
+	end
+
+	local code = codes:get("device_code:" .. params.client_id .. "#" .. params.device_code);
+	if type(code) ~= "table" or code_expired(code) then
+		return oauth_error("expired_token");
+	elseif code.error then
+		return code.error;
+	elseif not code.granted_jid then
+		return oauth_error("authorization_pending");
+	end
+	codes:set("device_code:" .. params.client_id .. "#" .. params.device_code, nil);
+
+	return json.encode(new_access_token(code.granted_jid, code.granted_role, code.granted_scopes, client, code.id_token));
 end
 
 -- RFC 7636 Proof Key for Code Exchange by OAuth Public Clients
@@ -467,7 +639,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
@@ -479,14 +651,14 @@
 			};
 		end
 
-		local scope = array():append(form):filter(function(field)
-			return field.name == "scope" or field.name == "role";
-		end):pluck("value"):concat(" ");
+		local scopes = array():append(form):filter(function(field)
+			return field.name == "scope";
+		end):pluck("value");
 
 		user.token = form.user_token;
 		return {
 			user = user;
-			scope = scope;
+			scopes = scopes;
 			consent = form.consent == "granted";
 		};
 	end
@@ -527,6 +699,7 @@
 		local request_password = assert(params.password, oauth_error("invalid_request", "missing 'password'"));
 		local request_username, request_host, request_resource = jid.prepped_split(request_jid);
 		if params.scope then
+			-- TODO shouldn't we support scopes / roles here?
 			return oauth_error("invalid_scope", "unknown scope requested");
 		end
 		if not request_host or request_host ~= module.host then
@@ -546,18 +719,20 @@
 	grant_type_handlers.authorization_code = nil;
 end
 
+local function render_error(err)
+	return render_page(templates.error, { error = err });
+end
+
 -- OAuth errors should be returned to the client if possible, i.e. by
 -- appending the error information to the redirect_uri and sending the
 -- redirect to the user-agent. In some cases we can't do this, e.g. if
 -- the redirect_uri is missing or invalid. In those cases, we render an
 -- error directly to the user-agent.
-local function error_response(request, err)
-	local q = request.url.query and http.formdecode(request.url.query);
-	local redirect_uri = q and q.redirect_uri;
-	if not redirect_uri or not is_secure_redirect(redirect_uri) then
-		module:log("warn", "Missing or invalid redirect_uri <%s>, rendering error to user-agent", redirect_uri or "");
-		return render_page(templates.error, { error = err });
+local function error_response(request, redirect_uri, err)
+	if not redirect_uri or redirect_uri == oob_uri then
+		return render_error(err);
 	end
+	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
@@ -567,12 +742,25 @@
 	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";
+	device_uri;
+})
+if allowed_grant_type_handlers:contains("device_code") then
+	-- expand short form because that URI is long
+	module:log("debug", "Expanding %q to %q in '%s'", "device_code", device_uri, "allowed_oauth2_grant_types");
+	allowed_grant_type_handlers:remove("device_code");
+	allowed_grant_type_handlers:add(device_uri);
+end
 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);
@@ -607,9 +795,11 @@
 	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 error_response(event.request, oauth_error("invalid_request"));
+		return oauth_error("invalid_request", "Could not parse request body as 'application/x-www-form-urlencoded'");
 	end
 
 	if credentials and credentials.type == "basic" then
@@ -621,7 +811,7 @@
 	local grant_type = params.grant_type
 	local grant_handler = grant_type_handlers[grant_type];
 	if not grant_handler then
-		return error_response(event.request, oauth_error("unsupported_grant_type"));
+		return oauth_error("invalid_request", "No such grant type.");
 	end
 	return grant_handler(params);
 end
@@ -629,55 +819,102 @@
 local function handle_authorization_request(event)
 	local request = event.request;
 
+	-- Directly returning errors to the user before we have a validated client object
 	if not request.url.query then
-		return error_response(request, oauth_error("invalid_request"));
+		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 error_response(request, oauth_error("invalid_request"));
+		return render_error(oauth_error("invalid_request", "Invalid query parameters"));
 	end
 
-	if not params.client_id then return oauth_error("invalid_request", "missing 'client_id'"); end
+	if not params.client_id then
+		return render_error(oauth_error("invalid_request", "Missing 'client_id' parameter"));
+	end
 
-	local ok, client = jwt_verify(params.client_id);
+	local client = check_client(params.client_id);
 
-	if not ok then
-		return oauth_error("invalid_client", "incorrect credentials");
+	if not client then
+		return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter"));
 	end
 
+	local redirect_uri = get_redirect_uri(client, params.redirect_uri);
+	if not redirect_uri then
+		return render_error(oauth_error("invalid_request", "Invalid 'redirect_uri' parameter"));
+	end
+	-- From this point we know that redirect_uri is safe to use
+
 	local client_response_types = set.new(array(client.response_types or { "code" }));
 	client_response_types = set.intersection(client_response_types, allowed_response_type_handlers);
 	if not client_response_types:contains(params.response_type) then
-		return oauth_error("invalid_client", "response_type not allowed");
+		return error_response(request, redirect_uri, oauth_error("invalid_client", "'response_type' not allowed"));
+	end
+
+	local requested_scopes = parse_scopes(params.scope or "");
+	if client.scope then
+		local client_scopes = set.new(parse_scopes(client.scope));
+		requested_scopes:filter(function(scope)
+			return client_scopes:contains(scope);
+		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") and not params.login_hint 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
-		return render_page(templates.login, { state = auth_state, client = client });
+		local extra = {};
+		if params.login_hint then
+			extra.username_hint = (jid.prepped_split(params.login_hint));
+		end
+		return render_page(templates.login, { state = auth_state; client = client; extra = extra });
 	elseif auth_state.consent == nil then
 		-- Render consent page
-		local scopes, requested_roles = split_scopes(parse_scopes(params.scope or ""));
-		local default_role = select_role(auth_state.user.username, requested_roles);
-		local roles = array(it.values(usermanager.get_all_roles(module.host))):filter(function(role)
-			return can_assume_role(auth_state.user.username, role.name);
-		end):sort(function(a, b)
-			return (a.priority or 0) < (b.priority or 0)
-		end):map(function(role)
-			return { name = role.name; selected = role.name == default_role };
-		end);
-		if not roles[2] then
-			-- Only one role to choose from, might as well skip the selector
-			roles = nil;
-		end
-		return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes; roles = roles }, true);
+		local scopes, roles = split_scopes(requested_scopes);
+		roles = user_assumable_roles(auth_state.user.username, roles);
+		return render_page(templates.consent, { state = auth_state; client = client; scopes = scopes+roles }, true);
 	elseif not auth_state.consent then
 		-- Notify client of rejection
-		return error_response(request, oauth_error("access_denied"));
+		if redirect_uri == device_uri then
+			local is_device, device_state = verify_device_token(params.state);
+			if is_device then
+				local device_code = b64url(hashes.hmac_sha256(verification_key, device_state.user_code));
+				local code = codes:get("device_code:" .. params.client_id .. "#" .. device_code);
+				code.error = oauth_error("access_denied");
+				code.expires = os.time() + 60;
+				codes:set("device_code:" .. params.client_id .. "#" .. device_code, code);
+			end
+		end
+		return error_response(request, redirect_uri, oauth_error("access_denied"));
 	end
 	-- else auth_state.consent == true
 
-	params.scope = auth_state.scope;
+	local granted_scopes = auth_state.scopes
+	if client.scope then
+		local client_scopes = set.new(parse_scopes(client.scope));
+		granted_scopes:filter(function(scope)
+			return client_scopes:contains(scope);
+		end);
+	end
+
+	params.scope = granted_scopes:concat(" ");
 
 	local user_jid = jid.join(auth_state.user.username, module.host);
 	local client_secret = make_client_secret(params.client_id);
@@ -686,18 +923,135 @@
 		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;
 	local response_handler = response_type_handlers[response_type];
 	if not response_handler then
-		return error_response(request, oauth_error("unsupported_response_type"));
+		return error_response(request, redirect_uri, oauth_error("unsupported_response_type"));
+	end
+	local ret = response_handler(client, params, user_jid, id_token);
+	if errors.is_err(ret) then
+		return error_response(request, redirect_uri, ret);
+	end
+	return ret;
+end
+
+local function handle_device_authorization_request(event)
+	local request = event.request;
+
+	local credentials = get_request_credentials(request);
+
+	local params = strict_formdecode(request.body);
+	if not params then
+		return render_error(oauth_error("invalid_request", "Invalid query parameters"));
+	end
+
+	if credentials and credentials.type == "basic" then
+		-- client_secret_basic converted internally to client_secret_post
+		params.client_id = http.urldecode(credentials.username);
+		local client_secret = http.urldecode(credentials.password);
+
+		if not verify_client_secret(params.client_id, client_secret) then
+			module:log("debug", "client_secret mismatch");
+			return oauth_error("invalid_client", "incorrect credentials");
+		end
+	else
+		return 401;
+	end
+
+	local client = check_client(params.client_id);
+
+	if not client then
+		return render_error(oauth_error("invalid_request", "Invalid 'client_id' parameter"));
+	end
+
+	if not set.new(client.grant_types):contains(device_uri) then
+		return render_error(oauth_error("invalid_client", "Client not registered for device authorization grant"));
+	end
+
+	local requested_scopes = parse_scopes(params.scope or "");
+	if client.scope then
+		local client_scopes = set.new(parse_scopes(client.scope));
+		requested_scopes:filter(function(scope)
+			return client_scopes:contains(scope);
+		end);
 	end
-	return response_handler(client, params, user_jid, id_token);
+
+	-- TODO better code generator, this one should be easy to type from a
+	-- screen onto a phone
+	local user_code = (id.tiny() .. "-" .. id.tiny()):upper();
+	local collisions = 0;
+	while codes:get("authorization_code:" .. device_uri .. "#" .. user_code) do
+		collisions = collisions + 1;
+		if collisions > 10 then
+			return oauth_error("temporarily_unavailable");
+		end
+		user_code = (id.tiny() .. "-" .. id.tiny()):upper();
+	end
+	-- device code should be derivable after consent but not guessable by the user
+	local device_code = b64url(hashes.hmac_sha256(verification_key, user_code));
+	local verification_uri = module:http_url() .. "/device";
+	local verification_uri_complete = verification_uri .. "?" .. http.formencode({ user_code = user_code });
+
+	local expires = os.time() + 600;
+	local dc_ok = codes:set("device_code:" .. params.client_id .. "#" .. device_code, { expires = expires });
+	local uc_ok = codes:set("user_code:" .. user_code,
+		{ user_code = user_code; expires = expires; client_id = params.client_id;
+    scope = requested_scopes:concat(" ") });
+	if not dc_ok or not uc_ok then
+		return oauth_error("temporarily_unavailable");
+	end
+
+	return {
+		headers = { content_type = "application/json"; cache_control = "no-store"; pragma = "no-cache" };
+		body = json.encode {
+			device_code = device_code;
+			user_code = user_code;
+			verification_uri = verification_uri;
+			verification_uri_complete = verification_uri_complete;
+			expires_in = 600;
+			interval = 5;
+		};
+	}
 end
 
+local function handle_device_verification_request(event)
+	local request = event.request;
+	local params = strict_formdecode(request.url.query);
+	if not params or not params.user_code then
+		return render_page(templates.device, { client = false });
+	end
+
+	local device_info = codes:get("user_code:" .. params.user_code);
+	if not device_info or code_expired(device_info) or not codes:set("user_code:" .. params.user_code, nil) then
+		return render_page(templates.device, {
+			client = false;
+			error = oauth_error("expired_token", "Incorrect or expired code");
+		});
+	end
+
+	return {
+		status_code = 303;
+		headers = {
+			location = module:http_url() .. "/authorize" .. "?" .. http.formencode({
+				client_id = device_info.client_id;
+				redirect_uri = device_uri;
+				response_type = "code";
+				scope = device_info.scope;
+				state = new_device_token({ user_code = params.user_code });
+			});
+		};
+	}
+end
+
+local strict_auth_revoke = module:get_option_boolean("oauth2_require_auth_revoke", false);
+
 local function handle_revocation_request(event)
 	local request, response = event.request, event.response;
+	response.headers.cache_control = "no-store";
+	response.headers.pragma = "no-cache";
 	if request.headers.authorization then
 		local credentials = get_request_credentials(request);
 		if not credentials or credentials.type ~= "basic" then
@@ -708,9 +1062,14 @@
 		if not verify_client_secret(credentials.username, credentials.password) then
 			return 401;
 		end
+		-- TODO check that it's their token I guess?
+	elseif strict_auth_revoke then
+		-- Why require auth to revoke a leaked token?
+		response.headers.www_authenticate = string.format("Basic realm=%q", module.host.."/"..module.name);
+		return 401;
 	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;
@@ -724,6 +1083,7 @@
 end
 
 local registration_schema = {
+	title = "OAuth 2.0 Dynamic Client Registration Protocol";
 	type = "object";
 	required = {
 		-- These are shown to users in the template
@@ -733,14 +1093,24 @@
 		"redirect_uris";
 	};
 	properties = {
-		redirect_uris = { type = "array"; minLength = 1; items = { type = "string"; format = "uri" } };
+		redirect_uris = {
+			title = "List of Redirect URIs";
+			type = "array";
+			minItems = 1;
+			uniqueItems = true;
+			items = { title = "Redirect URI"; type = "string"; format = "uri" };
+		};
 		token_endpoint_auth_method = {
+			title = "Token Endpoint Authentication Method";
 			type = "string";
 			enum = { "none"; "client_secret_post"; "client_secret_basic" };
 			default = "client_secret_basic";
 		};
 		grant_types = {
+			title = "Grant Types";
 			type = "array";
+			minItems = 1;
+			uniqueItems = true;
 			items = {
 				type = "string";
 				enum = {
@@ -751,35 +1121,111 @@
 					"refresh_token";
 					"urn:ietf:params:oauth:grant-type:jwt-bearer";
 					"urn:ietf:params:oauth:grant-type:saml2-bearer";
+					device_uri;
 				};
 			};
 			default = { "authorization_code" };
 		};
-		application_type = { type = "string"; enum = { "native"; "web" }; default = "web" };
-		response_types = { type = "array"; items = { type = "string"; enum = { "code"; "token" } }; default = { "code" } };
-		client_name = { type = "string" };
-		client_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
-		logo_uri = { type = "string"; format = "uri"; luaPattern = "^https:" };
-		scope = { type = "string" };
-		contacts = { type = "array"; 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" };
-		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:" };
+		application_type = {
+			title = "Application Type";
+			description = "Determines which kinds of redirect URIs the client may register. \z
+			The value 'web' limits the client to https:// URLs with the same hostname as in 'client_uri' \z
+			while the value 'native' allows either loopback http:// URLs or application specific URIs.";
+			type = "string";
+			enum = { "native"; "web" };
+			default = "web";
+		};
+		response_types = {
+			title = "Response Types";
+			type = "array";
+			minItems = 1;
+			uniqueItems = true;
+			items = { type = "string"; enum = { "code"; "token" } };
+			default = { "code" };
+		};
+		client_name = {
+			title = "Client Name";
+			description = "Human-readable name of the client, presented to the user in the consent dialog.";
+			type = "string";
+		};
+		client_uri = {
+			title = "Client URL";
+			description = "Should be an link to a page with information about the client.";
+			type = "string";
+			format = "uri";
+			pattern = "^https:";
+		};
+		logo_uri = {
+			title = "Logo URL";
+			description = "URL to the clients logotype (not currently used).";
+			type = "string";
+			format = "uri";
+			pattern = "^https:";
+		};
+		scope = {
+			title = "Scopes";
+			description = "Space-separated list of scopes the client promises to restrict itself to.";
+			type = "string";
+		};
+		contacts = {
+			title = "Contact Addresses";
+			description = "Addresses, typically email or URLs where the client developers can be contacted.";
+			type = "array";
+			minItems = 1;
+			items = { type = "string"; format = "email" };
+		};
+		tos_uri = {
+			title = "Terms of Service URL";
+			description = "Link to Terms of Service for the client, presented to the user in the consent dialog. \z
+			MUST be a https:// URL with hostname matching that of 'client_uri'.";
+			type = "string";
+			format = "uri";
+			pattern = "^https:";
+		};
+		policy_uri = {
+			title = "Privacy Policy URL";
+			description = "Link to a Privacy Policy for the client. MUST be a https:// URL with hostname matching that of 'client_uri'.";
+			type = "string";
+			format = "uri";
+			pattern = "^https:";
+		};
+		software_id = {
+			title = "Software ID";
+			description = "Unique identifier for the client software, common for all instances. Typically an UUID.";
+			type = "string";
+			format = "uuid";
+		};
+		software_version = {
+			title = "Software Version";
+			description = "Version of the client software being registered. \z
+			E.g. to allow revoking all related tokens in the event of a security incident.";
+			type = "string";
+			example = "2.3.1";
+		};
 	};
 }
 
+-- 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
+		return false; -- no relative URLs
+	end
 	if app_type == "native" then
-		return uri.scheme == "http" and loopbacks:contains(uri.host) or uri.scheme ~= "https";
+		return uri.scheme == "http" and loopbacks:contains(uri.host) or redirect_uri == oob_uri or uri.scheme:find(".", 1, true) ~= nil;
 	elseif app_type == "web" then
 		return uri.scheme == "https" and uri.host == client_uri.host;
 	end
@@ -790,6 +1236,16 @@
 		return nil, oauth_error("invalid_request", "Failed schema validation.");
 	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");
+	end
+
+	if not client_metadata.application_type and redirect_uri_allowed(client_metadata.redirect_uris[1], client_uri, "native") then
+		client_metadata.application_type = "native";
+		-- else defaults to "web"
+	end
+
 	-- Fill in default values
 	for propname, propspec in pairs(registration_schema.properties) do
 		if client_metadata[propname] == nil and type(propspec) == "table" and propspec.default ~= nil then
@@ -797,9 +1253,11 @@
 		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");
+	-- 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
 
 	for _, redirect_uri in ipairs(client_metadata.redirect_uris) do
@@ -816,19 +1274,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);
 
@@ -844,18 +1289,21 @@
 		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 = jwt_sign(client_metadata);
+	local client_id = sign_client(client_metadata);
 
 	client_metadata.client_id = client_id;
 	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;
 
@@ -879,7 +1327,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
@@ -888,6 +1340,8 @@
 	module:log("info", "No 'oauth2_registration_key', dynamic client registration disabled")
 	handle_authorization_request = nil
 	handle_register_request = nil
+	handle_device_authorization_request = nil
+	handle_device_verification_request = nil
 end
 
 local function handle_userinfo_request(event)
@@ -941,6 +1395,7 @@
 
 module:depends("http");
 module:provides("http", {
+	cors = { enabled = true; credentials = true };
 	route = {
 		-- OAuth 2.0 in 5 simple steps!
 		-- This is the normal 'authorization_code' flow.
@@ -948,9 +1403,14 @@
 		-- Step 1. Create OAuth client
 		["POST /register"] = handle_register_request;
 
+		-- Device flow
+		["POST /device"] = handle_device_authorization_request;
+		["GET /device"] = handle_device_verification_request;
+
 		-- 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
@@ -972,7 +1432,7 @@
 			headers = {
 				["Content-Type"] = "text/css";
 			};
-			body = _render_html(templates.css, module:get_option("oauth2_template_style"));
+			body = templates.css;
 		} or nil;
 		["GET /script.js"] = templates.js and {
 			headers = {
@@ -1002,37 +1462,51 @@
 
 -- 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" });
+		device_authorization_endpoint = handle_device_authorization_request and module:http_url() .. "/device";
+		code_challenge_methods_supported = array(it.keys(verifier_transforms));
+		grant_types_supported = array(it.keys(grant_type_handlers));
+		response_modes_supported = array(it.keys(response_type_handlers)):map(tmap { token = "fragment"; code = "query" });
+		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;
-				jwks_uri = nil; -- TODO?
-				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))):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;
-				id_token_signing_alg_values_supported = { "HS256" };
-			};
-		};
+		["GET"] = function()
+			return {
+				headers = { content_type = "application/json" };
+				body = json.encode(get_authorization_server_metadata());
+			}
+		end
 	};
 });
 
--- a/mod_invites/README.markdown	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_invites/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -1,18 +1,24 @@
 ---
 labels:
-- 'Stage-Beta'
+- 'Stage-Merged'
 summary: 'Invite management module for Prosody'
 ...
 
 Introduction
 ============
 
+::: {.alert .alert-info}
+This module has been merged into Prosody as
+[mod_invites][doc:modules:mod_invites]. Users of Prosody **0.12**
+and later should not install this version.
+:::
+
 This module is part of the suite of modules that implement invite-based
 account registration for Prosody. The other modules are:
 
-- [mod_invites_adhoc]
+- [mod_invites_adhoc][doc:modules:mod_invites_adhoc]
+- [mod_invites_register][doc:modules:mod_invites_register]
 - [mod_invites_page]
-- [mod_invites_register]
 - [mod_invites_register_web]
 - [mod_invites_api]
 - [mod_register_apps]
--- a/mod_invites_adhoc/README.markdown	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_invites_adhoc/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -1,18 +1,24 @@
 ---
 labels:
-- 'Stage-Beta'
+- 'Stage-Merged'
 summary: 'Enable ad-hoc command for XMPP clients to create invitations'
 ...
 
 Introduction
 ============
 
+::: {.alert .alert-info}
+This module has been merged into Prosody as
+[mod_invites_adhoc][doc:modules:mod_invites_adhoc]. Users of Prosody **0.12**
+and later should not install this version.
+:::
+
 This module is part of the suite of modules that implement invite-based
 account registration for Prosody. The other modules are:
 
-- [mod_invites]
+- [mod_invites][doc:modules:mod_invites]
+- [mod_invites_register][doc:modules:mod_invites_register]
 - [mod_invites_page]
-- [mod_invites_register]
 - [mod_invites_register_web]
 - [mod_invites_api]
 - [mod_register_apps]
@@ -48,4 +54,4 @@
 
 The `allow_user_invites` option should be set as desired. However it is
 strongly recommended to leave the other option (`allow_contact_invites`)
-at its default to provide the best user experience.
\ No newline at end of file
+at its default to provide the best user experience.
--- a/mod_invites_adhoc/mod_invites_adhoc.lua	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_invites_adhoc/mod_invites_adhoc.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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");
@@ -57,7 +61,11 @@
 		return module:may(":invite-new-users", context);
 	elseif usermanager.get_roles then -- COMPAT w/0.12
 		local user_roles = usermanager.get_roles(jid, module.host);
-		if not user_roles then return; end
+		if not user_roles then
+			-- User has no roles we can check, just return default
+			return allow_user_invites;
+		end
+
 		if user_roles["prosody:admin"] then
 			return true;
 		end
--- a/mod_invites_page/mod_invites_page.lua	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_invites_page/mod_invites_page.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -39,6 +39,9 @@
 		else
 			http_files = module:depends"http_files";
 		end
+	elseif prosody.process_type and module.get_option_period then
+		module:depends("http");
+		http_files = require "net.http.files";
 	end
 	-- Calculate automatic base_url default
 	base_url = module.http_url and module:http_url();
--- a/mod_invites_register/README.markdown	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_invites_register/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -1,17 +1,23 @@
 ---
 labels:
-- 'Stage-Beta'
+- 'Stage-Merged'
 summary: 'Allow account registration using invite tokens'
 ...
 
 Introduction
 ============
 
+::: {.alert .alert-info}
+This module has been merged into Prosody as
+[mod_invites_register][doc:modules:mod_invites_register]. Users of
+Prosody **0.12** and later should not install this version.
+:::
+
 This module is part of the suite of modules that implement invite-based
 account registration for Prosody. The other modules are:
 
-- [mod_invites]
-- [mod_invites_adhoc]
+- [mod_invites][doc:modules:mod_invites]
+- [mod_invites_adhoc][doc:modules:mod_invites_adhoc]
 - [mod_invites_page]
 - [mod_invites_register_web]
 - [mod_invites_api]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mod_measure_lua/README.md	Mon Sep 18 08:24:19 2023 -0500
@@ -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_block_pm/README.markdown	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_muc_block_pm/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -1,12 +1,11 @@
 ---
-summary: Prevent unaffiliated MUC participants from sending PMs
+summary: Prevent MUC participants from sending PMs
 ---
 
 # Introduction
 
-This module prevents unaffiliated users from sending private messages in
-chat rooms, unless someone with an affiliation (member, admin etc)
-messages them first.
+This module prevents *participants* from sending private messages to
+anyone except *moderators*.
 
 # Configuration
 
@@ -23,6 +22,5 @@
 
     Branch State
   -------- -----------------
-       0.9 Works
-      0.10 Should work
-      0.11 Should work
+      0.11 Will **not** work
+      0.12 Should work
--- a/mod_muc_block_pm/mod_muc_block_pm.lua	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_muc_block_pm/mod_muc_block_pm.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -1,29 +1,26 @@
-local bare_jid = require"util.jid".bare;
-local st = require"util.stanza";
+local st = require "util.stanza";
+
+module:hook("muc-disco#info", function(event)
+	table.insert(event.form, { name = "muc#roomconfig_allowpm"; value = "moderators" });
+end);
 
--- Support both old and new MUC code
-local mod_muc = module:depends"muc";
-local rooms = rawget(mod_muc, "rooms");
-local get_room_from_jid = rawget(mod_muc, "get_room_from_jid") or
-	function (jid)
-		return rooms[jid];
+module:hook("muc-private-message", function(event)
+	local stanza, room = event.stanza, event.room;
+	local from_occupant = room:get_occupant_by_nick(stanza.attr.from);
+
+	if from_occupant and from_occupant.role == "moderator" then
+		return -- moderators may message anyone
 	end
 
-module:hook("message/full", function(event)
-	local stanza, origin = event.stanza, event.origin;
-	if stanza.attr.type == "error" then
-		return
+	local to_occupant = room:get_occupant_by_nick(stanza.attr.to)
+	if to_occupant and to_occupant.role == "moderator" then
+		return -- messaging moderators is ok
 	end
-	local to, from = stanza.attr.to, stanza.attr.from;
-	local room = get_room_from_jid(bare_jid(to));
-	local to_occupant = room and room._occupants[to];
-	local from_occupant = room and room._occupants[room._jid_nick[from]]
-	if not ( to_occupant and from_occupant ) then return end
 
-	if from_occupant.affiliation then
-		to_occupant._pm_block_override = true;
-	elseif not from_occupant._pm_block_override then
-		origin.send(st.error_reply(stanza, "cancel", "not-authorized", "Private messages are disabled"));
-		return true;
+	if to_occupant.bare_jid == from_occupant.bare_jid then
+		return -- to yourself is okay, used by some clients to sync read state in public channels
 	end
+
+	room:route_to_occupant(from_occupant, st.error_reply(stanza, "cancel", "policy-violation", "Private messages are disabled", room.jid))
+	return false;
 end, 1);
--- a/mod_muc_defaults/README.markdown	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_muc_defaults/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_muc_limits/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -30,16 +30,22 @@
 
 Add the module to the MUC host (not the global modules\_enabled):
 
-        Component "conference.example.com" "muc"
-            modules_enabled = { "muc_limits" }
+```lua
+Component "conference.example.com" "muc"
+    modules_enabled = { "muc_limits" }
+```
 
 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
+  muc_limit_base_cost         1               Base cost of sending a stanza
+  muc_line_count_multiplier   0.1             Additional cost of each newline in the body of a message
 
 For more understanding of how these values are used, see the algorithm
 section below.
@@ -66,15 +72,7 @@
 Compatibility
 =============
 
-  ------- ------------------
+  ------- -------
   trunk   Works
   0.11    Works
-  0.10    Works
-  0.9     Works
-  0.8     Doesn't work[^1]
-  ------- ------------------
-
-[^1]: This module can be made to work in 0.8 (and *maybe* previous
-    versions) of Prosody by copying the new
-    [util.throttle](http://hg.prosody.im/trunk/raw-file/fc8a22936b3c/util/throttle.lua)
-    into your Prosody source directory (into the util/ subdirectory).
+  ------- -------
--- a/mod_muc_limits/mod_muc_limits.lua	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_muc_limits/mod_muc_limits.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -13,6 +13,11 @@
 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 base_cost = math.max(module:get_option_number("muc_limit_base_cost", 1), 0);
+local line_multiplier = math.max(module:get_option_number("muc_line_count_multiplier", 0.1), 0);
+
 local join_only = module:get_option_boolean("muc_limit_joins_only", false);
 local dropped_count = 0;
 local dropped_jids;
@@ -46,7 +51,25 @@
 		throttle = new_throttle(period*burst, burst);
 		room.throttle = throttle;
 	end
-	if not throttle:poll(1) then
+	local cost = base_cost;
+	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 * line_multiplier);
+	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 +83,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	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:24:19 2023 -0500
@@ -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_muc_moderation/README.markdown	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_muc_moderation/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -1,3 +1,7 @@
+---
+summary: Let moderators remove spam and abuse messages
+---
+
 # Introduction
 
 This module implements [XEP-0425: Message Moderation].
@@ -24,6 +28,7 @@
 -   Basic functionality with Prosody 0.11.x and later
 -   Full functionality with Prosody 0.12.x and `internal` or `sql`
     storage^[Replacing moderated messages with tombstones requires new storage API methods.]
+-   Works with [mod_storage_xmlarchive]
 
 ## Clients
 
@@ -33,7 +38,7 @@
 
 ### Feature requests
 
--   [Conv](https://github.com/iNPUTmice/Conversations/issues/3722)[ersa](https://github.com/iNPUTmice/Conversations/issues/3920)[tions](https://github.com/iNPUTmice/Conversations/issues/4227)
+-   [Conversations](https://codeberg.org/iNPUTmice/Conversations/issues/20)
 -   [Dino](https://github.com/dino/dino/issues/1133)
 -   [Poezio](https://lab.louiz.org/poezio/poezio/-/issues/3543)
 -   [Profanity](https://github.com/profanity-im/profanity/issues/1336)
--- a/mod_muc_moderation/mod_muc_moderation.lua	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_muc_moderation/mod_muc_moderation.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -27,6 +27,7 @@
 -- Namespaces
 local xmlns_fasten = "urn:xmpp:fasten:0";
 local xmlns_moderate = "urn:xmpp:message-moderate:0";
+local xmlns_occupant_id = "urn:xmpp:occupant-id:0";
 local xmlns_retract = "urn:xmpp:message-retract:0";
 
 -- Discovering support
@@ -95,11 +96,31 @@
 		announcement:text_tag("reason", reason);
 	end
 
+	local moderated_occupant_id = original:get_child("occupant-id", xmlns_occupant_id);
+	if room.get_occupant_id and moderated_occupant_id then
+		announcement:add_direct_child(moderated_occupant_id);
+	end
+
+	local actor_occupant = room:get_occupant_by_real_jid(actor) or room:new_occupant(jid.bare(actor), actor_nick);
+	if room.get_occupant_id then
+		-- This isn't a regular broadcast message going through the events occupant_id.lib hooks so we do this here
+		announcement:add_direct_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) }))
+	end
+
 	if muc_log_archive.set and retract then
 		local tombstone = st.message({ from = original.attr.from, type = "groupchat", id = original.attr.id })
 			:tag("moderated", { xmlns = xmlns_moderate, by = actor_nick })
 				:tag("retracted", { xmlns = xmlns_retract, stamp = dt.datetime() }):up();
 
+		if room.get_occupant_id then
+			tombstone:add_direct_child(st.stanza("occupant-id", { xmlns = xmlns_occupant_id; id = room:get_occupant_id(actor_occupant) }))
+
+			if moderated_occupant_id then
+				-- Copy occupant id from moderated message
+				tombstone:add_child(moderated_occupant_id);
+			end
+		end
+
 		if reason then
 			tombstone:text_tag("reason", reason);
 		end
--- a/mod_oidc_userinfo_vcard4/README.md	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_oidc_userinfo_vcard4/README.md	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_oidc_userinfo_vcard4/mod_oidc_userinfo_vcard4.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_pubsub_alertmanager/README.md	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_pubsub_alertmanager/mod_pubsub_alertmanager.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_pubsub_feeds/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_pubsub_feeds/mod_pubsub_feeds.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_rest/example/prosody_oauth.py	Mon Sep 18 08:24:19 2023 -0500
@@ -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/example/rest.sh	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_rest/example/rest.sh	Mon Sep 18 08:24:19 2023 -0500
@@ -5,23 +5,25 @@
 
 # Dependencies:
 # - https://httpie.io/
-# - https://github.com/stedolan/jq
-# - some sort of XDG 'open' command
+# - https://hg.sr.ht/~zash/httpie-oauth2
+
+# shellcheck disable=SC1091
 
 # Settings
 HOST=""
 DOMAIN=""
 
-AUTH_METHOD="session-read-only"
-AUTH_ID="rest"
-
 if [ -f "${XDG_CONFIG_HOME:-$HOME/.config}/restrc" ]; then
 	# Config file can contain the above settings
 	source "${XDG_CONFIG_HOME:-$HOME/.config}/restrc"
+
+	if [ -z "${SCOPE:-}" ]; then
+		SCOPE="openid xmpp"
+	fi
 fi
-	
+
 if [[ $# == 0 ]]; then
-	echo "${0##*/} [-h HOST] [-u USER|--login] [/path] kind=(message|presence|iq) ...."
+	echo "${0##*/} [-h HOST] [/path] kind=(message|presence|iq) ...."
 	# Last arguments are handed to HTTPie, so refer to its docs for further details
 	exit 0
 fi
@@ -45,80 +47,6 @@
 	fi
 fi
 
-if [[ "$1" == "-u" ]]; then
-	# -u username
-	AUTH_METHOD="auth"
-	AUTH_ID="$2"
-	shift 2
-elif [[ "$1" == "-rw" ]]; then
-	# To e.g. save Accept headers to the session
-	AUTH_METHOD="session"
-	shift 1
-fi
-
-if [[ "$1" == "--login" ]]; then
-	shift 1
-
-	# Check cache for OAuth client
-	if [ -f "${XDG_CACHE_HOME:-$HOME/.cache}/rest/$HOST" ]; then
-		source "${XDG_CACHE_HOME:-$HOME/.cache}/rest/$HOST" 
-	fi
-
-	OAUTH_META="$(http --check-status --json "https://$HOST/.well-known/oauth-authorization-server" Accept:application/json)"
-	AUTHORIZATION_ENDPOINT="$(echo "$OAUTH_META" | jq -e -r '.authorization_endpoint')"
-	TOKEN_ENDPOINT="$(echo "$OAUTH_META" | jq -e -r '.token_endpoint')"
-
-	if [ -z "${OAUTH_CLIENT_INFO:-}" ]; then
-		# Register a new OAuth client
-		REGISTRATION_ENDPOINT="$(echo "$OAUTH_META" | jq -e -r '.registration_endpoint')"
-		OAUTH_CLIENT_INFO="$(http --check-status "$REGISTRATION_ENDPOINT" Content-Type:application/json Accept:application/json client_name=rest.sh client_uri="https://modules.prosody.im/mod_rest" application_type=native software_id=0bdb0eb9-18e8-43af-a7f6-bd26613374c0 redirect_uris:='["urn:ietf:wg:oauth:2.0:oob"]')"
-		mkdir -p "${XDG_CACHE_HOME:-$HOME/.cache}/rest/"
-		typeset -p OAUTH_CLIENT_INFO >> "${XDG_CACHE_HOME:-$HOME/.cache}/rest/$HOST"
-	fi
-
-	CLIENT_ID="$(echo "$OAUTH_CLIENT_INFO" | jq -e -r '.client_id')"
-	CLIENT_SECRET="$(echo "$OAUTH_CLIENT_INFO" | jq -e -r '.client_secret')"
-
-	if [ -n "${REFRESH_TOKEN:-}" ]; then
-		TOKEN_RESPONSE="$(http --check-status --form "$TOKEN_ENDPOINT" 'grant_type=refresh_token' "client_id=$CLIENT_ID" "client_secret=$CLIENT_SECRET" "refresh_token=$REFRESH_TOKEN")"
-		ACCESS_TOKEN="$(echo "$TOKEN_RESPONSE" | jq -r '.access_token')"
-		if [ "$ACCESS_TOKEN" == "null" ]; then
-			ACCESS_TOKEN=""
-		fi
-	fi
-
-	if [ -z "${ACCESS_TOKEN:-}" ]; then
-		CODE_CHALLENGE="$(head -c 33 /dev/urandom | base64 | tr /+ _-)"
-		open "$AUTHORIZATION_ENDPOINT?response_type=code&client_id=$CLIENT_ID&code_challenge=$CODE_CHALLENGE&scope=openid+prosody:user"
-		read -p "Paste authorization code: " -s -r AUTHORIZATION_CODE
-
-		TOKEN_RESPONSE="$(http --check-status --form "$TOKEN_ENDPOINT" 'grant_type=authorization_code' "client_id=$CLIENT_ID" "client_secret=$CLIENT_SECRET" "code=$AUTHORIZATION_CODE" code_verifier="$CODE_CHALLENGE")"
-		ACCESS_TOKEN="$(echo "$TOKEN_RESPONSE" | jq -e -r '.access_token')"
-		REFRESH_TOKEN="$(echo "$TOKEN_RESPONSE" | jq -r '.refresh_token')"
-
-		if [ "$REFRESH_TOKEN" != "null" ]; then
-			# FIXME Better type check would be nice, but nobody should ever have the
-			# string "null" as a legitimate refresh token...
-			typeset -p REFRESH_TOKEN >> "${XDG_CACHE_HOME:-$HOME/.cache}/rest/$HOST"
-		fi
-
-		if [ -n "${COLORTERM:-}" ]; then
-			echo -ne '\e[1K\e[G'
-		else
-			echo
-		fi
-	fi
-
-	USERINFO_ENDPOINT="$(echo "$OAUTH_META" | jq -e -r '.userinfo_endpoint')"
-	http --check-status -b --session rest "$USERINFO_ENDPOINT" "Authorization:Bearer $ACCESS_TOKEN" Accept:application/json >&2
-	AUTH_METHOD="session-read-only"
-	AUTH_ID="rest"
-fi
-
-if [[ $# == 0 ]]; then
-	# Just login?
-	exit 0
-fi
 
 # For e.g /disco/example.com and such GET queries
 GET_PATH=""
@@ -127,4 +55,4 @@
 	shift 1
 fi
 
-http --check-status -p b "--$AUTH_METHOD" "$AUTH_ID" "https://$HOST/rest$GET_PATH" "$@"
+https --check-status -p b --session rest -A oauth2 -a "$HOST" --oauth2-scope "$SCOPE" "$HOST/rest$GET_PATH" "$@"
--- a/mod_rest/mod_rest.lua	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_rest/mod_rest.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_rest/res/openapi.yaml	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_rest/res/schema-xmpp.json	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_restrict_xmpp/mod_restrict_xmpp.lua	Mon Sep 18 08:24:19 2023 -0500
@@ -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	Mon Sep 18 08:24:19 2023 -0500
@@ -0,0 +1,54 @@
+---
+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.
+
+The special target `"*"` may be used to redirect all servers that don't have
+an exact match.
+
+One-level wildcards like `"*.example.net"` also work.
+
+Standard DNS SRV resolution can be restored by specifying a truthy value.
+
+```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";
+    ["*.allthese.example"] = = "tcp://198.51.100.123:9999";
+
+    -- catch-all:
+    ["*"] = "tls://127.0.0.1:5370";
+    -- bypass the catch-all, use standard DNS SRV:
+    ["jabber.example"] = true;
+}
+```
+
+# 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	Mon Sep 18 08:24:19 2023 -0500
@@ -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] or override_for[event.session.to_host:gsub("^[^.]+%.", "*.")] or override_for["*"];
+	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);
--- a/mod_welcome_page/README.markdown	Mon Sep 18 08:22:07 2023 -0500
+++ b/mod_welcome_page/README.markdown	Mon Sep 18 08:24:19 2023 -0500
@@ -4,7 +4,6 @@
 summary: 'Serve a welcome page to users'
 rockspec:
   dependencies:
-  - mod_invites
   - mod_http_libjs
   build:
     copy_directories: