Merge 0.10->0.11 0.11 0.11.2
authorMatthew Wild <mwild1@gmail.com>
Mon, 07 Jan 2019 15:34:23 +0000
branch0.11
changeset 9778 4f8b6c09e5f3
parent 9775 bf92f37de137 (diff)
parent 9777 7e053c022782 (current diff)
child 9779 b16780e7939f
child 9780 c6cf32de940d
Merge 0.10->0.11
.hgtags
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.busted	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,13 @@
+return {
+  _all = {
+  },
+  default = {
+    ["exclude-tags"] = "mod_bosh,storage";
+  };
+  bosh = {
+    tags = "mod_bosh";
+  };
+  storage = {
+    tags = "storage";
+  };
+}
--- a/.hgignore	Wed Nov 28 16:55:27 2018 +0000
+++ b/.hgignore	Mon Jan 07 15:34:23 2019 +0000
@@ -15,7 +15,6 @@
 *.rej
 *.save
 *~
-*.report
 *.o
 *.so
 *.install
@@ -27,3 +26,6 @@
 *.exp
 *.lib
 *.obj
+luacov.report.out
+luacov.report.out.index
+luacov.stats.out
\ No newline at end of file
--- a/.hgtags	Wed Nov 28 16:55:27 2018 +0000
+++ b/.hgtags	Mon Jan 07 15:34:23 2019 +0000
@@ -65,4 +65,6 @@
 4ae8dd415e9431924ad4aa0b57bcee8a4a9272f8 0.10.1
 29c6d2681bad9f67d8bd548bb3a7973473bae91e 0.9.14
 7ec098b68042f60687f1002e788b34b06048945d 0.10.2
+83f3a05c1b1bb9b54b3b153077a06eb02e247c8e 0.11.0
+91856829f18bb8ef7056ca02464122fc6de17807 0.11.1
 bb8486491b48431236c0d32548c20d9853781e69 0.10.3
--- a/.luacheckrc	Wed Nov 28 16:55:27 2018 +0000
+++ b/.luacheckrc	Mon Jan 07 15:34:23 2019 +0000
@@ -1,19 +1,33 @@
 cache = true
-read_globals = { "prosody", "hosts", "import" }
-globals = { "_M" }
-allow_defined_top = true
-module = true
-unused_secondaries = false
 codes = true
-ignore = { "411/err", "421/err", "411/ok", "421/ok", "211/_ENV", "431/log" }
+ignore = { "411/err", "421/err", "411/ok", "421/ok", "211/_ENV", "431/log", "143/table", "113/unpack" }
 
 max_line_length = 150
 
+read_globals = {
+	"prosody",
+	"import",
+};
+files["prosody"] = {
+	allow_defined_top = true;
+	module = true;
+}
+files["prosodyctl"] = {
+	allow_defined_top = true;
+	module = true;
+};
 files["core/"] = {
-	read_globals = { "prosody", "hosts" };
-	globals = { "prosody.hosts.?", "hosts.?" };
+	globals = {
+		"prosody.hosts.?",
+	};
+}
+files["util/"] = {
+	-- Ignore unwrapped license text
+	max_comment_line_length = false;
 }
 files["plugins/"] = {
+	module = true;
+	allow_defined_top = true;
 	read_globals = {
 		-- Module instance
 		"module.name",
@@ -51,8 +65,6 @@
 		"module.get_option_set",
 		"module.get_option_string",
 		"module.handle_items",
-		"module.has_feature",
-		"module.has_identity",
 		"module.hook",
 		"module.hook_global",
 		"module.hook_object_event",
@@ -74,10 +86,11 @@
 		"module.wrap_event",
 		"module.wrap_global",
 		"module.wrap_object_event",
+
+		-- mod_http API
+		"module.http_url",
 	};
 	globals = {
-		"_M",
-
 		-- Methods that can be set on module API
 		"module.unload",
 		"module.add_host",
@@ -89,16 +102,69 @@
 		"module.environment",
 	};
 }
-files["tests/"] = {
-	read_globals = {
-		"testlib_new_env",
-		"assert_equal",
-		"assert_table",
-		"assert_function",
-		"assert_string",
-		"assert_boolean",
-		"assert_is",
-		"assert_is_not",
-		"runtest",
+files["spec/"] = {
+	std = "+busted";
+	globals = { "randomize" };
+}
+files["prosody.cfg.lua"] = {
+	ignore = { "131" };
+	globals = {
+		"Host",
+		"host",
+		"VirtualHost",
+		"Component",
+		"component",
+		"Include",
+		"include",
+		"RunScript"
 	};
 }
+
+if os.getenv("PROSODY_STRICT_LINT") ~= "1" then
+	-- These files have not yet been brought up to standard
+	-- Do not add more files here, but do help us fix these!
+	unused_secondaries = false
+
+	local exclude_files = {
+	"doc/net.server.lua";
+
+	"fallbacks/bit.lua";
+	"fallbacks/lxp.lua";
+
+	"net/adns.lua";
+	"net/cqueues.lua";
+	"net/dns.lua";
+	"net/server_select.lua";
+
+	"plugins/mod_storage_sql1.lua";
+
+	"spec/core_configmanager_spec.lua";
+	"spec/core_moduleapi_spec.lua";
+	"spec/net_http_parser_spec.lua";
+	"spec/util_events_spec.lua";
+	"spec/util_http_spec.lua";
+	"spec/util_ip_spec.lua";
+	"spec/util_multitable_spec.lua";
+	"spec/util_rfc6724_spec.lua";
+	"spec/util_throttle_spec.lua";
+	"spec/util_xmppstream_spec.lua";
+
+	"tools/ejabberd2prosody.lua";
+	"tools/ejabberdsql2prosody.lua";
+	"tools/erlparse.lua";
+	"tools/jabberd14sql2prosody.lua";
+	"tools/migration/migrator.cfg.lua";
+	"tools/migration/migrator/jabberd14.lua";
+	"tools/migration/migrator/mtools.lua";
+	"tools/migration/migrator/prosody_files.lua";
+	"tools/migration/migrator/prosody_sql.lua";
+	"tools/migration/prosody-migrator.lua";
+	"tools/openfire2prosody.lua";
+	"tools/xep227toprosody.lua";
+
+	"util/sasl/digest-md5.lua";
+	}
+	for _, file in ipairs(exclude_files) do
+		files[file] = { only = {} }
+	end
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/.luacov	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,3 @@
+exclude = {
+	"^%./spec/";
+}
--- a/CHANGES	Wed Nov 28 16:55:27 2018 +0000
+++ b/CHANGES	Mon Jan 07 15:34:23 2019 +0000
@@ -1,3 +1,30 @@
+0.11.0
+======
+
+**2018-11-18**
+
+New features
+------------
+
+-   Rewritten more extensible MUC module
+    -   Store inactive rooms to disk
+    -   Store rooms to disk on shutdown
+    -   Voice requests
+    -   Tombstones in place of destroyed rooms
+-   PubSub features
+    -   Persistence
+    -   Affiliations
+    -   Access models
+    -   "publish-options"
+-   PEP now uses our pubsub code and now shares the above features
+-   Asynchronous operations
+-   Busted for tests
+-   mod\_muc\_mam (XEP-0313 in groupchats)
+-   mod\_vcard\_legacy (XEP-0398)
+-   mod\_vcard4 (XEP-0292)
+-   mod\_csi, mod\_csi\_simple (XEP-0352)
+-   New experimental network backend "epoll"
+
 0.10.0
 ======
 
--- a/DEPENDS	Wed Nov 28 16:55:27 2018 +0000
+++ b/DEPENDS	Mon Jan 07 15:34:23 2019 +0000
@@ -1,6 +1,6 @@
 
 For full information on our dependencies, version requirements, and
-where to find them, see http://prosody.im/doc/depends
+where to find them, see https://prosody.im/doc/depends
 
 If you have luarocks available on your platform, install the following:
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/GNUmakefile	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,112 @@
+
+include config.unix
+
+BIN = $(DESTDIR)$(PREFIX)/bin
+CONFIG = $(DESTDIR)$(SYSCONFDIR)
+MODULES = $(DESTDIR)$(LIBDIR)/prosody/modules
+SOURCE = $(DESTDIR)$(LIBDIR)/prosody
+DATA = $(DESTDIR)$(DATADIR)
+MAN = $(DESTDIR)$(PREFIX)/share/man
+
+INSTALLEDSOURCE = $(LIBDIR)/prosody
+INSTALLEDCONFIG = $(SYSCONFDIR)
+INSTALLEDMODULES = $(LIBDIR)/prosody/modules
+INSTALLEDDATA = $(DATADIR)
+
+INSTALL=install -p
+INSTALL_DATA=$(INSTALL) -m644
+INSTALL_EXEC=$(INSTALL) -m755
+MKDIR=install -d
+MKDIR_PRIVATE=$(MKDIR) -m750
+
+LUACHECK=luacheck
+BUSTED=busted
+
+.PHONY: all test coverage clean install
+
+all: prosody.install prosodyctl.install prosody.cfg.lua.install prosody.version
+	$(MAKE) -C util-src install
+ifeq ($(EXCERTS),yes)
+	-$(MAKE) -C certs localhost.crt example.com.crt
+endif
+
+install: prosody.install prosodyctl.install prosody.cfg.lua.install util/encodings.so util/encodings.so util/pposix.so util/signal.so
+	$(MKDIR) $(BIN) $(CONFIG) $(MODULES) $(SOURCE)
+	$(MKDIR_PRIVATE) $(DATA)
+	$(MKDIR) $(MAN)/man1
+	$(MKDIR) $(CONFIG)/certs
+	$(MKDIR) $(SOURCE)/core $(SOURCE)/net $(SOURCE)/util
+	$(INSTALL_EXEC) ./prosody.install $(BIN)/prosody
+	$(INSTALL_EXEC) ./prosodyctl.install $(BIN)/prosodyctl
+	$(INSTALL_DATA) core/*.lua $(SOURCE)/core
+	$(INSTALL_DATA) net/*.lua $(SOURCE)/net
+	$(MKDIR) $(SOURCE)/net/http $(SOURCE)/net/resolvers $(SOURCE)/net/websocket
+	$(INSTALL_DATA) net/http/*.lua $(SOURCE)/net/http
+	$(INSTALL_DATA) net/resolvers/*.lua $(SOURCE)/net/resolvers
+	$(INSTALL_DATA) net/websocket/*.lua $(SOURCE)/net/websocket
+	$(INSTALL_DATA) util/*.lua $(SOURCE)/util
+	$(INSTALL_DATA) util/*.so $(SOURCE)/util
+	$(MKDIR) $(SOURCE)/util/sasl
+	$(INSTALL_DATA) util/sasl/*.lua $(SOURCE)/util/sasl
+	$(MKDIR) $(MODULES)/mod_s2s $(MODULES)/mod_pubsub $(MODULES)/adhoc $(MODULES)/muc $(MODULES)/mod_mam
+	$(INSTALL_DATA) plugins/*.lua $(MODULES)
+	$(INSTALL_DATA) plugins/mod_s2s/*.lua $(MODULES)/mod_s2s
+	$(INSTALL_DATA) plugins/mod_pubsub/*.lua $(MODULES)/mod_pubsub
+	$(INSTALL_DATA) plugins/adhoc/*.lua $(MODULES)/adhoc
+	$(INSTALL_DATA) plugins/muc/*.lua $(MODULES)/muc
+	$(INSTALL_DATA) plugins/mod_mam/*.lua $(MODULES)/mod_mam
+	$(INSTALL_DATA) certs/* $(CONFIG)/certs
+	$(INSTALL_DATA) man/prosodyctl.man $(MAN)/man1/prosodyctl.1
+	test -f $(CONFIG)/prosody.cfg.lua || $(INSTALL_DATA) prosody.cfg.lua.install $(CONFIG)/prosody.cfg.lua
+	-test -f prosody.version && $(INSTALL_DATA) prosody.version $(SOURCE)/prosody.version
+	$(MAKE) install -C util-src
+
+clean:
+	rm -f prosody.install
+	rm -f prosodyctl.install
+	rm -f prosody.cfg.lua.install
+	rm -f prosody.version
+	$(MAKE) clean -C util-src
+
+test:
+	$(BUSTED) --lua=$(RUNWITH)
+
+coverage:
+	-rm -- luacov.*
+	$(BUSTED) --lua=$(RUNWITH) -c
+	luacov
+	luacov-console
+	luacov-console -s
+	@echo "To inspect individual files run: luacov-console -l FILENAME"
+
+lint:
+	$(LUACHECK) -q $$(HGPLAIN= hg files -I '**.lua') prosody prosodyctl
+	@echo $$(sed -n '/^\tlocal exclude_files/,/^}/p;' .luacheckrc | sed '1d;$d' | wc -l) files ignored
+	shellcheck configure
+
+util/%.so:
+	$(MAKE) install -C util-src
+
+%.install: %
+	sed "1s| lua$$| $(RUNWITH)|; \
+		s|^CFG_SOURCEDIR=.*;$$|CFG_SOURCEDIR='$(INSTALLEDSOURCE)';|; \
+		s|^CFG_CONFIGDIR=.*;$$|CFG_CONFIGDIR='$(INSTALLEDCONFIG)';|; \
+		s|^CFG_DATADIR=.*;$$|CFG_DATADIR='$(INSTALLEDDATA)';|; \
+		s|^CFG_PLUGINDIR=.*;$$|CFG_PLUGINDIR='$(INSTALLEDMODULES)/';|;" < $^ > $@
+
+prosody.cfg.lua.install: prosody.cfg.lua.dist
+	sed 's|certs/|$(INSTALLEDCONFIG)/certs/|' $^ > $@
+
+%.version: %.release
+	cp $^ $@
+
+%.version: .hg_archival.txt
+	sed -n 's/^node: \(............\).*/\1/p' $^ > $@
+
+%.version: .hg/dirstate
+	hexdump -n6 -e'6/1 "%02x"' $^ > $@
+
+%.version:
+	echo unknown > $@
+
+
--- a/HACKERS	Wed Nov 28 16:55:27 2018 +0000
+++ b/HACKERS	Mon Jan 07 15:34:23 2019 +0000
@@ -2,11 +2,11 @@
 
 This project accepts and *encourages* contributions. If you would like to get 
 involved you can join us on our mailing list and discussion rooms. More 
-information on these at http://prosody.im/discuss
+information on these at https://prosody.im/discuss
 
 Patches are welcome, though before sending we would appreciate if you read 
 docs/coding_style.txt for guidelines on how to format your code, and other tips.
 
-Documentation for developers can be found at http://prosody.im/doc/developers
+Documentation for developers can be found at https://prosody.im/doc/developers
 
 Have fun :)
--- a/INSTALL	Wed Nov 28 16:55:27 2018 +0000
+++ b/INSTALL	Mon Jan 07 15:34:23 2019 +0000
@@ -1,5 +1,5 @@
 (This file was created from
-http://prosody.im/doc/installing_from_source on 2013-03-31)
+https://prosody.im/doc/installing_from_source on 2013-03-31)
 
 ====== Installing from source ======
 ==== Dependencies ====
--- a/Makefile	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,96 +0,0 @@
-
-include config.unix
-
-BIN = $(DESTDIR)$(PREFIX)/bin
-CONFIG = $(DESTDIR)$(SYSCONFDIR)
-MODULES = $(DESTDIR)$(LIBDIR)/prosody/modules
-SOURCE = $(DESTDIR)$(LIBDIR)/prosody
-DATA = $(DESTDIR)$(DATADIR)
-MAN = $(DESTDIR)$(PREFIX)/share/man
-
-INSTALLEDSOURCE = $(LIBDIR)/prosody
-INSTALLEDCONFIG = $(SYSCONFDIR)
-INSTALLEDMODULES = $(LIBDIR)/prosody/modules
-INSTALLEDDATA = $(DATADIR)
-
-INSTALL=install -p
-INSTALL_DATA=$(INSTALL) -m644
-INSTALL_EXEC=$(INSTALL) -m755
-MKDIR=install -d
-MKDIR_PRIVATE=$(MKDIR) -m750
-
-.PHONY: all test clean install
-
-all: prosody.install prosodyctl.install prosody.cfg.lua.install prosody.version
-	$(MAKE) -C util-src install
-ifeq ($(EXCERTS),yes)
-	-$(MAKE) -C certs localhost.crt example.com.crt
-endif
-
-install: prosody.install prosodyctl.install prosody.cfg.lua.install util/encodings.so util/encodings.so util/pposix.so util/signal.so
-	$(MKDIR) $(BIN) $(CONFIG) $(MODULES) $(SOURCE)
-	$(MKDIR_PRIVATE) $(DATA)
-	$(MKDIR) $(MAN)/man1
-	$(MKDIR) $(CONFIG)/certs
-	$(MKDIR) $(SOURCE)/core $(SOURCE)/net $(SOURCE)/util
-	$(INSTALL_EXEC) ./prosody.install $(BIN)/prosody
-	$(INSTALL_EXEC) ./prosodyctl.install $(BIN)/prosodyctl
-	$(INSTALL_DATA) core/*.lua $(SOURCE)/core
-	$(INSTALL_DATA) net/*.lua $(SOURCE)/net
-	$(MKDIR) $(SOURCE)/net/http $(SOURCE)/net/websocket
-	$(INSTALL_DATA) net/http/*.lua $(SOURCE)/net/http
-	$(INSTALL_DATA) net/websocket/*.lua $(SOURCE)/net/websocket
-	$(INSTALL_DATA) util/*.lua $(SOURCE)/util
-	$(INSTALL_DATA) util/*.so $(SOURCE)/util
-	$(MKDIR) $(SOURCE)/util/sasl
-	$(INSTALL_DATA) util/sasl/*.lua $(SOURCE)/util/sasl
-	$(MKDIR) $(MODULES)/mod_s2s $(MODULES)/mod_pubsub $(MODULES)/adhoc $(MODULES)/muc $(MODULES)/mod_mam
-	$(INSTALL_DATA) plugins/*.lua $(MODULES)
-	$(INSTALL_DATA) plugins/mod_s2s/*.lua $(MODULES)/mod_s2s
-	$(INSTALL_DATA) plugins/mod_pubsub/*.lua $(MODULES)/mod_pubsub
-	$(INSTALL_DATA) plugins/adhoc/*.lua $(MODULES)/adhoc
-	$(INSTALL_DATA) plugins/muc/*.lua $(MODULES)/muc
-	$(INSTALL_DATA) plugins/mod_mam/*.lua $(MODULES)/mod_mam
-	$(INSTALL_DATA) certs/* $(CONFIG)/certs
-	$(INSTALL_DATA) man/prosodyctl.man $(MAN)/man1/prosodyctl.1
-	test -f $(CONFIG)/prosody.cfg.lua || $(INSTALL_DATA) prosody.cfg.lua.install $(CONFIG)/prosody.cfg.lua
-	-test -f prosody.version && $(INSTALL_DATA) prosody.version $(SOURCE)/prosody.version
-	$(MAKE) install -C util-src
-
-clean:
-	rm -f prosody.install
-	rm -f prosodyctl.install
-	rm -f prosody.cfg.lua.install
-	rm -f prosody.version
-	$(MAKE) clean -C util-src
-
-test:
-	cd tests && $(RUNWITH) test.lua 0
-	# Skipping: cd tests && RUNWITH=$(RUNWITH) ./test_util_json.sh
-
-util/%.so:
-	$(MAKE) install -C util-src
-
-%.install: %
-	sed "1s| lua$$| $(RUNWITH)|; \
-		s|^CFG_SOURCEDIR=.*;$$|CFG_SOURCEDIR='$(INSTALLEDSOURCE)';|; \
-		s|^CFG_CONFIGDIR=.*;$$|CFG_CONFIGDIR='$(INSTALLEDCONFIG)';|; \
-		s|^CFG_DATADIR=.*;$$|CFG_DATADIR='$(INSTALLEDDATA)';|; \
-		s|^CFG_PLUGINDIR=.*;$$|CFG_PLUGINDIR='$(INSTALLEDMODULES)/';|;" < $^ > $@
-
-prosody.cfg.lua.install: prosody.cfg.lua.dist
-	sed 's|certs/|$(INSTALLEDCONFIG)/certs/|' $^ > $@
-
-%.version: %.release
-	cp $^ $@
-
-%.version: .hg_archival.txt
-	sed -n 's/^node: \(............\).*/\1/p' $^ > $@
-
-%.version: .hg/dirstate
-	hexdump -n6 -e'6/1 "%02x"' $^ > $@
-
-%.version:
-	echo unknown > $@
-
-
--- a/README	Wed Nov 28 16:55:27 2018 +0000
+++ b/README	Mon Jan 07 15:34:23 2019 +0000
@@ -9,29 +9,29 @@
 
 ## Useful links
 
-Homepage:        http://prosody.im/
-Download:        http://prosody.im/download
-Documentation:   http://prosody.im/doc/
+Homepage:        https://prosody.im/
+Download:        https://prosody.im/download
+Documentation:   https://prosody.im/doc/
 
 Jabber/XMPP Chat:
                Address:
                  prosody@conference.prosody.im
                Web interface:
-                 http://prosody.im/webchat
+                 https://prosody.im/webchat
                
 Mailing lists:
                User support and discussion:
-                 http://groups.google.com/group/prosody-users
+                 https://groups.google.com/group/prosody-users
                
                Development discussion:
-                 http://groups.google.com/group/prosody-dev
+                 https://groups.google.com/group/prosody-dev
                
                Issue tracker changes:
-                 http://groups.google.com/group/prosody-issues
+                 https://groups.google.com/group/prosody-issues
 
 ## Installation
 
 See the accompanying INSTALL file for help on building Prosody from source. Alternatively 
-see our guide at http://prosody.im/doc/install
+see our guide at https://prosody.im/doc/install
 
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/certs/GNUmakefile	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,66 @@
+.DEFAULT: localhost.crt
+keysize=2048
+
+# How to:
+# First, `make yourhost.cnf` which creates a openssl config file.
+# Then edit this file and fill in the details you want it to have,
+# and add or change hosts and components it should cover.
+# Then `make yourhost.key` to create your private key, you can
+# include keysize=number to change the size of the key.
+# Then you can either `make yourhost.csr` to generate a certificate
+# signing request that you can submit to a CA, or `make yourhost.crt`
+# to generate a self signed certificate.
+
+.PRECIOUS: %.cnf %.key
+
+# To request a cert
+%.csr: %.cnf %.key
+	openssl req -new -key $(lastword $^) \
+		-sha256 -utf8 -config $(firstword $^) -out $@
+
+%.csr: %.cnf
+	umask 0077 && touch $*.key
+	openssl req -new -newkey rsa:$(keysize) -nodes -keyout $*.key \
+		-sha256 -utf8 -config $^ -out $@
+	@chmod 400 $*.key
+
+%.csr: %.key
+	openssl req -new -key $^ -utf8 -subj /CN=$* -out $@
+
+%.csr:
+	umask 0077 && touch $*.key
+	openssl req -new -newkey rsa:$(keysize) -nodes -keyout $*.key \
+		-utf8 -subj /CN=$* -out $@
+	@chmod 400 $*.key
+
+# Self signed
+%.crt: %.cnf %.key
+	openssl req -new -x509 -key $(lastword $^) -days 365 -sha256 -utf8 \
+		-config $(firstword $^) -out $@
+
+%.crt: %.cnf
+	umask 0077 && touch $*.key
+	openssl req -new -x509 -newkey rsa:$(keysize) -nodes -keyout $*.key \
+		-days 365 -sha256 -utf8 -config $(firstword $^) -out $@
+	@chmod 400 $*.key
+
+%.crt: %.key
+	openssl req -new -x509 -key $^ -days 365 -sha256 -utf8 -subj /CN=$* -out $@
+
+%.crt:
+	umask 0077 && touch $*.key
+	openssl req -new -x509 -newkey rsa:$(keysize) -nodes -keyout $*.key \
+		-days 365 -sha256 -out $@ -utf8 -subj /CN=$*
+	@chmod 400 $*.key
+
+# Generate a config from the example
+%.cnf:
+	sed 's,example\.com,$*,g' openssl.cnf > $@
+
+%.key:
+	umask 0077 && openssl genrsa -out $@ $(keysize)
+	@chmod 400 $@
+
+# Generate Diffie-Hellman parameters
+dh-%.pem:
+	openssl dhparam -out $@ $*
--- a/certs/Makefile	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,66 +0,0 @@
-.DEFAULT: localhost.crt
-keysize=2048
-
-# How to:
-# First, `make yourhost.cnf` which creates a openssl config file.
-# Then edit this file and fill in the details you want it to have,
-# and add or change hosts and components it should cover.
-# Then `make yourhost.key` to create your private key, you can
-# include keysize=number to change the size of the key.
-# Then you can either `make yourhost.csr` to generate a certificate
-# signing request that you can submit to a CA, or `make yourhost.crt`
-# to generate a self signed certificate.
-
-.PRECIOUS: %.cnf %.key
-
-# To request a cert
-%.csr: %.cnf %.key
-	openssl req -new -key $(lastword $^) \
-		-sha256 -utf8 -config $(firstword $^) -out $@
-
-%.csr: %.cnf
-	umask 0077 && touch $*.key
-	openssl req -new -newkey rsa:$(keysize) -nodes -keyout $*.key \
-		-sha256 -utf8 -config $^ -out $@
-	@chmod 400 $*.key
-
-%.csr: %.key
-	openssl req -new -key $^ -utf8 -subj /CN=$* -out $@
-
-%.csr:
-	umask 0077 && touch $*.key
-	openssl req -new -newkey rsa:$(keysize) -nodes -keyout $*.key \
-		-utf8 -subj /CN=$* -out $@
-	@chmod 400 $*.key
-
-# Self signed
-%.crt: %.cnf %.key
-	openssl req -new -x509 -key $(lastword $^) -days 365 -sha256 -utf8 \
-		-config $(firstword $^) -out $@
-
-%.crt: %.cnf
-	umask 0077 && touch $*.key
-	openssl req -new -x509 -newkey rsa:$(keysize) -nodes -keyout $*.key \
-		-days 365 -sha256 -utf8 -config $(firstword $^) -out $@
-	@chmod 400 $*.key
-
-%.crt: %.key
-	openssl req -new -x509 -key $^ -days 365 -sha256 -utf8 -subj /CN=$* -out $@
-
-%.crt:
-	umask 0077 && touch $*.key
-	openssl req -new -x509 -newkey rsa:$(keysize) -nodes -keyout $*.key \
-		-days 365 -sha256 -out $@ -utf8 -subj /CN=$*
-	@chmod 400 $*.key
-
-# Generate a config from the example
-%.cnf:
-	sed 's,example\.com,$*,g' openssl.cnf > $@
-
-%.key:
-	umask 0077 && openssl genrsa -out $@ $(keysize)
-	@chmod 400 $@
-
-# Generate Diffie-Hellman parameters
-dh-%.pem:
-	openssl dhparam -out $@ $*
--- a/certs/localhost.cnf	Wed Nov 28 16:55:27 2018 +0000
+++ b/certs/localhost.cnf	Mon Jan 07 15:34:23 2019 +0000
@@ -11,7 +11,7 @@
 [distinguished_name]
 countryName = GB
 organizationName = Prosody IM
-organizationalUnitName = http://prosody.im/doc/certificates
+organizationalUnitName = https://prosody.im/doc/certificates
 commonName = Example certificate
 
 [req]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/certs/makefile	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,18 @@
+.DEFAULT: localhost.crt
+keysize=2048
+
+# How to:
+# First, `make yourhost.cnf` which creates a openssl config file.
+# Then edit this file and fill in the details you want it to have,
+# and add or change hosts and components it should cover.
+# Then `make yourhost.key` to create your private key, you can
+# include keysize=number to change the size of the key.
+# Then you can either `make yourhost.csr` to generate a certificate
+# signing request that you can submit to a CA, or `make yourhost.crt`
+# to generate a self signed certificate.
+
+${.TARGETS:M*.crt}: 
+	openssl req -new -x509 -newkey rsa:$(keysize) -nodes -keyout ${.TARGET:R}.key \
+		-days 365 -sha256 -out $@ -utf8 -subj /CN=${.TARGET:R}
+
+.SUFFIXES: .key .crt
--- a/configure	Wed Nov 28 16:55:27 2018 +0000
+++ b/configure	Mon Jan 07 15:34:23 2019 +0000
@@ -92,7 +92,7 @@
 # Helper functions
 
 find_program() {
-   prog=`command -v "$1" 2>/dev/null`
+   prog=$(command -v "$1" 2>/dev/null)
    if [ -n "$prog" ]
    then
       dirname "$prog"
@@ -107,26 +107,8 @@
    exit 1
 }
 
-find_helper() {
-   explanation="$1"
-   shift
-   tried="$*"
-   while [ -n "$1" ]
-do
-      found=`find_program "$1"`
-      if [ -n "$found" ]
-      then
-         echo "$1 found at $found"
-         HELPER=$1
-         return
-      fi
-      shift
-   done
-   echo "Could not find $explanation. Tried: $tried."
-   die "Make sure one of them is installed and available in your PATH."
-}
-
-case `echo -n x` in
+# shellcheck disable=SC2039
+case $(echo -n x) in
 -n*) echo_n_flag='';;
 *)   echo_n_flag='-n';;
 esac
@@ -143,12 +125,14 @@
 
 while [ -n "$1" ]
 do
-   value="`echo $1 | sed 's/[^=]*.\(.*\)/\1/'`"
-   key="`echo $1 | sed 's/=.*//'`"
-   if `echo "$value" | grep "~" >/dev/null 2>/dev/null`
+   value=$(echo "$1" | sed 's/[^=]*.\(.*\)/\1/')
+   key=$(echo "$1" | sed 's/=.*//')
+   # shellcheck disable=SC2088
+   if echo "$value" | grep "~" >/dev/null 2>/dev/null
    then
       echo
       echo '*WARNING*: the "~" sign is not expanded in flags.'
+      # shellcheck disable=SC2016
       echo 'If you mean the home directory, use $HOME instead.'
       echo
    fi
@@ -169,9 +153,8 @@
       ;;
    --ostype)
 	# TODO make this a switch?
-      OSTYPE="$value"
-      OSTYPE_SET=yes
-      if [ "$OSTYPE" = "debian" ]; then
+      OSPRESET="$value"
+      if [ "$OSPRESET" = "debian" ]; then
          if [ "$LUA_SUFFIX_SET" != "yes" ]; then
             LUA_SUFFIX="5.1";
             LUA_SUFFIX_SET=yes
@@ -184,7 +167,7 @@
          LUA_INCDIR_SET=yes
          CFLAGS="$CFLAGS -ggdb"
       fi
-      if [ "$OSTYPE" = "macosx" ]; then
+      if [ "$OSPRESET" = "macosx" ]; then
          LUA_INCDIR=/usr/local/include;
          LUA_INCDIR_SET=yes
          LUA_LIBDIR=/usr/local/lib
@@ -192,14 +175,14 @@
          CFLAGS="$CFLAGS -mmacosx-version-min=10.3"
          LDFLAGS="-bundle -undefined dynamic_lookup"
       fi
-      if [ "$OSTYPE" = "linux" ]; then
+      if [ "$OSPRESET" = "linux" ]; then
          LUA_INCDIR=/usr/local/include;
          LUA_INCDIR_SET=yes
          LUA_LIBDIR=/usr/local/lib
          LUA_LIBDIR_SET=yes
          CFLAGS="$CFLAGS -ggdb"
       fi
-      if [ "$OSTYPE" = "freebsd" -o "$OSTYPE" = "openbsd" ]; then
+      if [ "$OSPRESET" = "freebsd" ] || [ "$OSPRESET" = "openbsd" ]; then
          LUA_INCDIR="/usr/local/include/lua51"
          LUA_INCDIR_SET=yes
          CFLAGS="-Wall -fPIC -I/usr/local/include"
@@ -211,11 +194,11 @@
          CC=cc
          LD=ld
       fi
-      if [ "$OSTYPE" = "openbsd" ]; then
+      if [ "$OSPRESET" = "openbsd" ]; then
          LUA_INCDIR="/usr/local/include";
          LUA_INCDIR_SET="yes"
       fi
-      if [ "$OSTYPE" = "netbsd" ]; then
+      if [ "$OSPRESET" = "netbsd" ]; then
          LUA_INCDIR="/usr/pkg/include/lua-5.1"
          LUA_INCDIR_SET=yes
          LUA_LIBDIR="/usr/pkg/lib/lua/5.1"
@@ -223,7 +206,7 @@
          CFLAGS="-Wall -fPIC -I/usr/pkg/include"
          LDFLAGS="-L/usr/pkg/lib -Wl,-rpath,/usr/pkg/lib -shared"
       fi
-      if [ "$OSTYPE" = "pkg-config" ]; then
+      if [ "$OSPRESET" = "pkg-config" ]; then
          if [ "$LUA_SUFFIX_SET" != "yes" ]; then
             LUA_SUFFIX="5.1";
             LUA_SUFFIX_SET=yes
@@ -254,7 +237,7 @@
    --lua-version|--with-lua-version)
       [ -n "$value" ] || die "Missing value in flag $key."
       LUA_VERSION="$value"
-      [ "$LUA_VERSION" = "5.1" -o "$LUA_VERSION" = "5.2" -o "$LUA_VERSION" = "5.3" ] || die "Invalid Lua version in flag $key."
+      [ "$LUA_VERSION" = "5.1" ] || [ "$LUA_VERSION" = "5.2" ] || [ "$LUA_VERSION" = "5.3" ] || die "Invalid Lua version in flag $key."
       LUA_VERSION_SET=yes
       ;;
    --with-lua)
@@ -335,7 +318,7 @@
    shift
 done
 
-if [ "$PREFIX_SET" = "yes" -a ! "$SYSCONFDIR_SET" = "yes" ]
+if [ "$PREFIX_SET" = "yes" ] && [ ! "$SYSCONFDIR_SET" = "yes" ]
 then
    if [ "$PREFIX" = "/usr" ]
    then SYSCONFDIR=/etc/$APP_DIRNAME
@@ -343,7 +326,7 @@
    fi
 fi
 
-if [ "$PREFIX_SET" = "yes" -a ! "$DATADIR_SET" = "yes" ]
+if [ "$PREFIX_SET" = "yes" ] && [ ! "$DATADIR_SET" = "yes" ]
 then
    if [ "$PREFIX" = "/usr" ]
    then DATADIR=/var/lib/$APP_DIRNAME
@@ -351,13 +334,13 @@
    fi
 fi
 
-if [ "$PREFIX_SET" = "yes" -a ! "$LIBDIR_SET" = "yes" ]
+if [ "$PREFIX_SET" = "yes" ] && [ ! "$LIBDIR_SET" = "yes" ]
 then
    LIBDIR=$PREFIX/lib
 fi
 
 detect_lua_version() {
-   detected_lua=`$1 -e 'print(_VERSION:match(" (5%.[123])$"))' 2> /dev/null`
+   detected_lua=$("$1" -e 'print(_VERSION:match(" (5%.[123])$"))' 2> /dev/null)
    if [ "$detected_lua" != "nil" ]
    then
       if [ "$LUA_VERSION_SET" != "yes" ]
@@ -386,9 +369,9 @@
          find_lua="$LUA_BINDIR"
       fi
    else
-      find_lua=`find_program lua$suffix`
+      find_lua=$(find_program lua"$suffix")
    fi
-   if [ -n "$find_lua" -a -x "$find_lua/lua$suffix" ]
+   if [ -n "$find_lua" ] && [ -x "$find_lua/lua$suffix" ]
    then
       if detect_lua_version "$find_lua/lua$suffix"
       then
@@ -399,7 +382,7 @@
          fi
          if [ "$LUA_DIR_SET" != "yes" ]
          then
-            LUA_DIR=`dirname "$find_lua"`
+            LUA_DIR=$(dirname "$find_lua")
          fi
          LUA_SUFFIX="$suffix"
          return 0
@@ -411,19 +394,19 @@
 lua_interp_found=no
 if [ "$LUA_SUFFIX_SET" != "yes" ]
 then
-   if [ "$LUA_VERSION_SET" = "yes" -a "$LUA_VERSION" = "5.1" ]
+   if [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.1" ]
    then
       suffixes="5.1 51 -5.1 -51"
-   elif [ "$LUA_VERSION_SET" = "yes" -a "$LUA_VERSION" = "5.2" ]
+   elif [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.2" ]
    then
       suffixes="5.2 52 -5.2 -52"
-   elif [ "$LUA_VERSION_SET" = "yes" -a "$LUA_VERSION" = "5.3" ]
+   elif [ "$LUA_VERSION_SET" = "yes" ] && [ "$LUA_VERSION" = "5.3" ]
    then
       suffixes="5.3 53 -5.3 -53"
    else
       suffixes="5.1 51 -5.1 -51 5.2 52 -5.2 -52 5.3 53 -5.3 -53"
    fi
-   for suffix in "" `echo $suffixes`
+   for suffix in "" $suffixes
    do
       search_interpreter "$suffix" && {
       lua_interp_found=yes
@@ -436,15 +419,23 @@
 }
 fi
 
-if [ "$lua_interp_found" != "yes" -a "$RUNWITH_SET" != "yes" ]
+if [ "$lua_interp_found" != "yes" ] && [ "$RUNWITH_SET" != "yes" ]
 then
-   [ "$LUA_VERSION_SET" ] && { interp="Lua $LUA_VERSION" ;} || { interp="Lua" ;}
-   [ "$LUA_DIR_SET" -o "$LUA_BINDIR_SET" ] && { where="$LUA_BINDIR" ;} || { where="\$PATH" ;}
+   if [ "$LUA_VERSION_SET" ]; then
+      interp="Lua $LUA_VERSION";
+   else
+      interp="Lua";
+   fi
+   if [ "$LUA_DIR_SET" ] || [ "$LUA_BINDIR_SET" ]; then
+      where="$LUA_BINDIR";
+   else
+      where="\$PATH";
+   fi
    echo "$interp interpreter not found in $where"
    die "You may want to use the flags --with-lua, --with-lua-bin and/or --lua-suffix. See --help."
 fi
 
-if [ "$LUA_VERSION_SET" = "yes" -a "$RUNWITH_SET" != "yes" ]
+if [ "$LUA_VERSION_SET" = "yes" ] && [ "$RUNWITH_SET" != "yes" ]
 then
    echo_n "Checking if $LUA_BINDIR/lua$LUA_SUFFIX is Lua version $LUA_VERSION... "
    if detect_lua_version "$LUA_BINDIR/lua$LUA_SUFFIX"
@@ -528,6 +519,8 @@
 
 if [ "$PRNG" = "OPENSSL" ]; then
    PRNGLIBS=$OPENSSL_LIBS
+elif [ "$PRNG" = "ARC4RANDOM" ] && [ "$(uname)" = "Linux" ]; then
+   PRNGLIBS="-lbsd"
 fi
 
 # Write config
--- a/core/certmanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/certmanager.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -55,6 +55,7 @@
 };
 
 local _ENV = nil;
+-- luacheck: std none
 
 -- Global SSL options if not overridden per-host
 local global_ssl_config = configmanager.get("*", "ssl");
--- a/core/configmanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/configmanager.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -7,12 +7,10 @@
 --
 
 local _G = _G;
-local setmetatable, rawget, rawset, io, error, dofile, type, pairs, table =
-      setmetatable, rawget, rawset, io, error, dofile, type, pairs, table;
+local setmetatable, rawget, rawset, io, os, error, dofile, type, pairs =
+      setmetatable, rawget, rawset, io, os, error, dofile, type, pairs;
 local format, math_max = string.format, math.max;
 
-local fire_event = prosody and prosody.events.fire_event or function () end;
-
 local envload = require"util.envload".envload;
 local deps = require"util.dependencies";
 local resolve_relative_path = require"util.paths".resolve_relative_path;
@@ -24,10 +22,11 @@
 
 local _M = {};
 local _ENV = nil;
+-- luacheck: std none
 
 _M.resolve_relative_path = resolve_relative_path; -- COMPAT
 
-local parsers = {};
+local parser = nil;
 
 local config_mt = { __index = function (t, _) return rawget(t, "*"); end};
 local config = setmetatable({ ["*"] = { } }, config_mt);
@@ -77,19 +76,14 @@
 function _M.load(filename, config_format)
 	config_format = config_format or filename:match("%w+$");
 
-	if parsers[config_format] and parsers[config_format].load then
+	if config_format == "lua" then
 		local f, err = io.open(filename);
 		if f then
 			local new_config = setmetatable({ ["*"] = { } }, config_mt);
-			local ok, err = parsers[config_format].load(f:read("*a"), filename, new_config);
+			local ok, err = parser.load(f:read("*a"), filename, new_config);
 			f:close();
 			if ok then
 				config = new_config;
-				fire_event("config-reloaded", {
-					filename = filename,
-					format = config_format,
-					config = config
-				});
 			end
 			return ok, "parser", err;
 		end
@@ -103,26 +97,11 @@
 	end
 end
 
-function _M.addparser(config_format, parser)
-	if config_format and parser then
-		parsers[config_format] = parser;
-	end
-end
-
--- _M needed to avoid name clash with local 'parsers'
-function _M.parsers()
-	local p = {};
-	for config_format in pairs(parsers) do
-		table.insert(p, config_format);
-	end
-	return p;
-end
-
 -- Built-in Lua parser
 do
 	local pcall = _G.pcall;
-	parsers.lua = {};
-	function parsers.lua.load(data, config_file, config_table)
+	parser = {};
+	function parser.load(data, config_file, config_table)
 		local env;
 		-- The ' = true' are needed so as not to set off __newindex when we assign the functions below
 		env = setmetatable({
@@ -130,6 +109,9 @@
 			Component = true, component = true,
 			Include = true, include = true, RunScript = true }, {
 				__index = function (_, k)
+					if k:match("^ENV_") then
+						return os.getenv(k:sub(5));
+					end
 					return rawget(_G, k);
 				end,
 				__newindex = function (_, k, v)
@@ -211,7 +193,7 @@
 			file = resolve_relative_path(config_file:gsub("[^"..path_sep.."]+$", ""), file);
 			local f, err = io.open(file);
 			if f then
-				local ret, err = parsers.lua.load(f:read("*a"), file, config_table);
+				local ret, err = parser.load(f:read("*a"), file, config_table);
 				if not ret then error(err:gsub("%[string.-%]", file), 0); end
 			end
 			if not f then error("Error loading included "..file..": "..err, 0); end
--- a/core/hostmanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/hostmanager.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -12,8 +12,6 @@
 local disco_items = require "util.multitable".new();
 local NULL = {};
 
-local jid_split = require "util.jid".split;
-
 local log = require "util.logger".init("hostmanager");
 
 local hosts = prosody.hosts;
@@ -24,11 +22,12 @@
 local incoming_s2s = _G.prosody.incoming_s2s;
 local core_route_stanza = _G.prosody.core_route_stanza;
 
-local pairs, select, rawget = pairs, select, rawget;
+local pairs, rawget = pairs, rawget;
 local tostring, type = tostring, type;
 local setmetatable = setmetatable;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local host_mt = { }
 function host_mt:__tostring()
@@ -71,13 +70,6 @@
 prosody_events.add_handler("server-starting", load_enabled_hosts);
 
 local function host_send(stanza)
-	local name, stanza_type = stanza.name, stanza.attr.type;
-	if stanza_type == "error" or (name == "iq" and stanza_type == "result") then
-		local dest_host_name = select(2, jid_split(stanza.attr.to));
-		local dest_host = hosts[dest_host_name] or { type = "unknown" };
-		log("warn", "Unhandled response sent to %s host %s: %s", dest_host.type, dest_host_name, tostring(stanza));
-		return;
-	end
 	core_route_stanza(nil, stanza);
 end
 
--- a/core/loggingmanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/loggingmanager.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -5,7 +5,6 @@
 -- This project is MIT/X11 licensed. Please see the
 -- COPYING file in the source package for more information.
 --
--- luacheck: globals log prosody.log
 
 local format = require "util.format".format;
 local setmetatable, rawset, pairs, ipairs, type =
@@ -18,12 +17,9 @@
 
 local config = require "core.configmanager";
 local logger = require "util.logger";
-local prosody = prosody;
-
-_G.log = logger.init("general");
-prosody.log = logger.init("general");
 
 local _ENV = nil;
+-- luacheck: std none
 
 -- The log config used if none specified in the config file (see reload_logging for initialization)
 local default_logging;
@@ -154,13 +150,8 @@
 	for name, sink_maker in pairs(old_sink_types) do
 		log_sink_types[name] = sink_maker;
 	end
-
-	prosody.events.fire_event("logging-reloaded");
 end
 
-reload_logging();
-prosody.events.add_handler("config-reloaded", reload_logging);
-
 --- Definition of built-in logging sinks ---
 
 -- Null sink, must enter log_sink_types *first*
--- a/core/moduleapi.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/moduleapi.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -6,7 +6,6 @@
 -- COPYING file in the source package for more information.
 --
 
-local config = require "core.configmanager";
 local array = require "util.array";
 local set = require "util.set";
 local it = require "util.iterators";
@@ -14,15 +13,15 @@
 local pluginloader = require "util.pluginloader";
 local timer = require "util.timer";
 local resolve_relative_path = require"util.paths".resolve_relative_path;
-local measure = require "core.statsmanager".measure;
 local st = require "util.stanza";
 
 local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat;
 local error, setmetatable, type = error, setmetatable, type;
 local ipairs, pairs, select = ipairs, pairs, select;
-local unpack = table.unpack or unpack; --luacheck: ignore 113
 local tonumber, tostring = tonumber, tostring;
 local require = require;
+local pack = table.pack or function(...) return {n=select("#",...), ...}; end -- table.pack is only in 5.2
+local unpack = table.unpack or unpack; --luacheck: ignore 113 -- renamed in 5.2
 
 local prosody = prosody;
 local hosts = prosody.hosts;
@@ -70,20 +69,6 @@
 function api:add_extension(data)
 	self:add_item("extension", data);
 end
-function api:has_feature(xmlns)
-	for _, feature in ipairs(self:get_host_items("feature")) do
-		if feature == xmlns then return true; end
-	end
-	return false;
-end
-function api:has_identity(category, identity_type, name)
-	for _, id in ipairs(self:get_host_items("identity")) do
-		if id.category == category and id.type == identity_type and id.name == name then
-			return true;
-		end
-	end
-	return false;
-end
 
 function api:fire_event(...)
 	return (hosts[self.host] or prosody).events.fire_event(...);
@@ -144,6 +129,9 @@
 
 function api:depends(name)
 	local modulemanager = require"core.modulemanager";
+	if self:get_option_inherited_set("modules_disabled", {}):contains(name) then
+		error("Dependency on disabled module mod_"..name);
+	end
 	if not self.dependencies then
 		self.dependencies = {};
 		self:hook("module-reloaded", function (event)
@@ -176,36 +164,36 @@
 	return mod;
 end
 
--- Returns one or more shared tables at the specified virtual paths
--- Intentionally does not allow the table at a path to be _set_, it
+local function get_shared_table_from_path(module, tables, path)
+	if path:sub(1,1) ~= "/" then -- Prepend default components
+		local default_path_components = { module.host, module.name };
+		local n_components = select(2, path:gsub("/", "%1"));
+		path = (n_components<#default_path_components and "/" or "")
+			..t_concat(default_path_components, "/", 1, #default_path_components-n_components).."/"..path;
+	end
+	local shared = tables[path];
+	if not shared then
+		shared = {};
+		if path:match("%-cache$") then
+			setmetatable(shared, { __mode = "kv" });
+		end
+		tables[path] = shared;
+	end
+	return shared;
+end
+
+-- Returns a shared table at the specified virtual path
+-- Intentionally does not allow the table to be _set_, it
 -- is auto-created if it does not exist.
-function api:shared(...)
+function api:shared(path)
 	if not self.shared_data then self.shared_data = {}; end
-	local paths = { n = select("#", ...), ... };
-	local data_array = {};
-	local default_path_components = { self.host, self.name };
-	for i = 1, paths.n do
-		local path = paths[i];
-		if path:sub(1,1) ~= "/" then -- Prepend default components
-			local n_components = select(2, path:gsub("/", "%1"));
-			path = (n_components<#default_path_components and "/" or "")
-				..t_concat(default_path_components, "/", 1, #default_path_components-n_components).."/"..path;
-		end
-		local shared = shared_data[path];
-		if not shared then
-			shared = {};
-			if path:match("%-cache$") then
-				setmetatable(shared, { __mode = "kv" });
-			end
-			shared_data[path] = shared;
-		end
-		t_insert(data_array, shared);
-		self.shared_data[path] = shared;
-	end
-	return unpack(data_array);
+	local shared = get_shared_table_from_path(self, shared_data, path);
+	self.shared_data[path] = shared;
+	return shared;
 end
 
 function api:get_option(name, default_value)
+	local config = require "core.configmanager";
 	local value = config.get(self.host, name);
 	if value == nil then
 		value = default_value;
@@ -299,7 +287,7 @@
 
 function api:get_option_path(name, default, parent)
 	if parent == nil then
-		parent = parent or self:get_directory();
+		parent = self:get_directory();
 	elseif prosody.paths[parent] then
 		parent = prosody.paths[parent];
 	end
@@ -377,15 +365,33 @@
 	for jid in (iter or it.values)(jids) do
 		local new_stanza = st.clone(stanza);
 		new_stanza.attr.to = jid;
-		core_post_stanza(hosts[self.host], new_stanza);
+		self:send(new_stanza);
 	end
 end
 
-function api:add_timer(delay, callback)
-	return timer.add_task(delay, function (t)
-		if self.loaded == false then return; end
-		return callback(t);
-	end);
+local timer_methods = { }
+local timer_mt = {
+	__index = timer_methods;
+}
+function timer_methods:stop( )
+	timer.stop(self.id);
+end
+timer_methods.disarm = timer_methods.stop
+function timer_methods:reschedule(delay)
+	timer.reschedule(self.id, delay)
+end
+
+local function timer_callback(now, id, t) --luacheck: ignore 212/id
+	if t.module_env.loaded == false then return; end
+	return t.callback(now, unpack(t, 1, t.n));
+end
+
+function api:add_timer(delay, callback, ...)
+	local t = pack(...)
+	t.module_env = self;
+	t.callback = callback;
+	t.id = timer.add_task(delay, timer_callback, t);
+	return setmetatable(t, timer_mt);
 end
 
 local path_sep = package.config:sub(1,1);
@@ -403,6 +409,7 @@
 end
 
 function api:measure(name, stat_type)
+	local measure = require "core.statsmanager".measure;
 	return measure(stat_type, "/"..self.host.."/mod_"..self.name.."/"..name);
 end
 
--- a/core/modulemanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/modulemanager.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -15,21 +15,13 @@
 local new_multitable = require "util.multitable".new;
 local api = require "core.moduleapi"; -- Module API container
 
-local hosts = hosts;
 local prosody = prosody;
+local hosts = prosody.hosts;
 
-local xpcall = xpcall;
-local setmetatable, rawget = setmetatable, rawget;
-local ipairs, pairs, type, tostring, t_insert = ipairs, pairs, type, tostring, table.insert;
-
+local xpcall = require "util.xpcall".xpcall;
 local debug_traceback = debug.traceback;
-local select = select;
-local unpack = table.unpack or unpack; --luacheck: ignore 113
-local pcall = function(f, ...)
-	local n = select("#", ...);
-	local params = {...};
-	return xpcall(function() return f(unpack(params, 1, n)) end, function(e) return tostring(e).."\n"..debug_traceback(); end);
-end
+local setmetatable, rawget = setmetatable, rawget;
+local ipairs, pairs, type, t_insert = ipairs, pairs, type, table.insert;
 
 local autoload_modules = {prosody.platform, "presence", "message", "iq", "offline", "c2s", "s2s", "s2s_auth_certs"};
 local component_inheritable_modules = {"tls", "saslauth", "dialback", "iq", "s2s"};
@@ -38,6 +30,7 @@
 local _G = _G;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local load_modules_for_host, load, unload, reload, get_module, get_items;
 local get_modules, is_loaded, module_has_method, call_module_method;
@@ -45,8 +38,8 @@
 -- [host] = { [module] = module_env }
 local modulemap = { ["*"] = {} };
 
--- Load modules when a host is activated
-function load_modules_for_host(host)
+-- Get the list of modules to be loaded on a host
+local function get_modules_for_host(host)
 	local component = config.get(host, "component_module");
 
 	local global_modules_enabled = config.get("*", "modules_enabled");
@@ -70,8 +63,16 @@
 		modules:add("admin_telnet");
 	end
 
-	if component then
-		load(host, component);
+	return modules, component;
+end
+
+-- Load modules when a host is activated
+function load_modules_for_host(host)
+	local modules, component_module = get_modules_for_host(host);
+
+	-- Ensure component module is loaded first
+	if component_module then
+		load(host, component_module);
 	end
 	for module in modules do
 		load(host, module);
@@ -174,7 +175,7 @@
 	api_instance.path = err;
 
 	modulemap[host][module_name] = pluginenv;
-	local ok, err = pcall(mod);
+	local ok, err = xpcall(mod, debug_traceback);
 	if ok then
 		-- Call module's "load"
 		if module_has_method(pluginenv, "load") then
@@ -316,13 +317,14 @@
 function call_module_method(module, method, ...)
 	local f = rawget(module.module, method);
 	if type(f) == "function" then
-		return pcall(f, ...);
+		return xpcall(f, debug_traceback, ...);
 	else
 		return false, "no-such-method";
 	end
 end
 
 return {
+	get_modules_for_host = get_modules_for_host;
 	load_modules_for_host = load_modules_for_host;
 	load = load;
 	unload = unload;
--- a/core/portmanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/portmanager.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -15,6 +15,7 @@
 local fire_event = prosody.events.fire_event;
 
 local _ENV = nil;
+-- luacheck: std none
 
 --- Config
 
--- a/core/rostermanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/rostermanager.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -11,11 +11,13 @@
 
 local log = require "util.logger".init("rostermanager");
 
+local new_id = require "util.id".short;
+
 local pairs = pairs;
 local tostring = tostring;
 local type = type;
 
-local hosts = hosts;
+local hosts = prosody.hosts;
 local bare_sessions = prosody.bare_sessions;
 
 local um_user_exists = require "core.usermanager".user_exists;
@@ -23,6 +25,7 @@
 local storagemanager = require "core.storagemanager";
 
 local _ENV = nil;
+-- luacheck: std none
 
 local save_roster; -- forward declaration
 
@@ -60,7 +63,7 @@
 	local roster = jid and hosts[host] and hosts[host].sessions[username] and hosts[host].sessions[username].roster;
 	if roster then
 		local item = hosts[host].sessions[username].roster[jid];
-		local stanza = st.iq({type="set"});
+		local stanza = st.iq({type="set", id=new_id()});
 		stanza:tag("query", {xmlns = "jabber:iq:roster", ver = tostring(roster[false].version or "1")  });
 		if item then
 			stanza:tag("item", {jid = jid, subscription = item.subscription, name = item.name, ask = item.ask});
--- a/core/s2smanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/s2smanager.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -17,12 +17,13 @@
 local log = logger_init("s2smanager");
 
 local prosody = _G.prosody;
-incoming_s2s = {};
+local incoming_s2s = {};
+_G.incoming_s2s = incoming_s2s;
 prosody.incoming_s2s = incoming_s2s;
-local incoming_s2s = incoming_s2s;
 local fire_event = prosody.events.fire_event;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local function new_incoming(conn)
 	local session = { conn = conn, type = "s2sin_unauthed", direction = "incoming", hosts = {} };
@@ -64,6 +65,7 @@
 
 	function session.send(data) log("debug", "Discarding data sent to resting session: %s", tostring(data)); end
 	function session.data(data) log("debug", "Discarding data received from resting session: %s", tostring(data)); end
+	session.thread = { run = function (_, data) return session.data(data) end };
 	session.sends2s = session.send;
 	return setmetatable(session, resting_session);
 end
--- a/core/sessionmanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/sessionmanager.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -10,7 +10,7 @@
 local tostring, setmetatable = tostring, setmetatable;
 local pairs, next= pairs, next;
 
-local hosts = hosts;
+local hosts = prosody.hosts;
 local full_sessions = prosody.full_sessions;
 local bare_sessions = prosody.bare_sessions;
 
@@ -20,12 +20,13 @@
 local config_get = require "core.configmanager".get;
 local resourceprep = require "util.encodings".stringprep.resourceprep;
 local nodeprep = require "util.encodings".stringprep.nodeprep;
-local uuid_generate = require "util.uuid".generate;
+local generate_identifier = require "util.id".short;
 
 local initialize_filters = require "util.filters".initialize;
 local gettime = require "socket".gettime;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local function new_session(conn)
 	local session = { conn = conn, type = "c2s_unauthed", conntime = gettime() };
@@ -74,6 +75,7 @@
 
 	function session.send(data) log("debug", "Discarding data sent to resting session: %s", tostring(data)); return false; end
 	function session.data(data) log("debug", "Discarding data received from resting session: %s", tostring(data)); end
+	session.thread = { run = function (_, data) return session.data(data) end };
 	return setmetatable(session, resting_session);
 end
 
@@ -137,7 +139,7 @@
 	end
 
 	resource = resourceprep(resource);
-	resource = resource ~= "" and resource or uuid_generate();
+	resource = resource ~= "" and resource or generate_identifier();
 	--FIXME: Randomly-generated resources must be unique per-user, and never conflict with existing
 
 	if not hosts[session.host].sessions[session.username] then
@@ -151,7 +153,7 @@
 			local policy = config_get(session.host, "conflict_resolve");
 			local increment;
 			if policy == "random" then
-				resource = uuid_generate();
+				resource = generate_identifier();
 				increment = true;
 			elseif policy == "increment" then
 				increment = true; -- TODO ping old resource
--- a/core/stanza_router.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/stanza_router.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -19,16 +19,6 @@
 
 local core_post_stanza, core_process_stanza, core_route_stanza;
 
-function deprecated_warning(f)
-	_G[f] = function(...)
-		log("warn", "Using the global %s() is deprecated, use module:send() or prosody.%s(). %s", f, f, debug.traceback());
-		return prosody[f](...);
-	end
-end
-deprecated_warning"core_post_stanza";
-deprecated_warning"core_process_stanza";
-deprecated_warning"core_route_stanza";
-
 local valid_stanzas = { message = true, presence = true, iq = true };
 local function handle_unhandled_stanza(host, origin, stanza) --luacheck: ignore 212/host
 	local name, xmlns, origin_type = stanza.name, stanza.attr.xmlns or "jabber:client", origin.type;
--- a/core/storagemanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/storagemanager.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -1,17 +1,21 @@
 
 local type, pairs = type, pairs;
 local setmetatable = setmetatable;
+local rawset = rawset;
 
 local config = require "core.configmanager";
 local datamanager = require "util.datamanager";
 local modulemanager = require "core.modulemanager";
 local multitable = require "util.multitable";
-local hosts = hosts;
 local log = require "util.logger".init("storagemanager");
+local async = require "util.async";
+local debug = debug;
 
 local prosody = prosody;
+local hosts = prosody.hosts;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local olddm = {}; -- maintain old datamanager, for backwards compatibility
 for k,v in pairs(datamanager) do olddm[k] = v; end
@@ -28,8 +32,34 @@
 	}
 );
 
+local async_check = config.get("*", "storage_async_check") == true;
+
 local stores_available = multitable.new();
 
+local function check_async_wrapper(event)
+	local store = event.store;
+	event.store = setmetatable({}, {
+		__index = function (t, method_name)
+			local original_method = store[method_name];
+			if type(original_method) ~= "function" then
+				if original_method then
+					rawset(t, method_name, original_method);
+				end
+				return original_method;
+			end
+			local wrapped_method = function (...)
+				if not async.ready() then
+					log("warn", "ASYNC-01: Attempt to access storage outside async context, "
+					  .."see https://prosody.im/doc/developers/async - %s", debug.traceback());
+				end
+				return original_method(...);
+			end
+			rawset(t, method_name, wrapped_method);
+			return wrapped_method;
+		end;
+	});
+end
+
 local function initialize_host(host)
 	local host_session = hosts[host];
 	host_session.events.add_handler("item-added/storage-provider", function (event)
@@ -41,6 +71,9 @@
 		local item = event.item;
 		stores_available:set(host, item.name, nil);
 	end);
+	if async_check then
+		host_session.events.add_handler("store-opened", check_async_wrapper);
+	end
 end
 prosody.events.add_handler("host-activated", initialize_host, 101);
 
@@ -137,7 +170,7 @@
 	};
 }
 
-local open;
+local open; -- forward declaration
 
 local function create_map_shim(host, store)
 	local keyval_store, err = open(host, store, "keyval");
--- a/core/usermanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/core/usermanager.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -13,17 +13,18 @@
 local jid_bare = require "util.jid".bare;
 local jid_prep = require "util.jid".prep;
 local config = require "core.configmanager";
-local hosts = hosts;
 local sasl_new = require "util.sasl".new;
 local storagemanager = require "core.storagemanager";
 
 local prosody = _G.prosody;
+local hosts = prosody.hosts;
 
 local setmetatable = setmetatable;
 
 local default_provider = "internal_plain";
 
 local _ENV = nil;
+-- luacheck: std none
 
 local function new_null_provider()
 	local function dummy() return nil, "method not implemented"; end;
--- a/doc/coding_style.txt	Wed Nov 28 16:55:27 2018 +0000
+++ b/doc/coding_style.txt	Mon Jan 07 15:34:23 2019 +0000
@@ -7,7 +7,7 @@
 
 == Spacing ==
 
-No space between function names and parenthesis and parenthesis and paramters:
+No space between function names and parenthesis and parenthesis and parameters:
 
 		function foo(bar, baz)
 
--- a/doc/names.txt	Wed Nov 28 16:55:27 2018 +0000
+++ b/doc/names.txt	Mon Jan 07 15:34:23 2019 +0000
@@ -15,7 +15,7 @@
 	Eclaire	-	Idem (French)
 	Adel	-	Random
 	Younha	-	Read as "yuna"
-	Quezacotl	-	Mayan gods -> google for correct form and pronounciation
+	Quezacotl	-	Mayan gods -> google for correct form and pronunciation
 	Carbuncle	-	FF8 Guardian Force ^^
 	Protos	-	Mars satellite
 	mins	-	Derived from minstrel
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/doc/net.server.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,258 @@
+-- Prosody IM
+-- Copyright (C) 2014,2016 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+
+--luacheck: ignore
+
+--[[
+This file is a template for writing a net.server compatible backend.
+]]
+
+--[[
+Read patterns (also called modes) can be one of:
+  - "*a": Read as much as possible
+  - "*l": Read until end of line
+]]
+
+--- Handle API
+local handle_mt = {};
+local handle_methods = {};
+handle_mt.__index = handle_methods;
+
+function handle_methods:set_mode(new_pattern)
+end
+
+function handle_methods:setlistener(listeners)
+end
+
+function handle_methods:setoption(option, value)
+end
+
+function handle_methods:ip()
+end
+
+function handle_methods:starttls(sslctx)
+end
+
+function handle_methods:write(data)
+end
+
+function handle_methods:close()
+end
+
+function handle_methods:pause()
+end
+
+function handle_methods:resume()
+end
+
+--[[
+Returns
+  - socket: the socket object underlying this handle
+]]
+function handle_methods:socket()
+end
+
+--[[
+Returns
+  - boolean: if an ssl context has been set on this handle
+]]
+function handle_methods:ssl()
+end
+
+
+--- Listeners API
+local listeners = {}
+
+--[[ connect
+Called when a client socket has established a connection with it's peer
+]]
+function listeners.onconnect(handle)
+end
+
+--[[ incoming
+Called when data is received
+If reading data failed this will be called with `nil, "error message"`
+]]
+function listeners.onincoming(handle, buff, err)
+end
+
+--[[ status
+Known statuses:
+  - "ssl-handshake-complete"
+]]
+function listeners.onstatus(handle, status)
+end
+
+--[[ disconnect
+Called when the peer has closed the connection
+]]
+function listeners.ondisconnect(handle)
+end
+
+--[[ drain
+Called when the handle's write buffer is empty
+]]
+function listeners.ondrain(handle)
+end
+
+--[[ readtimeout
+Called when a socket inactivity timeout occurs
+]]
+function listeners.onreadtimeout(handle)
+end
+
+--[[ detach: Called when other listeners are going to be removed
+Allows for clean-up
+]]
+function listeners.ondetach(handle)
+end
+
+--- Top level functions
+
+--[[ Returns the syscall level event mechanism in use.
+
+Returns:
+  - backend: e.g. "select", "epoll"
+]]
+local function get_backend()
+end
+
+--[[ Starts the event loop.
+
+Returns:
+  - "quitting"
+]]
+local function loop()
+end
+
+--[[ Stop a running loop()
+]]
+local function setquitting(quit)
+end
+
+
+--[[ Links to two handles together, so anything written to one is piped to the other
+
+Arguments:
+  - sender, receiver: handles to link
+  - buffersize: maximum #bytes until sender will be locked
+]]
+local function link(sender, receiver, buffersize)
+end
+
+--[[ Binds and listens on the given address and port
+If `sslctx` is given, the connecting clients will have to negotiate an SSL session
+
+Arguments:
+  - address: address to bind to, may be "*" to bind all addresses. will be resolved if it is a string.
+  - port: port to bind (as number)
+  - listeners: a table of listeners
+  - pattern: the read pattern
+  - sslctx: is a valid luasec constructor
+
+Returns:
+  - handle
+  - nil, "an error message": on failure (e.g. out of file descriptors)
+]]
+local function addserver(address, port, listeners, pattern, sslctx)
+end
+
+--[[ Wraps a lua-socket socket client socket in a handle.
+The socket must be already connected to the remote end.
+If `sslctx` is given, a SSL session will be negotiated before listeners are called.
+
+Arguments:
+  - socket: the lua-socket object to wrap
+  - ip: returned by `handle:ip()`
+  - port:
+  - listeners: a table of listeners
+  - pattern: the read pattern
+  - sslctx: is a valid luasec constructor
+  - typ: the socket type, one of:
+	  - "tcp"
+	  - "tcp6"
+	  - "udp"
+
+Returns:
+  - handle, socket
+  - nil, "an error message": on failure (e.g. )
+]]
+local function wrapclient(socket, ip, serverport, listeners, pattern, sslctx)
+end
+
+--[[ Connects to the given address and port
+If `sslctx` is given, a SSL session will be negotiated before listeners are called.
+
+Arguments:
+  - address: address to connect to. will be resolved if it is a string.
+  - port: port to connect to (as number)
+  - listeners: a table of listeners
+  - pattern: the read pattern
+  - sslctx: is a valid luasec constructor
+  - typ: the socket type, one of:
+	  - "tcp"
+	  - "tcp6"
+	  - "udp"
+
+Returns:
+  - handle
+  - nil, "an error message": on failure (e.g. out of file descriptors)
+]]
+local function addclient(address, port, listeners, pattern, sslctx, typ)
+end
+
+--[[ Close all handles
+]]
+local function closeall()
+end
+
+--[[ The callback should be called after `delay` seconds.
+The callback should be called with the time at the point of firing.
+If the callback returns a number, it should be called again after that many seconds.
+
+Arguments:
+  - delay: number of seconds to wait
+  - callback: function to call.
+]]
+local function add_task(delay, callback)
+end
+
+--[[ Adds a handler for when a signal is fired.
+Optional to implement
+callback does not take any arguments
+
+Arguments:
+  - signal_id: the signal id (as number) to listen for
+  - handler: callback
+]]
+local function hook_signal(signal_id, handler)
+end
+
+--[[ Adds a low-level FD watcher
+Arguments:
+-   fd_number: A non-negative integer representing a file descriptor or
+    object with a :getfd() method returning one
+-   on_readable: Optional callback for when the FD is readable
+-   on_writable: Optional callback for when the FD is writable
+
+Returns:
+-   net.server handle
+]]
+local function watchfd(fd_number, on_readable, on_writable)
+end
+
+return {
+	get_backend = get_backend;
+	loop = loop;
+	setquitting = setquitting;
+	link = link;
+	addserver = addserver;
+	wrapclient = wrapclient;
+	addclient = addclient;
+	closeall = closeall;
+	hook_signal = hook_signal;
+	watchfd = watchfd;
+}
--- a/doc/session.txt	Wed Nov 28 16:55:27 2018 +0000
+++ b/doc/session.txt	Mon Jan 07 15:34:23 2019 +0000
@@ -15,12 +15,12 @@
 	full_jid -- convenience for the above 3 as string in username@host/resource form (not defined before resource binding)
 	priority -- the resource priority, default: 0
 	presence -- the last non-directed presence with no type attribute. initially nil. reset to nil on unavailable presence.
-	interested -- true if the resource requested the roster. Interested resources recieve roster updates. Initially nil.
+	interested -- true if the resource requested the roster. Interested resources receive roster updates. Initially nil.
 	roster -- the user's roster. Loaded as soon as the resource is bound (session becomes a connected resource).
 	
 	-- methods --
 	send(x) -- converts x to a string, and writes it to the connection
-	disconnect(x) -- Disconnect the user and clean up the session, best call sessionmanager.destroy_session() instead of this in most cases
+	close(x) -- Disconnect the user and clean up the session, best call sessionmanager.destroy_session() instead of this in most cases
 }
 
 if session.full_jid (also session.roster and session.resource) then this is a "connected resource"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/makefile	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,101 @@
+
+include config.unix
+
+BIN = $(DESTDIR)$(PREFIX)/bin
+CONFIG = $(DESTDIR)$(SYSCONFDIR)
+MODULES = $(DESTDIR)$(LIBDIR)/prosody/modules
+SOURCE = $(DESTDIR)$(LIBDIR)/prosody
+DATA = $(DESTDIR)$(DATADIR)
+MAN = $(DESTDIR)$(PREFIX)/share/man
+
+INSTALLEDSOURCE = $(LIBDIR)/prosody
+INSTALLEDCONFIG = $(SYSCONFDIR)
+INSTALLEDMODULES = $(LIBDIR)/prosody/modules
+INSTALLEDDATA = $(DATADIR)
+
+INSTALL=install -p
+INSTALL_DATA=$(INSTALL) -m644
+INSTALL_EXEC=$(INSTALL) -m755
+MKDIR=install -d
+MKDIR_PRIVATE=$(MKDIR) -m750
+
+.PHONY: all test clean install
+
+all: prosody.install prosodyctl.install prosody.cfg.lua.install prosody.version
+	$(MAKE) -C util-src install
+.if $(EXCERTS) == "yes"
+	$(MAKE) -C certs localhost.crt example.com.crt
+.endif
+
+install: prosody.install prosodyctl.install prosody.cfg.lua.install util/encodings.so util/encodings.so util/pposix.so util/signal.so
+	$(MKDIR) $(BIN) $(CONFIG) $(MODULES) $(SOURCE)
+	$(MKDIR_PRIVATE) $(DATA)
+	$(MKDIR) $(MAN)/man1
+	$(MKDIR) $(CONFIG)/certs
+	$(MKDIR) $(SOURCE)/core $(SOURCE)/net $(SOURCE)/util
+	$(INSTALL_EXEC) ./prosody.install $(BIN)/prosody
+	$(INSTALL_EXEC) ./prosodyctl.install $(BIN)/prosodyctl
+	$(INSTALL_DATA) core/*.lua $(SOURCE)/core
+	$(INSTALL_DATA) net/*.lua $(SOURCE)/net
+	$(MKDIR) $(SOURCE)/net/http $(SOURCE)/net/resolvers $(SOURCE)/net/websocket
+	$(INSTALL_DATA) net/http/*.lua $(SOURCE)/net/http
+	$(INSTALL_DATA) net/resolvers/*.lua $(SOURCE)/net/resolvers
+	$(INSTALL_DATA) net/websocket/*.lua $(SOURCE)/net/websocket
+	$(INSTALL_DATA) util/*.lua $(SOURCE)/util
+	$(INSTALL_DATA) util/*.so $(SOURCE)/util
+	$(MKDIR) $(SOURCE)/util/sasl
+	$(INSTALL_DATA) util/sasl/*.lua $(SOURCE)/util/sasl
+	$(MKDIR) $(MODULES)/mod_s2s $(MODULES)/mod_pubsub $(MODULES)/adhoc $(MODULES)/muc $(MODULES)/mod_mam
+	$(INSTALL_DATA) plugins/*.lua $(MODULES)
+	$(INSTALL_DATA) plugins/mod_s2s/*.lua $(MODULES)/mod_s2s
+	$(INSTALL_DATA) plugins/mod_pubsub/*.lua $(MODULES)/mod_pubsub
+	$(INSTALL_DATA) plugins/adhoc/*.lua $(MODULES)/adhoc
+	$(INSTALL_DATA) plugins/muc/*.lua $(MODULES)/muc
+	$(INSTALL_DATA) plugins/mod_mam/*.lua $(MODULES)/mod_mam
+.if $(EXCERTS) == "yes"
+	$(INSTALL_DATA) certs/localhost.crt certs/localhost.key $(CONFIG)/certs
+	$(INSTALL_DATA) certs/example.com.crt certs/example.com.key $(CONFIG)/certs
+.endif
+	$(INSTALL_DATA) man/prosodyctl.man $(MAN)/man1/prosodyctl.1
+	test -f $(CONFIG)/prosody.cfg.lua || $(INSTALL_DATA) prosody.cfg.lua.install $(CONFIG)/prosody.cfg.lua
+	-test -f prosody.version && $(INSTALL_DATA) prosody.version $(SOURCE)/prosody.version
+	$(MAKE) install -C util-src
+
+clean:
+	rm -f prosody.install
+	rm -f prosodyctl.install
+	rm -f prosody.cfg.lua.install
+	rm -f prosody.version
+	$(MAKE) clean -C util-src
+
+test:
+	busted --lua=$(RUNWITH)
+
+
+prosody.install: prosody
+	sed "1s| lua$$| $(RUNWITH)|; \
+		s|^CFG_SOURCEDIR=.*;$$|CFG_SOURCEDIR='$(INSTALLEDSOURCE)';|; \
+		s|^CFG_CONFIGDIR=.*;$$|CFG_CONFIGDIR='$(INSTALLEDCONFIG)';|; \
+		s|^CFG_DATADIR=.*;$$|CFG_DATADIR='$(INSTALLEDDATA)';|; \
+		s|^CFG_PLUGINDIR=.*;$$|CFG_PLUGINDIR='$(INSTALLEDMODULES)/';|;" < prosody > $@
+
+prosodyctl.install: prosodyctl
+	sed "1s| lua$$| $(RUNWITH)|; \
+		s|^CFG_SOURCEDIR=.*;$$|CFG_SOURCEDIR='$(INSTALLEDSOURCE)';|; \
+		s|^CFG_CONFIGDIR=.*;$$|CFG_CONFIGDIR='$(INSTALLEDCONFIG)';|; \
+		s|^CFG_DATADIR=.*;$$|CFG_DATADIR='$(INSTALLEDDATA)';|; \
+		s|^CFG_PLUGINDIR=.*;$$|CFG_PLUGINDIR='$(INSTALLEDMODULES)/';|;" < prosodyctl > $@
+
+prosody.cfg.lua.install: prosody.cfg.lua.dist
+	sed 's|certs/|$(INSTALLEDCONFIG)/certs/|' prosody.cfg.lua.dist > $@
+
+prosody.version:
+	test -f prosody.release && \
+		cp prosody.release $@ || \
+		test -f .hg_archival.txt && \
+		sed -n 's/^node: \(............\).*/\1/p' .hg_archival.txt > $@ || \
+		test -f .hg/dirstate && \
+		hexdump -n6 -e'6/1 "%02x"' .hg/dirstate > $@ || \
+		echo unknown > $@
+
+
--- a/net/adns.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/net/adns.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -17,6 +17,7 @@
 local function dummy_send(sock, data, i, j) return (j-i)+1; end
 
 local _ENV = nil;
+-- luacheck: std none
 
 local async_resolver_methods = {};
 local async_resolver_mt = { __index = async_resolver_methods };
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/net/connect.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,89 @@
+local server = require "net.server";
+local log = require "util.logger".init("net.connect");
+local new_id = require "util.id".short;
+
+local pending_connection_methods = {};
+local pending_connection_mt = {
+	__name = "pending_connection";
+	__index = pending_connection_methods;
+	__tostring = function (p)
+		return "<pending connection "..p.id.." to "..tostring(p.target_resolver.hostname)..">";
+	end;
+};
+
+function pending_connection_methods:log(level, message, ...)
+	log(level, "[pending connection %s] "..message, self.id, ...);
+end
+
+-- pending_connections_map[conn] = pending_connection
+local pending_connections_map = {};
+
+local pending_connection_listeners = {};
+
+local function attempt_connection(p)
+	p:log("debug", "Checking for targets...");
+	if p.conn then
+		pending_connections_map[p.conn] = nil;
+		p.conn = nil;
+	end
+	p.target_resolver:next(function (conn_type, ip, port, extra)
+		if not conn_type then
+			-- No more targets to try
+			p:log("debug", "No more connection targets to try");
+			if p.listeners.onfail then
+				p.listeners.onfail(p.data, p.last_error or "unable to resolve service");
+			end
+			return;
+		end
+		p:log("debug", "Next target to try is %s:%d", ip, port);
+		local conn, err = server.addclient(ip, port, pending_connection_listeners, p.options.pattern or "*a", p.options.sslctx, conn_type, extra);
+		if not conn then
+			log("debug", "Connection attempt failed immediately: %s", tostring(err));
+			p.last_error = err or "unknown reason";
+			return attempt_connection(p);
+		end
+		p.conn = conn;
+		pending_connections_map[conn] = p;
+	end);
+end
+
+function pending_connection_listeners.onconnect(conn)
+	local p = pending_connections_map[conn];
+	if not p then
+		log("warn", "Successful connection, but unexpected! Closing.");
+		conn:close();
+		return;
+	end
+	pending_connections_map[conn] = nil;
+	p:log("debug", "Successfully connected");
+	conn:setlistener(p.listeners, p.data);
+	return p.listeners.onconnect(conn);
+end
+
+function pending_connection_listeners.ondisconnect(conn, reason)
+	local p = pending_connections_map[conn];
+	if not p then
+		log("warn", "Failed connection, but unexpected!");
+		return;
+	end
+	p.last_error = reason or "unknown reason";
+	p:log("debug", "Connection attempt failed: %s", p.last_error);
+	attempt_connection(p);
+end
+
+local function connect(target_resolver, listeners, options, data)
+	local p = setmetatable({
+		id = new_id();
+		target_resolver = target_resolver;
+		listeners = assert(listeners);
+		options = options or {};
+		data = data;
+	}, pending_connection_mt);
+
+	p:log("debug", "Starting connection process");
+	attempt_connection(p);
+end
+
+return {
+	connect = connect;
+};
--- a/net/connlisteners.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/net/connlisteners.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -3,15 +3,15 @@
 local traceback = debug.traceback;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local function fail()
-	log("error", "Attempt to use legacy connlisteners API. For more info see http://prosody.im/doc/developers/network");
+	log("error", "Attempt to use legacy connlisteners API. For more info see https://prosody.im/doc/developers/network");
 	log("error", "Legacy connlisteners API usage, %s", traceback("", 2));
 end
 
 return {
 	register = fail;
-	register = fail;
 	get = fail;
 	start = fail;
 	-- epic fail
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/net/cqueues.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,74 @@
+-- Prosody IM
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- This module allows you to use cqueues with a net.server mainloop
+--
+
+local server = require "net.server";
+local cqueues = require "cqueues";
+assert(cqueues.VERSION >= 20150113, "cqueues newer than 20150113 required")
+
+-- Create a single top level cqueue
+local cq;
+
+if server.cq then -- server provides cqueues object
+	cq = server.cq;
+elseif server.get_backend() == "select" and server._addtimer then -- server_select
+	cq = cqueues.new();
+	local function step()
+		assert(cq:loop(0));
+	end
+
+	-- Use wrapclient (as wrapconnection isn't exported) to get server_select to watch cq fd
+	local handler = server.wrapclient({
+		getfd = function() return cq:pollfd(); end;
+		settimeout = function() end; -- Method just needs to exist
+		close = function() end; -- Need close method for 'closeall'
+	}, nil, nil, {});
+
+	-- Only need to listen for readable; cqueues handles everything under the hood
+	-- readbuffer is called when `select` notes an fd as readable
+	handler.readbuffer = step;
+
+	-- Use server_select low lever timer facility,
+	-- this callback gets called *every* time there is a timeout in the main loop
+	server._addtimer(function(current_time)
+		-- This may end up in extra step()'s, but cqueues handles it for us.
+		step();
+		return cq:timeout();
+	end);
+elseif server.event and server.base then -- server_event
+	cq = cqueues.new();
+	-- Only need to listen for readable; cqueues handles everything under the hood
+	local EV_READ = server.event.EV_READ;
+	-- Convert a cqueues timeout to an acceptable timeout for luaevent
+	local function luaevent_safe_timeout(cq)
+		local t = cq:timeout();
+		-- if you give luaevent 0 or nil, it re-uses the previous timeout.
+		if t == 0 then
+			t = 0.000001; -- 1 microsecond is the smallest that works (goes into a `struct timeval`)
+		elseif t == nil then -- pick something big if we don't have one
+			t = 0x7FFFFFFF; -- largest 32bit int
+		end
+		return t
+	end
+	local event_handle;
+	event_handle = server.base:addevent(cq:pollfd(), EV_READ, function(e)
+			-- Need to reference event_handle or this callback will get collected
+			-- This creates a circular reference that can only be broken if event_handle is manually :close()'d
+			local _ = event_handle;
+			-- Run as many cqueues things as possible (with a timeout of 0)
+			-- If an error is thrown, it will break the libevent loop; but prosody resumes after logging a top level error
+			assert(cq:loop(0));
+			return EV_READ, luaevent_safe_timeout(cq);
+		end, luaevent_safe_timeout(cq));
+else
+	error "NYI"
+end
+
+return {
+	cq = cq;
+}
--- a/net/dns.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/net/dns.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -15,6 +15,7 @@
 local socket = require "socket";
 local timer = require "util.timer";
 local new_ip = require "util.ip".new_ip;
+local have_util_net, util_net = pcall(require, "util.net");
 
 local _, windows = pcall(require, "util.windows");
 local is_windows = (_ and windows) or os.getenv("WINDIR");
@@ -72,6 +73,7 @@
 
 -------------------------------------------------- module dns
 local _ENV = nil;
+-- luacheck: std none
 local dns = {};
 
 
@@ -119,11 +121,99 @@
 
 
 dns.types = {
-	'A', 'NS', 'MD', 'MF', 'CNAME', 'SOA', 'MB', 'MG', 'MR', 'NULL', 'WKS',
-	'PTR', 'HINFO', 'MINFO', 'MX', 'TXT',
-	[ 28] = 'AAAA', [ 29] = 'LOC',   [ 33] = 'SRV',
-	[252] = 'AXFR', [253] = 'MAILB', [254] = 'MAILA', [255] = '*' };
-
+	[1] = "A", -- a host address,[RFC1035],,
+	[2] = "NS", -- an authoritative name server,[RFC1035],,
+	[3] = "MD", -- a mail destination (OBSOLETE - use MX),[RFC1035],,
+	[4] = "MF", -- a mail forwarder (OBSOLETE - use MX),[RFC1035],,
+	[5] = "CNAME", -- the canonical name for an alias,[RFC1035],,
+	[6] = "SOA", -- marks the start of a zone of authority,[RFC1035],,
+	[7] = "MB", -- a mailbox domain name (EXPERIMENTAL),[RFC1035],,
+	[8] = "MG", -- a mail group member (EXPERIMENTAL),[RFC1035],,
+	[9] = "MR", -- a mail rename domain name (EXPERIMENTAL),[RFC1035],,
+	[10] = "NULL", -- a null RR (EXPERIMENTAL),[RFC1035],,
+	[11] = "WKS", -- a well known service description,[RFC1035],,
+	[12] = "PTR", -- a domain name pointer,[RFC1035],,
+	[13] = "HINFO", -- host information,[RFC1035],,
+	[14] = "MINFO", -- mailbox or mail list information,[RFC1035],,
+	[15] = "MX", -- mail exchange,[RFC1035],,
+	[16] = "TXT", -- text strings,[RFC1035],,
+	[17] = "RP", -- for Responsible Person,[RFC1183],,
+	[18] = "AFSDB", -- for AFS Data Base location,[RFC1183][RFC5864],,
+	[19] = "X25", -- for X.25 PSDN address,[RFC1183],,
+	[20] = "ISDN", -- for ISDN address,[RFC1183],,
+	[21] = "RT", -- for Route Through,[RFC1183],,
+	[22] = "NSAP", -- "for NSAP address, NSAP style A record",[RFC1706],,
+	[23] = "NSAP-PTR", -- "for domain name pointer, NSAP style",[RFC1348][RFC1637][RFC1706],,
+	[24] = "SIG", -- for security signature,[RFC4034][RFC3755][RFC2535][RFC2536][RFC2537][RFC2931][RFC3110][RFC3008],,
+	[25] = "KEY", -- for security key,[RFC4034][RFC3755][RFC2535][RFC2536][RFC2537][RFC2539][RFC3008][RFC3110],,
+	[26] = "PX", -- X.400 mail mapping information,[RFC2163],,
+	[27] = "GPOS", -- Geographical Position,[RFC1712],,
+	[28] = "AAAA", -- IP6 Address,[RFC3596],,
+	[29] = "LOC", -- Location Information,[RFC1876],,
+	[30] = "NXT", -- Next Domain (OBSOLETE),[RFC3755][RFC2535],,
+	[31] = "EID", -- Endpoint Identifier,[Michael_Patton][http://ana-3.lcs.mit.edu/~jnc/nimrod/dns.txt],,1995-06
+	[32] = "NIMLOC", -- Nimrod Locator,[1][Michael_Patton][http://ana-3.lcs.mit.edu/~jnc/nimrod/dns.txt],,1995-06
+	[33] = "SRV", -- Server Selection,[1][RFC2782],,
+	[34] = "ATMA", -- ATM Address,"[ ATM Forum Technical Committee, ""ATM Name System, V2.0"", Doc ID: AF-DANS-0152.000, July 2000. Available from and held in escrow by IANA.]",,
+	[35] = "NAPTR", -- Naming Authority Pointer,[RFC2915][RFC2168][RFC3403],,
+	[36] = "KX", -- Key Exchanger,[RFC2230],,
+	[37] = "CERT", -- CERT,[RFC4398],,
+	[38] = "A6", -- A6 (OBSOLETE - use AAAA),[RFC3226][RFC2874][RFC6563],,
+	[39] = "DNAME", -- DNAME,[RFC6672],,
+	[40] = "SINK", -- SINK,[Donald_E_Eastlake][http://tools.ietf.org/html/draft-eastlake-kitchen-sink],,1997-11
+	[41] = "OPT", -- OPT,[RFC6891][RFC3225],,
+	[42] = "APL", -- APL,[RFC3123],,
+	[43] = "DS", -- Delegation Signer,[RFC4034][RFC3658],,
+	[44] = "SSHFP", -- SSH Key Fingerprint,[RFC4255],,
+	[45] = "IPSECKEY", -- IPSECKEY,[RFC4025],,
+	[46] = "RRSIG", -- RRSIG,[RFC4034][RFC3755],,
+	[47] = "NSEC", -- NSEC,[RFC4034][RFC3755],,
+	[48] = "DNSKEY", -- DNSKEY,[RFC4034][RFC3755],,
+	[49] = "DHCID", -- DHCID,[RFC4701],,
+	[50] = "NSEC3", -- NSEC3,[RFC5155],,
+	[51] = "NSEC3PARAM", -- NSEC3PARAM,[RFC5155],,
+	[52] = "TLSA", -- TLSA,[RFC6698],,
+	[53] = "SMIMEA", -- S/MIME cert association,[RFC8162],SMIMEA/smimea-completed-template,2015-12-01
+	-- [54] = "Unassigned", -- ,,,
+	[55] = "HIP", -- Host Identity Protocol,[RFC8005],,
+	[56] = "NINFO", -- NINFO,[Jim_Reid],NINFO/ninfo-completed-template,2008-01-21
+	[57] = "RKEY", -- RKEY,[Jim_Reid],RKEY/rkey-completed-template,2008-01-21
+	[58] = "TALINK", -- Trust Anchor LINK,[Wouter_Wijngaards],TALINK/talink-completed-template,2010-02-17
+	[59] = "CDS", -- Child DS,[RFC7344],CDS/cds-completed-template,2011-06-06
+	[60] = "CDNSKEY", -- DNSKEY(s) the Child wants reflected in DS,[RFC7344],,2014-06-16
+	[61] = "OPENPGPKEY", -- OpenPGP Key,[RFC7929],OPENPGPKEY/openpgpkey-completed-template,2014-08-12
+	[62] = "CSYNC", -- Child-To-Parent Synchronization,[RFC7477],,2015-01-27
+	-- [63 .. 98] = "Unassigned", -- ,,,
+	[99] = "SPF", -- ,[RFC7208],,
+	[100] = "UINFO", -- ,[IANA-Reserved],,
+	[101] = "UID", -- ,[IANA-Reserved],,
+	[102] = "GID", -- ,[IANA-Reserved],,
+	[103] = "UNSPEC", -- ,[IANA-Reserved],,
+	[104] = "NID", -- ,[RFC6742],ILNP/nid-completed-template,
+	[105] = "L32", -- ,[RFC6742],ILNP/l32-completed-template,
+	[106] = "L64", -- ,[RFC6742],ILNP/l64-completed-template,
+	[107] = "LP", -- ,[RFC6742],ILNP/lp-completed-template,
+	[108] = "EUI48", -- an EUI-48 address,[RFC7043],EUI48/eui48-completed-template,2013-03-27
+	[109] = "EUI64", -- an EUI-64 address,[RFC7043],EUI64/eui64-completed-template,2013-03-27
+	-- [110 .. 248] = "Unassigned", -- ,,,
+	[249] = "TKEY", -- Transaction Key,[RFC2930],,
+	[250] = "TSIG", -- Transaction Signature,[RFC2845],,
+	[251] = "IXFR", -- incremental transfer,[RFC1995],,
+	[252] = "AXFR", -- transfer of an entire zone,[RFC1035][RFC5936],,
+	[253] = "MAILB", -- "mailbox-related RRs (MB, MG or MR)",[RFC1035],,
+	[254] = "MAILA", -- mail agent RRs (OBSOLETE - see MX),[RFC1035],,
+	[255] = "*", -- A request for all records the server/cache has available,[RFC1035][RFC6895],,
+	[256] = "URI", -- URI,[RFC7553],URI/uri-completed-template,2011-02-22
+	[257] = "CAA", -- Certification Authority Restriction,[RFC6844],CAA/caa-completed-template,2011-04-07
+	[258] = "AVC", -- Application Visibility and Control,[Wolfgang_Riedel],AVC/avc-completed-template,2016-02-26
+	[259] = "DOA", -- Digital Object Architecture,[draft-durand-doa-over-dns],DOA/doa-completed-template,2017-08-30
+	-- [260 .. 32767] = "Unassigned", -- ,,,
+	[32768] = "TA", -- DNSSEC Trust Authorities,"[Sam_Weiler][http://cameo.library.cmu.edu/][ Deploying DNSSEC Without a Signed Root.  Technical Report 1999-19, Information Networking Institute, Carnegie Mellon University, April 2004.]",,2005-12-13
+	[32769] = "DLV", -- DNSSEC Lookaside Validation,[RFC4431],,
+	-- [32770 .. 65279] = "Unassigned", -- ,,,
+	-- [65280 .. 65534] = "Private use", -- ,,,
+	-- [65535] = "Reserved", -- ,,,
+}
 
 dns.classes = { 'IN', 'CS', 'CH', 'HS', [255] = '*' };
 
@@ -391,6 +481,12 @@
 	rr.a = string.format('%i.%i.%i.%i', b1, b2, b3, b4);
 end
 
+if have_util_net and util_net.ntop then
+	function resolver:A(rr)
+		rr.a = util_net.ntop(self:sub(4));
+	end
+end
+
 function resolver:AAAA(rr)
 	local addr = {};
 	for _ = 1, rr.rdlength, 2 do
@@ -411,6 +507,12 @@
 	rr.aaaa = addr:gsub(zeros[1], "::", 1):gsub("^0::", "::"):gsub("::0$", "::");
 end
 
+if have_util_net and util_net.ntop then
+	function resolver:AAAA(rr)
+		rr.aaaa = util_net.ntop(self:sub(16));
+	end
+end
+
 function resolver:CNAME(rr)    -- - - - - - - - - - - - - - - - - - - -  CNAME
 	rr.cname = self:name();
 end
--- a/net/http.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/net/http.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -13,20 +13,22 @@
 local events = require "util.events";
 local verify_identity = require"util.x509".verify_identity;
 
-local ssl_available = pcall(require, "ssl");
+local basic_resolver = require "net.resolvers.basic";
+local connect = require "net.connect".connect;
 
-local server = require "net.server"
+local ssl_available = pcall(require, "ssl");
 
 local t_insert, t_concat = table.insert, table.concat;
 local pairs = pairs;
-local tonumber, tostring, xpcall, traceback =
-      tonumber, tostring, xpcall, debug.traceback;
+local tonumber, tostring, traceback =
+      tonumber, tostring, debug.traceback;
+local xpcall = require "util.xpcall".xpcall;
 local error = error
-local setmetatable = setmetatable;
 
 local log = require "util.logger".init("http");
 
 local _ENV = nil;
+-- luacheck: std none
 
 local requests = {}; -- Open requests
 
@@ -34,9 +36,78 @@
 
 local listener = { default_port = 80, default_mode = "*a" };
 
+-- Request-related helper functions
+local function handleerr(err) log("error", "Traceback[http]: %s", traceback(tostring(err), 2)); return err; end
+local function log_if_failed(req, ret, ...)
+	if not ret then
+		log("error", "Request '%s': error in callback: %s", req.id, tostring((...)));
+		if not req.suppress_errors then
+			error(...);
+		end
+	end
+	return ...;
+end
+
+local function destroy_request(request)
+	local conn = request.conn;
+	if conn then
+		request.conn = nil;
+		conn:close()
+	end
+end
+
+local function request_reader(request, data, err)
+	if not request.parser then
+		local function error_cb(reason)
+			if request.callback then
+				request.callback(reason or "connection-closed", 0, request);
+				request.callback = nil;
+			end
+			destroy_request(request);
+		end
+
+		if not data then
+			error_cb(err);
+			return;
+		end
+
+		local function success_cb(r)
+			if request.callback then
+				request.callback(r.body, r.code, r, request);
+				request.callback = nil;
+			end
+			destroy_request(request);
+		end
+		local function options_cb()
+			return request;
+		end
+		request.parser = httpstream_new(success_cb, error_cb, "client", options_cb);
+	end
+	request.parser:feed(data);
+end
+
+-- Connection listener callbacks
 function listener.onconnect(conn)
 	local req = requests[conn];
 
+	-- Initialize request object
+	req.write = function (...) return req.conn:write(...); end
+	local callback = req.callback;
+	req.callback = function (content, code, response, request)
+		do
+			local event = { http = req.http, url = req.url, request = req, response = response, content = content, code = code, callback = req.callback };
+			req.http.events.fire_event("response", event);
+			content, code, response = event.content, event.code, event.response;
+		end
+
+		log("debug", "Request '%s': Calling callback, status %s", req.id, code or "---");
+		return log_if_failed(req.id, xpcall(callback, handleerr, content, code, response, request));
+	end
+	req.reader = request_reader;
+	req.state = "status";
+
+	requests[req.conn] = req;
+
 	-- Validate certificate
 	if not req.insecure and conn:ssl() then
 		local sock = conn:socket();
@@ -96,58 +167,24 @@
 	requests[conn] = nil;
 end
 
+function listener.onattach(conn, req)
+	requests[conn] = req;
+	req.conn = conn;
+end
+
 function listener.ondetach(conn)
 	requests[conn] = nil;
 end
 
-local function destroy_request(request)
-	if request.conn then
-		request.conn = nil;
-		request.handler:close()
-	end
-end
-
-local function request_reader(request, data, err)
-	if not request.parser then
-		local function error_cb(reason)
-			if request.callback then
-				request.callback(reason or "connection-closed", 0, request);
-				request.callback = nil;
-			end
-			destroy_request(request);
-		end
-
-		if not data then
-			error_cb(err);
-			return;
-		end
-
-		local function success_cb(r)
-			if request.callback then
-				request.callback(r.body, r.code, r, request);
-				request.callback = nil;
-			end
-			destroy_request(request);
-		end
-		local function options_cb()
-			return request;
-		end
-		request.parser = httpstream_new(success_cb, error_cb, "client", options_cb);
-	end
-	request.parser:feed(data);
-end
-
-local function handleerr(err) log("error", "Traceback[http]: %s", traceback(tostring(err), 2)); end
-local function log_if_failed(id, ret, ...)
-	if not ret then
-		log("error", "Request '%s': error in callback: %s", id, tostring((...)));
-	end
-	return ...;
+function listener.onfail(req, reason)
+	req.http.events.fire_event("request-connection-error", { http = req.http, request = req, url = req.url, err = reason });
+	req.callback(reason or "connection failed", 0, req);
 end
 
 local function request(self, u, ex, callback)
 	local req = url.parse(u);
 	req.url = u;
+	req.http = self;
 
 	if not (req and req.host) then
 		callback("invalid-url", 0, req);
@@ -166,7 +203,7 @@
 		if ret then
 			return ret;
 		end
-		req, u, ex, callback = event.request, event.url, event.options, event.callback;
+		req, u, ex, req.callback = event.request, event.url, event.options, event.callback;
 	end
 
 	local method, headers, body;
@@ -204,6 +241,7 @@
 			end
 		end
 		req.insecure = ex.insecure;
+		req.suppress_errors = ex.suppress_errors;
 	end
 
 	log("debug", "Making %s %s request '%s' to %s", req.scheme:upper(), method or "GET", req.id, (ex and ex.suppress_url and host_header) or u);
@@ -222,29 +260,8 @@
 		sslctx = ex and ex.sslctx or self.options and self.options.sslctx;
 	end
 
-	local handler, conn = server.addclient(host, port_number, listener, "*a", sslctx)
-	if not handler then
-		self.events.fire_event("request-connection-error", { http = self, request = req, url = u, err = conn });
-		callback(conn, 0, req);
-		return nil, conn;
-	end
-	req.handler, req.conn = handler, conn
-	req.write = function (...) return req.handler:write(...); end
-
-	req.callback = function (content, code, response, request)
-		do
-			local event = { http = self, url = u, request = req, response = response, content = content, code = code, callback = callback };
-			self.events.fire_event("response", event);
-			content, code, response = event.content, event.code, event.response;
-		end
-
-		log("debug", "Request '%s': Calling callback, status %s", req.id, code or "---");
-		return log_if_failed(req.id, xpcall(function () return callback(content, code, response, request) end, handleerr));
-	end
-	req.reader = request_reader;
-	req.state = "status";
-
-	requests[req.handler] = req;
+	local http_service = basic_resolver.new(host, port_number);
+	connect(http_service, listener, { sslctx = sslctx }, req);
 
 	self.events.fire_event("request", { http = self, request = req, url = u });
 	return req;
@@ -255,7 +272,12 @@
 		options = options;
 		request = request;
 		new = options and function (new_options)
-			return new(setmetatable(new_options, { __index = options }));
+			local final_options = {};
+			for k, v in pairs(options) do final_options[k] = v; end
+			if new_options then
+				for k, v in pairs(new_options) do final_options[k] = v; end
+			end
+			return new(final_options);
 		end or new;
 		events = events.new();
 	};
@@ -264,6 +286,7 @@
 
 local default_http = new({
 	sslctx = { mode = "client", protocol = "sslv23", options = { "no_sslv2", "no_sslv3" } };
+	suppress_errors = true;
 });
 
 return {
--- a/net/http/codes.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/net/http/codes.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -1,73 +1,85 @@
 
 local response_codes = {
 	-- Source: http://www.iana.org/assignments/http-status-codes
-	-- s/^\(\d*\)\s*\(.*\S\)\s*\[RFC.*\]\s*$/^I["\1"] = "\2";
-	[100] = "Continue";
-	[101] = "Switching Protocols";
+
+	[100] = "Continue"; -- RFC7231, Section 6.2.1
+	[101] = "Switching Protocols"; -- RFC7231, Section 6.2.2
 	[102] = "Processing";
+	[103] = "Early Hints";
+	-- [104-199] = "Unassigned";
 
-	[200] = "OK";
-	[201] = "Created";
-	[202] = "Accepted";
-	[203] = "Non-Authoritative Information";
-	[204] = "No Content";
-	[205] = "Reset Content";
-	[206] = "Partial Content";
+	[200] = "OK"; -- RFC7231, Section 6.3.1
+	[201] = "Created"; -- RFC7231, Section 6.3.2
+	[202] = "Accepted"; -- RFC7231, Section 6.3.3
+	[203] = "Non-Authoritative Information"; -- RFC7231, Section 6.3.4
+	[204] = "No Content"; -- RFC7231, Section 6.3.5
+	[205] = "Reset Content"; -- RFC7231, Section 6.3.6
+	[206] = "Partial Content"; -- RFC7233, Section 4.1
 	[207] = "Multi-Status";
 	[208] = "Already Reported";
+	-- [209-225] = "Unassigned";
 	[226] = "IM Used";
+	-- [227-299] = "Unassigned";
 
-	[300] = "Multiple Choices";
-	[301] = "Moved Permanently";
-	[302] = "Found";
-	[303] = "See Other";
-	[304] = "Not Modified";
-	[305] = "Use Proxy";
-	-- The 306 status code was used in a previous version of [RFC2616], is no longer used, and the code is reserved.
-	[307] = "Temporary Redirect";
+	[300] = "Multiple Choices"; -- RFC7231, Section 6.4.1
+	[301] = "Moved Permanently"; -- RFC7231, Section 6.4.2
+	[302] = "Found"; -- RFC7231, Section 6.4.3
+	[303] = "See Other"; -- RFC7231, Section 6.4.4
+	[304] = "Not Modified"; -- RFC7232, Section 4.1
+	[305] = "Use Proxy"; -- RFC7231, Section 6.4.5
+	-- [306] = "(Unused)"; -- RFC7231, Section 6.4.6
+	[307] = "Temporary Redirect"; -- RFC7231, Section 6.4.7
 	[308] = "Permanent Redirect";
+	-- [309-399] = "Unassigned";
 
-	[400] = "Bad Request";
-	[401] = "Unauthorized";
-	[402] = "Payment Required";
-	[403] = "Forbidden";
-	[404] = "Not Found";
-	[405] = "Method Not Allowed";
-	[406] = "Not Acceptable";
-	[407] = "Proxy Authentication Required";
-	[408] = "Request Timeout";
-	[409] = "Conflict";
-	[410] = "Gone";
-	[411] = "Length Required";
-	[412] = "Precondition Failed";
-	[413] = "Payload Too Large";
-	[414] = "URI Too Long";
-	[415] = "Unsupported Media Type";
-	[416] = "Range Not Satisfiable";
-	[417] = "Expectation Failed";
-	[418] = "I'm a teapot";
-	[421] = "Misdirected Request";
+	[400] = "Bad Request"; -- RFC7231, Section 6.5.1
+	[401] = "Unauthorized"; -- RFC7235, Section 3.1
+	[402] = "Payment Required"; -- RFC7231, Section 6.5.2
+	[403] = "Forbidden"; -- RFC7231, Section 6.5.3
+	[404] = "Not Found"; -- RFC7231, Section 6.5.4
+	[405] = "Method Not Allowed"; -- RFC7231, Section 6.5.5
+	[406] = "Not Acceptable"; -- RFC7231, Section 6.5.6
+	[407] = "Proxy Authentication Required"; -- RFC7235, Section 3.2
+	[408] = "Request Timeout"; -- RFC7231, Section 6.5.7
+	[409] = "Conflict"; -- RFC7231, Section 6.5.8
+	[410] = "Gone"; -- RFC7231, Section 6.5.9
+	[411] = "Length Required"; -- RFC7231, Section 6.5.10
+	[412] = "Precondition Failed"; -- RFC7232, Section 4.2
+	[413] = "Payload Too Large"; -- RFC7231, Section 6.5.11
+	[414] = "URI Too Long"; -- RFC7231, Section 6.5.12
+	[415] = "Unsupported Media Type"; -- RFC7231, Section 6.5.13
+	[416] = "Range Not Satisfiable"; -- RFC7233, Section 4.4
+	[417] = "Expectation Failed"; -- RFC7231, Section 6.5.14
+	[418] = "I'm a teapot"; -- RFC2324, Section 2.3.2
+	-- [419-420] = "Unassigned";
+	[421] = "Misdirected Request"; -- RFC7540, Section 9.1.2
 	[422] = "Unprocessable Entity";
 	[423] = "Locked";
 	[424] = "Failed Dependency";
-	-- The 425 status code is reserved for the WebDAV advanced collections expired proposal [RFC2817]
-	[426] = "Upgrade Required";
+	[425] = "Too Early";
+	[426] = "Upgrade Required"; -- RFC7231, Section 6.5.15
+	-- [427] = "Unassigned";
 	[428] = "Precondition Required";
 	[429] = "Too Many Requests";
+	-- [430] = "Unassigned";
 	[431] = "Request Header Fields Too Large";
+	-- [432-450] = "Unassigned";
 	[451] = "Unavailable For Legal Reasons";
+	-- [452-499] = "Unassigned";
 
-	[500] = "Internal Server Error";
-	[501] = "Not Implemented";
-	[502] = "Bad Gateway";
-	[503] = "Service Unavailable";
-	[504] = "Gateway Timeout";
-	[505] = "HTTP Version Not Supported";
-	[506] = "Variant Also Negotiates"; -- Experimental
+	[500] = "Internal Server Error"; -- RFC7231, Section 6.6.1
+	[501] = "Not Implemented"; -- RFC7231, Section 6.6.2
+	[502] = "Bad Gateway"; -- RFC7231, Section 6.6.3
+	[503] = "Service Unavailable"; -- RFC7231, Section 6.6.4
+	[504] = "Gateway Timeout"; -- RFC7231, Section 6.6.5
+	[505] = "HTTP Version Not Supported"; -- RFC7231, Section 6.6.6
+	[506] = "Variant Also Negotiates";
 	[507] = "Insufficient Storage";
 	[508] = "Loop Detected";
+	-- [509] = "Unassigned";
 	[510] = "Not Extended";
 	[511] = "Network Authentication Required";
+	-- [512-599] = "Unassigned";
 };
 
 for k,v in pairs(response_codes) do response_codes[k] = k.." "..v; end
--- a/net/http/server.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/net/http/server.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -8,7 +8,7 @@
 local pairs = pairs;
 local s_upper = string.upper;
 local setmetatable = setmetatable;
-local xpcall = xpcall;
+local xpcall = require "util.xpcall".xpcall;
 local traceback = debug.traceback;
 local tostring = tostring;
 local cache = require "util.cache";
@@ -88,8 +88,6 @@
 });
 
 local handle_request;
-local _1, _2, _3;
-local function _handle_request() return handle_request(_1, _2, _3); end
 
 local last_err;
 local function _traceback_handler(err) last_err = err; log("error", "Traceback[httpserver]: %s", traceback(tostring(err), 2)); end
@@ -107,9 +105,7 @@
 		while sessions[conn] and #pending > 0 do
 			local request = t_remove(pending);
 			--log("debug", "process_next: %s", request.path);
-			--handle_request(conn, request, process_next);
-			_1, _2, _3 = conn, request, process_next;
-			if not xpcall(_handle_request, _traceback_handler) then
+			if not xpcall(handle_request, _traceback_handler, conn, request, process_next) then
 				conn:write("HTTP/1.0 500 Internal Server Error\r\n\r\n"..events.fire_event("http-error", { code = 500, private_message = last_err }));
 				conn:close();
 			end
@@ -217,14 +213,6 @@
 	local err_code, err;
 	if not request.path then
 		err_code, err = 400, "Invalid path";
-	elseif not hosts[host] then
-		if hosts[default_host] then
-			host = default_host;
-		elseif host then
-			err_code, err = 404, "Unknown host: "..host;
-		else
-			err_code, err = 400, "Missing or invalid 'Host' header";
-		end
 	end
 
 	if err then
@@ -233,10 +221,32 @@
 		return;
 	end
 
-	local event = request.method.." "..host..request.path:match("[^?]*");
+	local global_event = request.method.." "..request.path:match("[^?]*");
+
 	local payload = { request = request, response = response };
-	log("debug", "Firing event: %s", event);
-	local result = events.fire_event(event, payload);
+	log("debug", "Firing event: %s", global_event);
+	local result = events.fire_event(global_event, payload);
+	if result == nil then
+		if not hosts[host] then
+			if hosts[default_host] then
+				host = default_host;
+			elseif host then
+				err_code, err = 404, "Unknown host: "..host;
+			else
+				err_code, err = 400, "Missing or invalid 'Host' header";
+			end
+		end
+
+		if err then
+			response.status_code = err_code;
+			response:send(events.fire_event("http-error", { code = err_code, message = err, response = response }));
+			return;
+		end
+
+		local host_event = request.method.." "..host..request.path:match("[^?]*");
+		log("debug", "Firing event: %s", host_event);
+		result = events.fire_event(host_event, payload);
+	end
 	if result ~= nil then
 		if result ~= true then
 			local body;
--- a/net/httpserver.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/net/httpserver.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -3,9 +3,10 @@
 local traceback = debug.traceback;
 
 local _ENV = nil;
+-- luacheck: std none
 
-function fail()
-	log("error", "Attempt to use legacy HTTP API. For more info see http://prosody.im/doc/developers/legacy_http");
+local function fail()
+	log("error", "Attempt to use legacy HTTP API. For more info see https://prosody.im/doc/developers/legacy_http");
 	log("error", "Legacy HTTP API usage, %s", traceback("", 2));
 end
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/net/resolvers/basic.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,71 @@
+local adns = require "net.adns";
+local inet_pton = require "util.net".pton;
+
+local methods = {};
+local resolver_mt = { __index = methods };
+
+-- Find the next target to connect to, and
+-- pass it to cb()
+function methods:next(cb)
+	if self.targets then
+		if #self.targets == 0 then
+			cb(nil);
+			return;
+		end
+		local next_target = table.remove(self.targets, 1);
+		cb(unpack(next_target, 1, 4));
+		return;
+	end
+
+	local targets = {};
+	local n = 2;
+	local function ready()
+		n = n - 1;
+		if n > 0 then return; end
+		self.targets = targets;
+		self:next(cb);
+	end
+
+	local is_ip = inet_pton(self.hostname);
+	if is_ip then
+		if #is_ip == 16 then
+			cb(self.conn_type.."6", self.hostname, self.port, self.extra);
+		elseif #is_ip == 4 then
+			cb(self.conn_type.."4", self.hostname, self.port, self.extra);
+		end
+		return;
+	end
+
+	-- Resolve DNS to target list
+	local dns_resolver = adns.resolver();
+	dns_resolver:lookup(function (answer)
+		if answer then
+			for _, record in ipairs(answer) do
+				table.insert(targets, { self.conn_type.."4", record.a, self.port, self.extra });
+			end
+		end
+		ready();
+	end, self.hostname, "A", "IN");
+
+	dns_resolver:lookup(function (answer)
+		if answer then
+			for _, record in ipairs(answer) do
+				table.insert(targets, { self.conn_type.."6", record.aaaa, self.port, self.extra });
+			end
+		end
+		ready();
+	end, self.hostname, "AAAA", "IN");
+end
+
+local function new(hostname, port, conn_type, extra)
+	return setmetatable({
+		hostname = hostname;
+		port = port;
+		conn_type = conn_type or "tcp";
+		extra = extra;
+	}, resolver_mt);
+end
+
+return {
+	new = new;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/net/resolvers/manual.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,25 @@
+local methods = {};
+local resolver_mt = { __index = methods };
+
+-- Find the next target to connect to, and
+-- pass it to cb()
+function methods:next(cb)
+	if #self.targets == 0 then
+		cb(nil);
+		return;
+	end
+	local next_target = table.remove(self.targets, 1);
+	cb(unpack(next_target, 1, 4));
+end
+
+local function new(targets, conn_type, extra)
+	return setmetatable({
+		conn_type = conn_type;
+		extra = extra;
+		targets = targets or {};
+	}, resolver_mt);
+end
+
+return {
+	new = new;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/net/resolvers/service.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,70 @@
+local adns = require "net.adns";
+local basic = require "net.resolvers.basic";
+
+local methods = {};
+local resolver_mt = { __index = methods };
+
+-- Find the next target to connect to, and
+-- pass it to cb()
+function methods:next(cb)
+	if self.targets then
+		if #self.targets == 0 then
+			cb(nil);
+			return;
+		end
+		local next_target = table.remove(self.targets, 1);
+		self.resolver = basic.new(unpack(next_target, 1, 4));
+		self.resolver:next(function (...)
+			if ... == nil then
+				self:next(cb);
+			else
+				cb(...);
+			end
+		end);
+		return;
+	end
+
+	local targets = {};
+	local function ready()
+		self.targets = targets;
+		self:next(cb);
+	end
+
+	-- Resolve DNS to target list
+	local dns_resolver = adns.resolver();
+	dns_resolver:lookup(function (answer)
+		if answer then
+			if #answer == 0 then
+				if self.extra and self.extra.default_port then
+					table.insert(targets, { self.hostname, self.extra.default_port, self.conn_type, self.extra });
+				end
+				ready();
+				return;
+			end
+
+			if #answer == 1 and answer[1].srv.target == "." then -- No service here
+				ready();
+				return;
+			end
+
+			table.sort(answer, function (a, b) return a.srv.priority < b.srv.priority end);
+			for _, record in ipairs(answer) do
+				table.insert(targets, { record.srv.target, record.srv.port, self.conn_type, self.extra });
+			end
+		end
+		ready();
+	end, "_" .. self.service .. "._" .. self.conn_type .. "." .. self.hostname, "SRV", "IN");
+end
+
+local function new(hostname, service, conn_type, extra)
+	return setmetatable({
+		hostname = hostname;
+		service = service;
+		conn_type = conn_type or "tcp";
+		extra = extra;
+	}, resolver_mt);
+end
+
+return {
+	new = new;
+};
--- a/net/server.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/net/server.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -6,25 +6,83 @@
 -- COPYING file in the source package for more information.
 --
 
-local use_luaevent = prosody and require "core.configmanager".get("*", "use_libevent");
+if not (prosody and prosody.config_loaded) then
+	-- This module only supports loading inside Prosody, outside Prosody
+	-- you should directly require net.server_select or server_event, etc.
+	error(debug.traceback("Loading outside Prosody or Prosody not yet initialized"), 0);
+end
+
+local log = require "util.logger".init("net.server");
+local server_type = require "core.configmanager".get("*", "network_backend") or "select";
 
-if use_luaevent then
-	use_luaevent = pcall(require, "luaevent.core");
-	if not use_luaevent then
+if require "core.configmanager".get("*", "use_libevent") then
+	server_type = "event";
+end
+
+if server_type == "event" then
+	if not pcall(require, "luaevent.core") then
 		log("error", "libevent not found, falling back to select()");
+		server_type = "select"
 	end
 end
 
 local server;
-
-if use_luaevent then
+local set_config;
+if server_type == "event" then
 	server = require "net.server_event";
 
-	-- Overwrite signal.signal() because we need to ask libevent to
-	-- handle them instead
-	local ok, signal = pcall(require, "util.signal");
-	if ok and signal then
-		local _signal_signal = signal.signal;
+	local defaults = {};
+	for k,v in pairs(server.cfg) do
+		defaults[k] = v;
+	end
+	function set_config(settings)
+		local event_settings = {
+			ACCEPT_DELAY = settings.accept_retry_interval;
+			ACCEPT_QUEUE = settings.tcp_backlog;
+			CLEAR_DELAY = settings.event_clear_interval;
+			CONNECT_TIMEOUT = settings.connect_timeout;
+			DEBUG = settings.debug;
+			HANDSHAKE_TIMEOUT = settings.ssl_handshake_timeout;
+			MAX_CONNECTIONS = settings.max_connections;
+			MAX_HANDSHAKE_ATTEMPTS = settings.max_ssl_handshake_roundtrips;
+			MAX_READ_LENGTH = settings.max_receive_buffer_size;
+			MAX_SEND_LENGTH = settings.max_send_buffer_size;
+			READ_TIMEOUT = settings.read_timeout;
+			WRITE_TIMEOUT = settings.send_timeout;
+		};
+
+		for k,default in pairs(defaults) do
+			server.cfg[k] = event_settings[k] or default;
+		end
+	end
+elseif server_type == "select" then
+	server = require "net.server_select";
+
+	local defaults = {};
+	for k,v in pairs(server.getsettings()) do
+		defaults[k] = v;
+	end
+	function set_config(settings)
+		local select_settings = {};
+		for k,default in pairs(defaults) do
+			select_settings[k] = settings[k] or default;
+		end
+		server.changesettings(select_settings);
+	end
+else
+	server = require("net.server_"..server_type);
+	set_config = server.set_config;
+	if not server.get_backend then
+		function server.get_backend()
+			return server_type;
+		end
+	end
+end
+
+-- If server.hook_signal exists, replace signal.signal()
+local has_signal, signal = pcall(require, "util.signal");
+if has_signal then
+	if server.hook_signal then
 		function signal.signal(signal_id, handler)
 			if type(signal_id) == "string" then
 				signal_id = signal[signal_id:upper()];
@@ -34,46 +92,22 @@
 			end
 			return server.hook_signal(signal_id, handler);
 		end
+	else
+		server.hook_signal = signal.signal;
 	end
 else
-	use_luaevent = false;
-	server = require "net.server_select";
+	if not server.hook_signal then
+		server.hook_signal = function()
+			return false, "signal hooking not supported"
+		end
+	end
 end
 
-if prosody then
+if prosody and set_config then
 	local config_get = require "core.configmanager".get;
-	local defaults = {};
-	for k,v in pairs(server.cfg or server.getsettings()) do
-		defaults[k] = v;
-	end
 	local function load_config()
 		local settings = config_get("*", "network_settings") or {};
-		if use_luaevent then
-			local event_settings = {
-				ACCEPT_DELAY = settings.accept_retry_interval;
-				ACCEPT_QUEUE = settings.tcp_backlog;
-				CLEAR_DELAY = settings.event_clear_interval;
-				CONNECT_TIMEOUT = settings.connect_timeout;
-				DEBUG = settings.debug;
-				HANDSHAKE_TIMEOUT = settings.ssl_handshake_timeout;
-				MAX_CONNECTIONS = settings.max_connections;
-				MAX_HANDSHAKE_ATTEMPTS = settings.max_ssl_handshake_roundtrips;
-				MAX_READ_LENGTH = settings.max_receive_buffer_size;
-				MAX_SEND_LENGTH = settings.max_send_buffer_size;
-				READ_TIMEOUT = settings.read_timeout;
-				WRITE_TIMEOUT = settings.send_timeout;
-			};
-
-			for k,default in pairs(defaults) do
-				server.cfg[k] = event_settings[k] or default;
-			end
-		else
-			local select_settings = {};
-			for k,default in pairs(defaults) do
-				select_settings[k] = settings[k] or default;
-			end
-			server.changesettings(select_settings);
-		end
+		return set_config(settings);
 	end
 	load_config();
 	prosody.events.add_handler("config-reloaded", load_config);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/net/server_epoll.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,808 @@
+-- Prosody IM
+-- Copyright (C) 2016-2018 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+
+local t_sort = table.sort;
+local t_insert = table.insert;
+local t_remove = table.remove;
+local t_concat = table.concat;
+local setmetatable = setmetatable;
+local tostring = tostring;
+local pcall = pcall;
+local type = type;
+local next = next;
+local pairs = pairs;
+local log = require "util.logger".init("server_epoll");
+local socket = require "socket";
+local luasec = require "ssl";
+local gettime = require "util.time".now;
+local createtable = require "util.table".create;
+local inet = require "util.net";
+local inet_pton = inet.pton;
+local _SOCKETINVALID = socket._SOCKETINVALID or -1;
+
+local poller = require "util.poll"
+local EEXIST = poller.EEXIST;
+local ENOENT = poller.ENOENT;
+
+local poll = assert(poller.new());
+
+local _ENV = nil;
+-- luacheck: std none
+
+local default_config = { __index = {
+	-- If a connection is silent for this long, close it unless onreadtimeout says not to
+	read_timeout = 14 * 60;
+
+	-- How long to wait for a socket to become writable after queuing data to send
+	write_timeout = 60;
+
+	-- Some number possibly influencing how many pending connections can be accepted
+	tcp_backlog = 128;
+
+	-- If accepting a new incoming connection fails, wait this long before trying again
+	accept_retry_interval = 10;
+
+	-- If there is still more data to read from LuaSocktes buffer, wait this long and read again
+	read_retry_delay = 1e-06;
+
+	-- Size of chunks to read from sockets
+	read_size = 8192;
+
+	-- Timeout used during between steps in TLS handshakes
+	handshake_timeout = 60;
+
+	-- Maximum and minimum amount of time to sleep waiting for events (adjusted for pending timers)
+	max_wait = 86400;
+	min_wait = 1e-06;
+}};
+local cfg = default_config.__index;
+
+local fds = createtable(10, 0); -- FD -> conn
+
+-- Timer and scheduling --
+
+local timers = {};
+
+local function noop() end
+local function closetimer(t)
+	t[1] = 0;
+	t[2] = noop;
+end
+
+-- Set to true when timers have changed
+local resort_timers = false;
+
+-- Add absolute timer
+local function at(time, f)
+	local timer = { time, f, close = closetimer };
+	t_insert(timers, timer);
+	resort_timers = true;
+	return timer;
+end
+
+-- Add relative timer
+local function addtimer(timeout, f)
+	return at(gettime() + timeout, f);
+end
+
+-- Run callbacks of expired timers
+-- Return time until next timeout
+local function runtimers(next_delay, min_wait)
+	-- Any timers at all?
+	if not timers[1] then
+		return next_delay;
+	end
+
+	if resort_timers then
+		-- Sort earliest timers to the end
+		t_sort(timers, function (a, b) return a[1] > b[1]; end);
+		resort_timers = false;
+	end
+
+	-- Iterate from the end and remove completed timers
+	for i = #timers, 1, -1 do
+		local timer = timers[i];
+		local t, f = timer[1], timer[2];
+		-- Get time for every iteration to increase accuracy
+		local now = gettime();
+		if t > now then
+			-- This timer should not fire yet
+			local diff = t - now;
+			if diff < next_delay then
+				next_delay = diff;
+			end
+			break;
+		end
+		local new_timeout = f(now);
+		if new_timeout then
+			-- Schedule for 'delay' from the time actually scheduled,
+			-- not from now, in order to prevent timer drift.
+			timer[1] = t + new_timeout;
+			resort_timers = true;
+		else
+			t_remove(timers, i);
+		end
+	end
+
+	if resort_timers or next_delay < min_wait then
+		-- Timers may be added from within a timer callback.
+		-- Those would not be considered for next_delay,
+		-- and we might sleep for too long, so instead
+		-- we return a shorter timeout so we can
+		-- properly sort all new timers.
+		next_delay = min_wait;
+	end
+
+	return next_delay;
+end
+
+-- Socket handler interface
+
+local interface = {};
+local interface_mt = { __index = interface };
+
+function interface_mt:__tostring()
+	if self.sockname and self.peername then
+		return ("FD %d (%s, %d, %s, %d)"):format(self:getfd(), self.peername, self.peerport, self.sockname, self.sockport);
+	elseif self.sockname or self.peername then
+		return ("FD %d (%s, %d)"):format(self:getfd(), self.sockname or self.peername, self.sockport or self.peerport);
+	end
+	return ("FD %d"):format(self:getfd());
+end
+
+-- Replace the listener and tell the old one
+function interface:setlistener(listeners, data)
+	self:on("detach");
+	self.listeners = listeners;
+	self:on("attach", data);
+end
+
+-- Call a listener callback
+function interface:on(what, ...)
+	if not self.listeners then
+		log("error", "%s has no listeners", self);
+		return;
+	end
+	local listener = self.listeners["on"..what];
+	if not listener then
+		-- log("debug", "Missing listener 'on%s'", what); -- uncomment for development and debugging
+		return;
+	end
+	local ok, err = pcall(listener, self, ...);
+	if not ok then
+		log("error", "Error calling on%s: %s", what, err);
+	end
+	return err;
+end
+
+-- Return the file descriptor number
+function interface:getfd()
+	if self.conn then
+		return self.conn:getfd();
+	end
+	return _SOCKETINVALID;
+end
+
+function interface:server()
+	return self._server or self;
+end
+
+-- Get IP address
+function interface:ip()
+	return self.peername or self.sockname;
+end
+
+-- Get a port number, doesn't matter which
+function interface:port()
+	return self.sockport or self.peerport;
+end
+
+-- Get local port number
+function interface:clientport()
+	return self.sockport;
+end
+
+-- Get remote port
+function interface:serverport()
+	if self.sockport then
+		return self.sockport;
+	elseif self._server then
+		self._server:port();
+	end
+end
+
+-- Return underlying socket
+function interface:socket()
+	return self.conn;
+end
+
+function interface:set_mode(new_mode)
+	self.read_size = new_mode;
+end
+
+function interface:setoption(k, v)
+	-- LuaSec doesn't expose setoption :(
+	if self.conn.setoption then
+		self.conn:setoption(k, v);
+	end
+end
+
+-- Timeout for detecting dead or idle sockets
+function interface:setreadtimeout(t)
+	if t == false then
+		if self._readtimeout then
+			self._readtimeout:close();
+			self._readtimeout = nil;
+		end
+		return
+	end
+	t = t or cfg.read_timeout;
+	if self._readtimeout then
+		self._readtimeout[1] = gettime() + t;
+		resort_timers = true;
+	else
+		self._readtimeout = addtimer(t, function ()
+			if self:on("readtimeout") then
+				return cfg.read_timeout;
+			else
+				self:on("disconnect", "read timeout");
+				self:destroy();
+			end
+		end);
+	end
+end
+
+-- Timeout for detecting dead sockets
+function interface:setwritetimeout(t)
+	if t == false then
+		if self._writetimeout then
+			self._writetimeout:close();
+			self._writetimeout = nil;
+		end
+		return
+	end
+	t = t or cfg.write_timeout;
+	if self._writetimeout then
+		self._writetimeout[1] = gettime() + t;
+		resort_timers = true;
+	else
+		self._writetimeout = addtimer(t, function ()
+			self:on("disconnect", "write timeout");
+			self:destroy();
+		end);
+	end
+end
+
+function interface:add(r, w)
+	local fd = self:getfd();
+	if fd < 0 then
+		return nil, "invalid fd";
+	end
+	if r == nil then r = self._wantread; end
+	if w == nil then w = self._wantwrite; end
+	local ok, err, errno = poll:add(fd, r, w);
+	if not ok then
+		if errno == EEXIST then
+			log("debug", "%s already registered!", self);
+			return self:set(r, w); -- So try to change its flags
+		end
+		log("error", "Could not register %s: %s(%d)", self, err, errno);
+		return ok, err;
+	end
+	self._wantread, self._wantwrite = r, w;
+	fds[fd] = self;
+	log("debug", "Watching %s", self);
+	return true;
+end
+
+function interface:set(r, w)
+	local fd = self:getfd();
+	if fd < 0 then
+		return nil, "invalid fd";
+	end
+	if r == nil then r = self._wantread; end
+	if w == nil then w = self._wantwrite; end
+	local ok, err, errno = poll:set(fd, r, w);
+	if not ok then
+		log("error", "Could not update poller state %s: %s(%d)", self, err, errno);
+		return ok, err;
+	end
+	self._wantread, self._wantwrite = r, w;
+	return true;
+end
+
+function interface:del()
+	local fd = self:getfd();
+	if fd < 0 then
+		return nil, "invalid fd";
+	end
+	if fds[fd] ~= self then
+		return nil, "unregistered fd";
+	end
+	local ok, err, errno = poll:del(fd);
+	if not ok and errno ~= ENOENT then
+		log("error", "Could not unregister %s: %s(%d)", self, err, errno);
+		return ok, err;
+	end
+	self._wantread, self._wantwrite = nil, nil;
+	fds[fd] = nil;
+	log("debug", "Unwatched %s", self);
+	return true;
+end
+
+function interface:setflags(r, w)
+	if not(self._wantread or self._wantwrite) then
+		if not(r or w) then
+			return true; -- no change
+		end
+		return self:add(r, w);
+	end
+	if not(r or w) then
+		return self:del();
+	end
+	return self:set(r, w);
+end
+
+-- Called when socket is readable
+function interface:onreadable()
+	local data, err, partial = self.conn:receive(self.read_size or cfg.read_size);
+	if data then
+		self:onconnect();
+		self:on("incoming", data);
+	else
+		if partial and partial ~= "" then
+			self:onconnect();
+			self:on("incoming", partial, err);
+		end
+		if err == "wantread" then
+			self:set(true, nil);
+		elseif err == "wantwrite" then
+			self:set(nil, true);
+		elseif err ~= "timeout" then
+			self:on("disconnect", err);
+			self:destroy()
+			return;
+		end
+	end
+	if not self.conn then return; end
+	if self.conn:dirty() then
+		self:setreadtimeout(false);
+		self:pausefor(cfg.read_retry_delay);
+	else
+		self:setreadtimeout();
+	end
+end
+
+-- Called when socket is writable
+function interface:onwritable()
+	self:onconnect();
+	if not self.conn then return; end -- could have been closed in onconnect
+	local buffer = self.writebuffer;
+	local data = t_concat(buffer);
+	local ok, err, partial = self.conn:send(data);
+	if ok then
+		self:set(nil, false);
+		for i = #buffer, 1, -1 do
+			buffer[i] = nil;
+		end
+		self:setwritetimeout(false);
+		self:ondrain(); -- Be aware of writes in ondrain
+		return;
+	elseif partial then
+		buffer[1] = data:sub(partial+1);
+		for i = #buffer, 2, -1 do
+			buffer[i] = nil;
+		end
+		self:setwritetimeout();
+	end
+	if err == "wantwrite" or err == "timeout" then
+		self:set(nil, true);
+	elseif err == "wantread" then
+		self:set(true, nil);
+	elseif err ~= "timeout" then
+		self:on("disconnect", err);
+		self:destroy();
+	end
+end
+
+-- The write buffer has been successfully emptied
+function interface:ondrain()
+	return self:on("drain");
+end
+
+-- Add data to write buffer and set flag for wanting to write
+function interface:write(data)
+	local buffer = self.writebuffer;
+	if buffer then
+		t_insert(buffer, data);
+	else
+		self.writebuffer = { data };
+	end
+	self:setwritetimeout();
+	self:set(nil, true);
+	return #data;
+end
+interface.send = interface.write;
+
+-- Close, possibly after writing is done
+function interface:close()
+	if self.writebuffer and self.writebuffer[1] then
+		self:set(false, true); -- Flush final buffer contents
+		self.write, self.send = noop, noop; -- No more writing
+		log("debug", "Close %s after writing", self);
+		self.ondrain = interface.close;
+	else
+		log("debug", "Close %s now", self);
+		self.write, self.send = noop, noop;
+		self.close = noop;
+		self:on("disconnect");
+		self:destroy();
+	end
+end
+
+function interface:destroy()
+	self:del();
+	self:setwritetimeout(false);
+	self:setreadtimeout(false);
+	self.onreadable = noop;
+	self.onwritable = noop;
+	self.destroy = noop;
+	self.close = noop;
+	self.on = noop;
+	self.conn:close();
+	self.conn = nil;
+end
+
+function interface:ssl()
+	return self._tls;
+end
+
+function interface:starttls(tls_ctx)
+	if tls_ctx then self.tls_ctx = tls_ctx; end
+	self.starttls = false;
+	if self.writebuffer and self.writebuffer[1] then
+		log("debug", "Start TLS on %s after write", self);
+		self.ondrain = interface.starttls;
+		self:set(nil, true); -- make sure wantwrite is set
+	else
+		if self.ondrain == interface.starttls then
+			self.ondrain = nil;
+		end
+		self.onwritable = interface.tlshandskake;
+		self.onreadable = interface.tlshandskake;
+		self:set(true, true);
+		log("debug", "Prepare to start TLS on %s", self);
+	end
+end
+
+function interface:tlshandskake()
+	self:setwritetimeout(false);
+	self:setreadtimeout(false);
+	if not self._tls then
+		self._tls = true;
+		log("debug", "Start TLS on %s now", self);
+		self:del();
+		local ok, conn, err = pcall(luasec.wrap, self.conn, self.tls_ctx);
+		if not ok then
+			conn, err = ok, conn;
+			log("error", "Failed to initialize TLS: %s", err);
+		end
+		if not conn then
+			self:on("disconnect", err);
+			self:destroy();
+			return conn, err;
+		end
+		conn:settimeout(0);
+		self.conn = conn;
+		self:on("starttls");
+		self.ondrain = nil;
+		self.onwritable = interface.tlshandskake;
+		self.onreadable = interface.tlshandskake;
+		return self:init();
+	end
+	local ok, err = self.conn:dohandshake();
+	if ok then
+		log("debug", "TLS handshake on %s complete", self);
+		self.onwritable = nil;
+		self.onreadable = nil;
+		self:on("status", "ssl-handshake-complete");
+		self:setwritetimeout();
+		self:set(true, true);
+	elseif err == "wantread" then
+		log("debug", "TLS handshake on %s to wait until readable", self);
+		self:set(true, false);
+		self:setreadtimeout(cfg.handshake_timeout);
+	elseif err == "wantwrite" then
+		log("debug", "TLS handshake on %s to wait until writable", self);
+		self:set(false, true);
+		self:setwritetimeout(cfg.handshake_timeout);
+	else
+		log("debug", "TLS handshake error on %s: %s", self, err);
+		self:on("disconnect", err);
+		self:destroy();
+	end
+end
+
+local function wrapsocket(client, server, read_size, listeners, tls_ctx) -- luasocket object -> interface object
+	client:settimeout(0);
+	local conn = setmetatable({
+		conn = client;
+		_server = server;
+		created = gettime();
+		listeners = listeners;
+		read_size = read_size or (server and server.read_size);
+		writebuffer = {};
+		tls_ctx = tls_ctx or (server and server.tls_ctx);
+		tls_direct = server and server.tls_direct;
+	}, interface_mt);
+
+	conn:updatenames();
+	return conn;
+end
+
+function interface:updatenames()
+	local conn = self.conn;
+	local ok, peername, peerport = pcall(conn.getpeername, conn);
+	if ok then
+		self.peername, self.peerport = peername, peerport;
+	end
+	local ok, sockname, sockport = pcall(conn.getsockname, conn);
+	if ok then
+		self.sockname, self.sockport = sockname, sockport;
+	end
+end
+
+-- A server interface has new incoming connections waiting
+-- This replaces the onreadable callback
+function interface:onacceptable()
+	local conn, err = self.conn:accept();
+	if not conn then
+		log("debug", "Error accepting new client: %s, server will be paused for %ds", err, cfg.accept_retry_interval);
+		self:pausefor(cfg.accept_retry_interval);
+		return;
+	end
+	local client = wrapsocket(conn, self, nil, self.listeners);
+	log("debug", "New connection %s", tostring(client));
+	client:init();
+	if self.tls_direct then
+		client:starttls(self.tls_ctx);
+	end
+end
+
+-- Initialization
+function interface:init()
+	self:setwritetimeout();
+	return self:add(true, true);
+end
+
+function interface:pause()
+	return self:set(false);
+end
+
+function interface:resume()
+	return self:set(true);
+end
+
+-- Pause connection for some time
+function interface:pausefor(t)
+	if self._pausefor then
+		self._pausefor:close();
+	end
+	if t == false then return; end
+	self:set(false);
+	self._pausefor = addtimer(t, function ()
+		self._pausefor = nil;
+		if self.conn and self.conn:dirty() then
+			self:onreadable();
+		end
+		self:set(true);
+	end);
+end
+
+-- Connected!
+function interface:onconnect()
+	if self.conn and not self.peername and self.conn.getpeername then
+		self.peername, self.peerport = self.conn:getpeername();
+	end
+	self.onconnect = noop;
+	self:on("connect");
+end
+
+local function addserver(addr, port, listeners, read_size, tls_ctx)
+	local conn, err = socket.bind(addr, port, cfg.tcp_backlog);
+	if not conn then return conn, err; end
+	conn:settimeout(0);
+	local server = setmetatable({
+		conn = conn;
+		created = gettime();
+		listeners = listeners;
+		read_size = read_size;
+		onreadable = interface.onacceptable;
+		tls_ctx = tls_ctx;
+		tls_direct = tls_ctx and true or false;
+		sockname = addr;
+		sockport = port;
+	}, interface_mt);
+	server:add(true, false);
+	return server;
+end
+
+-- COMPAT
+local function wrapclient(conn, addr, port, listeners, read_size, tls_ctx)
+	local client = wrapsocket(conn, nil, read_size, listeners, tls_ctx);
+	if not client.peername then
+		client.peername, client.peerport = addr, port;
+	end
+	local ok, err = client:init();
+	if not ok then return ok, err; end
+	if tls_ctx then
+		client:starttls(tls_ctx);
+	end
+	return client;
+end
+
+-- New outgoing TCP connection
+local function addclient(addr, port, listeners, read_size, tls_ctx, typ)
+	local create;
+	if not typ then
+		local n = inet_pton(addr);
+		if not n then return nil, "invalid-ip"; end
+		if #n == 16 then
+			typ = "tcp6";
+		else
+			typ = "tcp4";
+		end
+	end
+	if typ then
+		create = socket[typ];
+	end
+	if type(create) ~= "function" then
+		return nil, "invalid socket type";
+	end
+	local conn, err = create();
+	local ok, err = conn:settimeout(0);
+	if not ok then return ok, err; end
+	local ok, err = conn:setpeername(addr, port);
+	if not ok and err ~= "timeout" then return ok, err; end
+	local client = wrapsocket(conn, nil, read_size, listeners, tls_ctx)
+	local ok, err = client:init();
+	if not ok then return ok, err; end
+	if tls_ctx then
+		client:starttls(tls_ctx);
+	end
+	return client, conn;
+end
+
+local function watchfd(fd, onreadable, onwritable)
+	local conn = setmetatable({
+		conn = fd;
+		onreadable = onreadable;
+		onwritable = onwritable;
+		close = function (self)
+			self:del();
+		end
+	}, interface_mt);
+	if type(fd) == "number" then
+		conn.getfd = function ()
+			return fd;
+		end;
+		-- Otherwise it'll need to be something LuaSocket-compatible
+	end
+	conn:add(onreadable, onwritable);
+	return conn;
+end;
+
+-- Dump all data from one connection into another
+local function link(from, to)
+	from.listeners = setmetatable({
+		onincoming = function (_, data)
+			from:pause();
+			to:write(data);
+		end,
+	}, {__index=from.listeners});
+	to.listeners = setmetatable({
+		ondrain = function ()
+			from:resume();
+		end,
+	}, {__index=to.listeners});
+	from:set(true, nil);
+	to:set(nil, true);
+end
+
+-- COMPAT
+-- net.adns calls this but then replaces :send so this can be a noop
+function interface:set_send(new_send) -- luacheck: ignore 212
+end
+
+-- Close all connections and servers
+local function closeall()
+	for fd, conn in pairs(fds) do -- luacheck: ignore 213/fd
+		conn:close();
+	end
+end
+
+local quitting = nil;
+
+-- Signal main loop about shutdown via above upvalue
+local function setquitting(quit)
+	if quit then
+		quitting = "quitting";
+		closeall();
+	else
+		quitting = nil;
+	end
+end
+
+-- Main loop
+local function loop(once)
+	repeat
+		local t = runtimers(cfg.max_wait, cfg.min_wait);
+		local fd, r, w = poll:wait(t);
+		if fd then
+			local conn = fds[fd];
+			if conn then
+				if r then
+					conn:onreadable();
+				end
+				if w then
+					conn:onwritable();
+				end
+			else
+				log("debug", "Removing unknown fd %d", fd);
+				poll:del(fd);
+			end
+		elseif r ~= "timeout" and r ~= "signal" then
+			log("debug", "epoll_wait error: %s[%d]", r, w);
+		end
+	until once or (quitting and next(fds) == nil);
+	return quitting;
+end
+
+return {
+	get_backend = function () return "epoll"; end;
+	addserver = addserver;
+	addclient = addclient;
+	add_task = addtimer;
+	at = at;
+	loop = loop;
+	closeall = closeall;
+	setquitting = setquitting;
+	wrapclient = wrapclient;
+	watchfd = watchfd;
+	link = link;
+	set_config = function (newconfig)
+		cfg = setmetatable(newconfig, default_config);
+	end;
+
+	-- libevent emulation
+	event = { EV_READ = "r", EV_WRITE = "w", EV_READWRITE = "rw", EV_LEAVE = -1 };
+	addevent = function (fd, mode, callback)
+		local function onevent(self)
+			local ret = self:callback();
+			if ret == -1 then
+				self:set(false, false);
+			elseif ret then
+				self:set(mode == "r" or mode == "rw", mode == "w" or mode == "rw");
+			end
+		end
+
+		local conn = setmetatable({
+			getfd = function () return fd; end;
+			callback = callback;
+			onreadable = onevent;
+			onwritable = onevent;
+			close = function (self)
+				self:del();
+				fds[fd] = nil;
+			end;
+		}, interface_mt);
+		local ok, err = conn:add(mode == "r" or mode == "rw", mode == "w" or mode == "rw");
+		if not ok then return ok, err; end
+		return conn;
+	end;
+};
--- a/net/server_event.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/net/server_event.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -5,9 +5,9 @@
 
 			notes:
 			-- when using luaevent, never register 2 or more EV_READ at one socket, same for EV_WRITE
-			-- you cant even register a new EV_READ/EV_WRITE callback inside another one
+			-- you can't even register a new EV_READ/EV_WRITE callback inside another one
 			-- to do some of the above, use timeout events or something what will called from outside
-			-- dont let garbagecollect eventcallbacks, as long they are running
+			-- don't let garbagecollect eventcallbacks, as long they are running
 			-- when using luasec, there are 4 cases of timeout errors: wantread or wantwrite during reading or writing
 
 --]]
@@ -26,7 +26,7 @@
 	MAX_SEND_LENGTH       = 1024 * 1024 * 1024 * 1024,  -- max bytes size of write buffer (for writing on sockets)
 	ACCEPT_QUEUE          = 128,  -- might influence the length of the pending sockets queue
 	ACCEPT_DELAY          = 10,  -- seconds to wait until the next attempt of a full server to accept
-	READ_TIMEOUT          = 60 * 60 * 6,  -- timeout in seconds for read data from socket
+	READ_TIMEOUT          = 14 * 60,  -- timeout in seconds for read data from socket
 	WRITE_TIMEOUT         = 180,  -- timeout in seconds for write data on socket
 	CONNECT_TIMEOUT       = 20,  -- timeout in seconds for connection attempts
 	CLEAR_DELAY           = 5,  -- seconds to wait for clearing interface list (and calling ondisconnect listeners)
@@ -50,9 +50,10 @@
 local has_luasec, ssl = pcall ( require , "ssl" )
 local socket = require "socket"
 local levent = require "luaevent.core"
+local inet = require "util.net";
+local inet_pton = inet.pton;
 
 local socket_gettime = socket.gettime
-local getaddrinfo = socket.dns.getaddrinfo
 
 local log = require ("util.logger").init("socket")
 
@@ -106,6 +107,12 @@
 			self:_close()
 			debug( "new connection failed. id:", self.id, "error:", self.fatalerror )
 		else
+			if EV_READWRITE == event then
+				if self.readcallback(event) == -1 then
+					-- Fatal error occurred
+					return -1;
+				end
+			end
 			if plainssl and has_luasec then  -- start ssl session
 				self:starttls(self._sslctx, true)
 			else  -- normal connection
@@ -116,7 +123,7 @@
 		self.eventconnect = nil
 		return -1
 	end
-	self.eventconnect = addevent( base, self.conn, EV_WRITE, callback, cfg.CONNECT_TIMEOUT )
+	self.eventconnect = addevent( base, self.conn, EV_READWRITE, callback, cfg.CONNECT_TIMEOUT )
 	return true
 end
 function interface_mt:_start_session(call_onconnect) -- new session, for example after startssl
@@ -151,7 +158,7 @@
 		self.fatalerror = err
 		self.conn = nil  -- cannot be used anymore
 		if call_onconnect then
-			self.ondisconnect = nil  -- dont call this when client isnt really connected
+			self.ondisconnect = nil  -- don't call this when client isn't really connected
 		end
 		self:_close()
 		debug( "fatal error while ssl wrapping:", err )
@@ -194,7 +201,7 @@
 			end
 			if self.fatalerror then
 				if call_onconnect then
-					self.ondisconnect = nil  -- dont call this when client isnt really connected
+					self.ondisconnect = nil  -- don't call this when client isn't really connected
 				end
 				self:_close()
 				debug( "handshake failed because:", self.fatalerror )
@@ -223,7 +230,8 @@
 		_ = self.eventsession and self.eventsession:close( )
 		_ = self.eventwritetimeout and self.eventwritetimeout:close( )
 		_ = self.eventreadtimeout and self.eventreadtimeout:close( )
-		_ = self.ondisconnect and self:ondisconnect( self.fatalerror ~= "client to close" and self.fatalerror)  -- call ondisconnect listener (wont be the case if handshake failed on connect)
+		-- call ondisconnect listener (won't be the case if handshake failed on connect)
+		_ = self.ondisconnect and self:ondisconnect( self.fatalerror ~= "client to close" and self.fatalerror)
 		_ = self.conn and self.conn:close( ) -- close connection
 		_ = self._server and self._server:counter(-1);
 		self.eventread, self.eventwrite = nil, nil
@@ -408,7 +416,7 @@
 	return false, "setoption not implemented";
 end
 
-function interface_mt:setlistener(listener)
+function interface_mt:setlistener(listener, data)
 	self:ondetach(); -- Notify listener that it is no longer responsible for this connection
 	self.onconnect = listener.onconnect;
 	self.ondisconnect = listener.ondisconnect;
@@ -417,7 +425,9 @@
 	self.onreadtimeout = listener.onreadtimeout;
 	self.onstatus = listener.onstatus;
 	self.ondetach = listener.ondetach;
+	self.onattach = listener.onattach;
 	self.ondrain = listener.ondrain;
+	self:onattach(data);
 end
 
 -- Stub handlers
@@ -439,6 +449,8 @@
 end
 function interface_mt:ondetach()
 end
+function interface_mt:onattach()
+end
 function interface_mt:onstatus()
 end
 
@@ -510,7 +522,7 @@
 			interface.writebuffer = { t_concat(interface.writebuffer) }
 			local succ, err, byte = interface.conn:send( interface.writebuffer[1], 1, interface.writebufferlen )
 			--vdebug( "write data:", interface.writebuffer, "error:", err, "part:", byte )
-			if succ then  -- writing succesful
+			if succ then  -- writing successful
 				interface.writebuffer[1] = nil
 				interface.writebufferlen = 0
 				interface:ondrain();
@@ -539,7 +551,7 @@
 						return -1;
 					end
 					interface.eventwritetimeout = addevent( base, nil, EV_TIMEOUT, callback, cfg.WRITE_TIMEOUT )  -- reg a new timeout event
-					debug( "wantread during write attempt, reg it in readcallback but dont know what really happens next..." )
+					debug( "wantread during write attempt, reg it in readcallback but don't know what really happens next..." )
 					-- hopefully this works with luasec; its simply not possible to use 2 different write events on a socket in luaevent
 					return -1
 				end
@@ -595,8 +607,8 @@
 				end
 				interface.eventreadtimeout = addevent( base, nil, EV_TIMEOUT,
 					function( ) interface:_close() end, cfg.READ_TIMEOUT)
-				debug( "wantwrite during read attempt, reg it in writecallback but dont know what really happens next..." )
-				-- to be honest i dont know what happens next, if it is allowed to first read, the write etc...
+				debug( "wantwrite during read attempt, reg it in writecallback but don't know what really happens next..." )
+				-- to be honest i don't know what happens next, if it is allowed to first read, the write etc...
 			else  -- connection was closed or fatal error
 				interface.fatalerror = err
 				debug( "connection failed in read event:", interface.fatalerror )
@@ -717,15 +729,15 @@
 		return nil, "luasec not found"
 	end
 	if not typ then
-		local addrinfo, err = getaddrinfo(addr)
-		if not addrinfo then return nil, err end
-		if addrinfo[1] and addrinfo[1].family == "inet6" then
-			typ = "tcp6"
-		else
-			typ = "tcp"
+		local n = inet_pton(addr);
+		if not n then return nil, "invalid-ip"; end
+		if #n == 16 then
+			typ = "tcp6";
+		elseif #n == 4 then
+			typ = "tcp4";
 		end
 	end
-	local create = socket[typ]
+	local create = socket[typ];
 	if type( create ) ~= "function"  then
 		return nil, "invalid socket type"
 	end
@@ -735,7 +747,7 @@
 		return nil, err
 	end
 	client:settimeout( 0 )  -- set nonblocking
-	local res, err = client:connect( addr, serverport )  -- connect
+	local res, err = client:setpeername( addr, serverport )  -- connect
 	if res or ( err == "timeout" ) then
 		local ip, port = client:getsockname( )
 		local interface = wrapclient( client, ip, serverport, listener, pattern, sslctx )
@@ -767,13 +779,15 @@
 local function setquitting(yes)
 	if yes then
 		-- Quit now
-		closeallservers();
+		if yes ~= "once" then
+			closeallservers();
+		end
 		base:loopexit();
 	end
 end
 
 local function get_backend()
-	return base:method();
+	return "libevent " .. base:method();
 end
 
 -- We need to hold onto the events to stop them
@@ -811,6 +825,48 @@
 	sender:set_mode("*a");
 end
 
+local function add_task(delay, callback)
+	local event_handle;
+	event_handle = base:addevent(nil, 0, function ()
+		local ret = callback(socket_gettime());
+		if ret then
+			return 0, ret;
+		elseif event_handle then
+			return -1;
+		end
+	end
+	, delay);
+	return event_handle;
+end
+
+local function watchfd(fd, onreadable, onwriteable)
+	local handle = {};
+	function handle:setflags(r,w)
+		if r ~= nil then
+			if r and not self.wantread then
+				self.wantread = base:addevent(fd, EV_READ, function ()
+					onreadable(self);
+				end);
+			elseif not r and self.wantread then
+				self.wantread:close();
+				self.wantread = nil;
+			end
+		end
+		if w ~= nil then
+			if w and not self.wantwrite then
+				self.wantwrite = base:addevent(fd, EV_WRITE, function ()
+					onwriteable(self);
+				end);
+			elseif not r and self.wantread then
+				self.wantwrite:close();
+				self.wantwrite = nil;
+			end
+		end
+	end
+	handle:setflags(onreadable, onwriteable);
+	return handle;
+end
+
 return {
 	cfg = cfg,
 	base = base,
@@ -826,6 +882,8 @@
 	closeall = closeallservers,
 	get_backend = get_backend,
 	hook_signal = hook_signal,
+	add_task = add_task,
+	watchfd = watchfd,
 
 	__NAME = SCRIPT_NAME,
 	__DATE = LAST_MODIFIED,
--- a/net/server_select.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/net/server_select.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -40,6 +40,7 @@
 local math_min = math.min
 local math_huge = math.huge
 local table_concat = table.concat
+local table_insert = table.insert
 local string_sub = string.sub
 local coroutine_wrap = coroutine.wrap
 local coroutine_yield = coroutine.yield
@@ -49,13 +50,13 @@
 local has_luasec, luasec = pcall ( require , "ssl" )
 local luasocket = use "socket" or require "socket"
 local luasocket_gettime = luasocket.gettime
-local getaddrinfo = luasocket.dns.getaddrinfo
+local inet = require "util.net";
+local inet_pton = inet.pton;
 
 --// extern lib methods //--
 
 local ssl_wrap = ( has_luasec and luasec.wrap )
 local socket_bind = luasocket.bind
-local socket_sleep = luasocket.sleep
 local socket_select = luasocket.select
 
 --// functions //--
@@ -100,7 +101,6 @@
 local _readtraffic
 
 local _selecttimeout
-local _sleeptime
 local _tcpbacklog
 local _accepretry
 
@@ -114,8 +114,6 @@
 local _sendtimeout
 local _readtimeout
 
-local _timer
-
 local _maxselectlen
 local _maxfd
 
@@ -135,13 +133,12 @@
 
 _readlistlen = 0 -- length of readlist
 _sendlistlen = 0 -- length of sendlist
-_timerlistlen = 0 -- lenght of timerlist
+_timerlistlen = 0 -- length of timerlist
 
 _sendtraffic = 0 -- some stats
 _readtraffic = 0
 
 _selecttimeout = 1 -- timeout of socket.select
-_sleeptime = 0 -- time to wait at the end of every loop
 _tcpbacklog = 128 -- some kind of hint to the OS
 _accepretry = 10 -- seconds to wait until the next attempt of a full server to accept
 
@@ -150,7 +147,7 @@
 
 _checkinterval = 30 -- interval in secs to check idle clients
 _sendtimeout = 60000 -- allowed send idle time in secs
-_readtimeout = 6 * 60 * 60 -- allowed read idle time in secs
+_readtimeout = 14 * 60 -- allowed read idle time in secs
 
 local is_windows = package.config:sub(1,1) == "\\" -- check the directory separator, to detemine whether this is Windows
 _maxfd = (is_windows and math.huge) or luasocket._SETSIZE or 1024 -- max fd number, limit to 1024 by default to prevent glibc buffer overflow, but not on Windows
@@ -301,7 +298,6 @@
 	local bufferqueuelen = 0	-- end of buffer array
 
 	local toclose
-	local fatalerror
 	local needtls
 
 	local bufferlen = 0
@@ -326,7 +322,7 @@
 	end
 	handler.onreadtimeout = onreadtimeout;
 
-	handler.setlistener = function( self, listeners )
+	handler.setlistener = function( self, listeners, data )
 		if detach then
 			detach(self) -- Notify listener that it is no longer responsible for this connection
 		end
@@ -336,6 +332,9 @@
 		drain = listeners.ondrain
 		handler.onreadtimeout = listeners.onreadtimeout
 		detach = listeners.ondetach
+		if listeners.onattach then
+			listeners.onattach(self, data)
+		end
 	end
 	handler.getstats = function( )
 		return readtraffic, sendtraffic
@@ -425,7 +424,7 @@
 		bufferlen = bufferlen + #data
 		if bufferlen > maxsendlen then
 			_closelist[ handler ] = "send buffer exceeded"	 -- cannot close the client at the moment, have to wait to the end of the cycle
-			handler.write = idfalse -- dont write anymore
+			handler.write = idfalse -- don't write anymore
 			return false
 		elseif socket and not _sendlist[ socket ] then
 			_sendlistlen = addsocket(_sendlist, socket, _sendlistlen)
@@ -517,7 +516,6 @@
 			return dispatch( handler, buffer, err )
 		else	-- connections was closed or fatal error
 			out_put( "server.lua: client ", tostring(ip), ":", tostring(clientport), " read error: ", tostring(err) )
-			fatalerror = true
 			_ = handler and handler:force_close( err )
 			return false
 		end
@@ -537,7 +535,7 @@
 		else
 			succ, err, count = false, "unexpected close", 0;
 		end
-		if succ then	-- sending succesful
+		if succ then	-- sending successful
 			bufferqueuelen = 0
 			bufferlen = 0
 			_sendlistlen = removesocket( _sendlist, socket, _sendlistlen ) -- delete socket from writelist
@@ -557,7 +555,6 @@
 			return true
 		else	-- connection was closed during sending or fatal error
 			out_put( "server.lua: client ", tostring(ip), ":", tostring(clientport), " write error: ", tostring(err) )
-			fatalerror = true
 			_ = handler and handler:force_close( err )
 			return false
 		end
@@ -806,7 +803,6 @@
 getsettings = function( )
 	return {
 		select_timeout = _selecttimeout;
-		select_sleep_time = _sleeptime;
 		tcp_backlog = _tcpbacklog;
 		max_send_buffer_size = _maxsendlen;
 		max_receive_buffer_size = _maxreadlen;
@@ -825,7 +821,6 @@
 		return nil, "invalid settings table"
 	end
 	_selecttimeout = tonumber( new.select_timeout ) or _selecttimeout
-	_sleeptime = tonumber( new.select_sleep_time ) or _sleeptime
 	_maxsendlen = tonumber( new.max_send_buffer_size ) or _maxsendlen
 	_maxreadlen = tonumber( new.max_receive_buffer_size ) or _maxreadlen
 	_checkinterval = tonumber( new.select_idle_check_interval ) or _checkinterval
@@ -848,6 +843,49 @@
 	return true
 end
 
+local add_task do
+	local data = {};
+	local new_data = {};
+
+	function add_task(delay, callback)
+		local current_time = luasocket_gettime();
+		delay = delay + current_time;
+		if delay >= current_time then
+			table_insert(new_data, {delay, callback});
+		else
+			local r = callback(current_time);
+			if r and type(r) == "number" then
+				return add_task(r, callback);
+			end
+		end
+	end
+
+	addtimer(function(current_time)
+		if #new_data > 0 then
+			for _, d in pairs(new_data) do
+				table_insert(data, d);
+			end
+			new_data = {};
+		end
+
+		local next_time = math_huge;
+		for i, d in pairs(data) do
+			local t, callback = d[1], d[2];
+			if t <= current_time then
+				data[i] = nil;
+				local r = callback(current_time);
+				if type(r) == "number" then
+					add_task(r, callback);
+					next_time = math_min(next_time, r);
+				end
+			else
+				next_time = math_min(next_time, t - current_time);
+			end
+		end
+		return next_time;
+	end);
+end
+
 stats = function( )
 	return _readtraffic, _sendtraffic, _readlistlen, _sendlistlen, _timerlistlen
 end
@@ -855,15 +893,31 @@
 local quitting;
 
 local function setquitting(quit)
-	quitting = not not quit;
+	quitting = quit;
 end
 
 loop = function(once) -- this is the main loop of the program
 	if quitting then return "quitting"; end
 	if once then quitting = "once"; end
-	local next_timer_time = math_huge;
+	_currenttime = luasocket_gettime( )
 	repeat
+		-- Fire timers
+	local next_timer_time = math_huge;
+		for i = 1, _timerlistlen do
+			local t = _timerlist[ i ]( _currenttime ) -- fire timers
+			if t then next_timer_time = math_min(next_timer_time, t); end
+		end
+
 		local read, write, err = socket_select( _readlist, _sendlist, math_min(_selecttimeout, next_timer_time) )
+		for _, socket in ipairs( read ) do -- receive data
+			local handler = _socketlist[ socket ]
+			if handler then
+				handler.readbuffer( )
+			else
+				closesocket( socket )
+				out_put "server.lua: found no handler and closed socket (readlist)" -- this can happen
+			end
+		end
 		for _, socket in ipairs( write ) do -- send data waiting in writequeues
 			local handler = _socketlist[ socket ]
 			if handler then
@@ -873,15 +927,6 @@
 				out_put "server.lua: found no handler and closed socket (writelist)"	-- this should not happen
 			end
 		end
-		for _, socket in ipairs( read ) do -- receive data
-			local handler = _socketlist[ socket ]
-			if handler then
-				handler.readbuffer( )
-			else
-				closesocket( socket )
-				out_put "server.lua: found no handler and closed socket (readlist)" -- this can happen
-			end
-		end
 		for handler, err in pairs( _closelist ) do
 			handler.disconnect( )( handler, err )
 			handler:force_close()	 -- forced disconnect
@@ -910,29 +955,14 @@
 			end
 		end
 
-		-- Fire timers
-		if _currenttime - _timer >= math_min(next_timer_time, 1) then
-			next_timer_time = math_huge;
-			for i = 1, _timerlistlen do
-				local t = _timerlist[ i ]( _currenttime ) -- fire timers
-				if t then next_timer_time = math_min(next_timer_time, t); end
-			end
-			_timer = _currenttime
-		else
-			next_timer_time = next_timer_time - (_currenttime - _timer);
-		end
-
 		for server, paused_time in pairs( _fullservers ) do
 			if _currenttime - paused_time > _accepretry then
 				_fullservers[ server ] = nil;
 				server.resume();
 			end
 		end
-
-		-- wait some time (0 by default)
-		socket_sleep( _sleeptime )
 	until quitting;
-	if once and quitting == "once" then quitting = nil; return; end
+	if quitting == "once" then quitting = nil; return; end
 	closeall();
 	return "quitting"
 end
@@ -952,6 +982,7 @@
 	if not handler then return nil, err end
 	_socketlist[ socket ] = handler
 	if not sslctx then
+		_readlistlen = addsocket(_readlist, socket, _readlistlen)
 		_sendlistlen = addsocket(_sendlist, socket, _sendlistlen)
 		if listeners.onconnect then
 			-- When socket is writeable, call onconnect
@@ -978,15 +1009,15 @@
 		err = "luasec not found"
 	end
 	if not typ then
-		local addrinfo, err = getaddrinfo(address)
-		if not addrinfo then return nil, err end
-		if addrinfo[1] and addrinfo[1].family == "inet6" then
-			typ = "tcp6"
-		else
-			typ = "tcp"
+		local n = inet_pton(address);
+		if not n then return nil, "invalid-ip"; end
+		if #n == 16 then
+			typ = "tcp6";
+		elseif #n == 4 then
+			typ = "tcp4";
 		end
 	end
-	local create = luasocket[typ]
+	local create = luasocket[typ];
 	if type( create ) ~= "function"  then
 		err = "invalid socket type"
 	end
@@ -1001,15 +1032,55 @@
 		return nil, err
 	end
 	client:settimeout( 0 )
-	local ok, err = client:connect( address, port )
-	if ok or err == "timeout" then
+	local ok, err = client:setpeername( address, port )
+	if ok or err == "timeout" or err == "Operation already in progress" then
 		return wrapclient( client, address, port, listeners, pattern, sslctx )
 	else
 		return nil, err
 	end
 end
 
---// EXPERIMENTAL //--
+local closewatcher = function (handler)
+	local socket = handler.conn;
+	_sendlistlen = removesocket( _sendlist, socket, _sendlistlen )
+	_readlistlen = removesocket( _readlist, socket, _readlistlen )
+	_socketlist[ socket ] = nil
+end;
+
+local addremove = function (handler, read, send)
+	local socket = handler.conn
+	_socketlist[ socket ] = handler
+	if read ~= nil then
+		if read then
+			_readlistlen = addsocket( _readlist, socket, _readlistlen )
+		else
+			_sendlistlen = removesocket( _sendlist, socket, _sendlistlen )
+		end
+	end
+	if send ~= nil then
+		if send then
+			_sendlistlen = addsocket( _sendlist, socket, _sendlistlen )
+		else
+			_readlistlen = removesocket( _readlist, socket, _readlistlen )
+		end
+	end
+end
+
+local watchfd = function ( fd, onreadable, onwriteable )
+	local socket = fd
+	if type(fd) == "number" then
+		socket = { getfd = function () return fd; end }
+	end
+	local handler = {
+		conn = socket;
+		readbuffer = onreadable or id;
+		sendbuffer = onwriteable or id;
+		close = closewatcher;
+		setflags = addremove;
+	};
+	addremove( handler, onreadable, onwriteable )
+	return handler
+end
 
 ----------------------------------// BEGIN //--
 
@@ -1017,7 +1088,6 @@
 use "setmetatable" ( _readtimes, { __mode = "k" } )
 use "setmetatable" ( _writetimes, { __mode = "k" } )
 
-_timer = luasocket_gettime( )
 _starttime = luasocket_gettime( )
 
 local function setlogger(new_logger)
@@ -1032,9 +1102,11 @@
 
 return {
 	_addtimer = addtimer,
+	add_task = add_task;
 
 	addclient = addclient,
 	wrapclient = wrapclient,
+	watchfd = watchfd,
 
 	loop = loop,
 	link = link,
--- a/net/websocket.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/net/websocket.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -21,9 +21,9 @@
 local websockets = {};
 
 local websocket_listeners = {};
-function websocket_listeners.ondisconnect(handler, err)
-	local s = websockets[handler];
-	websockets[handler] = nil;
+function websocket_listeners.ondisconnect(conn, err)
+	local s = websockets[conn];
+	websockets[conn] = nil;
 	if s.close_timer then
 		timer.stop(s.close_timer);
 		s.close_timer = nil;
@@ -33,19 +33,19 @@
 	if s.onclose then s:onclose(s.close_code, s.close_message or err); end
 end
 
-function websocket_listeners.ondetach(handler)
-	websockets[handler] = nil;
+function websocket_listeners.ondetach(conn)
+	websockets[conn] = nil;
 end
 
 local function fail(s, code, reason)
 	log("warn", "WebSocket connection failed, closing. %d %s", code, reason);
 	s:close(code, reason);
-	s.handler:close();
+	s.conn:close();
 	return false
 end
 
-function websocket_listeners.onincoming(handler, buffer, err) -- luacheck: ignore 212/err
-	local s = websockets[handler];
+function websocket_listeners.onincoming(conn, buffer, err) -- luacheck: ignore 212/err
+	local s = websockets[conn];
 	s.readbuffer = s.readbuffer..buffer;
 	while true do
 		local frame, len = frames.parse(s.readbuffer);
@@ -111,7 +111,7 @@
 			elseif frame.opcode == 0x9 then -- Ping frame
 				frame.opcode = 0xA;
 				frame.MASK = true; -- RFC 6455 6.1.5: If the data is being sent by the client, the frame(s) MUST be masked
-				handler:write(frames.build(frame));
+				conn:write(frames.build(frame));
 			elseif frame.opcode == 0xA then -- Pong frame
 				log("debug", "Received unexpected pong frame: " .. tostring(frame.data));
 			else
@@ -126,15 +126,15 @@
 local function close_timeout_cb(now, timerid, s) -- luacheck: ignore 212/now 212/timerid
 	s.close_timer = nil;
 	log("warn", "Close timeout waiting for server to close, closing manually.");
-	s.handler:close();
+	s.conn:close();
 end
 function websocket_methods:close(code, reason)
 	if self.readyState < 2 then
 		code = code or 1000;
 		log("debug", "closing WebSocket with code %i: %s" , code , tostring(reason));
 		self.readyState = 2;
-		local handler = self.handler;
-		handler:write(frames.build_close(code, reason, true));
+		local conn = self.conn;
+		conn:write(frames.build_close(code, reason, true));
 		-- Do not close socket straight away, wait for acknowledgement from server.
 		self.close_timer = timer.add_task(close_timeout, close_timeout_cb, self);
 	elseif self.readyState == 2 then
@@ -144,8 +144,8 @@
 			timer.stop(self.close_timer);
 			self.close_timer = nil;
 		end
-		local handler = self.handler;
-		handler:close();
+		local conn = self.conn;
+		conn:close();
 	else
 		log("debug", "tried to close a closed WebSocket, ignoring.");
 	end
@@ -168,7 +168,7 @@
 		data = tostring(data);
 	};
 	log("debug", "WebSocket sending frame: opcode=%0x, %i bytes", frame.opcode, #frame.data);
-	return self.handler:write(frames.build(frame));
+	return self.conn:write(frames.build(frame));
 end
 
 local websocket_metatable = {
@@ -216,7 +216,7 @@
 	local s = setmetatable({
 		readbuffer = "";
 		databuffer = nil;
-		handler = nil;
+		conn = nil;
 		close_code = nil;
 		close_message = nil;
 		close_timer = nil;
@@ -236,6 +236,7 @@
 		method = "GET";
 		headers = headers;
 		sslctx = ex.sslctx;
+		insecure = ex.insecure;
 	}, function(b, c, r, http_req)
 		if c ~= 101
 		   or r.headers["connection"]:lower() ~= "upgrade"
@@ -252,16 +253,16 @@
 		s.protocol = r.headers["sec-websocket-protocol"];
 
 		-- Take possession of socket from http
+		local conn = http_req.conn;
 		http_req.conn = nil;
-		local handler = http_req.handler;
-		s.handler = handler;
-		websockets[handler] = s;
-		handler:setlistener(websocket_listeners);
+		s.conn = conn;
+		websockets[conn] = s;
+		conn:setlistener(websocket_listeners);
 
 		log("debug", "WebSocket connected successfully to %s", url);
 		s.readyState = 1;
 		if s.onopen then s:onopen(); end
-		websocket_listeners.onincoming(handler, b);
+		websocket_listeners.onincoming(conn, b);
 	end);
 
 	return s;
--- a/net/websocket/frames.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/net/websocket/frames.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -21,8 +21,8 @@
 local s_byte = string.byte;
 local s_char= string.char;
 local s_sub = string.sub;
-local s_pack = string.pack;
-local s_unpack = string.unpack;
+local s_pack = string.pack; -- luacheck: ignore 143
+local s_unpack = string.unpack; -- luacheck: ignore 143
 
 if not s_pack and softreq"struct" then
 	s_pack = softreq"struct".pack;
@@ -112,9 +112,9 @@
 -- TODO: optimize
 local function apply_mask(str, key, from, to)
 	from = from or 1
-	if from < 0 then from = #str + from + 1 end -- negative indicies
+	if from < 0 then from = #str + from + 1 end -- negative indices
 	to = to or #str
-	if to < 0 then to = #str + to + 1 end -- negative indicies
+	if to < 0 then to = #str + to + 1 end -- negative indices
 	local key_len = #key
 	local counter = 0;
 	local data = {};
--- a/plugins/adhoc/adhoc.lib.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/adhoc/adhoc.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -36,30 +36,30 @@
 
 	local data, state = command:handler(dataIn, states[sessionid]);
 	states[sessionid] = state;
-	local cmdtag;
+	local cmdreply;
 	if data.status == "completed" then
 		states[sessionid] = nil;
-		cmdtag = command:cmdtag("completed", sessionid);
+		cmdreply = command:cmdtag("completed", sessionid);
 	elseif data.status == "canceled" then
 		states[sessionid] = nil;
-		cmdtag = command:cmdtag("canceled", sessionid);
+		cmdreply = command:cmdtag("canceled", sessionid);
 	elseif data.status == "error" then
 		states[sessionid] = nil;
 		local reply = st.error_reply(stanza, data.error.type, data.error.condition, data.error.message);
 		origin.send(reply);
 		return true;
 	else
-		cmdtag = command:cmdtag("executing", sessionid);
+		cmdreply = command:cmdtag("executing", sessionid);
 		data.actions = data.actions or { "complete" };
 	end
 
 	for name, content in pairs(data) do
 		if name == "info" then
-			cmdtag:tag("note", {type="info"}):text(content):up();
+			cmdreply:tag("note", {type="info"}):text(content):up();
 		elseif name == "warn" then
-			cmdtag:tag("note", {type="warn"}):text(content):up();
+			cmdreply:tag("note", {type="warn"}):text(content):up();
 		elseif name == "error" then
-			cmdtag:tag("note", {type="error"}):text(content.message):up();
+			cmdreply:tag("note", {type="error"}):text(content.message):up();
 		elseif name == "actions" then
 			local actions = st.stanza("actions", { execute = content.default });
 			for _, action in ipairs(content) do
@@ -70,17 +70,17 @@
 						command.name, command.node, action);
 				end
 			end
-			cmdtag:add_child(actions);
+			cmdreply:add_child(actions);
 		elseif name == "form" then
-			cmdtag:add_child((content.layout or content):form(content.values));
+			cmdreply:add_child((content.layout or content):form(content.values));
 		elseif name == "result" then
-			cmdtag:add_child((content.layout or content):form(content.values, "result"));
+			cmdreply:add_child((content.layout or content):form(content.values, "result"));
 		elseif name == "other" then
-			cmdtag:add_child(content);
+			cmdreply:add_child(content);
 		end
 	end
 	local reply = st.reply(stanza);
-	reply:add_child(cmdtag);
+	reply:add_child(cmdreply);
 	origin.send(reply);
 
 	return true;
--- a/plugins/adhoc/mod_adhoc.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/adhoc/mod_adhoc.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -5,9 +5,8 @@
 -- COPYING file in the source package for more information.
 --
 
+local it = require "util.iterators";
 local st = require "util.stanza";
-local keys = require "util.iterators".keys;
-local array_collect = require "util.array".collect;
 local is_admin = require "core.usermanager".is_admin;
 local jid_split = require "util.jid".split;
 local adhoc_handle_cmd = module:require "adhoc".handle_cmd;
@@ -45,8 +44,8 @@
 end);
 
 module:hook("host-disco-items-node", function (event)
-	local stanza, origin, reply, node = event.stanza, event.origin, event.reply, event.node;
-	if node ~= xmlns_cmd then
+	local stanza, reply, disco_node = event.stanza, event.reply, event.node;
+	if disco_node ~= xmlns_cmd then
 		return;
 	end
 
@@ -54,9 +53,7 @@
 	local admin = is_admin(from, stanza.attr.to);
 	local global_admin = is_admin(from);
 	local username, hostname = jid_split(from);
-	local nodes = array_collect(keys(commands)):sort();
-	for _, node in ipairs(nodes) do
-		local command = commands[node];
+	for node, command in it.sorted_pairs(commands) do
 		if (command.permission == "admin" and admin)
 		    or (command.permission == "global_admin" and global_admin)
 		    or (command.permission == "local_user" and hostname == module.host)
@@ -69,28 +66,26 @@
 	event.exists = true;
 end);
 
-module:hook("iq/host/"..xmlns_cmd..":command", function (event)
+module:hook("iq-set/host/"..xmlns_cmd..":command", function (event)
 	local origin, stanza = event.origin, event.stanza;
-	if stanza.attr.type == "set" then
-		local node = stanza.tags[1].attr.node
-		local command = commands[node];
-		if command then
-			local from = stanza.attr.from;
-			local admin = is_admin(from, stanza.attr.to);
-			local global_admin = is_admin(from);
-			local username, hostname = jid_split(from);
-			if (command.permission == "admin" and not admin)
-			    or (command.permission == "global_admin" and not global_admin)
-			    or (command.permission == "local_user" and hostname ~= module.host) then
-				origin.send(st.error_reply(stanza, "auth", "forbidden", "You don't have permission to execute this command"):up()
-				    :add_child(commands[node]:cmdtag("canceled")
-					:tag("note", {type="error"}):text("You don't have permission to execute this command")));
-				return true
-			end
-			-- User has permission now execute the command
-			adhoc_handle_cmd(commands[node], origin, stanza);
-			return true;
+	local node = stanza.tags[1].attr.node
+	local command = commands[node];
+	if command then
+		local from = stanza.attr.from;
+		local admin = is_admin(from, stanza.attr.to);
+		local global_admin = is_admin(from);
+		local username, hostname = jid_split(from);
+		if (command.permission == "admin" and not admin)
+		    or (command.permission == "global_admin" and not global_admin)
+		    or (command.permission == "local_user" and hostname ~= module.host) then
+			origin.send(st.error_reply(stanza, "auth", "forbidden", "You don't have permission to execute this command"):up()
+			    :add_child(commands[node]:cmdtag("canceled")
+				:tag("note", {type="error"}):text("You don't have permission to execute this command")));
+			return true
 		end
+		-- User has permission now execute the command
+		adhoc_handle_cmd(commands[node], origin, stanza);
+		return true;
 	end
 end, 500);
 
@@ -103,5 +98,5 @@
 	commands[event.item.node] = nil;
 end
 
-module:handle_items("adhoc", adhoc_added, adhoc_removed);
+module:handle_items("adhoc", adhoc_added, adhoc_removed); -- COMPAT pre module:provides() introduced in 0.9
 module:handle_items("adhoc-provider", adhoc_added, adhoc_removed);
--- a/plugins/mod_admin_adhoc.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_admin_adhoc.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -3,6 +3,7 @@
 -- This file is MIT/X11 licensed. Please see the
 -- COPYING file in the source package for more information.
 --
+-- luacheck: ignore 212/self 212/data 212/state 412/err 422/err
 
 local _G = _G;
 
@@ -95,7 +96,12 @@
 	end
 	local username, host, resource = jid.split(fields.accountjid);
 	if module_host ~= host then
-		return { status = "completed", error = { message = "Trying to change the password of a user on " .. host .. " but command was sent to " .. module_host}};
+		return {
+			status = "completed",
+			error = {
+				message = "Trying to change the password of a user on " .. host .. " but command was sent to " .. module_host
+			}
+		};
 	end
 	if usermanager_user_exists(username, host) and usermanager_set_password(username, fields.password, host, nil) then
 		return { status = "completed", info = "Password successfully changed" };
@@ -207,8 +213,8 @@
 		return generate_error_message(err);
 	end
 	local user, host, resource = jid.split(fields.accountjid);
-	local accountjid = "";
-	local password = "";
+	local accountjid;
+	local password;
 	if host ~= module_host then
 		return { status = "completed", error = { message = "Tried to get password for a user on " .. host .. " but command was sent to " .. module_host } };
 	elseif usermanager_user_exists(user, host) then
@@ -246,15 +252,15 @@
 	local roster = rm_load_roster(user, host);
 
 	local query = st.stanza("query", { xmlns = "jabber:iq:roster" });
-	for jid in pairs(roster) do
-		if jid then
+	for contact_jid in pairs(roster) do
+		if contact_jid then
 			query:tag("item", {
-				jid = jid,
-				subscription = roster[jid].subscription,
-				ask = roster[jid].ask,
-				name = roster[jid].name,
+				jid = contact_jid,
+				subscription = roster[contact_jid].subscription,
+				ask = roster[contact_jid].ask,
+				name = roster[contact_jid].name,
 			});
-			for group in pairs(roster[jid].groups) do
+			for group in pairs(roster[contact_jid].groups) do
 				query:tag("group"):text(group):up();
 			end
 			query:up();
@@ -289,7 +295,7 @@
 		return generate_error_message(err);
 	end
 
-	local user, host, resource = jid.split(fields.accountjid);
+	local user, host = jid.split(fields.accountjid);
 	if host ~= module_host then
 		return { status = "completed", error = { message = "Tried to get stats for a user on " .. host .. " but command was sent to " .. module_host } };
 	elseif not usermanager_user_exists(user, host) then
@@ -299,8 +305,8 @@
 	local rostersize = 0;
 	local IPs = "";
 	local resources = "";
-	for jid in pairs(roster) do
-		if jid then
+	for contact_jid in pairs(roster) do
+		if contact_jid then
 			rostersize = rostersize + 1;
 		end
 	end
@@ -319,7 +325,7 @@
 
 	{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" };
 	{ name = "max_items", type = "list-single", label = "Maximum number of users",
-		value = { "25", "50", "75", "100", "150", "200", "all" } };
+		options = { "25", "50", "75", "100", "150", "200", "all" } };
 	{ name = "details", type = "boolean", label = "Show details" };
 };
 
@@ -369,7 +375,7 @@
 
 	{ name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/s2s#list" };
 	{ name = "sessions", type = "text-multi", label = "Connections:" };
-	{ name = "num_in", type = "text-single", label = "#incomming connections:" };
+	{ name = "num_in", type = "text-single", label = "#incoming connections:" };
 	{ name = "num_out", type = "text-single", label = "#outgoing connections:" };
 };
 
@@ -588,7 +594,7 @@
 	end
 
 	local ok_list, err_list = {}, {};
-	for host_name, host in pairs(hosts) do
+	for host_name in pairs(hosts) do
 		if modulemanager.is_loaded(host_name, fields.module)  then
 			local ok, err = modulemanager.reload(host_name, fields.module);
 			if ok then
@@ -641,13 +647,16 @@
 
 	{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/admin" };
 	{ name = "delay", type = "list-single", label = "Time delay before shutting down",
-		value = { {label = "30 seconds", value = "30"},
-			  {label = "60 seconds", value = "60"},
-			  {label = "90 seconds", value = "90"},
-			  {label = "2 minutes", value = "120"},
-			  {label = "3 minutes", value = "180"},
-			  {label = "4 minutes", value = "240"},
-			  {label = "5 minutes", value = "300"},
+		value = "5",
+		options = {
+			{label =  "5 seconds", value = "5"},
+			{label = "30 seconds", value = "30"},
+			{label = "60 seconds", value = "60"},
+			{label = "90 seconds", value = "90"},
+			{label = "2 minutes", value = "120"},
+			{label = "3 minutes", value = "180"},
+			{label = "4 minutes", value = "240"},
+			{label = "5 minutes", value = "300"},
 		};
 	};
 	{ name = "announcement", type = "text-multi", label = "Announcement" };
@@ -664,7 +673,7 @@
 		send_to_online(message);
 	end
 
-	timer_add_task(tonumber(fields.delay or "5"), function(time) prosody.shutdown("Shutdown by adhoc command") end);
+	timer_add_task(tonumber(fields.delay or "5"), function() prosody.shutdown("Shutdown by adhoc command") end);
 
 	return { status = "completed", info = "Server is about to shut down" };
 end);
@@ -730,7 +739,7 @@
 	end
 
 	local ok_list, err_list = {}, {};
-	for host_name, host in pairs(hosts) do
+	for host_name in pairs(hosts) do
 		if modulemanager.is_loaded(host_name, fields.module)  then
 			local ok, err = modulemanager.unload(host_name, fields.module);
 			if ok then
@@ -799,6 +808,7 @@
 	end
 end);
 
+-- luacheck: max_line_length 180
 
 local add_user_desc = adhoc_new("Add User", "http://jabber.org/protocol/admin#add-user", add_user_command_handler, "admin");
 local change_user_password_desc = adhoc_new("Change User Password", "http://jabber.org/protocol/admin#change-user-password", change_user_password_command_handler, "admin");
--- a/plugins/mod_admin_telnet.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_admin_telnet.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -5,6 +5,7 @@
 -- This project is MIT/X11 licensed. Please see the
 -- COPYING file in the source package for more information.
 --
+-- luacheck: ignore 212/self
 
 module:set_global();
 
@@ -13,11 +14,11 @@
 local s2smanager = require "core.s2smanager";
 local portmanager = require "core.portmanager";
 local helpers = require "util.helpers";
+local server = require "net.server";
 
 local _G = _G;
 
 local prosody = _G.prosody;
-local hosts = prosody.hosts;
 
 local console_listener = { default_port = 5582; default_mode = "*a"; interface = "127.0.0.1" };
 
@@ -34,8 +35,8 @@
 local def_env = module:shared("env");
 local default_env_mt = { __index = def_env };
 
-local function redirect_output(_G, session)
-	local env = setmetatable({ print = session.print }, { __index = function (t, k) return rawget(_G, k); end });
+local function redirect_output(target, session)
+	local env = setmetatable({ print = session.print }, { __index = function (_, k) return rawget(target, k); end });
 	env.dofile = function(name)
 		local f, err = envloadfile(name, env);
 		if not f then return f, err; end
@@ -163,7 +164,7 @@
 	end
 end
 
-function console_listener.ondisconnect(conn, err)
+function console_listener.ondisconnect(conn, err) -- luacheck: ignore 212/err
 	local session = sessions[conn];
 	if session then
 		session.disconnect();
@@ -361,7 +362,7 @@
 	hosts = get_hosts_set(hosts);
 
 	-- Load the module for each host
-	local ok, err, count, mod = true, nil, 0, nil;
+	local ok, err, count, mod = true, nil, 0;
 	for host in hosts do
 		if (not modulemanager.is_loaded(host, name)) then
 			mod, err = modulemanager.load(host, name, config);
@@ -404,12 +405,14 @@
 	return ok, (ok and "Module unloaded from "..count.." host"..(count ~= 1 and "s" or "")) or ("Last error: "..tostring(err));
 end
 
+local function _sort_hosts(a, b)
+	if a == "*" then return true
+	elseif b == "*" then return false
+	else return a < b; end
+end
+
 function def_env.module:reload(name, hosts)
-	hosts = array.collect(get_hosts_set(hosts, name)):sort(function (a, b)
-		if a == "*" then return true
-		elseif b == "*" then return false
-		else return a < b; end
-	end);
+	hosts = array.collect(get_hosts_set(hosts, name)):sort(_sort_hosts)
 
 	-- Reload the module for each host
 	local ok, err, count = true, nil, 0;
@@ -567,7 +570,7 @@
 	end);
 end
 
-function def_env.c2s:count(match_jid)
+function def_env.c2s:count()
 	return true, "Total: "..  iterators.count(values(module:shared"/*/c2s/sessions")) .." clients";
 end
 
@@ -651,6 +654,7 @@
 
 		if (not match_jid) or remotehost:match(match_jid) or localhost:match(match_jid) then
 			table.insert(s2s_list, sess_lines);
+			-- luacheck: ignore 421/print
 			local print = function (s) table.insert(sess_lines, "        "..s); end
 			if session.sendq then
 				print("There are "..#session.sendq.." queued outgoing stanzas for this connection");
@@ -830,7 +834,7 @@
 
 	local match_id;
 	if from and not to then
-		match_id, from = from;
+		match_id, from = from, nil;
 	elseif not to then
 		return false, "Syntax: s2s:close('from', 'to') - Closes all s2s sessions from 'from' to 'to'";
 	elseif from == to then
@@ -875,9 +879,9 @@
 	local print = self.session.print;
 	local i = 0;
 	local type;
-	for host in values(array.collect(keys(prosody.hosts)):sort()) do
+	for host, host_session in iterators.sorted_pairs(prosody.hosts) do
 		i = i + 1;
-		type = hosts[host].type;
+		type = host_session.type;
 		if type == "local" then
 			print(host);
 		else
@@ -896,14 +900,11 @@
 function def_env.port:list()
 	local print = self.session.print;
 	local services = portmanager.get_active_services().data;
-	local ordered_services, n_ports = {}, 0;
-	for service, interfaces in pairs(services) do
-		table.insert(ordered_services, service);
-	end
-	table.sort(ordered_services);
-	for _, service in ipairs(ordered_services) do
+	local n_services, n_ports = 0, 0;
+	for service, interfaces in iterators.sorted_pairs(services) do
+		n_services = n_services + 1;
 		local ports_list = {};
-		for interface, ports in pairs(services[service]) do
+		for interface, ports in pairs(interfaces) do
 			for port in pairs(ports) do
 				table.insert(ports_list, "["..interface.."]:"..port);
 			end
@@ -911,14 +912,14 @@
 		n_ports = n_ports + #ports_list;
 		print(service..": "..table.concat(ports_list, ", "));
 	end
-	return true, #ordered_services.." services listening on "..n_ports.." ports";
+	return true, n_services.." services listening on "..n_ports.." ports";
 end
 
 function def_env.port:close(close_port, close_interface)
 	close_port = assert(tonumber(close_port), "Invalid port number");
 	local n_closed = 0;
 	local services = portmanager.get_active_services().data;
-	for service, interfaces in pairs(services) do
+	for service, interfaces in pairs(services) do -- luacheck: ignore 213
 		for interface, ports in pairs(interfaces) do
 			if not close_interface or close_interface == interface then
 				if ports[close_port] then
@@ -947,22 +948,23 @@
 
 local function check_muc(jid)
 	local room_name, host = jid_split(jid);
-	if not hosts[host] then
+	if not prosody.hosts[host] then
 		return nil, "No such host: "..host;
-	elseif not hosts[host].modules.muc then
+	elseif not prosody.hosts[host].modules.muc then
 		return nil, "Host '"..host.."' is not a MUC service";
 	end
 	return room_name, host;
 end
 
-function def_env.muc:create(room_jid)
+function def_env.muc:create(room_jid, config)
 	local room_name, host = check_muc(room_jid);
 	if not room_name then
 		return room_name, host;
 	end
 	if not room_name then return nil, host end
-	if hosts[host].modules.muc.rooms[room_jid] then return nil, "Room exists already" end
-	return hosts[host].modules.muc.create_room(room_jid);
+	if config ~= nil and type(config) ~= "table" then return nil, "Config must be a table"; end
+	if prosody.hosts[host].modules.muc.get_room_from_jid(room_jid) then return nil, "Room exists already" end
+	return prosody.hosts[host].modules.muc.create_room(room_jid, config);
 end
 
 function def_env.muc:room(room_jid)
@@ -970,7 +972,7 @@
 	if not room_name then
 		return room_name, host;
 	end
-	local room_obj = hosts[host].modules.muc.rooms[room_jid];
+	local room_obj = prosody.hosts[host].modules.muc.get_room_from_jid(room_jid);
 	if not room_obj then
 		return nil, "No such room: "..room_jid;
 	end
@@ -978,14 +980,14 @@
 end
 
 function def_env.muc:list(host)
-	local host_session = hosts[host];
+	local host_session = prosody.hosts[host];
 	if not host_session or not host_session.modules.muc then
 		return nil, "Please supply the address of a local MUC component";
 	end
 	local print = self.session.print;
 	local c = 0;
-	for name in keys(host_session.modules.muc.rooms) do
-		print(name);
+	for room in host_session.modules.muc.each_room() do
+		print(room.jid);
 		c = c + 1;
 	end
 	return true, c.." rooms";
@@ -996,7 +998,7 @@
 def_env.user = {};
 function def_env.user:create(jid, password)
 	local username, host = jid_split(jid);
-	if not hosts[host] then
+	if not prosody.hosts[host] then
 		return nil, "No such host: "..host;
 	elseif um.user_exists(username, host) then
 		return nil, "User exists";
@@ -1011,7 +1013,7 @@
 
 function def_env.user:delete(jid)
 	local username, host = jid_split(jid);
-	if not hosts[host] then
+	if not prosody.hosts[host] then
 		return nil, "No such host: "..host;
 	elseif not um.user_exists(username, host) then
 		return nil, "No such user";
@@ -1026,7 +1028,7 @@
 
 function def_env.user:password(jid, password)
 	local username, host = jid_split(jid);
-	if not hosts[host] then
+	if not prosody.hosts[host] then
 		return nil, "No such host: "..host;
 	elseif not um.user_exists(username, host) then
 		return nil, "No such user";
@@ -1042,7 +1044,7 @@
 function def_env.user:list(host, pat)
 	if not host then
 		return nil, "No host given";
-	elseif not hosts[host] then
+	elseif not prosody.hosts[host] then
 		return nil, "No such host";
 	end
 	local print = self.session.print;
@@ -1061,9 +1063,9 @@
 
 local st = require "util.stanza";
 function def_env.xmpp:ping(localhost, remotehost)
-	if hosts[localhost] then
+	if prosody.hosts[localhost] then
 		module:send(st.iq{ from=localhost, to=remotehost, type="get", id="ping" }
-				:tag("ping", {xmlns="urn:xmpp:ping"}), hosts[localhost]);
+				:tag("ping", {xmlns="urn:xmpp:ping"}), prosody.hosts[localhost]);
 		return true, "Sent ping";
 	else
 		return nil, "No such host";
@@ -1141,22 +1143,374 @@
 function def_env.debug:events(host, event)
 	local events_obj;
 	if host and host ~= "*" then
-		if not hosts[host] then
+		if host == "http" then
+			events_obj = require "net.http.server"._events;
+		elseif not prosody.hosts[host] then
 			return false, "Unknown host: "..host;
+		else
+			events_obj = prosody.hosts[host].events;
 		end
-		events_obj = hosts[host].events;
 	else
 		events_obj = prosody.events;
 	end
 	return true, helpers.show_events(events_obj, event);
 end
 
+function def_env.debug:timers()
+	local socket = require "socket";
+	local print = self.session.print;
+	local add_task = require"util.timer".add_task;
+	local h, params = add_task.h, add_task.params;
+	if h then
+		print("-- util.timer");
+		for i, id in ipairs(h.ids) do
+			if not params[id] then
+				print(os.date("%F %T", h.priorities[i]), h.items[id]);
+			elseif not params[id].callback then
+				print(os.date("%F %T", h.priorities[i]), h.items[id], unpack(params[id]));
+			else
+				print(os.date("%F %T", h.priorities[i]), params[id].callback, unpack(params[id]));
+			end
+		end
+	end
+	if server.event_base then
+		local count = 0;
+		for _, v in pairs(debug.getregistry()) do
+			if type(v) == "function" and v.callback and v.callback == add_task._on_timer then
+				count = count + 1;
+			end
+		end
+		print(count .. " libevent callbacks");
+	end
+	if h then
+		local next_time = h:peek();
+		if next_time then
+			return true, os.date("Next event at %F %T (in %%.6fs)", next_time):format(next_time - socket.gettime());
+		end
+	end
+	return true;
+end
+
+-- COMPAT: debug:timers() was timer:info() for some time in trunk
+def_env.timer = { info = def_env.debug.timers };
+
 module:hook("server-stopping", function(event)
-	for conn, session in pairs(sessions) do
+	for _, session in pairs(sessions) do
 		session.print("Shutting down: "..(event.reason or "unknown reason"));
 	end
 end);
 
+def_env.stats = {};
+
+local function format_stat(type, value, ref_value)
+	ref_value = ref_value or value;
+	--do return tostring(value) end
+	if type == "duration" then
+		if ref_value < 0.001 then
+			return ("%d µs"):format(value*1000000);
+		elseif ref_value < 0.9 then
+			return ("%0.2f ms"):format(value*1000);
+		end
+		return ("%0.2f"):format(value);
+	elseif type == "size" then
+		if ref_value > 1048576 then
+			return ("%d MB"):format(value/1048576);
+		elseif ref_value > 1024 then
+			return ("%d KB"):format(value/1024);
+		end
+		return ("%d bytes"):format(value);
+	elseif type == "rate" then
+		if ref_value < 0.9 then
+			return ("%0.2f/min"):format(value*60);
+		end
+		return ("%0.2f/sec"):format(value);
+	end
+	return tostring(value);
+end
+
+local stats_methods = {};
+function stats_methods:bounds(_lower, _upper)
+	for _, stat_info in ipairs(self) do
+		local data = stat_info[4];
+		if data then
+			local lower = _lower or data.min;
+			local upper = _upper or data.max;
+			local new_data = {
+				min = lower;
+				max = upper;
+				samples = {};
+				sample_count = 0;
+				count = data.count;
+				units = data.units;
+			};
+			local sum = 0;
+			for _, v in ipairs(data.samples) do
+				if v > upper then
+					break;
+				elseif v>=lower then
+					table.insert(new_data.samples, v);
+					sum = sum + v;
+				end
+			end
+			new_data.sample_count = #new_data.samples;
+			stat_info[4] = new_data;
+			stat_info[3] = sum/new_data.sample_count;
+		end
+	end
+	return self;
+end
+
+function stats_methods:trim(lower, upper)
+	upper = upper or (100-lower);
+	local statistics = require "util.statistics";
+	for _, stat_info in ipairs(self) do
+		-- Strip outliers
+		local data = stat_info[4];
+		if data then
+			local new_data = {
+				min = statistics.get_percentile(data, lower);
+				max = statistics.get_percentile(data, upper);
+				samples = {};
+				sample_count = 0;
+				count = data.count;
+				units = data.units;
+			};
+			local sum = 0;
+			for _, v in ipairs(data.samples) do
+				if v > new_data.max then
+					break;
+				elseif v>=new_data.min then
+					table.insert(new_data.samples, v);
+					sum = sum + v;
+				end
+			end
+			new_data.sample_count = #new_data.samples;
+			stat_info[4] = new_data;
+			stat_info[3] = sum/new_data.sample_count;
+		end
+	end
+	return self;
+end
+
+function stats_methods:max(upper)
+	return self:bounds(nil, upper);
+end
+
+function stats_methods:min(lower)
+	return self:bounds(lower, nil);
+end
+
+function stats_methods:summary()
+	local statistics = require "util.statistics";
+	for _, stat_info in ipairs(self) do
+		local type, value, data = stat_info[2], stat_info[3], stat_info[4];
+		if data and data.samples then
+			table.insert(stat_info.output, string.format("Count: %d (%d captured)",
+				data.count,
+				data.sample_count
+			));
+			table.insert(stat_info.output, string.format("Min: %s  Mean: %s  Max: %s",
+				format_stat(type, data.min),
+				format_stat(type, value),
+				format_stat(type, data.max)
+			));
+			table.insert(stat_info.output, string.format("Q1: %s  Median: %s  Q3: %s",
+				format_stat(type, statistics.get_percentile(data, 25)),
+				format_stat(type, statistics.get_percentile(data, 50)),
+				format_stat(type, statistics.get_percentile(data, 75))
+			));
+		end
+	end
+	return self;
+end
+
+function stats_methods:cfgraph()
+	for _, stat_info in ipairs(self) do
+		local name, type, value, data = unpack(stat_info, 1, 4);
+		local function print(s)
+			table.insert(stat_info.output, s);
+		end
+
+		if data and data.sample_count and data.sample_count > 0 then
+			local raw_histogram = require "util.statistics".get_histogram(data);
+
+			local graph_width, graph_height = 50, 10;
+			local eighth_chars = "   ▁▂▃▄▅▆▇█";
+
+			local range = data.max - data.min;
+
+			if range > 0 then
+				local x_scaling = #raw_histogram/graph_width;
+				local histogram = {};
+				for i = 1, graph_width do
+					histogram[i] = math.max(raw_histogram[i*x_scaling-1] or 0, raw_histogram[i*x_scaling] or 0);
+				end
+
+				print("");
+				print(("_"):rep(52)..format_stat(type, data.max));
+				for row = graph_height, 1, -1 do
+					local row_chars = {};
+					local min_eighths, max_eighths = 8, 0;
+					for i = 1, #histogram do
+						local char_eighths = math.ceil(math.max(math.min((graph_height/(data.max/histogram[i]))-(row-1), 1), 0)*8);
+						if char_eighths < min_eighths then
+							min_eighths = char_eighths;
+						end
+						if char_eighths > max_eighths then
+							max_eighths = char_eighths;
+						end
+						if char_eighths == 0 then
+							row_chars[i] = "-";
+						else
+							local char = eighth_chars:sub(char_eighths*3+1, char_eighths*3+3);
+							row_chars[i] = char;
+						end
+					end
+					print(table.concat(row_chars).."|-"..format_stat(type, data.max/(graph_height/(row-0.5))));
+				end
+				print(("\\    "):rep(11));
+				local x_labels = {};
+				for i = 1, 11 do
+					local s = ("%-4s"):format((i-1)*10);
+					if #s > 4 then
+						s = s:sub(1, 3).."…";
+					end
+					x_labels[i] = s;
+				end
+				print(" "..table.concat(x_labels, " "));
+				local units = "%";
+				local margin = math.floor((graph_width-#units)/2);
+				print((" "):rep(margin)..units);
+			else
+				print("[range too small to graph]");
+			end
+			print("");
+		end
+	end
+	return self;
+end
+
+function stats_methods:histogram()
+	for _, stat_info in ipairs(self) do
+		local name, type, value, data = unpack(stat_info, 1, 4);
+		local function print(s)
+			table.insert(stat_info.output, s);
+		end
+
+		if not data then
+			print("[no data]");
+			return self;
+		elseif not data.sample_count then
+			print("[not a sampled metric type]");
+			return self;
+		end
+
+		local graph_width, graph_height = 50, 10;
+		local eighth_chars = "   ▁▂▃▄▅▆▇█";
+
+		local range = data.max - data.min;
+
+		if range > 0 then
+			local n_buckets = graph_width;
+
+			local histogram = {};
+			for i = 1, n_buckets do
+				histogram[i] = 0;
+			end
+			local max_bin_samples = 0;
+			for _, d in ipairs(data.samples) do
+				local bucket = math.floor(1+(n_buckets-1)/(range/(d-data.min)));
+				histogram[bucket] = histogram[bucket] + 1;
+				if histogram[bucket] > max_bin_samples then
+					max_bin_samples = histogram[bucket];
+				end
+			end
+
+			print("");
+			print(("_"):rep(52)..max_bin_samples);
+			for row = graph_height, 1, -1 do
+				local row_chars = {};
+				local min_eighths, max_eighths = 8, 0;
+				for i = 1, #histogram do
+					local char_eighths = math.ceil(math.max(math.min((graph_height/(max_bin_samples/histogram[i]))-(row-1), 1), 0)*8);
+					if char_eighths < min_eighths then
+						min_eighths = char_eighths;
+					end
+					if char_eighths > max_eighths then
+						max_eighths = char_eighths;
+					end
+					if char_eighths == 0 then
+						row_chars[i] = "-";
+					else
+						local char = eighth_chars:sub(char_eighths*3+1, char_eighths*3+3);
+						row_chars[i] = char;
+					end
+				end
+				print(table.concat(row_chars).."|-"..math.ceil((max_bin_samples/graph_height)*(row-0.5)));
+			end
+			print(("\\    "):rep(11));
+			local x_labels = {};
+			for i = 1, 11 do
+				local s = ("%-4s"):format(format_stat(type, data.min+range*i/11, data.min):match("^%S+"));
+				if #s > 4 then
+					s = s:sub(1, 3).."…";
+				end
+				x_labels[i] = s;
+			end
+			print(" "..table.concat(x_labels, " "));
+			local units = format_stat(type, data.min):match("%s+(.+)$") or data.units or "";
+			local margin = math.floor((graph_width-#units)/2);
+			print((" "):rep(margin)..units);
+		else
+			print("[range too small to graph]");
+		end
+		print("");
+	end
+	return self;
+end
+
+local function stats_tostring(stats)
+	local print = stats.session.print;
+	for _, stat_info in ipairs(stats) do
+		if #stat_info.output > 0 then
+			print("\n#"..stat_info[1]);
+			print("");
+			for _, v in ipairs(stat_info.output) do
+				print(v);
+			end
+			print("");
+		else
+			print(("%-50s %s"):format(stat_info[1], format_stat(stat_info[2], stat_info[3])));
+		end
+	end
+	return #stats.." statistics displayed";
+end
+
+local stats_mt = {__index = stats_methods, __tostring = stats_tostring }
+local function new_stats_context(self)
+	return setmetatable({ session = self.session, stats = true }, stats_mt);
+end
+
+function def_env.stats:show(filter)
+	local stats, changed, extra = require "core.statsmanager".get_stats();
+	local available, displayed = 0, 0;
+	local displayed_stats = new_stats_context(self);
+	for name, value in pairs(stats) do
+		available = available + 1;
+		if not filter or name:match(filter) then
+			displayed = displayed + 1;
+			local type = name:match(":(%a+)$");
+			table.insert(displayed_stats, {
+				name, type, value, extra[name];
+				output = {};
+			});
+		end
+	end
+	return displayed_stats;
+end
+
+
+
 -------------
 
 function printbanner(session)
@@ -1175,7 +1529,7 @@
 	if option == "short" or option == "full" then
 	session.print("Welcome to the Prosody administration console. For a list of commands, type: help");
 	session.print("You may find more help on using this console in our online documentation at ");
-	session.print("http://prosody.im/doc/console\n");
+	session.print("https://prosody.im/doc/console\n");
 	end
 	if option ~= "short" and option ~= "full" and option ~= "graphic" then
 		session.print(option);
--- a/plugins/mod_announce.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_announce.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -37,7 +37,7 @@
 
 -- Old <message>-based jabberd-style announcement sending
 function handle_announcement(event)
-	local origin, stanza = event.origin, event.stanza;
+	local stanza = event.stanza;
 	local node, host, resource = jid.split(stanza.attr.to);
 
 	if resource ~= "announce/online" then
@@ -72,7 +72,7 @@
 	{ name = "announcement", type = "text-multi", required = true, label = "Announcement" };
 };
 
-function announce_handler(self, data, state)
+function announce_handler(_, data, state)
 	if state then
 		if data.action == "cancel" then
 			return { status = "canceled" };
@@ -91,10 +91,9 @@
 	else
 		return { status = "executing", actions = {"next", "complete", default = "complete"}, form = announce_layout }, "executing";
 	end
-
-	return true;
 end
 
+module:depends "adhoc";
 local adhoc_new = module:require "adhoc".new;
 local announce_desc = adhoc_new("Send Announcement to Online Users", "http://jabber.org/protocol/admin#announce", announce_handler, "admin");
 module:provides("adhoc", announce_desc);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_auth_insecure.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,53 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+-- luacheck: ignore 212
+
+local datamanager = require "util.datamanager";
+local new_sasl = require "util.sasl".new;
+
+local host = module.host;
+local provider = { name = "insecure" };
+
+assert(module:get_option_string("insecure_open_authentication") == "Yes please, I know what I'm doing!");
+
+function provider.test_password(username, password)
+	return true;
+end
+
+function provider.set_password(username, password)
+	local account = datamanager.load(username, host, "accounts");
+	if account then
+		account.password = password;
+		return datamanager.store(username, host, "accounts", account);
+	end
+	return nil, "Account not available.";
+end
+
+function provider.user_exists(username)
+	return true;
+end
+
+function provider.create_user(username, password)
+	return datamanager.store(username, host, "accounts", {password = password});
+end
+
+function provider.delete_user(username)
+	return datamanager.store(username, host, "accounts", nil);
+end
+
+function provider.get_sasl_handler()
+	local getpass_authentication_profile = {
+		plain_test = function(sasl, username, password, realm)
+			return true, true;
+		end
+	};
+	return new_sasl(module.host, getpass_authentication_profile);
+end
+
+module:add_item("auth-provider", provider);
+
--- a/plugins/mod_blocklist.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_blocklist.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -114,12 +114,14 @@
 -- Add or remove some jid(s) from the blocklist
 -- We want this to be atomic and not do a partial update
 local function edit_blocklist(event)
+	local now = os.time();
 	local origin, stanza = event.origin, event.stanza;
 	local username = origin.username;
 	local action = stanza.tags[1]; -- "block" or "unblock"
-	local is_blocking = action.name == "block" or nil; -- nil if unblocking
+	local is_blocking = action.name == "block" and now or nil; -- nil if unblocking
 	local new = {}; -- JIDs to block depending or unblock on action
 
+
 	-- XEP-0191 sayeth:
 	-- > When the user blocks communications with the contact, the user's
 	-- > server MUST send unavailable presence information to the contact (but
@@ -158,15 +160,15 @@
 
 	local new_blocklist = {
 		-- We set the [false] key to someting as a signal not to migrate privacy lists
-		[false] = blocklist[false] or { created = os.time(); };
+		[false] = blocklist[false] or { created = now; };
 	};
 	if type(blocklist[false]) == "table" then
-		new_blocklist[false].modified = os.time();
+		new_blocklist[false].modified = now;
 	end
 
 	if is_blocking or next(new) then
-		for jid in pairs(blocklist) do
-			if jid then new_blocklist[jid] = true; end
+		for jid, t in pairs(blocklist) do
+			if jid then new_blocklist[jid] = t; end
 		end
 		for jid in pairs(new) do
 			new_blocklist[jid] = is_blocking;
--- a/plugins/mod_bosh.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_bosh.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -6,9 +6,8 @@
 -- COPYING file in the source package for more information.
 --
 
-module:set_global(); -- Global module
+module:set_global();
 
-local hosts = _G.hosts;
 local new_xmpp_stream = require "util.xmppstream".new;
 local sm = require "core.sessionmanager";
 local sm_destroy_session = sm.destroy_session;
@@ -16,12 +15,14 @@
 local core_process_stanza = prosody.core_process_stanza;
 local st = require "util.stanza";
 local logger = require "util.logger";
-local log = logger.init("mod_bosh");
+local log = module._log;
 local initialize_filters = require "util.filters".initialize;
 local math_min = math.min;
-local xpcall, tostring, type = xpcall, tostring, type;
+local tostring, type = tostring, type;
 local traceback = debug.traceback;
+local runner = require"util.async".runner;
 local nameprep = require "util.encodings".stringprep.nameprep;
+local cache = require "util.cache";
 
 local xmlns_streams = "http://etherx.jabber.org/streams";
 local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams";
@@ -48,33 +49,14 @@
 if cross_domain == true then cross_domain = "*"; end
 if type(cross_domain) == "table" then cross_domain = table.concat(cross_domain, ", "); end
 
-local trusted_proxies = module:get_option_set("trusted_proxies", { "127.0.0.1", "::1" })._items;
-
-local function get_ip_from_request(request)
-	local ip = request.conn:ip();
-	local forwarded_for = request.headers.x_forwarded_for;
-	if forwarded_for then
-		forwarded_for = forwarded_for..", "..ip;
-		for forwarded_ip in forwarded_for:gmatch("[^%s,]+") do
-			if not trusted_proxies[forwarded_ip] then
-				ip = forwarded_ip;
-			end
-		end
-	end
-	return ip;
-end
-
 local t_insert, t_remove, t_concat = table.insert, table.remove, table.concat;
-local os_time = os.time;
 
 -- All sessions, and sessions that have no requests open
-local sessions, inactive_sessions = module:shared("sessions", "inactive_sessions");
+local sessions = module:shared("sessions");
 
 -- Used to respond to idle sessions (those with waiting requests)
-local waiting_requests = module:shared("waiting_requests");
 function on_destroy_request(request)
 	log("debug", "Request destroyed: %s", tostring(request));
-	waiting_requests[request] = nil;
 	local session = sessions[request.context.sid];
 	if session then
 		local requests = session.requests;
@@ -88,9 +70,24 @@
 		-- If this session now has no requests open, mark it as inactive
 		local max_inactive = session.bosh_max_inactive;
 		if max_inactive and #requests == 0 then
-			inactive_sessions[session] = os_time() + max_inactive;
+			if session.inactive_timer then
+				session.inactive_timer:stop();
+			end
+			session.inactive_timer = module:add_timer(max_inactive, check_inactive, session, request.context,
+				"BOSH client silent for over "..max_inactive.." seconds");
 			(session.log or log)("debug", "BOSH session marked as inactive (for %ds)", max_inactive);
 		end
+		if session.bosh_wait_timer then
+			session.bosh_wait_timer:stop();
+			session.bosh_wait_timer = nil;
+		end
+	end
+end
+
+function check_inactive(now, session, context, reason) -- luacheck: ignore 212/now
+	if not session.destroyed then
+		sessions[context.sid] = nil;
+		sm_destroy_session(session, reason);
 	end
 end
 
@@ -124,7 +121,7 @@
 	local headers = response.headers;
 	headers.content_type = "text/xml; charset=utf-8";
 
-	if cross_domain and event.request.headers.origin then
+	if cross_domain and request.headers.origin then
 		set_cross_domain_headers(response);
 	end
 
@@ -148,8 +145,14 @@
 	if session then
 		-- Session was marked as inactive, since we have
 		-- a request open now, unmark it
-		if inactive_sessions[session] and #session.requests > 0 then
-			inactive_sessions[session] = nil;
+		if session.inactive_timer and #session.requests > 0 then
+			session.inactive_timer:stop();
+			session.inactive_timer = nil;
+		end
+
+		if session.bosh_wait_timer then
+			session.bosh_wait_timer:stop();
+			session.bosh_wait_timer = nil;
 		end
 
 		local r = session.requests;
@@ -177,9 +180,6 @@
 		if not response.finished then
 			-- We're keeping this request open, to respond later
 			log("debug", "Have nothing to say, so leaving request unanswered for now");
-			if session.bosh_wait then
-				waiting_requests[response] = os_time() + session.bosh_wait;
-			end
 		end
 
 		if session.bosh_terminate then
@@ -187,10 +187,22 @@
 			session:close();
 			return nil;
 		else
+			if session.bosh_wait and #session.requests > 0 then
+				session.bosh_wait_timer = module:add_timer(session.bosh_wait, after_bosh_wait, session.requests[1], session)
+			end
+
 			return true; -- Inform http server we shall reply later
 		end
-	elseif response.finished then
-		return; -- A response has been sent already
+	elseif response.finished or context.ignore_request then
+		if response.finished then
+			module:log("debug", "Response finished");
+		end
+		if context.ignore_request then
+			module:log("debug", "Ignoring this request");
+		end
+		-- A response has been sent already, or we're ignoring this request
+		-- (e.g. so a different instance of the module can handle it)
+		return;
 	end
 	module:log("warn", "Unable to associate request with a session (incomplete request?)");
 	local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
@@ -198,13 +210,17 @@
 	return tostring(close_reply) .. "\n";
 end
 
+function after_bosh_wait(now, request, session) -- luacheck: ignore 212
+	if request.conn then
+		session.send("");
+	end
+end
 
 local function bosh_reset_stream(session) session.notopen = true; end
 
 local stream_xmlns_attr = { xmlns = "urn:ietf:params:xml:ns:xmpp-streams" };
-
 local function bosh_close_stream(session, reason)
-	(session.log or log)("info", "BOSH client disconnected");
+	(session.log or log)("info", "BOSH client disconnected: %s", tostring((reason and reason.condition or reason) or "session close"));
 
 	local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
 		["xmlns:stream"] = xmlns_streams });
@@ -237,21 +253,22 @@
 		held_request:send(response_body);
 	end
 	sessions[session.sid] = nil;
-	inactive_sessions[session] = nil;
 	sm_destroy_session(session);
 end
 
+local runner_callbacks = { };
+
 -- Handle the <body> tag in the request payload.
 function stream_callbacks.streamopened(context, attr)
 	local request, response = context.request, context.response;
-	local sid = attr.sid;
+	local sid, rid = attr.sid, tonumber(attr.rid);
 	log("debug", "BOSH body open (sid: %s)", sid or "<none>");
+	context.rid = rid;
 	if not sid then
 		-- New session request
 		context.notopen = nil; -- Signals that we accept this opening tag
 
 		local to_host = nameprep(attr.to);
-		local rid = tonumber(attr.rid);
 		local wait = tonumber(attr.wait);
 		if not to_host then
 			log("debug", "BOSH client tried to connect to invalid host: %s", tostring(attr.to));
@@ -259,13 +276,6 @@
 				["xmlns:stream"] = xmlns_streams, condition = "improper-addressing" });
 			response:send(tostring(close_reply));
 			return;
-		elseif not hosts[to_host] then
-			-- Unknown host
-			log("debug", "BOSH client tried to connect to unknown host: %s", tostring(attr.to));
-			local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
-				["xmlns:stream"] = xmlns_streams, condition = "host-unknown" });
-			response:send(tostring(close_reply));
-			return;
 		end
 		if not rid or (not wait and attr.wait or wait < 0 or wait % 1 ~= 0) then
 			log("debug", "BOSH client sent invalid rid or wait attributes: rid=%s, wait=%s", tostring(attr.rid), tostring(attr.wait));
@@ -275,28 +285,32 @@
 			return;
 		end
 
-		rid = rid - 1;
 		wait = math_min(wait, bosh_max_wait);
 
 		-- New session
 		sid = new_uuid();
 		local session = {
-			type = "c2s_unauthed", conn = request.conn, sid = sid, rid = rid, host = to_host,
+			type = "c2s_unauthed", conn = request.conn, sid = sid, host = attr.to,
+			rid = rid - 1, -- Hack for initial session setup, "previous" rid was $current_request - 1
 			bosh_version = attr.ver, bosh_wait = wait, streamid = sid,
-			bosh_max_inactive = bosh_max_inactivity,
+			bosh_max_inactive = bosh_max_inactivity, bosh_responses = cache.new(BOSH_HOLD+1):table();
 			requests = { }, send_buffer = {}, reset_stream = bosh_reset_stream,
 			close = bosh_close_stream, dispatch_stanza = core_process_stanza, notopen = true,
 			log = logger.init("bosh"..sid),	secure = consider_bosh_secure or request.secure,
-			ip = get_ip_from_request(request);
+			ip = request.ip;
 		};
 		sessions[sid] = session;
 
+		session.thread = runner(function (stanza)
+			session:dispatch_stanza(stanza);
+		end, runner_callbacks, session);
+
 		local filter = initialize_filters(session);
 
 		session.log("debug", "BOSH session created for request from %s", session.ip);
 		log("info", "New BOSH session, assigned it sid '%s'", sid);
 
-		hosts[session.host].events.fire_event("bosh-session", { session = session, request = request });
+		module:fire_event("bosh-session", { session = session, request = request });
 
 		-- Send creation response
 		local creating_session = true;
@@ -335,8 +349,9 @@
 					body_attr["xmlns:xmpp"] = "urn:xmpp:xbosh";
 					body_attr["xmpp:version"] = "1.0";
 				end
-				session.bosh_last_response = st.stanza("body", body_attr):top_tag()..t_concat(session.send_buffer).."</body>";
-				oldest_request:send(session.bosh_last_response);
+				local response_xml = st.stanza("body", body_attr):top_tag()..t_concat(session.send_buffer).."</body>";
+				session.bosh_responses[oldest_request.context.rid] = response_xml;
+				oldest_request:send(response_xml);
 				session.send_buffer = {};
 			end
 			return true;
@@ -356,24 +371,31 @@
 	session.conn = request.conn;
 
 	if session.rid then
-		local rid = tonumber(attr.rid);
 		local diff = rid - session.rid;
 		-- Diff should be 1 for a healthy request
+		session.log("debug", "rid: %d, sess: %s, diff: %d", rid, session.rid, diff)
 		if diff ~= 1 then
 			context.sid = sid;
 			context.notopen = nil;
-			if diff == 2 then
+			if diff == 2 then -- Missed a request
 				-- Hold request, but don't process it (ouch!)
 				session.log("debug", "rid skipped: %d, deferring this request", rid-1)
 				context.defer = true;
 				session.bosh_deferred = { context = context, sid = sid, rid = rid, terminate = attr.type == "terminate" };
 				return;
 			end
+			-- Set a marker to indicate that stanzas in this request should NOT be processed
+			-- (these stanzas will already be in the XML parser's buffer)
 			context.ignore = true;
-			if diff == 0 then
-				-- Re-send previous response, ignore stanzas in this request
-				session.log("debug", "rid repeated, ignoring: %s (diff %d)", session.rid, diff);
-				response:send(session.bosh_last_response);
+			if session.bosh_responses[rid] then
+				-- Re-send past response, ignore stanzas in this request
+				session.log("debug", "rid repeated within window, replaying old response");
+				response:send(session.bosh_responses[rid]);
+				return;
+			elseif diff == 0 then
+				session.log("debug", "current rid repeated, ignoring stanzas");
+				t_insert(session.requests, response);
+				context.sid = sid;
 				return;
 			end
 			-- Session broken, destroy it
@@ -397,13 +419,18 @@
 
 	if session.notopen then
 		local features = st.stanza("stream:features");
-		hosts[session.host].events.fire_event("stream-features", { origin = session, features = features });
+		module:context(session.host):fire_event("stream-features", { origin = session, features = features });
 		session.send(features);
 		session.notopen = nil;
 	end
 end
 
 local function handleerr(err) log("error", "Traceback[bosh]: %s", traceback(tostring(err), 2)); end
+
+function runner_callbacks:error(err) -- luacheck: ignore 212/self
+	return handleerr(err);
+end
+
 function stream_callbacks.handlestanza(context, stanza)
 	if context.ignore then return; end
 	log("debug", "BOSH stanza received: %s\n", stanza:top_tag());
@@ -417,9 +444,7 @@
 			t_insert(session.bosh_deferred, stanza);
 		else
 			stanza = session.filter("stanzas/in", stanza);
-			if stanza then
-				return xpcall(function () return core_process_stanza(session, stanza) end, handleerr);
-			end
+			session.thread:run(stanza);
 		end
 	else
 		log("debug", "No session for this stanza! (sid: %s)", context.sid or "none!");
@@ -432,13 +457,13 @@
 		if not context.defer and session.bosh_deferred then
 			-- Handle deferred stanzas now
 			local deferred_stanzas = session.bosh_deferred;
-			local context = deferred_stanzas.context;
+			local deferred_context = deferred_stanzas.context;
 			session.bosh_deferred = nil;
 			log("debug", "Handling deferred stanzas from rid %d", deferred_stanzas.rid);
 			session.rid = deferred_stanzas.rid;
-			t_insert(session.requests, context.response);
+			t_insert(session.requests, deferred_context.response);
 			for _, stanza in ipairs(deferred_stanzas) do
-				stream_callbacks.handlestanza(context, stanza);
+				stream_callbacks.handlestanza(deferred_context, stanza);
 			end
 			if deferred_stanzas.terminate then
 				session.bosh_terminate = true;
@@ -452,8 +477,8 @@
 end
 
 function stream_callbacks.error(context, error)
-	log("debug", "Error parsing BOSH request payload; %s", error);
 	if not context.sid then
+		log("debug", "Error parsing BOSH request payload; %s", error);
 		local response = context.response;
 		local close_reply = st.stanza("body", { xmlns = xmlns_bosh, type = "terminate",
 			["xmlns:stream"] = xmlns_streams, condition = "bad-request" });
@@ -462,6 +487,7 @@
 	end
 
 	local session = sessions[context.sid];
+	(session and session.log or log)("warn", "Error parsing BOSH request payload; %s", error);
 	if error == "stream-error" then -- Remote stream error, we close normally
 		session:close();
 	else
@@ -469,65 +495,25 @@
 	end
 end
 
-local dead_sessions = module:shared("dead_sessions");
-function on_timer()
-	-- log("debug", "Checking for requests soon to timeout...");
-	-- Identify requests timing out within the next few seconds
-	local now = os_time() + 3;
-	for request, reply_before in pairs(waiting_requests) do
-		if reply_before <= now then
-			log("debug", "%s was soon to timeout (at %d, now %d), sending empty response", tostring(request), reply_before, now);
-			-- Send empty response to let the
-			-- client know we're still here
-			if request.conn then
-				sessions[request.context.sid].send("");
-			end
-		end
-	end
-
-	now = now - 3;
-	local n_dead_sessions = 0;
-	for session, close_after in pairs(inactive_sessions) do
-		if close_after < now then
-			(session.log or log)("debug", "BOSH client inactive too long, destroying session at %d", now);
-			sessions[session.sid]  = nil;
-			inactive_sessions[session] = nil;
-			n_dead_sessions = n_dead_sessions + 1;
-			dead_sessions[n_dead_sessions] = session;
-		end
-	end
-
-	for i=1,n_dead_sessions do
-		local session = dead_sessions[i];
-		dead_sessions[i] = nil;
-		sm_destroy_session(session, "BOSH client silent for over "..session.bosh_max_inactive.." seconds");
-	end
-	return 1;
-end
-module:add_timer(1, on_timer);
-
-
 local GET_response = {
 	headers = {
 		content_type = "text/html";
 	};
 	body = [[<html><body>
 	<p>It works! Now point your BOSH client to this URL to connect to Prosody.</p>
-	<p>For more information see <a href="http://prosody.im/doc/setting_up_bosh">Prosody: Setting up BOSH</a>.</p>
+	<p>For more information see <a href="https://prosody.im/doc/setting_up_bosh">Prosody: Setting up BOSH</a>.</p>
 	</body></html>]];
 };
 
-function module.add_host(module)
-	module:depends("http");
-	module:provides("http", {
-		default_path = "/http-bind";
-		route = {
-			["GET"] = GET_response;
-			["GET /"] = GET_response;
-			["OPTIONS"] = handle_OPTIONS;
-			["OPTIONS /"] = handle_OPTIONS;
-			["POST"] = handle_POST;
-			["POST /"] = handle_POST;
-		};
-	});
-end
+module:depends("http");
+module:provides("http", {
+	default_path = "/http-bind";
+	route = {
+		["GET"] = GET_response;
+		["GET /"] = GET_response;
+		["OPTIONS"] = handle_OPTIONS;
+		["OPTIONS /"] = handle_OPTIONS;
+		["POST"] = handle_POST;
+		["POST /"] = handle_POST;
+	};
+});
--- a/plugins/mod_c2s.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_c2s.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -15,9 +15,9 @@
 local st = require "util.stanza";
 local sm_new_session, sm_destroy_session = sessionmanager.new_session, sessionmanager.destroy_session;
 local uuid_generate = require "util.uuid".generate;
+local runner = require "util.async".runner;
 
-local xpcall, tostring, type = xpcall, tostring, type;
-local traceback = debug.traceback;
+local tostring, type = tostring, type;
 
 local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams";
 
@@ -28,6 +28,7 @@
 local opt_keepalives = module:get_option_boolean("c2s_tcp_keepalives", module:get_option_boolean("tcp_keepalives", true));
 
 local measure_connections = module:measure("connections", "amount");
+local measure_ipv6 = module:measure("ipv6", "amount");
 
 local sessions = module:shared("sessions");
 local core_process_stanza = prosody.core_process_stanza;
@@ -35,13 +36,19 @@
 
 local stream_callbacks = { default_ns = "jabber:client" };
 local listener = {};
+local runner_callbacks = {};
 
 module:hook("stats-update", function ()
 	local count = 0;
-	for _ in pairs(sessions) do
+	local ipv6 = 0;
+	for _, session in pairs(sessions) do
 		count = count + 1;
+		if session.ip and session.ip:match(":") then
+			ipv6 = ipv6 + 1;
+		end
 	end
 	measure_connections(count);
+	measure_ipv6(ipv6);
 end);
 
 --- Stream events handlers
@@ -64,7 +71,7 @@
 	end
 	session.version = tonumber(attr.version) or 0;
 	session.streamid = uuid_generate();
-	(session.log or session)("debug", "Client sent opening <stream:stream> to %s", session.host);
+	(session.log or log)("debug", "Client sent opening <stream:stream> to %s", session.host);
 
 	if not hosts[session.host] or not hosts[session.host].modules.c2s then
 		-- We don't serve this host...
@@ -134,12 +141,9 @@
 	end
 end
 
-local function handleerr(err) log("error", "Traceback[c2s]: %s", traceback(tostring(err), 2)); end
 function stream_callbacks.handlestanza(session, stanza)
 	stanza = session.filter("stanzas/in", stanza);
-	if stanza then
-		return xpcall(function () return core_process_stanza(session, stanza) end, handleerr);
-	end
+	session.thread:run(stanza);
 end
 
 --- Session methods
@@ -220,6 +224,18 @@
 	end
 end, 200);
 
+function runner_callbacks:ready()
+	self.data.conn:resume();
+end
+
+function runner_callbacks:waiting()
+	self.data.conn:pause();
+end
+
+function runner_callbacks:error(err)
+	(self.data.log or log)("error", "Traceback[c2s]: %s", err);
+end
+
 --- Port listener
 function listener.onconnect(conn)
 	local session = sm_new_session(conn);
@@ -256,6 +272,10 @@
 		session.stream:reset();
 	end
 
+	session.thread = runner(function (stanza)
+		core_process_stanza(session, stanza);
+	end, runner_callbacks, session);
+
 	local filter = session.filter;
 	function session.data(data)
 		-- Parse the data, which will store stanzas in session.pending_stanzas
--- a/plugins/mod_component.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_component.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -9,7 +9,8 @@
 module:set_global();
 
 local t_concat = table.concat;
-local xpcall, tostring, type = xpcall, tostring, type;
+local tostring, type = tostring, type;
+local xpcall = require "util.xpcall".xpcall;
 local traceback = debug.traceback;
 
 local logger = require "util.logger";
@@ -38,7 +39,7 @@
 
 function module.add_host(module)
 	if module:get_host_type() ~= "component" then
-		error("Don't load mod_component manually, it should be for a component, please see http://prosody.im/doc/components", 0);
+		error("Don't load mod_component manually, it should be for a component, please see https://prosody.im/doc/components", 0);
 	end
 
 	local env = module.environment;
@@ -238,7 +239,7 @@
 	end
 
 	if stanza then
-		return xpcall(function () return core_process_stanza(session, stanza) end, handleerr);
+		return xpcall(core_process_stanza, handleerr, session, stanza);
 	end
 end
 
--- a/plugins/mod_compression.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,9 +0,0 @@
--- Prosody IM
--- Copyright (C) 2016 Matthew Wild
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
--- COMPAT w/ pre-0.10 configs
-error("mod_compression has been removed in Prosody 0.10+. Please see https://prosody.im/doc/modules/mod_compression for more information.");
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_csi.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,23 @@
+local st = require "util.stanza";
+local xmlns_csi = "urn:xmpp:csi:0";
+local csi_feature = st.stanza("csi", { xmlns = xmlns_csi });
+
+module:hook("stream-features", function (event)
+	if event.origin.username then
+		event.features:add_child(csi_feature);
+	end
+end);
+
+function refire_event(name)
+	return function (event)
+		if event.origin.username then
+			event.origin.state = event.stanza.name;
+			module:fire_event(name, event);
+			return true;
+		end
+	end;
+end
+
+module:hook("stanza/"..xmlns_csi..":active", refire_event("csi-client-active"));
+module:hook("stanza/"..xmlns_csi..":inactive", refire_event("csi-client-inactive"));
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_csi_simple.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,120 @@
+-- Copyright (C) 2016-2018 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+module:depends"csi"
+
+local jid = require "util.jid";
+local st = require "util.stanza";
+local dt = require "util.datetime";
+local new_queue = require "util.queue".new;
+
+local function new_pump(output, ...)
+	-- luacheck: ignore 212/self
+	local q = new_queue(...);
+	local flush = true;
+	function q:pause()
+		flush = false;
+	end
+	function q:resume()
+		flush = true;
+		return q:flush();
+	end
+	local push = q.push;
+	function q:push(item)
+		local ok = push(self, item);
+		if not ok then
+			q:flush();
+			output(item, self);
+		elseif flush then
+			return q:flush();
+		end
+		return true;
+	end
+	function q:flush()
+		local item = self:pop();
+		while item do
+			output(item, self);
+			item = self:pop();
+		end
+		return true;
+	end
+	return q;
+end
+
+local queue_size = module:get_option_number("csi_queue_size", 256);
+
+module:hook("csi-is-stanza-important", function (event)
+	local stanza = event.stanza;
+	if not st.is_stanza(stanza) then
+		return true;
+	end
+	local st_name = stanza.name;
+	if not st_name then return false; end
+	local st_type = stanza.attr.type;
+	if st_name == "presence" then
+		if st_type == nil or st_type == "unavailable" then
+			return false;
+		end
+		return true;
+	elseif st_name == "message" then
+		if st_type == "headline" then
+			return false;
+		end
+		if stanza:get_child("sent", "urn:xmpp:carbons:2") then
+			return true;
+		end
+		local forwarded = stanza:find("{urn:xmpp:carbons:2}received/{urn:xmpp:forward:0}/{jabber:client}message");
+		if forwarded then
+			stanza = forwarded;
+		end
+		if stanza:get_child("body") then
+			return true;
+		end
+		if stanza:get_child("subject") then
+			return true;
+		end
+		if stanza:get_child("encryption", "urn:xmpp:eme:0") then
+			return true;
+		end
+		return false;
+	end
+	return true;
+end, -1);
+
+module:hook("csi-client-inactive", function (event)
+	local session = event.origin;
+	if session.pump then
+		session.pump:pause();
+	else
+		local bare_jid = jid.join(session.username, session.host);
+		local send = session.send;
+		session._orig_send = send;
+		local pump = new_pump(session.send, queue_size);
+		pump:pause();
+		session.pump = pump;
+		function session.send(stanza)
+			if session.state == "active" or module:fire_event("csi-is-stanza-important", { stanza = stanza, session = session }) then
+				pump:flush();
+				send(stanza);
+			else
+				if st.is_stanza(stanza) and stanza.attr.xmlns == nil and stanza.name ~= "iq" then
+					stanza = st.clone(stanza);
+					stanza:add_direct_child(st.stanza("delay", {xmlns = "urn:xmpp:delay", from = bare_jid, stamp = dt.datetime()}));
+				end
+				pump:push(stanza);
+			end
+			return true;
+		end
+	end
+end);
+
+module:hook("csi-client-active", function (event)
+	local session = event.origin;
+	if session.pump then
+		session.pump:resume();
+	end
+end);
+
--- a/plugins/mod_disco.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_disco.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -95,9 +95,8 @@
 module:hook("item-removed/extension", clear_disco_cache);
 
 -- Handle disco requests to the server
-module:hook("iq/host/http://jabber.org/protocol/disco#info:query", function(event)
+module:hook("iq-get/host/http://jabber.org/protocol/disco#info:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
-	if stanza.attr.type ~= "get" then return; end
 	local node = stanza.tags[1].attr.node;
 	if node and node ~= "" and node ~= "http://prosody.im#"..get_server_caps_hash() then
 		local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#info', node=node});
@@ -117,9 +116,8 @@
 	origin.send(reply);
 	return true;
 end);
-module:hook("iq/host/http://jabber.org/protocol/disco#items:query", function(event)
+module:hook("iq-get/host/http://jabber.org/protocol/disco#items:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
-	if stanza.attr.type ~= "get" then return; end
 	local node = stanza.tags[1].attr.node;
 	if node and node ~= "" then
 		local reply = st.reply(stanza):tag('query', {xmlns='http://jabber.org/protocol/disco#items', node=node});
@@ -155,9 +153,8 @@
 
 -- Handle disco requests to user accounts
 if module:get_host_type() ~= "local" then	return end -- skip for components
-module:hook("iq/bare/http://jabber.org/protocol/disco#info:query", function(event)
+module:hook("iq-get/bare/http://jabber.org/protocol/disco#info:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
-	if stanza.attr.type ~= "get" then return; end
 	local node = stanza.tags[1].attr.node;
 	local username = jid_split(stanza.attr.to) or origin.username;
 	if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
@@ -182,9 +179,8 @@
 		return true;
 	end
 end);
-module:hook("iq/bare/http://jabber.org/protocol/disco#items:query", function(event)
+module:hook("iq-get/bare/http://jabber.org/protocol/disco#items:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
-	if stanza.attr.type ~= "get" then return; end
 	local node = stanza.tags[1].attr.node;
 	local username = jid_split(stanza.attr.to) or origin.username;
 	if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
--- a/plugins/mod_groups.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_groups.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -10,8 +10,8 @@
 local groups;
 local members;
 
-local jid, datamanager = require "util.jid", require "util.datamanager";
-local jid_prep = jid.prep;
+local datamanager = require "util.datamanager";
+local jid_prep = require "util.jid".prep;
 
 local module_host = module:get_host();
 
--- a/plugins/mod_http.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_http.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -13,6 +13,7 @@
 local moduleapi = require "core.moduleapi";
 local url_parse = require "socket.url".parse;
 local url_build = require "socket.url".build;
+local normalize_path = require "util.http".normalize_path;
 
 local server = require "net.http.server";
 
@@ -21,16 +22,6 @@
 server.set_option("body_size_limit", module:get_option_number("http_max_content_size"));
 server.set_option("buffer_size_limit", module:get_option_number("http_max_buffer_size"));
 
-local function normalize_path(path, is_dir)
-	if is_dir then
-		if path:sub(-1,-1) ~= "/" then path = path.."/"; end
-	else
-		if path:sub(-1,-1) == "/" then path = path:sub(1, -2); end
-	end
-	if path:sub(1,1) ~= "/" then path = "/"..path; end
-	return path;
-end
-
 local function get_http_event(host, app_path, key)
 	local method, path = key:match("^(%S+)%s+(.+)$");
 	if not method then -- No path specified, default to "" (base path)
@@ -42,7 +33,11 @@
 	if app_path == "/" and path:sub(1,1) == "/" then
 		app_path = "";
 	end
-	return method:upper().." "..host..app_path..path;
+	if host == "*" then
+		return method:upper().." "..app_path..path;
+	else
+		return method:upper().." "..host..app_path..path;
+	end
 end
 
 local function get_base_path(host_module, app_name, default_app_path)
@@ -54,6 +49,9 @@
 
 local function redir_handler(event)
 	event.response.headers.location = event.request.path.."/";
+	if event.request.url.query then
+		event.response.headers.location = event.response.headers.location .. "?" .. event.request.url.query
+	end
 	return 301;
 end
 
@@ -68,10 +66,10 @@
 	end
 	local services = portmanager.get_active_services();
 	local http_services = services:get("https") or services:get("http") or {};
-	for interface, ports in pairs(http_services) do
-		for port, services in pairs(ports) do
+	for interface, ports in pairs(http_services) do -- luacheck: ignore 213/interface
+		for port, service in pairs(ports) do -- luacheck: ignore 512
 			local url = {
-				scheme = (external_url.scheme or services[1].service.name);
+				scheme = (external_url.scheme or service[1].service.name);
 				host = (external_url.host or module:get_option_string("http_host", module.host));
 				port = tonumber(external_url.port) or port or 80;
 				path = normalize_path(external_url.path or "/", true)..
@@ -86,7 +84,10 @@
 end
 
 function module.add_host(module)
-	local host = module:get_option_string("http_host", module.host);
+	local host = module.host;
+	if host ~= "*" then
+		host = module:get_option_string("http_host", host);
+	end
 	local apps = {};
 	module.environment.apps = apps;
 	local function http_app_added(event)
@@ -109,9 +110,9 @@
 				elseif event_name:sub(-2, -1) == "/*" then
 					local base_path_len = #event_name:match("/.+$");
 					local _handler = handler;
-					handler = function (event)
-						local path = event.request.path:sub(base_path_len);
-						return _handler(event, path);
+					handler = function (_event)
+						local path = _event.request.path:sub(base_path_len);
+						return _handler(_event, path);
 					end;
 					module:hook_object_event(server, event_name:sub(1, -3), redir_handler, -1);
 				elseif event_name:sub(-1, -1) == "/" then
@@ -124,7 +125,7 @@
 					module:log("warn", "App %s added handler twice for '%s', ignoring", app_name, event_name);
 				end
 			else
-				module:log("error", "Invalid route in %s, %q. See http://prosody.im/doc/developers/http#routes", app_name, key);
+				module:log("error", "Invalid route in %s, %q. See https://prosody.im/doc/developers/http#routes", app_name, key);
 			end
 		end
 		local services = portmanager.get_active_services();
@@ -138,19 +139,48 @@
 	local function http_app_removed(event)
 		local app_handlers = apps[event.item.name];
 		apps[event.item.name] = nil;
-		for event, handler in pairs(app_handlers) do
-			module:unhook_object_event(server, event, handler);
+		for event_name, handler in pairs(app_handlers) do
+			module:unhook_object_event(server, event_name, handler);
 		end
 	end
 
 	module:handle_items("http-provider", http_app_added, http_app_removed);
 
-	server.add_host(host);
-	function module.unload()
-		server.remove_host(host);
+	if host ~= "*" then
+		server.add_host(host);
+		function module.unload()
+			server.remove_host(host);
+		end
 	end
 end
 
+module.add_host(module); -- set up handling on global context too
+
+local trusted_proxies = module:get_option_set("trusted_proxies", { "127.0.0.1", "::1" })._items;
+
+local function get_ip_from_request(request)
+	local ip = request.conn:ip();
+	local forwarded_for = request.headers.x_forwarded_for;
+	if forwarded_for then
+		forwarded_for = forwarded_for..", "..ip;
+		for forwarded_ip in forwarded_for:gmatch("[^%s,]+") do
+			if not trusted_proxies[forwarded_ip] then
+				ip = forwarded_ip;
+			end
+		end
+	end
+	return ip;
+end
+
+module:wrap_object_event(server._events, false, function (handlers, event_name, event_data)
+	local request = event_data.request;
+	if request then
+		-- Not included in eg http-error events
+		request.ip = get_ip_from_request(request);
+	end
+	return handlers(event_name, event_data);
+end);
+
 module:provides("net", {
 	name = "http";
 	listener = server.listener;
--- a/plugins/mod_http_files.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_http_files.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -89,6 +89,7 @@
 	if type(opts) ~= "table" then -- assume path string
 		opts = { path = opts };
 	end
+	-- luacheck: ignore 431
 	local base_path = opts.path;
 	local dir_indices = opts.index_files or dir_indices;
 	local directory_index = opts.directory_index;
@@ -127,9 +128,9 @@
 			data = data.data;
 		elseif attr.mode == "directory" and path then
 			if full_path:sub(-1) ~= "/" then
-				local path = { is_absolute = true, is_directory = true };
-				for dir in orig_path:gmatch("[^/]+") do path[#path+1]=dir; end
-				response_headers.location = build_path(path);
+				local dir_path = { is_absolute = true, is_directory = true };
+				for dir in orig_path:gmatch("[^/]+") do dir_path[#dir_path+1]=dir; end
+				response_headers.location = build_path(dir_path);
 				return 301;
 			end
 			for i=1,#dir_indices do
--- a/plugins/mod_iq.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_iq.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -13,7 +13,7 @@
 
 if module:get_host_type() == "local" then
 	module:hook("iq/full", function(data)
-		-- IQ to full JID recieved
+		-- IQ to full JID received
 		local origin, stanza = data.origin, data.stanza;
 
 		local session = full_sessions[stanza.attr.to];
@@ -27,7 +27,7 @@
 end
 
 module:hook("iq/bare", function(data)
-	-- IQ to bare JID recieved
+	-- IQ to bare JID received
 	local stanza = data.stanza;
 	local type = stanza.attr.type;
 
@@ -44,7 +44,7 @@
 end);
 
 module:hook("iq/self", function(data)
-	-- IQ to self JID recieved
+	-- IQ to self JID received
 	local stanza = data.stanza;
 	local type = stanza.attr.type;
 
@@ -60,7 +60,7 @@
 end);
 
 module:hook("iq/host", function(data)
-	-- IQ to a local host recieved
+	-- IQ to a local host received
 	local stanza = data.stanza;
 	local type = stanza.attr.type;
 
--- a/plugins/mod_lastactivity.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_lastactivity.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -24,22 +24,20 @@
 	end
 end, 10);
 
-module:hook("iq/bare/jabber:iq:last:query", function(event)
+module:hook("iq-get/bare/jabber:iq:last:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
-	if stanza.attr.type == "get" then
-		local username = jid_split(stanza.attr.to) or origin.username;
-		if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
-			local seconds, text = "0", "";
-			if map[username] then
-				seconds = tostring(os.difftime(os.time(), map[username].t));
-				text = map[username].s;
-			end
-			origin.send(st.reply(stanza):tag('query', {xmlns='jabber:iq:last', seconds=seconds}):text(text));
-		else
-			origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
+	local username = jid_split(stanza.attr.to) or origin.username;
+	if not stanza.attr.to or is_contact_subscribed(username, module.host, jid_bare(stanza.attr.from)) then
+		local seconds, text = "0", "";
+		if map[username] then
+			seconds = tostring(os.difftime(os.time(), map[username].t));
+			text = map[username].s;
 		end
-		return true;
+		origin.send(st.reply(stanza):tag('query', {xmlns='jabber:iq:last', seconds=seconds}):text(text));
+	else
+		origin.send(st.error_reply(stanza, 'auth', 'forbidden'));
 	end
+	return true;
 end);
 
 module.save = function()
--- a/plugins/mod_legacyauth.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_legacyauth.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -35,7 +35,8 @@
 	local session, stanza = event.origin, event.stanza;
 
 	if session.type ~= "c2s_unauthed" then
-		(session.sends2s or session.send)(st.error_reply(stanza, "cancel", "service-unavailable", "Legacy authentication is only allowed for unauthenticated client connections."));
+		(session.sends2s or session.send)(st.error_reply(stanza, "cancel", "service-unavailable",
+			"Legacy authentication is only allowed for unauthenticated client connections."));
 		return true;
 	end
 
--- a/plugins/mod_limits.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_limits.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -51,18 +51,18 @@
 local default_filter_set = {};
 
 function default_filter_set.bytes_in(bytes, session)
-	local throttle = session.throttle;
-	if throttle then
-		local ok, balance, outstanding = throttle:poll(#bytes, true);
+  local sess_throttle = session.throttle;
+  if sess_throttle then
+    local ok, balance, outstanding = sess_throttle:poll(#bytes, true);
 		if not ok then
-			session.log("debug", "Session over rate limit (%d) with %d (by %d), pausing", throttle.max, #bytes, outstanding);
+      session.log("debug", "Session over rate limit (%d) with %d (by %d), pausing", sess_throttle.max, #bytes, outstanding);
 			outstanding = ceil(outstanding);
 			session.conn:pause(); -- Read no more data from the connection until there is no outstanding data
 			local outstanding_data = bytes:sub(-outstanding);
 			bytes = bytes:sub(1, #bytes-outstanding);
 			timer.add_task(limits_resolution, function ()
 				if not session.conn then return; end
-				if throttle:peek(#outstanding_data) then
+        if sess_throttle:peek(#outstanding_data) then
 					session.log("debug", "Resuming paused session");
 					session.conn:resume();
 				end
--- a/plugins/mod_mam/fallback_archive.lib.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,91 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2017 Matthew Wild
--- Copyright (C) 2008-2017 Waqas Hussain
--- Copyright (C) 2011-2017 Kim Alvefur
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
--- luacheck: ignore 212/self
-
-local uuid = require "util.uuid".generate;
-local store = module:shared("archive");
-local archive_store = { _provided_by = "mam"; name = "fallback"; };
-
-function archive_store:append(username, key, value, when, with)
-	local archive = store[username];
-	if not archive then
-		archive = { [0] = 0 };
-		store[username] = archive;
-	end
-	local index = (archive[0] or #archive)+1;
-	local item = { key = key, when = when, with = with, value = value };
-	if not key or archive[key] then
-		key = uuid();
-		item.key = key;
-	end
-	archive[index] = item;
-	archive[key] = index;
-	archive[0] = index;
-	return key;
-end
-
-function archive_store:find(username, query)
-	local archive = store[username] or {};
-	local start, stop, step = 1, archive[0] or #archive, 1;
-	local qstart, qend, qwith = -math.huge, math.huge;
-	local limit;
-
-	if query then
-		if query.reverse then
-			start, stop, step = stop, start, -1;
-			if query.before and archive[query.before] then
-				start = archive[query.before] - 1;
-			end
-		elseif query.after and archive[query.after] then
-			start = archive[query.after] + 1;
-		end
-		qwith = query.with;
-		limit = query.limit;
-		qstart = query.start or qstart;
-		qend = query["end"] or qend;
-	end
-
-	return function ()
-		if limit and limit <= 0 then return end
-		for i = start, stop, step do
-			local item = archive[i];
-			if (not qwith or qwith == item.with) and item.when >= qstart and item.when <= qend then
-				if limit then limit = limit - 1; end
-				start = i + step; -- Start on next item
-				return item.key, item.value, item.when, item.with;
-			end
-		end
-	end
-end
-
-function archive_store:delete(username, query)
-	if not query or next(query) == nil then
-		-- no specifics, delete everything
-		store[username] = nil;
-		return true;
-	end
-	local archive = store[username];
-	if not archive then return true; end -- no messages, nothing to delete
-
-	local qstart = query.start or -math.huge;
-	local qend = query["end"] or math.huge;
-	local qwith = query.with;
-		store[username] = nil;
-	for i = 1, #archive do
-		local item = archive[i];
-		local when, with = item.when, item.when;
-		-- Add things that don't match the query
-		if not ((not qwith or qwith == item.with) and item.when >= qstart and item.when <= qend) then
-			self:append(username, item.key, item.value, when, with);
-		end
-	end
-	return true;
-end
-
-return archive_store;
--- a/plugins/mod_mam/mamprefsxml.lib.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_mam/mamprefsxml.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -2,6 +2,7 @@
 -- Copyright (C) 2008-2017 Matthew Wild
 -- Copyright (C) 2008-2017 Waqas Hussain
 -- Copyright (C) 2011-2017 Kim Alvefur
+-- Copyright (C) 2018 Emmanuel Gil Peyrot
 --
 -- This project is MIT/X11 licensed. Please see the
 -- COPYING file in the source package for more information.
@@ -10,6 +11,7 @@
 --
 
 local st = require"util.stanza";
+local jid_prep = require"util.jid".prep;
 local xmlns_mam = "urn:xmpp:mam:2";
 
 local default_attrs = {
@@ -42,16 +44,20 @@
 	local always = prefstanza:get_child("always");
 	if always then
 		for rule in always:childtags("jid") do
-			local jid = rule:get_text();
-			prefs[jid] = true;
+			local jid = jid_prep(rule:get_text());
+			if jid then
+				prefs[jid] = true;
+			end
 		end
 	end
 
 	local never = prefstanza:get_child("never");
 	if never then
 		for rule in never:childtags("jid") do
-			local jid = rule:get_text();
-			prefs[jid] = false;
+			local jid = jid_prep(rule:get_text());
+			if jid then
+				prefs[jid] = false;
+			end
 		end
 	end
 
--- a/plugins/mod_mam/mod_mam.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_mam/mod_mam.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -40,18 +40,10 @@
 local archive_store = module:get_option_string("archive_store", "archive");
 local archive = module:open_store(archive_store, "archive");
 
-if archive.name == "null" or not archive.find then
-	if not archive.find then
-		module:log("debug", "Attempt to open archive storage returned a valid driver but it does not seem to implement the storage API");
-		module:log("debug", "mod_%s does not support archiving", archive._provided_by or archive.name and "storage_"..archive.name.."(?)" or "<unknown>");
-	else
-		module:log("debug", "Attempt to open archive storage returned null driver");
-	end
-	module:log("debug", "See https://prosody.im/doc/storage and https://prosody.im/doc/archiving for more information");
-	module:log("info", "Using in-memory fallback archive driver");
-	archive = module:require "fallback_archive";
+if not archive.find then
+	error("mod_"..(archive._provided_by or archive.name and "storage_"..archive.name).." does not support archiving\n"
+		.."See https://prosody.im/doc/storage and https://prosody.im/doc/archiving for more information");
 end
-
 local use_total = module:get_option_boolean("mam_include_total", true);
 
 local cleanup;
--- a/plugins/mod_message.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_message.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -63,7 +63,7 @@
 end
 
 module:hook("message/full", function(data)
-	-- message to full JID recieved
+	-- message to full JID received
 	local origin, stanza = data.origin, data.stanza;
 
 	local session = full_sessions[stanza.attr.to];
@@ -75,7 +75,7 @@
 end, -1);
 
 module:hook("message/bare", function(data)
-	-- message to bare JID recieved
+	-- message to bare JID received
 	local origin, stanza = data.origin, data.stanza;
 
 	return process_to_bare(stanza.attr.to or (origin.username..'@'..origin.host), origin, stanza);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_muc_mam.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,385 @@
+-- XEP-0313: Message Archive Management for Prosody MUC
+-- Copyright (C) 2011-2017 Kim Alvefur
+--
+-- This file is MIT/X11 licensed.
+
+if module:get_host_type() ~= "component" then
+	module:log("error", "mod_%s should be loaded only on a MUC component, not normal hosts", module.name);
+	return;
+end
+
+local xmlns_mam     = "urn:xmpp:mam:2";
+local xmlns_delay   = "urn:xmpp:delay";
+local xmlns_forward = "urn:xmpp:forward:0";
+local xmlns_st_id   = "urn:xmpp:sid:0";
+local xmlns_muc_user = "http://jabber.org/protocol/muc#user";
+local muc_form_enable = "muc#roomconfig_enablearchiving"
+
+local st = require "util.stanza";
+local rsm = require "util.rsm";
+local jid_bare = require "util.jid".bare;
+local jid_split = require "util.jid".split;
+local jid_prep = require "util.jid".prep;
+local dataform = require "util.dataforms".new;
+
+local mod_muc = module:depends"muc";
+local get_room_from_jid = mod_muc.get_room_from_jid;
+
+local is_stanza = st.is_stanza;
+local tostring = tostring;
+local time_now = os.time;
+local m_min = math.min;
+local timestamp, timestamp_parse = require "util.datetime".datetime, require "util.datetime".parse;
+local default_max_items, max_max_items = 20, module:get_option_number("max_archive_query_results", 50);
+
+local default_history_length = 20;
+local max_history_length = module:get_option_number("max_history_messages", math.huge);
+
+local function get_historylength(room)
+	return math.min(room._data.history_length or default_history_length, max_history_length);
+end
+
+local log_all_rooms = module:get_option_boolean("muc_log_all_rooms", false);
+local log_by_default = module:get_option_boolean("muc_log_by_default", true);
+
+local archive_store = "muc_log";
+local archive = module:open_store(archive_store, "archive");
+
+if archive.name == "null" or not archive.find then
+	if not archive.find then
+		module:log("error", "Attempt to open archive storage returned a driver without archive API support");
+		module:log("error", "mod_%s does not support archiving",
+			archive._provided_by or archive.name and "storage_"..archive.name.."(?)" or "<unknown>");
+	else
+		module:log("error", "Attempt to open archive storage returned null driver");
+	end
+	module:log("info", "See https://prosody.im/doc/storage and https://prosody.im/doc/archiving for more information");
+	return false;
+end
+
+local function archiving_enabled(room)
+	if log_all_rooms then
+		return true;
+	end
+	local enabled = room._data.archiving;
+	if enabled == nil then
+		return log_by_default;
+	end
+	return enabled;
+end
+
+if not log_all_rooms then
+	module:hook("muc-config-form", function(event)
+		local room, form = event.room, event.form;
+		table.insert(form,
+		{
+			name = muc_form_enable,
+			type = "boolean",
+			label = "Enable archiving?",
+			value = archiving_enabled(room),
+		}
+		);
+	end);
+
+	module:hook("muc-config-submitted/"..muc_form_enable, function(event)
+		event.room._data.archiving = event.value;
+		event.status_codes[event.value and "170" or "171"] = true;
+	end);
+end
+
+-- Note: We ignore the 'with' field as this is internally used for stanza types
+local query_form = dataform {
+	{ name = "FORM_TYPE"; type = "hidden"; value = xmlns_mam; };
+	{ name = "with"; type = "jid-single"; };
+	{ name = "start"; type = "text-single" };
+	{ name = "end"; type = "text-single"; };
+};
+
+-- Serve form
+module:hook("iq-get/bare/"..xmlns_mam..":query", function(event)
+	local origin, stanza = event.origin, event.stanza;
+	origin.send(st.reply(stanza):tag("query", { xmlns = xmlns_mam }):add_child(query_form:form()));
+	return true;
+end);
+
+-- Handle archive queries
+module:hook("iq-set/bare/"..xmlns_mam..":query", function(event)
+	local origin, stanza = event.origin, event.stanza;
+	local room_jid = stanza.attr.to;
+	local room_node = jid_split(room_jid);
+	local orig_from = stanza.attr.from;
+	local query = stanza.tags[1];
+
+	local room = get_room_from_jid(room_jid);
+	if not room then
+		origin.send(st.error_reply(stanza, "cancel", "item-not-found"))
+		return true;
+	end
+	local from = jid_bare(orig_from);
+
+	-- Banned or not a member of a members-only room?
+	local from_affiliation = room:get_affiliation(from);
+	if from_affiliation == "outcast" -- banned
+		or room:get_members_only() and not from_affiliation then -- members-only, not a member
+		origin.send(st.error_reply(stanza, "auth", "forbidden"))
+		return true;
+	end
+
+	local qid = query.attr.queryid;
+
+	-- Search query parameters
+	local qstart, qend;
+	local form = query:get_child("x", "jabber:x:data");
+	if form then
+		local err;
+		form, err = query_form:data(form);
+		if err then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", select(2, next(err))));
+			return true;
+		end
+		qstart, qend = form["start"], form["end"];
+	end
+
+	if qstart or qend then -- Validate timestamps
+		local vstart, vend = (qstart and timestamp_parse(qstart)), (qend and timestamp_parse(qend))
+		if (qstart and not vstart) or (qend and not vend) then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid timestamp"))
+			return true;
+		end
+		qstart, qend = vstart, vend;
+	end
+
+	module:log("debug", "Archive query id %s from %s until %s)",
+		tostring(qid),
+		qstart and timestamp(qstart) or "the dawn of time",
+		qend and timestamp(qend) or "now");
+
+	-- RSM stuff
+	local qset = rsm.get(query);
+	local qmax = m_min(qset and qset.max or default_max_items, max_max_items);
+	local reverse = qset and qset.before or false;
+
+	local before, after = qset and qset.before, qset and qset.after;
+	if type(before) ~= "string" then before = nil; end
+
+	-- Load all the data!
+	local data, err = archive:find(room_node, {
+		start = qstart; ["end"] = qend; -- Time range
+		limit = qmax + 1;
+		before = before; after = after;
+		reverse = reverse;
+		with = "message<groupchat";
+	});
+
+	if not data then
+		origin.send(st.error_reply(stanza, "cancel", "internal-server-error"));
+		return true;
+	end
+	local total = tonumber(err);
+
+	local msg_reply_attr = { to = stanza.attr.from, from = stanza.attr.to };
+
+	local results = {};
+
+	-- Wrap it in stuff and deliver
+	local first, last;
+	local count = 0;
+	local complete = "true";
+	for id, item, when in data do
+		count = count + 1;
+		if count > qmax then
+			complete = nil;
+			break;
+		end
+		local fwd_st = st.message(msg_reply_attr)
+			:tag("result", { xmlns = xmlns_mam, queryid = qid, id = id })
+				:tag("forwarded", { xmlns = xmlns_forward })
+					:tag("delay", { xmlns = xmlns_delay, stamp = timestamp(when) }):up();
+
+		-- Strip <x> tag, containing the original senders JID, unless the room makes this public
+		if room:get_whois() ~= "anyone" then
+			item:maptags(function (tag)
+				if tag.name == "x" and tag.attr.xmlns == xmlns_muc_user then
+					return nil;
+				end
+				return tag;
+			end);
+		end
+		if not is_stanza(item) then
+			item = st.deserialize(item);
+		end
+		item.attr.xmlns = "jabber:client";
+		fwd_st:add_child(item);
+
+		if not first then first = id; end
+		last = id;
+
+		if reverse then
+			results[count] = fwd_st;
+		else
+			origin.send(fwd_st);
+		end
+	end
+
+	if reverse then
+		for i = #results, 1, -1 do
+			origin.send(results[i]);
+		end
+		first, last = last, first;
+	end
+
+	-- That's all folks!
+	module:log("debug", "Archive query %s completed", tostring(qid));
+
+	origin.send(st.reply(stanza)
+		:tag("fin", { xmlns = xmlns_mam, queryid = qid, complete = complete })
+			:add_child(rsm.generate {
+				first = first, last = last, count = total }));
+	return true;
+end);
+
+module:hook("muc-get-history", function (event)
+	local room = event.room;
+	if not archiving_enabled(room) then return end
+	local room_jid = room.jid;
+	local maxstanzas = event.maxstanzas;
+	local maxchars = event.maxchars;
+	local since = event.since;
+	local to = event.to;
+
+	if maxstanzas == 0 or maxchars == 0 then
+		return -- No history requested
+	end
+
+	if not maxstanzas or maxstanzas > get_historylength(room) then
+		maxstanzas = get_historylength(room);
+	end
+
+	if room._history and #room._history >= maxstanzas then
+		return -- It can deal with this itself
+	end
+
+	-- Load all the data!
+	local query = {
+		limit = maxstanzas;
+		start = since;
+		reverse = true;
+		with = "message<groupchat";
+	}
+	local data, err = archive:find(jid_split(room_jid), query);
+
+	if not data then
+		module:log("error", "Could not fetch history: %s", tostring(err));
+		return
+	end
+
+	local history, i = {}, 1;
+
+	for id, item, when in data do
+		item.attr.to = to;
+		item:tag("delay", { xmlns = "urn:xmpp:delay", from = room_jid, stamp = timestamp(when) }):up(); -- XEP-0203
+		item:tag("stanza-id", { xmlns = xmlns_st_id, by = room_jid, id = id }):up();
+		if room:get_whois() ~= "anyone" then
+			item:maptags(function (tag)
+				if tag.name == "x" and tag.attr.xmlns == xmlns_muc_user then
+					return nil;
+				end
+				return tag;
+			end);
+		end
+		if maxchars then
+			local chars = #tostring(item);
+			if maxchars - chars < 0 then
+				break
+			end
+			maxchars = maxchars - chars;
+		end
+		history[i], i = item, i+1;
+		-- module:log("debug", tostring(item));
+	end
+	function event.next_stanza()
+		i = i - 1;
+		return history[i];
+	end
+	return true;
+end, 1);
+
+module:hook("muc-broadcast-messages", function (event)
+	local room, stanza = event.room, event.stanza;
+
+	-- Filter out <stanza-id> that claim to be from us
+	stanza:maptags(function (tag)
+		if tag.name == "stanza-id" and tag.attr.xmlns == xmlns_st_id
+		and jid_prep(tag.attr.by) == room.jid then
+			return nil;
+		end
+		if tag.name == "x" and tag.attr.xmlns == xmlns_muc_user then
+			return nil;
+		end
+		return tag;
+	end);
+
+end, 0);
+
+-- Handle messages
+local function save_to_history(self, stanza)
+	local room_node, room_host = jid_split(self.jid);
+
+	local stored_stanza = stanza;
+
+	if stanza.name == "message" and self:get_whois() == "anyone" then
+		stored_stanza = st.clone(stanza);
+		local actor = jid_bare(self._occupants[stanza.attr.from].jid);
+		local affiliation = self:get_affiliation(actor) or "none";
+		local role = self:get_role(actor) or self:get_default_role(affiliation);
+		stored_stanza:add_direct_child(st.stanza("x", { xmlns = xmlns_muc_user })
+			:tag("item", { affiliation = affiliation; role = role; jid = actor }));
+	end
+
+	-- Policy check
+	if not archiving_enabled(self) then return end -- Don't log
+
+	-- And stash it
+	local with = stanza.name
+	if stanza.attr.type then
+		with = with .. "<" .. stanza.attr.type
+	end
+
+	local id = archive:append(room_node, nil, stored_stanza, time_now(), with);
+
+	if id then
+		stanza:add_direct_child(st.stanza("stanza-id", { xmlns = xmlns_st_id, by = self.jid, id = id }));
+	end
+end
+
+module:hook("muc-add-history", function (event)
+	local room, stanza = event.room, event.stanza;
+	save_to_history(room, stanza);
+end);
+
+if module:get_option_boolean("muc_log_presences", false) then
+	module:hook("muc-occupant-joined", function (event)
+		save_to_history(event.room, st.stanza("presence", { from = event.nick }):tag("x", { xmlns = "http://jabber.org/protocol/muc" }));
+	end);
+	module:hook("muc-occupant-left", function (event)
+		save_to_history(event.room, st.stanza("presence", { type = "unavailable", from = event.nick }));
+	end);
+end
+
+if not archive.delete then
+	module:log("warn", "Storage driver %s does not support deletion", archive._provided_by);
+	module:log("warn", "Archived message will persist after a room has been destroyed");
+else
+	module:hook("muc-room-destroyed", function(event)
+		local room_node = jid_split(event.room.jid);
+		archive:delete(room_node);
+	end);
+end
+
+-- And role/affiliation changes?
+
+module:add_feature(xmlns_mam);
+
+module:hook("muc-disco#info", function(event)
+	event.reply:tag("feature", {var=xmlns_mam}):up();
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_muc_unique.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,12 @@
+-- XEP-0307: Unique Room Names for Multi-User Chat
+local st = require "util.stanza";
+local unique_name = require "util.id".medium;
+module:add_feature "http://jabber.org/protocol/muc#unique"
+module:hook("iq-get/host/http://jabber.org/protocol/muc#unique:unique", function(event)
+	local origin, stanza = event.origin, event.stanza;
+	origin.send(st.reply(stanza)
+		:tag("unique", {xmlns = "http://jabber.org/protocol/muc#unique"})
+		:text(unique_name():lower())
+	);
+	return true;
+end,-1);
--- a/plugins/mod_net_multiplex.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_net_multiplex.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -18,7 +18,7 @@
 module:hook("service-added", function (event) add_service(event.service); end);
 module:hook("service-removed", function (event)	available_services[event.service] = nil; end);
 
-for service_name, services in pairs(portmanager.get_registered_services()) do
+for _, services in pairs(portmanager.get_registered_services()) do
 	for _, service in ipairs(services) do
 		add_service(service);
 	end
@@ -38,11 +38,11 @@
 	for service, multiplex_pattern in pairs(available_services) do
 		if buf:match(multiplex_pattern) then
 			module:log("debug", "Routing incoming connection to %s", service.name);
-			local listener = service.listener;
-			conn:setlistener(listener);
-			local onconnect = listener.onconnect;
+			local next_listener = service.listener;
+			conn:setlistener(next_listener);
+			local onconnect = next_listener.onconnect;
 			if onconnect then onconnect(conn) end
-			return listener.onincoming(conn, buf);
+			return next_listener.onincoming(conn, buf);
 		end
 	end
 	if #buf > max_buffer_len then -- Give up
@@ -52,7 +52,7 @@
 	end
 end
 
-function listener.ondisconnect(conn, err)
+function listener.ondisconnect(conn)
 	buffers[conn] = nil; -- warn if no buffer?
 end
 
--- a/plugins/mod_pep.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_pep.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -1,318 +1,469 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-
+local pubsub = require "util.pubsub";
 local jid_bare = require "util.jid".bare;
 local jid_split = require "util.jid".split;
+local jid_join = require "util.jid".join;
+local set_new = require "util.set".new;
 local st = require "util.stanza";
-local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
-local pairs = pairs;
-local next = next;
-local type = type;
 local calculate_hash = require "util.caps".calculate_hash;
-local core_post_stanza = prosody.core_post_stanza;
-local bare_sessions = prosody.bare_sessions;
+local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
+local cache = require "util.cache";
+local set = require "util.set";
+
+local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
+local xmlns_pubsub_event = "http://jabber.org/protocol/pubsub#event";
+local xmlns_pubsub_owner = "http://jabber.org/protocol/pubsub#owner";
+
+local lib_pubsub = module:require "pubsub";
 
--- Used as canonical 'empty table'
-local NULL = {};
--- data[user_bare_jid][node] = item_stanza
-local data = {};
---- recipients[user_bare_jid][contact_full_jid][subscribed_node] = true
+local empty_set = set_new();
+
+-- username -> util.pubsub service object
+local services = {};
+
+-- username -> recipient -> set of nodes
 local recipients = {};
--- hash_map[hash][subscribed_nodes] = true
+
+-- caps hash -> set of nodes
 local hash_map = {};
 
-module.save = function()
-	return { data = data, recipients = recipients, hash_map = hash_map };
+local host = module.host;
+
+local node_config = module:open_store("pep", "map");
+local known_nodes = module:open_store("pep");
+
+local max_max_items = module:get_option_number("pep_max_items", 256);
+
+function module.save()
+	return {
+		services = services;
+		recipients = recipients;
+	};
+end
+
+function module.restore(data)
+	services = data.services;
+	recipients = data.recipients;
+	for username, service in pairs(services) do
+		local user_bare = jid_join(username, host);
+		module:add_item("pep-service", { service = service, jid = user_bare });
+	end
+end
+
+function is_item_stanza(item)
+	return st.is_stanza(item) and item.attr.xmlns == xmlns_pubsub and item.name == "item";
+end
+
+function check_node_config(node, actor, new_config) -- luacheck: ignore 212/node 212/actor
+	if (new_config["max_items"] or 1) > max_max_items then
+		return false;
+	end
+	if new_config["access_model"] ~= "presence"
+	and new_config["access_model"] ~= "whitelist"
+	and new_config["access_model"] ~= "open" then
+		return false;
+	end
+	return true;
+end
+
+local function subscription_presence(username, recipient)
+	local user_bare = jid_join(username, host);
+	local recipient_bare = jid_bare(recipient);
+	if (recipient_bare == user_bare) then return true; end
+	return is_contact_subscribed(username, host, recipient_bare);
+end
+
+local function nodestore(username)
+	-- luacheck: ignore 212/self
+	local store = {};
+	function store:get(node)
+		local data, err = node_config:get(username, node)
+		if data == true then
+			-- COMPAT Previously stored only a boolean representing 'persist_items'
+			data = {
+				name = node;
+				config = {};
+				subscribers = {};
+				affiliations = {};
+			};
+		end
+		return data, err;
+	end
+	function store:set(node, data)
+		if data then
+			-- Save the data without subscriptions
+			local subscribers = {};
+			for jid, sub in pairs(data.subscribers) do
+				if type(sub) ~= "table" or not sub.presence then
+					subscribers[jid] = sub;
+				end
+			end
+			data = {
+				name = data.name;
+				config = data.config;
+				affiliations = data.affiliations;
+				subscribers = subscribers;
+			};
+		end
+		return node_config:set(username, node, data);
+	end
+	function store:users()
+		return pairs(known_nodes:get(username) or {});
+	end
+	return store;
+end
+
+local function simple_itemstore(username)
+	return function (config, node)
+		if config["persist_items"] then
+			module:log("debug", "Creating new persistent item store for user %s, node %q", username, node);
+			local archive = module:open_store("pep_"..node, "archive");
+			return lib_pubsub.archive_itemstore(archive, config, username, node, false);
+		else
+			module:log("debug", "Creating new ephemeral item store for user %s, node %q", username, node);
+			return cache.new(tonumber(config["max_items"]));
+		end
+	end
 end
-module.restore = function(state)
-	data = state.data or {};
-	recipients = state.recipients or {};
-	hash_map = state.hash_map or {};
+
+local function get_broadcaster(username)
+	local user_bare = jid_join(username, host);
+	local function simple_broadcast(kind, node, jids, item, _, node_obj)
+		if node_obj then
+			if node_obj.config["notify_"..kind] == false then
+				return;
+			end
+		end
+		if kind == "retract" then
+			kind = "items"; -- XEP-0060 signals retraction in an <items> container
+		end
+		local message = st.message({ from = user_bare, type = "headline" })
+			:tag("event", { xmlns = xmlns_pubsub_event })
+				:tag(kind, { node = node });
+		if item then
+			item = st.clone(item);
+			item.attr.xmlns = nil; -- Clear the pubsub namespace
+			if kind == "items" then
+				if node_obj and node_obj.config.include_payload == false then
+					item:maptags(function () return nil; end);
+				end
+			end
+			message:add_child(item);
+		end
+		for jid in pairs(jids) do
+			module:log("debug", "Sending notification to %s from %s: %s", jid, user_bare, tostring(item));
+			message.attr.to = jid;
+			module:send(message);
+		end
+	end
+	return simple_broadcast;
+end
+
+local function on_node_creation(event)
+	local service = event.service;
+	local node = event.node;
+	local username = service.config.pep_username;
+
+	local service_recipients = recipients[username];
+	if not service_recipients then return; end
+
+	for recipient, nodes in pairs(service_recipients) do
+		if nodes:contains(node) then
+			service:add_subscription(node, recipient, recipient, { presence = true });
+		end
+	end
 end
 
+function get_pep_service(username)
+	module:log("debug", "get_pep_service(%q)", username);
+	local user_bare = jid_join(username, host);
+	local service = services[username];
+	if service then
+		return service;
+	end
+	service = pubsub.new({
+		pep_username = username;
+		node_defaults = {
+			["max_items"] = 1;
+			["persist_items"] = true;
+			["access_model"] = "presence";
+		};
+
+		autocreate_on_publish = true;
+		autocreate_on_subscribe = false;
+
+		nodestore = nodestore(username);
+		itemstore = simple_itemstore(username);
+		broadcaster = get_broadcaster(username);
+		itemcheck = is_item_stanza;
+		get_affiliation = function (jid)
+			if jid_bare(jid) == user_bare then
+				return "owner";
+			end
+		end;
+
+		access_models = {
+			presence = function (jid)
+				if subscription_presence(username, jid) then
+					return "member";
+				end
+				return "outcast";
+			end;
+		};
+
+		normalize_jid = jid_bare;
+
+		check_node_config = check_node_config;
+	});
+	local nodes, err = known_nodes:get(username);
+	if nodes then
+		module:log("debug", "Restoring nodes for user %s", username);
+		for node in pairs(nodes) do
+			module:log("debug", "Restoring node %q", node);
+			service:create(node, true);
+		end
+	elseif err then
+		module:log("error", "Could not restore nodes for %s: %s", username, err);
+	else
+		module:log("debug", "No known nodes");
+	end
+	services[username] = service;
+	module:add_item("pep-service", { service = service, jid = user_bare });
+	return service;
+end
+
+module:hook("item-added/pep-service", function (event)
+	local service = event.item.service;
+	module:hook_object_event(service.events, "node-created", on_node_creation);
+end);
+
+function handle_pubsub_iq(event)
+	local origin, stanza = event.origin, event.stanza;
+	local service_name = origin.username;
+	if stanza.attr.to ~= nil then
+		service_name = jid_split(stanza.attr.to);
+	end
+	local service = get_pep_service(service_name);
+
+	return lib_pubsub.handle_pubsub_iq(event, service)
+end
+
+module:hook("iq/bare/"..xmlns_pubsub..":pubsub", handle_pubsub_iq);
+module:hook("iq/bare/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq);
+
 module:add_identity("pubsub", "pep", module:get_option_string("name", "Prosody"));
 module:add_feature("http://jabber.org/protocol/pubsub#publish");
 
-local function subscription_presence(user_bare, recipient)
-	local recipient_bare = jid_bare(recipient);
-	if (recipient_bare == user_bare) then return true end
-	local username, host = jid_split(user_bare);
-	return is_contact_subscribed(username, host, recipient_bare);
-end
-
-module:hook("pep-publish-item", function (event)
-	local session, bare, node, id, item = event.session, event.user, event.node, event.id, event.item;
-	item.attr.xmlns = nil;
-	local disable = #item.tags ~= 1 or #item.tags[1] == 0;
-	if #item.tags == 0 then item.name = "retract"; end
-	local stanza = st.message({from=bare, type='headline'})
-		:tag('event', {xmlns='http://jabber.org/protocol/pubsub#event'})
-			:tag('items', {node=node})
-				:add_child(item)
-			:up()
-		:up();
-
-	-- store for the future
-	local user_data = data[bare];
-	if disable then
-		if user_data then
-			user_data[node] = nil;
-			if not next(user_data) then data[bare] = nil; end
-		end
-	else
-		if not user_data then user_data = {}; data[bare] = user_data; end
-		user_data[node] = {id, item};
-	end
-
-	-- broadcast
-	for recipient, notify in pairs(recipients[bare] or NULL) do
-		if notify[node] then
-			stanza.attr.to = recipient;
-			core_post_stanza(session, stanza);
-		end
-	end
-end);
-
-local function publish_all(user, recipient, session)
-	local d = data[user];
-	local notify = recipients[user] and recipients[user][recipient];
-	if d and notify then
-		for node in pairs(notify) do
-			if d[node] then
-				local id, item = unpack(d[node]);
-				session.send(st.message({from=user, to=recipient, type='headline'})
-					:tag('event', {xmlns='http://jabber.org/protocol/pubsub#event'})
-						:tag('items', {node=node})
-							:add_child(item)
-						:up()
-					:up());
-			end
-		end
-	end
-end
-
 local function get_caps_hash_from_presence(stanza, current)
 	local t = stanza.attr.type;
 	if not t then
-		for _, child in pairs(stanza.tags) do
-			if child.name == "c" and child.attr.xmlns == "http://jabber.org/protocol/caps" then
-				local attr = child.attr;
-				if attr.hash then -- new caps
-					if attr.hash == 'sha-1' and attr.node and attr.ver then return attr.ver, attr.node.."#"..attr.ver; end
-				else -- legacy caps
-					if attr.node and attr.ver then return attr.node.."#"..attr.ver.."#"..(attr.ext or ""), attr.node.."#"..attr.ver; end
+		local child = stanza:get_child("c", "http://jabber.org/protocol/caps");
+		if child then
+			local attr = child.attr;
+			if attr.hash then -- new caps
+				if attr.hash == 'sha-1' and attr.node and attr.ver then
+					return attr.ver, attr.node.."#"..attr.ver;
 				end
-				return; -- bad caps format
+			else -- legacy caps
+				if attr.node and attr.ver then
+					return attr.node.."#"..attr.ver.."#"..(attr.ext or ""), attr.node.."#"..attr.ver;
+				end
 			end
 		end
+		return; -- no or bad caps
 	elseif t == "unavailable" or t == "error" then
 		return;
 	end
 	return current; -- no caps, could mean caps optimization, so return current
 end
 
+local function resend_last_item(jid, node, service)
+	local ok, id, item = service:get_last_item(node, jid);
+	if not (ok and id) then return; end
+	service.config.broadcaster("items", node, { [jid] = true }, item);
+end
+
+local function update_subscriptions(recipient, service_name, nodes)
+	nodes = nodes or empty_set;
+
+	local service_recipients = recipients[service_name];
+	if not service_recipients then
+		service_recipients = {};
+		recipients[service_name] = service_recipients;
+	end
+
+	local current = service_recipients[recipient];
+	if not current then
+		current = empty_set;
+	end
+
+	if (current == empty_set or current:empty()) and (nodes == empty_set or nodes:empty()) then
+		return;
+	end
+
+	local service = get_pep_service(service_name);
+	for node in current - nodes do
+		service:remove_subscription(node, recipient, recipient);
+	end
+
+	for node in nodes - current do
+		if service:add_subscription(node, recipient, recipient, { presence = true }) then
+			resend_last_item(recipient, node, service);
+		end
+	end
+
+	if nodes == empty_set or nodes:empty() then
+		nodes = nil;
+	end
+
+	service_recipients[recipient] = nodes;
+end
+
 module:hook("presence/bare", function(event)
-	-- inbound presence to bare JID recieved
+	-- inbound presence to bare JID received
 	local origin, stanza = event.origin, event.stanza;
-	local user = stanza.attr.to or (origin.username..'@'..origin.host);
 	local t = stanza.attr.type;
-	local self = not stanza.attr.to;
-
-	-- Only cache subscriptions if user is online
-	if not bare_sessions[user] then return; end
+	local is_self = not stanza.attr.to;
+	local username = jid_split(stanza.attr.to);
+	local user_bare = jid_bare(stanza.attr.to);
+	if is_self then
+		username = origin.username;
+		user_bare = jid_join(username, host);
+	end
 
 	if not t then -- available presence
-		if self or subscription_presence(user, stanza.attr.from) then
+		if is_self or subscription_presence(username, stanza.attr.from) then
 			local recipient = stanza.attr.from;
-			local current = recipients[user] and recipients[user][recipient];
-			local hash = get_caps_hash_from_presence(stanza, current);
+			local current = recipients[username] and recipients[username][recipient];
+			local hash, query_node = get_caps_hash_from_presence(stanza, current);
 			if current == hash or (current and current == hash_map[hash]) then return; end
 			if not hash then
-				if recipients[user] then recipients[user][recipient] = nil; end
+				update_subscriptions(recipient, username);
 			else
-				recipients[user] = recipients[user] or {};
+				recipients[username] = recipients[username] or {};
 				if hash_map[hash] then
-					recipients[user][recipient] = hash_map[hash];
-					publish_all(user, recipient, origin);
+					update_subscriptions(recipient, username, hash_map[hash]);
 				else
-					recipients[user][recipient] = hash;
-					local from_bare = origin.type == "c2s" and origin.username.."@"..origin.host;
-					if self or origin.type ~= "c2s" or (recipients[from_bare] and recipients[from_bare][origin.full_jid]) ~= hash then
-						-- COMPAT from ~= stanza.attr.to because OneTeam and Asterisk 1.8 can't deal with missing from attribute
-						origin.send(
-							st.stanza("iq", {from=user, to=stanza.attr.from, id="disco", type="get"})
-								:query("http://jabber.org/protocol/disco#info")
-						);
-					end
+					-- COMPAT from ~= stanza.attr.to because OneTeam can't deal with missing from attribute
+					origin.send(
+						st.stanza("iq", {from=user_bare, to=stanza.attr.from, id="disco", type="get"})
+							:tag("query", {xmlns = "http://jabber.org/protocol/disco#info", node = query_node})
+					);
 				end
 			end
 		end
 	elseif t == "unavailable" then
-		if recipients[user] then recipients[user][stanza.attr.from] = nil; end
-	elseif not self and t == "unsubscribe" then
+		update_subscriptions(stanza.attr.from, username);
+	elseif not is_self and t == "unsubscribe" then
 		local from = jid_bare(stanza.attr.from);
-		local subscriptions = recipients[user];
+		local subscriptions = recipients[username];
 		if subscriptions then
 			for subscriber in pairs(subscriptions) do
 				if jid_bare(subscriber) == from then
-					recipients[user][subscriber] = nil;
+					update_subscriptions(subscriber, username);
 				end
 			end
 		end
 	end
 end, 10);
 
-module:hook("iq/bare/http://jabber.org/protocol/pubsub:pubsub", function(event)
-	local session, stanza = event.origin, event.stanza;
-	local payload = stanza.tags[1];
+module:hook("iq-result/bare/disco", function(event)
+	local origin, stanza = event.origin, event.stanza;
+	local disco = stanza:get_child("query", "http://jabber.org/protocol/disco#info");
+	if not disco then
+		return;
+	end
 
-	if stanza.attr.type == 'set' and (not stanza.attr.to or jid_bare(stanza.attr.from) == stanza.attr.to) then
-		payload = payload.tags[1];
-		if payload and (payload.name == 'publish' or payload.name == 'retract') and payload.attr.node then -- <publish node='http://jabber.org/protocol/tune'>
-			local node = payload.attr.node;
-			payload = payload.tags[1];
-			if payload and payload.name == "item" then -- <item>
-				local id = payload.attr.id or "1";
-				payload.attr.id = id;
-				session.send(st.reply(stanza));
-				module:fire_event("pep-publish-item", {
-					node = node, user = jid_bare(session.full_jid), actor = session.jid,
-					id = id, session = session, item = st.clone(payload);
-				});
-				return true;
-			else
-				module:log("debug", "Payload is missing the <item>", node);
-			end
-		else
-			module:log("debug", "Unhandled payload: %s", payload and payload:top_tag() or "(no payload)");
+	-- Process disco response
+	local is_self = stanza.attr.to == nil;
+	local user_bare = jid_bare(stanza.attr.to);
+	local username = jid_split(stanza.attr.to);
+	if is_self then
+		username = origin.username;
+		user_bare = jid_join(username, host);
+	end
+	local contact = stanza.attr.from;
+	local ver = calculate_hash(disco.tags); -- calculate hash
+	local notify = set_new();
+	for _, feature in pairs(disco.tags) do
+		if feature.name == "feature" and feature.attr.var then
+			local nfeature = feature.attr.var:match("^(.*)%+notify$");
+			if nfeature then notify:add(nfeature); end
 		end
-	elseif stanza.attr.type == 'get' then
-		local user = stanza.attr.to and jid_bare(stanza.attr.to) or session.username..'@'..session.host;
-		if subscription_presence(user, stanza.attr.from) then
-			local user_data = data[user];
-			local node, requested_id;
-			payload = payload.tags[1];
-			if payload and payload.name == 'items' then
-				node = payload.attr.node;
-				local item = payload.tags[1];
-				if item and item.name == "item" then
-					requested_id = item.attr.id;
+	end
+	hash_map[ver] = notify; -- update hash map
+	if is_self then
+		-- Optimization: Fiddle with other local users
+		for jid, item in pairs(origin.roster) do -- for all interested contacts
+			if jid then
+				local contact_node, contact_host = jid_split(jid);
+				if contact_host == host and (item.subscription == "both" or item.subscription == "from") then
+					update_subscriptions(user_bare, contact_node, notify);
 				end
 			end
-			if node and user_data and user_data[node] then -- Send the last item
-				local id, item = unpack(user_data[node]);
-				if not requested_id or id == requested_id then
-					local stanza = st.reply(stanza)
-						:tag('pubsub', {xmlns='http://jabber.org/protocol/pubsub'})
-							:tag('items', {node=node})
-								:add_child(item)
-							:up()
-						:up();
-					session.send(stanza);
-					return true;
-				else -- requested item doesn't exist
-					local stanza = st.reply(stanza)
-						:tag('pubsub', {xmlns='http://jabber.org/protocol/pubsub'})
-							:tag('items', {node=node})
-						:up();
-					session.send(stanza);
-					return true;
-				end
-			elseif node then -- node doesn't exist
-				session.send(st.error_reply(stanza, 'cancel', 'item-not-found'));
-				module:log("debug", "Item '%s' not found", node)
-				return true;
-			else --invalid request
-				session.send(st.error_reply(stanza, 'modify', 'bad-request'));
-				module:log("debug", "Invalid request: %s", tostring(payload));
-				return true;
-			end
-		else --no presence subscription
-			session.send(st.error_reply(stanza, 'auth', 'not-authorized')
-				:tag('presence-subscription-required', {xmlns='http://jabber.org/protocol/pubsub#errors'}));
-			module:log("debug", "Unauthorized request: %s", tostring(payload));
-			return true;
 		end
 	end
+	update_subscriptions(contact, username, notify);
+end);
+
+module:hook("account-disco-info-node", function(event)
+	local stanza, origin = event.stanza, event.origin;
+	local service_name = origin.username;
+	if stanza.attr.to ~= nil then
+		service_name = jid_split(stanza.attr.to);
+	end
+	local service = get_pep_service(service_name);
+	return lib_pubsub.handle_disco_info_node(event, service);
+end);
+
+module:hook("account-disco-info", function(event)
+	local origin, reply = event.origin, event.reply;
+
+	reply:tag('identity', {category='pubsub', type='pep'}):up();
+
+	local username = jid_split(reply.attr.from) or origin.username;
+	local service = get_pep_service(username);
+
+	local supported_features = lib_pubsub.get_feature_set(service) + set.new{
+		-- Features not covered by the above
+		"auto-subscribe",
+		"filtered-notifications",
+		"last-published",
+		"presence-notifications",
+		"presence-subscribe",
+	};
+
+	for feature in supported_features do
+		reply:tag('feature', {var=xmlns_pubsub.."#"..feature}):up();
+	end
 end);
 
-module:hook("iq-result/bare/disco", function(event)
-	local session, stanza = event.origin, event.stanza;
-	if stanza.attr.type == "result" then
-		local disco = stanza.tags[1];
-		if disco and disco.name == "query" and disco.attr.xmlns == "http://jabber.org/protocol/disco#info" then
-			-- Process disco response
-			local self = not stanza.attr.to;
-			local user = stanza.attr.to or (session.username..'@'..session.host);
-			local contact = stanza.attr.from;
-			local current = recipients[user] and recipients[user][contact];
-			if type(current) ~= "string" then return; end -- check if waiting for recipient's response
-			local ver = current;
-			if not string.find(current, "#") then
-				ver = calculate_hash(disco.tags); -- calculate hash
-			end
-			local notify = {};
-			for _, feature in pairs(disco.tags) do
-				if feature.name == "feature" and feature.attr.var then
-					local nfeature = feature.attr.var:match("^(.*)%+notify$");
-					if nfeature then notify[nfeature] = true; end
-				end
-			end
-			hash_map[ver] = notify; -- update hash map
-			if self then
-				for jid, item in pairs(session.roster) do -- for all interested contacts
-					if item.subscription == "both" or item.subscription == "from" then
-						if not recipients[jid] then recipients[jid] = {}; end
-						recipients[jid][contact] = notify;
-						publish_all(jid, contact, session);
-					end
-				end
-			end
-			recipients[user][contact] = notify; -- set recipient's data to calculated data
-			-- send messages to recipient
-			publish_all(user, contact, session);
-		end
+module:hook("account-disco-items-node", function(event)
+	local stanza, origin = event.stanza, event.origin;
+	local is_self = stanza.attr.to == nil;
+	local username = jid_split(stanza.attr.to);
+	if is_self then
+		username = origin.username;
 	end
-end);
-
-module:hook("account-disco-info", function(event)
-	local reply = event.reply;
-	reply:tag('identity', {category='pubsub', type='pep'}):up();
-	reply:tag('feature', {var='http://jabber.org/protocol/pubsub#publish'}):up();
+	local service = get_pep_service(username);
+	return lib_pubsub.handle_disco_items_node(event, service);
 end);
 
 module:hook("account-disco-items", function(event)
-	local reply = event.reply;
-	local bare = reply.attr.to;
-	local user_data = data[bare];
+	local reply, stanza, origin = event.reply, event.stanza, event.origin;
 
-	if user_data then
-		for node, _ in pairs(user_data) do
-			reply:tag('item', {jid=bare, node=node}):up();
-		end
+	local is_self = stanza.attr.to == nil;
+	local user_bare = jid_bare(stanza.attr.to);
+	local username = jid_split(stanza.attr.to);
+	if is_self then
+		username = origin.username;
+		user_bare = jid_join(username, host);
+	end
+	local service = get_pep_service(username);
+
+	local ok, ret = service:get_nodes(jid_bare(stanza.attr.from));
+	if not ok then return; end
+
+	for node, node_obj in pairs(ret) do
+		reply:tag("item", { jid = user_bare, node = node, name = node_obj.config.title }):up();
 	end
 end);
-
-module:hook("account-disco-info-node", function (event)
-	local session, stanza, node = event.origin, event.stanza, event.node;
-	local user = stanza.attr.to;
-	local user_data = data[user];
-	if user_data and user_data[node] then
-		event.exists = true;
-		event.reply:tag('identity', {category='pubsub', type='leaf'}):up();
-	end
-end);
-
-module:hook("resource-unbind", function (event)
-	local user_bare_jid = event.session.username.."@"..event.session.host;
-	if not bare_sessions[user_bare_jid] then -- User went offline
-		-- We don't need this info cached anymore, clear it.
-		recipients[user_bare_jid] = nil;
-	end
-end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_pep_plus.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,2 @@
+module:log("error", "mod_pep_plus has been renamed to mod_pep, please update your config file. Auto-loading mod_pep...");
+module:depends("pep");
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_pep_simple.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,333 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+
+local jid_bare = require "util.jid".bare;
+local jid_split = require "util.jid".split;
+local st = require "util.stanza";
+local is_contact_subscribed = require "core.rostermanager".is_contact_subscribed;
+local pairs = pairs;
+local next = next;
+local type = type;
+local calculate_hash = require "util.caps".calculate_hash;
+local core_post_stanza = prosody.core_post_stanza;
+local bare_sessions = prosody.bare_sessions;
+
+local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
+
+-- Used as canonical 'empty table'
+local NULL = {};
+-- data[user_bare_jid][node] = item_stanza
+local data = {};
+--- recipients[user_bare_jid][contact_full_jid][subscribed_node] = true
+local recipients = {};
+-- hash_map[hash][subscribed_nodes] = true
+local hash_map = {};
+
+module.save = function()
+	return { data = data, recipients = recipients, hash_map = hash_map };
+end
+module.restore = function(state)
+	data = state.data or {};
+	recipients = state.recipients or {};
+	hash_map = state.hash_map or {};
+end
+
+local function subscription_presence(user_bare, recipient)
+	local recipient_bare = jid_bare(recipient);
+	if (recipient_bare == user_bare) then return true end
+	local username, host = jid_split(user_bare);
+	return is_contact_subscribed(username, host, recipient_bare);
+end
+
+module:hook("pep-publish-item", function (event)
+	local session, bare, node, id, item = event.session, event.user, event.node, event.id, event.item;
+	item.attr.xmlns = nil;
+	local disable = #item.tags ~= 1 or #item.tags[1] == 0;
+	if #item.tags == 0 then item.name = "retract"; end
+	local stanza = st.message({from=bare, type='headline'})
+		:tag('event', {xmlns='http://jabber.org/protocol/pubsub#event'})
+			:tag('items', {node=node})
+				:add_child(item)
+			:up()
+		:up();
+
+	-- store for the future
+	local user_data = data[bare];
+	if disable then
+		if user_data then
+			user_data[node] = nil;
+			if not next(user_data) then data[bare] = nil; end
+		end
+	else
+		if not user_data then user_data = {}; data[bare] = user_data; end
+		user_data[node] = {id, item};
+	end
+
+	-- broadcast
+	for recipient, notify in pairs(recipients[bare] or NULL) do
+		if notify[node] then
+			stanza.attr.to = recipient;
+			core_post_stanza(session, stanza);
+		end
+	end
+end);
+
+local function publish_all(user, recipient, session)
+	local d = data[user];
+	local notify = recipients[user] and recipients[user][recipient];
+	if d and notify then
+		for node in pairs(notify) do
+			if d[node] then
+				local id, item = unpack(d[node]);
+				session.send(st.message({from=user, to=recipient, type='headline'})
+					:tag('event', {xmlns='http://jabber.org/protocol/pubsub#event'})
+						:tag('items', {node=node})
+							:add_child(item)
+						:up()
+					:up());
+			end
+		end
+	end
+end
+
+local function get_caps_hash_from_presence(stanza, current)
+	local t = stanza.attr.type;
+	if not t then
+		for _, child in pairs(stanza.tags) do
+			if child.name == "c" and child.attr.xmlns == "http://jabber.org/protocol/caps" then
+				local attr = child.attr;
+				if attr.hash then -- new caps
+					if attr.hash == 'sha-1' and attr.node and attr.ver then return attr.ver, attr.node.."#"..attr.ver; end
+				else -- legacy caps
+					if attr.node and attr.ver then return attr.node.."#"..attr.ver.."#"..(attr.ext or ""), attr.node.."#"..attr.ver; end
+				end
+				return; -- bad caps format
+			end
+		end
+	elseif t == "unavailable" or t == "error" then
+		return;
+	end
+	return current; -- no caps, could mean caps optimization, so return current
+end
+
+module:hook("presence/bare", function(event)
+	-- inbound presence to bare JID received
+	local origin, stanza = event.origin, event.stanza;
+	local user = stanza.attr.to or (origin.username..'@'..origin.host);
+	local t = stanza.attr.type;
+	local self = not stanza.attr.to;
+
+	-- Only cache subscriptions if user is online
+	if not bare_sessions[user] then return; end
+
+	if not t then -- available presence
+		if self or subscription_presence(user, stanza.attr.from) then
+			local recipient = stanza.attr.from;
+			local current = recipients[user] and recipients[user][recipient];
+			local hash = get_caps_hash_from_presence(stanza, current);
+			if current == hash or (current and current == hash_map[hash]) then return; end
+			if not hash then
+				if recipients[user] then recipients[user][recipient] = nil; end
+			else
+				recipients[user] = recipients[user] or {};
+				if hash_map[hash] then
+					recipients[user][recipient] = hash_map[hash];
+					publish_all(user, recipient, origin);
+				else
+					recipients[user][recipient] = hash;
+					local from_bare = origin.type == "c2s" and origin.username.."@"..origin.host;
+					if self or origin.type ~= "c2s" or (recipients[from_bare] and recipients[from_bare][origin.full_jid]) ~= hash then
+						-- COMPAT from ~= stanza.attr.to because OneTeam and Asterisk 1.8 can't deal with missing from attribute
+						origin.send(
+							st.stanza("iq", {from=user, to=stanza.attr.from, id="disco", type="get"})
+								:query("http://jabber.org/protocol/disco#info")
+						);
+					end
+				end
+			end
+		end
+	elseif t == "unavailable" then
+		if recipients[user] then recipients[user][stanza.attr.from] = nil; end
+	elseif not self and t == "unsubscribe" then
+		local from = jid_bare(stanza.attr.from);
+		local subscriptions = recipients[user];
+		if subscriptions then
+			for subscriber in pairs(subscriptions) do
+				if jid_bare(subscriber) == from then
+					recipients[user][subscriber] = nil;
+				end
+			end
+		end
+	end
+end, 10);
+
+module:hook("iq/bare/http://jabber.org/protocol/pubsub:pubsub", function(event)
+	local session, stanza = event.origin, event.stanza;
+	local payload = stanza.tags[1];
+
+	if stanza.attr.type == 'set' and (not stanza.attr.to or jid_bare(stanza.attr.from) == stanza.attr.to) then
+		payload = payload.tags[1]; -- <publish node='http://jabber.org/protocol/tune'>
+		if payload and (payload.name == 'publish' or payload.name == 'retract') and payload.attr.node then
+			local node = payload.attr.node;
+			payload = payload.tags[1];
+			if payload and payload.name == "item" then -- <item>
+				local id = payload.attr.id or "1";
+				payload.attr.id = id;
+				session.send(st.reply(stanza));
+				module:fire_event("pep-publish-item", {
+					node = node, user = jid_bare(session.full_jid), actor = session.jid,
+					id = id, session = session, item = st.clone(payload);
+				});
+				return true;
+			else
+				module:log("debug", "Payload is missing the <item>", node);
+			end
+		else
+			module:log("debug", "Unhandled payload: %s", payload and payload:top_tag() or "(no payload)");
+		end
+	elseif stanza.attr.type == 'get' then
+		local user = stanza.attr.to and jid_bare(stanza.attr.to) or session.username..'@'..session.host;
+		if subscription_presence(user, stanza.attr.from) then
+			local user_data = data[user];
+			local node, requested_id;
+			payload = payload.tags[1];
+			if payload and payload.name == 'items' then
+				node = payload.attr.node;
+				local item = payload.tags[1];
+				if item and item.name == "item" then
+					requested_id = item.attr.id;
+				end
+			end
+			if node and user_data and user_data[node] then -- Send the last item
+				local id, item = unpack(user_data[node]);
+				if not requested_id or id == requested_id then
+					local reply_stanza = st.reply(stanza)
+						:tag('pubsub', {xmlns='http://jabber.org/protocol/pubsub'})
+							:tag('items', {node=node})
+								:add_child(item)
+							:up()
+						:up();
+					session.send(reply_stanza);
+					return true;
+				else -- requested item doesn't exist
+					local reply_stanza = st.reply(stanza)
+						:tag('pubsub', {xmlns='http://jabber.org/protocol/pubsub'})
+							:tag('items', {node=node})
+						:up();
+					session.send(reply_stanza);
+					return true;
+				end
+			elseif node then -- node doesn't exist
+				session.send(st.error_reply(stanza, 'cancel', 'item-not-found'));
+				module:log("debug", "Item '%s' not found", node)
+				return true;
+			else --invalid request
+				session.send(st.error_reply(stanza, 'modify', 'bad-request'));
+				module:log("debug", "Invalid request: %s", tostring(payload));
+				return true;
+			end
+		else --no presence subscription
+			session.send(st.error_reply(stanza, 'auth', 'not-authorized')
+				:tag('presence-subscription-required', {xmlns='http://jabber.org/protocol/pubsub#errors'}));
+			module:log("debug", "Unauthorized request: %s", tostring(payload));
+			return true;
+		end
+	end
+end);
+
+module:hook("iq-result/bare/disco", function(event)
+	local session, stanza = event.origin, event.stanza;
+	if stanza.attr.type == "result" then
+		local disco = stanza.tags[1];
+		if disco and disco.name == "query" and disco.attr.xmlns == "http://jabber.org/protocol/disco#info" then
+			-- Process disco response
+			local self = not stanza.attr.to;
+			local user = stanza.attr.to or (session.username..'@'..session.host);
+			local contact = stanza.attr.from;
+			local current = recipients[user] and recipients[user][contact];
+			if type(current) ~= "string" then return; end -- check if waiting for recipient's response
+			local ver = current;
+			if not string.find(current, "#") then
+				ver = calculate_hash(disco.tags); -- calculate hash
+			end
+			local notify = {};
+			for _, feature in pairs(disco.tags) do
+				if feature.name == "feature" and feature.attr.var then
+					local nfeature = feature.attr.var:match("^(.*)%+notify$");
+					if nfeature then notify[nfeature] = true; end
+				end
+			end
+			hash_map[ver] = notify; -- update hash map
+			if self then
+				for jid, item in pairs(session.roster) do -- for all interested contacts
+					if item.subscription == "both" or item.subscription == "from" then
+						if not recipients[jid] then recipients[jid] = {}; end
+						recipients[jid][contact] = notify;
+						publish_all(jid, contact, session);
+					end
+				end
+			end
+			recipients[user][contact] = notify; -- set recipient's data to calculated data
+			-- send messages to recipient
+			publish_all(user, contact, session);
+		end
+	end
+end);
+
+module:hook("account-disco-info", function(event)
+	local reply = event.reply;
+	reply:tag('identity', {category='pubsub', type='pep'}):up();
+	reply:tag('feature', {var=xmlns_pubsub}):up();
+	local features = {
+		"access-presence",
+		"auto-create",
+		"auto-subscribe",
+		"filtered-notifications",
+		"item-ids",
+		"last-published",
+		"presence-notifications",
+		"presence-subscribe",
+		"publish",
+		"retract-items",
+		"retrieve-items",
+	};
+	for _, feature in ipairs(features) do
+		reply:tag('feature', {var=xmlns_pubsub.."#"..feature}):up();
+	end
+end);
+
+module:hook("account-disco-items", function(event)
+	local reply = event.reply;
+	local bare = reply.attr.to;
+	local user_data = data[bare];
+
+	if user_data then
+		for node, _ in pairs(user_data) do
+			reply:tag('item', {jid=bare, node=node}):up();
+		end
+	end
+end);
+
+module:hook("account-disco-info-node", function (event)
+	local stanza, node = event.stanza, event.node;
+	local user = stanza.attr.to;
+	local user_data = data[user];
+	if user_data and user_data[node] then
+		event.exists = true;
+		event.reply:tag('identity', {category='pubsub', type='leaf'}):up();
+	end
+end);
+
+module:hook("resource-unbind", function (event)
+	local user_bare_jid = event.session.username.."@"..event.session.host;
+	if not bare_sessions[user_bare_jid] then -- User went offline
+		-- We don't need this info cached anymore, clear it.
+		recipients[user_bare_jid] = nil;
+	end
+end);
--- a/plugins/mod_ping.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_ping.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -21,12 +21,13 @@
 
 local datetime = require "util.datetime".datetime;
 
-function ping_command_handler (self, data, state)
+function ping_command_handler (self, data, state) -- luacheck: ignore 212
 	local now = datetime();
 	return { info = "Pong\n"..now, status = "completed" };
 end
 
+module:depends "adhoc";
 local adhoc_new = module:require "adhoc".new;
 local descriptor = adhoc_new("Ping", "ping", ping_command_handler);
-module:add_item ("adhoc", descriptor);
+module:provides("adhoc", descriptor);
 
--- a/plugins/mod_posix.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_posix.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -61,7 +61,7 @@
 	if not suid or suid == 0 or suid == "root" then
 		if pposix.getuid() == 0 and not module:get_option_boolean("run_as_root") then
 			module:log("error", "Danger, Will Robinson! Prosody doesn't need to be run as root, so don't do it!");
-			module:log("error", "For more information on running Prosody as root, see http://prosody.im/doc/root");
+			module:log("error", "For more information on running Prosody as root, see https://prosody.im/doc/root");
 			prosody.shutdown("Refusing to run as root");
 		end
 	end
@@ -161,23 +161,25 @@
 
 -- Set signal handlers
 if have_signal then
-	signal.signal("SIGTERM", function ()
-		module:log("warn", "Received SIGTERM");
-		prosody.unlock_globals();
-		prosody.shutdown("Received SIGTERM");
-		prosody.lock_globals();
-	end);
+	module:add_timer(0, function ()
+		signal.signal("SIGTERM", function ()
+			module:log("warn", "Received SIGTERM");
+			prosody.unlock_globals();
+			prosody.shutdown("Received SIGTERM");
+			prosody.lock_globals();
+		end);
 
-	signal.signal("SIGHUP", function ()
-		module:log("info", "Received SIGHUP");
-		prosody.reload_config();
-		prosody.reopen_logfiles();
-	end);
+		signal.signal("SIGHUP", function ()
+			module:log("info", "Received SIGHUP");
+			prosody.reload_config();
+			-- this also reloads logging
+		end);
 
-	signal.signal("SIGINT", function ()
-		module:log("info", "Received SIGINT");
-		prosody.unlock_globals();
-		prosody.shutdown("Received SIGINT");
-		prosody.lock_globals();
+		signal.signal("SIGINT", function ()
+			module:log("info", "Received SIGINT");
+			prosody.unlock_globals();
+			prosody.shutdown("Received SIGINT");
+			prosody.lock_globals();
+		end);
 	end);
 end
--- a/plugins/mod_presence.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_presence.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -10,7 +10,6 @@
 
 local require = require;
 local pairs = pairs;
-local t_concat = table.concat;
 local s_find = string.find;
 local tonumber = tonumber;
 
@@ -121,6 +120,8 @@
 	stanza.attr.to = nil; -- reset it
 end
 
+-- luacheck: ignore 212/recipient_session
+-- TODO This argument is used in 3rd party modules
 function send_presence_of_available_resources(user, host, jid, recipient_session, stanza)
 	local h = hosts[host];
 	local count = 0;
@@ -252,7 +253,7 @@
 end
 
 local outbound_presence_handler = function(data)
-	-- outbound presence recieved
+	-- outbound presence received
 	local origin, stanza = data.origin, data.stanza;
 
 	local to = stanza.attr.to;
@@ -280,7 +281,7 @@
 module:hook("pre-presence/host", outbound_presence_handler);
 
 module:hook("presence/bare", function(data)
-	-- inbound presence to bare JID recieved
+	-- inbound presence to bare JID received
 	local origin, stanza = data.origin, data.stanza;
 
 	local to = stanza.attr.to;
@@ -306,7 +307,7 @@
 	return true;
 end);
 module:hook("presence/full", function(data)
-	-- inbound presence to full JID recieved
+	-- inbound presence to full JID received
 	local origin, stanza = data.origin, data.stanza;
 
 	local t = stanza.attr.type;
--- a/plugins/mod_privacy.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,13 +0,0 @@
--- Prosody IM
--- Copyright (C) 2009-2010 Matthew Wild
--- Copyright (C) 2009-2010 Waqas Hussain
--- Copyright (C) 2009 Thilo Cestonaro
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-
--- COMPAT w/ pre 0.10
-module:log("error", "The mod_privacy plugin has been replaced by mod_blocklist. Please update your config. For more information see https://prosody.im/doc/modules/mod_privacy");
-module:depends("blocklist");
--- a/plugins/mod_private.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_private.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -9,7 +9,7 @@
 
 local st = require "util.stanza"
 
-local private_storage = module:open_store();
+local private_storage = module:open_store("private", "map");
 
 module:add_feature("jabber:iq:private");
 
@@ -22,28 +22,23 @@
 	end
 	local tag = query.tags[1];
 	local key = tag.name..":"..tag.attr.xmlns;
-	local data, err = private_storage:get(origin.username);
-	if err then
-		origin.send(st.error_reply(stanza, "wait", "internal-server-error", err));
-		return true;
-	end
 	if stanza.attr.type == "get" then
-		if data and data[key] then
-			origin.send(st.reply(stanza):query("jabber:iq:private"):add_child(st.deserialize(data[key])));
-			return true;
+		local data, err = private_storage:get(origin.username, key);
+		if data then
+			origin.send(st.reply(stanza):query("jabber:iq:private"):add_child(st.deserialize(data)));
+		elseif err then
+			origin.send(st.error_reply(stanza, "wait", "internal-server-error", err));
 		else
 			origin.send(st.reply(stanza):add_child(query));
-			return true;
 		end
-	else -- type == set
-		if not data then data = {}; end;
-		if #tag == 0 then
-			data[key] = nil;
-		else
-			data[key] = st.preserialize(tag);
+		return true;
+	else -- stanza.attr.type == "set"
+		local data;
+		if #tag ~= 0 then
+			data = st.preserialize(tag);
 		end
 		-- TODO delete datastore if empty
-		local ok, err = private_storage:set(origin.username, data);
+		local ok, err = private_storage:set(origin.username, key, data);
 		if not ok then
 			origin.send(st.error_reply(stanza, "wait", "internal-server-error", err));
 			return true;
--- a/plugins/mod_proxy65.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_proxy65.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -16,7 +16,8 @@
 local server = require "net.server";
 local portmanager = require "core.portmanager";
 
-local sessions, transfers = module:shared("sessions", "transfers");
+local sessions = module:shared("sessions");
+local transfers = module:shared("transfers");
 local max_buffer_size = 4096;
 
 local listener = {};
@@ -44,7 +45,7 @@
 		end -- else error, unexpected input
 		conn:write("\5\255"); -- send (SOCKS version 5, no acceptable method)
 		conn:close();
-		module:log("debug", "Invalid SOCKS5 greeting recieved: '%s'", b64(data));
+		module:log("debug", "Invalid SOCKS5 greeting received: '%s'", b64(data));
 	else -- connection request
 		--local head = string.char( 0x05, 0x01, 0x00, 0x03, 40 ); -- ( VER=5=SOCKS5, CMD=1=CONNECT, RSV=0=RESERVED, ATYP=3=DOMAIMNAME, SHA-1 size )
 		if #data == 47 and data:sub(1,5) == "\5\1\0\3\40" and data:sub(-2) == "\0\0" then
@@ -66,12 +67,12 @@
 		else -- error, unexpected input
 			conn:write("\5\1\0\3\0\0\0"); -- VER, REP, RSV, ATYP, BND.ADDR (sha), BND.PORT (2 Byte)
 			conn:close();
-			module:log("debug", "Invalid SOCKS5 negotiation recieved: '%s'", b64(data));
+			module:log("debug", "Invalid SOCKS5 negotiation received: '%s'", b64(data));
 		end
 	end
 end
 
-function listener.ondisconnect(conn, err)
+function listener.ondisconnect(conn)
 	local session = sessions[conn];
 	if session then
 		if transfers[session.sha] then
@@ -79,7 +80,7 @@
 			if initiator == conn and target ~= nil then
 				target:close();
 			elseif target == conn and initiator ~= nil then
-			 	initiator:close();
+				initiator:close();
 			end
 			transfers[session.sha] = nil;
 		end
@@ -108,7 +109,8 @@
 		local origin, stanza = event.origin, event.stanza;
 
 		-- check ACL
-		while proxy_acl and #proxy_acl > 0 do -- using 'while' instead of 'if' so we can break out of it
+		-- using 'while' instead of 'if' so we can break out of it
+		while proxy_acl and #proxy_acl > 0 do --luacheck: ignore 512
 			local jid = stanza.attr.from;
 			local allow;
 			for _, acl in ipairs(proxy_acl) do
@@ -129,7 +131,7 @@
 
 		local sid = stanza.tags[1].attr.sid;
 		origin.send(st.reply(stanza):tag("query", {xmlns="http://jabber.org/protocol/bytestreams", sid=sid})
-			:tag("streamhost", {jid=host, host=proxy_address, port=proxy_port}));
+			:tag("streamhost", {jid=host, host=proxy_address, port=("%d"):format(proxy_port)}));
 		return true;
 	end);
 
--- a/plugins/mod_pubsub/mod_pubsub.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_pubsub/mod_pubsub.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -2,6 +2,7 @@
 local st = require "util.stanza";
 local jid_bare = require "util.jid".bare;
 local usermanager = require "core.usermanager";
+local new_id = require "util.id".medium;
 
 local xmlns_pubsub = "http://jabber.org/protocol/pubsub";
 local xmlns_pubsub_event = "http://jabber.org/protocol/pubsub#event";
@@ -15,112 +16,143 @@
 local service;
 
 local lib_pubsub = module:require "pubsub";
-local handlers = lib_pubsub.handlers;
-local pubsub_error_reply = lib_pubsub.pubsub_error_reply;
 
 module:depends("disco");
 module:add_identity("pubsub", "service", pubsub_disco_name);
 module:add_feature("http://jabber.org/protocol/pubsub");
 
 function handle_pubsub_iq(event)
-	local origin, stanza = event.origin, event.stanza;
-	local pubsub = stanza.tags[1];
-	local action = pubsub.tags[1];
-	if not action then
-		origin.send(st.error_reply(stanza, "cancel", "bad-request"));
-		return true;
+	return lib_pubsub.handle_pubsub_iq(event, service);
+end
+
+-- An itemstore supports the following methods:
+--   items(): iterator over (id, item)
+--   get(id): return item with id
+--   set(id, item): set id to item
+--   clear(): clear all items
+--   resize(n): set new limit and trim oldest items
+--   tail(): return the latest item
+
+-- A nodestore supports the following methods:
+--   set(node_name, node_data)
+--   get(node_name)
+--   users(): iterator over (node_name)
+
+
+local node_store = module:open_store(module.name.."_nodes");
+
+local function create_simple_itemstore(node_config, node_name)
+	local archive = module:open_store("pubsub_"..node_name, "archive");
+	return lib_pubsub.archive_itemstore(archive, node_config, nil, node_name);
+end
+
+function simple_broadcast(kind, node, jids, item, actor, node_obj)
+	if node_obj then
+		if node_obj.config["notify_"..kind] == false then
+			return;
+		end
+	end
+	if kind == "retract" then
+		kind = "items"; -- XEP-0060 signals retraction in an <items> container
 	end
-	local handler = handlers[stanza.attr.type.."_"..action.name];
-	if handler then
-		handler(origin, stanza, action, service);
-		return true;
+
+	if item then
+		item = st.clone(item);
+		item.attr.xmlns = nil; -- Clear the pubsub namespace
+		if kind == "items" then
+			if node_obj and node_obj.config.include_payload == false then
+				item:maptags(function () return nil; end);
+			end
+			if expose_publisher and actor then
+				item.attr.publisher = actor
+			end
+		end
+	end
+
+	local id = new_id();
+	local msg_type = node_obj and node_obj.config.message_type or "headline";
+	local message = st.message({ from = module.host, type = msg_type, id = id })
+		:tag("event", { xmlns = xmlns_pubsub_event })
+			:tag(kind, { node = node })
+
+	if item then
+		message:add_child(item);
+	end
+
+	local summary;
+	-- Compose a sensible textual representation of at least Atom payloads
+	if item and item.tags[1] then
+		local payload = item.tags[1];
+		summary = module:fire_event("pubsub-summary/"..payload.attr.xmlns, {
+			kind = kind, node = node, jids = jids, actor = actor, item = item, payload = payload,
+		});
+	end
+
+	for jid, options in pairs(jids) do
+		local new_stanza = st.clone(message);
+		if summary and type(options) == "table" and options["pubsub#include_body"] then
+			new_stanza:body(summary);
+		end
+		new_stanza.attr.to = jid;
+		module:send(new_stanza);
 	end
 end
 
-function simple_broadcast(kind, node, jids, item, actor)
-	if item then
-		item = st.clone(item);
-		item.attr.xmlns = nil; -- Clear the pubsub namespace
-		if expose_publisher and actor then
-			item.attr.publisher = actor
+local max_max_items = module:get_option_number("pubsub_max_items", 256);
+function check_node_config(node, actor, new_config) -- luacheck: ignore 212/actor 212/node
+	if (new_config["max_items"] or 1) > max_max_items then
+		return false;
+	end
+	if new_config["access_model"] ~= "whitelist" and new_config["access_model"] ~= "open" then
+		return false;
+	end
+	return true;
+end
+
+function is_item_stanza(item)
+	return st.is_stanza(item) and item.attr.xmlns == xmlns_pubsub and item.name == "item";
+end
+
+module:hook("pubsub-summary/http://www.w3.org/2005/Atom", function (event)
+	local payload = event.payload;
+	local title = payload:get_child_text("title");
+	local summary = payload:get_child_text("summary");
+	if not summary and title then
+		local author = payload:find("author/name#");
+		summary = title;
+		if author then
+			summary = author .. " posted " .. summary;
 		end
 	end
-	local message = st.message({ from = module.host, type = "headline" })
-		:tag("event", { xmlns = xmlns_pubsub_event })
-			:tag(kind, { node = node })
-				:add_child(item);
-	for jid in pairs(jids) do
-		module:log("debug", "Sending notification to %s", jid);
-		message.attr.to = jid;
-		module:send(message);
-	end
-end
+	return summary;
+end);
 
 module:hook("iq/host/"..xmlns_pubsub..":pubsub", handle_pubsub_iq);
 module:hook("iq/host/"..xmlns_pubsub_owner..":pubsub", handle_pubsub_iq);
 
-local feature_map = {
-	create = { "create-nodes", "instant-nodes", "item-ids" };
-	retract = { "delete-items", "retract-items" };
-	purge = { "purge-nodes" };
-	publish = { "publish", autocreate_on_publish and "auto-create" };
-	delete = { "delete-nodes" };
-	get_items = { "retrieve-items" };
-	add_subscription = { "subscribe" };
-	get_subscriptions = { "retrieve-subscriptions" };
-	set_configure = { "config-node" };
-	get_default = { "retrieve-default" };
-};
-
-local function add_disco_features_from_service(service)
-	for method, features in pairs(feature_map) do
-		if service[method] then
-			for _, feature in ipairs(features) do
-				if feature then
-					module:add_feature(xmlns_pubsub.."#"..feature);
-				end
-			end
-		end
-	end
-	for affiliation in pairs(service.config.capabilities) do
-		if affiliation ~= "none" and affiliation ~= "owner" then
-			module:add_feature(xmlns_pubsub.."#"..affiliation.."-affiliation");
-		end
+local function add_disco_features_from_service(service) --luacheck: ignore 431/service
+	for feature in lib_pubsub.get_feature_set(service) do
+		module:add_feature(xmlns_pubsub.."#"..feature);
 	end
 end
 
 module:hook("host-disco-info-node", function (event)
-	local stanza, origin, reply, node = event.stanza, event.origin, event.reply, event.node;
-	local ok, ret = service:get_nodes(stanza.attr.from);
-	if not ok or not ret[node] then
-		return;
-	end
-	event.exists = true;
-	reply:tag("identity", { category = "pubsub", type = "leaf" });
+	return lib_pubsub.handle_disco_info_node(event, service);
 end);
 
 module:hook("host-disco-items-node", function (event)
-	local stanza, origin, reply, node = event.stanza, event.origin, event.reply, event.node;
-	local ok, ret = service:get_items(node, stanza.attr.from);
-	if not ok then
-		return;
-	end
-
-	for _, id in ipairs(ret) do
-		reply:tag("item", { jid = module.host, name = id }):up();
-	end
-	event.exists = true;
+	return lib_pubsub.handle_disco_items_node(event, service);
 end);
 
 
 module:hook("host-disco-items", function (event)
-	local stanza, origin, reply = event.stanza, event.origin, event.reply;
-	local ok, ret = service:get_nodes(event.stanza.attr.from);
+	local stanza, reply = event.stanza, event.reply;
+	local ok, ret = service:get_nodes(stanza.attr.from);
 	if not ok then
 		return;
 	end
 	for node, node_obj in pairs(ret) do
-		reply:tag("item", { jid = module.host, node = node, name = node_obj.config.name }):up();
+		reply:tag("item", { jid = module.host, node = node, name = node_obj.config.title }):up();
 	end
 end);
 
@@ -132,6 +164,10 @@
 	end
 end
 
+function get_service()
+	return service;
+end
+
 function set_service(new_service)
 	service = new_service;
 	module.environment.service = service;
@@ -150,82 +186,14 @@
 	if module.reloading then return; end
 
 	set_service(pubsub.new({
-		capabilities = {
-			none = {
-				create = false;
-				publish = false;
-				retract = false;
-				get_nodes = true;
-
-				subscribe = true;
-				unsubscribe = true;
-				get_subscription = true;
-				get_subscriptions = true;
-				get_items = true;
-
-				subscribe_other = false;
-				unsubscribe_other = false;
-				get_subscription_other = false;
-				get_subscriptions_other = false;
-
-				be_subscribed = true;
-				be_unsubscribed = true;
-
-				set_affiliation = false;
-			};
-			publisher = {
-				create = false;
-				publish = true;
-				retract = true;
-				get_nodes = true;
-
-				subscribe = true;
-				unsubscribe = true;
-				get_subscription = true;
-				get_subscriptions = true;
-				get_items = true;
-
-				subscribe_other = false;
-				unsubscribe_other = false;
-				get_subscription_other = false;
-				get_subscriptions_other = false;
-
-				be_subscribed = true;
-				be_unsubscribed = true;
-
-				set_affiliation = false;
-			};
-			owner = {
-				create = true;
-				publish = true;
-				retract = true;
-				delete = true;
-				get_nodes = true;
-				configure = true;
-
-				subscribe = true;
-				unsubscribe = true;
-				get_subscription = true;
-				get_subscriptions = true;
-				get_items = true;
-
-
-				subscribe_other = true;
-				unsubscribe_other = true;
-				get_subscription_other = true;
-				get_subscriptions_other = true;
-
-				be_subscribed = true;
-				be_unsubscribed = true;
-
-				set_affiliation = true;
-			};
-		};
-
 		autocreate_on_publish = autocreate_on_publish;
 		autocreate_on_subscribe = autocreate_on_subscribe;
 
+		nodestore = node_store;
+		itemstore = create_simple_itemstore;
 		broadcaster = simple_broadcast;
+		itemcheck = is_item_stanza;
+		check_node_config = check_node_config;
 		get_affiliation = get_affiliation;
 
 		normalize_jid = jid_bare;
--- a/plugins/mod_pubsub/pubsub.lib.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_pubsub/pubsub.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -1,4 +1,10 @@
+local t_unpack = table.unpack or unpack; -- luacheck: ignore 113
+local time_now = os.time;
+
+local jid_prep = require "util.jid".prep;
+local set = require "util.set";
 local st = require "util.stanza";
+local it = require "util.iterators";
 local uuid_generate = require "util.uuid".generate;
 local dataform = require"util.dataforms".new;
 
@@ -18,12 +24,17 @@
 	["nodeid-required"] = { "modify", "bad-request", nil, "nodeid-required" };
 	["item-not-found"] = { "cancel", "item-not-found" };
 	["not-subscribed"] = { "modify", "unexpected-request", nil, "not-subscribed" };
+	["invalid-options"] = { "modify", "bad-request", nil, "invalid-options" };
 	["forbidden"] = { "auth", "forbidden" };
 	["not-allowed"] = { "cancel", "not-allowed" };
+	["not-acceptable"] = { "modify", "not-acceptable" };
+	["internal-server-error"] = { "wait", "internal-server-error" };
+	["precondition-not-met"] = { "cancel", "conflict", nil, "precondition-not-met" };
+	["invalid-item"] = { "modify", "bad-request", "invalid item" };
 };
 local function pubsub_error_reply(stanza, error)
 	local e = pubsub_errors[error];
-	local reply = st.error_reply(stanza, unpack(e, 1, 3));
+	local reply = st.error_reply(stanza, t_unpack(e, 1, 3));
 	if e[4] then
 		reply:tag(e[4], { xmlns = xmlns_pubsub_errors }):up();
 	end
@@ -31,29 +42,267 @@
 end
 _M.pubsub_error_reply = pubsub_error_reply;
 
-local node_config_form = require"util.dataforms".new {
+local function dataform_error_message(err) -- ({ string : string }) -> string?
+	local out = {};
+	for field, errmsg in pairs(err) do
+		table.insert(out, ("%s: %s"):format(field, errmsg))
+	end
+	return table.concat(out, "; ");
+end
+
+-- Note: If any config options are added that are of complex types,
+-- (not simply strings/numbers) then the publish-options code will
+-- need to be revisited
+local node_config_form = dataform {
 	{
 		type = "hidden";
-		name = "FORM_TYPE";
+		var = "FORM_TYPE";
 		value = "http://jabber.org/protocol/pubsub#node_config";
 	};
 	{
 		type = "text-single";
-		name = "pubsub#max_items";
+		name = "title";
+		var = "pubsub#title";
+		label = "Title";
+	};
+	{
+		type = "text-single";
+		name = "description";
+		var = "pubsub#description";
+		label = "Description";
+	};
+	{
+		type = "text-single";
+		name = "payload_type";
+		var = "pubsub#type";
+		label = "The type of node data, usually specified by the namespace of the payload (if any)";
+	};
+	{
+		type = "text-single";
+		datatype = "xs:integer";
+		name = "max_items";
+		var = "pubsub#max_items";
 		label = "Max # of items to persist";
 	};
+	{
+		type = "boolean";
+		name = "persist_items";
+		var = "pubsub#persist_items";
+		label = "Persist items to storage";
+	};
+	{
+		type = "list-single";
+		name = "access_model";
+		var = "pubsub#access_model";
+		label = "Specify the subscriber model";
+		options = {
+			"authorize",
+			"open",
+			"presence",
+			"roster",
+			"whitelist",
+		};
+	};
+	{
+		type = "list-single";
+		name = "publish_model";
+		var = "pubsub#publish_model";
+		label = "Specify the publisher model";
+		options = {
+			"publishers";
+			"subscribers";
+			"open";
+		};
+	};
+	{
+		type = "boolean";
+		value = true;
+		label = "Whether to deliver event notifications";
+		name = "notify_items";
+		var = "pubsub#deliver_notifications";
+	};
+	{
+		type = "boolean";
+		value = true;
+		label = "Whether to deliver payloads with event notifications";
+		name = "include_payload";
+		var = "pubsub#deliver_payloads";
+	};
+	{
+		type = "list-single";
+		name = "notification_type";
+		var = "pubsub#notification_type";
+		label = "Specify the delivery style for notifications";
+		options = {
+			{ label = "Messages of type normal", value = "normal" },
+			{ label = "Messages of type headline", value = "headline", default = true },
+		};
+	};
+	{
+		type = "boolean";
+		label = "Whether to notify subscribers when the node is deleted";
+		name = "notify_delete";
+		var = "pubsub#notify_delete";
+		value = true;
+	};
+	{
+		type = "boolean";
+		label = "Whether to notify subscribers when items are removed from the node";
+		name = "notify_retract";
+		var = "pubsub#notify_retract";
+		value = true;
+	};
 };
 
+local subscribe_options_form = dataform {
+	{
+		type = "hidden";
+		var = "FORM_TYPE";
+		value = "http://jabber.org/protocol/pubsub#subscribe_options";
+	};
+	{
+		type = "boolean";
+		name = "pubsub#include_body";
+		label = "Receive message body in addition to payload?";
+	};
+};
+
+local node_metadata_form = dataform {
+	{
+		type = "hidden";
+		var = "FORM_TYPE";
+		value = "http://jabber.org/protocol/pubsub#meta-data";
+	};
+	{
+		type = "text-single";
+		name = "pubsub#title";
+	};
+	{
+		type = "text-single";
+		name = "pubsub#description";
+	};
+	{
+		type = "text-single";
+		name = "pubsub#type";
+	};
+};
+
+local service_method_feature_map = {
+	add_subscription = { "subscribe", "subscription-options" };
+	create = { "create-nodes", "instant-nodes", "item-ids", "create-and-configure" };
+	delete = { "delete-nodes" };
+	get_items = { "retrieve-items" };
+	get_subscriptions = { "retrieve-subscriptions" };
+	node_defaults = { "retrieve-default" };
+	publish = { "publish", "multi-items", "publish-options" };
+	purge = { "purge-nodes" };
+	retract = { "delete-items", "retract-items" };
+	set_node_config = { "config-node", "meta-data" };
+	set_affiliation = { "modify-affiliations" };
+};
+local service_config_feature_map = {
+	autocreate_on_publish = { "auto-create" };
+};
+
+function _M.get_feature_set(service)
+	local supported_features = set.new();
+
+	for method, features in pairs(service_method_feature_map) do
+		if service[method] then
+			for _, feature in ipairs(features) do
+				if feature then
+					supported_features:add(feature);
+				end
+			end
+		end
+	end
+
+	for option, features in pairs(service_config_feature_map) do
+		if service.config[option] then
+			for _, feature in ipairs(features) do
+				if feature then
+					supported_features:add(feature);
+				end
+			end
+		end
+	end
+
+	for affiliation in pairs(service.config.capabilities) do
+		if affiliation ~= "none" and affiliation ~= "owner" then
+			supported_features:add(affiliation.."-affiliation");
+		end
+	end
+
+	if service.node_defaults.access_model then
+		supported_features:add("access-"..service.node_defaults.access_model);
+	end
+
+	if rawget(service.config, "itemstore") and rawget(service.config, "nodestore") then
+		supported_features:add("persistent-items");
+	end
+
+	return supported_features;
+end
+
+function _M.handle_disco_info_node(event, service)
+	local stanza, reply, node = event.stanza, event.reply, event.node;
+	local ok, ret = service:get_nodes(stanza.attr.from);
+	local node_obj = ret[node];
+	if not ok or not node_obj then
+		return;
+	end
+	event.exists = true;
+	reply:tag("identity", { category = "pubsub", type = "leaf" }):up();
+	if node_obj.config then
+		reply:add_child(node_metadata_form:form({
+			["pubsub#title"] = node_obj.config.title;
+			["pubsub#description"] = node_obj.config.description;
+			["pubsub#type"] = node_obj.config.payload_type;
+		}, "result"));
+	end
+end
+
+function _M.handle_disco_items_node(event, service)
+	local stanza, reply, node = event.stanza, event.reply, event.node;
+	local ok, ret = service:get_items(node, stanza.attr.from);
+	if not ok then
+		return;
+	end
+
+	for _, id in ipairs(ret) do
+		reply:tag("item", { jid = module.host, name = id }):up();
+	end
+	event.exists = true;
+end
+
+function _M.handle_pubsub_iq(event, service)
+	local origin, stanza = event.origin, event.stanza;
+	local pubsub_tag = stanza.tags[1];
+	local action = pubsub_tag.tags[1];
+	if not action then
+		return origin.send(st.error_reply(stanza, "cancel", "bad-request"));
+	end
+	local prefix = "";
+	if pubsub_tag.attr.xmlns == xmlns_pubsub_owner then
+		prefix = "owner_";
+	end
+	local handler = handlers[prefix..stanza.attr.type.."_"..action.name];
+	if handler then
+		handler(origin, stanza, action, service);
+		return true;
+	end
+end
+
 function handlers.get_items(origin, stanza, items, service)
 	local node = items.attr.node;
 	local item = items:get_child("item");
-	local id = item and item.attr.id;
+	local item_id = item and item.attr.id;
 
 	if not node then
 		origin.send(pubsub_error_reply(stanza, "nodeid-required"));
 		return true;
 	end
-	local ok, results = service:get_items(node, stanza.attr.from, id);
+	local ok, results = service:get_items(node, stanza.attr.from, item_id);
 	if not ok then
 		origin.send(pubsub_error_reply(stanza, results));
 		return true;
@@ -92,11 +341,81 @@
 	return true;
 end
 
+function handlers.owner_get_subscriptions(origin, stanza, subscriptions, service)
+	local node = subscriptions.attr.node;
+	local ok, ret = service:get_subscriptions(node, stanza.attr.from);
+	if not ok then
+		origin.send(pubsub_error_reply(stanza, ret));
+		return true;
+	end
+	local reply = st.reply(stanza)
+		:tag("pubsub", { xmlns = xmlns_pubsub_owner })
+			:tag("subscriptions");
+	for _, sub in ipairs(ret) do
+		reply:tag("subscription", { node = sub.node, jid = sub.jid, subscription = 'subscribed' }):up();
+	end
+	origin.send(reply);
+	return true;
+end
+
+function handlers.owner_set_subscriptions(origin, stanza, subscriptions, service)
+	local node = subscriptions.attr.node;
+	if not node then
+		origin.send(pubsub_error_reply(stanza, "nodeid-required"));
+		return true;
+	end
+	if not service:may(node, stanza.attr.from, "subscribe_other") then
+		origin.send(pubsub_error_reply(stanza, "forbidden"));
+		return true;
+	end
+
+	local node_obj = service.nodes[node];
+	if not node_obj then
+		origin.send(pubsub_error_reply(stanza, "item-not-found"));
+		return true;
+	end
+
+	for subscription_tag in subscriptions:childtags("subscription") do
+		if subscription_tag.attr.subscription == 'subscribed' then
+			local ok, err = service:add_subscription(node, stanza.attr.from, subscription_tag.attr.jid);
+			if not ok then
+				origin.send(pubsub_error_reply(stanza, err));
+				return true;
+			end
+		elseif subscription_tag.attr.subscription == 'none' then
+			local ok, err = service:remove_subscription(node, stanza.attr.from, subscription_tag.attr.jid);
+			if not ok then
+				origin.send(pubsub_error_reply(stanza, err));
+				return true;
+			end
+		end
+	end
+
+	local reply = st.reply(stanza);
+	origin.send(reply);
+	return true;
+end
+
 function handlers.set_create(origin, stanza, create, service)
 	local node = create.attr.node;
 	local ok, ret, reply;
+	local config;
+	local configure = stanza.tags[1]:get_child("configure");
+	if configure then
+		local config_form = configure:get_child("x", "jabber:x:data");
+		if not config_form then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing dataform"));
+			return true;
+		end
+		local form_data, err = node_config_form:data(config_form);
+		if err then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(err)));
+			return true;
+		end
+		config = form_data;
+	end
 	if node then
-		ok, ret = service:create(node, stanza.attr.from);
+		ok, ret = service:create(node, stanza.attr.from, config);
 		if ok then
 			reply = st.reply(stanza);
 		else
@@ -105,7 +424,7 @@
 	else
 		repeat
 			node = uuid_generate();
-			ok, ret = service:create(node, stanza.attr.from);
+			ok, ret = service:create(node, stanza.attr.from, config);
 		until ok or ret ~= "conflict";
 		if ok then
 			reply = st.reply(stanza)
@@ -119,10 +438,10 @@
 	return true;
 end
 
-function handlers.set_delete(origin, stanza, delete, service)
+function handlers.owner_set_delete(origin, stanza, delete, service)
 	local node = delete.attr.node;
 
-	local reply, notifier;
+	local reply;
 	if not node then
 		origin.send(pubsub_error_reply(stanza, "nodeid-required"));
 		return true;
@@ -139,17 +458,21 @@
 
 function handlers.set_subscribe(origin, stanza, subscribe, service)
 	local node, jid = subscribe.attr.node, subscribe.attr.jid;
+	jid = jid_prep(jid);
 	if not (node and jid) then
 		origin.send(pubsub_error_reply(stanza, jid and "nodeid-required" or "invalid-jid"));
 		return true;
 	end
-	--[[
 	local options_tag, options = stanza.tags[1]:get_child("options"), nil;
 	if options_tag then
-		options = options_form:data(options_tag.tags[1]);
+		-- FIXME form parsing errors ignored here, why?
+		local err
+		options, err = subscribe_options_form:data(options_tag.tags[1]);
+		if err then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(err)));
+			return true
+		end
 	end
-	--]]
-	local options_tag, options; -- FIXME
 	local ok, ret = service:add_subscription(node, stanza.attr.from, jid, options);
 	local reply;
 	if ok then
@@ -171,6 +494,7 @@
 
 function handlers.set_unsubscribe(origin, stanza, unsubscribe, service)
 	local node, jid = unsubscribe.attr.node, unsubscribe.attr.jid;
+	jid = jid_prep(jid);
 	if not (node and jid) then
 		origin.send(pubsub_error_reply(stanza, jid and "nodeid-required" or "invalid-jid"));
 		return true;
@@ -186,12 +510,74 @@
 	return true;
 end
 
+function handlers.get_options(origin, stanza, options, service)
+	local node, jid = options.attr.node, options.attr.jid;
+	jid = jid_prep(jid);
+	if not (node and jid) then
+		origin.send(pubsub_error_reply(stanza, jid and "nodeid-required" or "invalid-jid"));
+		return true;
+	end
+	local ok, ret = service:get_subscription(node, stanza.attr.from, jid);
+	if not ok then
+		origin.send(pubsub_error_reply(stanza, "not-subscribed"));
+		return true;
+	end
+	if ret == true then ret = {} end
+	origin.send(st.reply(stanza)
+		:tag("pubsub", { xmlns = xmlns_pubsub })
+			:tag("options", { node = node, jid = jid })
+				:add_child(subscribe_options_form:form(ret)));
+	return true;
+end
+
+function handlers.set_options(origin, stanza, options, service)
+	local node, jid = options.attr.node, options.attr.jid;
+	jid = jid_prep(jid);
+	if not (node and jid) then
+		origin.send(pubsub_error_reply(stanza, jid and "nodeid-required" or "invalid-jid"));
+		return true;
+	end
+	local ok, ret = service:get_subscription(node, stanza.attr.from, jid);
+	if not ok then
+		origin.send(pubsub_error_reply(stanza, ret));
+		return true;
+	elseif not ret then
+		origin.send(pubsub_error_reply(stanza, "not-subscribed"));
+		return true;
+	end
+	local old_subopts = ret;
+	local new_subopts, err = subscribe_options_form:data(options.tags[1], old_subopts);
+	if err then
+		origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(err)));
+		return true;
+	end
+	local ok, err = service:add_subscription(node, stanza.attr.from, jid, new_subopts);
+	if not ok then
+		origin.send(pubsub_error_reply(stanza, err));
+		return true;
+	end
+	origin.send(st.reply(stanza));
+	return true;
+end
+
 function handlers.set_publish(origin, stanza, publish, service)
 	local node = publish.attr.node;
 	if not node then
 		origin.send(pubsub_error_reply(stanza, "nodeid-required"));
 		return true;
 	end
+	local required_config = nil;
+	local publish_options = stanza.tags[1]:get_child("publish-options");
+	if publish_options then
+		-- Ensure that the node configuration matches the values in publish-options
+		local publish_options_form = publish_options:get_child("x", "jabber:x:data");
+		local err;
+		required_config, err = node_config_form:data(publish_options_form);
+		if err then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(err)));
+			return true
+		end
+	end
 	local item = publish:get_child("item");
 	local id = (item and item.attr.id);
 	if not id then
@@ -200,9 +586,12 @@
 			item.attr.id = id;
 		end
 	end
-	local ok, ret = service:publish(node, stanza.attr.from, id, item);
+	local ok, ret = service:publish(node, stanza.attr.from, id, item, required_config);
 	local reply;
 	if ok then
+		if type(ok) == "string" then
+			id = ok;
+		end
 		reply = st.reply(stanza)
 			:tag("pubsub", { xmlns = xmlns_pubsub })
 				:tag("publish", { node = node })
@@ -237,7 +626,7 @@
 	return true;
 end
 
-function handlers.set_purge(origin, stanza, purge, service)
+function handlers.owner_set_purge(origin, stanza, purge, service)
 	local node, notify = purge.attr.node, purge.attr.notify;
 	notify = (notify == "1") or (notify == "true");
 	local reply;
@@ -255,17 +644,80 @@
 	return true;
 end
 
-function handlers.get_configure(origin, stanza, config, service)
+function handlers.owner_get_configure(origin, stanza, config, service)
 	local node = config.attr.node;
 	if not node then
 		origin.send(pubsub_error_reply(stanza, "nodeid-required"));
 		return true;
 	end
 
+	local ok, node_config = service:get_node_config(node, stanza.attr.from);
+	if not ok then
+		origin.send(pubsub_error_reply(stanza, node_config));
+		return true;
+	end
+
+	local reply = st.reply(stanza)
+		:tag("pubsub", { xmlns = xmlns_pubsub_owner })
+			:tag("configure", { node = node })
+				:add_child(node_config_form:form(node_config));
+	origin.send(reply);
+	return true;
+end
+
+function handlers.owner_set_configure(origin, stanza, config, service)
+	local node = config.attr.node;
+	if not node then
+		origin.send(pubsub_error_reply(stanza, "nodeid-required"));
+		return true;
+	end
 	if not service:may(node, stanza.attr.from, "configure") then
 		origin.send(pubsub_error_reply(stanza, "forbidden"));
 		return true;
 	end
+	local config_form = config:get_child("x", "jabber:x:data");
+	if not config_form then
+		origin.send(st.error_reply(stanza, "modify", "bad-request", "Missing dataform"));
+		return true;
+	end
+	local ok, old_config = service:get_node_config(node, stanza.attr.from);
+	if not ok then
+		origin.send(pubsub_error_reply(stanza, old_config));
+		return true;
+	end
+	local new_config, err = node_config_form:data(config_form, old_config);
+	if err then
+		origin.send(st.error_reply(stanza, "modify", "bad-request", dataform_error_message(err)));
+		return true;
+	end
+	local ok, err = service:set_node_config(node, stanza.attr.from, new_config);
+	if not ok then
+		origin.send(pubsub_error_reply(stanza, err));
+		return true;
+	end
+	origin.send(st.reply(stanza));
+	return true;
+end
+
+function handlers.owner_get_default(origin, stanza, default, service) -- luacheck: ignore 212/default
+	local reply = st.reply(stanza)
+		:tag("pubsub", { xmlns = xmlns_pubsub_owner })
+			:tag("default")
+				:add_child(node_config_form:form(service.node_defaults));
+	origin.send(reply);
+	return true;
+end
+
+function handlers.owner_get_affiliations(origin, stanza, affiliations, service)
+	local node = affiliations.attr.node;
+	if not node then
+		origin.send(pubsub_error_reply(stanza, "nodeid-required"));
+		return true;
+	end
+	if not service:may(node, stanza.attr.from, "set_affiliation") then
+		origin.send(pubsub_error_reply(stanza, "forbidden"));
+		return true;
+	end
 
 	local node_obj = service.nodes[node];
 	if not node_obj then
@@ -275,43 +727,138 @@
 
 	local reply = st.reply(stanza)
 		:tag("pubsub", { xmlns = xmlns_pubsub_owner })
-			:tag("configure", { node = node })
-				:add_child(node_config_form:form(node_obj.config));
+			:tag("affiliations", { node = node });
+
+	for jid, affiliation in pairs(node_obj.affiliations) do
+		reply:tag("affiliation", { jid = jid, affiliation = affiliation }):up();
+	end
+
 	origin.send(reply);
 	return true;
 end
 
-function handlers.set_configure(origin, stanza, config, service)
-	local node = config.attr.node;
+function handlers.owner_set_affiliations(origin, stanza, affiliations, service)
+	local node = affiliations.attr.node;
 	if not node then
 		origin.send(pubsub_error_reply(stanza, "nodeid-required"));
 		return true;
 	end
-	if not service:may(node, stanza.attr.from, "configure") then
+	if not service:may(node, stanza.attr.from, "set_affiliation") then
 		origin.send(pubsub_error_reply(stanza, "forbidden"));
 		return true;
 	end
-	local new_config, err = node_config_form:data(config.tags[1]);
-	if not new_config then
-		origin.send(st.error_reply(stanza, "modify", "bad-request", err));
-		return true;
-	end
-	local ok, err = service:set_node_config(node, stanza.attr.from, new_config);
-	if not ok then
-		origin.send(pubsub_error_reply(stanza, err));
+
+	local node_obj = service.nodes[node];
+	if not node_obj then
+		origin.send(pubsub_error_reply(stanza, "item-not-found"));
 		return true;
 	end
-	origin.send(st.reply(stanza));
-	return true;
-end
+
+	for affiliation_tag in affiliations:childtags("affiliation") do
+		local jid = affiliation_tag.attr.jid;
+		local affiliation = affiliation_tag.attr.affiliation;
+
+		jid = jid_prep(jid);
+		if affiliation == "none" then affiliation = nil; end
 
-function handlers.get_default(origin, stanza, default, service)
-	local reply = st.reply(stanza)
-		:tag("pubsub", { xmlns = xmlns_pubsub_owner })
-			:tag("default")
-				:add_child(node_config_form:form(service.node_defaults));
+		local ok, err = service:set_affiliation(node, stanza.attr.from, jid, affiliation);
+		if not ok then
+			-- FIXME Incomplete error handling,
+			-- see XEP 60 8.9.2.4 Multiple Simultaneous Modifications
+			origin.send(pubsub_error_reply(stanza, err));
+			return true;
+		end
+	end
+
+	local reply = st.reply(stanza);
 	origin.send(reply);
 	return true;
 end
 
+local function create_encapsulating_item(id, payload)
+	local item = st.stanza("item", { id = id, xmlns = xmlns_pubsub });
+	item:add_child(payload);
+	return item;
+end
+
+local function archive_itemstore(archive, config, user, node)
+	module:log("debug", "Creation of itemstore for node %s with config %s", node, config);
+	local get_set = {};
+	local max_items = config["max_items"];
+	function get_set:items() -- luacheck: ignore 212/self
+		local data, err = archive:find(user, {
+			limit = tonumber(max_items);
+			reverse = true;
+		});
+		if not data then
+			module:log("error", "Unable to get items: %s", err);
+			return true;
+		end
+		module:log("debug", "Listed items %s", data);
+		return it.reverse(function()
+			local id, payload, when, publisher = data();
+			if id == nil then
+				return;
+			end
+			local item = create_encapsulating_item(id, payload, publisher);
+			return id, item;
+		end);
+	end
+	function get_set:get(key) -- luacheck: ignore 212/self
+		local data, err = archive:find(user, {
+			key = key;
+			-- Get the last item with that key, if the archive doesn't deduplicate
+			reverse = true,
+			limit = 1;
+		});
+		if not data then
+			module:log("error", "Unable to get item: %s", err);
+			return nil, err;
+		end
+		local id, payload, when, publisher = data();
+		module:log("debug", "Get item %s (published at %s by %s)", id, when, publisher);
+		if id == nil then
+			return nil;
+		end
+		return create_encapsulating_item(id, payload, publisher);
+	end
+	function get_set:set(key, value) -- luacheck: ignore 212/self
+		local data, err;
+		if value ~= nil then
+			local publisher = value.attr.publisher;
+			local payload = value.tags[1];
+			data, err = archive:append(user, key, payload, time_now(), publisher);
+		else
+			data, err = archive:delete(user, { key = key; });
+		end
+		-- TODO archive support for maintaining maximum items
+		archive:delete(user, {
+			truncate = max_items;
+		});
+		if not data then
+			module:log("error", "Unable to set item: %s", err);
+			return nil, err;
+		end
+		return data;
+	end
+	function get_set:clear() -- luacheck: ignore 212/self
+		return archive:delete(user);
+	end
+	function get_set:resize(size) -- luacheck: ignore 212/self
+		max_items = size;
+		return archive:delete(user, {
+			truncate = size;
+		});
+	end
+	function get_set:head()
+		-- This should conveniently return the most recent item
+		local item = self:get(nil);
+		if item then
+			return item.attr.id, item;
+		end
+	end
+	return setmetatable(get_set, archive);
+end
+_M.archive_itemstore = archive_itemstore;
+
 return _M;
--- a/plugins/mod_register.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_register.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -7,288 +7,10 @@
 --
 
 
-local st = require "util.stanza";
-local dataform_new = require "util.dataforms".new;
-local usermanager_user_exists = require "core.usermanager".user_exists;
-local usermanager_create_user = require "core.usermanager".create_user;
-local usermanager_set_password = require "core.usermanager".set_password;
-local usermanager_delete_user = require "core.usermanager".delete_user;
-local nodeprep = require "util.encodings".stringprep.nodeprep;
-local jid_bare = require "util.jid".bare;
-local create_throttle = require "util.throttle".create;
-local new_cache = require "util.cache".new;
-
-local compat = module:get_option_boolean("registration_compat", true);
 local allow_registration = module:get_option_boolean("allow_registration", false);
-local additional_fields = module:get_option("additional_registration_fields", {});
-local require_encryption = module:get_option("c2s_require_encryption") or module:get_option("require_encryption");
-
-local account_details = module:open_store("account_details");
-
-local field_map = {
-	username = { name = "username", type = "text-single", label = "Username", required = true };
-	password = { name = "password", type = "text-private", label = "Password", required = true };
-	nick = { name = "nick", type = "text-single", label = "Nickname" };
-	name = { name = "name", type = "text-single", label = "Full Name" };
-	first = { name = "first", type = "text-single", label = "Given Name" };
-	last = { name = "last", type = "text-single", label = "Family Name" };
-	email = { name = "email", type = "text-single", label = "Email" };
-	address = { name = "address", type = "text-single", label = "Street" };
-	city = { name = "city", type = "text-single", label = "City" };
-	state = { name = "state", type = "text-single", label = "State" };
-	zip = { name = "zip", type = "text-single", label = "Postal code" };
-	phone = { name = "phone", type = "text-single", label = "Telephone number" };
-	url = { name = "url", type = "text-single", label = "Webpage" };
-	date = { name = "date", type = "text-single", label = "Birth date" };
-};
-
-local title = module:get_option_string("registration_title",
-	"Creating a new account");
-local instructions = module:get_option_string("registration_instructions",
-	"Choose a username and password for use with this service.");
-
-local registration_form = dataform_new{
-	title = title;
-	instructions = instructions;
-
-	field_map.username;
-	field_map.password;
-};
-
-local registration_query = st.stanza("query", {xmlns = "jabber:iq:register"})
-	:tag("instructions"):text(instructions):up()
-	:tag("username"):up()
-	:tag("password"):up();
-
-for _, field in ipairs(additional_fields) do
-	if type(field) == "table" then
-		registration_form[#registration_form + 1] = field;
-	elseif field_map[field] or field_map[field:sub(1, -2)] then
-		if field:match("%+$") then
-			field = field:sub(1, -2);
-			field_map[field].required = true;
-		end
-
-		registration_form[#registration_form + 1] = field_map[field];
-		registration_query:tag(field):up();
-	else
-		module:log("error", "Unknown field %q", field);
-	end
-end
-registration_query:add_child(registration_form:form());
 
-module:add_feature("jabber:iq:register");
-
-local register_stream_feature = st.stanza("register", {xmlns="http://jabber.org/features/iq-register"}):up();
-module:hook("stream-features", function(event)
-	local session, features = event.origin, event.features;
-
-	-- Advertise registration to unauthorized clients only.
-	if not(allow_registration) or session.type ~= "c2s_unauthed" or (require_encryption and not session.secure) then
-		return
-	end
-
-	features:add_child(register_stream_feature);
-end);
-
--- Password change and account deletion handler
-local function handle_registration_stanza(event)
-	local session, stanza = event.origin, event.stanza;
-	local log = session.log or module._log;
-
-	local query = stanza.tags[1];
-	if stanza.attr.type == "get" then
-		local reply = st.reply(stanza);
-		reply:tag("query", {xmlns = "jabber:iq:register"})
-			:tag("registered"):up()
-			:tag("username"):text(session.username):up()
-			:tag("password"):up();
-		session.send(reply);
-	else -- stanza.attr.type == "set"
-		if query.tags[1] and query.tags[1].name == "remove" then
-			local username, host = session.username, session.host;
-
-			-- This one weird trick sends a reply to this stanza before the user is deleted
-			local old_session_close = session.close;
-			session.close = function(self, ...)
-				self.send(st.reply(stanza));
-				return old_session_close(self, ...);
-			end
-
-			local ok, err = usermanager_delete_user(username, host);
-
-			if not ok then
-				log("debug", "Removing user account %s@%s failed: %s", username, host, err);
-				session.close = old_session_close;
-				session.send(st.error_reply(stanza, "cancel", "service-unavailable", err));
-				return true;
-			end
-
-			log("info", "User removed their account: %s@%s", username, host);
-			module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = session });
-		else
-			local username = nodeprep(query:get_child_text("username"));
-			local password = query:get_child_text("password");
-			if username and password then
-				if username == session.username then
-					if usermanager_set_password(username, password, session.host, session.resource) then
-						session.send(st.reply(stanza));
-					else
-						-- TODO unable to write file, file may be locked, etc, what's the correct error?
-						session.send(st.error_reply(stanza, "wait", "internal-server-error"));
-					end
-				else
-					session.send(st.error_reply(stanza, "modify", "bad-request"));
-				end
-			else
-				session.send(st.error_reply(stanza, "modify", "bad-request"));
-			end
-		end
-	end
-	return true;
+if allow_registration then
+	module:depends("register_ibr");
 end
 
-module:hook("iq/self/jabber:iq:register:query", handle_registration_stanza);
-if compat then
-	module:hook("iq/host/jabber:iq:register:query", function (event)
-		local session, stanza = event.origin, event.stanza;
-		if session.type == "c2s" and jid_bare(stanza.attr.to) == session.host then
-			return handle_registration_stanza(event);
-		end
-	end);
-end
-
-local function parse_response(query)
-	local form = query:get_child("x", "jabber:x:data");
-	if form then
-		return registration_form:data(form);
-	else
-		local data = {};
-		local errors = {};
-		for _, field in ipairs(registration_form) do
-			local name, required = field.name, field.required;
-			if field_map[name] then
-				data[name] = query:get_child_text(name);
-				if (not data[name] or #data[name] == 0) and required then
-					errors[name] = "Required value missing";
-				end
-			end
-		end
-		if next(errors) then
-			return data, errors;
-		end
-		return data;
-	end
-end
-
-local min_seconds_between_registrations = module:get_option_number("min_seconds_between_registrations");
-local whitelist_only = module:get_option_boolean("whitelist_registration_only");
-local whitelisted_ips = module:get_option_set("registration_whitelist", { "127.0.0.1", "::1" })._items;
-local blacklisted_ips = module:get_option_set("registration_blacklist", {})._items;
-
-local throttle_max = module:get_option_number("registration_throttle_max", min_seconds_between_registrations and 1);
-local throttle_period = module:get_option_number("registration_throttle_period", min_seconds_between_registrations);
-local throttle_cache_size = module:get_option_number("registration_throttle_cache_size", 100);
-local blacklist_overflow = module:get_option_boolean("blacklist_on_registration_throttle_overload", false);
-
-local throttle_cache = new_cache(throttle_cache_size, blacklist_overflow and function (ip, throttle)
-	if not throttle:peek() then
-		module:log("info", "Adding ip %s to registration blacklist", ip);
-		blacklisted_ips[ip] = true;
-	end
-end or nil);
-
-local function check_throttle(ip)
-	if not throttle_max then return true end
-	local throttle = throttle_cache:get(ip);
-	if not throttle then
-		throttle = create_throttle(throttle_max, throttle_period);
-	end
-	throttle_cache:set(ip, throttle);
-	return throttle:poll(1);
-end
-
--- In-band registration
-module:hook("stanza/iq/jabber:iq:register:query", function(event)
-	local session, stanza = event.origin, event.stanza;
-	local log = session.log or module._log;
-
-	if not(allow_registration) or session.type ~= "c2s_unauthed" then
-		log("debug", "Attempted registration when disabled or already authenticated");
-		session.send(st.error_reply(stanza, "cancel", "service-unavailable"));
-	elseif require_encryption and not session.secure then
-		session.send(st.error_reply(stanza, "modify", "policy-violation", "Encryption is required"));
-	else
-		local query = stanza.tags[1];
-		if stanza.attr.type == "get" then
-			local reply = st.reply(stanza);
-			reply:add_child(registration_query);
-			session.send(reply);
-		elseif stanza.attr.type == "set" then
-			if query.tags[1] and query.tags[1].name == "remove" then
-				session.send(st.error_reply(stanza, "auth", "registration-required"));
-			else
-				local data, errors = parse_response(query);
-				if errors then
-					log("debug", "Error parsing registration form:");
-					for field, err in pairs(errors) do
-						log("debug", "Field %q: %s", field, err);
-					end
-					session.send(st.error_reply(stanza, "modify", "not-acceptable"));
-				else
-					-- Check that the user is not blacklisted or registering too often
-					if not session.ip then
-						log("debug", "User's IP not known; can't apply blacklist/whitelist");
-					elseif blacklisted_ips[session.ip] or (whitelist_only and not whitelisted_ips[session.ip]) then
-						session.send(st.error_reply(stanza, "cancel", "not-acceptable", "You are not allowed to register an account."));
-						return true;
-					elseif throttle_max and not whitelisted_ips[session.ip] then
-						if not check_throttle(session.ip) then
-							log("debug", "Registrations over limit for ip %s", session.ip or "?");
-							session.send(st.error_reply(stanza, "wait", "not-acceptable"));
-							return true;
-						end
-					end
-					local username, password = nodeprep(data.username), data.password;
-					data.username, data.password = nil, nil;
-					local host = module.host;
-					if not username or username == "" then
-						log("debug", "The requested username is invalid.");
-						session.send(st.error_reply(stanza, "modify", "not-acceptable", "The requested username is invalid."));
-						return true;
-					end
-					local user = { username = username , host = host, additional = data, allowed = true }
-					module:fire_event("user-registering", user);
-					if not user.allowed then
-						log("debug", "Registration disallowed by module");
-						session.send(st.error_reply(stanza, "modify", "not-acceptable", "The requested username is forbidden."));
-					elseif usermanager_user_exists(username, host) then
-						log("debug", "Attempt to register with existing username");
-						session.send(st.error_reply(stanza, "cancel", "conflict", "The requested username already exists."));
-					else
-						-- TODO unable to write file, file may be locked, etc, what's the correct error?
-						local error_reply = st.error_reply(stanza, "wait", "internal-server-error", "Failed to write data to disk.");
-						if usermanager_create_user(username, password, host) then
-							data.registered = os.time();
-							if not account_details:set(username, data) then
-								log("debug", "Could not store extra details");
-								usermanager_delete_user(username, host);
-								session.send(error_reply);
-								return true;
-							end
-							session.send(st.reply(stanza)); -- user created!
-							log("info", "User account created: %s@%s", username, host);
-							module:fire_event("user-registered", {
-								username = username, host = host, source = "mod_register",
-								session = session });
-						else
-							log("debug", "Could not create user");
-							session.send(error_reply);
-						end
-					end
-				end
-			end
-		end
-	end
-	return true;
-end);
+module:depends("user_account_management");
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_register_ibr.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,199 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+
+local st = require "util.stanza";
+local dataform_new = require "util.dataforms".new;
+local usermanager_user_exists = require "core.usermanager".user_exists;
+local usermanager_create_user = require "core.usermanager".create_user;
+local usermanager_delete_user = require "core.usermanager".delete_user;
+local nodeprep = require "util.encodings".stringprep.nodeprep;
+
+local additional_fields = module:get_option("additional_registration_fields", {});
+local require_encryption = module:get_option_boolean("c2s_require_encryption",
+	module:get_option_boolean("require_encryption", false));
+
+pcall(function ()
+	module:depends("register_limits");
+end);
+
+local account_details = module:open_store("account_details");
+
+local field_map = {
+	username = { name = "username", type = "text-single", label = "Username", required = true };
+	password = { name = "password", type = "text-private", label = "Password", required = true };
+	nick = { name = "nick", type = "text-single", label = "Nickname" };
+	name = { name = "name", type = "text-single", label = "Full Name" };
+	first = { name = "first", type = "text-single", label = "Given Name" };
+	last = { name = "last", type = "text-single", label = "Family Name" };
+	email = { name = "email", type = "text-single", label = "Email" };
+	address = { name = "address", type = "text-single", label = "Street" };
+	city = { name = "city", type = "text-single", label = "City" };
+	state = { name = "state", type = "text-single", label = "State" };
+	zip = { name = "zip", type = "text-single", label = "Postal code" };
+	phone = { name = "phone", type = "text-single", label = "Telephone number" };
+	url = { name = "url", type = "text-single", label = "Webpage" };
+	date = { name = "date", type = "text-single", label = "Birth date" };
+};
+
+local title = module:get_option_string("registration_title",
+	"Creating a new account");
+local instructions = module:get_option_string("registration_instructions",
+	"Choose a username and password for use with this service.");
+
+local registration_form = dataform_new{
+	title = title;
+	instructions = instructions;
+
+	field_map.username;
+	field_map.password;
+};
+
+local registration_query = st.stanza("query", {xmlns = "jabber:iq:register"})
+	:tag("instructions"):text(instructions):up()
+	:tag("username"):up()
+	:tag("password"):up();
+
+for _, field in ipairs(additional_fields) do
+	if type(field) == "table" then
+		registration_form[#registration_form + 1] = field;
+	elseif field_map[field] or field_map[field:sub(1, -2)] then
+		if field:match("%+$") then
+			field = field:sub(1, -2);
+			field_map[field].required = true;
+		end
+
+		registration_form[#registration_form + 1] = field_map[field];
+		registration_query:tag(field):up();
+	else
+		module:log("error", "Unknown field %q", field);
+	end
+end
+registration_query:add_child(registration_form:form());
+
+local register_stream_feature = st.stanza("register", {xmlns="http://jabber.org/features/iq-register"}):up();
+module:hook("stream-features", function(event)
+	local session, features = event.origin, event.features;
+
+	-- Advertise registration to unauthorized clients only.
+	if session.type ~= "c2s_unauthed" or (require_encryption and not session.secure) then
+		return
+	end
+
+	features:add_child(register_stream_feature);
+end);
+
+local function parse_response(query)
+	local form = query:get_child("x", "jabber:x:data");
+	if form then
+		return registration_form:data(form);
+	else
+		local data = {};
+		local errors = {};
+		for _, field in ipairs(registration_form) do
+			local name, required = field.name, field.required;
+			if field_map[name] then
+				data[name] = query:get_child_text(name);
+				if (not data[name] or #data[name] == 0) and required then
+					errors[name] = "Required value missing";
+				end
+			end
+		end
+		if next(errors) then
+			return data, errors;
+		end
+		return data;
+	end
+end
+
+-- In-band registration
+module:hook("stanza/iq/jabber:iq:register:query", function(event)
+	local session, stanza = event.origin, event.stanza;
+	local log = session.log or module._log;
+
+	if session.type ~= "c2s_unauthed" then
+		log("debug", "Attempted registration when disabled or already authenticated");
+		session.send(st.error_reply(stanza, "cancel", "service-unavailable"));
+		return true;
+	end
+
+	if require_encryption and not session.secure then
+		session.send(st.error_reply(stanza, "modify", "policy-violation", "Encryption is required"));
+		return true;
+	end
+
+	local query = stanza.tags[1];
+	if stanza.attr.type == "get" then
+		local reply = st.reply(stanza);
+		reply:add_child(registration_query);
+		session.send(reply);
+		return true;
+	end
+
+	-- stanza.attr.type == "set"
+	if query.tags[1] and query.tags[1].name == "remove" then
+		session.send(st.error_reply(stanza, "auth", "registration-required"));
+		return true;
+	end
+
+	local data, errors = parse_response(query);
+	if errors then
+		log("debug", "Error parsing registration form:");
+		local textual_errors = {};
+		for field, err in pairs(errors) do
+			log("debug", "Field %q: %s", field, err);
+			table.insert(textual_errors, ("%s: %s"):format(field:gsub("^%a", string.upper), err));
+		end
+		session.send(st.error_reply(stanza, "modify", "not-acceptable", table.concat(textual_errors, "\n")));
+		return true;
+	end
+
+	local username, password = nodeprep(data.username), data.password;
+	data.username, data.password = nil, nil;
+	local host = module.host;
+	if not username or username == "" then
+		log("debug", "The requested username is invalid.");
+		session.send(st.error_reply(stanza, "modify", "not-acceptable", "The requested username is invalid."));
+		return true;
+	end
+
+	local user = { username = username, password = password, host = host, additional = data, ip = session.ip, session = session, allowed = true }
+	module:fire_event("user-registering", user);
+	if not user.allowed then
+		log("debug", "Registration disallowed by module: %s", user.reason or "no reason given");
+		session.send(st.error_reply(stanza, "modify", "not-acceptable", user.reason));
+		return true;
+	end
+
+	if usermanager_user_exists(username, host) then
+		log("debug", "Attempt to register with existing username");
+		session.send(st.error_reply(stanza, "cancel", "conflict", "The requested username already exists."));
+		return true;
+	end
+
+	-- TODO unable to write file, file may be locked, etc, what's the correct error?
+	local error_reply = st.error_reply(stanza, "wait", "internal-server-error", "Failed to write data to disk.");
+	if usermanager_create_user(username, password, host) then
+		data.registered = os.time();
+		if not account_details:set(username, data) then
+			log("debug", "Could not store extra details");
+			usermanager_delete_user(username, host);
+			session.send(error_reply);
+			return true;
+		end
+		session.send(st.reply(stanza)); -- user created!
+		log("info", "User account created: %s@%s", username, host);
+		module:fire_event("user-registered", {
+			username = username, host = host, source = "mod_register",
+			session = session });
+	else
+		log("debug", "Could not create user");
+		session.send(error_reply);
+	end
+	return true;
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_register_limits.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,78 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+
+local create_throttle = require "util.throttle".create;
+local new_cache = require "util.cache".new;
+local ip_util = require "util.ip";
+local new_ip = ip_util.new_ip;
+local match_ip = ip_util.match;
+local parse_cidr = ip_util.parse_cidr;
+
+local min_seconds_between_registrations = module:get_option_number("min_seconds_between_registrations");
+local whitelist_only = module:get_option_boolean("whitelist_registration_only");
+local whitelisted_ips = module:get_option_set("registration_whitelist", { "127.0.0.1", "::1" })._items;
+local blacklisted_ips = module:get_option_set("registration_blacklist", {})._items;
+
+local throttle_max = module:get_option_number("registration_throttle_max", min_seconds_between_registrations and 1);
+local throttle_period = module:get_option_number("registration_throttle_period", min_seconds_between_registrations);
+local throttle_cache_size = module:get_option_number("registration_throttle_cache_size", 100);
+local blacklist_overflow = module:get_option_boolean("blacklist_on_registration_throttle_overload", false);
+
+local throttle_cache = new_cache(throttle_cache_size, blacklist_overflow and function (ip, throttle)
+	if not throttle:peek() then
+		module:log("info", "Adding ip %s to registration blacklist", ip);
+		blacklisted_ips[ip] = true;
+	end
+end or nil);
+
+local function check_throttle(ip)
+	if not throttle_max then return true end
+	local throttle = throttle_cache:get(ip);
+	if not throttle then
+		throttle = create_throttle(throttle_max, throttle_period);
+	end
+	throttle_cache:set(ip, throttle);
+	return throttle:poll(1);
+end
+
+local function ip_in_set(set, ip)
+	if set[ip] then
+		return true;
+	end
+	ip = new_ip(ip);
+	for in_set in pairs(set) do
+		if match_ip(ip, parse_cidr(in_set)) then
+			return true;
+		end
+	end
+	return false;
+end
+
+module:hook("user-registering", function (event)
+	local session = event.session;
+	local ip = event.ip or session and session.ip;
+	local log = session and session.log or module._log;
+	if not ip then
+		log("warn", "IP not known; can't apply blacklist/whitelist");
+	elseif ip_in_set(blacklisted_ips, ip) then
+		log("debug", "Registration disallowed by blacklist");
+		event.allowed = false;
+		event.reason = "Your IP address is blacklisted";
+	elseif (whitelist_only and not ip_in_set(whitelisted_ips, ip)) then
+		log("debug", "Registration disallowed by whitelist");
+		event.allowed = false;
+		event.reason = "Your IP address is not whitelisted";
+	elseif throttle_max and not ip_in_set(whitelisted_ips, ip) then
+		if not check_throttle(ip) then
+			log("debug", "Registrations over limit for ip %s", ip or "?");
+			event.allowed = false;
+			event.reason = "Too many registrations from this IP address recently";
+		end
+	end
+end);
--- a/plugins/mod_roster.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_roster.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -11,9 +11,8 @@
 
 local jid_split = require "util.jid".split;
 local jid_prep = require "util.jid".prep;
-local t_concat = table.concat;
 local tonumber = tonumber;
-local pairs, ipairs = pairs, ipairs;
+local pairs = pairs;
 
 local rm_load_roster = require "core.rostermanager".load_roster;
 local rm_remove_from_roster = require "core.rostermanager".remove_from_roster;
@@ -51,7 +50,7 @@
 						name = item.name,
 					});
 					for group in pairs(item.groups) do
-						roster:tag("group"):text(group):up();
+						roster:text_tag("group", group);
 					end
 					roster:up(); -- move out from item
 				end
@@ -96,12 +95,10 @@
 						else
 							r_item.subscription = "none";
 						end
-						for _, child in ipairs(item) do
-							if child.name == "group" then
-								local text = t_concat(child);
-								if text and text ~= "" then
-									r_item.groups[text] = true;
-								end
+						for group in item:childtags("group") do
+							local text = group:get_text();
+							if text then
+								r_item.groups[text] = true;
 							end
 						end
 						local success, err_type, err_cond, err_msg = rm_add_to_roster(session, jid, r_item);
--- a/plugins/mod_s2s/mod_s2s.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_s2s/mod_s2s.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -14,7 +14,7 @@
 
 local tostring, type = tostring, type;
 local t_insert = table.insert;
-local xpcall, traceback = xpcall, debug.traceback;
+local traceback = debug.traceback;
 
 local add_task = require "util.timer".add_task;
 local st = require "util.stanza";
@@ -26,6 +26,7 @@
 local s2s_destroy_session = require "core.s2smanager".destroy_session;
 local uuid_gen = require "util.uuid".generate;
 local fire_global_event = prosody.events.fire_event;
+local runner = require "util.async".runner;
 
 local s2sout = module:require("s2sout");
 
@@ -38,17 +39,25 @@
 local require_encryption = module:get_option_boolean("s2s_require_encryption", false);
 
 local measure_connections = module:measure("connections", "amount");
+local measure_ipv6 = module:measure("ipv6", "amount");
 
 local sessions = module:shared("sessions");
 
+local runner_callbacks = {};
+
 local log = module._log;
 
 module:hook("stats-update", function ()
 	local count = 0;
-	for _ in pairs(sessions) do
+	local ipv6 = 0;
+	for _, session in pairs(sessions) do
 		count = count + 1;
+		if session.ip and session.ip:match(":") then
+			ipv6 = ipv6 + 1;
+		end
 	end
 	measure_connections(count);
+	measure_ipv6(ipv6);
 end);
 
 --- Handle stanzas to remote domains
@@ -57,19 +66,22 @@
 local function bounce_sendq(session, reason)
 	local sendq = session.sendq;
 	if not sendq then return; end
-	session.log("info", "Sending error replies for "..#sendq.." queued stanzas because of failed outgoing connection to "..tostring(session.to_host));
+	session.log("info", "Sending error replies for %d queued stanzas because of failed outgoing connection to %s", #sendq, session.to_host);
 	local dummy = {
 		type = "s2sin";
-		send = function(s)
+		send = function ()
 			(session.log or log)("error", "Replying to to an s2s error reply, please report this! Traceback: %s", traceback());
 		end;
 		dummy = true;
+		close = function ()
+			(session.log or log)("error", "Attempting to close the dummy origin of s2s error replies, please report this! Traceback: %s", traceback());
+		end;
 	};
 	for i, data in ipairs(sendq) do
 		local reply = data[2];
 		if reply and not(reply.attr.xmlns) and bouncy_stanzas[reply.name] then
 			reply.attr.type = "error";
-			reply:tag("error", {type = "cancel"})
+			reply:tag("error", {type = "cancel", by = session.from_host})
 				:tag("remote-server-not-found", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"}):up();
 			if reason then
 				reply:tag("text", {xmlns = "urn:ietf:params:xml:ns:xmpp-stanzas"})
@@ -100,8 +112,16 @@
 			(host.log or log)("debug", "trying to send over unauthed s2sout to "..to_host);
 
 			-- Queue stanza until we are able to send it
-			if host.sendq then t_insert(host.sendq, {tostring(stanza), stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza)});
-			else host.sendq = { {tostring(stanza), stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza)} }; end
+			local queued_item = {
+				tostring(stanza),
+				stanza.attr.type ~= "error" and stanza.attr.type ~= "result" and st.reply(stanza);
+			};
+			if host.sendq then
+				t_insert(host.sendq, queued_item);
+			else
+				-- luacheck: ignore 122
+				host.sendq = { queued_item };
+			end
 			host.log("debug", "stanza [%s] queued ", stanza.name);
 			return true;
 		elseif host.type == "local" or host.type == "component" then
@@ -113,7 +133,7 @@
 			-- FIXME
 			if host.from_host ~= from_host then
 				log("error", "WARNING! This might, possibly, be a bug, but it might not...");
-				log("error", "We are going to send from %s instead of %s", tostring(host.from_host), tostring(from_host));
+				log("error", "We are going to send from %s instead of %s", host.from_host, from_host);
 			end
 			if host.sends2s(stanza) then
 				return true;
@@ -149,14 +169,14 @@
 
 function module.add_host(module)
 	if module:get_option_boolean("disallow_s2s", false) then
-		module:log("warn", "The 'disallow_s2s' config option is deprecated, please see http://prosody.im/doc/s2s#disabling");
+		module:log("warn", "The 'disallow_s2s' config option is deprecated, please see https://prosody.im/doc/s2s#disabling");
 		return nil, "This host has disallow_s2s set";
 	end
 	module:hook("route/remote", route_to_existing_session, -1);
 	module:hook("route/remote", route_to_new_session, -10);
 	module:hook("s2s-authenticated", make_authenticated, -1);
 	module:hook("s2s-read-timeout", keepalive, -1);
-	module:hook_stanza("http://etherx.jabber.org/streams", "features", function (session, stanza)
+	module:hook_stanza("http://etherx.jabber.org/streams", "features", function (session, stanza) -- luacheck: ignore 212/stanza
 		if session.type == "s2sout" then
 			-- Stream is authenticated and we are seem to be done with feature negotiation,
 			-- so the stream is ready for stanzas.  RFC 6120 Section 4.3
@@ -265,11 +285,21 @@
 
 --- XMPP stream event handlers
 
-local stream_callbacks = { default_ns = "jabber:server", handlestanza =  core_process_stanza };
+local stream_callbacks = { default_ns = "jabber:server" };
+
+function stream_callbacks.handlestanza(session, stanza)
+	stanza = session.filter("stanzas/in", stanza);
+	session.thread:run(stanza);
+end
 
 local xmlns_xmpp_streams = "urn:ietf:params:xml:ns:xmpp-streams";
 
 function stream_callbacks.streamopened(session, attr)
+	-- run _streamopened in async context
+	session.thread:run({ attr = attr });
+end
+
+function stream_callbacks._streamopened(session, attr)
 	session.version = tonumber(attr.version) or 0;
 
 	-- TODO: Rename session.secure to session.encrypted
@@ -364,7 +394,7 @@
 			end
 
 			if ( session.type == "s2sin" or session.type == "s2sout" ) or features.tags[1] then
-				log("debug", "Sending stream features: %s", tostring(features));
+				log("debug", "Sending stream features: %s", features);
 				session.sends2s(features);
 			else
 				(session.log or log)("warn", "No stream features to offer, giving up");
@@ -421,7 +451,7 @@
 		session.log("debug", "Invalid opening stream header (%s)", (data:gsub("^([^\1]+)\1", "{%1}")));
 		session:close("invalid-namespace");
 	elseif error == "parse-error" then
-		session.log("debug", "Server-to-server XML parse error: %s", tostring(error));
+		session.log("debug", "Server-to-server XML parse error: %s", error);
 		session:close("not-well-formed");
 	elseif error == "stream-error" then
 		local condition, text = "undefined-condition";
@@ -441,14 +471,6 @@
 	end
 end
 
-local function handleerr(err) log("error", "Traceback[s2s]: %s", traceback(tostring(err), 2)); end
-function stream_callbacks.handlestanza(session, stanza)
-	stanza = session.filter("stanzas/in", stanza);
-	if stanza then
-		return xpcall(function () return core_process_stanza(session, stanza) end, handleerr);
-	end
-end
-
 local listener = {};
 
 --- Session methods
@@ -476,10 +498,13 @@
 					if reason.extra then
 						stanza:add_child(reason.extra);
 					end
-					log("debug", "Disconnecting %s[%s], <stream:error> is: %s", session.host or session.ip or "(unknown host)", session.type, tostring(stanza));
+					log("debug", "Disconnecting %s[%s], <stream:error> is: %s",
+					session.host or session.ip or "(unknown host)", session.type, stanza);
 					session.sends2s(stanza);
 				elseif reason.name then -- a stanza
-					log("debug", "Disconnecting %s->%s[%s], <stream:error> is: %s", session.from_host or "(unknown host)", session.to_host or "(unknown host)", session.type, tostring(reason));
+					log("debug", "Disconnecting %s->%s[%s], <stream:error> is: %s",
+						session.from_host or "(unknown host)", session.to_host or "(unknown host)",
+						session.type, reason);
 					session.sends2s(reason);
 				end
 			end
@@ -488,8 +513,11 @@
 		session.sends2s("</stream:stream>");
 		function session.sends2s() return false; end
 
+		-- luacheck: ignore 422/reason
+		-- FIXME reason should be managed in a place common to c2s, s2s, bosh, component etc
 		local reason = remote_reason or (reason and (reason.text or reason.condition)) or reason;
-		session.log("info", "%s s2s stream %s->%s closed: %s", session.direction:gsub("^.", string.upper), session.from_host or "(unknown host)", session.to_host or "(unknown host)", reason or "stream closed");
+		session.log("info", "%s s2s stream %s->%s closed: %s", session.direction:gsub("^.", string.upper),
+			session.from_host or "(unknown host)", session.to_host or "(unknown host)", reason or "stream closed");
 
 		-- Authenticated incoming stream may still be sending us stanzas, so wait for </stream:stream> from remote
 		local conn = session.conn;
@@ -508,7 +536,7 @@
 	end
 end
 
-function session_stream_attrs(session, from, to, attr)
+function session_stream_attrs(session, from, to, attr) -- luacheck: ignore 212/session
 	if not from or (hosts[from] and hosts[from].modules.dialback) then
 		attr["xmlns:db"] = 'jabber:server:dialback';
 	end
@@ -523,6 +551,15 @@
 -- Session initialization logic shared by incoming and outgoing
 local function initialize_session(session)
 	local stream = new_xmpp_stream(session, stream_callbacks);
+
+	session.thread = runner(function (stanza)
+		if stanza.name == nil then
+			stream_callbacks._streamopened(session, stanza.attr);
+		else
+			core_process_stanza(session, stanza);
+		end
+	end, runner_callbacks, session);
+
 	local log = session.log or log;
 	session.stream = stream;
 
@@ -567,7 +604,7 @@
 	session.close = session_close;
 
 	local handlestanza = stream_callbacks.handlestanza;
-	function session.dispatch_stanza(session, stanza)
+	function session.dispatch_stanza(session, stanza) -- luacheck: ignore 432/session
 		return handlestanza(session, stanza);
 	end
 
@@ -586,6 +623,20 @@
 	end);
 end
 
+function runner_callbacks:ready()
+	self.data.log("debug", "Runner %s ready (%s)", self.thread, coroutine.status(self.thread));
+	self.data.conn:resume();
+end
+
+function runner_callbacks:waiting()
+	self.data.log("debug", "Runner %s waiting (%s)", self.thread, coroutine.status(self.thread));
+	self.data.conn:pause();
+end
+
+function runner_callbacks:error(err)
+	(self.data.log or log)("error", "Traceback[s2s]: %s", err);
+end
+
 function listener.onconnect(conn)
 	conn:setoption("keepalive", opt_keepalives);
 	local session = sessions[conn];
@@ -627,7 +678,7 @@
 				return; -- Session lives for now
 			end
 		end
-		(session.log or log)("debug", "s2s disconnected: %s->%s (%s)", tostring(session.from_host), tostring(session.to_host), tostring(err or "connection closed"));
+		(session.log or log)("debug", "s2s disconnected: %s->%s (%s)", session.from_host, session.to_host, err or "connection closed");
 		s2s_destroy_session(session, err);
 	end
 end
--- a/plugins/mod_s2s/s2sout.lib.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_s2s/s2sout.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -8,6 +8,8 @@
 
 --- Module containing all the logic for connecting to a remote server
 
+-- luacheck: ignore 432/err
+
 local portmanager = require "core.portmanager";
 local wrapclient = require "net.server".wrapclient;
 local initialize_filters = require "util.filters".initialize;
@@ -16,7 +18,6 @@
 local rfc6724_dest = require "util.rfc6724".destination;
 local socket = require "socket";
 local adns = require "net.adns";
-local dns = require "net.dns";
 local t_insert, t_sort, ipairs = table.insert, table.sort, ipairs;
 local local_addresses = require "util.net".local_addresses;
 
@@ -30,6 +31,7 @@
 local has_ipv4, has_ipv6;
 
 local dns_timeout = module:get_option_number("dns_timeout", 15);
+local resolvers = module:get_option_set("s2s_dns_resolvers")
 
 local s2sout = {};
 
@@ -45,11 +47,18 @@
 end
 
 function s2sout.initiate_connection(host_session)
+	local log = host_session.log or log;
+
 	initialize_filters(host_session);
 	host_session.version = 1;
 
 	host_session.resolver = adns.resolver();
 	host_session.resolver._resolver:settimeout(dns_timeout);
+	if resolvers then
+		for resolver in resolvers do
+			host_session.resolver._resolver:addnameserver(resolver);
+		end
+	end
 
 	-- Kick the connection attempting machine into life
 	if not s2sout.attempt_connection(host_session) then
@@ -68,9 +77,9 @@
 				buffer = {};
 				host_session.send_buffer = buffer;
 			end
-			log("debug", "Buffering data on unconnected s2sout to %s", tostring(host_session.to_host));
+			log("debug", "Buffering data on unconnected s2sout to %s", host_session.to_host);
 			buffer[#buffer+1] = data;
-			log("debug", "Buffered item %d: %s", #buffer, tostring(data));
+			log("debug", "Buffered item %d: %s", #buffer, data);
 		end
 	end
 end
@@ -78,6 +87,7 @@
 function s2sout.attempt_connection(host_session, err)
 	local to_host = host_session.to_host;
 	local connect_host, connect_port = to_host and idna_to_ascii(to_host), 5269;
+	local log = host_session.log or log;
 
 	if not connect_host then
 		return false;
@@ -129,16 +139,16 @@
 		host_session.srv_choice = host_session.srv_choice + 1;
 		local srv_choice = host_session.srv_hosts[host_session.srv_choice];
 		connect_host, connect_port = srv_choice.target or to_host, srv_choice.port or connect_port;
-		host_session.log("info", "Connection failed (%s). Attempt #%d: This time to %s:%d", tostring(err), host_session.srv_choice, connect_host, connect_port);
+		host_session.log("info", "Connection failed (%s). Attempt #%d: This time to %s:%d", err, host_session.srv_choice, connect_host, connect_port);
 	else
-		host_session.log("info", "Failed in all attempts to connect to %s", tostring(host_session.to_host));
+		host_session.log("info", "Failed in all attempts to connect to %s", host_session.to_host);
 		-- We're out of options
 		return false;
 	end
 
 	if not (connect_host and connect_port) then
 		-- Likely we couldn't resolve DNS
-		log("warn", "Hmm, we're without a host (%s) and port (%s) to connect to for %s, giving up :(", tostring(connect_host), tostring(connect_port), tostring(to_host));
+		log("warn", "Hmm, we're without a host (%s) and port (%s) to connect to for %s, giving up :(", connect_host, connect_port, to_host);
 		return false;
 	end
 
@@ -160,10 +170,12 @@
 
 function s2sout.try_connect(host_session, connect_host, connect_port, err)
 	host_session.connecting = true;
+	local log = host_session.log or log;
 
 	if not err then
 		local IPs = {};
 		host_session.ip_hosts = IPs;
+		-- luacheck: ignore 231/handle4 231/handle6
 		local handle4, handle6;
 		local have_other_result = not(has_ipv4) or not(has_ipv6) or false;
 
@@ -246,6 +258,7 @@
 	elseif host_session.ip_hosts and #host_session.ip_hosts > host_session.ip_choice then -- Not our first attempt, and we also have IPs left to try
 		s2sout.try_next_ip(host_session);
 	else
+		log("debug", "Out of IP addresses, trying next SRV record (if any)");
 		host_session.ip_hosts = nil;
 		if not s2sout.attempt_connection(host_session, "out of IP addresses") then -- Retry if we can
 			log("debug", "No other records to try for %s - destroying", host_session.to_host);
@@ -259,7 +272,8 @@
 end
 
 function s2sout.make_connect(host_session, connect_host, connect_port)
-	(host_session.log or log)("debug", "Beginning new connection attempt to %s ([%s]:%d)", host_session.to_host, connect_host.addr, connect_port);
+	local log = host_session.log or log;
+	log("debug", "Beginning new connection attempt to %s ([%s]:%d)", host_session.to_host, connect_host.addr, connect_port);
 
 	-- Reset secure flag in case this is another
 	-- connection attempt after a failed STARTTLS
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_scansion_record.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,126 @@
+local names = { "Romeo", "Juliet", "Mercutio", "Tybalt", "Benvolio" };
+local devices = { "", "phone", "laptop", "tablet", "toaster", "fridge", "shoe" };
+local users = {};
+
+local filters = require "util.filters";
+local id = require "util.id";
+local dt = require "util.datetime";
+local dm = require "util.datamanager";
+local st = require "util.stanza";
+
+local record_id = id.medium():lower();
+local record_date = os.date("%Y%b%d"):lower();
+local header_file = dm.getpath(record_id, "scansion", record_date, "scs", true);
+local record_file = dm.getpath(record_id, "scansion", record_date, "log", true);
+
+local head = io.open(header_file, "w");
+local scan = io.open(record_file, "w+");
+
+local function record(string)
+	scan:write(string);
+end
+
+local function record_header(string)
+	head:write(string);
+end
+
+local function record_object(class, name, props)
+	head:write(("[%s] %s\n"):format(class, name));
+	for k,v in pairs(props) do
+		head:write(("\t%s: %s\n"):format(k, v));
+	end
+	head:write("\n");
+end
+
+local function record_event(session, event)
+	record(session.scansion_id.." "..event.."\n\n");
+end
+
+local function record_stanza(stanza, session, verb)
+	local flattened = tostring(stanza):gsub("><", ">\n\t<");
+	-- TODO Proper prettyprinting with indentation
+	record(session.scansion_id.." "..verb..":\n\t"..flattened.."\n\n");
+end
+
+local function record_stanza_in(stanza, session)
+	if stanza.attr.xmlns == nil then
+		local copy = st.clone(stanza);
+		copy.attr.from = nil;
+		record_stanza(copy, session, "sends")
+	end
+	return stanza;
+end
+
+local function record_stanza_out(stanza, session)
+	if stanza.attr.xmlns == nil then
+		if not (stanza.name == "iq" and stanza:get_child("bind", "urn:ietf:params:xml:ns:xmpp-bind")) then
+			local copy = st.clone(stanza);
+			if copy.attr.to == session.full_jid then
+				copy.attr.to = nil;
+			end
+			record_stanza(copy, session, "receives");
+		end
+	end
+	return stanza;
+end
+
+module:hook("resource-bind", function (event)
+	local session = event.session;
+	if not users[session.username] then
+		users[session.username] = {
+			character = table.remove(names, 1) or id.short();
+			devices = {};
+			n_devices = 0;
+		};
+	end
+	local user = users[session.username];
+	local device = user.devices[session.resource];
+	if not device then
+		user.n_devices = user.n_devices + 1;
+		device = devices[user.n_devices] or ("device"..id.short());
+		user.devices[session.resource] = device;
+	end
+	session.scansion_character = user.character;
+	session.scansion_device = device;
+	session.scansion_id = user.character..(device ~= "" and "'s "..device or device);
+
+	record_object("Client", session.scansion_id, {
+		jid = session.full_jid,
+		password = "password",
+	});
+
+	module:log("info", "Connected: %s", session.scansion_id);
+	record_event(session, "connects");
+
+	filters.add_filter(session, "stanzas/in", record_stanza_in);
+	filters.add_filter(session, "stanzas/out", record_stanza_out);
+end);
+
+module:hook("resource-unbind", function (event)
+	local session = event.session;
+	if session.scansion_id then
+		record_event(session, "disconnects");
+	end
+end)
+
+record_header("# mod_scansion_record on host '"..module.host.."' recording started "..dt.datetime().."\n\n");
+
+record[[
+-----
+
+]]
+
+module:hook_global("server-stopping", function ()
+	record("# recording ended on "..dt.datetime().."\n");
+	module:log("info", "Scansion recording available in %s", header_file);
+end);
+
+prosody.events.add_handler("server-cleanup", function ()
+	scan:seek("set", 0);
+	for line in scan:lines() do
+		head:write(line, "\n");
+	end
+	scan:close();
+	os.remove(record_file);
+	head:close()
+end);
--- a/plugins/mod_server_contact_info.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_server_contact_info.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -1,49 +1,29 @@
 -- XEP-0157: Contact Addresses for XMPP Services for Prosody
 --
--- Copyright (C) 2011-2016 Kim Alvefur
+-- Copyright (C) 2011-2018 Kim Alvefur
 --
--- This file is MIT/X11 licensed.
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
 --
 
-local t_insert = table.insert;
 local array = require "util.array";
-local df_new = require "util.dataforms".new;
 
 -- Source: http://xmpp.org/registrar/formtypes.html#http:--jabber.org-network-serverinfo
-local valid_types = {
-	abuse = true;
-	admin = true;
-	feedback = true;
-	sales = true;
-	security = true;
-	support = true;
-}
+local form_layout = require "util.dataforms".new({
+	{ var = "FORM_TYPE"; type = "hidden"; value = "http://jabber.org/network/serverinfo"; };
+	{ name = "abuse", var = "abuse-addresses", type = "list-multi" },
+	{ name = "admin", var = "admin-addresses", type = "list-multi" },
+	{ name = "feedback", var = "feedback-addresses", type = "list-multi" },
+	{ name = "sales", var = "sales-addresses", type = "list-multi" },
+	{ name = "security", var = "security-addresses", type = "list-multi" },
+	{ name = "support", var = "support-addresses", type = "list-multi" },
+});
 
-local contact_config = module:get_option("contact_info");
-if not contact_config or not next(contact_config) then -- we'll use admins from the config as default
-	local admins = module:get_option_inherited_set("admins", {});
-	if admins:empty() then
-		module:log("error", "No contact_info or admins set in config");
-		return -- Nothing to attach, so we'll just skip it.
-	end
-	module:log("info", "No contact_info in config, using admins as fallback");
-	contact_config = {
-		admin = array.collect( admins / function(admin) return "xmpp:" .. admin; end);
-	};
-end
+-- JIDs of configured service admins are used as fallback
+local admins = module:get_option_inherited_set("admins", {});
 
-local form_layout = {
-	{ value = "http://jabber.org/network/serverinfo"; type = "hidden"; name = "FORM_TYPE"; };
-};
-
-local form_values = {};
+local contact_config = module:get_option("contact_info", {
+	admin = array.collect( admins / function(admin) return "xmpp:" .. admin; end);
+});
 
-for t in pairs(valid_types) do
-	local addresses = contact_config[t];
-	if addresses then
-		t_insert(form_layout, { name = t .. "-addresses", type = "list-multi" });
-		form_values[t .. "-addresses"] = addresses;
-	end
-end
-
-module:add_extension(df_new(form_layout):form(form_values, "result"));
+module:add_extension(form_layout:form(contact_config, "result"));
--- a/plugins/mod_storage_internal.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_storage_internal.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -44,17 +44,36 @@
 driver.archive = { __index = archive };
 
 function archive:append(username, key, value, when, with)
-	key = key or id();
 	when = when or now();
 	if not st.is_stanza(value) then
 		return nil, "unsupported-datatype";
 	end
 	value = st.preserialize(st.clone(value));
-	value.key = key;
 	value.when = when;
 	value.with = with;
 	value.attr.stamp = datetime.datetime(when);
 	value.attr.stamp_legacy = datetime.legacy(when);
+
+	if key then
+		local items, err = datamanager.list_load(username, host, self.store);
+		if not items and err then return items, err; end
+		if items then
+			items = array(items);
+			items:filter(function (item)
+				return item.key ~= key;
+			end);
+			value.key = key;
+			items:push(value);
+			local ok, err = datamanager.list_store(username, host, self.store, items);
+			if not ok then return ok, err; end
+			return key;
+		end
+	else
+		key = id();
+	end
+
+	value.key = key;
+
 	local ok, err = datamanager.list_append(username, host, self.store, value);
 	if not ok then return ok, err; end
 	return key;
@@ -141,9 +160,6 @@
 	if not query or next(query) == nil then
 		return datamanager.list_store(username, host, self.store, nil);
 	end
-	for k in pairs(query) do
-		if k ~= "end" then return nil, "unsupported-query-field"; end
-	end
 	local items, err = datamanager.list_load(username, host, self.store);
 	if not items then
 		if err then
@@ -154,10 +170,48 @@
 	end
 	items = array(items);
 	local count_before = #items;
-	items:filter(function (item)
-		return item.when > query["end"];
-	end);
+	if query then
+		if query.key then
+			items:filter(function (item)
+				return item.key ~= query.key;
+			end);
+		end
+		if query.with then
+			items:filter(function (item)
+				return item.with ~= query.with;
+			end);
+		end
+		if query.start then
+			items:filter(function (item)
+				return item.when < query.start;
+			end);
+		end
+		if query["end"] then
+			items:filter(function (item)
+				return item.when > query["end"];
+			end);
+		end
+		if query.truncate and #items > query.truncate then
+			if query.reverse then
+				-- Before: { 1, 2, 3, 4, 5, }
+				-- After: { 1, 2, 3 }
+				for i = #items, query.truncate + 1, -1 do
+					items[i] = nil;
+				end
+			else
+				-- Before: { 1, 2, 3, 4, 5, }
+				-- After: { 3, 4, 5 }
+				local offset = #items - query.truncate;
+				for i = 1, #items do
+					items[i] = items[i+offset];
+				end
+			end
+		end
+	end
 	local count = count_before - #items;
+	if count == 0 then
+		return 0; -- No changes, skip write
+	end
 	local ok, err = datamanager.list_store(username, host, self.store, items);
 	if not ok then return ok, err; end
 	return count;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_storage_memory.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,227 @@
+local serialize = require "util.serialization".serialize;
+local array = require "util.array";
+local envload = require "util.envload".envload;
+local st = require "util.stanza";
+local is_stanza = st.is_stanza or function (s) return getmetatable(s) == st.stanza_mt end
+
+local auto_purge_enabled = module:get_option_boolean("storage_memory_temporary", false);
+local auto_purge_stores = module:get_option_set("storage_memory_temporary_stores", {});
+
+local memory = setmetatable({}, {
+	__index = function(t, k)
+		local store = module:shared(k)
+		t[k] = store;
+		return store;
+	end
+});
+
+local function NULL() return nil end
+
+local function _purge_store(self, username)
+	self.store[username or NULL] = nil;
+	return true;
+end
+
+local keyval_store = {};
+keyval_store.__index = keyval_store;
+
+function keyval_store:get(username)
+	return (self.store[username or NULL] or NULL)();
+end
+
+function keyval_store:set(username, data)
+	if data ~= nil then
+		data = envload("return "..serialize(data), "=(data)", {});
+	end
+	self.store[username or NULL] = data;
+	return true;
+end
+
+keyval_store.purge = _purge_store;
+
+local archive_store = {};
+archive_store.__index = archive_store;
+
+function archive_store:append(username, key, value, when, with)
+	if is_stanza(value) then
+		value = st.preserialize(value);
+		value = envload("return xml"..serialize(value), "=(stanza)", { xml = st.deserialize })
+	else
+		value = envload("return "..serialize(value), "=(data)", {});
+	end
+	local a = self.store[username or NULL];
+	if not a then
+		a = {};
+		self.store[username or NULL] = a;
+	end
+	local v = { key = key, when = when, with = with, value = value };
+	if not key then
+		key = tostring(a):match"%x+$"..tostring(v):match"%x+$";
+		v.key = key;
+	end
+	if a[key] then
+		table.remove(a, a[key]);
+	end
+	local i = #a+1;
+	a[i] = v;
+	a[key] = i;
+	return key;
+end
+
+local function archive_iter (a, start, stop, step, limit, when_start, when_end, match_with)
+	local item, when, with;
+	local count = 0;
+	coroutine.yield(true); -- Ready
+	for i = start, stop, step do
+		item = a[i];
+		when, with = item.when, item.with;
+		if when >= when_start and when_end >= when and (not match_with or match_with == with) then
+			coroutine.yield(item.key, item.value(), when, with);
+			count = count + 1;
+			if limit and count >= limit then return end
+		end
+	end
+end
+
+function archive_store:find(username, query)
+	local a = self.store[username or NULL] or {};
+	local start, stop, step = 1, #a, 1;
+	local qstart, qend, qwith = -math.huge, math.huge;
+	local limit;
+	if query then
+		module:log("debug", "query included")
+		if query.reverse then
+			start, stop, step = stop, start, -1;
+			if query.before then
+				start = a[query.before];
+			end
+		elseif query.after then
+			start = a[query.after];
+		end
+		limit = query.limit;
+		qstart = query.start or qstart;
+		qend = query["end"] or qend;
+		qwith = query.with;
+	end
+	if not start then return nil, "invalid-key"; end
+	local iter = coroutine.wrap(archive_iter);
+	iter(a, start, stop, step, limit, qstart, qend, qwith);
+	return iter;
+end
+
+function archive_store:delete(username, query)
+	if not query or next(query) == nil then
+		self.store[username or NULL] = nil;
+		return true;
+	end
+	local items = self.store[username or NULL];
+	if not items then
+		-- Store is empty
+		return 0;
+	end
+	items = array(items);
+	local count_before = #items;
+	if query then
+		if query.key then
+			items:filter(function (item)
+				return item.key ~= query.key;
+			end);
+		end
+		if query.with then
+			items:filter(function (item)
+				return item.with ~= query.with;
+			end);
+		end
+		if query.start then
+			items:filter(function (item)
+				return item.when < query.start;
+			end);
+		end
+		if query["end"] then
+			items:filter(function (item)
+				return item.when > query["end"];
+			end);
+		end
+		if query.truncate and #items > query.truncate then
+			if query.reverse then
+				-- Before: { 1, 2, 3, 4, 5, }
+				-- After: { 1, 2, 3 }
+				for i = #items, query.truncate + 1, -1 do
+					items[i] = nil;
+				end
+			else
+				-- Before: { 1, 2, 3, 4, 5, }
+				-- After: { 3, 4, 5 }
+				local offset = #items - query.truncate;
+				for i = 1, #items do
+					items[i] = items[i+offset];
+				end
+			end
+		end
+	end
+	local count = count_before - #items;
+	if count == 0 then
+		return 0; -- No changes, skip write
+	end
+	setmetatable(items, nil);
+
+	do -- re-index by key
+		for k in pairs(items) do
+			if type(k) == "string" then
+				items[k] = nil;
+			end
+		end
+
+		for i = 1, #items do
+			items[ items[i].key ] = i;
+		end
+	end
+
+	return count;
+end
+
+archive_store.purge = _purge_store;
+
+local stores = {
+	keyval = keyval_store;
+	archive = archive_store;
+}
+
+local driver = {};
+
+function driver:open(store, typ) -- luacheck: ignore 212/self
+	local store_mt = stores[typ or "keyval"];
+	if store_mt then
+		return setmetatable({ store = memory[store] }, store_mt);
+	end
+	return nil, "unsupported-store";
+end
+
+function driver:purge(user) -- luacheck: ignore 212/self
+	for _, store in pairs(memory) do
+		store[user] = nil;
+	end
+end
+
+if auto_purge_enabled then
+	module:hook("resource-unbind", function (event)
+		local user_bare_jid = event.session.username.."@"..event.session.host;
+		if not prosody.bare_sessions[user_bare_jid] then -- User went offline
+			module:log("debug", "Clearing store for offline user %s", user_bare_jid);
+			local f, s, v;
+			if auto_purge_stores:empty() then
+				f, s, v = pairs(memory);
+			else
+				f, s, v = auto_purge_stores:items();
+			end
+
+			for store_name in f, s, v do
+				if memory[store_name] then
+					memory[store_name][event.session.username] = nil;
+				end
+			end
+		end
+	end);
+end
+
+module:provides("storage", driver);
--- a/plugins/mod_storage_sql.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_storage_sql.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -11,7 +11,7 @@
 local t_concat = table.concat;
 
 local noop = function() end
-local unpack = unpack
+local unpack = table.unpack or unpack;
 local function iterator(result)
 	return function(result_)
 		local row = result_();
@@ -43,12 +43,17 @@
 	elseif t == "boolean" then
 		if value == "true" then return true;
 		elseif value == "false" then return false; end
-	elseif t == "number" then return tonumber(value);
+		return nil, "invalid-boolean";
+	elseif t == "number" then
+		value = tonumber(value);
+		if value then return value; end
+		return nil, "invalid-number";
 	elseif t == "json" then
 		return json.decode(value);
 	elseif t == "xml" then
 		return xml_parse(value);
 	end
+	return nil, "Unhandled value type: "..t;
 end
 
 local host = module.host;
@@ -65,7 +70,8 @@
 	for row in engine:select(select_sql, host, user or "", store) do
 		haveany = true;
 		local k = row[1];
-		local v = deserialize(row[2], row[3]);
+		local v, e = deserialize(row[2], row[3]);
+		assert(v ~= nil, e);
 		if k and v then
 			if k ~= "" then result[k] = v; elseif type(v) == "table" then
 				for a,b in pairs(v) do
@@ -136,7 +142,7 @@
 		]];
 		return engine:select(select_sql, host, self.store);
 	end);
-	if not ok then return ok, result end
+	if not ok then error(result); end
 	return iterator(result);
 end
 
@@ -154,15 +160,17 @@
 		WHERE "host"=? AND "user"=? AND "store"=? AND "key"=?
 		LIMIT 1
 		]];
-		local data;
+		local data, err;
 		if type(key) == "string" and key ~= "" then
 			for row in engine:select(query, host, username or "", self.store, key) do
-				data = deserialize(row[1], row[2]);
+				data, err = deserialize(row[1], row[2]);
+				assert(data ~= nil, err);
 			end
 			return data;
 		else
 			for row in engine:select(query, host, username or "", self.store, "") do
-				data = deserialize(row[1], row[2]);
+				data, err = deserialize(row[1], row[2]);
+				assert(data ~= nil, err);
 			end
 			return data and data[key] or nil;
 		end
@@ -200,9 +208,10 @@
 					engine:insert(insert_sql, host, username or "", self.store, key, t, value);
 				end
 			else
-				local extradata = {};
+				local extradata, err = {};
 				for row in engine:select(select_extradata_sql, host, username or "", self.store, "") do
-					extradata = deserialize(row[1], row[2]);
+					extradata, err = deserialize(row[1], row[2]);
+					assert(extradata ~= nil, err);
 				end
 				engine:delete(delete_sql, host, username or "", self.store, "");
 				extradata[key] = data;
@@ -356,7 +365,9 @@
 	return function()
 		local row = result();
 		if row ~= nil then
-			return row[1], deserialize(row[2], row[3]), row[4], row[5];
+			local value, err = deserialize(row[2], row[3]);
+			assert(value ~= nil, err);
+			return row[1], value, row[4], row[5];
 		end
 	end, total;
 end
@@ -374,7 +385,41 @@
 		end
 		archive_where(query, args, where);
 		archive_where_id_range(query, args, where);
-		sql_query = sql_query:format(t_concat(where, " AND "));
+		if query.truncate == nil then
+			sql_query = sql_query:format(t_concat(where, " AND "));
+		else
+			args[#args+1] = query.truncate;
+			local unlimited = "ALL";
+			if engine.params.driver == "SQLite3" then
+				sql_query = [[
+				DELETE FROM "prosodyarchive"
+				WHERE %s
+				ORDER BY "sort_id" %s
+				LIMIT %s OFFSET ?;
+				]];
+				unlimited = "-1";
+			elseif engine.params.driver == "MySQL" then
+				sql_query = [[
+				DELETE result FROM prosodyarchive AS result JOIN (
+					SELECT sort_id FROM prosodyarchive
+					WHERE %s
+					ORDER BY "sort_id" %s
+					LIMIT %s OFFSET ?
+				) AS limiter on result.sort_id = limiter.sort_id;]];
+				unlimited = "18446744073709551615";
+			else
+				sql_query = [[
+				DELETE FROM "prosodyarchive"
+				WHERE "sort_id" IN (
+					SELECT "sort_id" FROM "prosodyarchive"
+					WHERE %s
+					ORDER BY "sort_id" %s
+					LIMIT %s OFFSET ?
+				);]];
+			end
+			sql_query = string.format(sql_query, t_concat(where, " AND "),
+				query.reverse and "ASC" or "DESC", unlimited);
+		end
 		return engine:delete(sql_query, unpack(args));
 	end);
 	return ok and stmt:affected(), stmt;
@@ -423,11 +468,11 @@
 --- Initialization
 
 
-local function create_table(engine, name) -- luacheck: ignore 431/engine
+local function create_table(engine) -- luacheck: ignore 431/engine
 	local Table, Column, Index = sql.Table, sql.Column, sql.Index;
 
 	local ProsodyTable = Table {
-		name= name or "prosody";
+		name = "prosody";
 		Column { name="host", type="TEXT", nullable=false };
 		Column { name="user", type="TEXT", nullable=false };
 		Column { name="store", type="TEXT", nullable=false };
@@ -451,7 +496,7 @@
 		Column { name="with", type="TEXT", nullable=false }; -- related id
 		Column { name="type", type="TEXT", nullable=false };
 		Column { name="value", type="MEDIUMTEXT", nullable=false };
-		Index { name="prosodyarchive_index", unique = true, "host", "user", "store", "key" };
+		Index { name="prosodyarchive_index", unique = engine.params.driver ~= "MySQL", "host", "user", "store", "key" };
 		Index { name="prosodyarchive_with_when", "host", "user", "store", "with", "when" };
 		Index { name="prosodyarchive_when", "host", "user", "store", "when" };
 	};
@@ -464,20 +509,37 @@
 	local changes = false;
 	if params.driver == "MySQL" then
 		local success,err = engine:transaction(function()
-			local result = engine:execute("SHOW COLUMNS FROM \"prosody\" WHERE \"Field\"='value' and \"Type\"='text'");
-			if result:rowcount() > 0 then
-				changes = true;
-				if apply_changes then
-					module:log("info", "Upgrading database schema...");
-					engine:execute("ALTER TABLE \"prosody\" MODIFY COLUMN \"value\" MEDIUMTEXT");
-					module:log("info", "Database table automatically upgraded");
+			do
+				local result = assert(engine:execute("SHOW COLUMNS FROM \"prosody\" WHERE \"Field\"='value' and \"Type\"='text'"));
+				if result:rowcount() > 0 then
+					changes = true;
+					if apply_changes then
+						module:log("info", "Upgrading database schema (value column size)...");
+						assert(engine:execute("ALTER TABLE \"prosody\" MODIFY COLUMN \"value\" MEDIUMTEXT"));
+						module:log("info", "Database table automatically upgraded");
+					end
+				end
+			end
+
+			do
+				-- Ensure index is not unique (issue #1073)
+				local result = assert(engine:execute([[SHOW INDEX FROM prosodyarchive WHERE key_name='prosodyarchive_index' and non_unique=0]]));
+				if result:rowcount() > 0 then
+					changes = true;
+					if apply_changes then
+						module:log("info", "Upgrading database schema (prosodyarchive_index)...");
+						assert(engine:execute[[ALTER TABLE "prosodyarchive" DROP INDEX prosodyarchive_index;]]);
+						local new_index = sql.Index { table = "prosodyarchive", name="prosodyarchive_index", "host", "user", "store", "key" };
+						assert(engine:_create_index(new_index));
+						module:log("info", "Database table automatically upgraded");
+					end
 				end
 			end
 			return true;
 		end);
 		if not success then
 			module:log("error", "Failed to check/upgrade database schema (%s), please see "
-				.."http://prosody.im/doc/mysql for help",
+				.."https://prosody.im/doc/mysql for help",
 				err or "unknown error");
 			return false;
 		end
--- a/plugins/mod_storage_sql1.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,414 +0,0 @@
-
---[[
-
-DB Tables:
-	Prosody - key-value, map
-		| host | user | store | key | type | value |
-	ProsodyArchive - list
-		| host | user | store | key | time | stanzatype | jsonvalue |
-
-Mapping:
-	Roster - Prosody
-		| host | user | "roster" | "contactjid" | type | value |
-		| host | user | "roster" | NULL | "json" | roster[false] data |
-	Account - Prosody
-		| host | user | "accounts" | "username" | type | value |
-
-	Offline - ProsodyArchive
-		| host | user | "offline" | "contactjid" | time | "message" | json|XML |
-
-]]
-
-local type = type;
-local tostring = tostring;
-local tonumber = tonumber;
-local pairs = pairs;
-local next = next;
-local setmetatable = setmetatable;
-local xpcall = xpcall;
-local json = require "util.json";
-local build_url = require"socket.url".build;
-
-local DBI;
-local connection;
-local host,user,store = module.host;
-local params = module:get_option("sql");
-
-local dburi;
-local connections = module:shared "/*/sql/connection-cache";
-
-local function db2uri(params)
-	return build_url{
-		scheme = params.driver,
-		user = params.username,
-		password = params.password,
-		host = params.host,
-		port = params.port,
-		path = params.database,
-	};
-end
-
-
-local resolve_relative_path = require "util.paths".resolve_relative_path;
-
-local function test_connection()
-	if not connection then return nil; end
-	if connection:ping() then
-		return true;
-	else
-		module:log("debug", "Database connection closed");
-		connection = nil;
-		connections[dburi] = nil;
-	end
-end
-local function connect()
-	if not test_connection() then
-		prosody.unlock_globals();
-		local dbh, err = DBI.Connect(
-			params.driver, params.database,
-			params.username, params.password,
-			params.host, params.port
-		);
-		prosody.lock_globals();
-		if not dbh then
-			module:log("debug", "Database connection failed: %s", tostring(err));
-			return nil, err;
-		end
-		module:log("debug", "Successfully connected to database");
-		dbh:autocommit(false); -- don't commit automatically
-		connection = dbh;
-
-		connections[dburi] = dbh;
-	end
-	return connection;
-end
-
-local function create_table()
-	if not module:get_option("sql_manage_tables", true) then
-		return;
-	end
-	local create_sql = "CREATE TABLE `prosody` (`host` TEXT, `user` TEXT, `store` TEXT, `key` TEXT, `type` TEXT, `value` TEXT);";
-	if params.driver == "PostgreSQL" then
-		create_sql = create_sql:gsub("`", "\"");
-	elseif params.driver == "MySQL" then
-		create_sql = create_sql:gsub("`value` TEXT", "`value` MEDIUMTEXT");
-	end
-
-	local stmt, err = connection:prepare(create_sql);
-	if stmt then
-		local ok = stmt:execute();
-		local commit_ok = connection:commit();
-		if ok and commit_ok then
-			module:log("info", "Initialized new %s database with prosody table", params.driver);
-			local index_sql = "CREATE INDEX `prosody_index` ON `prosody` (`host`, `user`, `store`, `key`)";
-			if params.driver == "PostgreSQL" then
-				index_sql = index_sql:gsub("`", "\"");
-			elseif params.driver == "MySQL" then
-				index_sql = index_sql:gsub("`([,)])", "`(20)%1");
-			end
-			local stmt, err = connection:prepare(index_sql);
-			local ok, commit_ok, commit_err;
-			if stmt then
-				ok, err = stmt:execute();
-				commit_ok, commit_err = connection:commit();
-			end
-			if not(ok and commit_ok) then
-				module:log("warn", "Failed to create index (%s), lookups may not be optimised", err or commit_err);
-			end
-		elseif params.driver == "MySQL" then  -- COMPAT: Upgrade tables from 0.8.0
-			-- Failed to create, but check existing MySQL table here
-			local stmt = connection:prepare("SHOW COLUMNS FROM prosody WHERE Field='value' and Type='text'");
-			local ok = stmt:execute();
-			local commit_ok = connection:commit();
-			if ok and commit_ok then
-				if stmt:rowcount() > 0 then
-					module:log("info", "Upgrading database schema...");
-					local stmt = connection:prepare("ALTER TABLE prosody MODIFY COLUMN `value` MEDIUMTEXT");
-					local ok, err = stmt:execute();
-					local commit_ok = connection:commit();
-					if ok and commit_ok then
-						module:log("info", "Database table automatically upgraded");
-					else
-						module:log("error", "Failed to upgrade database schema (%s), please see "
-							.."http://prosody.im/doc/mysql for help",
-							err or "unknown error");
-					end
-				end
-				repeat until not stmt:fetch();
-			end
-		end
-	elseif params.driver ~= "SQLite3" then -- SQLite normally fails to prepare for existing table
-		module:log("warn", "Prosody was not able to automatically check/create the database table (%s), "
-			.."see http://prosody.im/doc/modules/mod_storage_sql#table_management for help.",
-			err or "unknown error");
-	end
-end
-
-do -- process options to get a db connection
-	local ok;
-	prosody.unlock_globals();
-	ok, DBI = pcall(require, "DBI");
-	if not ok then
-		package.loaded["DBI"] = {};
-		module:log("error", "Failed to load the LuaDBI library for accessing SQL databases: %s", DBI);
-		module:log("error", "More information on installing LuaDBI can be found at http://prosody.im/doc/depends#luadbi");
-	end
-	prosody.lock_globals();
-	if not ok or not DBI.Connect then
-		return; -- Halt loading of this module
-	end
-
-	params = params or { driver = "SQLite3" };
-
-	if params.driver == "SQLite3" then
-		params.database = resolve_relative_path(prosody.paths.data or ".", params.database or "prosody.sqlite");
-	end
-
-	assert(params.driver and params.database, "Both the SQL driver and the database need to be specified");
-
-	dburi = db2uri(params);
-	connection = connections[dburi];
-
-	assert(connect());
-
-	-- Automatically create table, ignore failure (table probably already exists)
-	create_table();
-end
-
-local function serialize(value)
-	local t = type(value);
-	if t == "string" or t == "boolean" or t == "number" then
-		return t, tostring(value);
-	elseif t == "table" then
-		local value,err = json.encode(value);
-		if value then return "json", value; end
-		return nil, err;
-	end
-	return nil, "Unhandled value type: "..t;
-end
-local function deserialize(t, value)
-	if t == "string" then return value;
-	elseif t == "boolean" then
-		if value == "true" then return true;
-		elseif value == "false" then return false; end
-	elseif t == "number" then return tonumber(value);
-	elseif t == "json" then
-		return json.decode(value);
-	end
-end
-
-local function dosql(sql, ...)
-	if params.driver == "PostgreSQL" then
-		sql = sql:gsub("`", "\"");
-	end
-	-- do prepared statement stuff
-	local stmt, err = connection:prepare(sql);
-	if not stmt and not test_connection() then error("connection failed"); end
-	if not stmt then module:log("error", "QUERY FAILED: %s %s", err, debug.traceback()); return nil, err; end
-	-- run query
-	local ok, err = stmt:execute(...);
-	if not ok and not test_connection() then error("connection failed"); end
-	if not ok then return nil, err; end
-
-	return stmt;
-end
-local function getsql(sql, ...)
-	return dosql(sql, host or "", user or "", store or "", ...);
-end
-local function setsql(sql, ...)
-	local stmt, err = getsql(sql, ...);
-	if not stmt then return stmt, err; end
-	return stmt:affected();
-end
-local function transact(...)
-	-- ...
-end
-local function rollback(...)
-	if connection then connection:rollback(); end -- FIXME check for rollback error?
-	return ...;
-end
-local function commit(...)
-	local success,err = connection:commit();
-	if not success then return nil, "SQL commit failed: "..tostring(err); end
-	return ...;
-end
-
-local function keyval_store_get()
-	local stmt, err = getsql("SELECT * FROM `prosody` WHERE `host`=? AND `user`=? AND `store`=?");
-	if not stmt then return rollback(nil, err); end
-
-	local haveany;
-	local result = {};
-	for row in stmt:rows(true) do
-		haveany = true;
-		local k = row.key;
-		local v = deserialize(row.type, row.value);
-		if k and v then
-			if k ~= "" then result[k] = v; elseif type(v) == "table" then
-				for a,b in pairs(v) do
-					result[a] = b;
-				end
-			end
-		end
-	end
-	return commit(haveany and result or nil);
-end
-local function keyval_store_set(data)
-	local affected, err = setsql("DELETE FROM `prosody` WHERE `host`=? AND `user`=? AND `store`=?");
-	if not affected then return rollback(affected, err); end
-
-	if data and next(data) ~= nil then
-		local extradata = {};
-		for key, value in pairs(data) do
-			if type(key) == "string" and key ~= "" then
-				local t, value = serialize(value);
-				if not t then return rollback(t, value); end
-				local ok, err = setsql("INSERT INTO `prosody` (`host`,`user`,`store`,`key`,`type`,`value`) VALUES (?,?,?,?,?,?)", key, t, value);
-				if not ok then return rollback(ok, err); end
-			else
-				extradata[key] = value;
-			end
-		end
-		if next(extradata) ~= nil then
-			local t, extradata = serialize(extradata);
-			if not t then return rollback(t, extradata); end
-			local ok, err = setsql("INSERT INTO `prosody` (`host`,`user`,`store`,`key`,`type`,`value`) VALUES (?,?,?,?,?,?)", "", t, extradata);
-			if not ok then return rollback(ok, err); end
-		end
-	end
-	return commit(true);
-end
-
-local keyval_store = {};
-keyval_store.__index = keyval_store;
-function keyval_store:get(username)
-	user,store = username,self.store;
-	if not connection and not connect() then return nil, "Unable to connect to database"; end
-	local success, ret, err = xpcall(keyval_store_get, debug.traceback);
-	if not connection and connect() then
-		success, ret, err = xpcall(keyval_store_get, debug.traceback);
-	end
-	if success then return ret, err; else return rollback(nil, ret); end
-end
-function keyval_store:set(username, data)
-	user,store = username,self.store;
-	if not connection and not connect() then return nil, "Unable to connect to database"; end
-	local success, ret, err = xpcall(function() return keyval_store_set(data); end, debug.traceback);
-	if not connection and connect() then
-		success, ret, err = xpcall(function() return keyval_store_set(data); end, debug.traceback);
-	end
-	if success then return ret, err; else return rollback(nil, ret); end
-end
-function keyval_store:users()
-	local stmt, err = dosql("SELECT DISTINCT `user` FROM `prosody` WHERE `host`=? AND `store`=?", host, self.store);
-	if not stmt then
-		return rollback(nil, err);
-	end
-	local next = stmt:rows();
-	return commit(function()
-		local row = next();
-		return row and row[1];
-	end);
-end
-
-local function map_store_get(key)
-	local stmt, err = getsql("SELECT * FROM `prosody` WHERE `host`=? AND `user`=? AND `store`=? AND `key`=?", key or "");
-	if not stmt then return rollback(nil, err); end
-
-	local haveany;
-	local result = {};
-	for row in stmt:rows(true) do
-		haveany = true;
-		local k = row.key;
-		local v = deserialize(row.type, row.value);
-		if k and v then
-			if k ~= "" then result[k] = v; elseif type(v) == "table" then
-				for a,b in pairs(v) do
-					result[a] = b;
-				end
-			end
-		end
-	end
-	return commit(haveany and result[key] or nil);
-end
-local function map_store_set(key, data)
-	local affected, err = setsql("DELETE FROM `prosody` WHERE `host`=? AND `user`=? AND `store`=? AND `key`=?", key or "");
-	if not affected then return rollback(affected, err); end
-
-	if data and next(data) ~= nil then
-		if type(key) == "string" and key ~= "" then
-			local t, value = serialize(data);
-			if not t then return rollback(t, value); end
-			local ok, err = setsql("INSERT INTO `prosody` (`host`,`user`,`store`,`key`,`type`,`value`) VALUES (?,?,?,?,?,?)", key, t, value);
-			if not ok then return rollback(ok, err); end
-		else
-			-- TODO non-string keys
-		end
-	end
-	return commit(true);
-end
-
-local map_store = {};
-map_store.__index = map_store;
-function map_store:get(username, key)
-	user,store = username,self.store;
-	local success, ret, err = xpcall(function() return map_store_get(key); end, debug.traceback);
-	if success then return ret, err; else return rollback(nil, ret); end
-end
-function map_store:set(username, key, data)
-	user,store = username,self.store;
-	local success, ret, err = xpcall(function() return map_store_set(key, data); end, debug.traceback);
-	if success then return ret, err; else return rollback(nil, ret); end
-end
-
-local list_store = {};
-list_store.__index = list_store;
-function list_store:scan(username, from, to, jid, typ)
-	user,store = username,self.store;
-
-	local cols = {"from", "to", "jid", "typ"};
-	local vals = { from ,  to ,  jid ,  typ };
-	local stmt, err;
-	local query = "SELECT * FROM `prosodyarchive` WHERE `host`=? AND `user`=? AND `store`=?";
-
-	query = query.." ORDER BY time";
-	--local stmt, err = getsql("SELECT * FROM `prosody` WHERE `host`=? AND `user`=? AND `store`=? AND `key`=?", key or "");
-
-	return nil, "not-implemented"
-end
-
-local driver = {};
-
-function driver:open(store, typ)
-	if typ and typ ~= "keyval" then
-		return nil, "unsupported-store";
-	end
-	return setmetatable({ store = store }, keyval_store);
-end
-
-function driver:stores(username)
-	local sql = "SELECT DISTINCT `store` FROM `prosody` WHERE `host`=? AND `user`" ..
-		(username == true and "!=?" or "=?");
-	if username == true or not username then
-		username = "";
-	end
-	local stmt, err = dosql(sql, host, username);
-	if not stmt then
-		return rollback(nil, err);
-	end
-	local next = stmt:rows();
-	return commit(function()
-		local row = next();
-		return row and row[1];
-	end);
-end
-
-function driver:purge(username)
-	local stmt, err = dosql("DELETE FROM `prosody` WHERE `host`=? AND `user`=?", host, username);
-	if not stmt then return rollback(stmt, err); end
-	local changed, err = stmt:affected();
-	if not changed then return rollback(changed, err); end
-	return commit(true, changed);
-end
-
-module:provides("storage", driver);
--- a/plugins/mod_storage_xep0227.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_storage_xep0227.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -164,10 +164,84 @@
 	end;
 };
 
+handlers.roster = {
+	get = function(self, user)
+		user = getUserElement(getXml(user, self.host));
+		if user then
+			local roster = user:get_child("query", "jabber:iq:roster");
+			if roster then
+				local r = {
+					[false] = {
+						version = roster.attr.version;
+						pending = {};
+					}
+				};
+				for item in roster:childtags("item") do
+					r[item.attr.jid] = {
+						jid = item.attr.jid,
+						subscription = item.attr.subscription,
+						ask = item.attr.ask,
+						name = item.attr.name,
+						groups = {};
+					};
+					for group in item:childtags("group") do
+						r[item.attr.jid].groups[group:get_text()] = true;
+					end
+					for pending in user:childtags("presence", "jabber:client") do
+						r[false].pending[pending.attr.from] = true;
+					end
+				end
+				return r;
+			end
+		end
+	end;
+	set = function(self, user, data)
+		local xml = getXml(user, self.host);
+		local usere = xml and getUserElement(xml);
+		if usere then
+			local roster = usere:get_child("query", 'jabber:iq:roster');
+			if roster then removeStanzaChild(usere, roster); end
+			usere:maptags(function (tag)
+				if tag.attr.xmlns == "jabber:client" and tag.name == "presence" and tag.attr.type == "subscribe" then
+					return nil;
+				end
+				return tag;
+			end);
+			if data and next(data) ~= nil then
+				roster = st.stanza("query", {xmlns='jabber:iq:roster'});
+				usere:add_child(roster);
+				for jid, item in pairs(data) do
+					if jid then
+						roster:tag("item", {
+							jid = jid,
+							subscription = item.subscription,
+							ask = item.ask,
+							name = item.name,
+						});
+						for group in pairs(item.groups) do
+							roster:tag("group"):text(group):up();
+						end
+						roster:up(); -- move out from item
+					else
+						roster.attr.version = item.version;
+						for pending_jid in pairs(item.pending) do
+							usere:add_child(st.presence({ from = pending_jid, type = "subscribe" }));
+						end
+					end
+				end
+			end
+			return setXml(user, self.host, xml);
+		end
+		return true;
+	end;
+};
+
+
 -----------------------------
 local driver = {};
 
-function driver:open(datastore, typ)
+function driver:open(datastore, typ) -- luacheck: ignore 212/self
+	if typ and typ ~= "keyval" then return nil, "unsupported-store"; end
 	local handler = handlers[datastore];
 	if not handler then return nil, "unsupported-datastore"; end
 	local instance = setmetatable({ host = module.host; datastore = datastore; }, { __index = handler });
--- a/plugins/mod_time.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_time.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -16,16 +16,14 @@
 
 local function time_handler(event)
 	local origin, stanza = event.origin, event.stanza;
-	if stanza.attr.type == "get" then
-		origin.send(st.reply(stanza):tag("time", {xmlns="urn:xmpp:time"})
-			:tag("tzo"):text("+00:00"):up() -- TODO get the timezone in a platform independent fashion
-			:tag("utc"):text(datetime()));
-		return true;
-	end
+	origin.send(st.reply(stanza):tag("time", {xmlns="urn:xmpp:time"})
+		:tag("tzo"):text("+00:00"):up() -- TODO get the timezone in a platform independent fashion
+		:tag("utc"):text(datetime()));
+	return true;
 end
 
-module:hook("iq/bare/urn:xmpp:time:time", time_handler);
-module:hook("iq/host/urn:xmpp:time:time", time_handler);
+module:hook("iq-get/bare/urn:xmpp:time:time", time_handler);
+module:hook("iq-get/host/urn:xmpp:time:time", time_handler);
 
 -- XEP-0090: Entity Time (deprecated)
 
@@ -33,12 +31,10 @@
 
 local function legacy_time_handler(event)
 	local origin, stanza = event.origin, event.stanza;
-	if stanza.attr.type == "get" then
-		origin.send(st.reply(stanza):tag("query", {xmlns="jabber:iq:time"})
-			:tag("utc"):text(legacy()));
-		return true;
-	end
+	origin.send(st.reply(stanza):tag("query", {xmlns="jabber:iq:time"})
+		:tag("utc"):text(legacy()));
+	return true;
 end
 
-module:hook("iq/bare/jabber:iq:time:query", legacy_time_handler);
-module:hook("iq/host/jabber:iq:time:query", legacy_time_handler);
+module:hook("iq-get/bare/jabber:iq:time:query", legacy_time_handler);
+module:hook("iq-get/host/jabber:iq:time:query", legacy_time_handler);
--- a/plugins/mod_uptime.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_uptime.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -14,15 +14,14 @@
 -- XEP-0012: Last activity
 module:add_feature("jabber:iq:last");
 
-module:hook("iq/host/jabber:iq:last:query", function(event)
+module:hook("iq-get/host/jabber:iq:last:query", function(event)
 	local origin, stanza = event.origin, event.stanza;
-	if stanza.attr.type == "get" then
-		origin.send(st.reply(stanza):tag("query", {xmlns = "jabber:iq:last", seconds = tostring(os.difftime(os.time(), start_time))}));
-		return true;
-	end
+	origin.send(st.reply(stanza):tag("query", {xmlns = "jabber:iq:last", seconds = tostring(os.difftime(os.time(), start_time))}));
+	return true;
 end);
 
 -- Ad-hoc command
+module:depends "adhoc";
 local adhoc_new = module:require "adhoc".new;
 
 function uptime_text()
@@ -39,10 +38,10 @@
 		minutes, (minutes ~= 1 and "s") or "", os.date("%c", prosody.start_time));
 end
 
-function uptime_command_handler (self, data, state)
+function uptime_command_handler ()
 	return { info = uptime_text(), status = "completed" };
 end
 
 local descriptor = adhoc_new("Get uptime", "uptime", uptime_command_handler);
 
-module:add_item ("adhoc", descriptor);
+module:provides("adhoc", descriptor);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_user_account_management.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,86 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+
+local st = require "util.stanza";
+local usermanager_set_password = require "core.usermanager".set_password;
+local usermanager_delete_user = require "core.usermanager".delete_user;
+local nodeprep = require "util.encodings".stringprep.nodeprep;
+local jid_bare = require "util.jid".bare;
+
+local compat = module:get_option_boolean("registration_compat", true);
+
+module:add_feature("jabber:iq:register");
+
+-- Password change and account deletion handler
+local function handle_registration_stanza(event)
+	local session, stanza = event.origin, event.stanza;
+	local log = session.log or module._log;
+
+	local query = stanza.tags[1];
+	if stanza.attr.type == "get" then
+		local reply = st.reply(stanza);
+		reply:tag("query", {xmlns = "jabber:iq:register"})
+			:tag("registered"):up()
+			:tag("username"):text(session.username):up()
+			:tag("password"):up();
+		session.send(reply);
+	else -- stanza.attr.type == "set"
+		if query.tags[1] and query.tags[1].name == "remove" then
+			local username, host = session.username, session.host;
+
+			-- This one weird trick sends a reply to this stanza before the user is deleted
+			local old_session_close = session.close;
+			session.close = function(self, ...)
+				self.send(st.reply(stanza));
+				return old_session_close(self, ...);
+			end
+
+			local ok, err = usermanager_delete_user(username, host);
+
+			if not ok then
+				log("debug", "Removing user account %s@%s failed: %s", username, host, err);
+				session.close = old_session_close;
+				session.send(st.error_reply(stanza, "cancel", "service-unavailable", err));
+				return true;
+			end
+
+			log("info", "User removed their account: %s@%s", username, host);
+			module:fire_event("user-deregistered", { username = username, host = host, source = "mod_register", session = session });
+		else
+			local username = nodeprep(query:get_child_text("username"));
+			local password = query:get_child_text("password");
+			if username and password then
+				if username == session.username then
+					if usermanager_set_password(username, password, session.host, session.resource) then
+						session.send(st.reply(stanza));
+					else
+						-- TODO unable to write file, file may be locked, etc, what's the correct error?
+						session.send(st.error_reply(stanza, "wait", "internal-server-error"));
+					end
+				else
+					session.send(st.error_reply(stanza, "modify", "bad-request"));
+				end
+			else
+				session.send(st.error_reply(stanza, "modify", "bad-request"));
+			end
+		end
+	end
+	return true;
+end
+
+module:hook("iq/self/jabber:iq:register:query", handle_registration_stanza);
+if compat then
+	module:hook("iq/host/jabber:iq:register:query", function (event)
+		local session, stanza = event.origin, event.stanza;
+		if session.type == "c2s" and jid_bare(stanza.attr.to) == session.host then
+			return handle_registration_stanza(event);
+		end
+	end);
+end
+
--- a/plugins/mod_vcard.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_vcard.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -29,7 +29,7 @@
 		else
 			session.send(st.error_reply(stanza, "cancel", "item-not-found"));
 		end
-	else
+	else -- stanza.attr.type == "set"
 		if not to then
 			if vcards:set(session.username, st.preserialize(stanza.tags[1])) then
 				session.send(st.reply(stanza));
@@ -46,9 +46,3 @@
 
 module:hook("iq/bare/vcard-temp:vCard", handle_vcard);
 module:hook("iq/host/vcard-temp:vCard", handle_vcard);
-
--- COMPAT w/0.8
-if module:get_option("vcard_compatibility") ~= nil then
-	module:log("error", "The vcard_compatibility option has been removed, see"..
-		"mod_compat_vcard in prosody-modules if you still need this.");
-end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_vcard4.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,45 @@
+local st = require "util.stanza"
+local jid_split = require "util.jid".split;
+
+local mod_pep = module:depends("pep");
+
+module:hook("account-disco-info", function (event)
+	event.reply:tag("feature", { var = "urn:ietf:params:xml:ns:vcard-4.0" }):up();
+end);
+
+module:hook("iq-get/bare/urn:ietf:params:xml:ns:vcard-4.0:vcard", function (event)
+	local origin, stanza = event.origin, event.stanza;
+
+	local pep_service = mod_pep.get_pep_service(jid_split(stanza.attr.to) or origin.username);
+	local ok, id, item = pep_service:get_last_item("urn:xmpp:vcard4", stanza.attr.from);
+	if ok and item then
+		origin.send(st.reply(stanza):add_child(item.tags[1]));
+	elseif item == "item-not-found" or not id then
+		origin.send(st.error_reply(stanza, "cancel", "item-not-found"));
+	elseif item == "forbidden" then
+		origin.send(st.error_reply(stanza, "auth", "forbidden"));
+	else
+		origin.send(st.error_reply(stanza, "modify", "undefined-condition"));
+	end
+	return true;
+end);
+
+module:hook("iq-set/self/urn:ietf:params:xml:ns:vcard-4.0:vcard", function (event)
+	local origin, stanza = event.origin, event.stanza;
+
+	local vcard4 = st.stanza("item", { xmlns = "http://jabber.org/protocol/pubsub", id = "current" })
+		:add_child(stanza.tags[1]);
+
+	local pep_service = mod_pep.get_pep_service(origin.username);
+
+	local ok, err = pep_service:publish("urn:xmpp:vcard4", origin.full_jid, "current", vcard4);
+	if ok then
+		origin.send(st.reply(stanza));
+	elseif err == "forbidden" then
+		origin.send(st.error_reply(stanza, "auth", "forbidden"));
+	else
+		origin.send(st.error_reply(stanza, "modify", "undefined-condition", err));
+	end
+	return true;
+end);
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/mod_vcard_legacy.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,291 @@
+local st = require "util.stanza"
+local jid_split = require "util.jid".split;
+
+local mod_pep = module:depends("pep");
+
+local sha1 = require "util.hashes".sha1;
+local base64_decode = require "util.encodings".base64.decode;
+
+local vcards = module:open_store("vcard");
+
+module:add_feature("vcard-temp");
+module:hook("account-disco-info", function (event)
+	event.reply:tag("feature", { var = "urn:xmpp:pep-vcard-conversion:0" }):up();
+end);
+
+local function handle_error(origin, stanza, err)
+	if err == "forbidden" then
+		origin.send(st.error_reply(stanza, "auth", "forbidden"));
+	elseif err == "internal-server-error" then
+		origin.send(st.error_reply(stanza, "wait", "internal-server-error"));
+	else
+		origin.send(st.error_reply(stanza, "modify", "undefined-condition", err));
+	end
+end
+
+-- Simple translations
+-- <foo><text>hey</text></foo> -> <FOO>hey</FOO>
+local simple_map = {
+	nickname = "text";
+	title = "text";
+	role = "text";
+	categories = "text";
+	note = "text";
+	url = "uri";
+	bday = "date";
+}
+
+module:hook("iq-get/bare/vcard-temp:vCard", function (event)
+	local origin, stanza = event.origin, event.stanza;
+	local pep_service = mod_pep.get_pep_service(jid_split(stanza.attr.to) or origin.username);
+	local ok, id, vcard4_item = pep_service:get_last_item("urn:xmpp:vcard4", stanza.attr.from);
+
+	local vcard_temp = st.stanza("vCard", { xmlns = "vcard-temp" });
+	if ok and vcard4_item then
+		local vcard4 = vcard4_item.tags[1];
+
+		local fn = vcard4:get_child("fn");
+		vcard_temp:text_tag("FN", fn and fn:get_child_text("text"));
+
+		local v4n = vcard4:get_child("n");
+		vcard_temp:tag("N")
+			:text_tag("FAMILY", v4n and v4n:get_child_text("surname"))
+			:text_tag("GIVEN", v4n and v4n:get_child_text("given"))
+			:text_tag("MIDDLE", v4n and v4n:get_child_text("additional"))
+			:text_tag("PREFIX", v4n and v4n:get_child_text("prefix"))
+			:text_tag("SUFFIX", v4n and v4n:get_child_text("suffix"))
+			:up();
+
+		for tag in vcard4:childtags() do
+			local typ = simple_map[tag.name];
+			if typ then
+				local text = tag:get_child_text(typ);
+				if text then
+					vcard_temp:text_tag(tag.name:upper(), text);
+				end
+			elseif tag.name == "email" then
+				local text = tag:get_child_text("text");
+				if text then
+					vcard_temp:tag("EMAIL")
+						:text_tag("USERID", text)
+						:tag("INTERNET"):up();
+					if tag:find"parameters/type/text#" == "home" then
+						vcard_temp:tag("HOME"):up();
+					elseif tag:find"parameters/type/text#" == "work" then
+						vcard_temp:tag("WORK"):up();
+					end
+					vcard_temp:up();
+				end
+			elseif tag.name == "tel" then
+				local text = tag:get_child_text("uri");
+				if text then
+					if text:sub(1, 4) == "tel:" then
+						text = text:sub(5)
+					end
+					vcard_temp:tag("TEL"):text_tag("NUMBER", text);
+					if tag:find"parameters/type/text#" == "home" then
+						vcard_temp:tag("HOME"):up();
+					elseif tag:find"parameters/type/text#" == "work" then
+						vcard_temp:tag("WORK"):up();
+					end
+					vcard_temp:up();
+				end
+			elseif tag.name == "adr" then
+				vcard_temp:tag("ADR")
+					:text_tag("POBOX", tag:get_child_text("pobox"))
+					:text_tag("EXTADD", tag:get_child_text("ext"))
+					:text_tag("STREET", tag:get_child_text("street"))
+					:text_tag("LOCALITY", tag:get_child_text("locality"))
+					:text_tag("REGION", tag:get_child_text("region"))
+					:text_tag("PCODE", tag:get_child_text("code"))
+					:text_tag("CTRY", tag:get_child_text("country"));
+				if tag:find"parameters/type/text#" == "home" then
+					vcard_temp:tag("HOME"):up();
+				elseif tag:find"parameters/type/text#" == "work" then
+					vcard_temp:tag("WORK"):up();
+				end
+				vcard_temp:up();
+			end
+		end
+	end
+
+	local meta_ok, avatar_meta = pep_service:get_items("urn:xmpp:avatar:metadata", stanza.attr.from);
+	local data_ok, avatar_data = pep_service:get_items("urn:xmpp:avatar:data", stanza.attr.from);
+
+	if data_ok then
+		for _, hash in ipairs(avatar_data) do
+			local meta = meta_ok and avatar_meta[hash];
+			local data = avatar_data[hash];
+			local info = meta and meta.tags[1]:get_child("info");
+			vcard_temp:tag("PHOTO");
+			if info and info.attr.type then
+				vcard_temp:text_tag("TYPE", info.attr.type);
+			end
+			if data then
+				vcard_temp:text_tag("BINVAL", data.tags[1]:get_text());
+			elseif info and info.attr.url then
+				vcard_temp:text_tag("EXTVAL", info.attr.url);
+			end
+			vcard_temp:up();
+		end
+	end
+
+	if not vcard_temp.tags[1] then
+		vcard_temp = st.deserialize(vcards:get(jid_split(stanza.attr.to) or origin.username)) or vcard_temp;
+	end
+
+	origin.send(st.reply(stanza):add_child(vcard_temp));
+	return true;
+end);
+
+local node_defaults = {
+	access_model = "open";
+	_defaults_only = true;
+};
+
+module:hook("iq-set/self/vcard-temp:vCard", function (event)
+	local origin, stanza = event.origin, event.stanza;
+	local pep_service = mod_pep.get_pep_service(origin.username);
+
+	local vcard_temp = stanza.tags[1];
+
+	local vcard4 = st.stanza("item", { xmlns = "http://jabber.org/protocol/pubsub", id = "current" })
+		:tag("vcard", { xmlns = 'urn:ietf:params:xml:ns:vcard-4.0' });
+
+	if pep_service:purge("urn:xmpp:avatar:metadata", origin.full_jid) then
+		pep_service:purge("urn:xmpp:avatar:data", origin.full_jid);
+	end
+
+	vcard4:tag("fn"):text_tag("text", vcard_temp:get_child_text("FN")):up();
+
+	local N = vcard_temp:get_child("N");
+
+	vcard4:tag("n")
+		:text_tag("surname", N and N:get_child_text("FAMILY"))
+		:text_tag("given", N and N:get_child_text("GIVEN"))
+		:text_tag("additional", N and N:get_child_text("MIDDLE"))
+		:text_tag("prefix", N and N:get_child_text("PREFIX"))
+		:text_tag("suffix", N and N:get_child_text("SUFFIX"))
+	:up();
+
+	for tag in vcard_temp:childtags() do
+		local typ = simple_map[tag.name:lower()];
+		if typ then
+			local text = tag:get_text();
+			if text then
+				vcard4:tag(tag.name:lower()):text_tag(typ, text):up();
+			end
+		elseif tag.name == "EMAIL" then
+			local text = tag:get_child_text("USERID");
+			if text then
+				vcard4:tag("email")
+				vcard4:text_tag("text", text)
+				vcard4:tag("parameters"):tag("type");
+				if tag:get_child("HOME") then
+					vcard4:text_tag("text", "home");
+				elseif tag:get_child("WORK") then
+					vcard4:text_tag("text", "work");
+				end
+				vcard4:up():up():up();
+			end
+		elseif tag.name == "TEL" then
+			local text = tag:get_child_text("NUMBER");
+			if text then
+				vcard4:tag("tel"):text_tag("uri", "tel:"..text);
+			end
+			vcard4:tag("parameters"):tag("type");
+			if tag:get_child("HOME") then
+				vcard4:text_tag("text", "home");
+			elseif tag:get_child("WORK") then
+				vcard4:text_tag("text", "work");
+			end
+			vcard4:up():up():up();
+		elseif tag.name == "ORG" then
+			local text = tag:get_child_text("ORGNAME");
+			if text then
+				vcard4:tag("org"):text_tag("text", text):up();
+			end
+		elseif tag.name == "DESC" then
+			local text = tag:get_text();
+			if text then
+				vcard4:tag("note"):text_tag("text", text):up();
+			end
+			-- <note> gets mapped into <NOTE> in the other direction
+		elseif tag.name == "ADR" then
+			vcard4:tag("adr")
+				:text_tag("pobox", tag:get_child_text("POBOX"))
+				:text_tag("ext", tag:get_child_text("EXTADD"))
+				:text_tag("street", tag:get_child_text("STREET"))
+				:text_tag("locality", tag:get_child_text("LOCALITY"))
+				:text_tag("region", tag:get_child_text("REGION"))
+				:text_tag("code", tag:get_child_text("PCODE"))
+				:text_tag("country", tag:get_child_text("CTRY"));
+			vcard4:tag("parameters"):tag("type");
+			if tag:get_child("HOME") then
+				vcard4:text_tag("text", "home");
+			elseif tag:get_child("WORK") then
+				vcard4:text_tag("text", "work");
+			end
+			vcard4:up():up():up();
+		elseif tag.name == "PHOTO" then
+			local avatar_type = tag:get_child_text("TYPE");
+			local avatar_payload = tag:get_child_text("BINVAL");
+			-- Can EXTVAL be translated? No way to know the sha1 of the data?
+
+			if avatar_payload then
+				local avatar_raw = base64_decode(avatar_payload);
+				local avatar_hash = sha1(avatar_raw, true);
+
+				local avatar_meta = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" })
+					:tag("metadata", { xmlns="urn:xmpp:avatar:metadata" })
+						:tag("info", {
+							bytes = tostring(#avatar_raw),
+							id = avatar_hash,
+							type = avatar_type,
+						});
+
+				local avatar_data = st.stanza("item", { id = avatar_hash, xmlns = "http://jabber.org/protocol/pubsub" })
+					:tag("data", { xmlns="urn:xmpp:avatar:data" })
+						:text(avatar_payload);
+
+				local ok, err = pep_service:publish("urn:xmpp:avatar:data", origin.full_jid, avatar_hash, avatar_data, node_defaults)
+				if ok then
+					ok, err = pep_service:publish("urn:xmpp:avatar:metadata", origin.full_jid, avatar_hash, avatar_meta, node_defaults);
+				end
+				if not ok then
+					handle_error(origin, stanza, err);
+					return true;
+				end
+			end
+		end
+	end
+
+	local ok, err = pep_service:publish("urn:xmpp:vcard4", origin.full_jid, "current", vcard4, node_defaults);
+	if ok then
+		origin.send(st.reply(stanza));
+	else
+		handle_error(origin, stanza, err);
+	end
+
+	return true;
+end);
+
+local function inject_xep153(event)
+	local origin, stanza = event.origin, event.stanza;
+	local username = origin.username;
+	if not username then return end
+	if stanza.attr.type then return end
+	local pep_service = mod_pep.get_pep_service(username);
+
+	stanza:remove_children("x", "vcard-temp:x:update");
+	local x_update = st.stanza("x", { xmlns = "vcard-temp:x:update" });
+	local ok, avatar_hash = pep_service:get_last_item("urn:xmpp:avatar:metadata", true);
+	if ok and avatar_hash then
+		x_update:text_tag("photo", avatar_hash);
+	end
+	stanza:add_direct_child(x_update);
+end
+
+module:hook("pre-presence/full", inject_xep153, 1);
+module:hook("pre-presence/bare", inject_xep153, 1);
+module:hook("pre-presence/host", inject_xep153, 1);
--- a/plugins/mod_version.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_version.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -10,39 +10,36 @@
 
 module:add_feature("jabber:iq:version");
 
-local version;
-
 local query = st.stanza("query", {xmlns = "jabber:iq:version"})
-	:tag("name"):text("Prosody"):up()
-	:tag("version"):text(prosody.version):up();
+	:text_tag("name", "Prosody")
+	:text_tag("version", prosody.version);
 
 if not module:get_option_boolean("hide_os_type") then
+	local platform;
 	if os.getenv("WINDIR") then
-		version = "Windows";
+		platform = "Windows";
 	else
 		local os_version_command = module:get_option_string("os_version_command");
 		local ok, pposix = pcall(require, "util.pposix");
 		if not os_version_command and (ok and pposix and pposix.uname) then
-			version = pposix.uname().sysname;
+			platform = pposix.uname().sysname;
 		end
-		if not version then
+		if not platform then
 			local uname = io.popen(os_version_command or "uname");
 			if uname then
-				version = uname:read("*a");
+				platform = uname:read("*a");
 			end
 			uname:close();
 		end
 	end
-	if version then
-		version = version:match("^%s*(.-)%s*$") or version;
-		query:tag("os"):text(version):up();
+	if platform then
+		platform = platform:match("^%s*(.-)%s*$") or platform;
+		query:text_tag("os", platform);
 	end
 end
 
-module:hook("iq/host/jabber:iq:version:query", function(event)
-	local stanza = event.stanza;
-	if stanza.attr.type == "get" and stanza.attr.to == module.host then
-		event.origin.send(st.reply(stanza):add_child(query));
-		return true;
-	end
+module:hook("iq-get/host/jabber:iq:version:query", function(event)
+	local origin, stanza = event.origin, event.stanza;
+	origin.send(st.reply(stanza):add_child(query));
+	return true;
 end);
--- a/plugins/mod_watchregistrations.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_watchregistrations.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -13,12 +13,13 @@
 local registration_watchers = module:get_option_set("registration_watchers", module:get_option("admins", {})) / jid_prep;
 local registration_from = module:get_option_string("registration_from", host);
 local registration_notification = module:get_option_string("registration_notification", "User $username just registered on $host from $ip");
+local msg_type = module:get_option_string("registration_notification_type", "chat");
 
 local st = require "util.stanza";
 
 module:hook("user-registered", function (user)
 	module:log("debug", "Notifying of new registration");
-	local message = st.message{ type = "chat", from = registration_from }
+	local message = st.message{ type = msg_type, from = registration_from }
 		:tag("body")
 			:text(registration_notification:gsub("%$(%w+)", function (v)
 				return user[v] or user.session and user.session[v] or nil;
--- a/plugins/mod_websocket.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/mod_websocket.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -95,6 +95,8 @@
 		session.send(st.stanza("close", { xmlns = xmlns_framing }));
 		function session.send() return false; end
 
+		-- luacheck: ignore 422/reason
+		-- FIXME reason should be handled in common place
 		local reason = (reason and (reason.name or reason.text or reason.condition)) or reason;
 		session.log("debug", "c2s stream for %s closed: %s", session.full_jid or ("<"..session.ip..">"), reason or "session closed");
 
@@ -256,6 +258,10 @@
 
 	local session = sessions[conn];
 
+	-- Use upstream IP if a HTTP proxy was used
+	-- See mod_http and #540
+	session.ip = request.ip;
+
 	session.secure = consider_websocket_secure or session.secure;
 	session.websocket_request = request;
 
@@ -311,16 +317,17 @@
 
 module:hook("c2s-read-timeout", keepalive, -0.9);
 
+module:depends("http");
+module:provides("http", {
+	name = "websocket";
+	default_path = "xmpp-websocket";
+	route = {
+		["GET"] = handle_request;
+		["GET /"] = handle_request;
+	};
+});
+
 function module.add_host(module)
-	module:depends("http");
-	module:provides("http", {
-		name = "websocket";
-		default_path = "xmpp-websocket";
-		route = {
-			["GET"] = handle_request;
-			["GET /"] = handle_request;
-		};
-	});
 	module:hook("c2s-read-timeout", keepalive, -0.9);
 
 	if cross_domain ~= true then
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/config_form_sections.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,27 @@
+module:hook("muc-config-form", function(event)
+	table.insert(event.form, {
+		type = "fixed";
+		value = "Room information";
+	});
+end, 100);
+
+module:hook("muc-config-form", function(event)
+	table.insert(event.form, {
+		type = "fixed";
+		value = "Access to the room";
+	});
+end, 90);
+
+module:hook("muc-config-form", function(event)
+	table.insert(event.form, {
+		type = "fixed";
+		value = "Permissions in the room";
+	});
+end, 80);
+
+module:hook("muc-config-form", function(event)
+	table.insert(event.form, {
+		type = "fixed";
+		value = "Other options";
+	});
+end, 70);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/description.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,52 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local function get_description(room)
+	return room._data.description;
+end
+
+local function set_description(room, description)
+	if description == "" then description = nil; end
+	if get_description(room) == description then return false; end
+	room._data.description = description;
+	return true;
+end
+
+local function add_disco_form(event)
+	table.insert(event.form, {
+		name = "muc#roominfo_description";
+		label = "Description";
+		value = "";
+	});
+	event.formdata["muc#roominfo_description"] = get_description(event.room);
+end
+
+local function add_form_option(event)
+	table.insert(event.form, {
+		name = "muc#roomconfig_roomdesc";
+		type = "text-single";
+		label = "Description";
+		desc = "A brief description of the room";
+		value = get_description(event.room) or "";
+	});
+end
+
+module:hook("muc-disco#info", add_disco_form);
+module:hook("muc-config-form", add_form_option, 100-2);
+
+module:hook("muc-config-submitted/muc#roomconfig_roomdesc", function(event)
+	if set_description(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+return {
+	get = get_description;
+	set = set_description;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/hidden.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,54 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local restrict_public = not module:get_option_boolean("muc_room_allow_public", true);
+local um_is_admin = require "core.usermanager".is_admin;
+
+local function get_hidden(room)
+	return room._data.hidden;
+end
+
+local function set_hidden(room, hidden)
+	hidden = hidden and true or nil;
+	if get_hidden(room) == hidden then return false; end
+	room._data.hidden = hidden;
+	return true;
+end
+
+module:hook("muc-config-form", function(event)
+	if restrict_public and not um_is_admin(event.actor, module.host) then
+		-- Don't show option if public rooms are restricted and user is not admin of this host
+		return;
+	end
+	table.insert(event.form, {
+		name = "muc#roomconfig_publicroom";
+		type = "boolean";
+		label = "Include room information in public lists";
+		desc = "Enable this to allow people to find the room";
+		value = not get_hidden(event.room);
+	});
+end, 100-9);
+
+module:hook("muc-config-submitted/muc#roomconfig_publicroom", function(event)
+	if restrict_public and not um_is_admin(event.actor, module.host) then
+		return; -- Not allowed
+	end
+	if set_hidden(event.room, not event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+module:hook("muc-disco#info", function(event)
+	event.reply:tag("feature", {var = get_hidden(event.room) and "muc_hidden" or "muc_public"}):up();
+end);
+
+return {
+	get = get_hidden;
+	set = set_hidden;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/history.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,210 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local gettime = os.time;
+local datetime = require "util.datetime";
+local st = require "util.stanza";
+
+local default_history_length = 20;
+local max_history_length = module:get_option_number("max_history_messages", math.huge);
+
+local function set_max_history_length(_max_history_length)
+	max_history_length = _max_history_length or math.huge;
+end
+
+local function get_historylength(room)
+	return math.min(room._data.history_length or default_history_length, max_history_length);
+end
+
+local function set_historylength(room, length)
+	if length then
+		length = assert(tonumber(length), "Length not a valid number");
+	end
+	if length == default_history_length then length = nil; end
+	room._data.history_length = length;
+	return true;
+end
+
+-- Fix for clients who don't support XEP-0045 correctly
+-- Default number of history messages the room returns
+local function get_defaulthistorymessages(room)
+	return room._data.default_history_messages or default_history_length;
+end
+local function set_defaulthistorymessages(room, number)
+	number = math.min(tonumber(number) or default_history_length, room._data.history_length or default_history_length);
+	if number == default_history_length then
+		number = nil;
+	end
+	room._data.default_history_messages = number;
+end
+
+module:hook("muc-config-form", function(event)
+	table.insert(event.form, {
+		name = "muc#roomconfig_historylength";
+		type = "text-single";
+		label = "Maximum number of history messages returned by room";
+		desc = "Specify the maximum number of previous messages that should be sent to users when they join the room";
+		value = tostring(get_historylength(event.room));
+	});
+	table.insert(event.form, {
+		name = 'muc#roomconfig_defaulthistorymessages',
+		type = 'text-single',
+		label = 'Default number of history messages returned by room',
+		desc = "Specify the number of previous messages sent to new users when they join the room";
+		value = tostring(get_defaulthistorymessages(event.room))
+	});
+end, 70-5);
+
+module:hook("muc-config-submitted/muc#roomconfig_historylength", function(event)
+	if set_historylength(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+module:hook("muc-config-submitted/muc#roomconfig_defaulthistorymessages", function(event)
+	if set_defaulthistorymessages(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+local function parse_history(stanza)
+	local x_tag = stanza:get_child("x", "http://jabber.org/protocol/muc");
+	local history_tag = x_tag and x_tag:get_child("history", "http://jabber.org/protocol/muc");
+	if not history_tag then
+		return nil, nil, nil;
+	end
+
+	local maxchars = tonumber(history_tag.attr.maxchars);
+
+	local maxstanzas = tonumber(history_tag.attr.maxstanzas);
+
+	-- messages received since the UTC datetime specified
+	local since = history_tag.attr.since;
+	if since then
+		since = datetime.parse(since);
+	end
+
+	-- messages received in the last "X" seconds.
+	local seconds = tonumber(history_tag.attr.seconds);
+	if seconds then
+		seconds = gettime() - seconds;
+		if since then
+			since = math.max(since, seconds);
+		else
+			since = seconds;
+		end
+	end
+
+	return maxchars, maxstanzas, since;
+end
+
+module:hook("muc-get-history", function(event)
+	local room = event.room;
+	local history = room._history; -- send discussion history
+	if not history then return nil end
+	local history_len = #history;
+
+	local to = event.to;
+	local maxchars = event.maxchars;
+	local maxstanzas = event.maxstanzas or history_len;
+	local since = event.since;
+	local n = 0;
+	local charcount = 0;
+	for i=history_len,1,-1 do
+		local entry = history[i];
+		if maxchars then
+			if not entry.chars then
+				entry.stanza.attr.to = "";
+				entry.chars = #tostring(entry.stanza);
+			end
+			charcount = charcount + entry.chars + #to;
+			if charcount > maxchars then break; end
+		end
+		if since and since > entry.timestamp then break; end
+		if n + 1 > maxstanzas then break; end
+		n = n + 1;
+	end
+
+	local i = history_len-n+1
+	function event.next_stanza()
+		if i > history_len then return nil end
+		local entry = history[i];
+		local msg = entry.stanza;
+		msg.attr.to = to;
+		i = i + 1;
+		return msg;
+	end
+	return true;
+end, -1);
+
+local function send_history(room, stanza)
+	local maxchars, maxstanzas, since = parse_history(stanza);
+	if not(maxchars or maxstanzas or since) then
+		maxstanzas = get_defaulthistorymessages(room);
+	end
+	local event = {
+		room = room;
+		stanza = stanza;
+		to = stanza.attr.from; -- `to` is required to calculate the character count for `maxchars`
+		maxchars = maxchars,
+		maxstanzas = maxstanzas,
+		since = since;
+		next_stanza = function() end; -- events should define this iterator
+	};
+	module:fire_event("muc-get-history", event);
+	for msg in event.next_stanza, event do
+		room:route_stanza(msg);
+	end
+end
+
+-- Send history on join
+module:hook("muc-occupant-session-new", function(event)
+	send_history(event.room, event.stanza);
+end, 50); -- Before subject(20)
+
+-- add to history
+module:hook("muc-add-history", function(event)
+	local room = event.room
+	local history = room._history;
+	if not history then history = {}; room._history = history; end
+	local stanza = st.clone(event.stanza);
+	stanza.attr.to = "";
+	local ts = gettime();
+	local stamp = datetime.datetime(ts);
+	stanza:tag("delay", { -- XEP-0203
+		xmlns = "urn:xmpp:delay", from = module.host, stamp = stamp
+	}):up();
+	stanza:tag("x", { -- XEP-0091 (deprecated)
+		xmlns = "jabber:x:delay", from = module.host, stamp = datetime.legacy()
+	}):up();
+	local entry = { stanza = stanza, timestamp = ts };
+	table.insert(history, entry);
+	while #history > get_historylength(room) do table.remove(history, 1) end
+	return true;
+end, -1);
+
+-- Have a single muc-add-history event, so that plugins can mark it
+-- as handled without stopping other muc-broadcast-message handlers
+module:hook("muc-broadcast-message", function(event)
+	if module:fire_event("muc-message-is-historic", event) then
+		module:fire_event("muc-add-history", event);
+	end
+end);
+
+module:hook("muc-message-is-historic", function (event)
+	return event.stanza:get_child("body");
+end, -1);
+
+return {
+	set_max_length = set_max_history_length;
+	parse_history = parse_history;
+	send = send_history;
+	get_length = get_historylength;
+	set_length = set_historylength;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/language.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,51 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local function get_language(room)
+	return room._data.language;
+end
+
+local function set_language(room, language)
+	if language == "" then language = nil; end
+	if get_language(room) == language then return false; end
+	room._data.language = language;
+	return true;
+end
+
+local function add_disco_form(event)
+	table.insert(event.form, {
+		name = "muc#roominfo_lang";
+		value = "";
+	});
+	event.formdata["muc#roominfo_lang"] = get_language(event.room);
+end
+
+local function add_form_option(event)
+	table.insert(event.form, {
+		name = "muc#roomconfig_lang";
+		label = "Language tag for room (e.g. 'en', 'de', 'fr' etc.)";
+		type = "text-single";
+		desc = "Indicate the primary language spoken in this room";
+		value = get_language(event.room) or "";
+	});
+end
+
+module:hook("muc-disco#info", add_disco_form);
+module:hook("muc-config-form", add_form_option, 100-3);
+
+module:hook("muc-config-submitted/muc#roomconfig_lang", function(event)
+	if set_language(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+return {
+	get = get_language;
+	set = set_language;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/lock.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,62 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local st = require "util.stanza";
+
+local lock_rooms = module:get_option_boolean("muc_room_locking", true);
+local lock_room_timeout = module:get_option_number("muc_room_lock_timeout", 300);
+
+local function lock(room)
+	module:fire_event("muc-room-locked", {room = room;});
+	room._data.locked = os.time() + lock_room_timeout;
+end
+local function unlock(room)
+	module:fire_event("muc-room-unlocked", {room = room;});
+	room._data.locked = nil;
+end
+local function is_locked(room)
+	local ts = room._data.locked;
+	if ts then
+		if os.time() < ts then return true; end
+		unlock(room);
+	end
+	return false;
+end
+
+if lock_rooms then
+	module:hook("muc-room-pre-create", function(event)
+		-- Older groupchat protocol doesn't lock
+		if not event.stanza:get_child("x", "http://jabber.org/protocol/muc") then return end
+		-- Lock room at creation
+		local room = event.room;
+		lock(room);
+	end, 10);
+end
+
+-- Don't let users into room while it is locked
+module:hook("muc-occupant-pre-join", function(event)
+	if not event.is_new_room and is_locked(event.room) then -- Deny entry
+		module:log("debug", "Room is locked, denying entry");
+		event.origin.send(st.error_reply(event.stanza, "cancel", "item-not-found"));
+		return true;
+	end
+end, -30);
+
+-- When config is submitted; unlock the room
+module:hook("muc-config-submitted", function(event)
+	if is_locked(event.room) then
+		unlock(event.room);
+	end
+end, -1);
+
+return {
+	lock = lock;
+	unlock = unlock;
+	is_locked = is_locked;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/members_only.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,165 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local st = require "util.stanza";
+
+local muc_util = module:require "muc/util";
+local valid_affiliations = muc_util.valid_affiliations;
+
+local function get_members_only(room)
+	return room._data.members_only;
+end
+
+local function set_members_only(room, members_only)
+	members_only = members_only and true or nil;
+	if room._data.members_only == members_only then return false; end
+	room._data.members_only = members_only;
+	if members_only then
+		--[[
+		If as a result of a change in the room configuration the room type is
+		changed to members-only but there are non-members in the room,
+		the service MUST remove any non-members from the room and include a
+		status code of 322 in the presence unavailable stanzas sent to those users
+		as well as any remaining occupants.
+		]]
+		local occupants_changed = {};
+		for _, occupant in room:each_occupant() do
+			local affiliation = room:get_affiliation(occupant.bare_jid);
+			if valid_affiliations[affiliation or "none"] <= valid_affiliations.none then
+				occupant.role = nil;
+				room:save_occupant(occupant);
+				occupants_changed[occupant] = true;
+			end
+		end
+		local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"})
+			:tag("status", {code="322"}):up();
+		for occupant in pairs(occupants_changed) do
+			room:publicise_occupant_status(occupant, x);
+			module:fire_event("muc-occupant-left", {room = room; nick = occupant.nick; occupant = occupant;});
+		end
+	end
+	return true;
+end
+
+local function get_allow_member_invites(room)
+	return room._data.allow_member_invites;
+end
+
+-- Allows members to invite new members into a members-only room,
+-- effectively creating an invite-only room
+local function set_allow_member_invites(room, allow_member_invites)
+	allow_member_invites = allow_member_invites and true or nil;
+	if room._data.allow_member_invites == allow_member_invites then return false; end
+	room._data.allow_member_invites = allow_member_invites;
+	return true;
+end
+
+module:hook("muc-disco#info", function(event)
+	event.reply:tag("feature", {var = get_members_only(event.room) and "muc_membersonly" or "muc_open"}):up();
+	table.insert(event.form, {
+		name = "{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites";
+		label = "Allow members to invite new members";
+		type = "boolean";
+		value = not not get_allow_member_invites(event.room);
+	});
+end);
+
+
+module:hook("muc-config-form", function(event)
+	table.insert(event.form, {
+		name = "muc#roomconfig_membersonly";
+		type = "boolean";
+		label = "Only allow members to join";
+		desc = "Enable this to only allow access for room owners, admins and members";
+		value = get_members_only(event.room);
+	});
+	table.insert(event.form, {
+		name = "{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites";
+		type = "boolean";
+		label = "Allow members to invite new members";
+		value = get_allow_member_invites(event.room);
+	});
+end, 90-3);
+
+module:hook("muc-config-submitted/muc#roomconfig_membersonly", function(event)
+	if set_members_only(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+module:hook("muc-config-submitted/{http://prosody.im/protocol/muc}roomconfig_allowmemberinvites", function(event)
+	if set_allow_member_invites(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+-- No affiliation => role of "none"
+module:hook("muc-get-default-role", function(event)
+	if not event.affiliation and get_members_only(event.room) then
+		return false;
+	end
+end, 2);
+
+-- registration required for entering members-only room
+module:hook("muc-occupant-pre-join", function(event)
+	local room = event.room;
+	if get_members_only(room) then
+		local stanza = event.stanza;
+		local affiliation = room:get_affiliation(stanza.attr.from);
+		if valid_affiliations[affiliation or "none"] <= valid_affiliations.none then
+			local reply = st.error_reply(stanza, "auth", "registration-required"):up();
+			reply.tags[1].attr.code = "407";
+			event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+			return true;
+		end
+	end
+end, -5);
+
+-- Invitation privileges in members-only rooms SHOULD be restricted to room admins;
+-- if a member without privileges to edit the member list attempts to invite another user
+-- the service SHOULD return a <forbidden/> error to the occupant
+module:hook("muc-pre-invite", function(event)
+	local room = event.room;
+	if get_members_only(room) then
+		local stanza = event.stanza;
+		local inviter_affiliation = room:get_affiliation(stanza.attr.from) or "none";
+		local required_affiliation = room._data.allow_member_invites and "member" or "admin";
+		if valid_affiliations[inviter_affiliation] < valid_affiliations[required_affiliation] then
+			event.origin.send(st.error_reply(stanza, "auth", "forbidden"));
+			return true;
+		end
+	end
+end);
+
+-- When an invite is sent; add an affiliation for the invitee
+module:hook("muc-invite", function(event)
+	local room = event.room;
+	if get_members_only(room) then
+		local stanza = event.stanza;
+		local invitee = stanza.attr.to;
+		local affiliation = room:get_affiliation(invitee);
+		local invited_unaffiliated = valid_affiliations[affiliation or "none"] <= valid_affiliations.none;
+		if invited_unaffiliated then
+			local from = stanza:get_child("x", "http://jabber.org/protocol/muc#user")
+				:get_child("invite").attr.from;
+			module:log("debug", "%s invited %s into members only room %s, granting membership",
+				from, invitee, room.jid);
+			-- This might fail; ignore for now
+			room:set_affiliation(true, invitee, "member", "Invited by " .. from);
+			room:save();
+		end
+	end
+end);
+
+return {
+	get = get_members_only;
+	set = set_members_only;
+	get_allow_member_invites = get_allow_member_invites;
+	set_allow_member_invites = set_allow_member_invites;
+};
--- a/plugins/muc/mod_muc.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/muc/mod_muc.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -6,288 +6,510 @@
 -- COPYING file in the source package for more information.
 --
 
-local array = require "util.array";
+-- Exposed functions:
+--
+-- create_room(jid) -> room
+-- track_room(room)
+-- delete_room(room)
+-- forget_room(room)
+-- get_room_from_jid(jid) -> room
+-- each_room(live_only) -> () -> room [DEPRECATED]
+-- all_rooms() -> room
+-- live_rooms() -> room
+-- shutdown_component()
 
 if module:get_host_type() ~= "component" then
-	error("MUC should be loaded as a component, please see http://prosody.im/doc/components", 0);
+	error("MUC should be loaded as a component, please see https://prosody.im/doc/components", 0);
 end
 
-local muc_host = module:get_host();
-local muc_name = module:get_option_string("name", "Prosody Chatrooms");
-local restrict_room_creation = module:get_option("restrict_room_creation");
-if restrict_room_creation then
-	if restrict_room_creation == true then
-		restrict_room_creation = "admin";
-	elseif restrict_room_creation ~= "admin" and restrict_room_creation ~= "local" then
-		restrict_room_creation = nil;
-	end
+local muclib = module:require "muc";
+room_mt = muclib.room_mt; -- Yes, global.
+new_room = muclib.new_room;
+
+local name = module:require "muc/name";
+room_mt.get_name = name.get;
+room_mt.set_name = name.set;
+
+local description = module:require "muc/description";
+room_mt.get_description = description.get;
+room_mt.set_description = description.set;
+
+local language = module:require "muc/language";
+room_mt.get_language = language.get;
+room_mt.set_language = language.set;
+
+local hidden = module:require "muc/hidden";
+room_mt.get_hidden = hidden.get;
+room_mt.set_hidden = hidden.set;
+function room_mt:get_public()
+	return not self:get_hidden();
+end
+function room_mt:set_public(public)
+	return self:set_hidden(not public);
 end
-local lock_rooms = module:get_option_boolean("muc_room_locking", false);
-local lock_room_timeout = module:get_option_number("muc_room_lock_timeout", 300);
+
+local password = module:require "muc/password";
+room_mt.get_password = password.get;
+room_mt.set_password = password.set;
+
+local members_only = module:require "muc/members_only";
+room_mt.get_members_only = members_only.get;
+room_mt.set_members_only = members_only.set;
+room_mt.get_allow_member_invites = members_only.get_allow_member_invites;
+room_mt.set_allow_member_invites = members_only.set_allow_member_invites;
+
+local moderated = module:require "muc/moderated";
+room_mt.get_moderated = moderated.get;
+room_mt.set_moderated = moderated.set;
+
+local request = module:require "muc/request";
+room_mt.handle_role_request = request.handle_request;
 
-local muclib = module:require "muc";
-local muc_new_room = muclib.new_room;
+local persistent = module:require "muc/persistent";
+room_mt.get_persistent = persistent.get;
+room_mt.set_persistent = persistent.set;
+
+local subject = module:require "muc/subject";
+room_mt.get_changesubject = subject.get_changesubject;
+room_mt.set_changesubject = subject.set_changesubject;
+room_mt.get_subject = subject.get;
+room_mt.set_subject = subject.set;
+room_mt.send_subject = subject.send;
+
+local history = module:require "muc/history";
+room_mt.send_history = history.send;
+room_mt.get_historylength = history.get_length;
+room_mt.set_historylength = history.set_length;
+
+local register = module:require "muc/register";
+room_mt.get_registered_nick = register.get_registered_nick;
+room_mt.get_registered_jid = register.get_registered_jid;
+room_mt.handle_register_iq = register.handle_register_iq;
+
 local jid_split = require "util.jid".split;
 local jid_bare = require "util.jid".bare;
 local st = require "util.stanza";
-local uuid_gen = require "util.uuid".generate;
+local cache = require "util.cache";
 local um_is_admin = require "core.usermanager".is_admin;
-local hosts = prosody.hosts;
 
-rooms = {};
-local rooms = rooms;
-local persistent_rooms_storage = module:open_store("persistent");
-local persistent_rooms, err = persistent_rooms_storage:get();
-if not persistent_rooms then
-	if err then
-		module:log("error", "Error loading list of persistent rooms from storage. Reload mod_muc or restart to recover.");
-		error("Storage error: "..err);
-	end
-	module:log("debug", "No persistent rooms found in the database");
-	persistent_rooms = {};
-end
-local room_configs = module:open_store("config");
-
--- Configurable options
-muclib.set_max_history_length(module:get_option_number("max_history_messages"));
+module:require "muc/config_form_sections";
 
 module:depends("disco");
-module:add_identity("conference", "text", muc_name);
+module:add_identity("conference", "text", module:get_option_string("name", "Prosody Chatrooms"));
 module:add_feature("http://jabber.org/protocol/muc");
+module:depends "muc_unique"
+module:require "muc/lock";
 
 local function is_admin(jid)
 	return um_is_admin(jid, module.host);
 end
 
-room_mt = muclib.room_mt; -- Yes, global.
-local _set_affiliation = room_mt.set_affiliation;
-local _get_affiliation = room_mt.get_affiliation;
-function muclib.room_mt:get_affiliation(jid)
-	if is_admin(jid) then return "owner"; end
-	return _get_affiliation(self, jid);
+do -- Monkey patch to make server admins room owners
+	local _get_affiliation = room_mt.get_affiliation;
+	function room_mt:get_affiliation(jid)
+		if is_admin(jid) then return "owner"; end
+		return _get_affiliation(self, jid);
+	end
+
+	local _set_affiliation = room_mt.set_affiliation;
+	function room_mt:set_affiliation(actor, jid, affiliation, reason, data)
+		if affiliation ~= "owner" and is_admin(jid) then return nil, "modify", "not-acceptable"; end
+		return _set_affiliation(self, actor, jid, affiliation, reason, data);
+	end
 end
-function muclib.room_mt:set_affiliation(actor, jid, affiliation, callback, reason)
-	if affiliation ~= "owner" and is_admin(jid) then return nil, "modify", "not-acceptable"; end
-	return _set_affiliation(self, actor, jid, affiliation, callback, reason);
+
+local persistent_rooms_storage = module:open_store("persistent");
+local persistent_rooms = module:open_store("persistent", "map");
+local room_configs = module:open_store("config");
+local room_state = module:open_store("state");
+
+local room_items_cache = {};
+
+local function room_save(room, forced, savestate)
+	local node = jid_split(room.jid);
+	local is_persistent = persistent.get(room);
+	room_items_cache[room.jid] = room:get_public() and room:get_name() or nil;
+	if is_persistent or savestate then
+		persistent_rooms:set(nil, room.jid, true);
+		local data, state = room:freeze(savestate);
+		room_state:set(node, state);
+		return room_configs:set(node, data);
+	elseif forced then
+		persistent_rooms:set(nil, room.jid, nil);
+		room_state:set(node, nil);
+		return room_configs:set(node, nil);
+	end
 end
 
-local function room_route_stanza(room, stanza) module:send(stanza); end
-local function room_save(room, forced)
-	local node = jid_split(room.jid);
-	persistent_rooms[room.jid] = room._data.persistent;
-	if room._data.persistent then
-		local history = room._data.history;
-		room._data.history = nil;
-		local data = {
-			jid = room.jid;
-			_data = room._data;
-			_affiliations = room._affiliations;
-		};
-		room_configs:set(node, data);
-		room._data.history = history;
-	elseif forced then
-		room_configs:set(node, nil);
-		if not next(room._occupants) then -- Room empty
-			rooms[room.jid] = nil;
-		end
+local max_rooms = module:get_option_number("muc_max_rooms");
+local max_live_rooms = module:get_option_number("muc_room_cache_size", 100);
+
+local room_hit = module:measure("room_hit", "rate");
+local room_miss = module:measure("room_miss", "rate")
+local room_eviction = module:measure("room_eviction", "rate");
+local rooms = cache.new(max_rooms or max_live_rooms, function (jid, room)
+	if max_rooms then
+		module:log("info", "Room limit of %d reached, no new rooms allowed", max_rooms);
+		return false;
+	end
+	module:log("debug", "Evicting room %s", jid);
+	room_eviction();
+	room_items_cache[room.jid] = room:get_public() and room:get_name() or nil;
+	local ok, err = room_save(room, nil, true); -- Force to disk
+	if not ok then
+		module:log("error", "Failed to swap inactive room %s to disk: %s", jid, err);
+		return false;
 	end
-	if forced then persistent_rooms_storage:set(nil, persistent_rooms); end
+end);
+
+-- Automatically destroy empty non-persistent rooms
+module:hook("muc-occupant-left",function(event)
+	local room = event.room
+	if room.destroying then return end
+	if not room:has_occupant() and not persistent.get(room) then -- empty, non-persistent room
+		module:log("debug", "%q empty, destroying", room.jid);
+		module:fire_event("muc-room-destroyed", { room = room });
+	end
+end, -1);
+
+function track_room(room)
+	if rooms:set(room.jid, room) then
+		-- When room is created, over-ride 'save' method
+		room.save = room_save;
+		return room;
+	end
+	-- Resource limit reached
+	return false;
 end
 
-function create_room(jid, locked)
-	local room = muc_new_room(jid);
-	room.route_stanza = room_route_stanza;
-	room.save = room_save;
-	rooms[jid] = room;
-	if locked then
-		room.locked = true;
-		if lock_room_timeout and lock_room_timeout > 0 then
-			module:add_timer(lock_room_timeout, function ()
-				if room.locked then
-					room:destroy(); -- Not unlocked in time
-				end
-			end);
-		end
-	end
-	module:fire_event("muc-room-created", { room = room });
-	return room;
+local function handle_broken_room(room, origin, stanza)
+	module:log("debug", "Returning error from broken room %s", room.jid);
+	origin.send(st.error_reply(stanza, "wait", "internal-server-error"));
+	return true;
 end
 
-local persistent_errors = false;
-for jid in pairs(persistent_rooms) do
+local function restore_room(jid)
 	local node = jid_split(jid);
 	local data, err = room_configs:get(node);
 	if data then
-		local room = create_room(jid);
-		room._data = data._data;
-		room._affiliations = data._affiliations;
-	elseif not err then -- missing room data
-		persistent_rooms[jid] = nil;
-		module:log("error", "Missing data for room '%s', removing from persistent room list", jid);
-		persistent_errors = true;
-	else -- error
-		module:log("error", "Error loading data for room '%s', locking it until service restart. Error was: %s", jid, err);
-		local room = muc_new_room(jid);
-		room.locked = true;
-		room._affiliations = { [muc_host] = "owner" }; -- To prevent unlocking
-		rooms[jid] = room;
+		module:log("debug", "Restoring room %s from storage", jid);
+		if module:fire_event("muc-room-pre-restore", { jid = jid, data = data }) == false then
+			return false;
+		end
+		local state, s_err = room_state:get(node);
+		if not state and s_err then
+			module:log("debug", "Could not restore state of room %s: %s", jid, s_err);
+		end
+		local room = muclib.restore_room(data, state);
+		if track_room(room) then
+			room_state:set(node, nil);
+			module:fire_event("muc-room-restored", { jid = jid, room = room });
+			return room;
+		else
+			return false;
+		end
+	elseif err then
+		module:log("error", "Error restoring room %s from storage: %s", jid, err);
+		local room = muclib.new_room(jid, { locked = math.huge });
+		room.handle_normal_presence = handle_broken_room;
+		room.handle_first_presence = handle_broken_room;
+		return room;
+	end
+end
+
+-- Removes a room from memory, without saving it (save first if required)
+function forget_room(room)
+	module:log("debug", "Forgetting %s", room.jid);
+	rooms.save = nil;
+	rooms:set(room.jid, nil);
+end
+
+-- Removes a room from the database (may remain in memory)
+function delete_room(room)
+	module:log("debug", "Deleting %s", room.jid);
+	room_configs:set(jid_split(room.jid), nil);
+	room_state:set(jid_split(room.jid), nil);
+	persistent_rooms:set(nil, room.jid, nil);
+	room_items_cache[room.jid] = nil;
+end
+
+function module.unload()
+	for room in live_rooms() do
+		room:save(nil, true);
+		forget_room(room);
 	end
 end
-if persistent_errors then persistent_rooms_storage:set(nil, persistent_rooms); end
+
+function get_room_from_jid(room_jid)
+	local room = rooms:get(room_jid);
+	if room then
+		room_hit();
+		rooms:set(room_jid, room); -- bump to top;
+		return room;
+	end
+	room_miss();
+	return restore_room(room_jid);
+end
+
+local function set_room_defaults(room, lang)
+	room:set_public(module:get_option_boolean("muc_room_default_public", false));
+	room:set_persistent(module:get_option_boolean("muc_room_default_persistent", room:get_persistent()));
+	room:set_members_only(module:get_option_boolean("muc_room_default_members_only", room:get_members_only()));
+	room:set_allow_member_invites(module:get_option_boolean("muc_room_default_allow_member_invites",
+		room:get_allow_member_invites()));
+	room:set_moderated(module:get_option_boolean("muc_room_default_moderated", room:get_moderated()));
+	room:set_whois(module:get_option_boolean("muc_room_default_public_jids",
+		room:get_whois() == "anyone") and "anyone" or "moderators");
+	room:set_changesubject(module:get_option_boolean("muc_room_default_change_subject", room:get_changesubject()));
+	room:set_historylength(module:get_option_number("muc_room_default_history_length", room:get_historylength()));
+	room:set_language(lang or module:get_option_string("muc_room_default_language"));
+end
 
-local host_room = muc_new_room(muc_host);
-host_room.route_stanza = room_route_stanza;
-host_room.save = room_save;
+function create_room(room_jid, config)
+	local exists = get_room_from_jid(room_jid);
+	if exists then
+		return nil, "room-exists";
+	end
+	local room = muclib.new_room(room_jid, config);
+	if not config then
+		set_room_defaults(room);
+	end
+	module:fire_event("muc-room-created", {
+		room = room;
+	});
+	return track_room(room);
+end
+
+function all_rooms()
+	return coroutine.wrap(function ()
+		local seen = {}; -- Don't iterate over persistent rooms twice
+		for room in live_rooms() do
+			coroutine.yield(room);
+			seen[room.jid] = true;
+		end
+		local all_persistent_rooms, err = persistent_rooms_storage:get(nil);
+		if not all_persistent_rooms then
+			if err then
+				module:log("error", "Error loading list of persistent rooms, only rooms live in memory were iterated over");
+				module:log("debug", "%s", debug.traceback(err));
+			end
+			return nil;
+		end
+		for room_jid in pairs(all_persistent_rooms) do
+			if not seen[room_jid] then
+				local room = restore_room(room_jid);
+				if room then
+					coroutine.yield(room);
+				else
+					module:log("error", "Missing data for room '%s', omitting from iteration", room_jid);
+				end
+			end
+		end
+	end);
+end
+
+function live_rooms()
+	return rooms:values();
+end
+
+function each_room(live_only)
+	if live_only then
+		return live_rooms();
+	end
+	return all_rooms();
+end
 
 module:hook("host-disco-items", function(event)
 	local reply = event.reply;
 	module:log("debug", "host-disco-items called");
-	for jid, room in pairs(rooms) do
-		if not room:get_hidden() then
-			reply:tag("item", {jid=jid, name=room:get_name()}):up();
+	if next(room_items_cache) ~= nil then
+		for jid, room_name in pairs(room_items_cache) do
+			reply:tag("item", { jid = jid, name = room_name }):up();
+		end
+	else
+		for room in all_rooms() do
+			if not room:get_hidden() then
+				local jid, room_name = room.jid, room:get_name();
+				room_items_cache[jid] = room_name;
+				reply:tag("item", { jid = jid, name = room_name }):up();
+			end
 		end
 	end
 end);
 
-local function handle_to_domain(event)
+module:hook("muc-room-pre-create", function (event)
+	set_room_defaults(event.room, event.stanza.attr["xml:lang"]);
+end, 1);
+
+module:hook("muc-room-pre-create", function(event)
 	local origin, stanza = event.origin, event.stanza;
-	local type = stanza.attr.type;
-	if type == "error" or type == "result" then return; end
-	if stanza.name == "iq" and type == "get" then
-		local xmlns = stanza.tags[1].attr.xmlns;
-		local node = stanza.tags[1].attr.node;
-		if xmlns == "http://jabber.org/protocol/muc#unique" then
-			origin.send(st.reply(stanza):tag("unique", {xmlns = xmlns}):text(uuid_gen())); -- FIXME Random UUIDs can theoretically have collisions
-		else
-			origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); -- TODO disco/etc
+	if not track_room(event.room) then
+		origin.send(st.error_reply(stanza, "wait", "resource-constraint"));
+		return true;
+	end
+end, -1000);
+
+module:hook("muc-room-destroyed",function(event)
+	local room = event.room;
+	forget_room(room);
+	delete_room(room);
+end);
+
+if module:get_option_boolean("muc_tombstones", true) then
+
+	local ttl = module:get_option_number("muc_tombstone_expiry", 86400 * 31);
+
+	module:hook("muc-room-destroyed",function(event)
+		local room = event.room;
+		if not room:get_persistent() then return end
+		if room._data.destroyed then
+			return -- Allow destruction of tombstone
 		end
-	else
-		host_room:handle_stanza(origin, stanza);
-		--origin.send(st.error_reply(stanza, "cancel", "service-unavailable", "The muc server doesn't deal with messages and presence directed at it"));
-	end
-	return true;
+
+		local tombstone = new_room(room.jid, {
+			locked = os.time() + ttl;
+			destroyed = true;
+			reason = event.reason;
+			newjid = event.newjid;
+			-- password?
+		});
+		tombstone.save = room_save;
+		tombstone:set_persistent(true);
+		tombstone:set_hidden(true);
+		tombstone:save(true);
+		return true;
+	end, -10);
 end
 
-function stanza_handler(event)
-	local origin, stanza = event.origin, event.stanza;
-	local bare = jid_bare(stanza.attr.to);
-	local room = rooms[bare];
-	if not room then
-		if stanza.name ~= "presence" or stanza.attr.type ~= nil then
-			if stanza.attr.type ~= "error" then
-				origin.send(st.error_reply(stanza, "cancel", "item-not-found"));
-			end
-			return true;
-		end
-		if not(restrict_room_creation) or
-		  is_admin(stanza.attr.from) or
-		  (restrict_room_creation == "local" and select(2, jid_split(stanza.attr.from)) == module.host:gsub("^[^%.]+%.", "")) then
-			room = create_room(bare, lock_rooms);
-		end
+do
+	local restrict_room_creation = module:get_option("restrict_room_creation");
+	if restrict_room_creation == true then
+		restrict_room_creation = "admin";
 	end
-	if room then
-		room:handle_stanza(origin, stanza);
-		if not next(room._occupants) and not persistent_rooms[room.jid] then -- empty, non-persistent room
-			module:fire_event("muc-room-destroyed", { room = room });
-			rooms[bare] = nil; -- discard room
-		end
-	else
-		origin.send(st.error_reply(stanza, "cancel", "not-allowed"));
+	if restrict_room_creation then
+		local host_suffix = module.host:gsub("^[^%.]+%.", "");
+		module:hook("muc-room-pre-create", function(event)
+			local origin, stanza = event.origin, event.stanza;
+			local user_jid = stanza.attr.from;
+			if not is_admin(user_jid) and not (
+				restrict_room_creation == "local" and
+				select(2, jid_split(user_jid)) == host_suffix
+			) then
+				origin.send(st.error_reply(stanza, "cancel", "not-allowed", "Room creation is restricted"));
+				return true;
+			end
+		end);
 	end
-	return true;
-end
-module:hook("iq/bare", stanza_handler, -1);
-module:hook("message/bare", stanza_handler, -1);
-module:hook("presence/bare", stanza_handler, -1);
-module:hook("iq/full", stanza_handler, -1);
-module:hook("message/full", stanza_handler, -1);
-module:hook("presence/full", stanza_handler, -1);
-module:hook("iq/host", handle_to_domain, -1);
-module:hook("message/host", handle_to_domain, -1);
-module:hook("presence/host", handle_to_domain, -1);
-
-hosts[module.host].send = function(stanza) -- FIXME do a generic fix
-	if stanza.attr.type == "result" or stanza.attr.type == "error" then
-		module:send(stanza);
-	else error("component.send only supports result and error stanzas at the moment"); end
 end
 
-hosts[module:get_host()].muc = { rooms = rooms };
+for event_name, method in pairs {
+	-- Normal room interactions
+	["iq-get/bare/http://jabber.org/protocol/disco#info:query"] = "handle_disco_info_get_query" ;
+	["iq-get/bare/http://jabber.org/protocol/disco#items:query"] = "handle_disco_items_get_query" ;
+	["iq-set/bare/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_set_command" ;
+	["iq-get/bare/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_get_command" ;
+	["iq-set/bare/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ;
+	["iq-get/bare/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_get_to_room" ;
+	["message/bare"] = "handle_message_to_room" ;
+	["presence/bare"] = "handle_presence_to_room" ;
+	["iq/bare/jabber:iq:register:query"] = "handle_register_iq";
+	-- Host room
+	["iq-get/host/http://jabber.org/protocol/disco#info:query"] = "handle_disco_info_get_query" ;
+	["iq-get/host/http://jabber.org/protocol/disco#items:query"] = "handle_disco_items_get_query" ;
+	["iq-set/host/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_set_command" ;
+	["iq-get/host/http://jabber.org/protocol/muc#admin:query"] = "handle_admin_query_get_command" ;
+	["iq-set/host/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_set_to_room" ;
+	["iq-get/host/http://jabber.org/protocol/muc#owner:query"] = "handle_owner_query_get_to_room" ;
+	["message/host"] = "handle_message_to_room" ;
+	["presence/host"] = "handle_presence_to_room" ;
+	-- Direct to occupant (normal rooms and host room)
+	["presence/full"] = "handle_presence_to_occupant" ;
+	["iq/full"] = "handle_iq_to_occupant" ;
+	["message/full"] = "handle_message_to_occupant" ;
+} do
+	module:hook(event_name, function (event)
+		local origin, stanza = event.origin, event.stanza;
+		local room_jid = jid_bare(stanza.attr.to);
+		local room = get_room_from_jid(room_jid);
 
-local saved = false;
-module.save = function()
-	saved = true;
-	return {rooms = rooms};
-end
-module.restore = function(data)
-	for jid, oldroom in pairs(data.rooms or {}) do
-		local room = create_room(jid);
-		room._jid_nick = oldroom._jid_nick;
-		room._occupants = oldroom._occupants;
-		room._data = oldroom._data;
-		room._affiliations = oldroom._affiliations;
-	end
-	hosts[module:get_host()].muc = { rooms = rooms };
+		if room and room._data.destroyed then
+			if room._data.locked < os.time()
+			or (is_admin(stanza.attr.from) and stanza.name == "presence" and stanza.attr.type == nil) then
+				-- Allow the room to be recreated by admin or after time has passed
+				delete_room(room);
+				room = nil;
+			else
+				if stanza.attr.type ~= "error" then
+					local reply = st.error_reply(stanza, "cancel", "gone", room._data.reason)
+					if room._data.newjid then
+						local uri = "xmpp:"..room._data.newjid.."?join";
+						reply:get_child("error"):child_with_name("gone"):text(uri);
+					end
+					event.origin.send(reply);
+				end
+				return true;
+			end
+		end
+
+		if room == nil then
+			-- Watch presence to create rooms
+			if stanza.attr.type == nil and stanza.name == "presence" then
+				room = muclib.new_room(room_jid);
+				return room:handle_first_presence(origin, stanza);
+			elseif stanza.attr.type ~= "error" then
+				origin.send(st.error_reply(stanza, "cancel", "item-not-found"));
+				return true;
+			else
+				return;
+			end
+		elseif room == false then -- Error loading room
+			origin.send(st.error_reply(stanza, "wait", "resource-constraint"));
+			return true;
+		end
+		return room[method](room, origin, stanza);
+	end, -2)
 end
 
-function shutdown_room(room, stanza)
-	for nick, occupant in pairs(room._occupants) do
-		stanza.attr.from = nick;
-		for jid in pairs(occupant.sessions) do
-			stanza.attr.to = jid;
-			room:_route_stanza(stanza);
-			room._jid_nick[jid] = nil;
-		end
-		room._occupants[nick] = nil;
+function shutdown_component()
+	for room in live_rooms() do
+		room:save(nil, true);
 	end
 end
-function shutdown_component()
-	if not saved then
-		local stanza = st.presence({type = "unavailable"})
-			:tag("x", {xmlns = "http://jabber.org/protocol/muc#user"})
-				:tag("item", { affiliation='none', role='none' }):up()
-				:tag("status", { code = "332"}):up();
-		for roomjid, room in pairs(rooms) do
-			shutdown_room(room, stanza);
-		end
-		shutdown_room(host_room, stanza);
-	end
-end
-module.unload = shutdown_component;
-module:hook_global("server-stopping", shutdown_component);
+module:hook_global("server-stopping", shutdown_component, -300);
 
--- Ad-hoc commands
-module:depends("adhoc")
-local t_concat = table.concat;
-local keys = require "util.iterators".keys;
-local adhoc_new = module:require "adhoc".new;
-local adhoc_initial = require "util.adhoc".new_initial_data_form;
-local dataforms_new = require "util.dataforms".new;
+do -- Ad-hoc commands
+	module:depends "adhoc";
+	local t_concat = table.concat;
+	local adhoc_new = module:require "adhoc".new;
+	local adhoc_initial = require "util.adhoc".new_initial_data_form;
+	local array = require "util.array";
+	local dataforms_new = require "util.dataforms".new;
+
+	local destroy_rooms_layout = dataforms_new {
+		title = "Destroy rooms";
+		instructions = "Select the rooms to destroy";
+
+		{ name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/muc#destroy" };
+		{ name = "rooms", type = "list-multi", required = true, label = "Rooms to destroy:"};
+	};
 
-local destroy_rooms_layout = dataforms_new {
-	title = "Destroy rooms";
-	instructions = "Select the rooms to destroy";
-
-	{ name = "FORM_TYPE", type = "hidden", value = "http://prosody.im/protocol/muc#destroy" };
-	{ name = "rooms", type = "list-multi", required = true, label = "Rooms to destroy:"};
-};
+	local destroy_rooms_handler = adhoc_initial(destroy_rooms_layout, function()
+		return { rooms = array.collect(all_rooms()):pluck("jid"):sort(); };
+	end, function(fields, errors)
+		if errors then
+			local errmsg = {};
+			for field, err in pairs(errors) do
+				errmsg[#errmsg + 1] = field .. ": " .. err;
+			end
+			return { status = "completed", error = { message = t_concat(errmsg, "\n") } };
+		end
+		for _, room in ipairs(fields.rooms) do
+			get_room_from_jid(room):destroy();
+		end
+		return { status = "completed", info = "The following rooms were destroyed:\n"..t_concat(fields.rooms, "\n") };
+	end);
+	local destroy_rooms_desc = adhoc_new("Destroy Rooms",
+		"http://prosody.im/protocol/muc#destroy", destroy_rooms_handler, "admin");
 
-local destroy_rooms_handler = adhoc_initial(destroy_rooms_layout, function()
-	return { rooms = array.collect(keys(rooms)):sort() };
-end, function(fields, errors)
-	if errors then
-		local errmsg = {};
-		for name, err in pairs(errors) do
-			errmsg[#errmsg + 1] = name .. ": " .. err;
-		end
-		return { status = "completed", error = { message = t_concat(errmsg, "\n") } };
-	end
-	for _, room in ipairs(fields.rooms) do
-		rooms[room]:destroy();
-		rooms[room] = nil;
-	end
-	return { status = "completed", info = "The following rooms were destroyed:\n"..t_concat(fields.rooms, "\n") };
-end);
-local destroy_rooms_desc = adhoc_new("Destroy Rooms", "http://prosody.im/protocol/muc#destroy", destroy_rooms_handler, "admin");
-
-module:provides("adhoc", destroy_rooms_desc);
+	module:provides("adhoc", destroy_rooms_desc);
+end
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/moderated.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,54 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local function get_moderated(room)
+	return room._data.moderated;
+end
+
+local function set_moderated(room, moderated)
+	moderated = moderated and true or nil;
+	if get_moderated(room) == moderated then return false; end
+	room._data.moderated = moderated;
+	return true;
+end
+
+module:hook("muc-disco#info", function(event)
+	event.reply:tag("feature", {var = get_moderated(event.room) and "muc_moderated" or "muc_unmoderated"}):up();
+end);
+
+module:hook("muc-config-form", function(event)
+	table.insert(event.form, {
+		name = "muc#roomconfig_moderatedroom";
+		type = "boolean";
+		label = "Moderated (require permission to speak)";
+		desc = "In moderated rooms occupants must be given permission to speak by a room moderator";
+		value = get_moderated(event.room);
+	});
+end, 80-3);
+
+module:hook("muc-config-submitted/muc#roomconfig_moderatedroom", function(event)
+	if set_moderated(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+module:hook("muc-get-default-role", function(event)
+	if event.affiliation == nil then
+		if get_moderated(event.room) then
+			-- XEP-0045:
+			-- An implementation MAY grant voice by default to visitors in unmoderated rooms.
+			return "visitor"
+		end
+	end
+end, 1);
+
+return {
+	get = get_moderated;
+	set = set_moderated;
+};
--- a/plugins/muc/muc.lib.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/plugins/muc/muc.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -1,66 +1,35 @@
 -- Prosody IM
 -- Copyright (C) 2008-2010 Matthew Wild
 -- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
 --
 -- This project is MIT/X11 licensed. Please see the
 -- COPYING file in the source package for more information.
 --
 
 local select = select;
-local pairs, ipairs = pairs, ipairs;
-
-local datetime = require "util.datetime";
+local pairs = pairs;
+local next = next;
+local setmetatable = setmetatable;
 
 local dataform = require "util.dataforms";
-
+local iterators = require "util.iterators";
 local jid_split = require "util.jid".split;
 local jid_bare = require "util.jid".bare;
 local jid_prep = require "util.jid".prep;
+local jid_join = require "util.jid".join;
+local jid_resource = require "util.jid".resource;
+local resourceprep = require "util.encodings".stringprep.resourceprep;
 local st = require "util.stanza";
-local log = require "util.logger".init("mod_muc");
-local t_insert, t_remove = table.insert, table.remove;
-local setmetatable = setmetatable;
 local base64 = require "util.encodings".base64;
 local md5 = require "util.hashes".md5;
 
-local muc_domain = nil; --module:get_host();
-local default_history_length, max_history_length = 20, math.huge;
-
-------------
-local presence_filters = {["http://jabber.org/protocol/muc"]=true;["http://jabber.org/protocol/muc#user"]=true};
-local function presence_filter(tag)
-	if presence_filters[tag.attr.xmlns] then
-		return nil;
-	end
-	return tag;
-end
+local log = module._log;
 
-local function get_filtered_presence(stanza)
-	return st.clone(stanza):maptags(presence_filter);
-end
-local kickable_error_conditions = {
-	["gone"] = true;
-	["internal-server-error"] = true;
-	["item-not-found"] = true;
-	["jid-malformed"] = true;
-	["recipient-unavailable"] = true;
-	["redirect"] = true;
-	["remote-server-not-found"] = true;
-	["remote-server-timeout"] = true;
-	["service-unavailable"] = true;
-	["malformed error"] = true;
-};
-
-local function get_error_condition(stanza)
-	local _, condition = stanza:get_error();
-	return condition or "malformed error";
-end
-
-local function is_kickable_error(stanza)
-	local cond = get_error_condition(stanza);
-	return kickable_error_conditions[cond] and cond;
-end
------------
+local occupant_lib = module:require "muc/occupant"
+local muc_util = module:require "muc/util";
+local is_kickable_error = muc_util.is_kickable_error;
+local valid_roles, valid_affiliations = muc_util.valid_roles, muc_util.valid_affiliations;
 
 local room_mt = {};
 room_mt.__index = room_mt;
@@ -69,37 +38,149 @@
 	return "MUC room ("..self.jid..")";
 end
 
+function room_mt.save()
+	-- overriden by mod_muc.lua
+end
+
+function room_mt:get_occupant_jid(real_jid)
+	return self._jid_nick[real_jid]
+end
+
 function room_mt:get_default_role(affiliation)
-	if affiliation == "owner" or affiliation == "admin" then
+	local role = module:fire_event("muc-get-default-role", {
+		room = self;
+		affiliation = affiliation;
+		affiliation_rank = valid_affiliations[affiliation or "none"];
+	});
+	role = role ~= "none" and role or nil; -- coerces `role == false` to `nil`
+	return role, valid_roles[role or "none"];
+end
+module:hook("muc-get-default-role", function(event)
+	if event.affiliation_rank >= valid_affiliations.admin then
 		return "moderator";
-	elseif affiliation == "member" then
+	elseif event.affiliation_rank >= valid_affiliations.none then
 		return "participant";
-	elseif not affiliation then
-		if not self:get_members_only() then
-			return self:get_moderated() and "visitor" or "participant";
-		end
+	end
+end, -1);
+
+--- Occupant functions
+function room_mt:new_occupant(bare_real_jid, nick)
+	local occupant = occupant_lib.new(bare_real_jid, nick);
+	local affiliation = self:get_affiliation(bare_real_jid);
+	occupant.role = self:get_default_role(affiliation);
+	return occupant;
+end
+
+-- nick is in the form of an in-room JID
+function room_mt:get_occupant_by_nick(nick)
+	local occupant = self._occupants[nick];
+	if occupant == nil then return nil end
+	return occupant_lib.copy(occupant);
+end
+
+do
+	local function next_copied_occupant(occupants, occupant_jid)
+		local next_occupant_jid, raw_occupant = next(occupants, occupant_jid);
+		if next_occupant_jid == nil then return nil end
+		return next_occupant_jid, occupant_lib.copy(raw_occupant);
+	end
+	-- FIXME Explain what 'read_only' is supposed to be
+	function room_mt:each_occupant(read_only) -- luacheck: ignore 212
+		return next_copied_occupant, self._occupants, nil;
 	end
 end
 
-function room_mt:broadcast_presence(stanza, sid, code, nick)
-	stanza = get_filtered_presence(stanza);
-	local occupant = self._occupants[stanza.attr.from];
-	stanza:tag("x", {xmlns='http://jabber.org/protocol/muc#user'})
-		:tag("item", {affiliation=occupant.affiliation or "none", role=occupant.role or "none", nick=nick}):up();
-	if code then
-		stanza:tag("status", {code=code}):up();
+function room_mt:has_occupant()
+	return next(self._occupants, nil) ~= nil
+end
+
+function room_mt:get_occupant_by_real_jid(real_jid)
+	local occupant_jid = self:get_occupant_jid(real_jid);
+	if occupant_jid == nil then return nil end
+	return self:get_occupant_by_nick(occupant_jid);
+end
+
+function room_mt:save_occupant(occupant)
+	occupant = occupant_lib.copy(occupant); -- So that occupant can be modified more
+	local id = occupant.nick
+
+	-- Need to maintain _jid_nick secondary index
+	local old_occupant = self._occupants[id];
+	if old_occupant then
+		for real_jid in old_occupant:each_session() do
+			self._jid_nick[real_jid] = nil;
+		end
+	end
+
+	local has_live_session = false
+	if occupant.role ~= nil then
+		for real_jid, presence in occupant:each_session() do
+			if presence.attr.type == nil then
+				has_live_session = true
+				self._jid_nick[real_jid] = occupant.nick;
+			end
+		end
+		if not has_live_session then
+			-- Has no live sessions left; they have left the room.
+			occupant.role = nil
+		end
+	end
+	if not has_live_session then
+		occupant = nil
 	end
-	self:broadcast_except_nick(stanza, stanza.attr.from);
-	local me = self._occupants[stanza.attr.from];
-	if me then
-		stanza:tag("status", {code='110'}):up();
-		stanza.attr.to = sid;
-		self:_route_stanza(stanza);
+	self._occupants[id] = occupant
+	return occupant
+end
+
+function room_mt:route_to_occupant(occupant, stanza)
+	local to = stanza.attr.to;
+	for jid in occupant:each_session() do
+		stanza.attr.to = jid;
+		self:route_stanza(stanza);
+	end
+	stanza.attr.to = to;
+end
+
+-- actor is the attribute table
+local function add_item(x, affiliation, role, jid, nick, actor_nick, actor_jid, reason)
+	x:tag("item", {affiliation = affiliation; role = role; jid = jid; nick = nick;})
+	if actor_nick or actor_jid then
+		x:tag("actor", {nick = actor_nick; jid = actor_jid;}):up()
+	end
+	if reason then
+		x:tag("reason"):text(reason):up()
 	end
+	x:up();
+	return x
 end
-function room_mt:broadcast_message(stanza, historic)
-	local to = stanza.attr.to;
-	local room_jid = self.jid;
+
+-- actor is (real) jid
+function room_mt:build_item_list(occupant, x, is_anonymous, nick, actor_nick, actor_jid, reason)
+	local affiliation = self:get_affiliation(occupant.bare_jid) or "none";
+	local role = occupant.role or "none";
+	if is_anonymous then
+		add_item(x, affiliation, role, nil, nick, actor_nick, actor_jid, reason);
+	else
+		for real_jid in occupant:each_session() do
+			add_item(x, affiliation, role, real_jid, nick, actor_nick, actor_jid, reason);
+		end
+	end
+	return x
+end
+
+function room_mt:broadcast_message(stanza)
+	if module:fire_event("muc-broadcast-message", {room = self, stanza = stanza}) then
+		return true;
+	end
+	self:broadcast(stanza);
+	return true;
+end
+
+-- Strip delay tags claiming to be from us
+module:hook("muc-occupant-groupchat", function (event)
+	local stanza = event.stanza;
+	local room = event.room;
+	local room_jid = room.jid;
 
 	stanza:maptags(function (child)
 		if child.name == "delay" and child.attr["xmlns"] == "urn:xmpp:delay" then
@@ -114,507 +195,595 @@
 		end
 		return child;
 	end)
+end);
 
-	for occupant, o_data in pairs(self._occupants) do
-		for jid in pairs(o_data.sessions) do
-			stanza.attr.to = jid;
-			self:_route_stanza(stanza);
+-- Broadcast a stanza to all occupants in the room.
+-- optionally checks conditional called with (nick, occupant)
+function room_mt:broadcast(stanza, cond_func)
+	for nick, occupant in self:each_occupant() do
+		if cond_func == nil or cond_func(nick, occupant) then
+			self:route_to_occupant(occupant, stanza)
 		end
 	end
-	stanza.attr.to = to;
-	if historic then -- add to history
-		return self:save_to_history(stanza)
+end
+
+local function can_see_real_jids(whois, occupant)
+	if whois == "anyone" then
+		return true;
+	elseif whois == "moderators" then
+		return valid_roles[occupant.role or "none"] >= valid_roles.moderator;
 	end
 end
-function room_mt:save_to_history(stanza)
-	local history = self._data['history'];
-	if not history then history = {}; self._data['history'] = history; end
-	stanza = st.clone(stanza);
-	stanza.attr.to = "";
-	local stamp = datetime.datetime();
-	stanza:tag("delay", {xmlns = "urn:xmpp:delay", from = self.jid, stamp = stamp}):up(); -- XEP-0203
-	stanza:tag("x", {xmlns = "jabber:x:delay", from = self.jid, stamp = datetime.legacy()}):up(); -- XEP-0091 (deprecated)
-	local entry = { stanza = stanza, stamp = stamp };
-	t_insert(history, entry);
-	while #history > (self._data.history_length or default_history_length) do t_remove(history, 1) end
+
+-- Broadcasts an occupant's presence to the whole room
+-- Takes the x element that goes into the stanzas
+function room_mt:publicise_occupant_status(occupant, x, nick, actor, reason)
+	local base_x = x.base or x;
+	-- Build real jid and (optionally) occupant jid template presences
+	local base_presence do
+		-- Try to use main jid's presence
+		local pr = occupant:get_presence();
+		if pr and (occupant.role ~= nil or pr.attr.type == "unavailable") then
+			base_presence = st.clone(pr);
+		else -- user is leaving but didn't send a leave presence. make one for them
+			base_presence = st.presence {from = occupant.nick; type = "unavailable";};
+		end
+	end
+
+	-- Fire event (before full_p and anon_p are created)
+	local event = {
+		room = self; stanza = base_presence; x = base_x;
+		occupant = occupant; nick = nick; actor = actor;
+		reason = reason;
+	}
+	module:fire_event("muc-broadcast-presence", event);
+
+	-- Allow muc-broadcast-presence listeners to change things
+	nick = event.nick;
+	actor = event.actor;
+	reason = event.reason;
+
+	local whois = self:get_whois();
+
+	local actor_nick;
+	if actor then
+		actor_nick = jid_resource(self:get_occupant_jid(actor));
+	end
+
+	local full_p, full_x;
+	local function get_full_p()
+		if full_p == nil then
+			full_x = st.clone(x.full or base_x);
+			self:build_item_list(occupant, full_x, false, nick, actor_nick, actor, reason);
+			full_p = st.clone(base_presence):add_child(full_x);
+		end
+		return full_p, full_x;
+	end
+
+	local anon_p, anon_x;
+	local function get_anon_p()
+		if anon_p == nil then
+			anon_x = st.clone(x.anon or base_x);
+			self:build_item_list(occupant, anon_x, true, nick, actor_nick, nil, reason);
+			anon_p = st.clone(base_presence):add_child(anon_x);
+		end
+		return anon_p, anon_x;
+	end
+
+	local self_p, self_x;
+	do
+		-- Can always see your own full jids
+		-- But not allowed to see actor's
+		self_x = st.clone(x.self or base_x);
+		self:build_item_list(occupant, self_x, false, nick, actor_nick, nil, reason);
+		self_p = st.clone(base_presence):add_child(self_x);
+	end
+
+	-- General populance
+	for occupant_nick, n_occupant in self:each_occupant() do
+		if occupant_nick ~= occupant.nick then
+			local pr;
+			if can_see_real_jids(whois, n_occupant) then
+				pr = get_full_p();
+			elseif occupant.bare_jid == n_occupant.bare_jid then
+				pr = self_p;
+			else
+				pr = get_anon_p();
+			end
+			self:route_to_occupant(n_occupant, pr);
+		end
+	end
+
+	-- Presences for occupant itself
+	self_x:tag("status", {code = "110";}):up();
+	if occupant.role == nil then
+		-- They get an unavailable
+		self:route_to_occupant(occupant, self_p);
+	else
+		-- use their own presences as templates
+		for full_jid, pr in occupant:each_session() do
+			pr = st.clone(pr);
+			pr.attr.to = full_jid;
+			pr:add_child(self_x);
+			self:route_stanza(pr);
+		end
+	end
 end
-function room_mt:broadcast_except_nick(stanza, nick)
-	for rnick, occupant in pairs(self._occupants) do
-		if rnick ~= nick then
-			for jid in pairs(occupant.sessions) do
-				stanza.attr.to = jid;
-				self:_route_stanza(stanza);
+
+function room_mt:send_occupant_list(to, filter)
+	local to_bare = jid_bare(to);
+	local is_anonymous = false;
+	local whois = self:get_whois();
+	if whois ~= "anyone" then
+		local affiliation = self:get_affiliation(to);
+		if affiliation ~= "admin" and affiliation ~= "owner" then
+			local occupant = self:get_occupant_by_real_jid(to);
+			if not (occupant and can_see_real_jids(whois, occupant)) then
+				is_anonymous = true;
 			end
 		end
 	end
+	for occupant_jid, occupant in self:each_occupant() do
+		if filter == nil or filter(occupant_jid, occupant) then
+			local x = st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'});
+			self:build_item_list(occupant, x, is_anonymous and to_bare ~= occupant.bare_jid); -- can always see your own jids
+			local pres = st.clone(occupant:get_presence());
+			pres.attr.to = to;
+			pres:add_child(x);
+			self:route_stanza(pres);
+		end
+	end
 end
 
-function room_mt:send_occupant_list(to)
-	local current_nick = self._jid_nick[to];
-	for occupant, o_data in pairs(self._occupants) do
-		if occupant ~= current_nick then
-			local pres = get_filtered_presence(o_data.sessions[o_data.jid]);
-			pres.attr.to, pres.attr.from = to, occupant;
-			pres:tag("x", {xmlns='http://jabber.org/protocol/muc#user'})
-				:tag("item", {affiliation=o_data.affiliation or "none", role=o_data.role or "none"}):up();
-			self:_route_stanza(pres);
-		end
+function room_mt:get_disco_info(stanza)
+	local node = stanza.tags[1].attr.node or "";
+	local reply = st.reply(stanza):tag("query", { xmlns = "http://jabber.org/protocol/disco#info", node = node });
+	local event_name = "muc-disco#info";
+	local event_data = { room = self, reply = reply, stanza = stanza };
+
+	if node ~= "" then
+		event_name = event_name.."/"..node;
+	else
+		event_data.form = dataform.new {
+			{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#roominfo" };
+		};
+		event_data.formdata = {};
 	end
+	module:fire_event(event_name, event_data);
+	if event_data.form then
+		reply:add_child(event_data.form:form(event_data.formdata, "result"));
+	end
+	return reply;
 end
-function room_mt:send_history(to, stanza)
-	local history = self._data['history']; -- send discussion history
-	if history then
-		local x_tag = stanza and stanza:get_child("x", "http://jabber.org/protocol/muc");
-		local history_tag = x_tag and x_tag:get_child("history", "http://jabber.org/protocol/muc");
-
-		local maxchars = history_tag and tonumber(history_tag.attr.maxchars);
-		if maxchars then maxchars = math.floor(maxchars); end
-
-		local maxstanzas = math.floor(history_tag and tonumber(history_tag.attr.maxstanzas) or #history);
-		if not history_tag then maxstanzas = 20; end
-
-		local seconds = history_tag and tonumber(history_tag.attr.seconds);
-		if seconds then seconds = datetime.datetime(os.time() - math.floor(seconds)); end
+module:hook("muc-disco#info", function(event)
+	event.reply:tag("feature", {var = "http://jabber.org/protocol/muc"}):up();
+	event.reply:tag("feature", {var = "http://jabber.org/protocol/muc#stable_id"}):up();
+end);
+module:hook("muc-disco#info", function(event)
+	table.insert(event.form, { name = "muc#roominfo_occupants", label = "Number of occupants" });
+	event.formdata["muc#roominfo_occupants"] = tostring(iterators.count(event.room:each_occupant()));
+end);
 
-		local since = history_tag and history_tag.attr.since;
-		if since then since = datetime.parse(since); since = since and datetime.datetime(since); end
-		if seconds and (not since or since < seconds) then since = seconds; end
-
-		local n = 0;
-		local charcount = 0;
+function room_mt:get_disco_items(stanza) -- luacheck: ignore 212
+	return st.reply(stanza):query("http://jabber.org/protocol/disco#items");
+end
 
-		for i=#history,1,-1 do
-			local entry = history[i];
-			if maxchars then
-				if not entry.chars then
-					entry.stanza.attr.to = "";
-					entry.chars = #tostring(entry.stanza);
-				end
-				charcount = charcount + entry.chars + #to;
-				if charcount > maxchars then break; end
-			end
-			if since and since > entry.stamp then break; end
-			if n + 1 > maxstanzas then break; end
-			n = n + 1;
-		end
-		for i=#history-n+1,#history do
-			local msg = history[i].stanza;
-			msg.attr.to = to;
-			self:_route_stanza(msg);
-		end
+function room_mt:handle_kickable(origin, stanza) -- luacheck: ignore 212
+	local real_jid = stanza.attr.from;
+	local occupant = self:get_occupant_by_real_jid(real_jid);
+	if occupant == nil then return nil; end
+	local type, condition, text = stanza:get_error();
+	local error_message = "Kicked: "..(condition and condition:gsub("%-", " ") or "presence error");
+	if text and self:get_whois() == "anyone" then
+		error_message = error_message..": "..text;
 	end
-end
-function room_mt:send_subject(to)
-	self:_route_stanza(st.message({type='groupchat', from=self._data['subject_from'] or self.jid, to=to}):tag("subject"):text(self._data['subject']));
+	occupant:set_session(real_jid, st.presence({type="unavailable"})
+		:tag('status'):text(error_message));
+	local is_last_session = occupant.jid == real_jid;
+	if is_last_session then
+		occupant.role = nil;
+	end
+	local new_occupant = self:save_occupant(occupant);
+	local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
+	if is_last_session then
+		x:tag("status", {code = "333"});
+	end
+	self:publicise_occupant_status(new_occupant or occupant, x);
+	if is_last_session then
+		module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;});
+	end
+	return true;
 end
 
-function room_mt:get_disco_info(stanza)
-	local count = 0; for _ in pairs(self._occupants) do count = count + 1; end
-	local reply = st.reply(stanza):query("http://jabber.org/protocol/disco#info")
-		:tag("identity", {category="conference", type="text", name=self:get_name()}):up()
-		:tag("feature", {var="http://jabber.org/protocol/muc"}):up()
-		:tag("feature", {var="http://jabber.org/protocol/muc#stable_id"}):up()
-		:tag("feature", {var=self:get_password() and "muc_passwordprotected" or "muc_unsecured"}):up()
-		:tag("feature", {var=self:get_moderated() and "muc_moderated" or "muc_unmoderated"}):up()
-		:tag("feature", {var=self:get_members_only() and "muc_membersonly" or "muc_open"}):up()
-		:tag("feature", {var=self:get_persistent() and "muc_persistent" or "muc_temporary"}):up()
-		:tag("feature", {var=self:get_hidden() and "muc_hidden" or "muc_public"}):up()
-		:tag("feature", {var=self._data.whois ~= "anyone" and "muc_semianonymous" or "muc_nonanonymous"}):up()
-	;
-	local dataform = dataform.new({
-		{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#roominfo" },
-		{ name = "muc#roominfo_description", label = "Description", value = "" },
-		{ name = "muc#roominfo_occupants", label = "Number of occupants", value = "" }
+-- Give the room creator owner affiliation
+module:hook("muc-room-pre-create", function(event)
+	event.room:set_affiliation(true, jid_bare(event.stanza.attr.from), "owner");
+end, -1);
+
+-- check if user is banned
+module:hook("muc-occupant-pre-join", function(event)
+	local room, stanza = event.room, event.stanza;
+	local affiliation = room:get_affiliation(stanza.attr.from);
+	if affiliation == "outcast" then
+		local reply = st.error_reply(stanza, "auth", "forbidden"):up();
+		reply.tags[1].attr.code = "403";
+		event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+		return true;
+	end
+end, -10);
+
+module:hook("muc-occupant-pre-join", function(event)
+	local nick = jid_resource(event.occupant.nick);
+	if not nick:find("%S") then
+		event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden"));
+		return true;
+	end
+end, 1);
+
+module:hook("muc-occupant-pre-change", function(event)
+	if not jid_resource(event.dest_occupant.nick):find("%S") then
+		event.origin.send(st.error_reply(event.stanza, "modify", "not-allowed", "Invisible Nicknames are forbidden"));
+		return true;
+	end
+end, 1);
+
+function room_mt:handle_first_presence(origin, stanza)
+	if not stanza:get_child("x", "http://jabber.org/protocol/muc") then
+		module:log("debug", "Room creation without <x>, possibly desynced");
+
+		origin.send(st.error_reply(stanza, "cancel", "item-not-found"));
+		return true;
+	end
+
+	local real_jid = stanza.attr.from;
+	local dest_jid = stanza.attr.to;
+	local bare_jid = jid_bare(real_jid);
+	if module:fire_event("muc-room-pre-create", {
+			room = self;
+			origin = origin;
+			stanza = stanza;
+		}) then return true; end
+	local is_first_dest_session = true;
+	local dest_occupant = self:new_occupant(bare_jid, dest_jid);
+
+	local orig_nick = dest_occupant.nick;
+	if module:fire_event("muc-occupant-pre-join", {
+		room = self;
+		origin = origin;
+		stanza = stanza;
+		is_first_session = is_first_dest_session;
+		is_new_room = true;
+		occupant = dest_occupant;
+	}) then return true; end
+	local nick_changed = orig_nick ~= dest_occupant.nick;
+
+	dest_occupant:set_session(real_jid, stanza);
+	local dest_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
+	dest_x:tag("status", {code = "201"}):up();
+	if self:get_whois() == "anyone" then
+		dest_x:tag("status", {code = "100"}):up();
+	end
+	if nick_changed then
+		dest_x:tag("status", {code = "210"}):up();
+	end
+	self:save_occupant(dest_occupant);
+
+	self:publicise_occupant_status(dest_occupant, dest_x);
+
+	module:fire_event("muc-occupant-joined", {
+		room = self;
+		nick = dest_occupant.nick;
+		occupant = dest_occupant;
+		stanza = stanza;
+		origin = origin;
 	});
-	local formdata = {
-		["muc#roominfo_description"] = self:get_description(),
-		["muc#roominfo_occupants"] = tostring(count),
-	};
-	module:fire_event("muc-disco#info", { room = self, reply = reply, form = dataform, formdata = formdata });
-	reply:add_child(dataform:form(formdata, 'result'))
-	return reply;
-end
-function room_mt:get_disco_items(stanza)
-	return st.reply(stanza):query("http://jabber.org/protocol/disco#items");
-end
-function room_mt:set_subject(current_nick, subject)
-	if subject == "" then subject = nil; end
-	self._data['subject'] = subject;
-	self._data['subject_from'] = current_nick;
-	if self.save then self:save(); end
-	local msg = st.message({type='groupchat', from=current_nick})
-		:tag('subject'):text(subject):up();
-	self:broadcast_message(msg, false);
+	module:fire_event("muc-occupant-session-new", {
+		room = self;
+		nick = dest_occupant.nick;
+		occupant = dest_occupant;
+		stanza = stanza;
+		origin = origin;
+		jid = real_jid;
+	});
+	module:fire_event("muc-room-created", {
+		room = self;
+		creator = dest_occupant;
+		stanza = stanza;
+		origin = origin;
+	});
 	return true;
 end
 
-local function build_unavailable_presence_from_error(stanza)
-	local type, condition, text = stanza:get_error();
-	local error_message = "Kicked: "..(condition and condition:gsub("%-", " ") or "presence error");
-	if text then
-		error_message = error_message..": "..text;
+function room_mt:handle_normal_presence(origin, stanza)
+	local type = stanza.attr.type;
+	local real_jid = stanza.attr.from;
+	local bare_jid = jid_bare(real_jid);
+	local orig_occupant = self:get_occupant_by_real_jid(real_jid);
+	local muc_x = stanza:get_child("x", "http://jabber.org/protocol/muc");
+
+	if orig_occupant == nil and not muc_x and stanza.attr.type == nil then
+		module:log("debug", "Attempted join without <x>, possibly desynced");
+		origin.send(st.error_reply(stanza, "cancel", "item-not-found",
+			"You must join the room before sending presence updates"));
+		return true;
+	end
+
+	local is_first_dest_session;
+	local dest_occupant;
+	if type == "unavailable" then
+		if orig_occupant == nil then return true; end -- Unavailable from someone not in the room
+		-- dest_occupant = nil
+	elseif orig_occupant and orig_occupant.nick == stanza.attr.to then -- Just a presence update
+		log("debug", "presence update for %s from session %s", orig_occupant.nick, real_jid);
+		dest_occupant = orig_occupant;
+	else
+		local dest_jid = stanza.attr.to;
+		dest_occupant = self:get_occupant_by_nick(dest_jid);
+		if dest_occupant == nil then
+			log("debug", "no occupant found for %s; creating new occupant object for %s", dest_jid, real_jid);
+			is_first_dest_session = true;
+			dest_occupant = self:new_occupant(bare_jid, dest_jid);
+		else
+			is_first_dest_session = false;
+		end
+	end
+	local is_last_orig_session;
+	if orig_occupant ~= nil then
+		-- Is there are least 2 sessions?
+		local iter, ob, last = orig_occupant:each_session();
+		is_last_orig_session = iter(ob, iter(ob, last)) == nil;
+	end
+
+	local orig_nick = dest_occupant and dest_occupant.nick;
+
+	local event, event_name = {
+		room = self;
+		origin = origin;
+		stanza = stanza;
+		is_first_session = is_first_dest_session;
+		is_last_session = is_last_orig_session;
+	};
+	if orig_occupant == nil then
+		event_name = "muc-occupant-pre-join";
+		event.occupant = dest_occupant;
+	elseif dest_occupant == nil then
+		event_name = "muc-occupant-pre-leave";
+		event.occupant = orig_occupant;
+	else
+		event_name = "muc-occupant-pre-change";
+		event.orig_occupant = orig_occupant;
+		event.dest_occupant = dest_occupant;
+	end
+	if module:fire_event(event_name, event) then return true; end
+
+	local nick_changed = dest_occupant and orig_nick ~= dest_occupant.nick;
+
+	-- Check for nick conflicts
+	if dest_occupant ~= nil and not is_first_dest_session
+		and bare_jid ~= jid_bare(dest_occupant.bare_jid) then
+		-- new nick or has different bare real jid
+		log("debug", "%s couldn't join due to nick conflict: %s", real_jid, dest_occupant.nick);
+		local reply = st.error_reply(stanza, "cancel", "conflict"):up();
+		reply.tags[1].attr.code = "409";
+		origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+		return true;
 	end
-	return st.presence({type='unavailable', from=stanza.attr.from, to=stanza.attr.to})
-		:tag('status'):text(error_message);
+
+	-- Send presence stanza about original occupant
+	if orig_occupant ~= nil and orig_occupant ~= dest_occupant then
+		local orig_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
+		local dest_nick;
+		if dest_occupant == nil then -- Session is leaving
+			log("debug", "session %s is leaving occupant %s", real_jid, orig_occupant.nick);
+			if is_last_orig_session then
+				orig_occupant.role = nil;
+			end
+			orig_occupant:set_session(real_jid, stanza);
+		else
+			log("debug", "session %s is changing from occupant %s to %s", real_jid, orig_occupant.nick, dest_occupant.nick);
+			local generated_unavail = st.presence {from = orig_occupant.nick, to = real_jid, type = "unavailable"};
+			orig_occupant:set_session(real_jid, generated_unavail);
+			dest_nick = jid_resource(dest_occupant.nick);
+			if not is_first_dest_session then -- User is swapping into another pre-existing session
+				log("debug", "session %s is swapping into multisession %s, showing it leave.", real_jid, dest_occupant.nick);
+				-- Show the other session leaving
+				local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
+				add_item(x, self:get_affiliation(bare_jid), "none");
+				local pr = st.presence{from = dest_occupant.nick, to = real_jid, type = "unavailable"}
+					:tag("status"):text("you are joining pre-existing session " .. dest_nick):up()
+					:add_child(x);
+				self:route_stanza(pr);
+			end
+			if is_first_dest_session and is_last_orig_session then -- Normal nick change
+				log("debug", "no sessions in %s left; publicly marking as nick change", orig_occupant.nick);
+				orig_x:tag("status", {code = "303";}):up();
+			else -- The session itself always needs to see a nick change
+				-- don't want to get our old nick's available presence,
+				-- so remove our session from there, and manually generate an unavailable
+				orig_occupant:remove_session(real_jid);
+				log("debug", "generating nick change for %s", real_jid);
+				local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
+				-- COMPAT: clients get confused if they see other items besides their own
+				-- self:build_item_list(orig_occupant, x, false, dest_nick);
+				add_item(x, self:get_affiliation(bare_jid), orig_occupant.role, real_jid, dest_nick);
+				x:tag("status", {code = "303";}):up();
+				x:tag("status", {code = "110";}):up();
+				self:route_stanza(generated_unavail:add_child(x));
+				dest_nick = nil; -- set dest_nick to nil; so general populance doesn't see it for whole orig_occupant
+			end
+		end
+
+		self:save_occupant(orig_occupant);
+		self:publicise_occupant_status(orig_occupant, orig_x, dest_nick);
+
+		if is_last_orig_session then
+			module:fire_event("muc-occupant-left", {
+				room = self;
+				nick = orig_occupant.nick;
+				occupant = orig_occupant;
+				origin = origin;
+				stanza = stanza;
+			});
+		end
+	end
+
+	if dest_occupant ~= nil then
+		dest_occupant:set_session(real_jid, stanza);
+		self:save_occupant(dest_occupant);
+
+		if orig_occupant == nil or muc_x then
+			-- Send occupant list to newly joined or desynced user
+			self:send_occupant_list(real_jid, function(nick, occupant) -- luacheck: ignore 212
+				-- Don't include self
+				return occupant:get_presence(real_jid) == nil;
+			end)
+		end
+		local dest_x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
+		local self_x = st.clone(dest_x);
+		if orig_occupant == nil and self:get_whois() == "anyone" then
+			self_x:tag("status", {code = "100"}):up();
+		end
+		if nick_changed then
+			self_x:tag("status", {code="210"}):up();
+		end
+		self:publicise_occupant_status(dest_occupant, {base=dest_x,self=self_x});
+
+		if orig_occupant ~= nil and orig_occupant ~= dest_occupant and not is_last_orig_session then
+			-- If user is swapping and wasn't last original session
+			log("debug", "session %s split nicks; showing %s rejoining", real_jid, orig_occupant.nick);
+			-- Show the original nick joining again
+			local pr = st.clone(orig_occupant:get_presence());
+			pr.attr.to = real_jid;
+			local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user";});
+			self:build_item_list(orig_occupant, x, false);
+			-- TODO: new status code to inform client this was the multi-session it left?
+			pr:add_child(x);
+			self:route_stanza(pr);
+		end
+
+		if orig_occupant == nil or muc_x then
+			if is_first_dest_session then
+				module:fire_event("muc-occupant-joined", {
+					room = self;
+					nick = dest_occupant.nick;
+					occupant = dest_occupant;
+					stanza = stanza;
+					origin = origin;
+				});
+			end
+			module:fire_event("muc-occupant-session-new", {
+				room = self;
+				nick = dest_occupant.nick;
+				occupant = dest_occupant;
+				stanza = stanza;
+				origin = origin;
+				jid = real_jid;
+			});
+		end
+	end
+	return true;
 end
 
-function room_mt:set_name(name)
-	if name == "" or type(name) ~= "string" or name == (jid_split(self.jid)) then name = nil; end
-	if self._data.name ~= name then
-		self._data.name = name;
-		if self.save then self:save(true); end
-	end
-end
-function room_mt:get_name()
-	return self._data.name or jid_split(self.jid);
-end
-function room_mt:set_description(description)
-	if description == "" or type(description) ~= "string" then description = nil; end
-	if self._data.description ~= description then
-		self._data.description = description;
-		if self.save then self:save(true); end
-	end
-end
-function room_mt:get_description()
-	return self._data.description;
-end
-function room_mt:set_password(password)
-	if password == "" or type(password) ~= "string" then password = nil; end
-	if self._data.password ~= password then
-		self._data.password = password;
-		if self.save then self:save(true); end
-	end
-end
-function room_mt:get_password()
-	return self._data.password;
-end
-function room_mt:set_moderated(moderated)
-	moderated = moderated and true or nil;
-	if self._data.moderated ~= moderated then
-		self._data.moderated = moderated;
-		if self.save then self:save(true); end
-	end
-end
-function room_mt:get_moderated()
-	return self._data.moderated;
-end
-function room_mt:set_members_only(members_only)
-	members_only = members_only and true or nil;
-	if self._data.members_only ~= members_only then
-		self._data.members_only = members_only;
-		if self.save then self:save(true); end
+function room_mt:handle_presence_to_occupant(origin, stanza)
+	local type = stanza.attr.type;
+	if type == "error" then -- error, kick em out!
+		return self:handle_kickable(origin, stanza)
+	elseif type == nil or type == "unavailable" then
+		return self:handle_normal_presence(origin, stanza);
+	elseif type ~= 'result' then -- bad type
+		if type ~= 'visible' and type ~= 'invisible' then -- COMPAT ejabberd can broadcast or forward XEP-0018 presences
+			origin.send(st.error_reply(stanza, "modify", "bad-request")); -- FIXME correct error?
+		end
 	end
-end
-function room_mt:get_members_only()
-	return self._data.members_only;
-end
-function room_mt:set_persistent(persistent)
-	persistent = persistent and true or nil;
-	if self._data.persistent ~= persistent then
-		self._data.persistent = persistent;
-		if self.save then self:save(true); end
-	end
-end
-function room_mt:get_persistent()
-	return self._data.persistent;
-end
-function room_mt:set_hidden(hidden)
-	hidden = hidden and true or nil;
-	if self._data.hidden ~= hidden then
-		self._data.hidden = hidden;
-		if self.save then self:save(true); end
-	end
-end
-function room_mt:get_hidden()
-	return self._data.hidden;
-end
-function room_mt:get_public()
-	return not self:get_hidden();
-end
-function room_mt:set_public(public)
-	return self:set_hidden(not public);
-end
-function room_mt:set_changesubject(changesubject)
-	changesubject = changesubject and true or nil;
-	if self._data.changesubject ~= changesubject then
-		self._data.changesubject = changesubject;
-		if self.save then self:save(true); end
-	end
-end
-function room_mt:get_changesubject()
-	return self._data.changesubject;
-end
-function room_mt:get_historylength()
-	return self._data.history_length or default_history_length;
-end
-function room_mt:set_historylength(length)
-	length = math.min(tonumber(length) or default_history_length, max_history_length or math.huge);
-	if length == default_history_length then
-		length = nil;
-	end
-	self._data.history_length = length;
+	return true;
 end
 
-
-local valid_whois = { moderators = true, anyone = true };
-
-function room_mt:set_whois(whois)
-	if valid_whois[whois] and self._data.whois ~= whois then
-		self._data.whois = whois;
-		if self.save then self:save(true); end
+function room_mt:handle_iq_to_occupant(origin, stanza)
+	local from, to = stanza.attr.from, stanza.attr.to;
+	local type = stanza.attr.type;
+	local id = stanza.attr.id;
+	local occupant = self:get_occupant_by_nick(to);
+	if (type == "error" or type == "result") then
+		do -- deconstruct_stanza_id
+			if not occupant then return nil; end
+			local from_jid, orig_id, to_jid_hash = (base64.decode(id) or ""):match("^(%Z+)%z(%Z*)%z(.+)$");
+			if not(from == from_jid or from == jid_bare(from_jid)) then return nil; end
+			local from_occupant_jid = self:get_occupant_jid(from_jid);
+			if from_occupant_jid == nil then return nil; end
+			local session_jid
+			for to_jid in occupant:each_session() do
+				if md5(to_jid) == to_jid_hash then
+					session_jid = to_jid;
+					break;
+				end
+			end
+			if session_jid == nil then return nil; end
+			stanza.attr.from, stanza.attr.to, stanza.attr.id = from_occupant_jid, session_jid, orig_id;
+		end
+		log("debug", "%s sent private iq stanza to %s (%s)", from, to, stanza.attr.to);
+		self:route_stanza(stanza);
+		stanza.attr.from, stanza.attr.to, stanza.attr.id = from, to, id;
+		return true;
+	else -- Type is "get" or "set"
+		local current_nick = self:get_occupant_jid(from);
+		if not current_nick then
+			origin.send(st.error_reply(stanza, "cancel", "not-acceptable"));
+			return true;
+		end
+		if not occupant then -- recipient not in room
+			origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room"));
+			return true;
+		end
+		-- XEP-0410 MUC Self-Ping #1220
+		if to == current_nick and stanza.attr.type == "get" and stanza:get_child("ping", "urn:xmpp:ping") then
+			self:route_stanza(st.reply(stanza));
+			return true;
+		end
+		do -- construct_stanza_id
+			stanza.attr.id = base64.encode(occupant.jid.."\0"..stanza.attr.id.."\0"..md5(from));
+		end
+		stanza.attr.from, stanza.attr.to = current_nick, occupant.jid;
+		log("debug", "%s sent private iq stanza to %s (%s)", from, to, occupant.jid);
+		local iq_ns = stanza.tags[1].attr.xmlns;
+		if iq_ns == 'vcard-temp' or iq_ns == "http://jabber.org/protocol/pubsub" or iq_ns == "urn:ietf:params:xml:ns:vcard-4.0" then
+			stanza.attr.to = jid_bare(stanza.attr.to);
+		end
+		self:route_stanza(stanza);
+		stanza.attr.from, stanza.attr.to, stanza.attr.id = from, to, id;
+		return true;
 	end
 end
 
-function room_mt:get_whois()
-	return self._data.whois;
-end
-
-local function construct_stanza_id(room, stanza)
-	local from_jid, to_nick = stanza.attr.from, stanza.attr.to;
-	local from_nick = room._jid_nick[from_jid];
-	local occupant = room._occupants[to_nick];
-	local to_jid = occupant.jid;
-
-	return from_nick, to_jid, base64.encode(to_jid.."\0"..stanza.attr.id.."\0"..md5(from_jid));
-end
-local function deconstruct_stanza_id(room, stanza)
-	local from_jid_possiblybare, to_nick = stanza.attr.from, stanza.attr.to;
-	local from_jid, id, to_jid_hash = (base64.decode(stanza.attr.id) or ""):match("^(%Z+)%z(%Z*)%z(.+)$");
-	local from_nick = room._jid_nick[from_jid];
-
-	if not(from_nick) then return; end
-	if not(from_jid_possiblybare == from_jid or from_jid_possiblybare == jid_bare(from_jid)) then return; end
-
-	local occupant = room._occupants[to_nick];
-	for to_jid in pairs(occupant and occupant.sessions or {}) do
-		if md5(to_jid) == to_jid_hash then
-			return from_nick, to_jid, id;
-		end
-	end
-end
-
-
-function room_mt:handle_to_occupant(origin, stanza) -- PM, vCards, etc
+function room_mt:handle_message_to_occupant(origin, stanza)
 	local from, to = stanza.attr.from, stanza.attr.to;
-	local room = jid_bare(to);
-	local current_nick = self._jid_nick[from];
+	local current_nick = self:get_occupant_jid(from);
 	local type = stanza.attr.type;
-	log("debug", "room: %s, current_nick: %s, stanza: %s", room or "nil", current_nick or "nil", stanza:top_tag());
-	if (select(2, jid_split(from)) == muc_domain) then error("Presence from the MUC itself!!!"); end
-	if stanza.name == "presence" then
-		local pr = get_filtered_presence(stanza);
-		pr.attr.from = current_nick;
-		if type == "error" then -- error, kick em out!
-			if current_nick then
-				log("debug", "kicking %s from %s", current_nick, room);
-				self:handle_to_occupant(origin, build_unavailable_presence_from_error(stanza));
-			end
-		elseif type == "unavailable" then -- unavailable
-			if current_nick then
-				log("debug", "%s leaving %s", current_nick, room);
-				self._jid_nick[from] = nil;
-				local occupant = self._occupants[current_nick];
-				local new_jid = next(occupant.sessions);
-				if new_jid == from then new_jid = next(occupant.sessions, new_jid); end
-				if new_jid then
-					local jid = occupant.jid;
-					occupant.jid = new_jid;
-					occupant.sessions[from] = nil;
-					pr.attr.to = from;
-					pr:tag("x", {xmlns='http://jabber.org/protocol/muc#user'})
-						:tag("item", {affiliation=occupant.affiliation or "none", role='none'}):up()
-						:tag("status", {code='110'}):up();
-					self:_route_stanza(pr);
-					if jid ~= new_jid then
-						pr = st.clone(occupant.sessions[new_jid])
-							:tag("x", {xmlns='http://jabber.org/protocol/muc#user'})
-							:tag("item", {affiliation=occupant.affiliation or "none", role=occupant.role or "none"});
-						pr.attr.from = current_nick;
-						self:broadcast_except_nick(pr, current_nick);
-					end
-				else
-					occupant.role = 'none';
-					self:broadcast_presence(pr, from);
-					self._occupants[current_nick] = nil;
-				end
-			end
-		elseif not type then -- available
-			if current_nick then
-				--if #pr == #stanza or current_nick ~= to then -- commented because google keeps resending directed presence
-					if current_nick == to then -- simple presence
-						log("debug", "%s broadcasted presence", current_nick);
-						self._occupants[current_nick].sessions[from] = pr;
-						self:broadcast_presence(pr, from);
-					else -- change nick
-						-- a MUC service MUST NOT allow empty or invisible Room Nicknames
-						-- (i.e., Room Nicknames that consist only of one or more space characters).
-						if not select(3, jid_split(to)):find("[^ ]") then -- resourceprep turns all whitespace into 0x20
-							module:log("debug", "Rejecting invisible nickname");
-							origin.send(st.error_reply(stanza, "cancel", "not-allowed"));
-							return;
-						end
-						local occupant = self._occupants[current_nick];
-						local is_multisession = next(occupant.sessions, next(occupant.sessions));
-						if self._occupants[to] or is_multisession then
-							log("debug", "%s couldn't change nick", current_nick);
-							local reply = st.error_reply(stanza, "cancel", "conflict"):up();
-							reply.tags[1].attr.code = "409";
-							origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
-						else
-							local data = self._occupants[current_nick];
-							local to_nick = select(3, jid_split(to));
-							if to_nick then
-								log("debug", "%s (%s) changing nick to %s", current_nick, data.jid, to);
-								local p = st.presence({type='unavailable', from=current_nick});
-								self:broadcast_presence(p, from, '303', to_nick);
-								self._occupants[current_nick] = nil;
-								self._occupants[to] = data;
-								self._jid_nick[from] = to;
-								pr.attr.from = to;
-								self._occupants[to].sessions[from] = pr;
-								self:broadcast_presence(pr, from);
-							else
-								--TODO malformed-jid
-							end
-						end
-					end
-				--else -- possible rejoin
-				--	log("debug", "%s had connection replaced", current_nick);
-				--	self:handle_to_occupant(origin, st.presence({type='unavailable', from=from, to=to})
-				--		:tag('status'):text('Replaced by new connection'):up()); -- send unavailable
-				--	self:handle_to_occupant(origin, stanza); -- resend available
-				--end
-			else -- enter room
-				-- a MUC service MUST NOT allow empty or invisible Room Nicknames
-				-- (i.e., Room Nicknames that consist only of one or more space characters).
-				if not select(3, jid_split(to)):find("[^ ]") then -- resourceprep turns all whitespace into 0x20
-						module:log("debug", "Rejecting invisible nickname");
-						origin.send(st.error_reply(stanza, "cancel", "not-allowed"));
-						return;
-				end
-				local new_nick = to;
-				local is_merge;
-				if self._occupants[to] then
-					if jid_bare(from) ~= jid_bare(self._occupants[to].jid) then
-						new_nick = nil;
-					end
-					is_merge = true;
-				end
-				local password = stanza:get_child("x", "http://jabber.org/protocol/muc");
-				password = password and password:get_child("password", "http://jabber.org/protocol/muc");
-				password = password and password[1] ~= "" and password[1];
-				if self:get_password() and self:get_password() ~= password then
-					log("debug", "%s couldn't join due to invalid password: %s", from, to);
-					local reply = st.error_reply(stanza, "auth", "not-authorized"):up();
-					reply.tags[1].attr.code = "401";
-					origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
-				elseif not new_nick then
-					log("debug", "%s couldn't join due to nick conflict: %s", from, to);
-					local reply = st.error_reply(stanza, "cancel", "conflict"):up();
-					reply.tags[1].attr.code = "409";
-					origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
-				else
-					log("debug", "%s joining as %s", from, to);
-					if not next(self._affiliations) then -- new room, no owners
-						self._affiliations[jid_bare(from)] = "owner";
-						if self.locked and not stanza:get_child("x", "http://jabber.org/protocol/muc") then
-							self.locked = nil; -- Older groupchat protocol doesn't lock
-						end
-					elseif self.locked then -- Deny entry
-						module:log("debug", "Room is locked, denying entry");
-						origin.send(st.error_reply(stanza, "cancel", "item-not-found"));
-						return;
-					end
-					local affiliation = self:get_affiliation(from);
-					local role = self:get_default_role(affiliation)
-					if role then -- new occupant
-						if not is_merge then
-							self._occupants[to] = {affiliation=affiliation, role=role, jid=from, sessions={[from]=get_filtered_presence(stanza)}};
-						else
-							self._occupants[to].sessions[from] = get_filtered_presence(stanza);
-						end
-						self._jid_nick[from] = to;
-						self:send_occupant_list(from);
-						pr.attr.from = to;
-						pr:tag("x", {xmlns='http://jabber.org/protocol/muc#user'})
-							:tag("item", {affiliation=affiliation or "none", role=role or "none"}):up();
-						if not is_merge then
-							self:broadcast_except_nick(pr, to);
-						end
-						pr:tag("status", {code='110'}):up();
-						if self._data.whois == 'anyone' then
-							pr:tag("status", {code='100'}):up();
-						end
-						if self.locked then
-							pr:tag("status", {code='201'}):up();
-						end
-						pr.attr.to = from;
-						self:_route_stanza(pr);
-						self:send_history(from, stanza);
-						self:send_subject(from);
-					elseif not affiliation then -- registration required for entering members-only room
-						local reply = st.error_reply(stanza, "auth", "registration-required"):up();
-						reply.tags[1].attr.code = "407";
-						origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
-					else -- banned
-						local reply = st.error_reply(stanza, "auth", "forbidden"):up();
-						reply.tags[1].attr.code = "403";
-						origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
-					end
-				end
-			end
-		elseif type ~= 'result' then -- bad type
-			if type ~= 'visible' and type ~= 'invisible' then -- COMPAT ejabberd can broadcast or forward XEP-0018 presences
-				origin.send(st.error_reply(stanza, "modify", "bad-request")); -- FIXME correct error?
-			end
-		end
-	elseif not current_nick then -- not in room
-		if (type == "error" or type == "result") and stanza.name == "iq" then
-			local id = stanza.attr.id;
-			stanza.attr.from, stanza.attr.to, stanza.attr.id = deconstruct_stanza_id(self, stanza);
-			if stanza.attr.id then
-				self:_route_stanza(stanza);
-			end
-			stanza.attr.from, stanza.attr.to, stanza.attr.id = from, to, id;
-		elseif type ~= "error" then
+	if not current_nick then -- not in room
+		if type ~= "error" then
 			origin.send(st.error_reply(stanza, "cancel", "not-acceptable"));
 		end
-	elseif stanza.name == "message" and type == "groupchat" then -- groupchat messages not allowed in PM
+		return true;
+	end
+	if type == "groupchat" then -- groupchat messages not allowed in PM
 		origin.send(st.error_reply(stanza, "modify", "bad-request"));
-	elseif current_nick and stanza.name == "message" and type == "error" and is_kickable_error(stanza) then
+		return true;
+	elseif type == "error" and is_kickable_error(stanza) then
 		log("debug", "%s kicked from %s for sending an error message", current_nick, self.jid);
-		self:handle_to_occupant(origin, build_unavailable_presence_from_error(stanza)); -- send unavailable
-	else -- private stanza
-		local o_data = self._occupants[to];
-		if o_data then
-			log("debug", "%s sent private stanza to %s (%s)", from, to, o_data.jid);
-			if stanza.name == "iq" then
-				local id = stanza.attr.id;
-				if stanza.attr.type == "get" or stanza.attr.type == "set" then
-					stanza.attr.from, stanza.attr.to, stanza.attr.id = construct_stanza_id(self, stanza);
-				else
-					stanza.attr.from, stanza.attr.to, stanza.attr.id = deconstruct_stanza_id(self, stanza);
-				end
-				if type == 'get' and stanza.tags[1].attr.xmlns == 'vcard-temp' then
-					stanza.attr.to = jid_bare(stanza.attr.to);
-				end
-				if stanza.attr.id then
-					self:_route_stanza(stanza);
-				end
-				stanza.attr.from, stanza.attr.to, stanza.attr.id = from, to, id;
-			else -- message
-				stanza:tag("x", { xmlns = "http://jabber.org/protocol/muc#user" }):up();
-				stanza.attr.from = current_nick;
-				for jid in pairs(o_data.sessions) do
-					stanza.attr.to = jid;
-					self:_route_stanza(stanza);
-				end
-				stanza.attr.from, stanza.attr.to = from, to;
-			end
-		elseif type ~= "error" and type ~= "result" then -- recipient not in room
-			origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room"));
-		end
+		return self:handle_kickable(origin, stanza); -- send unavailable
+	end
+
+	local o_data = self:get_occupant_by_nick(to);
+	if not o_data then
+		origin.send(st.error_reply(stanza, "cancel", "item-not-found", "Recipient not in room"));
+		return true;
 	end
+	log("debug", "%s sent private message stanza to %s (%s)", from, to, o_data.jid);
+	stanza:tag("x", { xmlns = "http://jabber.org/protocol/muc#user" }):up();
+	stanza.attr.from = current_nick;
+	self:route_to_occupant(o_data, stanza)
+	-- TODO: Remove x tag?
+	stanza.attr.from = from;
+	return true;
 end
 
 function room_mt:send_form(origin, stanza)
@@ -631,546 +800,628 @@
 			name = 'FORM_TYPE',
 			type = 'hidden',
 			value = 'http://jabber.org/protocol/muc#roomconfig'
-		},
-		{
-			name = 'muc#roomconfig_roomname',
-			type = 'text-single',
-			label = 'Name',
-			value = self:get_name() or "",
-		},
-		{
-			name = 'muc#roomconfig_roomdesc',
-			type = 'text-single',
-			label = 'Description',
-			value = self:get_description() or "",
-		},
-		{
-			name = 'muc#roomconfig_persistentroom',
-			type = 'boolean',
-			label = 'Make Room Persistent?',
-			value = self:get_persistent()
-		},
-		{
-			name = 'muc#roomconfig_publicroom',
-			type = 'boolean',
-			label = 'Make Room Publicly Searchable?',
-			value = not self:get_hidden()
-		},
-		{
-			name = 'muc#roomconfig_changesubject',
-			type = 'boolean',
-			label = 'Allow Occupants to Change Subject?',
-			value = self:get_changesubject()
-		},
-		{
-			name = 'muc#roomconfig_whois',
-			type = 'list-single',
-			label = 'Who May Discover Real JIDs?',
-			value = {
-				{ value = 'moderators', label = 'Moderators Only', default = self._data.whois == 'moderators' },
-				{ value = 'anyone',     label = 'Anyone',          default = self._data.whois == 'anyone' }
-			}
-		},
-		{
-			name = 'muc#roomconfig_roomsecret',
-			type = 'text-private',
-			label = 'Password',
-			value = self:get_password() or "",
-		},
-		{
-			name = 'muc#roomconfig_moderatedroom',
-			type = 'boolean',
-			label = 'Make Room Moderated?',
-			value = self:get_moderated()
-		},
-		{
-			name = 'muc#roomconfig_membersonly',
-			type = 'boolean',
-			label = 'Make Room Members-Only?',
-			value = self:get_members_only()
-		},
-		{
-			name = 'muc#roomconfig_historylength',
-			type = 'text-single',
-			label = 'Maximum Number of History Messages Returned by Room',
-			value = tostring(self:get_historylength())
 		}
 	});
 	return module:fire_event("muc-config-form", { room = self, actor = actor, form = form }) or form;
 end
 
 function room_mt:process_form(origin, stanza)
-	local query = stanza.tags[1];
-	local form;
-	for _, tag in ipairs(query.tags) do if tag.name == "x" and tag.attr.xmlns == "jabber:x:data" then form = tag; break; end end
-	if not form then origin.send(st.error_reply(stanza, "cancel", "service-unavailable")); return; end
-	if form.attr.type == "cancel" then origin.send(st.reply(stanza)); return; end
-	if form.attr.type ~= "submit" then origin.send(st.error_reply(stanza, "cancel", "bad-request", "Not a submitted form")); return; end
-
-	if form.tags[1] == nil then
-		-- instant room
-		if self.save then self:save(true); end
+	local form = stanza.tags[1]:get_child("x", "jabber:x:data");
+	if form.attr.type == "cancel" then
 		origin.send(st.reply(stanza));
-		return true;
-	end
-
-	local fields, errors, present = self:get_form_layout(stanza.attr.from):data(form);
-	if fields.FORM_TYPE ~= "http://jabber.org/protocol/muc#roomconfig" then
-		origin.send(st.error_reply(stanza, "cancel", "bad-request", "Form is not of type room configuration"));
-		return;
-	end
-
-	local changed = {};
-
-	local function handle_option(name, field, allowed)
-		if not present[field] then return; end
-		local new = fields[field];
-		if allowed and not allowed[new] then return; end
-		if new == self["get_"..name](self) then return; end
-		changed[name] = true;
-		self["set_"..name](self, new);
-	end
+	elseif form.attr.type == "submit" then
+		local fields, errors, present;
+		if form.tags[1] == nil then -- Instant room
+			fields, present = {}, {};
+		else
+			fields, errors, present = self:get_form_layout(stanza.attr.from):data(form);
+			if fields.FORM_TYPE ~= "http://jabber.org/protocol/muc#roomconfig" then
+				origin.send(st.error_reply(stanza, "cancel", "bad-request", "Form is not of type room configuration"));
+				return true;
+			end
+		end
 
-	local event = { room = self, fields = fields, changed = changed, stanza = stanza, origin = origin, update_option = handle_option };
-	module:fire_event("muc-config-submitted", event);
+		local event = {
+			room = self;
+			origin = origin;
+			stanza = stanza;
+			fields = fields;
+			status_codes = {};
+			actor = stanza.attr.from;
+		};
+		function event.update_option(name, field, allowed)
+			local new = fields[field];
+			if new == nil then return; end
+			if allowed and not allowed[new] then return; end
+			if new == self["get_"..name](self) then return; end
+			event.status_codes["104"] = true;
+			self["set_"..name](self, new);
+			return true;
+		end
+		module:fire_event("muc-config-submitted", event);
+		for submitted_field in pairs(present) do
+			event.field, event.value = submitted_field, fields[submitted_field];
+			module:fire_event("muc-config-submitted/"..submitted_field, event);
+		end
+		event.field, event.value = nil, nil;
 
-	handle_option("name", "muc#roomconfig_roomname");
-	handle_option("description", "muc#roomconfig_roomdesc");
-	handle_option("persistent", "muc#roomconfig_persistentroom");
-	handle_option("moderated", "muc#roomconfig_moderatedroom");
-	handle_option("members_only", "muc#roomconfig_membersonly");
-	handle_option("public", "muc#roomconfig_publicroom");
-	handle_option("changesubject", "muc#roomconfig_changesubject");
-	handle_option("historylength", "muc#roomconfig_historylength");
-	handle_option("whois", "muc#roomconfig_whois", valid_whois);
-	handle_option("password", "muc#roomconfig_roomsecret");
+		self:save(true);
+		origin.send(st.reply(stanza));
 
-	if self.save then self:save(true); end
-	if self.locked then
-		module:fire_event("muc-room-unlocked", { room = self });
-		self.locked = nil;
+		if next(event.status_codes) then
+			local msg = st.message({type='groupchat', from=self.jid})
+				:tag('x', {xmlns='http://jabber.org/protocol/muc#user'})
+			for code in pairs(event.status_codes) do
+				msg:tag("status", {code = code;}):up();
+			end
+			msg:up();
+			self:broadcast_message(msg);
+		end
+	else
+		origin.send(st.error_reply(stanza, "cancel", "bad-request", "Not a submitted form"));
 	end
-	origin.send(st.reply(stanza));
+	return true;
+end
 
-	if next(changed) then
-		local msg = st.message({type='groupchat', from=self.jid})
-			:tag('x', {xmlns='http://jabber.org/protocol/muc#user'})
-				:tag('status', {code = '104'}):up();
-		if changed.whois then
-			local code = (self:get_whois() == 'moderators') and "173" or "172";
-			msg.tags[1]:tag('status', {code = code}):up();
-		end
-		self:broadcast_message(msg, false)
+-- Removes everyone from the room
+function room_mt:clear(x)
+	x = x or st.stanza("x", {xmlns='http://jabber.org/protocol/muc#user'});
+	local occupants_updated = {};
+	for nick, occupant in self:each_occupant() do -- luacheck: ignore 213
+		occupant.role = nil;
+		self:save_occupant(occupant);
+		occupants_updated[occupant] = true;
+	end
+	for occupant in pairs(occupants_updated) do
+		self:publicise_occupant_status(occupant, x);
+		module:fire_event("muc-occupant-left", { room = self; nick = occupant.nick; occupant = occupant;});
 	end
 end
 
 function room_mt:destroy(newjid, reason, password)
-	local pr = st.presence({type = "unavailable"})
-		:tag("x", {xmlns = "http://jabber.org/protocol/muc#user"})
-			:tag("item", { affiliation='none', role='none' }):up()
-			:tag("destroy", {jid=newjid})
-	if reason then pr:tag("reason"):text(reason):up(); end
-	if password then pr:tag("password"):text(password):up(); end
-	for nick, occupant in pairs(self._occupants) do
-		pr.attr.from = nick;
-		for jid in pairs(occupant.sessions) do
-			pr.attr.to = jid;
-			self:_route_stanza(pr);
-			self._jid_nick[jid] = nil;
+	local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"})
+		:tag("destroy", {jid=newjid});
+	if reason then x:tag("reason"):text(reason):up(); end
+	if password then x:tag("password"):text(password):up(); end
+	x:up();
+	self.destroying = reason or true;
+	self:clear(x);
+	module:fire_event("muc-room-destroyed", { room = self, reason = reason, newjid = newjid, password = password });
+	return true;
+end
+
+function room_mt:handle_disco_info_get_query(origin, stanza)
+	origin.send(self:get_disco_info(stanza));
+	return true;
+end
+
+function room_mt:handle_disco_items_get_query(origin, stanza)
+	origin.send(self:get_disco_items(stanza));
+	return true;
+end
+
+function room_mt:handle_admin_query_set_command(origin, stanza)
+	local item = stanza.tags[1].tags[1];
+	if not item then
+		origin.send(st.error_reply(stanza, "cancel", "bad-request"));
+		return true;
+	end
+	if item.attr.jid then -- Validate provided JID
+		item.attr.jid = jid_prep(item.attr.jid);
+		if not item.attr.jid then
+			origin.send(st.error_reply(stanza, "modify", "jid-malformed"));
+			return true;
+		end
+	end
+	if item.attr.nick then -- Validate provided nick
+		item.attr.nick = resourceprep(item.attr.nick);
+		if not item.attr.nick then
+			origin.send(st.error_reply(stanza, "modify", "jid-malformed", "invalid nickname"));
+			return true;
+		end
+	end
+	if not item.attr.jid and item.attr.nick then
+		-- COMPAT Workaround for Miranda sending 'nick' instead of 'jid' when changing affiliation
+		local occupant = self:get_occupant_by_nick(self.jid.."/"..item.attr.nick);
+		if occupant then item.attr.jid = occupant.bare_jid; end
+	elseif item.attr.role and not item.attr.nick and item.attr.jid then
+		-- Role changes should use nick, but we have a JID so pull the nick from that
+		local nick = self:get_occupant_jid(item.attr.jid);
+		if nick then item.attr.nick = jid_resource(nick); end
+	end
+	local actor = stanza.attr.from;
+	local reason = item:get_child_text("reason");
+	local success, errtype, err
+	if item.attr.affiliation and item.attr.jid and not item.attr.role then
+		local registration_data;
+		if item.attr.nick then
+			local room_nick = self.jid.."/"..item.attr.nick;
+			local existing_occupant = self:get_occupant_by_nick(room_nick);
+			if existing_occupant and existing_occupant.bare_jid ~= item.attr.jid then
+				module:log("debug", "Existing occupant for %s: %s does not match %s", room_nick, existing_occupant.bare_jid, item.attr.jid);
+				self:set_role(true, room_nick, nil, "This nickname is reserved");
+			end
+			module:log("debug", "Reserving %s for %s (%s)", item.attr.nick, item.attr.jid, item.attr.affiliation);
+			registration_data = { reserved_nickname = item.attr.nick };
 		end
-		self._occupants[nick] = nil;
+		success, errtype, err = self:set_affiliation(actor, item.attr.jid, item.attr.affiliation, reason, registration_data);
+	elseif item.attr.role and item.attr.nick and not item.attr.affiliation then
+		success, errtype, err = self:set_role(actor, self.jid.."/"..item.attr.nick, item.attr.role, reason);
+	else
+		success, errtype, err = nil, "cancel", "bad-request";
+	end
+	self:save(true);
+	if not success then
+		origin.send(st.error_reply(stanza, errtype, err));
+	else
+		origin.send(st.reply(stanza));
 	end
-	self:set_persistent(false);
-	module:fire_event("muc-room-destroyed", { room = self });
+	return true;
+end
+
+function room_mt:handle_admin_query_get_command(origin, stanza)
+	local actor = stanza.attr.from;
+	local affiliation = self:get_affiliation(actor);
+	local item = stanza.tags[1].tags[1];
+	local _aff = item.attr.affiliation;
+	local _aff_rank = valid_affiliations[_aff or "none"];
+	local _rol = item.attr.role;
+	if _aff and _aff_rank and not _rol then
+		-- You need to be at least an admin, and be requesting info about your affifiliation or lower
+		-- e.g. an admin can't ask for a list of owners
+		local affiliation_rank = valid_affiliations[affiliation or "none"];
+		if (affiliation_rank >= valid_affiliations.admin and affiliation_rank >= _aff_rank)
+		or (self:get_whois() == "anyone") then
+			local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin");
+			for jid in self:each_affiliation(_aff or "none") do
+				local nick = self:get_registered_nick(jid);
+				reply:tag("item", {affiliation = _aff, jid = jid, nick = nick }):up();
+			end
+			origin.send(reply:up());
+			return true;
+		else
+			origin.send(st.error_reply(stanza, "auth", "forbidden"));
+			return true;
+		end
+	elseif _rol and valid_roles[_rol or "none"] and not _aff then
+		local role = self:get_role(self:get_occupant_jid(actor)) or self:get_default_role(affiliation);
+		if valid_roles[role or "none"] >= valid_roles.moderator then
+			if _rol == "none" then _rol = nil; end
+			local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin");
+			-- TODO: whois check here? (though fully anonymous rooms are not supported)
+			for occupant_jid, occupant in self:each_occupant() do
+				if occupant.role == _rol then
+					local nick = jid_resource(occupant_jid);
+					self:build_item_list(occupant, reply, false, nick);
+				end
+			end
+			origin.send(reply:up());
+			return true;
+		else
+			origin.send(st.error_reply(stanza, "auth", "forbidden"));
+			return true;
+		end
+	else
+		origin.send(st.error_reply(stanza, "cancel", "bad-request"));
+		return true;
+	end
+end
+
+function room_mt:handle_owner_query_get_to_room(origin, stanza)
+	if self:get_affiliation(stanza.attr.from) ~= "owner" then
+		origin.send(st.error_reply(stanza, "auth", "forbidden", "Only owners can configure rooms"));
+		return true;
+	end
+
+	self:send_form(origin, stanza);
+	return true;
+end
+function room_mt:handle_owner_query_set_to_room(origin, stanza)
+	if self:get_affiliation(stanza.attr.from) ~= "owner" then
+		origin.send(st.error_reply(stanza, "auth", "forbidden", "Only owners can configure rooms"));
+		return true;
+	end
+
+	local child = stanza.tags[1].tags[1];
+	if not child then
+		origin.send(st.error_reply(stanza, "modify", "bad-request"));
+		return true;
+	elseif child.name == "destroy" then
+		local newjid = child.attr.jid;
+		local reason = child:get_child_text("reason");
+		local password = child:get_child_text("password");
+		self:destroy(newjid, reason, password);
+		origin.send(st.reply(stanza));
+		return true;
+	elseif child.name == "x" and child.attr.xmlns == "jabber:x:data" then
+		return self:process_form(origin, stanza);
+	else
+		origin.send(st.error_reply(stanza, "cancel", "service-unavailable"));
+		return true;
+	end
+end
+
+function room_mt:handle_groupchat_to_room(origin, stanza)
+	local from = stanza.attr.from;
+	local occupant = self:get_occupant_by_real_jid(from);
+	if module:fire_event("muc-occupant-groupchat", {
+		room = self; origin = origin; stanza = stanza; from = from; occupant = occupant;
+	}) then return true; end
+	stanza.attr.from = occupant.nick;
+	self:broadcast_message(stanza);
+	stanza.attr.from = from;
 	return true;
 end
 
-function room_mt:handle_to_room(origin, stanza) -- presence changes and groupchat messages, along with disco/etc
+-- Role check
+module:hook("muc-occupant-groupchat", function(event)
+	local role_rank = valid_roles[event.occupant and event.occupant.role or "none"];
+	if role_rank <= valid_roles.none then
+		event.origin.send(st.error_reply(event.stanza, "cancel", "not-acceptable"));
+		return true;
+	elseif role_rank <= valid_roles.visitor then
+		event.origin.send(st.error_reply(event.stanza, "auth", "forbidden"));
+		return true;
+	end
+end, 50);
+
+-- hack - some buggy clients send presence updates to the room rather than their nick
+function room_mt:handle_presence_to_room(origin, stanza)
+	local current_nick = self:get_occupant_jid(stanza.attr.from);
+	local handled
+	if current_nick then
+		local to = stanza.attr.to;
+		stanza.attr.to = current_nick;
+		handled = self:handle_presence_to_occupant(origin, stanza);
+		stanza.attr.to = to;
+	end
+	return handled;
+end
+
+-- Need visitor role or higher to invite
+module:hook("muc-pre-invite", function(event)
+	local room, stanza = event.room, event.stanza;
+	local _from = stanza.attr.from;
+	local inviter = room:get_occupant_by_real_jid(_from);
+	local role = inviter and inviter.role or room:get_default_role(room:get_affiliation(_from));
+	if valid_roles[role or "none"] <= valid_roles.visitor then
+		event.origin.send(st.error_reply(stanza, "auth", "forbidden"));
+		return true;
+	end
+end);
+
+function room_mt:handle_mediated_invite(origin, stanza)
+	local payload = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("invite");
+	local invitee = jid_prep(payload.attr.to);
+	if not invitee then
+		origin.send(st.error_reply(stanza, "cancel", "jid-malformed"));
+		return true;
+	elseif module:fire_event("muc-pre-invite", {room = self, origin = origin, stanza = stanza}) then
+		return true;
+	end
+	local invite = muc_util.filter_muc_x(st.clone(stanza));
+	invite.attr.from = self.jid;
+	invite.attr.to = invitee;
+	invite:tag('x', {xmlns='http://jabber.org/protocol/muc#user'})
+			:tag('invite', {from = stanza.attr.from;})
+				:tag('reason'):text(payload:get_child_text("reason")):up()
+			:up()
+		:up();
+	if not module:fire_event("muc-invite", {room = self, stanza = invite, origin = origin, incoming = stanza}) then
+		self:route_stanza(invite);
+	end
+	return true;
+end
+
+-- COMPAT: Some older clients expect this
+module:hook("muc-invite", function(event)
+	local room, stanza = event.room, event.stanza;
+	local invite = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("invite");
+	local reason = invite:get_child_text("reason");
+	stanza:tag('x', {xmlns = "jabber:x:conference"; jid = room.jid;})
+		:text(reason or "")
+	:up();
+end);
+
+-- Add a plain message for clients which don't support invites
+module:hook("muc-invite", function(event)
+	local room, stanza = event.room, event.stanza;
+	if not stanza:get_child("body") then
+		local invite = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("invite");
+		local reason = invite:get_child_text("reason") or "";
+		stanza:tag("body")
+			:text(invite.attr.from.." invited you to the room "..room.jid..(reason ~= "" and (" ("..reason..")") or ""))
+		:up();
+	end
+end);
+
+function room_mt:handle_mediated_decline(origin, stanza)
+	local payload = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("decline");
+	local declinee = jid_prep(payload.attr.to);
+	if not declinee then
+		origin.send(st.error_reply(stanza, "cancel", "jid-malformed"));
+		return true;
+	elseif module:fire_event("muc-pre-decline", {room = self, origin = origin, stanza = stanza}) then
+		return true;
+	end
+	local decline = muc_util.filter_muc_x(st.clone(stanza));
+	decline.attr.from = self.jid;
+	decline.attr.to = declinee;
+	decline:tag("x", {xmlns = "http://jabber.org/protocol/muc#user"})
+			:tag("decline", {from = stanza.attr.from})
+				:tag("reason"):text(payload:get_child_text("reason")):up()
+			:up()
+		:up();
+	if not module:fire_event("muc-decline", {room = self, stanza = decline, origin = origin, incoming = stanza}) then
+		declinee = decline.attr.to; -- re-fetch, in case event modified it
+		local occupant
+		if jid_bare(declinee) == self.jid then -- declinee jid is already an in-room jid
+			occupant = self:get_occupant_by_nick(declinee);
+		end
+		if occupant then
+			self:route_to_occupant(occupant, decline);
+		else
+			self:route_stanza(decline);
+		end
+	end
+	return true;
+end
+
+-- Add a plain message for clients which don't support declines
+module:hook("muc-decline", function(event)
+	local room, stanza = event.room, event.stanza;
+	if not stanza:get_child("body") then
+		local decline = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("decline");
+		local reason = decline:get_child_text("reason") or "";
+		stanza:body(decline.attr.from.." declined your invite to the room "
+			..room.jid..(reason ~= "" and (" ("..reason..")") or ""));
+	end
+end);
+
+function room_mt:handle_message_to_room(origin, stanza)
 	local type = stanza.attr.type;
-	local xmlns = stanza.tags[1] and stanza.tags[1].attr.xmlns;
-	if stanza.name == "iq" then
-		if xmlns == "http://jabber.org/protocol/disco#info" and type == "get" and not stanza.tags[1].attr.node then
-			origin.send(self:get_disco_info(stanza));
-		elseif xmlns == "http://jabber.org/protocol/disco#items" and type == "get" and not stanza.tags[1].attr.node then
-			origin.send(self:get_disco_items(stanza));
-		elseif xmlns == "http://jabber.org/protocol/muc#admin" then
-			local actor = stanza.attr.from;
-			local affiliation = self:get_affiliation(actor);
-			local current_nick = self._jid_nick[actor];
-			local role = current_nick and self._occupants[current_nick].role or self:get_default_role(affiliation);
-			local item = stanza.tags[1].tags[1];
-			if item and item.name == "item" then
-				if type == "set" then
-					local callback = function() origin.send(st.reply(stanza)); end
-					if item.attr.jid then -- Validate provided JID
-						item.attr.jid = jid_prep(item.attr.jid);
-						if not item.attr.jid then
-							origin.send(st.error_reply(stanza, "modify", "jid-malformed"));
-							return;
-						end
-					end
-					if not item.attr.jid and item.attr.nick then -- COMPAT Workaround for Miranda sending 'nick' instead of 'jid' when changing affiliation
-						local occupant = self._occupants[self.jid.."/"..item.attr.nick];
-						if occupant then item.attr.jid = occupant.jid; end
-					elseif not item.attr.nick and item.attr.jid then
-						local nick = self._jid_nick[item.attr.jid];
-						if nick then item.attr.nick = select(3, jid_split(nick)); end
-					end
-					local reason = item.tags[1] and item.tags[1].name == "reason" and #item.tags[1] == 1 and item.tags[1][1];
-					if item.attr.affiliation and item.attr.jid and not item.attr.role then
-						local success, errtype, err = self:set_affiliation(actor, item.attr.jid, item.attr.affiliation, callback, reason);
-						if not success then origin.send(st.error_reply(stanza, errtype, err)); end
-					elseif item.attr.role and item.attr.nick and not item.attr.affiliation then
-						local success, errtype, err = self:set_role(actor, self.jid.."/"..item.attr.nick, item.attr.role, callback, reason);
-						if not success then origin.send(st.error_reply(stanza, errtype, err)); end
-					else
-						origin.send(st.error_reply(stanza, "cancel", "bad-request"));
-					end
-				elseif type == "get" then
-					local _aff = item.attr.affiliation;
-					local _rol = item.attr.role;
-					if _aff and not _rol then
-						if affiliation == "owner" or (affiliation == "admin" and _aff ~= "owner" and _aff ~= "admin")
-						or (affiliation and affiliation ~= "outcast" and self:get_members_only() and self:get_whois() == "anyone") then
-							local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin");
-							for jid, affiliation in pairs(self._affiliations) do
-								if affiliation == _aff then
-									reply:tag("item", {affiliation = _aff, jid = jid}):up();
-								end
-							end
-							origin.send(reply);
-						else
-							origin.send(st.error_reply(stanza, "auth", "forbidden"));
-						end
-					elseif _rol and not _aff then
-						if role == "moderator" then
-							-- TODO allow admins and owners not in room? Provide read-only access to everyone who can see the participants anyway?
-							if _rol == "none" then _rol = nil; end
-							local reply = st.reply(stanza):query("http://jabber.org/protocol/muc#admin");
-							for occupant_jid, occupant in pairs(self._occupants) do
-								if occupant.role == _rol then
-									reply:tag("item", {
-										nick = select(3, jid_split(occupant_jid)),
-										role = _rol or "none",
-										affiliation = occupant.affiliation or "none",
-										jid = occupant.jid
-										}):up();
-								end
-							end
-							origin.send(reply);
-						else
-							origin.send(st.error_reply(stanza, "auth", "forbidden"));
-						end
-					else
-						origin.send(st.error_reply(stanza, "cancel", "bad-request"));
-					end
-				end
-			elseif type == "set" or type == "get" then
-				origin.send(st.error_reply(stanza, "cancel", "bad-request"));
+	if type == "groupchat" then
+		return self:handle_groupchat_to_room(origin, stanza)
+	elseif type == "error" and is_kickable_error(stanza) then
+		return self:handle_kickable(origin, stanza)
+	elseif type == nil or type == "normal" then
+		local x = stanza:get_child("x", "http://jabber.org/protocol/muc#user");
+		if x then
+			local payload = x.tags[1];
+			if payload == nil then --luacheck: ignore 542
+				-- fallthrough
+			elseif payload.name == "invite" and payload.attr.to then
+				return self:handle_mediated_invite(origin, stanza)
+			elseif payload.name == "decline" and payload.attr.to then
+				return self:handle_mediated_decline(origin, stanza)
 			end
-		elseif xmlns == "http://jabber.org/protocol/muc#owner" and (type == "get" or type == "set") and stanza.tags[1].name == "query" then
-			if self:get_affiliation(stanza.attr.from) ~= "owner" then
-				origin.send(st.error_reply(stanza, "auth", "forbidden", "Only owners can configure rooms"));
-			elseif stanza.attr.type == "get" then
-				self:send_form(origin, stanza);
-			elseif stanza.attr.type == "set" then
-				local child = stanza.tags[1].tags[1];
-				if not child then
-					origin.send(st.error_reply(stanza, "modify", "bad-request"));
-				elseif child.name == "destroy" then
-					local newjid = child.attr.jid;
-					local reason, password;
-					for _,tag in ipairs(child.tags) do
-						if tag.name == "reason" then
-							reason = #tag.tags == 0 and tag[1];
-						elseif tag.name == "password" then
-							password = #tag.tags == 0 and tag[1];
-						end
-					end
-					self:destroy(newjid, reason, password);
-					origin.send(st.reply(stanza));
-				else
-					self:process_form(origin, stanza);
-				end
-			end
-		elseif type == "set" or type == "get" then
-			origin.send(st.error_reply(stanza, "cancel", "service-unavailable"));
-		end
-	elseif stanza.name == "message" and type == "groupchat" then
-		local from = stanza.attr.from;
-		local current_nick = self._jid_nick[from];
-		local occupant = self._occupants[current_nick];
-		if not occupant then -- not in room
-			origin.send(st.error_reply(stanza, "cancel", "not-acceptable"));
-		elseif occupant.role == "visitor" then
-			origin.send(st.error_reply(stanza, "auth", "forbidden"));
-		else
-			local from = stanza.attr.from;
-			stanza.attr.from = current_nick;
-			local subject = stanza:get_child_text("subject");
-			if subject then
-				if occupant.role == "moderator" or
-					( self._data.changesubject and occupant.role == "participant" ) then -- and participant
-					self:set_subject(current_nick, subject);
-				else
-					stanza.attr.from = from;
-					origin.send(st.error_reply(stanza, "auth", "forbidden"));
-				end
-			else
-				self:broadcast_message(stanza, self:get_historylength() > 0 and stanza:get_child("body"));
-			end
-			stanza.attr.from = from;
+			origin.send(st.error_reply(stanza, "cancel", "bad-request"));
+			return true;
 		end
-	elseif stanza.name == "message" and type == "error" and is_kickable_error(stanza) then
-		local current_nick = self._jid_nick[stanza.attr.from];
-		log("debug", "%s kicked from %s for sending an error message", current_nick, self.jid);
-		self:handle_to_occupant(origin, build_unavailable_presence_from_error(stanza)); -- send unavailable
-	elseif stanza.name == "presence" then -- hack - some buggy clients send presence updates to the room rather than their nick
-		local to = stanza.attr.to;
-		local current_nick = self._jid_nick[stanza.attr.from];
-		if current_nick then
-			stanza.attr.to = current_nick;
-			self:handle_to_occupant(origin, stanza);
-			stanza.attr.to = to;
-		elseif type ~= "error" and type ~= "result" then
-			origin.send(st.error_reply(stanza, "cancel", "service-unavailable"));
+
+		local form = stanza:get_child("x", "jabber:x:data");
+		local form_type = dataform.get_type(form);
+		if form_type == "http://jabber.org/protocol/muc#request" then
+			self:handle_role_request(origin, stanza, form);
+			return true;
 		end
-	elseif stanza.name == "message" and not(type == "chat" or type == "error" or type == "groupchat" or type == "headline") and #stanza.tags == 1
-		and self._jid_nick[stanza.attr.from] and stanza.tags[1].name == "x" and stanza.tags[1].attr.xmlns == "http://jabber.org/protocol/muc#user" then
-		local x = stanza.tags[1];
-		local payload = (#x.tags == 1 and x.tags[1]);
-		if payload and payload.name == "invite" and payload.attr.to then
-			local _from, _to = stanza.attr.from, stanza.attr.to;
-			local _invitee = jid_prep(payload.attr.to);
-			if _invitee then
-				local _reason = payload.tags[1] and payload.tags[1].name == 'reason' and #payload.tags[1].tags == 0 and payload.tags[1][1];
-				local invite = st.message({from = _to, to = _invitee, id = stanza.attr.id})
-					:tag('x', {xmlns='http://jabber.org/protocol/muc#user'})
-						:tag('invite', {from=_from})
-							:tag('reason'):text(_reason or ""):up()
-						:up();
-						if self:get_password() then
-							invite:tag("password"):text(self:get_password()):up();
-						end
-					invite:up()
-					:tag('x', {xmlns="jabber:x:conference", jid=_to}) -- COMPAT: Some older clients expect this
-						:text(_reason or "")
-					:up()
-					:tag('body') -- Add a plain message for clients which don't support invites
-						:text(_from..' invited you to the room '.._to..(_reason and (' ('.._reason..')') or ""))
-					:up();
-				if self:get_members_only() and not self:get_affiliation(_invitee) then
-					log("debug", "%s invited %s into members only room %s, granting membership", _from, _invitee, _to);
-					self:set_affiliation(_from, _invitee, "member", nil, "Invited by " .. self._jid_nick[_from])
-				end
-				self:_route_stanza(invite);
-			else
-				origin.send(st.error_reply(stanza, "cancel", "jid-malformed"));
-			end
-		else
-			origin.send(st.error_reply(stanza, "cancel", "bad-request"));
-		end
-	else
-		if type == "error" or type == "result" then return; end
-		origin.send(st.error_reply(stanza, "cancel", "service-unavailable"));
 	end
 end
 
-function room_mt:handle_stanza(origin, stanza)
-	local to_node, to_host, to_resource = jid_split(stanza.attr.to);
-	if to_resource then
-		self:handle_to_occupant(origin, stanza);
-	else
-		self:handle_to_room(origin, stanza);
-	end
+function room_mt:route_stanza(stanza) -- luacheck: ignore 212
+	module:send(stanza);
 end
 
-function room_mt:route_stanza(stanza) end -- Replace with a routing function, e.g., function(room, stanza) core_route_stanza(origin, stanza); end
-
 function room_mt:get_affiliation(jid)
 	local node, host, resource = jid_split(jid);
+	-- Affiliations are granted, revoked, and maintained based on the user's bare JID.
 	local bare = node and node.."@"..host or host;
-	local result = self._affiliations[bare]; -- Affiliations are granted, revoked, and maintained based on the user's bare JID.
+	local result = self._affiliations[bare];
 	if not result and self._affiliations[host] == "outcast" then result = "outcast"; end -- host banned
 	return result;
 end
-function room_mt:set_affiliation(actor, jid, affiliation, callback, reason)
-	jid = jid_bare(jid);
-	if affiliation == "none" then affiliation = nil; end
-	if affiliation and affiliation ~= "outcast" and affiliation ~= "owner" and affiliation ~= "admin" and affiliation ~= "member" then
+
+-- Iterates over jid, affiliation pairs
+function room_mt:each_affiliation(with_affiliation)
+	local _affiliations, _affiliation_data = self._affiliations, self._affiliation_data;
+	return function(_, jid)
+		local affiliation;
+		repeat -- Iterate until we get a match
+			jid, affiliation = next(_affiliations, jid);
+		until with_affiliation == nil or jid == nil or affiliation == with_affiliation
+		return jid, affiliation, _affiliation_data[jid];
+	end, nil, nil;
+end
+
+function room_mt:set_affiliation(actor, jid, affiliation, reason, data)
+	if not actor then return nil, "modify", "not-acceptable"; end;
+
+	local node, host, resource = jid_split(jid);
+	if not host then return nil, "modify", "not-acceptable"; end
+	jid = jid_join(node, host); -- Bare
+	local is_host_only = node == nil;
+
+	if valid_affiliations[affiliation or "none"] == nil then
 		return nil, "modify", "not-acceptable";
 	end
-	if actor ~= true then
+	affiliation = affiliation ~= "none" and affiliation or nil; -- coerces `affiliation == false` to `nil`
+
+	local target_affiliation = self._affiliations[jid]; -- Raw; don't want to check against host
+	local is_downgrade = valid_affiliations[target_affiliation or "none"] > valid_affiliations[affiliation or "none"];
+
+	if actor == true then
+		actor = nil -- So we can pass it safely to 'publicise_occupant_status' below
+	else
 		local actor_affiliation = self:get_affiliation(actor);
-		local target_affiliation = self:get_affiliation(jid);
-		if target_affiliation == affiliation then -- no change, shortcut
-			if callback then callback(); end
-			return true;
+		if actor_affiliation == "owner" then
+			if jid_bare(actor) == jid and is_downgrade then -- self change
+				-- need at least one owner
+				local is_last = true;
+				for j in self:each_affiliation("owner") do
+					if j ~= jid then is_last = false; break; end
+				end
+				if is_last then
+					return nil, "cancel", "conflict";
+				end
+			end
+			-- owners can do anything else
+		elseif affiliation == "owner" or affiliation == "admin"
+			or actor_affiliation ~= "admin"
+			or target_affiliation == "owner" or target_affiliation == "admin" then
+			-- Can't demote owners or other admins
+			return nil, "cancel", "not-allowed";
 		end
-		if actor_affiliation ~= "owner" then
-			if affiliation == "owner" or affiliation == "admin" or actor_affiliation ~= "admin" or target_affiliation == "owner" or target_affiliation == "admin" then
-				return nil, "cancel", "not-allowed";
-			end
-		elseif target_affiliation == "owner" and jid_bare(actor) == jid then -- self change
-			local is_last = true;
-			for j, aff in pairs(self._affiliations) do if j ~= jid and aff == "owner" then is_last = false; break; end end
-			if is_last then
-				return nil, "cancel", "conflict";
+	end
+
+	-- Set in 'database'
+	self._affiliations[jid] = affiliation;
+	if not affiliation or data == false or (data ~= nil and next(data) == nil) then
+		module:log("debug", "Clearing affiliation data for %s", jid);
+		self._affiliation_data[jid] = nil;
+	elseif data then
+		module:log("debug", "Updating affiliation data for %s", jid);
+		self._affiliation_data[jid] = data;
+	end
+
+	-- Update roles
+	local role = self:get_default_role(affiliation);
+	local role_rank = valid_roles[role or "none"];
+	local occupants_updated = {}; -- Filled with old roles
+	for nick, occupant in self:each_occupant() do -- luacheck: ignore 213
+		if occupant.bare_jid == jid or (
+			-- Outcast can be by host.
+			is_host_only and affiliation == "outcast" and select(2, jid_split(occupant.bare_jid)) == host
+		) then
+			-- need to publcize in all cases; as affiliation in <item/> has changed.
+			occupants_updated[occupant] = occupant.role;
+			if occupant.role ~= role and (
+				is_downgrade or
+				valid_roles[occupant.role or "none"] < role_rank -- upgrade
+			) then
+				occupant.role = role;
+				self:save_occupant(occupant);
 			end
 		end
 	end
-	self._affiliations[jid] = affiliation;
-	local role = self:get_default_role(affiliation);
-	local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"})
-			:tag("item", {affiliation=affiliation or "none", role=role or "none"})
-				:tag("reason"):text(reason or ""):up()
-			:up();
-	local presence_type = nil;
+
+	-- Tell the room of the new occupant affiliations+roles
+	local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"});
 	if not role then -- getting kicked
-		presence_type = "unavailable";
 		if affiliation == "outcast" then
 			x:tag("status", {code="301"}):up(); -- banned
 		else
 			x:tag("status", {code="321"}):up(); -- affiliation change
 		end
 	end
-	-- Your own presence should have status 110
-	local self_x = st.clone(x);
-	self_x:tag("status", {code="110"});
-	local modified_nicks = {};
-	for nick, occupant in pairs(self._occupants) do
-		if jid_bare(occupant.jid) == jid then
-			if not role then -- getting kicked
-				self._occupants[nick] = nil;
-			else
-				occupant.affiliation, occupant.role = affiliation, role;
-			end
-			for jid,pres in pairs(occupant.sessions) do -- remove for all sessions of the nick
-				if not role then self._jid_nick[jid] = nil; end
-				local p = st.clone(pres);
-				p.attr.from = nick;
-				p.attr.type = presence_type;
-				p.attr.to = jid;
-				if occupant.jid == jid then
-					-- Broadcast this presence to everyone else later, with the public <x> variant
-					local bp = st.clone(p);
-					bp:add_child(x);
-					modified_nicks[nick] = bp;
+	local is_semi_anonymous = self:get_whois() == "moderators";
+
+	if next(occupants_updated) ~= nil then
+		for occupant, old_role in pairs(occupants_updated) do
+			self:publicise_occupant_status(occupant, x, nil, actor, reason);
+			if occupant.role == nil then
+				module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;});
+			elseif is_semi_anonymous and
+				(old_role == "moderator" and occupant.role ~= "moderator") or
+				(old_role ~= "moderator" and occupant.role == "moderator") then -- Has gained or lost moderator status
+				-- Send everyone else's presences (as jid visibility has changed)
+				for real_jid in occupant:each_session() do
+					self:send_occupant_list(real_jid, function(occupant_jid, occupant) --luacheck: ignore 212 433
+						return occupant.bare_jid ~= jid;
+					end);
 				end
-				p:add_child(self_x);
-				self:_route_stanza(p);
 			end
 		end
+	else
+		-- Announce affiliation change for a user that is not currently in the room,
+		-- XEP-0045 (v1.31.2) example 195
+		-- add_item(x, affiliation, role, jid, nick, actor_nick, actor_jid, reason)
+		local announce_msg = st.message({ from = self.jid })
+			:add_child(add_item(st.clone(x), affiliation, nil, jid, nil, nil, nil, reason));
+		local min_role = is_semi_anonymous and "moderator" or "none";
+		self:broadcast(announce_msg, muc_util.only_with_min_role(min_role));
 	end
-	if self.save then self:save(); end
-	if callback then callback(); end
-	for nick,p in pairs(modified_nicks) do
-		p.attr.from = nick;
-		self:broadcast_except_nick(p, nick);
+
+	self:save(true);
+
+	module:fire_event("muc-set-affiliation", {
+		room = self;
+		actor = actor;
+		jid = jid;
+		affiliation = affiliation or "none";
+		reason = reason;
+		previous_affiliation = target_affiliation;
+		data = data and data or nil; -- coerce false to nil
+		in_room = next(occupants_updated) ~= nil;
+	});
+
+	return true;
+end
+
+function room_mt:get_affiliation_data(jid, key)
+	local data = self._affiliation_data[jid];
+	if not data then return nil; end
+	if key then
+		return data[key];
+	end
+	return data;
+end
+
+function room_mt:get_role(nick)
+	local occupant = self:get_occupant_by_nick(nick);
+	return occupant and occupant.role or nil;
+end
+
+function room_mt:set_role(actor, occupant_jid, role, reason)
+	if not actor then return nil, "modify", "not-acceptable"; end
+
+	local occupant = self:get_occupant_by_nick(occupant_jid);
+	if not occupant then return nil, "modify", "item-not-found"; end
+
+	if valid_roles[role or "none"] == nil then
+		return nil, "modify", "not-acceptable";
+	end
+	role = role ~= "none" and role or nil; -- coerces `role == false` to `nil`
+
+	if actor == true then
+		actor = nil -- So we can pass it safely to 'publicise_occupant_status' below
+	else
+		-- Can't do anything to other owners or admins
+		local occupant_affiliation = self:get_affiliation(occupant.bare_jid);
+		if occupant_affiliation == "owner" or occupant_affiliation == "admin" then
+			return nil, "cancel", "not-allowed";
+		end
+
+		-- If you are trying to give or take moderator role you need to be an owner or admin
+		if occupant.role == "moderator" or role == "moderator" then
+			local actor_affiliation = self:get_affiliation(actor);
+			if actor_affiliation ~= "owner" and actor_affiliation ~= "admin" then
+				return nil, "cancel", "not-allowed";
+			end
+		end
+
+		-- Need to be in the room and a moderator
+		local actor_occupant = self:get_occupant_by_real_jid(actor);
+		if not actor_occupant or actor_occupant.role ~= "moderator" then
+			return nil, "cancel", "not-allowed";
+		end
+	end
+
+	local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"});
+	if not role then
+		x:tag("status", {code = "307"}):up();
+	end
+	occupant.role = role;
+	self:save_occupant(occupant);
+	self:publicise_occupant_status(occupant, x, nil, actor, reason);
+	if role == nil then
+		module:fire_event("muc-occupant-left", {room = self; nick = occupant.nick; occupant = occupant;});
 	end
 	return true;
 end
 
-function room_mt:get_role(nick)
-	local session = self._occupants[nick];
-	return session and session.role or nil;
-end
-function room_mt:can_set_role(actor_jid, occupant_jid, role)
-	local occupant = self._occupants[occupant_jid];
-	if not occupant or not actor_jid then return nil, "modify", "not-acceptable"; end
-
-	if actor_jid == true then return true; end
-
-	local actor = self._occupants[self._jid_nick[actor_jid]];
-	if actor and actor.role == "moderator" then
-		if occupant.affiliation ~= "owner" and occupant.affiliation ~= "admin" then
-			if actor.affiliation == "owner" or actor.affiliation == "admin" then
-				return true;
-			elseif occupant.role ~= "moderator" and role ~= "moderator" then
-				return true;
-			end
-		end
-	end
-	return nil, "cancel", "not-allowed";
-end
-function room_mt:set_role(actor, occupant_jid, role, callback, reason)
-	if role == "none" then role = nil; end
-	if role and role ~= "moderator" and role ~= "participant" and role ~= "visitor" then return nil, "modify", "not-acceptable"; end
-	local allowed, err_type, err_condition = self:can_set_role(actor, occupant_jid, role);
-	if not allowed then return allowed, err_type, err_condition; end
-	local occupant = self._occupants[occupant_jid];
-	local x = st.stanza("x", {xmlns = "http://jabber.org/protocol/muc#user"})
-			:tag("item", {affiliation=occupant.affiliation or "none", nick=select(3, jid_split(occupant_jid)), role=role or "none"})
-				:tag("reason"):text(reason or ""):up()
-			:up();
-	local presence_type = nil;
-	if not role then -- kick
-		presence_type = "unavailable";
-		self._occupants[occupant_jid] = nil;
-		for jid in pairs(occupant.sessions) do -- remove for all sessions of the nick
-			self._jid_nick[jid] = nil;
-		end
-		x:tag("status", {code = "307"}):up();
-	else
-		occupant.role = role;
-	end
-	local self_x = st.clone(x);
-	self_x:tag("status", {code = "110"}):up();
-	local bp;
-	for jid,pres in pairs(occupant.sessions) do -- send to all sessions of the nick
-		local p = st.clone(pres);
-		p.attr.from = occupant_jid;
-		p.attr.type = presence_type;
-		p.attr.to = jid;
-		if occupant.jid == jid then
-			bp = st.clone(p);
-			bp:add_child(x);
-		end
-		p:add_child(self_x);
-		self:_route_stanza(p);
-	end
-	if callback then callback(); end
-	if bp then
-		self:broadcast_except_nick(bp, occupant_jid);
-	end
-	return true;
-end
-
-function room_mt:_route_stanza(stanza)
-	local muc_child;
-	local to_occupant = self._occupants[self._jid_nick[stanza.attr.to]];
-	local from_occupant = self._occupants[stanza.attr.from];
-	if stanza.name == "presence" then
-		if to_occupant and from_occupant then
-			if self._data.whois == 'anyone' then
-			    muc_child = stanza:get_child("x", "http://jabber.org/protocol/muc#user");
-			else
-				if to_occupant.role == "moderator" or jid_bare(to_occupant.jid) == jid_bare(from_occupant.jid) then
-					muc_child = stanza:get_child("x", "http://jabber.org/protocol/muc#user");
-				end
-			end
-		end
-	end
-	if muc_child then
-		for _, item in pairs(muc_child.tags) do
-			if item.name == "item" then
-				if from_occupant == to_occupant then
-					item.attr.jid = stanza.attr.to;
-				else
-					item.attr.jid = from_occupant.jid;
-				end
-			end
-		end
-	end
-	self:route_stanza(stanza);
-	if muc_child then
-		for _, item in pairs(muc_child.tags) do
-			if item.name == "item" then
-				item.attr.jid = nil;
-			end
-		end
-	end
-end
+local whois = module:require "muc/whois";
+room_mt.get_whois = whois.get;
+room_mt.set_whois = whois.set;
 
 local _M = {}; -- module "muc"
 
@@ -1179,17 +1430,109 @@
 		jid = jid;
 		_jid_nick = {};
 		_occupants = {};
-		_data = {
-		    whois = 'moderators';
-		    history_length = math.min((config and config.history_length)
-		    	or default_history_length, max_history_length);
-		};
+		_data = config or {};
 		_affiliations = {};
+		_affiliation_data = {};
 	}, room_mt);
 end
 
-function _M.set_max_history_length(_max_history_length)
-	max_history_length = _max_history_length or math.huge;
+local new_format = module:get_option_boolean("new_muc_storage_format", false);
+
+function room_mt:freeze(live)
+	local frozen, state;
+	if new_format then
+		frozen = {
+			_jid = self.jid;
+			_data = self._data;
+		};
+		for user, affiliation in pairs(self._affiliations) do
+			frozen[user] = affiliation;
+		end
+	else
+		frozen = {
+			jid = self.jid;
+			_data = self._data;
+			_affiliations = self._affiliations;
+			_affiliation_data = self._affiliation_data;
+		};
+	end
+	if live then
+		state = {};
+		for nick, occupant in self:each_occupant() do
+			state[nick] = {
+				bare_jid = occupant.bare_jid;
+				role = occupant.role;
+				jid = occupant.jid;
+			}
+			for jid, presence in occupant:each_session() do
+				state[jid] = st.preserialize(presence);
+			end
+		end
+		local history = self._history;
+		if history and history[1] ~= nil then
+			state._last_message = st.preserialize(history[#history].stanza);
+			state._last_message_at = history[#history].timestamp;
+		end
+	end
+	return frozen, state;
+end
+
+function _M.restore_room(frozen, state)
+	local room_jid = frozen._jid or frozen.jid;
+	local room = _M.new_room(room_jid, frozen._data);
+
+	if state and state._last_message and state._last_message_at then
+		room._history = {
+			{ stanza = st.deserialize(state._last_message),
+			  timestamp = state._last_message_at, },
+		};
+	end
+
+	local occupants = {};
+	local room_name, room_host = jid_split(room_jid);
+
+	room._affiliation_data = frozen._affiliation_data or {};
+
+	if frozen.jid and frozen._affiliations then
+		-- Old storage format
+		room._affiliations = frozen._affiliations;
+	else
+		-- New storage format
+		for jid, data in pairs(frozen) do
+			local node, host, resource = jid_split(jid);
+			if host:sub(1,1) ~= "_" and not resource and type(data) == "string" then
+				-- bare jid: affiliation
+				room._affiliations[jid] = data;
+			end
+		end
+	end
+	for jid, data in pairs(state or frozen) do
+		local node, host, resource = jid_split(jid);
+		if node or host:sub(1,1) ~= "_" then
+			if host == room_host and node == room_name and resource and type(data) == "table" then
+				-- full room jid: bare real jid and role
+				local nick = jid;
+				local occupant = occupants[nick] or occupant_lib.new(data.bare_jid, nick);
+				occupant.bare_jid = data.bare_jid;
+				occupant.role = data.role;
+				occupant.jid = data.jid; -- Primary session JID
+				occupants[nick] = occupant;
+			elseif type(data) == "table" and data.name == "presence" then
+				-- full user jid: presence
+				local nick = data.attr.from;
+				local occupant = occupants[nick] or occupant_lib.new(nil, nick);
+				local presence = st.deserialize(data);
+				occupant:set_session(jid, presence);
+				occupants[nick] = occupant;
+			end
+		end
+	end
+
+	for _, occupant in pairs(occupants) do
+		room:save_occupant(occupant);
+	end
+
+	return room;
 end
 
 _M.room_mt = room_mt;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/name.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,48 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local jid_split = require "util.jid".split;
+
+local function get_name(room)
+	return room._data.name or jid_split(room.jid);
+end
+
+local function set_name(room, name)
+	if name == "" then name = nil; end
+	if room._data.name == name then return false; end
+	room._data.name = name;
+	return true;
+end
+
+local function insert_name_into_form(event)
+	table.insert(event.form, {
+		name = "muc#roomconfig_roomname";
+		type = "text-single";
+		label = "Title";
+		value = event.room._data.name;
+	});
+end
+
+module:hook("muc-disco#info", function(event)
+	event.reply:tag("identity", {category="conference", type="text", name=get_name(event.room)}):up();
+	insert_name_into_form(event);
+end);
+
+module:hook("muc-config-form", insert_name_into_form, 100-1);
+
+module:hook("muc-config-submitted/muc#roomconfig_roomname", function(event)
+	if set_name(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+return {
+	get = get_name;
+	set = set_name;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/occupant.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,85 @@
+local pairs = pairs;
+local setmetatable = setmetatable;
+local st = require "util.stanza";
+local util = module:require "muc/util";
+
+local function get_filtered_presence(stanza)
+	return util.filter_muc_x(st.clone(stanza));
+end
+
+local occupant_mt = {};
+occupant_mt.__index = occupant_mt;
+
+local function new_occupant(bare_real_jid, nick)
+	return setmetatable({
+		bare_jid = bare_real_jid;
+		nick = nick; -- in-room jid
+		sessions = {}; -- hash from real_jid to presence stanzas. stanzas should not be modified
+		role = nil;
+		jid = nil; -- Primary session
+	}, occupant_mt);
+end
+
+-- Deep copy an occupant
+local function copy_occupant(occupant)
+	local sessions = {};
+	for full_jid, presence_stanza in pairs(occupant.sessions) do
+		-- Don't keep unavailable presences, as they'll accumulate; unless they're the primary session
+		if presence_stanza.attr.type ~= "unavailable" or full_jid == occupant.jid then
+			sessions[full_jid] = presence_stanza;
+		end
+	end
+	return setmetatable({
+		bare_jid = occupant.bare_jid;
+		nick = occupant.nick;
+		sessions = sessions;
+		role = occupant.role;
+		jid = occupant.jid;
+	}, occupant_mt);
+end
+
+-- finds another session to be the primary (there might not be one)
+function occupant_mt:choose_new_primary()
+	for jid, pr in self:each_session() do
+		if pr.attr.type == nil then
+			return jid;
+		end
+	end
+	return nil;
+end
+
+function occupant_mt:set_session(real_jid, presence_stanza, replace_primary)
+	local pr = get_filtered_presence(presence_stanza);
+	pr.attr.from = self.nick;
+	pr.attr.to = real_jid;
+
+	self.sessions[real_jid] = pr;
+	if replace_primary then
+		self.jid = real_jid;
+	elseif self.jid == nil or (pr.attr.type == "unavailable" and self.jid == real_jid) then
+		-- Only leave an unavailable presence as primary when there are no other options
+		self.jid = self:choose_new_primary() or real_jid;
+	end
+end
+
+function occupant_mt:remove_session(real_jid)
+	-- Delete original session
+	self.sessions[real_jid] = nil;
+	if self.jid == real_jid then
+		self.jid = self:choose_new_primary();
+	end
+end
+
+function occupant_mt:each_session()
+	return pairs(self.sessions)
+end
+
+function occupant_mt:get_presence(real_jid)
+	return self.sessions[real_jid or self.jid]
+end
+
+return {
+	new = new_occupant;
+	copy = copy_occupant;
+	mt = occupant_mt;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/password.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,80 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local st = require "util.stanza";
+
+local function get_password(room)
+	return room._data.password;
+end
+
+local function set_password(room, password)
+	if password == "" then password = nil; end
+	if room._data.password == password then return false; end
+	room._data.password = password;
+	return true;
+end
+
+module:hook("muc-disco#info", function(event)
+	event.reply:tag("feature", {var = get_password(event.room) and "muc_passwordprotected" or "muc_unsecured"}):up();
+end);
+
+module:hook("muc-config-form", function(event)
+	table.insert(event.form, {
+		name = "muc#roomconfig_roomsecret";
+		type = "text-private";
+		label = "Password";
+		value = get_password(event.room) or "";
+	});
+end, 90-2);
+
+module:hook("muc-config-submitted/muc#roomconfig_roomsecret", function(event)
+	if set_password(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+-- Don't allow anyone to join room unless they provide the password
+module:hook("muc-occupant-pre-join", function(event)
+	local room, stanza = event.room, event.stanza;
+	if not get_password(room) then return end
+	local muc_x = stanza:get_child("x", "http://jabber.org/protocol/muc");
+	if not muc_x then return end
+	local password = muc_x:get_child_text("password", "http://jabber.org/protocol/muc");
+	if not password or password == "" then password = nil; end
+	if get_password(room) ~= password then
+		local from, to = stanza.attr.from, stanza.attr.to;
+		module:log("debug", "%s couldn't join due to invalid password: %s", from, to);
+		local reply = st.error_reply(stanza, "auth", "not-authorized"):up();
+		reply.tags[1].attr.code = "401";
+		event.origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+		return true;
+	end
+end, -20);
+
+-- Add password to outgoing invite
+module:hook("muc-invite", function(event)
+	local password = get_password(event.room);
+	if password then
+		local x = event.stanza:get_child("x", "http://jabber.org/protocol/muc#user");
+		x:tag("password"):text(password):up();
+	end
+end);
+
+module:hook("muc-room-pre-create", function (event)
+	local stanza, room = event.stanza, event.room;
+	local muc_x = stanza:get_child("x", "http://jabber.org/protocol/muc");
+	if not muc_x then return end
+	local password = muc_x:get_child_text("password", "http://jabber.org/protocol/muc");
+	set_password(room, password);
+end);
+
+return {
+	get = get_password;
+	set = set_password;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/persistent.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,58 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local restrict_persistent = not module:get_option_boolean("muc_room_allow_persistent", true);
+local um_is_admin = require "core.usermanager".is_admin;
+
+local function get_persistent(room)
+	return room._data.persistent;
+end
+
+local function set_persistent(room, persistent)
+	persistent = persistent and true or nil;
+	if get_persistent(room) == persistent then return false; end
+	room._data.persistent = persistent;
+	return true;
+end
+
+module:hook("muc-config-form", function(event)
+	if restrict_persistent and not um_is_admin(event.actor, module.host) then
+		-- Don't show option if hidden rooms are restricted and user is not admin of this host
+		return;
+	end
+	table.insert(event.form, {
+		name = "muc#roomconfig_persistentroom";
+		type = "boolean";
+		label = "Persistent (room should remain even when it is empty)";
+		desc = "Rooms are automatically deleted when they are empty, unless this option is enabled";
+		value = get_persistent(event.room);
+	});
+end, 100-5);
+
+module:hook("muc-config-submitted/muc#roomconfig_persistentroom", function(event)
+	if restrict_persistent and not um_is_admin(event.actor, module.host) then
+		return; -- Not allowed
+	end
+	if set_persistent(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+module:hook("muc-disco#info", function(event)
+	event.reply:tag("feature", {var = get_persistent(event.room) and "muc_persistent" or "muc_temporary"}):up();
+end);
+
+module:hook("muc-room-destroyed", function(event)
+	set_persistent(event.room, false);
+end, -100);
+
+return {
+	get = get_persistent;
+	set = set_persistent;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/register.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,195 @@
+local jid_bare = require "util.jid".bare;
+local jid_resource = require "util.jid".resource;
+local resourceprep = require "util.encodings".stringprep.resourceprep;
+local st = require "util.stanza";
+local dataforms = require "util.dataforms";
+
+local allow_unaffiliated = module:get_option_boolean("allow_unaffiliated_register", false);
+
+local enforce_nick = module:get_option_boolean("enforce_registered_nickname", false);
+
+-- reserved_nicks[nick] = jid
+local function get_reserved_nicks(room)
+	if room._reserved_nicks then
+		return room._reserved_nicks;
+	end
+	module:log("debug", "Refreshing reserved nicks...");
+	local reserved_nicks = {};
+	for jid in room:each_affiliation() do
+		local data = room._affiliation_data[jid];
+		local nick = data and data.reserved_nickname;
+		module:log("debug", "Refreshed for %s: %s", jid, nick);
+		if nick then
+			reserved_nicks[nick] = jid;
+		end
+	end
+	room._reserved_nicks = reserved_nicks;
+	return reserved_nicks;
+end
+
+-- Returns the registered nick, if any, for a JID
+-- Note: this is just the *nick* part, i.e. the resource of the in-room JID
+local function get_registered_nick(room, jid)
+	local registered_data = room._affiliation_data[jid];
+	if not registered_data then
+		return;
+	end
+	return registered_data.reserved_nickname;
+end
+
+-- Returns the JID, if any, that registered a nick (not in-room JID)
+local function get_registered_jid(room, nick)
+	local reserved_nicks = get_reserved_nicks(room);
+	return reserved_nicks[nick];
+end
+
+module:hook("muc-set-affiliation", function (event)
+	-- Clear reserved nick cache
+	event.room._reserved_nicks = nil;
+end);
+
+module:add_feature("jabber:iq:register");
+
+module:hook("muc-disco#info", function (event)
+	event.reply:tag("feature", { var = "jabber:iq:register" }):up();
+end);
+
+local registration_form = dataforms.new {
+	{ name = "FORM_TYPE", type = "hidden", value = "http://jabber.org/protocol/muc#register" },
+	{ name = "muc#register_roomnick", type = "text-single", label = "Nickname"},
+};
+
+local function enforce_nick_policy(event)
+	local origin, stanza = event.origin, event.stanza;
+	local room = assert(event.room); -- FIXME
+	if not room then return; end
+
+	-- Check if the chosen nickname is reserved
+	local requested_nick = jid_resource(stanza.attr.to);
+	local reserved_by = get_registered_jid(room, requested_nick);
+	if reserved_by and reserved_by ~= jid_bare(stanza.attr.from) then
+		module:log("debug", "%s attempted to use nick %s reserved by %s", stanza.attr.from, requested_nick, reserved_by);
+		local reply = st.error_reply(stanza, "cancel", "conflict"):up();
+		origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+		return true;
+	end
+
+	-- Check if the occupant has a reservation they must use
+	if enforce_nick then
+		local nick = get_registered_nick(room, jid_bare(stanza.attr.from));
+		if nick then
+			if event.occupant then
+				event.occupant.nick = jid_bare(event.occupant.nick) .. "/" .. nick;
+			elseif event.dest_occupant.nick ~= jid_bare(event.dest_occupant.nick) .. "/" .. nick then
+				module:log("debug", "Attempt by %s to join as %s, but their reserved nick is %s", stanza.attr.from, requested_nick, nick);
+				local reply = st.error_reply(stanza, "cancel", "not-acceptable"):up();
+				origin.send(reply:tag("x", {xmlns = "http://jabber.org/protocol/muc"}));
+				return true;
+			end
+		end
+	end
+end
+
+module:hook("muc-occupant-pre-join", enforce_nick_policy);
+module:hook("muc-occupant-pre-change", enforce_nick_policy);
+
+-- Discovering Reserved Room Nickname
+-- http://xmpp.org/extensions/xep-0045.html#reservednick
+module:hook("muc-disco#info/x-roomuser-item", function (event)
+	local nick = get_registered_nick(event.room, jid_bare(event.stanza.attr.from));
+	if nick then
+		event.reply:tag("identity", { category = "conference", type = "text", name = nick })
+	end
+end);
+
+local function handle_register_iq(room, origin, stanza)
+	local user_jid = jid_bare(stanza.attr.from)
+	local affiliation = room:get_affiliation(user_jid);
+	if affiliation == "outcast" then
+		origin.send(st.error_reply(stanza, "auth", "forbidden"));
+		return true;
+	elseif not (affiliation or allow_unaffiliated) then
+		origin.send(st.error_reply(stanza, "auth", "registration-required"));
+		return true;
+	end
+	local reply = st.reply(stanza);
+	local registered_nick = get_registered_nick(room, user_jid);
+	if stanza.attr.type == "get" then
+		reply:query("jabber:iq:register");
+		if registered_nick then
+			reply:tag("registered"):up();
+			reply:tag("username"):text(registered_nick);
+			origin.send(reply);
+			return true;
+		end
+		reply:add_child(registration_form:form());
+	else -- type == set -- handle registration form
+		local query = stanza.tags[1];
+		if query:get_child("remove") then
+			-- Remove "member" affiliation, but preserve if any other
+			local new_affiliation = affiliation ~= "member" and affiliation;
+			local ok, err_type, err_condition = room:set_affiliation(true, user_jid, new_affiliation, nil, false);
+			if not ok then
+				origin.send(st.error_reply(stanza, err_type, err_condition));
+				return true;
+			end
+			origin.send(reply);
+			return true;
+		end
+		local form_tag = query:get_child("x", "jabber:x:data");
+		local reg_data = form_tag and registration_form:data(form_tag);
+		if not reg_data then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", "Error in form"));
+			return true;
+		end
+		-- Is the nickname valid?
+		local desired_nick = resourceprep(reg_data["muc#register_roomnick"]);
+		if not desired_nick then
+			origin.send(st.error_reply(stanza, "modify", "bad-request", "Invalid Nickname"));
+			return true;
+		end
+		-- Is the nickname currently in use by another user?
+		local current_occupant = room:get_occupant_by_nick(room.jid.."/"..desired_nick);
+		if current_occupant and current_occupant.bare_jid ~= user_jid then
+			origin.send(st.error_reply(stanza, "cancel", "conflict"));
+			return true;
+		end
+		-- Is the nickname currently reserved by another user?
+		local reserved_by = get_registered_jid(room, desired_nick);
+		if reserved_by and reserved_by ~= user_jid then
+			origin.send(st.error_reply(stanza, "cancel", "conflict"));
+			return true;
+		end
+
+		if enforce_nick then
+			-- Kick any sessions that are not using this nick before we register it
+			local required_room_nick = room.jid.."/"..desired_nick;
+			for room_nick, occupant in room:each_occupant() do
+				if occupant.bare_jid == user_jid and room_nick ~= required_room_nick then
+					room:set_role(true, room_nick, nil); -- Kick (TODO: would be nice to use 333 code)
+				end
+			end
+		end
+
+		-- Checks passed, save the registration
+		if registered_nick ~= desired_nick then
+			local registration_data = { reserved_nickname = desired_nick };
+			local ok, err_type, err_condition = room:set_affiliation(true, user_jid, affiliation or "member", nil, registration_data);
+			if not ok then
+				origin.send(st.error_reply(stanza, err_type, err_condition));
+				return true;
+			end
+			module:log("debug", "Saved nick registration for %s: %s", user_jid, desired_nick);
+			origin.send(reply);
+			return true;
+		end
+	end
+	origin.send(reply);
+	return true;
+end
+
+return {
+	get_registered_nick = get_registered_nick;
+	get_registered_jid = get_registered_jid;
+	handle_register_iq = handle_register_iq;
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/request.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,132 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local st = require "util.stanza";
+local jid_resource = require "util.jid".resource;
+
+module:hook("muc-disco#info", function(event)
+	event.reply:tag("feature", {var = "http://jabber.org/protocol/muc#request"}):up();
+end);
+
+local voice_request_form = require "util.dataforms".new({
+	title = "Voice Request";
+	{
+		name = "FORM_TYPE";
+		type = "hidden";
+		value = "http://jabber.org/protocol/muc#request";
+	},
+	{
+		name = "muc#jid";
+		type = "jid-single";
+		label = "User ID";
+		desc = "The user's JID (address)";
+	},
+	{
+		name = "muc#roomnick";
+		type = "text-single";
+		label = "Room nickname";
+		desc = "The user's nickname within the room";
+	},
+	{
+		name = "muc#role";
+		type = "list-single";
+		label = "Requested role";
+		value = "participant";
+		options = {
+			"none",
+			"visitor",
+			"participant",
+			"moderator",
+		};
+	},
+	{
+		name = "muc#request_allow";
+		type = "boolean";
+		label = "Grant voice to this person?";
+		desc = "Specify whether this person is able to speak in a moderated room";
+		value = false;
+	}
+});
+
+local function handle_request(room, origin, stanza, form)
+	local occupant = room:get_occupant_by_real_jid(stanza.attr.from);
+	local fields = voice_request_form:data(form);
+	local event = {
+		room = room;
+		origin = origin;
+		stanza = stanza;
+		fields = fields;
+		occupant = occupant;
+	};
+	if occupant.role == "moderator" then
+		module:log("debug", "%s responded to a voice request in %s", jid_resource(occupant.nick), room.jid);
+		module:fire_event("muc-voice-response", event);
+	else
+		module:log("debug", "%s requested voice in %s", jid_resource(occupant.nick), room.jid);
+		module:fire_event("muc-voice-request", event);
+	end
+end
+
+module:hook("muc-voice-request", function(event)
+	if event.occupant.role == "visitor" then
+		local nick = jid_resource(event.occupant.nick);
+		local formdata = {
+			["muc#jid"] = event.stanza.attr.from;
+			["muc#roomnick"] = nick;
+		};
+
+		local message = st.message({ type = "normal"; from = event.room.jid })
+			:add_child(voice_request_form:form(formdata));
+
+		event.room:broadcast(message, function (_, occupant)
+			return occupant.role == "moderator";
+		end);
+	end
+end);
+
+module:hook("muc-voice-response", function(event)
+	local actor = event.stanza.attr.from;
+	local affected_occupant = event.room:get_occupant_by_real_jid(event.fields["muc#jid"]);
+	local occupant = event.occupant;
+
+	if occupant.role ~= "moderator" then
+		module:log("debug", "%s tried to grant voice but wasn't a moderator", jid_resource(occupant.nick));
+		return;
+	end
+
+	if not event.fields["muc#request_allow"] then
+		module:log("debug", "%s did not grant voice", jid_resource(occupant.nick));
+		return;
+	end
+
+	if not affected_occupant then
+		module:log("debug", "%s tried to grant voice to unknown occupant %s",
+			jid_resource(occupant.nick), event.fields["muc#jid"]);
+		return;
+	end
+
+	if affected_occupant.role ~= "visitor" then
+		module:log("debug", "%s tried to grant voice to %s but they already have it",
+			jid_resource(occupant.nick), jid_resource(occupant.jid));
+		return;
+	end
+
+	module:log("debug", "%s granted voice to %s", jid_resource(event.occupant.nick), jid_resource(occupant.jid));
+	local ok, errtype, err = event.room:set_role(actor, affected_occupant.nick, "participant", "Voice granted");
+
+	if not ok then
+		module:log("debug", "Error granting voice: %s", err or errtype);
+		event.origin.send(st.error_reply(event.stanza, errtype, err));
+	end
+end);
+
+
+return {
+	handle_request = handle_request;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/subject.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,119 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local st = require "util.stanza";
+local dt = require "util.datetime";
+
+local muc_util = module:require "muc/util";
+local valid_roles = muc_util.valid_roles;
+
+local function create_subject_message(from, subject)
+	return st.message({from = from; type = "groupchat"})
+		:tag("subject"):text(subject or ""):up();
+end
+
+local function get_changesubject(room)
+	return room._data.changesubject;
+end
+
+local function set_changesubject(room, changesubject)
+	changesubject = changesubject and true or nil;
+	if get_changesubject(room) == changesubject then return false; end
+	room._data.changesubject = changesubject;
+	return true;
+end
+
+module:hook("muc-disco#info", function (event)
+	table.insert(event.form, {
+		name = "muc#roominfo_changesubject";
+		type = "boolean";
+	});
+	event.formdata["muc#roominfo_changesubject"] = get_changesubject(event.room);
+end);
+
+module:hook("muc-config-form", function(event)
+	table.insert(event.form, {
+		name = "muc#roomconfig_changesubject";
+		type = "boolean";
+		label = "Allow anyone to set the room's subject";
+		desc = "Choose whether anyone, or only moderators, may set the room's subject";
+		value = get_changesubject(event.room);
+	});
+end, 80-1);
+
+module:hook("muc-config-submitted/muc#roomconfig_changesubject", function(event)
+	if set_changesubject(event.room, event.value) then
+		event.status_codes["104"] = true;
+	end
+end);
+
+local function get_subject(room)
+	-- a <message/> stanza from the room JID (or from the occupant JID of the entity that set the subject)
+	return room._data.subject_from or room.jid, room._data.subject;
+end
+
+local function send_subject(room, to, time)
+	local msg = create_subject_message(get_subject(room));
+	msg.attr.to = to;
+	if time then
+		msg:tag("delay", {
+			xmlns = "urn:xmpp:delay",
+			from = room.jid,
+			stamp = dt.datetime(time);
+		}):up();
+	end
+	room:route_stanza(msg);
+end
+
+local function set_subject(room, from, subject)
+	if subject == "" then subject = nil; end
+	local old_from, old_subject = get_subject(room);
+	if old_subject == subject and old_from == from then return false; end
+	room._data.subject_from = from;
+	room._data.subject = subject;
+	room._data.subject_time = os.time();
+	local msg = create_subject_message(from, subject);
+	room:broadcast_message(msg);
+	return true;
+end
+
+-- Send subject to joining user
+module:hook("muc-occupant-session-new", function(event)
+	send_subject(event.room, event.stanza.attr.from, event.room._data.subject_time);
+end, 20);
+
+-- Prosody has made the decision that messages with <subject/> are exclusively subject changes
+-- e.g. body will be ignored; even if the subject change was not allowed
+module:hook("muc-occupant-groupchat", function(event)
+	local stanza = event.stanza;
+	local subject = stanza:get_child("subject");
+	if subject then
+		local room = event.room;
+		local occupant = event.occupant;
+		-- Role check for subject changes
+		local role_rank = valid_roles[occupant and occupant.role or "none"];
+		if role_rank >= valid_roles.moderator or
+			( role_rank >= valid_roles.participant and get_changesubject(room) ) then -- and participant
+			set_subject(room, occupant.nick, subject:get_text());
+			room:save();
+			return true;
+		else
+			event.origin.send(st.error_reply(stanza, "auth", "forbidden", "You are not allowed to change the subject"));
+			return true;
+		end
+	end
+end, 20);
+
+return {
+	get_changesubject = get_changesubject;
+	set_changesubject = set_changesubject;
+	get = get_subject;
+	set = set_subject;
+	send = send_subject;
+};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/util.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,67 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local _M = {};
+
+_M.valid_affiliations = {
+	outcast = -1;
+	none = 0;
+	member = 1;
+	admin = 2;
+	owner = 3;
+};
+
+_M.valid_roles = {
+	none = 0;
+	visitor = 1;
+	participant = 2;
+	moderator = 3;
+};
+
+local kickable_error_conditions = {
+	["gone"] = true;
+	["internal-server-error"] = true;
+	["item-not-found"] = true;
+	["jid-malformed"] = true;
+	["recipient-unavailable"] = true;
+	["redirect"] = true;
+	["remote-server-not-found"] = true;
+	["remote-server-timeout"] = true;
+	["service-unavailable"] = true;
+	["malformed error"] = true;
+};
+function _M.is_kickable_error(stanza)
+	local cond = select(2, stanza:get_error()) or "malformed error";
+	return kickable_error_conditions[cond];
+end
+
+local muc_x_filters = {
+	["http://jabber.org/protocol/muc"] = true;
+	["http://jabber.org/protocol/muc#user"] = true;
+}
+local function muc_x_filter(tag)
+	if muc_x_filters[tag.attr.xmlns] then
+		return nil;
+	end
+	return tag;
+end
+function _M.filter_muc_x(stanza)
+	return stanza:maptags(muc_x_filter);
+end
+
+function _M.only_with_min_role(role)
+	local min_role_value = _M.valid_roles[role];
+	return function (nick, occupant) --luacheck: ignore 212/nick
+		if _M.valid_roles[occupant.role or "none"] >= min_role_value then
+			return true;
+		end
+	end;
+end
+
+return _M;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/plugins/muc/whois.lib.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,66 @@
+-- Prosody IM
+-- Copyright (C) 2008-2010 Matthew Wild
+-- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2014 Daurnimator
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+local valid_whois = {
+	moderators = true;
+	anyone = true;
+};
+
+local function get_whois(room)
+	return room._data.whois or "moderators";
+end
+
+local function set_whois(room, whois)
+	assert(valid_whois[whois], "Invalid whois value")
+	if get_whois(room) == whois then return false; end
+	room._data.whois = whois;
+	return true;
+end
+
+module:hook("muc-disco#info", function(event)
+	local whois = get_whois(event.room) ~= "anyone" and "muc_semianonymous" or "muc_nonanonymous";
+	event.reply:tag("feature", { var = whois }):up();
+end);
+
+module:hook("muc-config-form", function(event)
+	local whois = get_whois(event.room);
+	table.insert(event.form, {
+		name = 'muc#roomconfig_whois',
+		type = 'list-single',
+		label = 'Addresses (JIDs) of room occupants may be viewed by:',
+		options = {
+			{ value = 'moderators', label = 'Moderators only', default = whois == 'moderators' },
+			{ value = 'anyone',     label = 'Anyone',          default = whois == 'anyone' }
+		}
+	});
+end, 80-4);
+
+module:hook("muc-config-submitted/muc#roomconfig_whois", function(event)
+	if set_whois(event.room, event.value) then
+		local code = (event.value == 'moderators') and "173" or "172";
+		event.status_codes[code] = true;
+	end
+end);
+
+-- Mask 'from' jid as occupant jid if room is anonymous
+module:hook("muc-invite", function(event)
+	local room, stanza = event.room, event.stanza;
+	if get_whois(room) == "moderators" and room:get_default_role(room:get_affiliation(stanza.attr.to)) ~= "moderator" then
+		local invite = stanza:get_child("x", "http://jabber.org/protocol/muc#user"):get_child("invite");
+		local occupant_jid = room:get_occupant_jid(invite.attr.from);
+		if occupant_jid ~= nil then -- FIXME: This will expose real jid if inviter is not in room
+			invite.attr.from = occupant_jid;
+		end
+	end
+end, 50);
+
+return {
+	get = get_whois;
+	set = set_whois;
+};
--- a/prosody	Wed Nov 28 16:55:27 2018 +0000
+++ b/prosody	Mon Jan 07 15:34:23 2019 +0000
@@ -49,390 +49,49 @@
 	return 1;
 end
 
--- Global 'prosody' object
-local prosody = { events = require "util.events".new(); };
-_G.prosody = prosody;
-
--- Check dependencies
-local dependencies = require "util.dependencies";
-
--- Load the config-parsing module
-config = require "core.configmanager"
-
--- -- -- --
--- Define the functions we call during startup, the
--- actual startup happens right at the end, where these
--- functions get called
-
-function read_config()
-	local filenames = {};
-
-	local filename;
-	if arg[1] == "--config" and arg[2] then
-		table.insert(filenames, arg[2]);
-		if CFG_CONFIGDIR then
-			table.insert(filenames, CFG_CONFIGDIR.."/"..arg[2]);
-		end
-	elseif os.getenv("PROSODY_CONFIG") then -- Passed by prosodyctl
-			table.insert(filenames, os.getenv("PROSODY_CONFIG"));
-	else
-		for _, format in ipairs(config.parsers()) do
-			table.insert(filenames, (CFG_CONFIGDIR or ".").."/prosody.cfg."..format);
-		end
-	end
-	for _,_filename in ipairs(filenames) do
-		filename = _filename;
-		local file = io.open(filename);
-		if file then
-			file:close();
-			CFG_CONFIGDIR = filename:match("^(.*)[\\/][^\\/]*$");
-			break;
-		end
-	end
-	prosody.config_file = filename
-	local ok, level, err = config.load(filename);
-	if not ok then
-		print("\n");
-		print("**************************");
-		if level == "parser" then
-			print("A problem occured while reading the config file "..filename);
-			print("");
-			local err_line, err_message = tostring(err):match("%[string .-%]:(%d*): (.*)");
-			if err:match("chunk has too many syntax levels$") then
-				print("An Include statement in a config file is including an already-included");
-				print("file and causing an infinite loop. An Include statement in a config file is...");
-			else
-				print("Error"..(err_line and (" on line "..err_line) or "")..": "..(err_message or tostring(err)));
-			end
-			print("");
-		elseif level == "file" then
-			print("Prosody was unable to find the configuration file.");
-			print("We looked for: "..filename);
-			print("A sample config file is included in the Prosody download called prosody.cfg.lua.dist");
-			print("Copy or rename it to prosody.cfg.lua and edit as necessary.");
-		end
-		print("More help on configuring Prosody can be found at http://prosody.im/doc/configure");
-		print("Good luck!");
-		print("**************************");
-		print("");
-		os.exit(1);
-	end
-end
-
-function check_dependencies()
-	if not dependencies.check_dependencies() then
-		os.exit(1);
-	end
-end
-
--- luacheck: globals socket server
-
-function load_libraries()
-	-- Load socket framework
-	-- luacheck: ignore 111/server 111/socket
-	socket = require "socket";
-	server = require "net.server"
-end
-
--- The global log() gets defined by loggingmanager
--- luacheck: ignore 113/log
-
-function init_logging()
-	-- Initialize logging
-	require "core.loggingmanager"
-end
-
-function log_dependency_warnings()
-	dependencies.log_warnings();
-end
-
-function sanity_check()
-	for host, host_config in pairs(config.getconfig()) do
-		if host ~= "*"
-		and host_config.enabled ~= false
-		and not host_config.component_module then
-			return;
-		end
-	end
-	log("error", "No enabled VirtualHost entries found in the config file.");
-	log("error", "At least one active host is required for Prosody to function. Exiting...");
-	os.exit(1);
-end
-
-function sandbox_require()
-	-- Replace require() with one that doesn't pollute _G, required
-	-- for neat sandboxing of modules
-	-- luacheck: ignore 113/getfenv 111/require
-	local _realG = _G;
-	local _real_require = require;
-	local getfenv = getfenv or function (f)
-		-- FIXME: This is a hack to replace getfenv() in Lua 5.2
-		local name, env = debug.getupvalue(debug.getinfo(f or 1).func, 1);
-		if name == "_ENV" then
-			return env;
-		end
-	end
-	function require(...)
-		local curr_env = getfenv(2);
-		local curr_env_mt = getmetatable(curr_env);
-		local _realG_mt = getmetatable(_realG);
-		if curr_env_mt and curr_env_mt.__index and not curr_env_mt.__newindex and _realG_mt then
-			local old_newindex, old_index;
-			old_newindex, _realG_mt.__newindex = _realG_mt.__newindex, curr_env;
-			old_index, _realG_mt.__index = _realG_mt.__index, function (_G, k) -- luacheck: ignore 212/_G
-				return rawget(curr_env, k);
-			end;
-			local ret = _real_require(...);
-			_realG_mt.__newindex = old_newindex;
-			_realG_mt.__index = old_index;
-			return ret;
-		end
-		return _real_require(...);
-	end
-end
+local startup = require "util.startup";
+local async = require "util.async";
 
-function set_function_metatable()
-	local mt = {};
-	function mt.__index(f, upvalue)
-		local i, name, value = 0;
-		repeat
-			i = i + 1;
-			name, value = debug.getupvalue(f, i);
-		until name == upvalue or name == nil;
-		return value;
-	end
-	function mt.__newindex(f, upvalue, value)
-		local i, name = 0;
-		repeat
-			i = i + 1;
-			name = debug.getupvalue(f, i);
-		until name == upvalue or name == nil;
-		if name then
-			debug.setupvalue(f, i, value);
-		end
-	end
-	function mt.__tostring(f)
-		local info = debug.getinfo(f);
-		return ("function(%s:%d)"):format(info.short_src:match("[^\\/]*$"), info.linedefined);
-	end
-	debug.setmetatable(function() end, mt);
-end
-
-function init_global_state()
-	prosody.bare_sessions = {};
-	prosody.full_sessions = {};
-	prosody.hosts = {};
-
-	-- COMPAT: These globals are deprecated
-	-- luacheck: ignore 111/bare_sessions 111/full_sessions 111/hosts
-	bare_sessions = prosody.bare_sessions;
-	full_sessions = prosody.full_sessions;
-	hosts = prosody.hosts;
-
-	local data_path = config.get("*", "data_path") or CFG_DATADIR or "data";
-	local custom_plugin_paths = config.get("*", "plugin_paths");
-	if custom_plugin_paths then
-		local path_sep = package.config:sub(3,3);
-		-- path1;path2;path3;defaultpath...
-		CFG_PLUGINDIR = table.concat(custom_plugin_paths, path_sep)..path_sep..(CFG_PLUGINDIR or "plugins");
-	end
-	prosody.paths = { source = CFG_SOURCEDIR, config = CFG_CONFIGDIR or ".",
-	                  plugins = CFG_PLUGINDIR or "plugins", data = data_path };
-
-	prosody.arg = _G.arg;
-
-	prosody.platform = "unknown";
-	if os.getenv("WINDIR") then
-		prosody.platform = "windows";
-	elseif package.config:sub(1,1) == "/" then
-		prosody.platform = "posix";
-	end
-
-	prosody.installed = nil;
-	if CFG_SOURCEDIR and (prosody.platform == "windows" or CFG_SOURCEDIR:match("^/")) then
-		prosody.installed = true;
-	end
-
-	if prosody.installed then
-		-- Change working directory to data path.
-		require "lfs".chdir(data_path);
-	end
-
-	-- Function to reload the config file
-	function prosody.reload_config()
-		log("info", "Reloading configuration file");
-		prosody.events.fire_event("reloading-config");
-		local ok, level, err = config.load(prosody.config_file);
-		if not ok then
-			if level == "parser" then
-				log("error", "There was an error parsing the configuration file: %s", tostring(err));
-			elseif level == "file" then
-				log("error", "Couldn't read the config file when trying to reload: %s", tostring(err));
-			end
-		end
-		return ok, (err and tostring(level)..": "..tostring(err)) or nil;
-	end
-
-	-- Function to reopen logfiles
-	function prosody.reopen_logfiles()
-		log("info", "Re-opening log files");
-		prosody.events.fire_event("reopen-log-files");
-	end
+-- Note: it's important that this thread is not GC'd, as some C libraries
+-- that are initialized here store a pointer to it ( :/ ).
+local thread = async.runner();
 
-	-- Function to initiate prosody shutdown
-	function prosody.shutdown(reason, code)
-		log("info", "Shutting down: %s", reason or "unknown reason");
-		prosody.shutdown_reason = reason;
-		prosody.shutdown_code = code;
-		prosody.events.fire_event("server-stopping", {
-			reason = reason;
-			code = code;
-		});
-		server.setquitting(true);
-	end
-end
-
-function read_version()
-	-- Try to determine version
-	local version_file = io.open((CFG_SOURCEDIR or ".").."/prosody.version");
-	if version_file then
-		prosody.version = version_file:read("*a"):gsub("%s*$", "");
-		version_file:close();
-		if #prosody.version == 12 and prosody.version:match("^[a-f0-9]+$") then
-			prosody.version = "hg:"..prosody.version;
-		end
-	else
-		prosody.version = "unknown";
-	end
-end
-
-function load_secondary_libraries()
-	--- Load and initialise core modules
-	require "util.import"
-	require "util.xmppstream"
-	require "core.stanza_router"
-	require "core.statsmanager"
-	require "core.hostmanager"
-	require "core.portmanager"
-	require "core.modulemanager"
-	require "core.usermanager"
-	require "core.rostermanager"
-	require "core.sessionmanager"
-	package.loaded['core.componentmanager'] = setmetatable({},{__index=function()
-		log("warn", "componentmanager is deprecated: %s", debug.traceback():match("\n[^\n]*\n[ \t]*([^\n]*)"));
-		return function() end
-	end});
-
-	local http = require "net.http"
-	local config_ssl = config.get("*", "ssl") or {}
-	local https_client = config.get("*", "client_https_ssl")
-	http.default.options.sslctx = require "core.certmanager".create_context("client_https port 0", "client",
-		{ capath = config_ssl.capath, cafile = config_ssl.cafile, verify = "peer", }, https_client);
+thread:run(startup.prosody);
 
-	require "util.array"
-	require "util.datetime"
-	require "util.iterators"
-	require "util.timer"
-	require "util.helpers"
-
-	pcall(require, "util.signal") -- Not on Windows
-
-	-- Commented to protect us from
-	-- the second kind of people
-	--[[
-	pcall(require, "remdebug.engine");
-	if remdebug then remdebug.engine.start() end
-	]]
-
-	require "util.stanza"
-	require "util.jid"
-end
-
-function init_data_store()
-	require "core.storagemanager";
-end
-
-function prepare_to_start()
-	log("info", "Prosody is using the %s backend for connection handling", server.get_backend());
-	-- Signal to modules that we are ready to start
-	prosody.events.fire_event("server-starting");
-	prosody.start_time = os.time();
-end
-
-function init_global_protection()
-	-- Catch global accesses
-	-- luacheck: ignore 212/t
-	local locked_globals_mt = {
-		__index = function (t, k) log("warn", "%s", debug.traceback("Attempt to read a non-existent global '"..tostring(k).."'", 2)); end;
-		__newindex = function (t, k, v) error("Attempt to set a global: "..tostring(k).." = "..tostring(v), 2); end;
-	};
-
-	function prosody.unlock_globals()
-		setmetatable(_G, nil);
-	end
-
-	function prosody.lock_globals()
-		setmetatable(_G, locked_globals_mt);
-	end
-
-	-- And lock now...
-	prosody.lock_globals();
-end
-
-function loop()
+local function loop()
 	-- Error handler for errors that make it this far
 	local function catch_uncaught_error(err)
 		if type(err) == "string" and err:match("interrupted!$") then
 			return "quitting";
 		end
 
-		log("error", "Top-level error, please report:\n%s", tostring(err));
+		prosody.log("error", "Top-level error, please report:\n%s", tostring(err));
 		local traceback = debug.traceback("", 2);
 		if traceback then
-			log("error", "%s", traceback);
+			prosody.log("error", "%s", traceback);
 		end
 
 		prosody.events.fire_event("very-bad-error", {error = err, traceback = traceback});
 	end
 
 	local sleep = require"socket".sleep;
+	local server = require "net.server";
 
 	while select(2, xpcall(server.loop, catch_uncaught_error)) ~= "quitting" do
 		sleep(0.2);
 	end
 end
 
-function cleanup()
-	log("info", "Shutdown status: Cleaning up");
+local function cleanup()
+	prosody.log("info", "Shutdown status: Cleaning up");
 	prosody.events.fire_event("server-cleanup");
 end
 
--- Are you ready? :)
--- These actions are in a strict order, as many depend on
--- previous steps to have already been performed
-read_config();
-init_logging();
-sanity_check();
-sandbox_require();
-set_function_metatable();
-check_dependencies();
-load_libraries();
-init_global_state();
-read_version();
-log("info", "Hello and welcome to Prosody version %s", prosody.version);
-log_dependency_warnings();
-load_secondary_libraries();
-init_data_store();
-init_global_protection();
-prepare_to_start();
-
-prosody.events.fire_event("server-started");
-
 loop();
 
-log("info", "Shutting down...");
+prosody.log("info", "Shutting down...");
 cleanup();
 prosody.events.fire_event("server-stopped");
-log("info", "Shutdown complete");
+prosody.log("info", "Shutdown complete");
 
-os.exit(prosody.shutdown_code)
+os.exit(prosody.shutdown_code);
--- a/prosody.cfg.lua.dist	Wed Nov 28 16:55:27 2018 +0000
+++ b/prosody.cfg.lua.dist	Mon Jan 07 15:34:23 2019 +0000
@@ -46,10 +46,11 @@
 
 	-- Not essential, but recommended
 		"carbons"; -- Keep multiple clients in sync
-		"pep"; -- Enables users to publish their mood, activity, playing music and more
+		"pep"; -- Enables users to publish their avatar, mood, activity, playing music and more
 		"private"; -- Private XML storage (for room bookmarks, etc.)
 		"blocklist"; -- Allow users to block communications with other users
-		"vcard"; -- Allow users to set vCards
+		"vcard4"; -- User profiles (stored in PEP)
+		"vcard_legacy"; -- Conversion between legacy vCard and PEP Avatar, vcard
 
 	-- Nice to have
 		"version"; -- Replies to server version requests
@@ -58,6 +59,7 @@
 		"ping"; -- Replies to XMPP pings with pongs
 		"register"; -- Allow users to register on this server using a client and change passwords
 		--"mam"; -- Store messages in an archive and allow users to access it
+		--"csi_simple"; -- Simple Mobile optimizations
 
 	-- Admin interfaces
 		"admin_adhoc"; -- Allows administration via an XMPP client that supports ad-hoc commands
@@ -100,16 +102,10 @@
 
 -- Force servers to use encrypted connections? This option will
 -- prevent servers from authenticating unless they are using encryption.
--- Note that this is different from authentication
 
 s2s_require_encryption = true
 
-
 -- Force certificate authentication for server-to-server connections?
--- This provides ideal security, but requires servers you communicate
--- with to support encryption AND present valid, trusted certificates.
--- NOTE: Your version of LuaSec must support certificate verification!
--- For more information see https://prosody.im/doc/s2s#security
 
 s2s_secure_auth = false
 
@@ -120,17 +116,13 @@
 
 --s2s_insecure_domains = { "insecure.example" }
 
--- Even if you leave s2s_secure_auth disabled, you can still require valid
+-- Even if you disable s2s_secure_auth, you can still require valid
 -- certificates for some domains by specifying a list here.
 
 --s2s_secure_domains = { "jabber.org" }
 
 -- Select the authentication backend to use. The 'internal' providers
 -- use Prosody's configured data storage to store the authentication data.
--- To allow Prosody to offer secure authentication mechanisms to clients, the
--- default provider stores passwords in plaintext. If you do not trust your
--- server please see https://prosody.im/doc/modules/mod_auth_internal_hashed
--- for information about using the hashed backend.
 
 authentication = "internal_hashed"
 
@@ -181,6 +173,9 @@
 -- Location of directory to find certificates in (relative to main config file):
 certificates = "certs"
 
+-- HTTPS currently only supports a single certificate, specify it here:
+--https_certificate = "certs/localhost.crt"
+
 ----------- Virtual hosts -----------
 -- You need to add a VirtualHost entry for each domain you wish Prosody to serve.
 -- Settings under each VirtualHost entry apply *only* to that host.
@@ -197,6 +192,8 @@
 
 ---Set up a MUC (multi-user chat) room server on conference.example.com:
 --Component "conference.example.com" "muc"
+--- Store MUC messages in an archive and allow users to access it
+--modules_enabled = { "muc_mam" }
 
 ---Set up an external component (default component port is 5347)
 --
--- a/prosodyctl	Wed Nov 28 16:55:27 2018 +0000
+++ b/prosodyctl	Mon Jan 07 15:34:23 2019 +0000
@@ -20,8 +20,8 @@
 
 local function is_relative(path)
 	local path_sep = package.config:sub(1,1);
-	return ((path_sep == "/" and path:sub(1,1) ~= "/")
-		or (path_sep == "\\" and (path:sub(1,1) ~= "/" and path:sub(2,3) ~= ":\\")))
+        return ((path_sep == "/" and path:sub(1,1) ~= "/")
+	or (path_sep == "\\" and (path:sub(1,1) ~= "/" and path:sub(2,3) ~= ":\\")))
 end
 
 -- Tell Lua where to find our libraries
@@ -43,190 +43,12 @@
 	end
 end
 
--- Global 'prosody' object
-local prosody = {
-	hosts = {};
-	events = require "util.events".new();
-	platform = "posix";
-	lock_globals = function () end;
-	unlock_globals = function () end;
-	installed = CFG_SOURCEDIR ~= nil;
-	core_post_stanza = function () end; -- TODO: mod_router!
-};
-_G.prosody = prosody;
-
-local dependencies = require "util.dependencies";
-if not dependencies.check_dependencies() then
-	os.exit(1);
-end
-
-config = require "core.configmanager"
-
-local ENV_CONFIG;
-do
-	local filenames = {};
-
-	local filename;
-	if arg[1] == "--config" and arg[2] then
-		table.insert(filenames, arg[2]);
-		if CFG_CONFIGDIR then
-			table.insert(filenames, CFG_CONFIGDIR.."/"..arg[2]);
-		end
-		table.remove(arg, 1); table.remove(arg, 1);
-	else
-		for _, format in ipairs(config.parsers()) do
-			table.insert(filenames, (CFG_CONFIGDIR or ".").."/prosody.cfg."..format);
-		end
-	end
-	for _,_filename in ipairs(filenames) do
-		filename = _filename;
-		local file = io.open(filename);
-		if file then
-			file:close();
-			ENV_CONFIG = filename;
-			CFG_CONFIGDIR = filename:match("^(.*)[\\/][^\\/]*$");
-			break;
-		end
-	end
-	local ok, level, err = config.load(filename);
-	if not ok then
-		print("\n");
-		print("**************************");
-		if level == "parser" then
-			print("A problem occured while reading the config file "..filename);
-			local err_line, err_message = tostring(err):match("%[string .-%]:(%d*): (.*)");
-			print("Error"..(err_line and (" on line "..err_line) or "")..": "..(err_message or tostring(err)));
-			print("");
-		elseif level == "file" then
-			print("Prosody was unable to find the configuration file.");
-			print("We looked for: "..filename);
-			print("A sample config file is included in the Prosody download called prosody.cfg.lua.dist");
-			print("Copy or rename it to prosody.cfg.lua and edit as necessary.");
-		end
-		print("More help on configuring Prosody can be found at http://prosody.im/doc/configure");
-		print("Good luck!");
-		print("**************************");
-		print("");
-		os.exit(1);
-	end
-end
-local original_logging_config = config.get("*", "log");
-config.set("*", "log", { { levels = { min = os.getenv("PROSODYCTL_LOG_LEVEL") or "info" }, to = "console" } });
-
-local data_path = config.get("*", "data_path") or CFG_DATADIR or "data";
-local custom_plugin_paths = config.get("*", "plugin_paths");
-if custom_plugin_paths then
-	local path_sep = package.config:sub(3,3);
-	-- path1;path2;path3;defaultpath...
-	CFG_PLUGINDIR = table.concat(custom_plugin_paths, path_sep)..path_sep..(CFG_PLUGINDIR or "plugins");
-end
-prosody.paths = { source = CFG_SOURCEDIR, config = CFG_CONFIGDIR,
-	          plugins = CFG_PLUGINDIR or "plugins", data = data_path };
-
-if prosody.installed then
-	-- Change working directory to data path.
-	require "lfs".chdir(data_path);
-end
-
-require "core.loggingmanager"
-
-dependencies.log_warnings();
-
--- Switch away from root and into the prosody user --
-local switched_user, current_uid;
+-----------
 
-local want_pposix_version = "0.4.0";
-local have_pposix, pposix = pcall(require, "util.pposix");
-
-if have_pposix and pposix then
-	if pposix._VERSION ~= want_pposix_version then
-		print(string.format("Unknown version (%s) of binary pposix module, expected %s",
-			tostring(pposix._VERSION), want_pposix_version)); return;
-	end
-	current_uid = pposix.getuid();
-	local arg_root = arg[1] == "--root";
-	if arg_root then table.remove(arg, 1); end
-	if current_uid == 0 and config.get("*", "run_as_root") ~= true and not arg_root then
-		-- We haz root!
-		local desired_user = config.get("*", "prosody_user") or "prosody";
-		local desired_group = config.get("*", "prosody_group") or desired_user;
-		local ok, err = pposix.setgid(desired_group);
-		if ok then
-			ok, err = pposix.initgroups(desired_user);
-		end
-		if ok then
-			ok, err = pposix.setuid(desired_user);
-			if ok then
-				-- Yay!
-				switched_user = true;
-			end
-		end
-		if not switched_user then
-			-- Boo!
-			print("Warning: Couldn't switch to Prosody user/group '"..tostring(desired_user).."'/'"..tostring(desired_group).."': "..tostring(err));
-		else
-			-- Make sure the Prosody user can read the config
-			local conf, err, errno = io.open(ENV_CONFIG);
-			if conf then
-				conf:close();
-			else
-				print("The config file is not readable by the '"..desired_user.."' user.");
-				print("Prosody will not be able to read it.");
-				print("Error was "..err);
-				os.exit(1);
-			end
-		end
-	end
+local startup = require "util.startup";
+startup.prosodyctl();
 
-	-- Set our umask to protect data files
-	pposix.umask(config.get("*", "umask") or "027");
-	pposix.setenv("HOME", data_path);
-	pposix.setenv("PROSODY_CONFIG", ENV_CONFIG);
-else
-	print("Error: Unable to load pposix module. Check that Prosody is installed correctly.")
-	print("For more help send the below error to us through http://prosody.im/discuss");
-	print(tostring(pposix))
-	os.exit(1);
-end
-
-local function test_writeable(filename)
-	local f, err = io.open(filename, "a");
-	if not f then
-		return false, err;
-	end
-	f:close();
-	return true;
-end
-
-local unwriteable_files = {};
-if type(original_logging_config) == "string" and original_logging_config:sub(1,1) ~= "*" then
-	local ok, err = test_writeable(original_logging_config);
-	if not ok then
-		table.insert(unwriteable_files, err);
-	end
-elseif type(original_logging_config) == "table" then
-	for _, rule in ipairs(original_logging_config) do
-		if rule.filename then
-			local ok, err = test_writeable(rule.filename);
-			if not ok then
-				table.insert(unwriteable_files, err);
-			end
-		end
-	end
-end
-
-if #unwriteable_files > 0 then
-	print("One of more of the Prosody log files are not");
-	print("writeable, please correct the errors and try");
-	print("starting prosodyctl again.");
-	print("");
-	for _, err in ipairs(unwriteable_files) do
-		print(err);
-	end
-	print("");
-	os.exit(1);
-end
-
+-----------
 
 local error_messages = setmetatable({
 		["invalid-username"] = "The given username is invalid in a Jabber ID";
@@ -235,60 +57,21 @@
 		["no-such-user"] = "The given user does not exist on the server";
 		["no-such-host"] = "The given hostname does not exist in the config";
 		["unable-to-save-data"] = "Unable to store, perhaps you don't have permission?";
-		["no-pidfile"] = "There is no 'pidfile' option in the configuration file, see http://prosody.im/doc/prosodyctl#pidfile for help";
-		["invalid-pidfile"] = "The 'pidfile' option in the configuration file is not a string, see http://prosody.im/doc/prosodyctl#pidfile for help";
-		["no-posix"] = "The mod_posix module is not enabled in the Prosody config file, see http://prosody.im/doc/prosodyctl for more info";
+		["no-pidfile"] = "There is no 'pidfile' option in the configuration file, see https://prosody.im/doc/prosodyctl#pidfile for help";
+		["invalid-pidfile"] = "The 'pidfile' option in the configuration file is not a string, see https://prosody.im/doc/prosodyctl#pidfile for help";
+		["no-posix"] = "The mod_posix module is not enabled in the Prosody config file, see https://prosody.im/doc/prosodyctl for more info";
 		["no-such-method"] = "This module has no commands";
 		["not-running"] = "Prosody is not running";
-		}, { __index = function (t,k) return "Error: "..(tostring(k):gsub("%-", " "):gsub("^.", string.upper)); end });
-
-hosts = prosody.hosts;
+		}, { __index = function (_,k) return "Error: "..(tostring(k):gsub("%-", " "):gsub("^.", string.upper)); end });
 
-local function make_host(hostname)
-	return {
-		type = "local",
-		events = prosody.events,
-		modules = {},
-		sessions = {},
-		users = require "core.usermanager".new_null_provider(hostname)
-	};
-end
-
-for hostname, config in pairs(config.getconfig()) do
-	hosts[hostname] = make_host(hostname);
-end
-
+local configmanager = require "core.configmanager";
 local modulemanager = require "core.modulemanager"
-
 local prosodyctl = require "util.prosodyctl"
 local socket = require "socket"
-
-local http = require "net.http"
-local config_ssl = config.get("*", "ssl") or {}
-local https_client = config.get("*", "client_https_ssl")
-http.default.options.sslctx = require "core.certmanager".create_context("client_https port 0", "client",
-	{ capath = config_ssl.capath, cafile = config_ssl.cafile, verify = "peer", }, https_client);
+local dependencies = require "util.dependencies";
 
 -----------------------
 
--- FIXME: Duplicate code waiting for util.startup
-function read_version()
-	-- Try to determine version
-	local version_file = io.open((CFG_SOURCEDIR or ".").."/prosody.version");
-	prosody.version = "unknown";
-	if version_file then
-		prosody.version = version_file:read("*a"):gsub("%s*$", "");
-		version_file:close();
-		if #prosody.version == 12 and prosody.version:match("^[a-f0-9]+$") then
-			prosody.version = "hg:"..prosody.version;
-		end
-	else
-		local hg = require"util.mercurial";
-		local hgid = hg.check_id(CFG_SOURCEDIR or ".");
-		if hgid then prosody.version = "hg:" .. hgid; end
-	end
-end
-
 local show_message, show_warning = prosodyctl.show_message, prosodyctl.show_warning;
 local show_usage = prosodyctl.show_usage;
 local show_yesno = prosodyctl.show_yesno;
@@ -297,7 +80,7 @@
 
 local jid_split = require "util.jid".prepped_split;
 
-local prosodyctl_timeout = (config.get("*", "prosodyctl_timeout") or 5) * 2;
+local prosodyctl_timeout = (configmanager.get("*", "prosodyctl_timeout") or 5) * 2;
 -----------------------
 local commands = {};
 local command = arg[1];
@@ -319,10 +102,10 @@
 		return 1;
 	end
 
-	if not hosts[host] then
+	if not prosody.hosts[host] then
 		show_warning("The host '%s' is not listed in the configuration file (or is not enabled).", host)
 		show_warning("The user will not be able to log in until this is changed.");
-		hosts[host] = make_host(host);
+		prosody.hosts[host] = startup.make_host(host); --luacheck: ignore 122
 	end
 
 	if prosodyctl.user_exists{ user = user, host = host } then
@@ -358,10 +141,10 @@
 		return 1;
 	end
 
-	if not hosts[host] then
+	if not prosody.hosts[host] then
 		show_warning("The host '%s' is not listed in the configuration file (or is not enabled).", host)
 		show_warning("The user will not be able to log in until this is changed.");
-		hosts[host] = make_host(host);
+		prosody.hosts[host] = startup.make_host(host); --luacheck: ignore 122
 	end
 
 	if not prosodyctl.user_exists { user = user, host = host } then
@@ -397,9 +180,9 @@
 		return 1;
 	end
 
-	if not hosts[host] then
+	if not prosody.hosts[host] then
 		show_warning("The host '%s' is not listed in the configuration file (or is not enabled).", host)
-		hosts[host] = make_host(host);
+		prosody.hosts[host] = startup.make_host(host); --luacheck: ignore 122
 	end
 
 	if not prosodyctl.user_exists { user = user, host = host } then
@@ -427,6 +210,7 @@
 	end
 
 	if ret then
+		--luacheck: ignore 421/ret
 		local ok, ret = prosodyctl.getpid();
 		if not ok then
 			show_message("Couldn't get running Prosody's PID");
@@ -437,9 +221,10 @@
 		return 1;
 	end
 
-	local ok, ret = prosodyctl.start();
+	--luacheck: ignore 411/ret
+	local ok, ret = prosodyctl.start(prosody.paths.source);
 	if ok then
-		local daemonize = config.get("*", "daemonize");
+		local daemonize = configmanager.get("*", "daemonize");
 		if daemonize == nil then
 			daemonize = prosody.installed;
 		end
@@ -481,6 +266,7 @@
 	end
 
 	if ret then
+		--luacheck: ignore 421/ret
 		local ok, ret = prosodyctl.getpid();
 		if not ok then
 			show_message("Couldn't get running Prosody's PID");
@@ -491,7 +277,7 @@
 		return 0;
 	else
 		show_message("Prosody is not running");
-		if not switched_user and current_uid ~= 0 then
+		if not prosody.switched_user and prosody.current_uid ~= 0 then
 			print("\nNote:")
 			print(" You will also see this if prosodyctl is not running under");
 			print(" the same user account as Prosody. Try running as root (e.g. ");
@@ -499,7 +285,6 @@
 		end
 		return 2
 	end
-	return 1;
 end
 
 function commands.stop(arg)
@@ -548,28 +333,26 @@
 end
 
 function commands.about(arg)
-	read_version();
 	if arg[1] == "--help" then
 		show_usage([[about]], [[Show information about this Prosody installation]]);
 		return 1;
 	end
 
 	local pwd = ".";
-	local lfs = require "lfs";
 	local array = require "util.array";
 	local keys = require "util.iterators".keys;
 	local hg = require"util.mercurial";
-	local relpath = config.resolve_relative_path;
+	local relpath = configmanager.resolve_relative_path;
 
 	print("Prosody "..(prosody.version or "(unknown version)"));
 	print("");
 	print("# Prosody directories");
-	print("Data directory:     "..relpath(pwd, data_path));
-	print("Config directory:   "..relpath(pwd, CFG_CONFIGDIR or "."));
-	print("Source directory:   "..relpath(pwd, CFG_SOURCEDIR or "."));
+	print("Data directory:     "..relpath(pwd, prosody.paths.data));
+	print("Config directory:   "..relpath(pwd, prosody.paths.config or "."));
+	print("Source directory:   "..relpath(pwd, prosody.paths.source or "."));
 	print("Plugin directories:")
 	print("  "..(prosody.paths.plugins:gsub("([^;]+);?", function(path)
-			path = config.resolve_relative_path(pwd, path);
+			path = configmanager.resolve_relative_path(pwd, path);
 			local hgid, hgrepo = hg.check_id(path);
 			if not hgid and hgrepo then
 				return path.." - "..hgrepo .."!\n  ";
@@ -593,15 +376,21 @@
 		print("  "..path);
 	end
 	print("");
-	local luarocks_status = (pcall(require, "luarocks.loader") and "Installed ("..(package.loaded["luarocks.cfg"].program_version or "2.x+")..")")
-		or (pcall(require, "luarocks.require") and "Installed (1.x)")
-		or "Not installed";
+	local luarocks_status = "Not installed"
+	if pcall(require, "luarocks.loader") then
+		luarocks_status = "Installed (2.x+)";
+		if package.loaded["luarocks.cfg"] then
+			luarocks_status = "Installed ("..(package.loaded["luarocks.cfg"].program_version or "2.x+")..")";
+		end
+	elseif pcall(require, "luarocks.require") then
+		luarocks_status = "Installed (1.x)";
+	end
 	print("LuaRocks:        ", luarocks_status);
 	print("");
 	print("# Lua module versions");
 	local module_versions, longest_name = {}, 8;
 	local luaevent =dependencies.softreq"luaevent";
-	local ssl = dependencies.softreq"ssl";
+	dependencies.softreq"ssl";
 	for name, module in pairs(package.loaded) do
 		if type(module) == "table" and rawget(module, "_VERSION")
 		and name ~= "_G" and not name:match("%.") then
@@ -718,11 +507,12 @@
 	end
 end
 
-local cert_basedir = CFG_DATADIR or "./certs";
+local have_pposix, pposix = pcall(require, "util.pposix");
+local cert_basedir = prosody.paths.data == "." and "./certs" or prosody.paths.data;
 if have_pposix and pposix.getuid() == 0 then
 	-- FIXME should be enough to check if this directory is writable
-	local cert_dir = config.get("*", "certificates") or "certs";
-	cert_basedir = config.resolve_relative_path(prosody.paths.config, cert_dir);
+	local cert_dir = configmanager.get("*", "certificates") or "certs";
+	cert_basedir = configmanager.resolve_relative_path(prosody.paths.config, cert_dir);
 end
 
 function cert_commands.config(arg)
@@ -736,7 +526,7 @@
 			distinguished_name = table.remove(arg);
 		end
 		local conf = openssl.config.new();
-		conf:from_prosody(hosts, config, arg);
+		conf:from_prosody(prosody.hosts, configmanager, arg);
 		if distinguished_name then
 			local dn = {};
 			for k, v in distinguished_name:gmatch("/([^=/]+)=([^/]+)") do
@@ -750,7 +540,7 @@
 			for _, k in ipairs(openssl._DN_order) do
 				local v = conf.distinguished_name[k];
 				if v then
-					local nv;
+					local nv = nil;
 					if k == "commonName" then
 						v = arg[1]
 					elseif k == "emailAddress" then
@@ -892,7 +682,7 @@
 			end
 		else
 			for host in pairs(prosody.hosts) do
-				if host ~= "*" and config.get(host, "enabled") ~= false then
+				if host ~= "*" and configmanager.get(host, "enabled") ~= false then
 					table.insert(hostnames, host);
 				end
 			end
@@ -905,8 +695,8 @@
 	end
 	local owner, group;
 	if pposix.getuid() == 0 then -- We need root to change ownership
-		owner = config.get("*", "prosody_user") or "prosody";
-		group = config.get("*", "prosody_group") or owner;
+		owner = configmanager.get("*", "prosody_user") or "prosody";
+		group = configmanager.get("*", "prosody_group") or owner;
 	end
 	local cm = require "core.certmanager";
 	local imported = {};
@@ -965,7 +755,7 @@
 					show_message"You need to supply at least one hostname"
 					arg = { "--help" };
 				end
-				if arg[1] ~= "--help" and not hosts[arg[1]] then
+				if arg[1] ~= "--help" and not prosody.hosts[arg[1]] then
 					show_message(error_messages["no-such-host"]);
 					return 1;
 				end
@@ -988,26 +778,26 @@
 		return 1;
 	end
 	local what = table.remove(arg, 1);
-	local array, set = require "util.array", require "util.set";
+	local set = require "util.set";
 	local it = require "util.iterators";
 	local ok = true;
 	local function disabled_hosts(host, conf) return host ~= "*" and conf.enabled ~= false; end
-	local function enabled_hosts() return it.filter(disabled_hosts, pairs(config.getconfig())); end
+	local function enabled_hosts() return it.filter(disabled_hosts, pairs(configmanager.getconfig())); end
 	if not (what == nil or what == "disabled" or what == "config" or what == "dns" or what == "certs") then
 		show_warning("Don't know how to check '%s'. Try one of 'config', 'dns', 'certs' or 'disabled'.", what);
 		return 1;
 	end
 	if not what or what == "disabled" then
-		local disabled_hosts = set.new();
-		for host, host_options in it.filter("*", pairs(config.getconfig())) do
+		local disabled_hosts_set = set.new();
+		for host, host_options in it.filter("*", pairs(configmanager.getconfig())) do
 			if host_options.enabled == false then
-				disabled_hosts:add(host);
+				disabled_hosts_set:add(host);
 			end
 		end
-		if not disabled_hosts:empty() then
+		if not disabled_hosts_set:empty() then
 			local msg = "Checks will be skipped for these disabled hosts: %s";
 			if what then msg = "These hosts are disabled: %s"; end
-			show_warning(msg, tostring(disabled_hosts));
+			show_warning(msg, tostring(disabled_hosts_set));
 			if what then return 0; end
 			print""
 		end
@@ -1022,14 +812,15 @@
 			"pidfile", "log", "plugin_paths", "prosody_user", "prosody_group", "daemonize",
 			"umask", "prosodyctl_timeout", "use_ipv6", "use_libevent", "network_settings",
 			"network_backend", "http_default_host",
+			"statistics_interval", "statistics", "statistics_config",
 		});
-		local config = config.getconfig();
+		local config = configmanager.getconfig();
 		-- Check that we have any global options (caused by putting a host at the top)
 		if it.count(it.filter("log", pairs(config["*"]))) == 0 then
 			ok = false;
 			print("");
 			print("    No global options defined. Perhaps you have put a host definition at the top")
-			print("    of the config file? They should be at the bottom, see http://prosody.im/doc/configure#overview");
+			print("    of the config file? They should be at the bottom, see https://prosody.im/doc/configure#overview");
 		end
 		if it.count(enabled_hosts()) == 0 then
 			ok = false;
@@ -1045,7 +836,7 @@
 		if not config["*"].modules_enabled then
 			print("    No global modules_enabled is set?");
 			local suggested_global_modules;
-			for host, options in enabled_hosts() do
+			for host, options in enabled_hosts() do --luacheck: ignore 213/host
 				if not options.component_module and options.modules_enabled then
 					suggested_global_modules = set.intersection(suggested_global_modules or set.new(options.modules_enabled), set.new(options.modules_enabled));
 				end
@@ -1056,6 +847,19 @@
 			end
 			print();
 		end
+
+		do -- Check for modules enabled both normally and as components
+			local modules = set.new(config["*"]["modules_enabled"]);
+			for host, options in enabled_hosts() do
+				local component_module = options.component_module;
+				if component_module and modules:contains(component_module) then
+					print(("    mod_%s is enabled both in modules_enabled and as Component %q %q"):format(component_module, host, component_module));
+					print("    This means the service is enabled on all VirtualHosts as well as the Component.");
+					print("    Are you sure this what you want? It may cause unexpected behaviour.");
+				end
+			end
+		end
+
 		-- Check for global options under hosts
 		local global_options = set.new(it.to_array(it.keys(config["*"])));
 		local deprecated_global_options = set.intersection(global_options, deprecated);
@@ -1065,7 +869,7 @@
 			print("    "..tostring(deprecated_global_options))
 			ok = false;
 		end
-		for host, options in enabled_hosts() do
+		for host, options in it.filter(function (h) return h ~= "*" end, pairs(configmanager.getconfig())) do
 			local host_options = set.new(it.to_array(it.keys(options)));
 			local misplaced_options = set.intersection(host_options, known_global_options);
 			for name in pairs(options) do
@@ -1081,17 +885,20 @@
 				local n = it.count(misplaced_options);
 				print("    You have "..n.." option"..(n>1 and "s " or " ").."set under "..host.." that should be");
 				print("    in the global section of the config file, above any VirtualHost or Component definitions,")
-				print("    see http://prosody.im/doc/configure#overview for more information.")
+				print("    see https://prosody.im/doc/configure#overview for more information.")
 				print("");
 				print("    You need to move the following option"..(n>1 and "s" or "")..": "..table.concat(it.to_array(misplaced_options), ", "));
 			end
+		end
+		for host, options in enabled_hosts() do
+			local host_options = set.new(it.to_array(it.keys(options)));
 			local subdomain = host:match("^[^.]+");
 			if not(host_options:contains("component_module")) and (subdomain == "jabber" or subdomain == "xmpp"
-				or subdomain == "chat" or subdomain == "im") then
+			   or subdomain == "chat" or subdomain == "im") then
 				print("");
 				print("    Suggestion: If "..host.. " is a new host with no real users yet, consider renaming it now to");
 				print("     "..host:gsub("^[^.]+%.", "")..". You can use SRV records to redirect XMPP clients and servers to "..host..".");
-				print("     For more information see: http://prosody.im/doc/dns");
+				print("     For more information see: https://prosody.im/doc/dns");
 			end
 		end
 		local all_modules = set.new(config["*"].modules_enabled);
@@ -1121,14 +928,16 @@
 				print("    For more information see https://prosody.im/doc/storage");
 			end
 		end
-		for host, config in pairs(config) do
-			if type(rawget(config, "storage")) == "string" and rawget(config, "default_storage") then
+		for host, host_config in pairs(config) do --luacheck: ignore 213/host
+			if type(rawget(host_config, "storage")) == "string" and rawget(host_config, "default_storage") then
 				print("");
 				print("    The 'default_storage' option is not needed if 'storage' is set to a string.");
 				break;
 			end
 		end
-		local require_encryption = set.intersection(all_options, set.new({"require_encryption", "c2s_require_encryption", "s2s_require_encryption"})):empty();
+		local require_encryption = set.intersection(all_options, set.new({
+			"require_encryption", "c2s_require_encryption", "s2s_require_encryption"
+		})):empty();
 		local ssl = dependencies.softreq"ssl";
 		if not ssl then
 			if not require_encryption then
@@ -1174,8 +983,8 @@
 		local dns = require "net.dns";
 		local idna = require "util.encodings".idna;
 		local ip = require "util.ip";
-		local c2s_ports = set.new(config.get("*", "c2s_ports") or {5222});
-		local s2s_ports = set.new(config.get("*", "s2s_ports") or {5269});
+		local c2s_ports = set.new(configmanager.get("*", "c2s_ports") or {5222});
+		local s2s_ports = set.new(configmanager.get("*", "s2s_ports") or {5269});
 
 		local c2s_srv_required, s2s_srv_required;
 		if not c2s_ports:contains(5222) then
@@ -1191,16 +1000,20 @@
 
 		local fqdn = socket.dns.tohostname(socket.dns.gethostname());
 		if fqdn then
-			local res = dns.lookup(idna.to_ascii(fqdn), "A");
-			if res then
-				for _, record in ipairs(res) do
-					external_addresses:add(record.a);
+			do
+				local res = dns.lookup(idna.to_ascii(fqdn), "A");
+				if res then
+					for _, record in ipairs(res) do
+						external_addresses:add(record.a);
+					end
 				end
 			end
-			local res = dns.lookup(idna.to_ascii(fqdn), "AAAA");
-			if res then
-				for _, record in ipairs(res) do
-					external_addresses:add(record.aaaa);
+			do
+				local res = dns.lookup(idna.to_ascii(fqdn), "AAAA");
+				if res then
+					for _, record in ipairs(res) do
+						external_addresses:add(record.aaaa);
+					end
 				end
 			end
 		end
@@ -1227,13 +1040,18 @@
 			local all_targets_ok, some_targets_ok = true, false;
 			local node, host = jid_split(jid);
 
+			local modules, component_module = modulemanager.get_modules_for_host(host);
+			if component_module then
+				modules:add(component_module);
+			end
+
 			local is_component = not not host_options.component_module;
 			print("Checking DNS for "..(is_component and "component" or "host").." "..jid.."...");
 			if node then
 				print("Only the domain part ("..host..") is used in DNS.")
 			end
 			local target_hosts = set.new();
-			if not is_component then
+			if modules:contains("c2s") then
 				local res = dns.lookup("_xmpp-client._tcp."..idna.to_ascii(host)..".", "SRV");
 				if res then
 					for _, record in ipairs(res) do
@@ -1251,20 +1069,22 @@
 					end
 				end
 			end
-			local res = dns.lookup("_xmpp-server._tcp."..idna.to_ascii(host)..".", "SRV");
-			if res then
-				for _, record in ipairs(res) do
-					target_hosts:add(record.srv.target);
-					if not s2s_ports:contains(record.srv.port) then
-						print("    SRV target "..record.srv.target.." contains unknown server port: "..record.srv.port);
+			if modules:contains("s2s") then
+				local res = dns.lookup("_xmpp-server._tcp."..idna.to_ascii(host)..".", "SRV");
+				if res then
+					for _, record in ipairs(res) do
+						target_hosts:add(record.srv.target);
+						if not s2s_ports:contains(record.srv.port) then
+							print("    SRV target "..record.srv.target.." contains unknown server port: "..record.srv.port);
+						end
 					end
-				end
-			else
-				if s2s_srv_required then
-					print("    No _xmpp-server SRV record found for "..host..", but it looks like you need one.");
-					all_targets_ok = false;
 				else
-					target_hosts:add(host);
+					if s2s_srv_required then
+						print("    No _xmpp-server SRV record found for "..host..", but it looks like you need one.");
+						all_targets_ok = false;
+					else
+						target_hosts:add(host);
+					end
 				end
 			end
 			if target_hosts:empty() then
@@ -1276,12 +1096,8 @@
 				target_hosts:remove("localhost");
 			end
 
-			local modules = set.new(it.to_array(it.values(host_options.modules_enabled or {})))
-			                + set.new(it.to_array(it.values(config.get("*", "modules_enabled") or {})))
-			                + set.new({ config.get(host, "component_module") });
-
 			if modules:contains("proxy65") then
-				local proxy65_target = config.get(host, "proxy65_address") or host;
+				local proxy65_target = configmanager.get(host, "proxy65_address") or host;
 				local A, AAAA = dns.lookup(idna.to_ascii(proxy65_target), "A"), dns.lookup(idna.to_ascii(proxy65_target), "AAAA");
 				local prob = {};
 				if not A then
@@ -1291,41 +1107,46 @@
 					table.insert(prob, "AAAA");
 				end
 				if #prob > 0 then
-					print("    File transfer proxy "..proxy65_target.." has no "..table.concat(prob, "/").." record. Create one or set 'proxy65_address' to the correct host/IP.");
+					print("    File transfer proxy "..proxy65_target.." has no "..table.concat(prob, "/")
+					.." record. Create one or set 'proxy65_address' to the correct host/IP.");
 				end
 			end
 
-			for host in target_hosts do
+			for target_host in target_hosts do
 				local host_ok_v4, host_ok_v6;
-				local res = dns.lookup(idna.to_ascii(host), "A");
-				if res then
-					for _, record in ipairs(res) do
-						if external_addresses:contains(record.a) then
-							some_targets_ok = true;
-							host_ok_v4 = true;
-						elseif internal_addresses:contains(record.a) then
-							host_ok_v4 = true;
-							some_targets_ok = true;
-							print("    "..host.." A record points to internal address, external connections might fail");
-						else
-							print("    "..host.." A record points to unknown address "..record.a);
-							all_targets_ok = false;
+				do
+					local res = dns.lookup(idna.to_ascii(target_host), "A");
+					if res then
+						for _, record in ipairs(res) do
+							if external_addresses:contains(record.a) then
+								some_targets_ok = true;
+								host_ok_v4 = true;
+							elseif internal_addresses:contains(record.a) then
+								host_ok_v4 = true;
+								some_targets_ok = true;
+								print("    "..target_host.." A record points to internal address, external connections might fail");
+							else
+								print("    "..target_host.." A record points to unknown address "..record.a);
+								all_targets_ok = false;
+							end
 						end
 					end
 				end
-				local res = dns.lookup(idna.to_ascii(host), "AAAA");
-				if res then
-					for _, record in ipairs(res) do
-						if external_addresses:contains(record.aaaa) then
-							some_targets_ok = true;
-							host_ok_v6 = true;
-						elseif internal_addresses:contains(record.aaaa) then
-							host_ok_v6 = true;
-							some_targets_ok = true;
-							print("    "..host.." AAAA record points to internal address, external connections might fail");
-						else
-							print("    "..host.." AAAA record points to unknown address "..record.aaaa);
-							all_targets_ok = false;
+				do
+					local res = dns.lookup(idna.to_ascii(target_host), "AAAA");
+					if res then
+						for _, record in ipairs(res) do
+							if external_addresses:contains(record.aaaa) then
+								some_targets_ok = true;
+								host_ok_v6 = true;
+							elseif internal_addresses:contains(record.aaaa) then
+								host_ok_v6 = true;
+								some_targets_ok = true;
+								print("    "..target_host.." AAAA record points to internal address, external connections might fail");
+							else
+								print("    "..target_host.." AAAA record points to unknown address "..record.aaaa);
+								all_targets_ok = false;
+							end
 						end
 					end
 				end
@@ -1338,11 +1159,11 @@
 					table.insert(bad_protos, "IPv6");
 				end
 				if #bad_protos > 0 then
-					print("    Host "..host.." does not seem to resolve to this server ("..table.concat(bad_protos, "/")..")");
+					print("    Host "..target_host.." does not seem to resolve to this server ("..table.concat(bad_protos, "/")..")");
 				end
 				if host_ok_v6 and not v6_supported then
-					print("    Host "..host.." has AAAA records, but your version of LuaSocket does not support IPv6.");
-					print("      Please see http://prosody.im/doc/ipv6 for more information.");
+					print("    Host "..target_host.." has AAAA records, but your version of LuaSocket does not support IPv6.");
+					print("      Please see https://prosody.im/doc/ipv6 for more information.");
 				end
 			end
 			if not all_targets_ok then
@@ -1356,7 +1177,7 @@
 		end
 		if not problem_hosts:empty() then
 			print("");
-			print("For more information about DNS configuration please see http://prosody.im/doc/dns");
+			print("For more information about DNS configuration please see https://prosody.im/doc/dns");
 			print("");
 			ok = false;
 		end
@@ -1387,9 +1208,9 @@
 			for host in it.filter(skip_bare_jid_hosts, enabled_hosts()) do
 				print("Checking certificate for "..host);
 				-- First, let's find out what certificate this host uses.
-				local host_ssl_config = config.rawget(host, "ssl")
-					or config.rawget(host:match("%.(.*)"), "ssl");
-				local global_ssl_config = config.rawget("*", "ssl");
+				local host_ssl_config = configmanager.rawget(host, "ssl")
+					or configmanager.rawget(host:match("%.(.*)"), "ssl");
+				local global_ssl_config = configmanager.rawget("*", "ssl");
 				local ok, err, ssl_config = create_context(host, "server", host_ssl_config, global_ssl_config);
 				if not ok then
 					print("  Error: "..err);
@@ -1414,7 +1235,7 @@
 						cert_ok = false
 					else
 						print("  Certificate: "..ssl_config.certificate)
-						local cert = load_cert(cert_fh:read"*a"); cert_fh = cert_fh:close();
+						local cert = load_cert(cert_fh:read"*a"); cert_fh:close();
 						if not cert:validat(os.time()) then
 							print("    Certificate has expired.")
 							cert_ok = false
@@ -1426,13 +1247,13 @@
 						elseif not cert:validat(os.time() + 86400*31) then
 							print("    Certificate expires within one month.")
 						end
-						if config.get(host, "component_module") == nil
+						if configmanager.get(host, "component_module") == nil
 							and not x509_verify_identity(host, "_xmpp-client", cert) then
 							print("    Not valid for client connections to "..host..".")
 							cert_ok = false
 						end
-						if (not (config.get(host, "anonymous_login")
-							or config.get(host, "authentication") == "anonymous"))
+						if (not (configmanager.get(host, "anonymous_login")
+							or configmanager.get(host, "authentication") == "anonymous"))
 							and not x509_verify_identity(host, "_xmpp-server", cert) then
 							print("    Not valid for server-to-server connections to "..host..".")
 							cert_ok = false
@@ -1440,11 +1261,11 @@
 					end
 				end
 			end
-			if cert_ok == false then
-				print("")
-				print("For more information about certificates please see http://prosody.im/doc/certificates");
-				ok = false
-			end
+		end
+		if cert_ok == false then
+			print("")
+			print("For more information about certificates please see https://prosody.im/doc/certificates");
+			ok = false
 		end
 		print("")
 	end
@@ -1458,77 +1279,93 @@
 
 ---------------------
 
-if command and command:match("^mod_") then -- Is a command in a module
-	local module_name = command:match("^mod_(.+)");
-	local ret, err = modulemanager.load("*", module_name);
-	if not ret then
-		show_message("Failed to load module '"..module_name.."': "..err);
-		os.exit(1);
-	end
-
-	table.remove(arg, 1);
-
-	local module = modulemanager.get_module("*", module_name);
-	if not module then
-		show_message("Failed to load module '"..module_name.."': Unknown error");
-		os.exit(1);
-	end
-
-	if not modulemanager.module_has_method(module, "command") then
-		show_message("Fail: mod_"..module_name.." does not support any commands");
-		os.exit(1);
-	end
-
-	local ok, ret = modulemanager.call_module_method(module, "command", arg);
-	if ok then
-		if type(ret) == "number" then
-			os.exit(ret);
-		elseif type(ret) == "string" then
-			show_message(ret);
+local async = require "util.async";
+local server = require "net.server";
+local watchers = {
+	error = function (_, err)
+		error(err);
+	end;
+	waiting = function ()
+		server.loop();
+	end;
+};
+local command_runner = async.runner(function ()
+	if command and command:match("^mod_") then -- Is a command in a module
+		local module_name = command:match("^mod_(.+)");
+		do
+			local ret, err = modulemanager.load("*", module_name);
+			if not ret then
+				show_message("Failed to load module '"..module_name.."': "..err);
+				os.exit(1);
+			end
 		end
-		os.exit(0); -- :)
-	else
-		show_message("Failed to execute command: "..error_messages[ret]);
-		os.exit(1); -- :(
-	end
-end
+
+		table.remove(arg, 1);
 
-if not commands[command] then -- Show help for all commands
-	function show_usage(usage, desc)
-		print(" "..usage);
-		print("    "..desc);
-	end
+		local module = modulemanager.get_module("*", module_name);
+		if not module then
+			show_message("Failed to load module '"..module_name.."': Unknown error");
+			os.exit(1);
+		end
 
-	print("prosodyctl - Manage a Prosody server");
-	print("");
-	print("Usage: "..arg[0].." COMMAND [OPTIONS]");
-	print("");
-	print("Where COMMAND may be one of:\n");
-
-	local hidden_commands = require "util.set".new{ "register", "unregister", "addplugin" };
-	local commands_order = { "adduser", "passwd", "deluser", "start", "stop", "restart", "reload", "about" };
+		if not modulemanager.module_has_method(module, "command") then
+			show_message("Fail: mod_"..module_name.." does not support any commands");
+			os.exit(1);
+		end
 
-	local done = {};
-
-	for _, command_name in ipairs(commands_order) do
-		local command = commands[command_name];
-		if command then
-			command{ "--help" };
-			print""
-			done[command_name] = true;
+		local ok, ret = modulemanager.call_module_method(module, "command", arg);
+		if ok then
+			if type(ret) == "number" then
+				os.exit(ret);
+			elseif type(ret) == "string" then
+				show_message(ret);
+			end
+			os.exit(0); -- :)
+		else
+			show_message("Failed to execute command: "..error_messages[ret]);
+			os.exit(1); -- :(
 		end
 	end
 
-	for command_name, command in pairs(commands) do
-		if not done[command_name] and not hidden_commands:contains(command_name) then
-			command{ "--help" };
-			print""
-			done[command_name] = true;
+	if not commands[command] then -- Show help for all commands
+		function show_usage(usage, desc)
+			print(" "..usage);
+			print("    "..desc);
 		end
+
+		print("prosodyctl - Manage a Prosody server");
+		print("");
+		print("Usage: "..arg[0].." COMMAND [OPTIONS]");
+		print("");
+		print("Where COMMAND may be one of:\n");
+
+		local hidden_commands = require "util.set".new{ "register", "unregister", "addplugin" };
+		local commands_order = { "adduser", "passwd", "deluser", "start", "stop", "restart", "reload", "about" };
+
+		local done = {};
+
+		for _, command_name in ipairs(commands_order) do
+			local command_func = commands[command_name];
+			if command_func then
+				command_func{ "--help" };
+				print""
+				done[command_name] = true;
+			end
+		end
+
+		for command_name, command_func in pairs(commands) do
+			if not done[command_name] and not hidden_commands:contains(command_name) then
+				command_func{ "--help" };
+				print""
+				done[command_name] = true;
+			end
+		end
+
+
+		os.exit(0);
 	end
 
+	os.exit(commands[command]({ select(2, unpack(arg)) }));
+end, watchers);
 
-	os.exit(0);
-end
-
-os.exit(commands[command]({ select(2, unpack(arg)) }));
+command_runner:run(true);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/core_configmanager_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,31 @@
+
+local configmanager = require "core.configmanager";
+
+describe("core.configmanager", function()
+	describe("#get()", function()
+		it("should work", function()
+			configmanager.set("example.com", "testkey", 123);
+			assert.are.equal(123, configmanager.get("example.com", "testkey"), "Retrieving a set key");
+
+			configmanager.set("*", "testkey1", 321);
+			assert.are.equal(321, configmanager.get("*", "testkey1"), "Retrieving a set global key");
+			assert.are.equal(321, configmanager.get("example.com", "testkey1"), "Retrieving a set key of undefined host, of which only a globally set one exists");
+
+			configmanager.set("example.com", ""); -- Creates example.com host in config
+			assert.are.equal(321, configmanager.get("example.com", "testkey1"), "Retrieving a set key, of which only a globally set one exists");
+
+			assert.are.equal(nil, configmanager.get(), "No parameters to get()");
+			assert.are.equal(nil, configmanager.get("undefined host"), "Getting for undefined host");
+			assert.are.equal(nil, configmanager.get("undefined host", "undefined key"), "Getting for undefined host & key");
+		end);
+	end);
+
+	describe("#set()", function()
+		it("should work", function()
+			assert.are.equal(false, configmanager.set("*"), "Set with no key");
+
+			assert.are.equal(true, configmanager.set("*", "set_test", "testkey"), "Setting a nil global value");
+			assert.are.equal(true, configmanager.set("*", "set_test", "testkey", 123), "Setting a global value");
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/core_moduleapi_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,76 @@
+
+package.loaded["core.configmanager"] = {};
+package.loaded["core.statsmanager"] = {};
+package.loaded["net.server"] = {};
+
+local set = require "util.set";
+
+_G.prosody = { hosts = {}, core_post_stanza = true };
+
+local api = require "core.moduleapi";
+
+local module = setmetatable({}, {__index = api});
+local opt = nil;
+function module:log() end
+function module:get_option(name)
+	if name == "opt" then
+		return opt;
+	else
+		return nil;
+	end
+end
+
+function test_option_value(value, returns)
+	opt = value;
+	assert(module:get_option_number("opt") == returns.number, "number doesn't match");
+	assert(module:get_option_string("opt") == returns.string, "string doesn't match");
+	assert(module:get_option_boolean("opt") == returns.boolean, "boolean doesn't match");
+
+	if type(returns.array) == "table" then
+		local target_array, returned_array = returns.array, module:get_option_array("opt");
+		assert(#target_array == #returned_array, "array length doesn't match");
+		for i=1,#target_array do
+			assert(target_array[i] == returned_array[i], "array item doesn't match");
+		end
+	else
+		assert(module:get_option_array("opt") == returns.array, "array is returned (not nil)");
+	end
+
+	if type(returns.set) == "table" then
+		local target_items, returned_items = set.new(returns.set), module:get_option_set("opt");
+		assert(target_items == returned_items, "set doesn't match");
+	else
+		assert(module:get_option_set("opt") == returns.set, "set is returned (not nil)");
+	end
+end
+
+describe("core.moduleapi", function()
+	describe("#get_option_*()", function()
+		it("should handle missing options", function()
+			test_option_value(nil, {});
+		end);
+
+		it("should return correctly handle boolean options", function()
+			test_option_value(true, { boolean = true, string = "true", array = {true}, set = {true} });
+			test_option_value(false, { boolean = false, string = "false", array = {false}, set = {false} });
+			test_option_value("true", { boolean = true, string = "true", array = {"true"}, set = {"true"} });
+			test_option_value("false", { boolean = false, string = "false", array = {"false"}, set = {"false"} });
+			test_option_value(1, { boolean = true, string = "1", array = {1}, set = {1}, number = 1 });
+			test_option_value(0, { boolean = false, string = "0", array = {0}, set = {0}, number = 0 });
+		end);
+
+		it("should return handle strings", function()
+			test_option_value("hello world", { string = "hello world", array = {"hello world"}, set = {"hello world"} });
+		end);
+
+		it("should return handle numbers", function()
+			test_option_value(1234, { string = "1234", number = 1234, array = {1234}, set = {1234} });
+		end);
+
+		it("should return handle arrays", function()
+			test_option_value({1, 2, 3}, { boolean = true, string = "1", number = 1, array = {1, 2, 3}, set = {1, 2, 3} });
+			test_option_value({1, 2, 3, 3, 4}, {boolean = true, string = "1", number = 1, array = {1, 2, 3, 3, 4}, set = {1, 2, 3, 4} });
+			test_option_value({0, 1, 2, 3}, { boolean = false, string = "0", number = 0, array = {0, 1, 2, 3}, set = {0, 1, 2, 3} });
+		end);
+	end)
+end)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/core_storagemanager_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,332 @@
+local unpack = table.unpack or unpack;
+local server = require "net.server_select";
+package.loaded["net.server"] = server;
+
+local st = require "util.stanza";
+
+local function mock_prosody()
+	_G.prosody = {
+		core_post_stanza = function () end;
+		events = require "util.events".new();
+		hosts = {};
+		paths = {
+			data = "./data";
+		};
+	};
+end
+
+local configs = {
+	memory = {
+		storage = "memory";
+	};
+	internal = {
+		storage = "internal";
+	};
+	sqlite = {
+		storage = "sql";
+		sql = { driver = "SQLite3", database = "prosody-tests.sqlite" };
+	};
+	mysql = {
+		storage = "sql";
+		sql = { driver = "MySQL",  database = "prosody", username = "prosody", password = "secret", host = "localhost" };
+	};
+	postgres = {
+		storage = "sql";
+		sql = { driver = "PostgreSQL", database = "prosody", username = "prosody", password = "secret", host = "localhost" };
+	};
+};
+
+local test_host = "storage-unit-tests.invalid";
+
+describe("storagemanager", function ()
+	for backend, backend_config in pairs(configs) do
+		local tagged_name = "#"..backend;
+		if backend ~= backend_config.storage then
+			tagged_name = tagged_name.." #"..backend_config.storage;
+		end
+		insulate(tagged_name.." #storage backend", function ()
+			mock_prosody();
+
+			local config = require "core.configmanager";
+			local sm = require "core.storagemanager";
+			local hm = require "core.hostmanager";
+			local mm = require "core.modulemanager";
+
+			-- Simple check to ensure insulation is working correctly
+			assert.is_nil(config.get(test_host, "storage"));
+
+			for k, v in pairs(backend_config) do
+				config.set(test_host, k, v);
+			end
+			assert(hm.activate(test_host, {}));
+			sm.initialize_host(test_host);
+			assert(mm.load(test_host, "storage_"..backend_config.storage));
+
+			describe("key-value stores", function ()
+				-- These tests rely on being executed in order, disable any order
+				-- randomization for this block
+				randomize(false);
+
+				local store;
+				it("may be opened", function ()
+					store = assert(sm.open(test_host, "test"));
+				end);
+
+				local simple_data = { foo = "bar" };
+
+				it("may set data for a user", function ()
+					assert(store:set("user9999", simple_data));
+				end);
+
+				it("may get data for a user", function ()
+					assert.same(simple_data, assert(store:get("user9999")));
+				end);
+
+				it("may remove data for a user", function ()
+					assert(store:set("user9999", nil));
+					local ret, err = store:get("user9999");
+					assert.is_nil(ret);
+					assert.is_nil(err);
+				end);
+			end);
+
+			describe("archive stores", function ()
+				randomize(false);
+
+				local archive;
+				it("can be opened", function ()
+					archive = assert(sm.open(test_host, "test-archive", "archive"));
+				end);
+
+				local test_stanza = st.stanza("test", { xmlns = "urn:example:foo" })
+					:tag("foo"):up()
+					:tag("foo"):up();
+				local test_time = 1539204123;
+
+				local test_data = {
+					{ nil, test_stanza, test_time, "contact@example.com" };
+					{ nil, test_stanza, test_time+1, "contact2@example.com" };
+					{ nil, test_stanza, test_time+2, "contact2@example.com" };
+					{ nil, test_stanza, test_time-1, "contact2@example.com" };
+				};
+
+				it("can be added to", function ()
+					for _, data_item in ipairs(test_data) do
+						local ok = archive:append("user", unpack(data_item, 1, 4));
+						assert.truthy(ok);
+					end
+				end);
+
+				describe("can be queried", function ()
+					it("for all items", function ()
+						local data, err = archive:find("user", {});
+						assert.truthy(data);
+						local count = 0;
+						for id, item, when in data do
+							count = count + 1;
+							assert.truthy(id);
+							assert(st.is_stanza(item));
+							assert.equal("test", item.name);
+							assert.equal("urn:example:foo", item.attr.xmlns);
+							assert.equal(2, #item.tags);
+							assert.equal(test_data[count][3], when);
+						end
+						assert.equal(#test_data, count);
+					end);
+
+					it("by JID", function ()
+						local data, err = archive:find("user", {
+							with = "contact@example.com";
+						});
+						assert.truthy(data);
+						local count = 0;
+						for id, item, when in data do
+							count = count + 1;
+							assert.truthy(id);
+							assert(st.is_stanza(item));
+							assert.equal("test", item.name);
+							assert.equal("urn:example:foo", item.attr.xmlns);
+							assert.equal(2, #item.tags);
+							assert.equal(test_time, when);
+						end
+						assert.equal(1, count);
+					end);
+
+					it("by time (end)", function ()
+						local data, err = archive:find("user", {
+							["end"] = test_time;
+						});
+						assert.truthy(data);
+						local count = 0;
+						for id, item, when in data do
+							count = count + 1;
+							assert.truthy(id);
+							assert(st.is_stanza(item));
+							assert.equal("test", item.name);
+							assert.equal("urn:example:foo", item.attr.xmlns);
+							assert.equal(2, #item.tags);
+							assert(test_time >= when);
+						end
+						assert.equal(2, count);
+					end);
+
+					it("by time (start)", function ()
+						local data, err = archive:find("user", {
+							["start"] = test_time;
+						});
+						assert.truthy(data);
+						local count = 0;
+						for id, item, when in data do
+							count = count + 1;
+							assert.truthy(id);
+							assert(st.is_stanza(item));
+							assert.equal("test", item.name);
+							assert.equal("urn:example:foo", item.attr.xmlns);
+							assert.equal(2, #item.tags);
+							assert(test_time <= when);
+						end
+						assert.equal(#test_data -1, count);
+					end);
+
+					it("by time (start+end)", function ()
+						local data, err = archive:find("user", {
+							["start"] = test_time;
+							["end"] = test_time+1;
+						});
+						assert.truthy(data);
+						local count = 0;
+						for id, item, when in data do
+							count = count + 1;
+							assert.truthy(id);
+							assert(st.is_stanza(item));
+							assert.equal("test", item.name);
+							assert.equal("urn:example:foo", item.attr.xmlns);
+							assert.equal(2, #item.tags);
+							assert(when >= test_time, ("%d >= %d"):format(when, test_time));
+							assert(when <= test_time+1, ("%d <= %d"):format(when, test_time+1));
+						end
+						assert.equal(2, count);
+					end);
+				end);
+
+				it("can selectively delete items", function ()
+					local delete_id;
+					do
+						local data = assert(archive:find("user", {}));
+						local count = 0;
+						for id, item, when in data do --luacheck: ignore 213/item 213/when
+							count = count + 1;
+							if count == 2 then
+								delete_id = id;
+							end
+							assert.truthy(id);
+						end
+						assert.equal(#test_data, count);
+					end
+
+					assert(archive:delete("user", { key = delete_id }));
+
+					do
+						local data = assert(archive:find("user", {}));
+						local count = 0;
+						for id, item, when in data do --luacheck: ignore 213/item 213/when
+							count = count + 1;
+							assert.truthy(id);
+							assert.not_equal(delete_id, id);
+						end
+						assert.equal(#test_data-1, count);
+					end
+				end);
+
+				it("can be purged", function ()
+					local ok, err = archive:delete("user");
+					assert.truthy(ok);
+					local data, err = archive:find("user", {
+						with = "contact@example.com";
+					});
+					assert.truthy(data);
+					local count = 0;
+					for id, item, when in data do -- luacheck: ignore id item when
+						count = count + 1;
+					end
+					assert.equal(0, count);
+				end);
+
+				it("can truncate the oldest items", function ()
+					local username = "user-truncate";
+					for i = 1, 10 do
+						assert(archive:append(username, nil, test_stanza, i, "contact@example.com"));
+					end
+					assert(archive:delete(username, { truncate = 3 }));
+
+					do
+						local data = assert(archive:find(username, {}));
+						local count = 0;
+						for id, item, when in data do --luacheck: ignore 213/when
+							count = count + 1;
+							assert.truthy(id);
+							assert(st.is_stanza(item));
+							assert(when > 7, ("%d > 7"):format(when));
+						end
+						assert.equal(3, count);
+					end
+				end);
+
+				it("overwrites existing keys with new data", function ()
+					local prefix = ("a"):rep(50);
+					local username = "user-overwrite";
+					assert(archive:append(username, prefix.."-1", test_stanza, test_time, "contact@example.com"));
+					assert(archive:append(username, prefix.."-2", test_stanza, test_time, "contact@example.com"));
+
+					do
+						local data = assert(archive:find(username, {}));
+						local count = 0;
+						for id, item, when in data do --luacheck: ignore 213/when
+							count = count + 1;
+							assert.truthy(id);
+							assert.equals(("%s-%d"):format(prefix, count), id);
+							assert(st.is_stanza(item));
+						end
+						assert.equal(2, count);
+					end
+
+					local new_stanza = st.clone(test_stanza);
+					new_stanza.attr.foo = "bar";
+					assert(archive:append(username, prefix.."-2", new_stanza, test_time+1, "contact2@example.com"));
+
+					do
+						local data = assert(archive:find(username, {}));
+						local count = 0;
+						for id, item, when in data do
+							count = count + 1;
+							assert.truthy(id);
+							assert.equals(("%s-%d"):format(prefix, count), id);
+							assert(st.is_stanza(item));
+							if count == 2 then
+								assert.equals(test_time+1, when);
+								assert.equals("bar", item.attr.foo);
+							end
+						end
+						assert.equal(2, count);
+					end
+				end);
+
+				it("can contain multiple long unique keys #issue1073", function ()
+					local prefix = ("a"):rep(50);
+					assert(archive:append("user-issue1073", prefix.."-1", test_stanza, test_time, "contact@example.com"));
+					assert(archive:append("user-issue1073", prefix.."-2", test_stanza, test_time, "contact@example.com"));
+
+					local data = assert(archive:find("user-issue1073", {}));
+					local count = 0;
+					for id, item, when in data do --luacheck: ignore 213/when
+						count = count + 1;
+						assert.truthy(id);
+						assert(st.is_stanza(item));
+					end
+					assert.equal(2, count);
+					assert(archive:delete("user-issue1073"));
+				end);
+			end);
+		end);
+	end
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail1.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+"A JSON payload should be an object or array, not a string."
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail10.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+{"Extra value after close": true} "misplaced quoted value"
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail11.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+{"Illegal expression": 1 + 2}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail12.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+{"Illegal invocation": alert()}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail13.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+{"Numbers cannot have leading zeroes": 013}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail14.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+{"Numbers cannot be hex": 0x14}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail15.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+["Illegal backslash escape: \x15"]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail16.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+[\naked]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail17.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+["Illegal backslash escape: \017"]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail18.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+[[[[[[[[[[[[[[[[[[[["Too deep"]]]]]]]]]]]]]]]]]]]]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail19.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+{"Missing colon" null}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail2.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+["Unclosed array"
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail20.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+{"Double colon":: null}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail21.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+{"Comma instead of colon", null}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail22.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+["Colon instead of comma": false]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail23.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+["Bad value", truth]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail24.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+['single quote']
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail25.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+["	tab	character	in	string	"]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail26.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+["tab\   character\   in\  string\  "]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail27.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,2 @@
+["line
+break"]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail28.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,2 @@
+["line\
+break"]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail29.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+[0e]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail3.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+{unquoted_key: "keys must be quoted"}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail30.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+[0e+]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail31.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+[0e+-1]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail32.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+{"Comma instead if closing brace": true,
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail33.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+["mismatch"}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail4.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+["extra comma",]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail5.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+["double extra comma",,]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail6.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+[   , "<-- missing value"]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail7.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+["Comma after the close"],
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail8.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+["Extra close"]]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/fail9.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+{"Extra comma": true,}
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/pass1.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,58 @@
+[
+    "JSON Test Pattern pass1",
+    {"object with 1 member":["array with 1 element"]},
+    {},
+    [],
+    -42,
+    true,
+    false,
+    null,
+    {
+        "integer": 1234567890,
+        "real": -9876.543210,
+        "e": 0.123456789e-12,
+        "E": 1.234567890E+34,
+        "":  23456789012E66,
+        "zero": 0,
+        "one": 1,
+        "space": " ",
+        "quote": "\"",
+        "backslash": "\\",
+        "controls": "\b\f\n\r\t",
+        "slash": "/ & \/",
+        "alpha": "abcdefghijklmnopqrstuvwyz",
+        "ALPHA": "ABCDEFGHIJKLMNOPQRSTUVWYZ",
+        "digit": "0123456789",
+        "0123456789": "digit",
+        "special": "`1~!@#$%^&*()_+-={':[,]}|;.</>?",
+        "hex": "\u0123\u4567\u89AB\uCDEF\uabcd\uef4A",
+        "true": true,
+        "false": false,
+        "null": null,
+        "array":[  ],
+        "object":{  },
+        "address": "50 St. James Street",
+        "url": "http://www.JSON.org/",
+        "comment": "// /* <!-- --",
+        "# -- --> */": " ",
+        " s p a c e d " :[1,2 , 3
+
+,
+
+4 , 5        ,          6           ,7        ],"compact":[1,2,3,4,5,6,7],
+        "jsontext": "{\"object with 1 member\":[\"array with 1 element\"]}",
+        "quotes": "&#34; \u0022 %22 0x22 034 &#x22;",
+        "\/\\\"\uCAFE\uBABE\uAB98\uFCDE\ubcda\uef4A\b\f\n\r\t`1~!@#$%^&*()_+-=[]{}|;:',./<>?"
+: "A key can be any string"
+    },
+    0.5 ,98.6
+,
+99.44
+,
+
+1066,
+1e1,
+0.1e1,
+1e-1,
+1e00,2e+00,2e-00
+,"rosebud"]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/pass2.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,1 @@
+[[[[[[[[[[[[[[[[[[["Not too deep"]]]]]]]]]]]]]]]]]]]
\ No newline at end of file
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/json/pass3.json	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,6 @@
+{
+    "JSON Test Pattern pass3": {
+        "The outermost value": "must be an object or array.",
+        "In this test": "It is an object."
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/mod_bosh_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,674 @@
+
+-- Requires a host 'localhost' with SASL ANONYMOUS
+
+local bosh_url = "http://localhost:5280/http-bind"
+
+local logger = require "util.logger";
+
+local debug = false;
+
+local print = print;
+if debug then
+	logger.add_simple_sink(print, {
+		--"debug";
+		"info";
+		"warn";
+		"error";
+	});
+else
+	print = function () end
+end
+
+describe("#mod_bosh", function ()
+	local server = require "net.server_select";
+	package.loaded["net.server"] = server;
+	local async = require "util.async";
+	local timer = require "util.timer";
+	local http = require "net.http".new({ suppress_errors = false });
+
+	local function sleep(n)
+		local wait, done = async.waiter();
+		timer.add_task(n, function () done() end);
+		wait();
+	end
+
+	local st = require "util.stanza";
+	local xml = require "util.xml";
+
+	local function request(url, opt, cb, auto_wait)
+		local wait, done = async.waiter();
+		local ok, err;
+		http:request(url, opt, function (...)
+			ok, err = pcall(cb, ...);
+			if not ok then print("CAUGHT", err) end
+			done();
+		end);
+		local function err_wait(throw)
+			wait();
+			if throw ~= false and not ok then
+				error(err);
+			end
+			return ok, err;
+		end
+		if auto_wait == false then
+			return err_wait;
+		else
+			err_wait();
+		end
+	end
+
+	local function run_async(f)
+		local err;
+		local r = async.runner();
+		r:onerror(function (_, err_)
+			print("EER", err_)
+			err = err_;
+			server.setquitting("once");
+		end)
+		:onwaiting(function ()
+			--server.loop();
+		end)
+		:run(function ()
+			f()
+			server.setquitting("once");
+		end);
+		server.loop();
+		if err then
+			error(err);
+		end
+		if r.state ~= "ready" then
+			error("Runner in unexpected state: "..r.state);
+		end
+	end
+
+	it("test endpoint should be reachable", function ()
+		-- This is partly just to ensure the other tests have a chance to succeed
+		-- (i.e. the BOSH endpoint is up and functioning)
+		local function test()
+			request(bosh_url, nil, function (resp, code)
+				if code ~= 200 then
+					error("Unable to reach BOSH endpoint "..bosh_url);
+				end
+				assert.is_string(resp);
+			end);
+		end
+		run_async(test);
+	end);
+
+	it("should respond to past rids with past responses", function ()
+		local resp_1000_1, resp_1000_2 = "1", "2";
+
+		local function test_bosh()
+			local sid;
+
+		-- Set up BOSH session
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					to = "localhost";
+					from = "test@localhost";
+					content = "text/xml; charset=utf-8";
+					hold = "1";
+					rid = "998";
+					wait = "10";
+					["xml:lang"] = "en";
+					["xmpp:version"] = "1.0";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				})
+				:tag("auth", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl", mechanism = "ANONYMOUS" }):up()
+				:tag("iq", { xmlns = "jabber:client", type = "set", id = "bind1" })
+					:tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" })
+						:tag("resource"):text("bosh-test1"):up()
+					:up()
+				:up()
+				);
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				if not response_body:find("<jid>", 1, true) then
+					print("ERR", resp:pretty_print());
+					error("Failed to set up BOSH session");
+				end
+				sid = assert(resp.attr.sid);
+				print("SID", sid);
+			end);
+
+		-- Receive some additional post-login stuff
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "999";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				})
+				)
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				print("RESP 999", resp:pretty_print());
+			end);
+
+		-- Send first long poll
+			print "SEND 1000#1"
+			local wait1000 = request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "1000";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}))
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				resp_1000_1 = resp;
+				print("RESP 1000#1", resp:pretty_print());
+			end, false);
+
+		-- Wait a couple of seconds
+			sleep(2)
+
+		-- Send an early request, causing rid 1000 to return early
+			print "SEND 1001"
+			local wait1001 = request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "1001";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}))
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				print("RESP 1001", resp:pretty_print());
+			end, false);
+		-- Ensure we've received the response for rid 1000
+			wait1000();
+
+		-- Sleep a couple of seconds
+			print "...pause..."
+			sleep(2);
+
+		-- Re-send rid 1000, we should get the same response
+			print "SEND 1000#2"
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "1000";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}))
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				resp_1000_2 = resp;
+				print("RESP 1000#2", resp:pretty_print());
+			end);
+
+			local wait_final = request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "1002";
+					type = "terminate";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}))
+			}, function ()
+			end, false);
+
+			print "WAIT 1001"
+			wait1001();
+			wait_final();
+			print "DONE ALL"
+		end
+		run_async(test_bosh);
+		assert.truthy(resp_1000_1);
+		assert.same(resp_1000_1, resp_1000_2);
+	end);
+
+	it("should handle out-of-order requests", function ()
+		local function test()
+			local sid;
+		-- Set up BOSH session
+			local wait, done = async.waiter();
+			http:request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					to = "localhost";
+					from = "test@localhost";
+					content = "text/xml; charset=utf-8";
+					hold = "1";
+					rid = "1";
+					wait = "10";
+					["xml:lang"] = "en";
+					["xmpp:version"] = "1.0";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}));
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				sid = assert(resp.attr.sid, "Failed to set up BOSH session");
+				print("SID", sid);
+				done();
+			end);
+			print "WAIT 1"
+			wait();
+			print "DONE 1"
+
+			local rid2_response_received = false;
+
+		-- Temporarily skip rid 2, to simulate missed request
+			local wait3, done3 = async.waiter();
+			http:request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "3";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}):tag("iq", { xmlns = "jabber:client", type = "set", id = "bind" })
+					:tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" }):up()
+				:up()
+				)
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				print("RESP 3", resp:pretty_print());
+				done3();
+				-- The server should not respond to this request until
+				-- it has responded to rid 2
+				assert.is_true(rid2_response_received);
+			end);
+
+			print "SLEEPING"
+			sleep(2);
+			print "SLEPT"
+
+		-- Send the "missed" rid 2
+			local wait2, done2 = async.waiter();
+			http:request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "2";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}):tag("auth", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl", mechanism = "ANONYMOUS" }):up()
+				)
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				print("RESP 2", resp:pretty_print());
+				rid2_response_received = true;
+				done2();
+			end);
+			print "WAIT 2"
+			wait2();
+			print "WAIT 3"
+			wait3();
+			print "QUIT"
+		end
+		run_async(test);
+	end);
+
+	it("should work", function ()
+		local function test()
+			local sid;
+		-- Set up BOSH session
+			local wait, done = async.waiter();
+			http:request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					to = "localhost";
+					from = "test@localhost";
+					content = "text/xml; charset=utf-8";
+					hold = "1";
+					rid = "1";
+					wait = "10";
+					["xml:lang"] = "en";
+					["xmpp:version"] = "1.0";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}));
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				sid = assert(resp.attr.sid, "Failed to set up BOSH session");
+				print("SID", sid);
+				done();
+			end);
+			print "WAIT 1"
+			wait();
+			print "DONE 1"
+
+			local rid2_response_received = false;
+
+		-- Send the "missed" rid 2
+			local wait2, done2 = async.waiter();
+			http:request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "2";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}):tag("auth", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl", mechanism = "ANONYMOUS" }):up()
+				)
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				print("RESP 2", resp:pretty_print());
+				rid2_response_received = true;
+				done2();
+			end);
+
+			local wait3, done3 = async.waiter();
+			http:request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "3";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}):tag("iq", { xmlns = "jabber:client", type = "set", id = "bind" })
+					:tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" }):up()
+				:up()
+				)
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				print("RESP 3", resp:pretty_print());
+				done3();
+				-- The server should not respond to this request until
+				-- it has responded to rid 2
+				assert.is_true(rid2_response_received);
+			end);
+
+			print "SLEEPING"
+			sleep(2);
+			print "SLEPT"
+
+			print "WAIT 2"
+			wait2();
+			print "WAIT 3"
+			wait3();
+			print "QUIT"
+		end
+		run_async(test);
+	end);
+
+	it("should handle aborted pending requests", function ()
+		local resp_1000_1, resp_1000_2 = "1", "2";
+
+		local function test_bosh()
+			local sid;
+
+		-- Set up BOSH session
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					to = "localhost";
+					from = "test@localhost";
+					content = "text/xml; charset=utf-8";
+					hold = "1";
+					rid = "998";
+					wait = "10";
+					["xml:lang"] = "en";
+					["xmpp:version"] = "1.0";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				})
+				:tag("auth", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl", mechanism = "ANONYMOUS" }):up()
+				:tag("iq", { xmlns = "jabber:client", type = "set", id = "bind1" })
+					:tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" })
+						:tag("resource"):text("bosh-test1"):up()
+					:up()
+				:up()
+				);
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				if not response_body:find("<jid>", 1, true) then
+					print("ERR", resp:pretty_print());
+					error("Failed to set up BOSH session");
+				end
+				sid = assert(resp.attr.sid);
+				print("SID", sid);
+			end);
+
+		-- Receive some additional post-login stuff
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "999";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				})
+				)
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				print("RESP 999", resp:pretty_print());
+			end);
+
+		-- Send first long poll
+			print "SEND 1000#1"
+			local wait1000_1 = request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "1000";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}))
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				resp_1000_1 = resp;
+				assert.is_nil(resp.attr.type);
+				print("RESP 1000#1", resp:pretty_print());
+			end, false);
+
+		-- Wait a couple of seconds
+			sleep(2)
+
+		-- Re-send rid 1000, we should eventually get a normal response (with no stanzas)
+			print "SEND 1000#2"
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "1000";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}))
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				resp_1000_2 = resp;
+				assert.is_nil(resp.attr.type);
+				print("RESP 1000#2", resp:pretty_print());
+			end);
+
+			wait1000_1();
+			print "DONE ALL"
+		end
+		run_async(test_bosh);
+		assert.truthy(resp_1000_1);
+		assert.same(resp_1000_1, resp_1000_2);
+	end);
+
+	it("should fail on requests beyond rid window", function ()
+		local function test_bosh()
+			local sid;
+
+		-- Set up BOSH session
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					to = "localhost";
+					from = "test@localhost";
+					content = "text/xml; charset=utf-8";
+					hold = "1";
+					rid = "998";
+					wait = "10";
+					["xml:lang"] = "en";
+					["xmpp:version"] = "1.0";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				})
+				:tag("auth", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl", mechanism = "ANONYMOUS" }):up()
+				:tag("iq", { xmlns = "jabber:client", type = "set", id = "bind1" })
+					:tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" })
+						:tag("resource"):text("bosh-test1"):up()
+					:up()
+				:up()
+				);
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				if not response_body:find("<jid>", 1, true) then
+					print("ERR", resp:pretty_print());
+					error("Failed to set up BOSH session");
+				end
+				sid = assert(resp.attr.sid);
+				print("SID", sid);
+			end);
+
+		-- Receive some additional post-login stuff
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "999";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				})
+				)
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				print("RESP 999", resp:pretty_print());
+			end);
+
+		-- Send poll with a rid that's too high (current + 2, where only current + 1 is allowed)
+			print "SEND 1002(!)"
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "1002";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}))
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				assert.equal("terminate", resp.attr.type);
+				print("RESP 1002(!)", resp:pretty_print());
+			end);
+
+			print "DONE ALL"
+		end
+		run_async(test_bosh);
+	end);
+
+	it("should always succeed for requests within the rid window", function ()
+		local function test()
+			local sid;
+		-- Set up BOSH session
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					to = "localhost";
+					from = "test@localhost";
+					content = "text/xml; charset=utf-8";
+					hold = "1";
+					rid = "1";
+					wait = "10";
+					["xml:lang"] = "en";
+					["xmpp:version"] = "1.0";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}));
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				sid = assert(resp.attr.sid, "Failed to set up BOSH session");
+				print("SID", sid);
+			end);
+			print "DONE 1"
+
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "2";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}):tag("auth", { xmlns = "urn:ietf:params:xml:ns:xmpp-sasl", mechanism = "ANONYMOUS" }):up()
+				)
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				print("RESP 2", resp:pretty_print());
+			end);
+
+			local resp3;
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "3";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}):tag("iq", { xmlns = "jabber:client", type = "set", id = "bind" })
+					:tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" }):up()
+				:up()
+				)
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				print("RESP 3#1", resp:pretty_print());
+				resp3 = resp;
+			end);
+
+
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "4";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}):tag("iq", { xmlns = "jabber:client", type = "get", id = "ping1" })
+					:tag("ping", { xmlns = "urn:xmpp:ping" }):up()
+				:up()
+				)
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				print("RESP 4", resp:pretty_print());
+			end);
+
+			request(bosh_url, {
+				body = tostring(st.stanza("body", {
+					sid = sid;
+					rid = "3";
+					content = "text/xml; charset=utf-8";
+					["xml:lang"] = "en";
+					xmlns = "http://jabber.org/protocol/httpbind";
+					["xmlns:xmpp"] = "urn:xmpp:xbosh";
+				}):tag("iq", { xmlns = "jabber:client", type = "set", id = "bind" })
+					:tag("bind", { xmlns = "urn:ietf:params:xml:ns:xmpp-bind" }):up()
+				:up()
+				)
+			}, function (response_body)
+				local resp = xml.parse(response_body);
+				print("RESP 3#2", resp:pretty_print());
+				assert.not_equal("terminate", resp.attr.type);
+				assert.same(resp3, resp);
+			end);
+
+
+			print "QUIT"
+		end
+		run_async(test);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/muc_util_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,35 @@
+local muc_util;
+
+local st = require "util.stanza";
+
+do
+	local old_pp = package.path;
+	package.path = "./?.lib.lua;"..package.path;
+	muc_util = require "plugins.muc.util";
+	package.path = old_pp;
+end
+
+describe("muc/util", function ()
+	describe("filter_muc_x()", function ()
+		it("correctly filters muc#user", function ()
+			local stanza = st.message({ to = "to", from = "from", id = "foo" })
+				:tag("x", { xmlns = "http://jabber.org/protocol/muc#user" })
+					:tag("invite", { to = "user@example.com" });
+
+			assert.equal(1, #stanza.tags);
+			assert.equal(stanza, muc_util.filter_muc_x(stanza));
+			assert.equal(0, #stanza.tags);
+		end);
+
+		it("correctly filters muc#user on a cloned stanza", function ()
+			local stanza = st.message({ to = "to", from = "from", id = "foo" })
+				:tag("x", { xmlns = "http://jabber.org/protocol/muc#user" })
+					:tag("invite", { to = "user@example.com" });
+
+			assert.equal(1, #stanza.tags);
+			local filtered = muc_util.filter_muc_x(st.clone(stanza));
+			assert.equal(1, #stanza.tags);
+			assert.equal(0, #filtered.tags);
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/net_http_parser_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,52 @@
+local httpstreams = { [[
+GET / HTTP/1.1
+Host: example.com
+
+]], [[
+HTTP/1.1 200 OK
+Content-Length: 0
+
+]], [[
+HTTP/1.1 200 OK
+Content-Length: 7
+
+Hello
+HTTP/1.1 200 OK
+Transfer-Encoding: chunked
+
+1
+H
+1
+e
+2
+ll
+1
+o
+0
+
+
+]]
+}
+
+
+local http_parser = require "net.http.parser";
+
+describe("net.http.parser", function()
+	describe("#new()", function()
+		it("should work", function()
+			for _, stream in ipairs(httpstreams) do
+				local success;
+				local function success_cb(packet)
+					success = true;
+				end
+				stream = stream:gsub("\n", "\r\n");
+				local parser = http_parser.new(success_cb, error, stream:sub(1,4) == "HTTP" and "client" or "server")
+				for chunk in stream:gmatch("..?.?") do
+					parser:feed(chunk);
+				end
+
+				assert.is_true(success);
+			end
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/net_http_server_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,13 @@
+describe("net.http.server", function ()
+	package.loaded["net.server"] = {}
+	local server = require "net.http.server";
+	describe("events", function ()
+		it("should work with util.helpers", function ()
+			-- See #1044
+			server.add_handler("GET host/foo/*", function () end, 0);
+			server.add_handler("GET host/foo/bar", function () end, 0);
+			local helpers = require "util.helpers";
+			assert.is.string(helpers.show_events(server._events));
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/net_websocket_frames_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,56 @@
+describe("net.websocket.frames", function ()
+	local nwf = require "net.websocket.frames";
+
+	local test_frames = {
+		simple_empty = {
+			["opcode"] = 0;
+			["length"] = 0;
+			["data"] = "";
+			["FIN"] = false;
+			["MASK"] = false;
+			["RSV1"] = false;
+			["RSV2"] = false;
+			["RSV3"] = false;
+		};
+		simple_data = {
+			["opcode"] = 0;
+			["length"] = 5;
+			["data"] = "hello";
+			["FIN"] = false;
+			["MASK"] = false;
+			["RSV1"] = false;
+			["RSV2"] = false;
+			["RSV3"] = false;
+		};
+		simple_fin = {
+			["opcode"] = 0;
+			["length"] = 0;
+			["data"] = "";
+			["FIN"] = true;
+			["MASK"] = false;
+			["RSV1"] = false;
+			["RSV2"] = false;
+			["RSV3"] = false;
+		};
+	}
+
+	describe("build", function ()
+		local build = nwf.build;
+		it("works", function ()
+			assert.equal("\0\0", build(test_frames.simple_empty));
+			assert.equal("\0\5hello", build(test_frames.simple_data));
+			assert.equal("\128\0", build(test_frames.simple_fin));
+		end);
+	end);
+
+	describe("parse", function ()
+		local parse = nwf.parse;
+		it("works", function ()
+			assert.same(test_frames.simple_empty, parse("\0\0"));
+			assert.same(test_frames.simple_data, parse("\0\5hello"));
+			assert.same(test_frames.simple_fin, parse("\128\0"));
+		end);
+	end);
+
+end);
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/basic.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,18 @@
+# Basic login and initial presence
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+---------
+
+Romeo connects
+
+Romeo sends:
+	<presence/>
+
+Romeo receives:
+	<presence/>
+
+Romeo disconnects
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/basic_message.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,174 @@
+# Basic message routing and delivery
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: juliet@localhost
+	password: password
+
+[Client] Juliet's phone
+	jid: juliet@localhost
+	password: password
+	resource: mobile
+
+---------
+
+# Act 1, scene 1
+# The clients connect
+
+Romeo connects
+
+Juliet connects
+
+Juliet's phone connects
+
+# Romeo publishes his presence. Juliet has not, and so does not receive presence.
+
+Romeo sends:
+	<presence/>
+
+Romeo receives:
+	<presence from="${Romeo's full JID}" />
+
+# Romeo sends a message to Juliet's full JID
+
+Romeo sends:
+	<message to="${Juliet's full JID}" type="chat">
+		<body>Hello Juliet!</body>
+	</message>
+
+Juliet receives:
+	<message to="${Juliet's full JID}" from="${Romeo's full JID}" type="chat">
+		<body>Hello Juliet!</body>
+	</message>
+
+# Romeo sends a message to Juliet's phone
+
+Romeo sends:
+	<message to="${Juliet's phone's full JID}" type="chat">
+		<body>Hello Juliet, on your phone.</body>
+	</message>
+
+Juliet's phone receives:
+	<message to="${Juliet's phone's full JID}" from="${Romeo's full JID}" type="chat">
+		<body>Hello Juliet, on your phone.</body>
+	</message>
+
+# Scene 2
+# This requires the server to support offline messages (which is optional).
+
+# Romeo sends a message to Juliet's bare JID. This is not immediately delivered, as she
+# has not published presence on either of her resources.
+
+Romeo sends:
+	<message to="juliet@localhost" type="chat">
+		<body>Hello Juliet, are you there?</body>
+	</message>
+
+# Juliet sends presence on her phone, and should receive the message there
+
+Juliet's phone sends:
+	<presence/>
+
+Juliet's phone receives:
+	<presence/>
+
+Juliet's phone receives:
+	<message from="${Romeo's full JID}" type="chat">
+		<body>Hello Juliet, are you there?</body>
+		<delay xmlns='urn:xmpp:delay' from='localhost' stamp='{scansion:any}' />
+	</message>	
+
+# Romeo sends another bare-JID message, it should be delivered
+# instantly to Juliet's phone
+
+Romeo sends:
+	<message to="juliet@localhost" type="chat">
+		<body>Oh, hi!</body>
+	</message>
+
+Juliet's phone receives:
+	<message from="${Romeo's full JID}" type="chat">
+		<body>Oh, hi!</body>
+	</message>	
+
+# Juliet's laptop goes online, but with a negative priority
+
+Juliet sends:
+	<presence>
+		<priority>-1</priority>
+	</presence>
+
+Juliet receives:
+	<presence from="${Juliet's full JID}">
+		<priority>-1</priority>
+	</presence>
+
+Juliet's phone receives:
+	<presence from="${Juliet's full JID}">
+		<priority>-1</priority>
+	</presence>
+
+# Again, Romeo sends a message to her bare JID, but it should
+# only get delivered to her phone:
+
+Romeo sends:
+	<message to="juliet@localhost" type="chat">
+		<body>How are you?</body>
+	</message>
+
+Juliet's phone receives:
+	<message from="${Romeo's full JID}" type="chat">
+		<body>How are you?</body>
+	</message>	
+
+# Romeo sends direct to Juliet's full JID, and she should receive it
+
+Romeo sends:
+	<message to="${Juliet's full JID}" type="chat">
+		<body>Are you hiding?</body>
+	</message>
+
+Juliet receives:
+	<message from="${Romeo's full JID}" type="chat">
+		<body>Are you hiding?</body>
+	</message>
+
+# Juliet publishes non-negative presence
+
+Juliet sends:
+	<presence/>
+
+Juliet receives:
+	<presence from="${Juliet's full JID}"/>
+
+Juliet's phone receives:
+	<presence from="${Juliet's full JID}"/>
+
+# And now Romeo's bare JID messages get delivered to both resources
+# (server behaviour may vary here)
+
+Romeo sends:
+	<message to="juliet@localhost" type="chat">
+		<body>There!</body>
+	</message>
+
+Juliet receives:
+	<message from="${Romeo's full JID}" type="chat">
+		<body>There!</body>
+	</message>
+
+Juliet's phone receives:
+	<message from="${Romeo's full JID}" type="chat">
+		<body>There!</body>
+	</message>
+
+# The End
+
+Romeo disconnects
+
+Juliet disconnects
+
+Juliet's phone disconnects
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/basic_roster.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,73 @@
+# Basic roster test
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: juliet@localhost
+	password: password
+
+---------
+
+Romeo connects
+
+Juliet connects
+
+Romeo sends:
+	<presence/>
+
+Romeo receives:
+	<presence from="${Romeo's full JID}" />
+
+Romeo sends:
+	<iq type="get" id="roster1">
+		<query xmlns='jabber:iq:roster'/>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id="roster1">
+		<query ver='{scansion:any}' xmlns="jabber:iq:roster"/>
+	</iq>
+
+# Add nurse to roster
+
+Romeo sends:
+	<iq type="set" id="roster2">
+		<query xmlns="jabber:iq:roster">
+			<item jid='nurse@localhost'/>
+		</query>
+	</iq>
+
+# Receive the roster add result
+
+Romeo receives:
+	<iq type="result" id="roster2"/>
+
+# Receive the roster push
+
+Romeo receives:
+	<iq type="set" id="{scansion:any}">
+		<query xmlns='jabber:iq:roster' ver='{scansion:any}'>
+			<item jid='nurse@localhost' subscription='none'/>
+		</query>
+	</iq>
+
+Romeo sends:
+	<iq type="result" id="fixme"/>
+
+# Fetch the roster, it should include nurse now
+
+Romeo sends:
+	<iq type="get" id="roster3">
+		<query xmlns='jabber:iq:roster'/>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id="roster3">
+		<query xmlns='jabber:iq:roster' ver="{scansion:any}">
+			<item subscription='none' jid='nurse@localhost'/>
+		</query>
+	</iq>
+
+Romeo disconnects
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/issue1224.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,115 @@
+# MUC: Handle affiliation changes from buggy clients
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result">
+	</iq>
+
+Romeo sends:
+	<iq id='member1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Juliet's JID}" />
+		</query>
+	</iq>
+
+Romeo receives:
+	<message from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's JID}" affiliation='member' xmlns='http://jabber.org/protocol/muc#user'/>
+		</x>
+	</message>
+
+Romeo receives:
+	<iq from='room@conference.localhost' id='member1' type='result'/>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+# Romeo makes Juliet a member of the room, however his client is buggy and only
+# specifies her nickname
+
+Romeo sends:
+	<iq id='member1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' nick='Juliet' />
+		</query>
+	</iq>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='member' role='participant' jid="${Juliet's full JID}">
+				<actor jid="${Romeo's full JID}" nick='Romeo'/>
+			</item>
+		</x>
+	</presence>
+
+Romeo receives:
+	<iq type='result' id='member1' from='room@conference.localhost' />
+
+Juliet receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='member' role='participant' jid="${Juliet's full JID}">
+				<actor nick='Romeo' />
+			</item>
+			<status xmlns='http://jabber.org/protocol/muc#user' code='110'/>
+		</x>
+	</presence>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/issue505.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,79 @@
+# Issue 505: mod_muc doesn’t forward part statuses
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result">
+	</iq>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet sends:
+	<presence type='unavailable' to='room@conference.localhost'>
+		<status>Farewell</status>
+	</presence>
+
+Romeo receives:
+	<presence type='unavailable' from='room@conference.localhost/Juliet'>
+		<status>Farewell</status>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's full JID}" affiliation='none' role='none'/>
+		</x>
+	</presence>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/issue978-multi.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,111 @@
+# Issue 978: MUC does not carry error into occupant leave status (multiple clients)
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+[Client] Juliet's phone
+	jid: user2@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user' scansion:strict='false'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result">
+	</iq>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+# Juliet's phone connects, and joins the room
+Juliet's phone connects
+
+Juliet's phone sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet's phone receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet's phone receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet's phone receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user' scansion:strict='false'>
+			<item affiliation='none' jid="${Juliet's phone's full JID}" role='participant'/>
+			<item affiliation='none' jid="${Juliet's full JID}" role='participant'/>
+		</x>
+	</presence>
+
+# Juliet leaves with an error
+Juliet sends:
+	<presence type='error' to='room@conference.localhost'>
+		<error type='cancel'>
+			<service-unavailable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+			<text xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>Test error</text>
+		</error>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's phone's full JID}" affiliation='none' role='participant'/>
+		</x>
+	</presence>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/issue978.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,85 @@
+# Issue 978: MUC does not carry error into occupant leave status (single client)
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+				<field var='muc#roomconfig_whois'>
+					<value>anyone</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result">
+	</iq>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet sends:
+	<presence type='error' to='room@conference.localhost'>
+		<error type='cancel'>
+			<service-unavailable xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+			<text xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'>Test error</text>
+		</error>
+	</presence>
+
+Romeo receives:
+	<presence type='unavailable' from='room@conference.localhost/Juliet'>
+		<status>Kicked: service unavailable: Test error</status>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='333'/>
+			<item jid="${Juliet's full JID}" affiliation='none' role='none'/>
+		</x>
+	</presence>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/mam_prefs_prep.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,36 @@
+# mod_mam shold apply JIDprep in prefs
+
+[Client] Romeo
+	jid: romeo@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<iq id="lx2" type="set">
+		<prefs xmlns="urn:xmpp:mam:2" default="roster">
+			<always>
+				<jid>JULIET@MONTAGUE.LIT</jid>
+			</always>
+			<never>
+				<jid>MONTAGUE@MONTAGUE.LIT</jid>
+			</never>
+		</prefs>
+	</iq>
+
+Romeo receives:
+	<iq id="lx2" type="result">
+		<prefs xmlns="urn:xmpp:mam:2" default="roster">
+			<always>
+				<jid>juliet@montague.lit</jid>
+			</always>
+			<never>
+				<jid>montague@montague.lit</jid>
+			</never>
+		</prefs>
+	</iq>
+
+Romeo disconnects
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_affiliation_notify.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,137 @@
+# MUC: Notification of affiliation changes of non-occupants
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+[Client] Rosaline
+	jid: user3@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result">
+	</iq>
+
+# Promote Juliet to member
+Romeo sends:
+	<iq id='member1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Juliet's JID}" />
+		</query>
+	</iq>
+
+# Juliet is not in the room, so an affiliation change message is received
+
+Romeo receives:
+	<message from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's JID}" affiliation='member' xmlns='http://jabber.org/protocol/muc#user'/>
+		</x>
+	</message>
+
+# The affiliation change succeeded
+
+Romeo receives:
+	<iq from='room@conference.localhost' id='member1' type='result'/>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+# To check the status of the room is as expected, Romeo requests the member list
+
+Romeo sends:
+	<iq id='member3' to='room@conference.localhost' type='get'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member'/>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq from='room@conference.localhost' type='result' id='member3'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Juliet's JID}" />
+		</query>
+	</iq>
+
+# Romeo grants membership to Rosaline, who is not in the room
+
+Romeo sends:
+	<iq id='member2' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Rosaline's JID}" />
+		</query>
+	</iq>
+
+Romeo receives:
+	<message from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Rosaline's JID}" affiliation='member' xmlns='http://jabber.org/protocol/muc#user'/>
+		</x>
+	</message>
+
+Romeo receives:
+	<iq type='result' id='member2' from='room@conference.localhost' />
+
+Romeo sends:
+	<message type="groupchat" to="room@conference.localhost">
+		<body>Finished!</body>
+	</message>
+
+Juliet receives:
+	<message type="groupchat" from="room@conference.localhost/Romeo">
+		<body>Finished!</body>
+	</message>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_mediated_invite.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,76 @@
+# MUC: Mediated invites
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result">
+	</iq>
+
+# Juliet connects
+Juliet connects
+
+Juliet sends:
+	<presence/>
+
+Juliet receives:
+	<presence/>
+
+
+# Romeo invites Juliet to join the room
+
+Romeo sends:
+	<message to="room@conference.localhost" id="invite1">
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<invite to="${Juliet's JID}" />
+		</x>
+	</message>
+
+Juliet receives:
+	<message from="room@conference.localhost" id="invite1">
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<invite from="room@conference.localhost/Romeo">
+				<reason/>
+			</invite>
+		</x>
+		<body>room@conference.localhost/Romeo invited you to the room room@conference.localhost</body>
+		<x xmlns="jabber:x:conference" jid="room@conference.localhost"/>
+	</message>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_members_only_change.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,114 @@
+# MUC: Members-only rooms kick members who lose affiliation
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form, set the room to members-only
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+				<field var='muc#roomconfig_membersonly'>
+					<value>1</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result">
+	</iq>
+
+# Romeo adds Juliet to the member list
+Romeo sends:
+	<iq id='member1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Juliet's JID}" />
+		</query>
+	</iq>
+
+Romeo receives:
+	<message from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's JID}" affiliation='member' />
+		</x>
+	</message>
+
+Romeo receives:
+	<iq from='room@conference.localhost' id='member1' type='result'/>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+
+# Romeo removes Juliet's membership status
+Romeo sends:
+	<iq id='member2' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='none' jid="${Juliet's JID}" />
+		</query>
+	</iq>
+	
+# As a non-member, Juliet must now be removed from the room
+Romeo receives:
+	<presence type='unavailable' from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='321'/>
+			<item role='none' jid="${Juliet's full JID}" affiliation='none'>
+				<actor nick='Romeo' jid="${Romeo's full JID}"/>
+			</item>
+		</x>
+	</presence>
+
+Romeo receives:
+	<iq id='member2' type='result'/>
+
+Romeo disconnects
+
+Juliet disconnects
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_members_only_deregister.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,122 @@
+# MUC: Members-only rooms kick members who deregister
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form, set the room to members-only
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+				<field var='muc#roomconfig_membersonly'>
+					<value>1</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result">
+	</iq>
+
+# Romeo adds Juliet to the member list
+Romeo sends:
+	<iq id='member1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Juliet's JID}" />
+		</query>
+	</iq>
+
+Romeo receives:
+	<message from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's JID}" affiliation='member' />
+		</x>
+	</message>
+
+Romeo receives:
+	<iq from='room@conference.localhost' id='member1' type='result'/>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+
+# Tired of Romeo's company, Juliet unregisters from the room
+
+Juliet sends:
+	<iq type='set' to='room@conference.localhost' id='unreg1'>
+		<query xmlns='jabber:iq:register'>
+			<remove/>
+		</query>
+	</iq>
+
+Juliet receives:
+	<presence type='unavailable' from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='321'/>
+			<item affiliation='none' role='none' jid="${Juliet's full JID}" />
+			<status code='110'/>
+		  </x>
+	</presence>
+
+Juliet receives:
+	<iq type='result' from='room@conference.localhost' id='unreg1'/>
+
+Romeo receives:
+	<presence type='unavailable' from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='321'/>
+			<item affiliation='none' role='none' jid="${Juliet's full JID}" />
+		  </x>
+	</presence>
+
+
+Romeo disconnects
+
+Juliet disconnects
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_password.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,143 @@
+# MUC: Password-protected rooms
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+				<field var='muc#roomconfig_roomsecret'>
+					<value>cauldronburn</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result">
+	</iq>
+
+# Juliet connects, and tries to join the room (password-protected)
+Juliet connects
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" type="error">
+		<error type="auth" code="401">
+			<not-authorized xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+	</presence>
+
+# Retry with the correct password
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc">
+			<password>cauldronburn</password>
+		</x>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+# Ok, now Juliet leaves, and Romeo unsets the password
+
+Juliet sends:
+	<presence type="unavailable" to="room@conference.localhost"/>
+
+Romeo receives:
+	<presence type="unavailable" from="room@conference.localhost/Juliet"/>
+
+Juliet receives:
+	<presence type="unavailable" from="room@conference.localhost/Juliet"/>
+
+# Remove room password
+Romeo sends:
+	<iq id='config2' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+				<field var='muc#roomconfig_roomsecret'>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+# Config change success
+Romeo receives:
+	<iq id="config2" from="room@conference.localhost" type="result">
+	</iq>
+
+# Notification of room configuration update
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='104'/>
+		</x>
+	</message>
+
+# Juliet tries to join (should succeed)
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+# Notification of Romeo's presence in the room
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+# Room topic
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_register.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,528 @@
+# MUC: Room registration and reserved nicknames
+
+[Client] Romeo
+	jid: user@localhost
+	password: password
+
+[Client] Juliet
+	jid: user2@localhost
+	password: password
+
+[Client] Rosaline
+	jid: user3@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='201'/>
+			<item jid="${Romeo's full JID}" affiliation='owner' role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+# Submit config form
+Romeo sends:
+	<iq id='config1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#owner'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#roomconfig</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq id="config1" from="room@conference.localhost" type="result">
+	</iq>
+
+Romeo sends:
+	<iq id='member1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Juliet's JID}" />
+		</query>
+	</iq>
+
+Romeo receives:
+	<message from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's JID}" affiliation='member' />
+		</x>
+	</message>
+
+Romeo receives:
+	<iq from='room@conference.localhost' id='member1' type='result'/>
+
+# Juliet connects, and joins the room
+Juliet connects
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+# Juliet retrieves the registration form
+
+Juliet sends:
+	<iq id='jw81b36f' to='room@conference.localhost' type='get'>
+		<query xmlns='jabber:iq:register'/>
+	</iq>
+
+Juliet receives:
+	<iq type='result' from='room@conference.localhost' id='jw81b36f'>
+		<query xmlns='jabber:iq:register'>
+			<x type='form' xmlns='jabber:x:data'>
+				<field type='hidden' var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#register</value>
+				</field>
+				<field type='text-single' label='Nickname' var='muc#register_roomnick'/>
+			</x>
+		</query>
+	</iq>
+
+Juliet sends:
+	<iq id='nv71va54' to='room@conference.localhost' type='set'>
+		<query xmlns='jabber:iq:register'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#register</value>
+				</field>
+				<field var='muc#register_roomnick'>
+					<value>Juliet</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Juliet receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='member' jid="${Juliet's full JID}" role='participant'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Juliet receives:
+	<iq type='result' from='room@conference.localhost' id='nv71va54'/>
+
+# Juliet discovers her reserved nick
+
+Juliet sends:
+	<iq id='getnick1' to='room@conference.localhost' type='get'>
+		<query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'/>
+	</iq>
+
+Juliet receives:
+	<iq type='result' from='room@conference.localhost' id='getnick1'>
+		<query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'>
+			<identity category='conference' name='Juliet' type='text'/>
+		</query>
+	</iq>
+
+# Juliet leaves the room:
+
+Juliet sends:
+	<presence type="unavailable" to="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<presence type='unavailable' from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's full JID}" affiliation='member' role='none'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's full JID}" affiliation='member' role='participant'/>
+		</x>
+	</presence>
+
+# Rosaline connect and tries to join the room as Juliet
+
+Rosaline connects
+
+Rosaline sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Rosaline receives:
+	<presence type='error' from='room@conference.localhost/Juliet'>
+		<error type='cancel'>
+			<conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+		<x xmlns='http://jabber.org/protocol/muc'/>
+	</presence>
+
+# In a heated moment, Juliet unregisters from the room
+
+Juliet sends:
+	<iq type='set' to='room@conference.localhost' id='unreg1'>
+		<query xmlns='jabber:iq:register'>
+			<remove/>
+		</query>
+	</iq>
+
+Juliet receives:
+	<iq type='result' from='room@conference.localhost' id='unreg1'/>
+
+# Romeo is notified of Juliet's sad decision
+
+Romeo receives:
+	<message from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user' scansion:strict='true'>
+			<item jid="${Juliet's JID}" />
+		</x>
+	</message>
+
+# Rosaline attempts once more to sneak into the room, disguised as Juliet
+
+Rosaline sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Rosaline receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='owner' role='moderator'/>
+		</x>
+	</presence>
+
+Rosaline receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='none' jid="${Rosaline's full JID}" role='participant'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='none' jid="${Rosaline's full JID}" role='participant'/>
+		</x>
+	</presence>
+
+# On discovering the ruse, Romeo restores Juliet's nick and status within the room
+
+Romeo sends:
+	<iq id='member1' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Juliet's JID}" nick='Juliet' />
+		</query>
+	</iq>
+
+# Rosaline is evicted from the room
+
+Romeo receives:
+	<presence from='room@conference.localhost/Juliet' type='unavailable'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='307'/>
+			<item affiliation='none' role='none' jid="${Rosaline's full JID}">
+				<reason>This nickname is reserved</reason>
+			</item>
+		</x>
+	</presence>
+
+# An out-of-room affiliation change is received for Juliet
+
+Romeo receives:
+	<message from='room@conference.localhost'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Juliet's JID}" affiliation='member' />
+		</x>
+	</message>
+
+Romeo receives:
+	<iq type='result' id='member1' from='room@conference.localhost' />
+
+Rosaline receives:
+	<presence type='unavailable' from='room@conference.localhost/Juliet'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<status code='307'/>
+			<item affiliation='none' jid="${Rosaline's full JID}" role='none'>
+				<reason>This nickname is reserved</reason>
+			</item>
+			<status code='110'/>
+		</x>
+	</presence>
+
+# Rosaline, frustrated, attempts to get back into the room...
+
+Rosaline sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+# ...but once again, is denied
+
+Rosaline receives:
+	<presence type='error' from='room@conference.localhost/Juliet'>
+		<error type='cancel'>
+			<conflict xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+		<x xmlns='http://jabber.org/protocol/muc'/>
+	</presence>
+
+# Juliet, however, quietly joins the room with success
+
+Juliet sends:
+	<presence to="room@conference.localhost/Juliet">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Juliet receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Juliet receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Juliet" />
+
+# Romeo checks whether he has reserved his own nick yet
+
+Romeo sends:
+	<iq id='getnick1' to='room@conference.localhost' type='get'>
+		<query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'/>
+	</iq>
+
+# But no nick is returned, as he hasn't registered yet!
+
+Romeo receives:
+	<iq type='result' from='room@conference.localhost' id='getnick1'>
+		<query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item' scansion:strict='true' />
+	</iq>
+
+# Romeo updates his own registration
+	
+Romeo sends:
+	<iq id='jw81b36f' to='room@conference.localhost' type='get'>
+		<query xmlns='jabber:iq:register'/>
+	</iq>
+
+Romeo receives:
+	<iq type='result' from='room@conference.localhost' id='jw81b36f'>
+		<query xmlns='jabber:iq:register'>
+			<x type='form' xmlns='jabber:x:data'>
+				<field type='hidden' var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#register</value>
+				</field>
+				<field type='text-single' label='Nickname' var='muc#register_roomnick'/>
+			</x>
+		</query>
+	</iq>
+
+Romeo sends:
+	<iq id='nv71va54' to='room@conference.localhost' type='set'>
+		<query xmlns='jabber:iq:register'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#register</value>
+				</field>
+				<field var='muc#register_roomnick'>
+					<value>Romeo</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='owner' jid="${Romeo's full JID}" role='moderator'/>
+			<status code='110'/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<iq type='result' from='room@conference.localhost' id='nv71va54'/>
+
+Juliet receives:
+	<presence from='room@conference.localhost/Romeo'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item role='moderator' xmlns='http://jabber.org/protocol/muc#user' affiliation='owner'/>
+		</x>
+	</presence>
+
+# Romeo discovers his reserved nick
+
+Romeo sends:
+	<iq id='getnick1' to='room@conference.localhost' type='get'>
+		<query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'/>
+	</iq>
+
+Romeo receives:
+	<iq type='result' from='room@conference.localhost' id='getnick1'>
+		<query xmlns='http://jabber.org/protocol/disco#info' node='x-roomuser-item'>
+			<identity category='conference' name='Romeo' type='text'/>
+		</query>
+	</iq>
+
+# To check the status of the room is as expected, Romeo requests the member list
+
+Romeo sends:
+	<iq id='member3' to='room@conference.localhost' type='get'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member'/>
+		</query>
+	</iq>
+
+Romeo receives:
+	<iq from='room@conference.localhost' type='result' id='member3'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item jid="${Juliet's JID}" affiliation='member' nick='Juliet'/>
+		</query>
+	</iq>
+
+Juliet sends:
+	<presence type="unavailable" to="room@conference.localhost/Juliet" />
+
+Juliet receives:
+	<presence from='room@conference.localhost/Juliet' type='unavailable' />
+
+Romeo receives:
+	<presence type='unavailable' from='room@conference.localhost/Juliet' />
+
+# Rosaline joins as herself
+
+Rosaline sends:
+	<presence to="room@conference.localhost/Rosaline">
+		<x xmlns="http://jabber.org/protocol/muc"/>
+	</presence>
+
+Rosaline receives:
+	<presence from="room@conference.localhost/Romeo" />
+
+Rosaline receives:
+	<presence from="room@conference.localhost/Rosaline" />
+
+Rosaline receives:
+	<message type='groupchat' from='room@conference.localhost'><subject/></message>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Rosaline'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item jid="${Rosaline's full JID}" affiliation='none' role='participant'/>
+		</x>
+	</presence>
+
+# Rosaline tries to register her own nickname, but unaffiliated
+# registration is disabled by default
+
+Rosaline sends:
+	<iq id='reg990' to='room@conference.localhost' type='get'>
+		<query xmlns='jabber:iq:register'/>
+	</iq>
+
+Rosaline receives:
+	<iq type='error' from='room@conference.localhost' id='reg990'>
+		<error type='auth'>
+			<registration-required xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+	</iq>
+
+Rosaline sends:
+	<iq id='reg991' to='room@conference.localhost' type='set'>
+		<query xmlns='jabber:iq:register'>
+			<x xmlns='jabber:x:data' type='submit'>
+				<field var='FORM_TYPE'>
+					<value>http://jabber.org/protocol/muc#register</value>
+				</field>
+				<field var='muc#register_roomnick'>
+					<value>Romeo</value>
+				</field>
+			</x>
+		</query>
+	</iq>
+
+Rosaline receives:
+	<iq id='reg991' type='error'>
+		<error type='auth'>
+			<registration-required xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+	</iq>
+
+# Romeo reserves her nickname for her
+
+Romeo sends:
+	<iq id='member2' to='room@conference.localhost' type='set'>
+		<query xmlns='http://jabber.org/protocol/muc#admin'>
+			<item affiliation='member' jid="${Rosaline's JID}" nick='Rosaline' />
+		</query>
+	</iq>
+
+Romeo receives:
+	<presence from='room@conference.localhost/Rosaline'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='member' role='participant' jid="${Rosaline's full JID}">
+				<actor jid="${Romeo's full JID}" nick='Romeo'/>
+			</item>
+		</x>
+	</presence>
+
+Romeo receives:
+	<iq type='result' id='member2' from='room@conference.localhost' />
+
+Rosaline receives:
+	<presence from='room@conference.localhost/Rosaline'>
+		<x xmlns='http://jabber.org/protocol/muc#user'>
+			<item affiliation='member' role='participant' jid="${Rosaline's full JID}">
+				<actor nick='Romeo' />
+			</item>
+			<status xmlns='http://jabber.org/protocol/muc#user' code='110'/>
+		</x>
+	</presence>
+
+# Romeo sets their their own nickname via admin query (see #1273)
+Romeo sends:
+	<iq to="room@conference.localhost" id="reserve" type="set">
+		<query xmlns="http://jabber.org/protocol/muc#admin">
+			<item nick="Romeo" affiliation="owner" jid="${Romeo's JID}"/>
+		</query>
+	</iq>
+
+Romeo receives:
+	<presence from="room@conference.localhost/Romeo">
+		<x xmlns="http://jabber.org/protocol/muc#user">
+			<item xmlns="http://jabber.org/protocol/muc#user" role="moderator" jid="${Romeo's full JID}" affiliation="owner">
+				<actor xmlns="http://jabber.org/protocol/muc#user" nick="Romeo"/>
+			</item>
+			<status xmlns="http://jabber.org/protocol/muc#user" code="110"/>
+		</x>
+	</presence>
+
+Romeo receives:
+	<iq from="room@conference.localhost" id="reserve" type="result"/>
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/muc_whois_anyone_member.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,101 @@
+# MUC: Allow members to fetch the affiliation lists in open non-anonymous rooms
+
+[Client] Romeo
+	jid: romeo@localhost/MsliYo9C
+	password: password
+
+[Client] Juliet
+	jid: juliet@localhost/vJrUtY4Z
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence to='issue1230@conference.localhost/romeo'>
+	<x xmlns='http://jabber.org/protocol/muc'/>
+	</presence>
+
+Romeo receives:
+	<presence from='issue1230@conference.localhost/romeo'>
+	<x xmlns='http://jabber.org/protocol/muc#user'>
+	<status code='201'/>
+	<item jid="${Romeo's JID}" role='moderator' affiliation='owner'/>
+	<status code='110'/>
+	</x>
+	</presence>
+
+Romeo receives:
+	<message from='issue1230@conference.localhost' type='groupchat'>
+	<subject/>
+	</message>
+
+Romeo sends:
+	<iq id='lx3' type='set' to='issue1230@conference.localhost'>
+	<query xmlns='http://jabber.org/protocol/muc#owner'>
+	<x type='submit' xmlns='jabber:x:data'>
+	<field var='FORM_TYPE'>
+	<value>http://jabber.org/protocol/muc#roomconfig</value>
+	</field>
+	<field var='muc#roomconfig_whois'>
+	<value>anyone</value>
+	</field>
+	</x>
+	</query>
+	</iq>
+
+Romeo receives:
+	<iq from='issue1230@conference.localhost' type='result' id='lx3'/>
+
+Romeo receives:
+	<message from='issue1230@conference.localhost' type='groupchat'>
+	<x xmlns='http://jabber.org/protocol/muc#user'>
+	<status code='172'/>
+	</x>
+	</message>
+
+Juliet connects
+
+Juliet sends:
+	<presence to='issue1230@conference.localhost/juliet'>
+	<x xmlns='http://jabber.org/protocol/muc'/>
+	</presence>
+
+Juliet receives:
+	<presence from='issue1230@conference.localhost/romeo'>
+	<x xmlns='http://jabber.org/protocol/muc#user'>
+	<item jid="${Romeo's JID}" role='moderator' affiliation='owner'/>
+	</x>
+	</presence>
+
+Juliet receives:
+	<presence from='issue1230@conference.localhost/juliet'>
+	<x xmlns='http://jabber.org/protocol/muc#user'>
+	<status code='100'/>
+	<item jid="${Juliet's JID}" role='participant' affiliation='none'/>
+	<status code='110'/>
+	</x>
+	</presence>
+
+Juliet receives:
+	<message from='issue1230@conference.localhost' type='groupchat'>
+	<subject/>
+	</message>
+
+Juliet sends:
+	<iq id='lx2' type='get' to='issue1230@conference.localhost'>
+	<query xmlns='http://jabber.org/protocol/muc#admin'>
+	<item affiliation='member'/>
+	</query>
+	</iq>
+
+Juliet receives:
+	<iq from='issue1230@conference.localhost' type='result' id='lx2'>
+	<query xmlns='http://jabber.org/protocol/muc#admin'/>
+	</iq>
+
+Juliet disconnects
+
+Romeo disconnects
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pep_nickname.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,72 @@
+# Publishing a nickname in PEP and receiving a notification
+
+[Client] Romeo
+	jid: romeo@localhost/nJi7BeTR
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<iq id="4" type="set">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
+	    <publish node="http://jabber.org/protocol/nick">
+	      <item id="current">
+	        <nickname xmlns="http://jabber.org/protocol/nick"/>
+	      </item>
+	    </publish>
+	  </pubsub>
+	</iq>
+
+Romeo receives:
+	<iq id="4" to="romeo@localhost/nJi7BeTR" type="result">
+	  <pubsub xmlns="http://jabber.org/protocol/pubsub">
+	    <publish node="http://jabber.org/protocol/nick">
+	      <item id="current"/>
+	    </publish>
+	  </pubsub>
+	</iq>
+
+Romeo sends:
+	<presence>
+	  <c xmlns="http://jabber.org/protocol/caps" hash="sha-1" node="http://code.matthewwild.co.uk/clix/" ver="jC32N+FhQoLrZ7nNQtZK3aqR0Fk="/>
+	</presence>
+
+Romeo receives:
+	<iq id="disco" to="romeo@localhost/nJi7BeTR" from="romeo@localhost" type="get">
+	  <query xmlns="http://jabber.org/protocol/disco#info" node="http://code.matthewwild.co.uk/clix/#jC32N+FhQoLrZ7nNQtZK3aqR0Fk="/>
+	</iq>
+
+Romeo receives:
+	<presence from="romeo@localhost/nJi7BeTR">
+	  <c xmlns="http://jabber.org/protocol/caps" hash="sha-1" node="http://code.matthewwild.co.uk/clix/" ver="jC32N+FhQoLrZ7nNQtZK3aqR0Fk="/>
+	</presence>
+
+Romeo sends:
+	<iq id="disco" type="result" to="romeo@localhost">
+	  <query xmlns="http://jabber.org/protocol/disco#info" node="http://code.matthewwild.co.uk/clix/#jC32N+FhQoLrZ7nNQtZK3aqR0Fk=">
+	    <identity type="console" name="clix" category="client"/>
+	    <feature var="http://jabber.org/protocol/disco#items"/>
+	    <feature var="http://jabber.org/protocol/disco#info"/>
+	    <feature var="http://jabber.org/protocol/caps"/>
+	    <feature var="http://jabber.org/protocol/nick+notify"/>
+	  </query>
+	</iq>
+
+Romeo receives:
+	<message type="headline" from="romeo@localhost" to="romeo@localhost/nJi7BeTR">
+	  <event xmlns="http://jabber.org/protocol/pubsub#event">
+	    <items node="http://jabber.org/protocol/nick">
+	      <item id="current">
+	        <nickname xmlns="http://jabber.org/protocol/nick"/>
+	      </item>
+	    </items>
+	  </event>
+	</message>
+
+Romeo sends:
+	<presence type="unavailable"/>
+
+Romeo disconnects
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pep_publish_subscribe.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,210 @@
+# PEP publish, subscribe and publish-options
+
+[Client] Romeo
+	jid: pep-test-wjebo4kg@localhost
+	password: password
+
+[Client] Juliet
+	jid: pep-test-tqvqu_pv@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<presence>
+		<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/>
+	</presence>
+
+Romeo receives:
+	<iq type='get' id='disco' from="${Romeo's JID}">
+		<query node='http://code.matthewwild.co.uk/verse/#PDH7CGVPRERS2WUqBD18PHGEzaY=' xmlns='http://jabber.org/protocol/disco#info'/>
+	</iq>
+
+Romeo receives:
+	<presence from="${Romeo's full JID}">
+		<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/>
+	</presence>
+
+Romeo sends:
+	<iq type='get' id='6'>
+		<query ver='' xmlns='jabber:iq:roster'/>
+	</iq>
+
+Romeo receives:
+	<iq type='result' id='6'>
+		<query ver='1' xmlns='jabber:iq:roster'/>
+	</iq>
+
+Juliet connects
+
+Juliet sends:
+	<presence>
+		<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/>
+	</presence>
+
+Juliet receives:
+	<iq type='get' id='disco' from="${Juliet's JID}">
+		<query node='http://code.matthewwild.co.uk/verse/#PDH7CGVPRERS2WUqBD18PHGEzaY=' xmlns='http://jabber.org/protocol/disco#info'/>
+	</iq>
+
+Juliet receives:
+	<presence from="${Juliet's full JID}">
+		<c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/>
+	</presence>
+
+Juliet sends:
+	<iq type='get' id='6'>
+		<query ver='' xmlns='jabber:iq:roster'/>
+	</iq>
+
+Juliet receives:
+	<iq type='result' id='6'>
+		<query ver='1' xmlns='jabber:iq:roster'/>
+	</iq>
+
+Romeo sends:
+	<iq type='result' id='disco' to='pep-test-wjebo4kg@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#PDH7CGVPRERS2WUqBD18PHGEzaY='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/></query></iq>
+
+Romeo sends:
+	<presence type='subscribe' to="${Juliet's JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='set' id='{scansion:any}'><query ver='1' xmlns='jabber:iq:roster'><item ask='subscribe' jid='pep-test-tqvqu_pv@localhost' subscription='none'/></query></iq>
+
+Romeo receives:
+	<presence type='unavailable' to='pep-test-wjebo4kg@localhost' from='pep-test-tqvqu_pv@localhost'/>
+
+Juliet receives:
+	<presence type='subscribe' from='pep-test-wjebo4kg@localhost' to='pep-test-tqvqu_pv@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet sends:
+	<iq type='result' id='disco' to='pep-test-tqvqu_pv@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#PDH7CGVPRERS2WUqBD18PHGEzaY='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/></query></iq>
+
+Juliet sends:
+	<presence type='subscribe' to="${Romeo's JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet receives:
+	<iq type='set' id='{scansion:any}'><query ver='2' xmlns='jabber:iq:roster'><item ask='subscribe' jid='pep-test-wjebo4kg@localhost' subscription='none'/></query></iq>
+
+Juliet receives:
+	<presence type='unavailable' to='pep-test-tqvqu_pv@localhost' from='pep-test-wjebo4kg@localhost'/>
+
+Romeo receives:
+	<presence type='subscribe' from='pep-test-tqvqu_pv@localhost' to='pep-test-wjebo4kg@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo sends:
+	<iq type='result' id='fixme'/>
+
+Romeo sends:
+	<presence type='subscribed' to='pep-test-tqvqu_pv@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='set' id='{scansion:any}'><query ver='3' xmlns='jabber:iq:roster'><item ask='subscribe' jid='pep-test-tqvqu_pv@localhost' subscription='from'/></query></iq>
+
+Juliet receives:
+	<presence type='subscribed' from='pep-test-wjebo4kg@localhost' to='pep-test-tqvqu_pv@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet receives:
+	<iq type='set' id='{scansion:any}'><query ver='3' xmlns='jabber:iq:roster'><item jid='pep-test-wjebo4kg@localhost' subscription='to'/></query></iq>
+
+Juliet receives:
+	<presence to='pep-test-tqvqu_pv@localhost' from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/><delay xmlns='urn:xmpp:delay' stamp='{scansion:any}' from='localhost'/></presence>
+
+Juliet sends:
+	<presence type='subscribed' to='pep-test-wjebo4kg@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet receives:
+	<iq type='set' id='{scansion:any}'><query ver='4' xmlns='jabber:iq:roster'><item jid='pep-test-wjebo4kg@localhost' subscription='both'/></query></iq>
+
+Juliet receives:
+	<presence to='pep-test-tqvqu_pv@localhost' from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/><delay xmlns='urn:xmpp:delay' stamp='{scansion:any}' from='localhost'/></presence>
+
+Romeo receives:
+	<presence type='subscribed' from='pep-test-tqvqu_pv@localhost' to='pep-test-wjebo4kg@localhost'><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='set' id='{scansion:any}'><query ver='4' xmlns='jabber:iq:roster'><item jid='pep-test-tqvqu_pv@localhost' subscription='both'/></query></iq>
+
+Romeo receives:
+	<presence to='pep-test-wjebo4kg@localhost' from="${Juliet's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='PDH7CGVPRERS2WUqBD18PHGEzaY=' node='http://code.matthewwild.co.uk/verse/'/><delay xmlns='urn:xmpp:delay' stamp='{scansion:any}' from='localhost'/></presence>
+
+Juliet sends:
+	<iq type='result' id='fixme'/>
+
+Romeo sends:
+	<iq type='result' id='fixme'/>
+
+Romeo sends:
+	<iq type='result' id='fixme'/>
+
+Romeo sends:
+	<presence><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='m/sIsyfzKk8X1okZMtStR43nQQg=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='get' id='disco' from='pep-test-wjebo4kg@localhost'><query node='http://code.matthewwild.co.uk/verse/#m/sIsyfzKk8X1okZMtStR43nQQg=' xmlns='http://jabber.org/protocol/disco#info'/></iq>
+
+Romeo receives:
+	<presence from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='m/sIsyfzKk8X1okZMtStR43nQQg=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='get' id='disco' from='pep-test-tqvqu_pv@localhost'><query node='http://code.matthewwild.co.uk/verse/#m/sIsyfzKk8X1okZMtStR43nQQg=' xmlns='http://jabber.org/protocol/disco#info'/></iq>
+
+Juliet receives:
+	<presence from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='m/sIsyfzKk8X1okZMtStR43nQQg=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo sends:
+	<presence><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='IfQwbaaDB4LEP5tkGArEaB/3Y+s=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='get' id='disco' from='pep-test-wjebo4kg@localhost'><query node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s=' xmlns='http://jabber.org/protocol/disco#info'/></iq>
+
+Romeo receives:
+	<presence from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='IfQwbaaDB4LEP5tkGArEaB/3Y+s=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Romeo receives:
+	<iq type='get' id='disco' from='pep-test-tqvqu_pv@localhost'><query node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s=' xmlns='http://jabber.org/protocol/disco#info'/></iq>
+
+Romeo sends:
+	<iq type='result' id='disco' to='pep-test-wjebo4kg@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#m/sIsyfzKk8X1okZMtStR43nQQg='/></iq>
+
+Romeo sends:
+	<iq type='result' id='disco' to='pep-test-tqvqu_pv@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#m/sIsyfzKk8X1okZMtStR43nQQg='/></iq>
+
+Romeo sends:
+	<iq type='result' id='disco' to='pep-test-wjebo4kg@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/tune+notify'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/><feature var='http://jabber.org/protocol/mood+notify'/></query></iq>
+
+Juliet receives:
+	<presence from="${Romeo's full JID}"><c xmlns='http://jabber.org/protocol/caps' hash='sha-1' ver='IfQwbaaDB4LEP5tkGArEaB/3Y+s=' node='http://code.matthewwild.co.uk/verse/'/></presence>
+
+Juliet sends:
+	<iq type='result' id='fixme'/>
+
+Juliet sends:
+	<iq type='set' id='7'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/tune'><item id='current'><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></publish></pubsub></iq>
+
+Juliet receives:
+	<iq type='result' id='7' ><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/tune'><item id='current'/></publish></pubsub></iq>
+
+Juliet sends:
+	<iq type='set' id='8'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/mood'><item><mood xmlns='http://jabber.org/protocol/mood'><happy/></mood></item></publish><publish-options><x type='submit' xmlns='jabber:x:data'><field type='hidden' var='FORM_TYPE'><value>http://jabber.org/protocol/pubsub#publish-options</value></field><field var='pubsub#persist_items'><value>true</value></field><field var='pubsub#access_model'><value>whitelist</value></field></x></publish-options></pubsub></iq>
+
+Juliet receives:
+	<iq type='result' id='8'><pubsub xmlns='http://jabber.org/protocol/pubsub'><publish node='http://jabber.org/protocol/mood'><item id='{scansion:any}'/></publish></pubsub></iq>
+
+Juliet sends:
+	<iq type='result' id='{scansion:any}'/>
+
+Romeo receives:
+	<message type='headline' from='pep-test-tqvqu_pv@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current'><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
+
+Romeo sends:
+	<iq type='result' id='disco' to='pep-test-tqvqu_pv@localhost'><query xmlns='http://jabber.org/protocol/disco#info' node='http://code.matthewwild.co.uk/verse/#IfQwbaaDB4LEP5tkGArEaB/3Y+s='><identity type='pc' name='Verse' category='client'/><feature var='http://jabber.org/protocol/tune+notify'/><feature var='http://jabber.org/protocol/disco#info'/><feature var='http://jabber.org/protocol/disco#items'/><feature var='http://jabber.org/protocol/caps'/><feature var='http://jabber.org/protocol/mood+notify'/></query></iq>
+
+Romeo receives:
+	<message type='headline' from='pep-test-tqvqu_pv@localhost'><event xmlns='http://jabber.org/protocol/pubsub#event'><items node='http://jabber.org/protocol/tune'><item id='current'><tune xmlns='http://jabber.org/protocol/tune'><title>Beautiful Cedars</title><artist>The Spinners</artist><source>Not Quite Folk</source><track>4</track></tune></item></items></event></message>
+
+Juliet disconnects
+
+Romeo disconnects
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/prosody.cfg.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,83 @@
+--luacheck: ignore
+
+admins = { "admin@localhost" }
+
+use_libevent = true
+
+modules_enabled = {
+	-- Generally required
+		"roster"; -- Allow users to have a roster. Recommended ;)
+		"saslauth"; -- Authentication for clients and servers. Recommended if you want to log in.
+		"tls"; -- Add support for secure TLS on c2s/s2s connections
+		"dialback"; -- s2s dialback support
+		"disco"; -- Service discovery
+
+	-- Not essential, but recommended
+		"carbons"; -- Keep multiple clients in sync
+		"pep"; -- Enables users to publish their mood, activity, playing music and more
+		"private"; -- Private XML storage (for room bookmarks, etc.)
+		"blocklist"; -- Allow users to block communications with other users
+		"vcard"; -- Allow users to set vCards
+
+	-- Nice to have
+		"version"; -- Replies to server version requests
+		"uptime"; -- Report how long server has been running
+		"time"; -- Let others know the time here on this server
+		"ping"; -- Replies to XMPP pings with pongs
+		"register"; -- Allow users to register on this server using a client and change passwords
+		"mam"; -- Store messages in an archive and allow users to access it
+
+	-- HTTP modules
+		--"bosh"; -- Enable BOSH clients, aka "Jabber over HTTP"
+		--"websocket"; -- XMPP over WebSockets
+		--"http_files"; -- Serve static files from a directory over HTTP
+
+	-- Other specific functionality
+		--"limits"; -- Enable bandwidth limiting for XMPP connections
+		--"groups"; -- Shared roster support
+		--"server_contact_info"; -- Publish contact information for this service
+		--"announce"; -- Send announcement to all online users
+		--"welcome"; -- Welcome users who register accounts
+		--"watchregistrations"; -- Alert admins of registrations
+		--"motd"; -- Send a message to users when they log in
+		--"legacyauth"; -- Legacy authentication. Only used by some old clients and bots.
+		--"proxy65"; -- Enables a file transfer proxy service which clients behind NAT can use
+
+	-- Useful for testing
+		--"scansion_record"; -- Records things that happen in scansion test case format
+}
+
+certificate = "certs"
+
+allow_registration = false
+
+c2s_require_encryption = false
+allow_unencrypted_plain_auth = true
+
+authentication = "insecure"
+insecure_open_authentication = "Yes please, I know what I'm doing!"
+
+storage = "memory"
+
+mam_smart_enable = true
+
+-- For the "sql" backend, you can uncomment *one* of the below to configure:
+--sql = { driver = "SQLite3", database = "prosody.sqlite" } -- Default. 'database' is the filename.
+--sql = { driver = "MySQL", database = "prosody", username = "prosody", password = "secret", host = "localhost" }
+--sql = { driver = "PostgreSQL", database = "prosody", username = "prosody", password = "secret", host = "localhost" }
+
+
+-- Logging configuration
+-- For advanced logging see https://prosody.im/doc/logging
+log = "*console"
+
+daemonize = true
+pidfile = "prosody.pid"
+
+VirtualHost "localhost"
+
+Component "conference.localhost" "muc"
+	storage = "memory"
+
+Component "pubsub.localhost" "pubsub"
+	storage = "memory"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pubsub_advanced.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,167 @@
+# Pubsub: Node creation, publish, subscribe, affiliations and delete
+
+[Client] Balthasar
+	jid: admin@localhost
+	password: password
+
+[Client] Romeo
+	jid: romeo@localhost
+	password: password
+
+[Client] Juliet
+	jid: juliet@localhost
+	password: password
+
+---------
+
+Romeo connects
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='create1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<create node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="error" id='create1'>
+		<error type="auth">
+			<forbidden xmlns='urn:ietf:params:xml:ns:xmpp-stanzas'/>
+		</error>
+	</iq>
+
+Balthasar connects
+
+Balthasar sends:
+	<iq type='set' to='pubsub.localhost' id='create2'>
+		<pubsub xmlns='http://jabber.org/protocol/pubsub'>
+			<create node='princely_musings'/>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<iq type="result" id='create2'/>
+
+Balthasar sends:
+	<iq type="set" to="pubsub.localhost" id='create3'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<create node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<iq type="error" id='create3'>
+		<error type="cancel">
+			<conflict xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+		</error>
+	</iq>
+
+Juliet connects
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='sub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscribe node="princely_musings" jid="${Romeo's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="error" id='sub1'/>
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='sub2'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscribe node="princely_musings" jid="${Juliet's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" id='sub2'>
+		<pubsub xmlns='http://jabber.org/protocol/pubsub'>
+			<subscription jid="${Juliet's full JID}" node='princely_musings' subscription='subscribed'/>
+		</pubsub>
+	</iq>
+
+Balthasar sends:
+	<iq type="get" id='aff1' to='pubsub.localhost'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<affiliations node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<iq type="result" id='aff1' from='pubsub.localhost'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<affiliations node="princely_musings">
+				<affiliation affiliation='owner' jid='admin@localhost' xmlns='http://jabber.org/protocol/pubsub#owner'/>
+			</affiliations>
+		</pubsub>
+	</iq>
+
+Balthasar sends:
+	<iq type="set" id='aff2' to='pubsub.localhost'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<affiliations node="princely_musings">
+				<affiliation affiliation='owner' jid='admin@localhost' xmlns='http://jabber.org/protocol/pubsub#owner'/>
+				<affiliation jid="${Romeo's JID}" affiliation="publisher"/>
+			</affiliations>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<iq type="result" id='aff2' from='pubsub.localhost'/>
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='pub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<publish node="princely_musings">
+				<item id="current">
+					<entry xmlns="http://www.w3.org/2005/Atom">
+						<title>Soliloquy</title>
+						<summary>Lorem ipsum dolor sit amet</summary>
+					</entry>
+				</item>
+			</publish>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<message type="headline" from="pubsub.localhost">
+		<event xmlns="http://jabber.org/protocol/pubsub#event">
+			<items node="princely_musings">
+				<item id="current">
+					<entry xmlns="http://www.w3.org/2005/Atom">
+						<title>Soliloquy</title>
+						<summary>Lorem ipsum dolor sit amet</summary>
+					</entry>
+				</item>
+			</items>
+		</event>
+	</message>
+
+Romeo receives:
+	<iq type="result" id='pub1'/>
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='unsub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<unsubscribe node="princely_musings" jid="${Juliet's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" id='unsub1'/>
+
+Balthasar sends:
+	<iq type="set" to="pubsub.localhost" id='del1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<delete node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Balthasar receives:
+	<iq type="result" from='pubsub.localhost' id='del1'/>
+
+Romeo disconnects
+
+// vim: syntax=xml:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pubsub_basic.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,104 @@
+# Pubsub: Basic support
+
+[Client] Romeo
+	jid: admin@localhost
+	password: password
+
+// admin@localhost is assumed to have node creation privileges
+
+[Client] Juliet
+	jid: juliet@localhost
+	password: password
+
+---------
+
+Romeo connects
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='create1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<create node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id='create1'/>
+
+Juliet connects
+
+-- Juliet sends:
+-- 	<iq type="set" to="pubsub.localhost">
+-- 		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+-- 			<subscribe node="princely_musings" jid="${Romeo's full JID}"/>
+-- 		</pubsub>
+-- 	</iq>
+-- 
+-- Juliet receives:
+-- 	<iq type="error"/>
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='sub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<subscribe node="princely_musings" jid="${Juliet's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" id='sub1'/>
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='pub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<publish node="princely_musings">
+				<item id="current">
+					<entry xmlns="http://www.w3.org/2005/Atom">
+						<title>Soliloquy</title>
+						<summary>Lorem ipsum dolor sit amet</summary>
+					</entry>
+				</item>
+			</publish>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id='pub1'/>
+
+Juliet receives:
+	<message type="headline" from="pubsub.localhost">
+		<event xmlns="http://jabber.org/protocol/pubsub#event">
+			<items node="princely_musings">
+				<item id="current">
+					<entry xmlns="http://www.w3.org/2005/Atom">
+						<title>Soliloquy</title>
+						<summary>Lorem ipsum dolor sit amet</summary>
+					</entry>
+				</item>
+			</items>
+		</event>
+	</message>
+
+Juliet sends:
+	<iq type="set" to="pubsub.localhost" id='unsub1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<unsubscribe node="princely_musings" jid="${Juliet's full JID}"/>
+		</pubsub>
+	</iq>
+
+Juliet receives:
+	<iq type="result" id='unsub1'/>
+
+Juliet disconnects
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='del1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<delete node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id='del1'/>
+
+Romeo disconnects
+
+// vim: syntax=xml:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pubsub_config.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,205 @@
+# pubsub#title as name attribute in disco#items
+# Issue 1226
+
+[Client] Romeo
+	password: password
+	jid: jqpcrbq@localhost
+
+-----
+
+Romeo connects
+
+Romeo sends:
+	<iq id="67eb1f47-1e69-4cb3-91e2-4d5943e72d4c" type="set">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<publish node="http://jabber.org/protocol/tune">
+				<item id="current">
+					<tune xmlns="http://jabber.org/protocol/tune"/>
+				</item>
+			</publish>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq id="67eb1f47-1e69-4cb3-91e2-4d5943e72d4c" type="result">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<publish node="http://jabber.org/protocol/tune">
+				<item id="current"/>
+			</publish>
+		</pubsub>
+	</iq>
+
+Romeo sends:
+	<iq id="52d74a36-afb0-4028-87ed-b25b988b049e" type="get">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<configure node="http://jabber.org/protocol/tune"/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq id="52d74a36-afb0-4028-87ed-b25b988b049e" type="result">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<configure node="http://jabber.org/protocol/tune">
+				<x xmlns="jabber:x:data" type="form">
+					<field var="FORM_TYPE" type="hidden">
+						<value>http://jabber.org/protocol/pubsub#node_config</value>
+					</field>
+					<field var="pubsub#title" label="Title" type="text-single"/>
+					<field var="pubsub#description" label="Description" type="text-single"/>
+					<field var="pubsub#type" label="The type of node data, usually specified by the namespace of the payload (if any)" type="text-single"/>
+					<field var="pubsub#max_items" label="Max # of items to persist" type="text-single">
+						<validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="xs:integer"/>
+						<value>1</value>
+					</field>
+					<field var="pubsub#persist_items" label="Persist items to storage" type="boolean">
+						<value>1</value>
+					</field>
+					<field var="pubsub#access_model" label="Specify the subscriber model" type="list-single">
+						<option label="authorize">
+							<value>authorize</value>
+						</option>
+						<option label="open">
+							<value>open</value>
+						</option>
+						<option label="presence">
+							<value>presence</value>
+						</option>
+						<option label="roster">
+							<value>roster</value>
+						</option>
+						<option label="whitelist">
+							<value>whitelist</value>
+						</option>
+						<value>presence</value>
+					</field>
+					<field var="pubsub#publish_model" label="Specify the publisher model" type="list-single">
+						<option label="publishers">
+							<value>publishers</value>
+						</option>
+						<option label="subscribers">
+							<value>subscribers</value>
+						</option>
+						<option label="open">
+							<value>open</value>
+						</option>
+						<value>publishers</value>
+					</field>
+					<field var="pubsub#deliver_notifications" label="Whether to deliver event notifications" type="boolean">
+						<value>1</value>
+					</field>
+					<field var="pubsub#deliver_payloads" label="Whether to deliver payloads with event notifications" type="boolean">
+						<value>1</value>
+					</field>
+					<field var="pubsub#notification_type" label="Specify the delivery style for notifications" type="list-single">
+						<option label="Messages of type normal">
+							<value>normal</value>
+						</option>
+						<option label="Messages of type headline">
+							<value>headline</value>
+						</option>
+						<value>headline</value>
+					</field>
+					<field var="pubsub#notify_delete" label="Whether to notify subscribers when the node is deleted" type="boolean">
+						<value>1</value>
+					</field>
+					<field var="pubsub#notify_retract" label="Whether to notify subscribers when items are removed from the node" type="boolean">
+						<value>1</value>
+					</field>
+				</x>
+			</configure>
+		</pubsub>
+	</iq>
+
+Romeo sends:
+	<iq id="a73aac09-74be-4ee2-97e5-571bbdbcd956" type="set">
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<configure node="http://jabber.org/protocol/tune">
+				<x xmlns="jabber:x:data" type="submit">
+					<field var="FORM_TYPE" type="hidden">
+						<value>http://jabber.org/protocol/pubsub#node_config</value>
+					</field>
+					<field var="pubsub#title" type="text-single" label="Title">
+						<value>Nice tunes</value>
+					</field>
+					<field var="pubsub#description" type="text-single" label="Description"/>
+					<field var="pubsub#type" type="text-single" label="The type of node data, usually specified by the namespace of the payload (if any)"/>
+					<field var="pubsub#max_items" type="text-single" label="Max # of items to persist">
+						<validate xmlns="http://jabber.org/protocol/xdata-validate" datatype="xs:integer"/>
+						<value>1</value>
+					</field>
+					<field var="pubsub#persist_items" type="boolean" label="Persist items to storage">
+						<value>1</value>
+					</field>
+					<field var="pubsub#access_model" type="list-single" label="Specify the subscriber model">
+						<option label="authorize">
+							<value>authorize</value>
+						</option>
+						<option label="open">
+							<value>open</value>
+						</option>
+						<option label="presence">
+							<value>presence</value>
+						</option>
+						<option label="roster">
+							<value>roster</value>
+						</option>
+						<option label="whitelist">
+							<value>whitelist</value>
+						</option>
+						<value>presence</value>
+					</field>
+					<field var="pubsub#publish_model" type="list-single" label="Specify the publisher model">
+						<option label="publishers">
+							<value>publishers</value>
+						</option>
+						<option label="subscribers">
+							<value>subscribers</value>
+						</option>
+						<option label="open">
+							<value>open</value>
+						</option>
+						<value>publishers</value>
+					</field>
+					<field var="pubsub#deliver_notifications" type="boolean" label="Whether to deliver event notifications">
+						<value>1</value>
+					</field>
+					<field var="pubsub#deliver_payloads" type="boolean" label="Whether to deliver payloads with event notifications">
+						<value>1</value>
+					</field>
+					<field var="pubsub#notification_type" type="list-single" label="Specify the delivery style for notifications">
+						<option label="Messages of type normal">
+							<value>normal</value>
+						</option>
+						<option label="Messages of type headline">
+							<value>headline</value>
+						</option>
+						<value>headline</value>
+					</field>
+					<field var="pubsub#notify_delete" type="boolean" label="Whether to notify subscribers when the node is deleted">
+						<value>1</value>
+					</field>
+					<field var="pubsub#notify_retract" type="boolean" label="Whether to notify subscribers when items are removed from the node">
+						<value>1</value>
+					</field>
+				</x>
+			</configure>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq id="a73aac09-74be-4ee2-97e5-571bbdbcd956" type="result"/>
+
+Romeo sends:
+	<iq id="ab0e92d2-c06b-4987-9d45-f9f9e7721709" type="get">
+		<query xmlns="http://jabber.org/protocol/disco#items"/>
+	</iq>
+
+Romeo receives:
+	<iq id="ab0e92d2-c06b-4987-9d45-f9f9e7721709" type="result">
+		<query xmlns="http://jabber.org/protocol/disco#items">
+			<item name="Nice tunes" node="http://jabber.org/protocol/tune" jid="${Romeo's JID}"/>
+		</query>
+	</iq>
+
+Romeo disconnects
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/pubsub_createdelete.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,63 @@
+# Pubsub: Create and delete
+
+[Client] Romeo
+	jid: admin@localhost
+	password: password
+
+// admin@localhost is assumed to have node creation privileges
+
+---------
+
+Romeo connects
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='create1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<create node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id='create1'/>
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='create2'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub">
+			<create node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="error" id='create2'>
+		<error type="cancel">
+			<conflict xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+		</error>
+	</iq>
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='delete1'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<delete node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id='delete1'/>
+
+Romeo sends:
+	<iq type="set" to="pubsub.localhost" id='delete2'>
+		<pubsub xmlns="http://jabber.org/protocol/pubsub#owner">
+			<delete node="princely_musings"/>
+		</pubsub>
+	</iq>
+
+Romeo receives:
+	<iq type="error" id='delete2'>
+		<error type="cancel">
+			<item-not-found xmlns="urn:ietf:params:xml:ns:xmpp-stanzas"/>
+		</error>
+	</iq>
+
+Romeo disconnects
+
+// vim: syntax=xml:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/scansion/vcard_temp.scs	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,80 @@
+# XEP-0054 vCard-temp writable and readable by anyone
+# mod_scansion_record on host 'localhost' recording started 2018-10-20T15:00:12Z
+
+[Client] Romeo
+	jid: romeo@localhost
+	password: password
+
+[Client] Juliet
+	jid: juliet@localhost
+	password: password
+
+-----
+
+Romeo connects
+
+# Romeo sets his vCard
+# FN and N are required by the schema and mod_vcard_legacy will always inject them
+Romeo sends:
+	<iq id="lx3" type="set">
+		<vCard xmlns="vcard-temp">
+			<FN>Romeo Montague</FN>
+			<N>
+				<FAMILY>Montague</FAMILY>
+				<GIVEN>Romeo</GIVEN>
+				<MIDDLE/>
+				<PREFIX/>
+				<SUFFIX/>
+			</N>
+		</vCard>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id="lx3" to="${Romeo's full JID}"/>
+
+Romeo sends:
+	<iq id="lx4" type="get">
+		<vCard xmlns="vcard-temp"/>
+	</iq>
+
+Romeo receives:
+	<iq type="result" id="lx4" to="${Romeo's full JID}">
+		<vCard xmlns="vcard-temp">
+			<FN>Romeo Montague</FN>
+			<N>
+				<FAMILY>Montague</FAMILY>
+				<GIVEN>Romeo</GIVEN>
+				<MIDDLE/>
+				<PREFIX/>
+				<SUFFIX/>
+			</N>
+		</vCard>
+	</iq>
+
+Romeo disconnects
+
+Juliet connects
+
+Juliet sends:
+	<iq type="get" id="lx3" to="romeo@localhost">
+		<vCard xmlns="vcard-temp"/>
+	</iq>
+
+# Juliet can see Romeo's vCard since it's public
+Juliet receives:
+	<iq type="result" from="romeo@localhost" id="lx3" to="${Juliet's full JID}">
+		<vCard xmlns="vcard-temp">
+			<FN>Romeo Montague</FN>
+			<N>
+				<FAMILY>Montague</FAMILY>
+				<GIVEN>Romeo</GIVEN>
+				<MIDDLE/>
+				<PREFIX/>
+				<SUFFIX/>
+			</N>
+		</vCard>
+	</iq>
+
+Juliet disconnects
+
+# recording ended on 2018-10-20T15:02:14Z
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/utf8_sequences.txt	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,52 @@
+Should pass: 41 42 43               # Simple ASCII - abc
+Should pass: 41 42 c3 87            # "ABÇ"
+Should pass: 41 42 e1 b8 88         # "ABḈ"
+Should pass: 41 42 f0 9d 9c 8d      # "AB𝜍"
+Should pass: F4 8F BF BF            # Last valid sequence (U+10FFFF)
+Should fail: F4 90 80 80            # First invalid sequence (U+110000)
+Should fail: 80 81 82 83            # Invalid sequence (invalid start byte)
+Should fail: C2 C3                  # Invalid sequence (invalid continuation byte)
+Should fail: C0 43                  # Overlong sequence
+Should fail: F5 80 80 80            # U+140000 (out of range)
+Should fail: ED A0 80               # U+D800 (forbidden by RFC 3629)
+Should fail: ED BF BF               # U+DFFF (forbidden by RFC 3629)
+Should pass: ED 9F BF               # U+D7FF (U+D800 minus 1: allowed)
+Should pass: EE 80 80               # U+E000 (U+D7FF plus 1: allowed)
+Should fail: C0                     # Invalid start byte
+Should fail: C1                     # Invalid start byte
+Should fail: C2                     # Incomplete sequence
+Should fail: F8 88 80 80 80         # 6-byte sequence
+Should pass: 7F                     # Last valid 1-byte sequence (U+00007F)
+Should pass: DF BF                  # Last valid 2-byte sequence (U+0007FF)
+Should pass: EF BF BF               # Last valid 3-byte sequence (U+00FFFF)
+Should pass: 00                     # First valid 1-byte sequence (U+000000)
+Should pass: C2 80                  # First valid 2-byte sequence (U+000080)
+Should pass: E0 A0 80               # First valid 3-byte sequence (U+000800)
+Should pass: F0 90 80 80            # First valid 4-byte sequence (U+000800)
+Should fail: F8 88 80 80 80         # First 5-byte sequence - invalid per RFC 3629
+Should fail: FC 84 80 80 80 80      # First 6-byte sequence - invalid per RFC 3629
+Should pass: EF BF BD               # U+00FFFD (replacement character)
+Should fail: 80                     # First continuation byte
+Should fail: BF                     # Last continuation byte
+Should fail: 80 BF                  # 2 continuation bytes
+Should fail: 80 BF 80               # 3 continuation bytes
+Should fail: 80 BF 80 BF            # 4 continuation bytes
+Should fail: 80 BF 80 BF 80         # 5 continuation bytes
+Should fail: 80 BF 80 BF 80 BF      # 6 continuation bytes
+Should fail: 80 BF 80 BF 80 BF 80   # 7 continuation bytes
+Should fail: FE                     # Impossible byte
+Should fail: FF                     # Impossible byte
+Should fail: FE FE FF FF            # Impossible bytes
+Should fail: C0 AF                  # Overlong "/"
+Should fail: E0 80 AF               # Overlong "/"
+Should fail: F0 80 80 AF            # Overlong "/"
+Should fail: F8 80 80 80 AF         # Overlong "/"
+Should fail: FC 80 80 80 80 AF      # Overlong "/"
+Should fail: C0 80 AF               # Overlong "/" (invalid)
+Should fail: C1 BF                  # Overlong
+Should fail: E0 9F BF               # Overlong
+Should fail: F0 8F BF BF            # Overlong
+Should fail: F8 87 BF BF BF         # Overlong
+Should fail: FC 83 BF BF BF BF      # Overlong
+Should pass: EF BF BE               # U+FFFE (invalid unicode, valid UTF-8)
+Should pass: EF BF BF               # U+FFFF (invalid unicode, valid UTF-8)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_async_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,616 @@
+local async = require "util.async";
+
+describe("util.async", function()
+	local debug = false;
+	local print = print;
+	if debug then
+		require "util.logger".add_simple_sink(print);
+	else
+		print = function () end
+	end
+
+	local function mock_watchers(event_log)
+		local function generic_logging_watcher(name)
+			return function (...)
+				table.insert(event_log, { name = name, n = select("#", ...)-1, select(2, ...) });
+			end;
+		end;
+		return setmetatable(mock{
+			ready = generic_logging_watcher("ready");
+			waiting = generic_logging_watcher("waiting");
+			error = generic_logging_watcher("error");
+		}, {
+			__index = function (_, event)
+				-- Unexpected watcher called
+				assert(false, "unexpected watcher called: "..event);
+			end;
+		})
+	end
+
+	local function new(func)
+		local event_log = {};
+		local spy_func = spy.new(func);
+		return async.runner(spy_func, mock_watchers(event_log)), spy_func, event_log;
+	end
+	describe("#runner", function()
+		it("should work", function()
+			local r = new(function (item) assert(type(item) == "number") end);
+			r:run(1);
+			r:run(2);
+		end);
+
+		it("should be ready after creation", function ()
+			local r = new(function () end);
+			assert.equal(r.state, "ready");
+		end);
+
+		it("should do nothing if the queue is empty", function ()
+			local did_run;
+			local r = new(function () did_run = true end);
+			r:run();
+			assert.equal(r.state, "ready");
+			assert.is_nil(did_run);
+			r:run("hello");
+			assert.is_true(did_run);
+		end);
+
+		it("should support queuing work items without running", function ()
+			local did_run;
+			local r = new(function () did_run = true end);
+			r:enqueue("hello");
+			assert.equal(r.state, "ready");
+			assert.is_nil(did_run);
+			r:run();
+			assert.is_true(did_run);
+		end);
+
+		it("should support queuing multiple work items", function ()
+			local last_item;
+			local r, s = new(function (item) last_item = item; end);
+			r:enqueue("hello");
+			r:enqueue("there");
+			r:enqueue("world");
+			assert.equal(r.state, "ready");
+			r:run();
+			assert.equal(r.state, "ready");
+			assert.spy(s).was.called(3);
+			assert.equal(last_item, "world");
+		end);
+
+		it("should support all simple data types", function ()
+			local last_item;
+			local r, s = new(function (item) last_item = item; end);
+			local values = { {}, 123, "hello", true, false };
+			for i = 1, #values do
+				r:enqueue(values[i]);
+			end
+			assert.equal(r.state, "ready");
+			r:run();
+			assert.equal(r.state, "ready");
+			assert.spy(s).was.called(#values);
+			for i = 1, #values do
+				assert.spy(s).was.called_with(values[i]);
+			end
+			assert.equal(last_item, values[#values]);
+		end);
+
+		it("should work with no parameters", function ()
+			local item = "fail";
+			local r = async.runner();
+			local f = spy.new(function () item = "success"; end);
+			r:run(f);
+			assert.spy(f).was.called();
+			assert.equal(item, "success");
+		end);
+
+		it("supports a default error handler", function ()
+			local item = "fail";
+			local r = async.runner();
+			local f = spy.new(function () error("test error"); end);
+			assert.error_matches(function ()
+				r:run(f);
+			end, "test error");
+			assert.spy(f).was.called();
+			assert.equal(item, "fail");
+		end);
+
+		describe("#errors", function ()
+			describe("should notify", function ()
+				local last_processed_item, last_error;
+				local r;
+				r = async.runner(function (item)
+					if item == "error" then
+						error({ e = "test error" });
+					end
+					last_processed_item = item;
+				end, mock{
+					ready = function () end;
+					waiting = function () end;
+					error = function (runner, err)
+						assert.equal(r, runner);
+						last_error = err;
+					end;
+				});
+
+				-- Simple item, no error
+				r:run("hello");
+				assert.equal(r.state, "ready");
+				assert.equal(last_processed_item, "hello");
+				assert.spy(r.watchers.ready).was_not.called();
+				assert.spy(r.watchers.error).was_not.called();
+
+				-- Trigger an error inside the runner
+				assert.equal(last_error, nil);
+				r:run("error");
+				test("the correct watcher functions", function ()
+					-- Only the error watcher should have been called
+					assert.spy(r.watchers.ready).was_not.called();
+					assert.spy(r.watchers.waiting).was_not.called();
+					assert.spy(r.watchers.error).was.called(1);
+				end);
+				test("with the correct error", function ()
+					-- The error watcher state should be correct, to
+					-- demonstrate the error was passed correctly
+					assert.is_table(last_error);
+					assert.equal(last_error.e, "test error");
+					last_error = nil;
+				end);
+				assert.equal(r.state, "ready");
+				assert.equal(last_processed_item, "hello");
+			end);
+
+			do
+				local last_processed_item, last_error;
+				local r;
+				local wait, done;
+				r = async.runner(function (item)
+					if item == "error" then
+						error({ e = "test error" });
+					elseif item == "wait" then
+						wait, done = async.waiter();
+						wait();
+						error({ e = "post wait error" });
+					end
+					last_processed_item = item;
+				end, mock({
+					ready = function () end;
+					waiting = function () end;
+					error = function (runner, err)
+						assert.equal(r, runner);
+						last_error = err;
+					end;
+				}));
+
+				randomize(false); --luacheck: ignore 113/randomize
+
+				it("should not be fatal to the runner", function ()
+					r:run("world");
+					assert.equal(r.state, "ready");
+					assert.spy(r.watchers.ready).was_not.called();
+					assert.equal(last_processed_item, "world");
+				end);
+				it("should work despite a #waiter", function ()
+					-- This test covers an important case where a runner
+					-- throws an error while being executed outside of the
+					-- main loop. This happens when it was blocked ('waiting'),
+					-- and then released (via a call to done()).
+					last_error = nil;
+					r:run("wait");
+					assert.equal(r.state, "waiting");
+					assert.spy(r.watchers.waiting).was.called(1);
+					done();
+					-- At this point an error happens (state goes error->ready)
+					assert.equal(r.state, "ready");
+					assert.spy(r.watchers.error).was.called(1);
+					assert.spy(r.watchers.ready).was.called(1);
+					assert.is_table(last_error);
+					assert.equal(last_error.e, "post wait error");
+					last_error = nil;
+					r:run("hello again");
+					assert.spy(r.watchers.ready).was.called(1);
+					assert.spy(r.watchers.waiting).was.called(1);
+					assert.spy(r.watchers.error).was.called(1);
+					assert.equal(r.state, "ready");
+					assert.equal(last_processed_item, "hello again");
+				end);
+			end
+
+			it("should continue to process work items", function ()
+				local last_item;
+				local runner, runner_func = new(function (item)
+					if item == "error" then
+						error("test error");
+					end
+					last_item = item;
+				end);
+				runner:enqueue("one");
+				runner:enqueue("error");
+				runner:enqueue("two");
+				runner:run();
+				assert.equal(runner.state, "ready");
+				assert.spy(runner_func).was.called(3);
+				assert.spy(runner.watchers.error).was.called(1);
+				assert.spy(runner.watchers.ready).was.called(0);
+				assert.spy(runner.watchers.waiting).was.called(0);
+				assert.equal(last_item, "two");
+			end);
+
+			it("should continue to process work items during resume", function ()
+				local wait, done, last_item;
+				local runner, runner_func = new(function (item)
+					if item == "wait-error" then
+						wait, done = async.waiter();
+						wait();
+						error("test error");
+					end
+					last_item = item;
+				end);
+				runner:enqueue("one");
+				runner:enqueue("wait-error");
+				runner:enqueue("two");
+				runner:run();
+				done();
+				assert.equal(runner.state, "ready");
+				assert.spy(runner_func).was.called(3);
+				assert.spy(runner.watchers.error).was.called(1);
+				assert.spy(runner.watchers.waiting).was.called(1);
+				assert.spy(runner.watchers.ready).was.called(1);
+				assert.equal(last_item, "two");
+			end);
+		end);
+	end);
+	describe("#waiter", function()
+		it("should error outside of async context", function ()
+			assert.has_error(function ()
+				async.waiter();
+			end);
+		end);
+		it("should work", function ()
+			local wait, done;
+
+			local r = new(function (item)
+				assert(type(item) == "number")
+				if item == 3 then
+					wait, done = async.waiter();
+					wait();
+				end
+			end);
+
+			r:run(1);
+			assert(r.state == "ready");
+			r:run(2);
+			assert(r.state == "ready");
+			r:run(3);
+			assert(r.state == "waiting");
+			done();
+			assert(r.state == "ready");
+			--for k, v in ipairs(l) do print(k,v) end
+		end);
+
+		it("should work", function ()
+			--------------------
+			local wait, done;
+			local last_item = 0;
+			local r = new(function (item)
+				assert(type(item) == "number")
+				assert(item == last_item + 1);
+				last_item = item;
+				if item == 3 then
+					wait, done = async.waiter();
+					wait();
+				end
+			end);
+
+			r:run(1);
+			assert(r.state == "ready");
+			r:run(2);
+			assert(r.state == "ready");
+			r:run(3);
+			assert(r.state == "waiting");
+			r:run(4);
+			assert(r.state == "waiting");
+			done();
+			assert(r.state == "ready");
+			--for k, v in ipairs(l) do print(k,v) end
+		end);
+		it("should work", function ()
+			--------------------
+			local wait, done;
+			local last_item = 0;
+			local r = new(function (item)
+				assert(type(item) == "number")
+				assert((item == last_item + 1) or item == 3);
+				last_item = item;
+				if item == 3 then
+					wait, done = async.waiter();
+					wait();
+				end
+			end);
+
+			r:run(1);
+			assert(r.state == "ready");
+			r:run(2);
+			assert(r.state == "ready");
+
+			r:run(3);
+			assert(r.state == "waiting");
+			r:run(3);
+			assert(r.state == "waiting");
+			r:run(3);
+			assert(r.state == "waiting");
+			r:run(4);
+			assert(r.state == "waiting");
+
+			for i = 1, 3 do
+				done();
+				if i < 3 then
+					assert(r.state == "waiting");
+				end
+			end
+
+			assert(r.state == "ready");
+			--for k, v in ipairs(l) do print(k,v) end
+		end);
+		it("should work", function ()
+			--------------------
+			local wait, done;
+			local last_item = 0;
+			local r = new(function (item)
+				assert(type(item) == "number")
+				assert((item == last_item + 1) or item == 3);
+				last_item = item;
+				if item == 3 then
+					wait, done = async.waiter();
+					wait();
+				end
+			end);
+
+			r:run(1);
+			assert(r.state == "ready");
+			r:run(2);
+			assert(r.state == "ready");
+
+			r:run(3);
+			assert(r.state == "waiting");
+			r:run(3);
+			assert(r.state == "waiting");
+
+			for i = 1, 2 do
+				done();
+				if i < 2 then
+					assert(r.state == "waiting");
+				end
+			end
+
+			assert(r.state == "ready");
+			r:run(4);
+			assert(r.state == "ready");
+
+			assert(r.state == "ready");
+			--for k, v in ipairs(l) do print(k,v) end
+		end);
+		it("should work with multiple runners in parallel", function ()
+			-- Now with multiple runners
+			--------------------
+			local wait1, done1;
+			local last_item1 = 0;
+			local r1 = new(function (item)
+				assert(type(item) == "number")
+				assert((item == last_item1 + 1) or item == 3);
+				last_item1 = item;
+				if item == 3 then
+					wait1, done1 = async.waiter();
+					wait1();
+				end
+			end, "r1");
+
+			local wait2, done2;
+			local last_item2 = 0;
+			local r2 = new(function (item)
+				assert(type(item) == "number")
+				assert((item == last_item2 + 1) or item == 3);
+				last_item2 = item;
+				if item == 3 then
+					wait2, done2 = async.waiter();
+					wait2();
+				end
+			end, "r2");
+
+			r1:run(1);
+			assert(r1.state == "ready");
+			r1:run(2);
+			assert(r1.state == "ready");
+
+			r1:run(3);
+			assert(r1.state == "waiting");
+			r1:run(3);
+			assert(r1.state == "waiting");
+
+			r2:run(1);
+			assert(r1.state == "waiting");
+			assert(r2.state == "ready");
+
+			r2:run(2);
+			assert(r1.state == "waiting");
+			assert(r2.state == "ready");
+
+			r2:run(3);
+			assert(r1.state == "waiting");
+			assert(r2.state == "waiting");
+			done2();
+
+			r2:run(3);
+			assert(r1.state == "waiting");
+			assert(r2.state == "waiting");
+			done2();
+
+			r2:run(4);
+			assert(r1.state == "waiting");
+			assert(r2.state == "ready");
+
+			for i = 1, 2 do
+				done1();
+				if i < 2 then
+					assert(r1.state == "waiting");
+				end
+			end
+
+			assert(r1.state == "ready");
+			r1:run(4);
+			assert(r1.state == "ready");
+
+			assert(r1.state == "ready");
+			--for k, v in ipairs(l1) do print(k,v) end
+		end);
+		it("should work work with multiple runners in parallel", function ()
+			--------------------
+			local wait1, done1;
+			local last_item1 = 0;
+			local r1 = new(function (item)
+				print("r1 processing ", item);
+				assert(type(item) == "number")
+				assert((item == last_item1 + 1) or item == 3);
+				last_item1 = item;
+				if item == 3 then
+					wait1, done1 = async.waiter();
+					wait1();
+				end
+			end, "r1");
+
+			local wait2, done2;
+			local last_item2 = 0;
+			local r2 = new(function (item)
+				print("r2 processing ", item);
+				assert.is_number(item);
+				assert((item == last_item2 + 1) or item == 3);
+				last_item2 = item;
+				if item == 3 then
+					wait2, done2 = async.waiter();
+					wait2();
+				end
+			end, "r2");
+
+			r1:run(1);
+			assert.equal(r1.state, "ready");
+			r1:run(2);
+			assert.equal(r1.state, "ready");
+
+			r1:run(5);
+			assert.equal(r1.state, "ready");
+
+			r1:run(3);
+			assert.equal(r1.state, "waiting");
+			r1:run(5); -- Will error, when we get to it
+			assert.equal(r1.state, "waiting");
+			done1();
+			assert.equal(r1.state, "ready");
+			r1:run(3);
+			assert.equal(r1.state, "waiting");
+
+			r2:run(1);
+			assert.equal(r1.state, "waiting");
+			assert.equal(r2.state, "ready");
+
+			r2:run(2);
+			assert.equal(r1.state, "waiting");
+			assert.equal(r2.state, "ready");
+
+			r2:run(3);
+			assert.equal(r1.state, "waiting");
+			assert.equal(r2.state, "waiting");
+
+			done2();
+			assert.equal(r1.state, "waiting");
+			assert.equal(r2.state, "ready");
+
+			r2:run(3);
+			assert.equal(r1.state, "waiting");
+			assert.equal(r2.state, "waiting");
+
+			done2();
+			assert.equal(r1.state, "waiting");
+			assert.equal(r2.state, "ready");
+
+			r2:run(4);
+			assert.equal(r1.state, "waiting");
+			assert.equal(r2.state, "ready");
+
+			done1();
+
+			assert.equal(r1.state, "ready");
+			r1:run(4);
+			assert.equal(r1.state, "ready");
+
+			assert.equal(r1.state, "ready");
+		end);
+
+		it("should support multiple done() calls", function ()
+			local processed_item;
+			local wait, done;
+			local r, rf = new(function (item)
+				wait, done = async.waiter(4);
+				wait();
+				processed_item = item;
+			end);
+			r:run("test");
+			for _ = 1, 3 do
+				done();
+				assert.equal(r.state, "waiting");
+				assert.is_nil(processed_item);
+			end
+			done();
+			assert.equal(r.state, "ready");
+			assert.equal(processed_item, "test");
+			assert.spy(r.watchers.error).was_not.called();
+		end);
+
+		it("should not allow done() to be called more than specified", function ()
+			local processed_item;
+			local wait, done;
+			local r, rf = new(function (item)
+				wait, done = async.waiter(4);
+				wait();
+				processed_item = item;
+			end);
+			r:run("test");
+			for _ = 1, 4 do
+				done();
+			end
+			assert.has_error(done);
+			assert.equal(r.state, "ready");
+			assert.equal(processed_item, "test");
+			assert.spy(r.watchers.error).was_not.called();
+		end);
+
+		it("should allow done() to be called before wait()", function ()
+			local processed_item;
+			local r, rf = new(function (item)
+				local wait, done = async.waiter();
+				done();
+				wait();
+				processed_item = item;
+			end);
+			r:run("test");
+			assert.equal(processed_item, "test");
+			assert.equal(r.state, "ready");
+			-- Since the observable state did not change,
+			-- the watchers should not have been called
+			assert.spy(r.watchers.waiting).was_not.called();
+			assert.spy(r.watchers.ready).was_not.called();
+		end);
+	end);
+
+	describe("#ready()", function ()
+		it("should return false outside an async context", function ()
+			assert.falsy(async.ready());
+		end);
+		it("should return true inside an async context", function ()
+			local r = new(function ()
+				assert.truthy(async.ready());
+			end);
+			r:run(true);
+			assert.spy(r.func).was.called();
+			assert.spy(r.watchers.error).was_not.called();
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_cache_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,316 @@
+
+local cache = require "util.cache";
+
+describe("util.cache", function()
+	describe("#new()", function()
+		it("should work", function()
+
+			local c = cache.new(5);
+
+			local function expect_kv(key, value, actual_key, actual_value)
+				assert.are.equal(key, actual_key, "key incorrect");
+				assert.are.equal(value, actual_value, "value incorrect");
+			end
+
+			expect_kv(nil, nil, c:head());
+			expect_kv(nil, nil, c:tail());
+
+			assert.are.equal(c:count(), 0);
+
+			c:set("one", 1)
+			assert.are.equal(c:count(), 1);
+			expect_kv("one", 1, c:head());
+			expect_kv("one", 1, c:tail());
+
+			c:set("two", 2)
+			expect_kv("two", 2, c:head());
+			expect_kv("one", 1, c:tail());
+
+			c:set("three", 3)
+			expect_kv("three", 3, c:head());
+			expect_kv("one", 1, c:tail());
+
+			c:set("four", 4)
+			c:set("five", 5);
+			assert.are.equal(c:count(), 5);
+			expect_kv("five", 5, c:head());
+			expect_kv("one", 1, c:tail());
+
+			c:set("foo", nil);
+			assert.are.equal(c:count(), 5);
+			expect_kv("five", 5, c:head());
+			expect_kv("one", 1, c:tail());
+
+			assert.are.equal(c:get("one"), 1);
+			expect_kv("five", 5, c:head());
+			expect_kv("one", 1, c:tail());
+
+			assert.are.equal(c:get("two"), 2);
+			assert.are.equal(c:get("three"), 3);
+			assert.are.equal(c:get("four"), 4);
+			assert.are.equal(c:get("five"), 5);
+
+			assert.are.equal(c:get("foo"), nil);
+			assert.are.equal(c:get("bar"), nil);
+
+			c:set("six", 6);
+			assert.are.equal(c:count(), 5);
+			expect_kv("six", 6, c:head());
+			expect_kv("two", 2, c:tail());
+
+			assert.are.equal(c:get("one"), nil);
+			assert.are.equal(c:get("two"), 2);
+			assert.are.equal(c:get("three"), 3);
+			assert.are.equal(c:get("four"), 4);
+			assert.are.equal(c:get("five"), 5);
+			assert.are.equal(c:get("six"), 6);
+
+			c:set("three", nil);
+			assert.are.equal(c:count(), 4);
+
+			assert.are.equal(c:get("one"), nil);
+			assert.are.equal(c:get("two"), 2);
+			assert.are.equal(c:get("three"), nil);
+			assert.are.equal(c:get("four"), 4);
+			assert.are.equal(c:get("five"), 5);
+			assert.are.equal(c:get("six"), 6);
+
+			c:set("seven", 7);
+			assert.are.equal(c:count(), 5);
+
+			assert.are.equal(c:get("one"), nil);
+			assert.are.equal(c:get("two"), 2);
+			assert.are.equal(c:get("three"), nil);
+			assert.are.equal(c:get("four"), 4);
+			assert.are.equal(c:get("five"), 5);
+			assert.are.equal(c:get("six"), 6);
+			assert.are.equal(c:get("seven"), 7);
+
+			c:set("eight", 8);
+			assert.are.equal(c:count(), 5);
+
+			assert.are.equal(c:get("one"), nil);
+			assert.are.equal(c:get("two"), nil);
+			assert.are.equal(c:get("three"), nil);
+			assert.are.equal(c:get("four"), 4);
+			assert.are.equal(c:get("five"), 5);
+			assert.are.equal(c:get("six"), 6);
+			assert.are.equal(c:get("seven"), 7);
+			assert.are.equal(c:get("eight"), 8);
+
+			c:set("four", 4);
+			assert.are.equal(c:count(), 5);
+
+			assert.are.equal(c:get("one"), nil);
+			assert.are.equal(c:get("two"), nil);
+			assert.are.equal(c:get("three"), nil);
+			assert.are.equal(c:get("four"), 4);
+			assert.are.equal(c:get("five"), 5);
+			assert.are.equal(c:get("six"), 6);
+			assert.are.equal(c:get("seven"), 7);
+			assert.are.equal(c:get("eight"), 8);
+
+			c:set("nine", 9);
+			assert.are.equal(c:count(), 5);
+
+			assert.are.equal(c:get("one"), nil);
+			assert.are.equal(c:get("two"), nil);
+			assert.are.equal(c:get("three"), nil);
+			assert.are.equal(c:get("four"), 4);
+			assert.are.equal(c:get("five"), nil);
+			assert.are.equal(c:get("six"), 6);
+			assert.are.equal(c:get("seven"), 7);
+			assert.are.equal(c:get("eight"), 8);
+			assert.are.equal(c:get("nine"), 9);
+
+			do
+				local keys = { "nine", "four", "eight", "seven", "six" };
+				local values = { 9, 4, 8, 7, 6 };
+				local i = 0;
+				for k, v in c:items() do
+					i = i + 1;
+					assert.are.equal(k, keys[i]);
+					assert.are.equal(v, values[i]);
+				end
+				assert.are.equal(i, 5);
+
+				c:set("four", "2+2");
+				assert.are.equal(c:count(), 5);
+
+				assert.are.equal(c:get("one"), nil);
+				assert.are.equal(c:get("two"), nil);
+				assert.are.equal(c:get("three"), nil);
+				assert.are.equal(c:get("four"), "2+2");
+				assert.are.equal(c:get("five"), nil);
+				assert.are.equal(c:get("six"), 6);
+				assert.are.equal(c:get("seven"), 7);
+				assert.are.equal(c:get("eight"), 8);
+				assert.are.equal(c:get("nine"), 9);
+			end
+
+			do
+				local keys = { "four", "nine", "eight", "seven", "six" };
+				local values = { "2+2", 9, 8, 7, 6 };
+				local i = 0;
+				for k, v in c:items() do
+					i = i + 1;
+					assert.are.equal(k, keys[i]);
+					assert.are.equal(v, values[i]);
+				end
+				assert.are.equal(i, 5);
+
+				c:set("foo", nil);
+				assert.are.equal(c:count(), 5);
+
+				assert.are.equal(c:get("one"), nil);
+				assert.are.equal(c:get("two"), nil);
+				assert.are.equal(c:get("three"), nil);
+				assert.are.equal(c:get("four"), "2+2");
+				assert.are.equal(c:get("five"), nil);
+				assert.are.equal(c:get("six"), 6);
+				assert.are.equal(c:get("seven"), 7);
+				assert.are.equal(c:get("eight"), 8);
+				assert.are.equal(c:get("nine"), 9);
+			end
+
+			do
+				local keys = { "four", "nine", "eight", "seven", "six" };
+				local values = { "2+2", 9, 8, 7, 6 };
+				local i = 0;
+				for k, v in c:items() do
+					i = i + 1;
+					assert.are.equal(k, keys[i]);
+					assert.are.equal(v, values[i]);
+				end
+				assert.are.equal(i, 5);
+
+				c:set("four", nil);
+
+				assert.are.equal(c:get("one"), nil);
+				assert.are.equal(c:get("two"), nil);
+				assert.are.equal(c:get("three"), nil);
+				assert.are.equal(c:get("four"), nil);
+				assert.are.equal(c:get("five"), nil);
+				assert.are.equal(c:get("six"), 6);
+				assert.are.equal(c:get("seven"), 7);
+				assert.are.equal(c:get("eight"), 8);
+				assert.are.equal(c:get("nine"), 9);
+			end
+
+			do
+				local keys = { "nine", "eight", "seven", "six" };
+				local values = { 9, 8, 7, 6 };
+				local i = 0;
+				for k, v in c:items() do
+					i = i + 1;
+					assert.are.equal(k, keys[i]);
+					assert.are.equal(v, values[i]);
+				end
+				assert.are.equal(i, 4);
+			end
+
+			do
+				local evicted_key, evicted_value;
+				local c2 = cache.new(3, function (_key, _value)
+					evicted_key, evicted_value = _key, _value;
+				end);
+				local function set(k, v, should_evict_key, should_evict_value)
+					evicted_key, evicted_value = nil, nil;
+					c2:set(k, v);
+					assert.are.equal(evicted_key, should_evict_key);
+					assert.are.equal(evicted_value, should_evict_value);
+				end
+				set("a", 1)
+				set("a", 1)
+				set("a", 1)
+				set("a", 1)
+				set("a", 1)
+
+				set("b", 2)
+				set("c", 3)
+				set("b", 2)
+				set("d", 4, "a", 1)
+				set("e", 5, "c", 3)
+			end
+
+			do
+				local evicted_key, evicted_value;
+				local c3 = cache.new(1, function (_key, _value)
+					evicted_key, evicted_value = _key, _value;
+					if _key == "a" then
+						-- Sanity check for what we're evicting
+						assert.are.equal(_key, "a");
+						assert.are.equal(_value, 1);
+						-- We're going to block eviction of this key/value, so set to nil...
+						evicted_key, evicted_value = nil, nil;
+						-- Returning false to block eviction
+						return false
+					end
+				end);
+				local function set(k, v, should_evict_key, should_evict_value)
+					evicted_key, evicted_value = nil, nil;
+					local ret = c3:set(k, v);
+					assert.are.equal(evicted_key, should_evict_key);
+					assert.are.equal(evicted_value, should_evict_value);
+					return ret;
+				end
+				set("a", 1)
+				set("a", 1)
+				set("a", 1)
+				set("a", 1)
+				set("a", 1)
+
+				-- Our on_evict prevents "a" from being evicted, causing this to fail...
+				assert.are.equal(set("b", 2), false, "Failed to prevent eviction, or signal result");
+
+				expect_kv("a", 1, c3:head());
+				expect_kv("a", 1, c3:tail());
+
+				-- Check the final state is what we expect
+				assert.are.equal(c3:get("a"), 1);
+				assert.are.equal(c3:get("b"), nil);
+				assert.are.equal(c3:count(), 1);
+			end
+
+
+			local c4 = cache.new(3, false);
+
+			assert.are.equal(c4:set("a", 1), true);
+			assert.are.equal(c4:set("a", 1), true);
+			assert.are.equal(c4:set("a", 1), true);
+			assert.are.equal(c4:set("a", 1), true);
+			assert.are.equal(c4:set("b", 2), true);
+			assert.are.equal(c4:set("c", 3), true);
+			assert.are.equal(c4:set("d", 4), false);
+			assert.are.equal(c4:set("d", 4), false);
+			assert.are.equal(c4:set("d", 4), false);
+
+			expect_kv("c", 3, c4:head());
+			expect_kv("a", 1, c4:tail());
+
+			local c5 = cache.new(3, function (k, v) --luacheck: ignore 212/v
+				if k == "a" then
+					return nil;
+				elseif k == "b" then
+					return true;
+				end
+				return false;
+			end);
+
+			assert.are.equal(c5:set("a", 1), true);
+			assert.are.equal(c5:set("a", 1), true);
+			assert.are.equal(c5:set("a", 1), true);
+			assert.are.equal(c5:set("a", 1), true);
+			assert.are.equal(c5:set("b", 2), true);
+			assert.are.equal(c5:set("c", 3), true);
+			assert.are.equal(c5:set("d", 4), true); -- "a" evicted (cb returned nil)
+			assert.are.equal(c5:set("d", 4), true); -- nop
+			assert.are.equal(c5:set("d", 4), true); -- nop
+			assert.are.equal(c5:set("e", 5), true); -- "b" evicted (cb returned true)
+			assert.are.equal(c5:set("f", 6), false); -- "c" won't evict (cb returned false)
+
+			expect_kv("e", 5, c5:head());
+			expect_kv("c", 3, c5:tail());
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_dataforms_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,427 @@
+local dataforms = require "util.dataforms";
+local st = require "util.stanza";
+local jid = require "util.jid";
+local iter = require "util.iterators";
+
+describe("util.dataforms", function ()
+	local some_form, xform;
+	setup(function ()
+		some_form = dataforms.new({
+			title = "form-title",
+			instructions = "form-instructions",
+			{
+				type = "hidden",
+				name = "FORM_TYPE",
+				value = "xmpp:prosody.im/spec/util.dataforms#1",
+			};
+			{
+				type = "fixed";
+				value = "Fixed field";
+			},
+			{
+				type = "boolean",
+				label = "boolean-label",
+				name = "boolean-field",
+				value = true,
+			},
+			{
+				type = "fixed",
+				label = "fixed-label",
+				name = "fixed-field",
+				value = "fixed-value",
+			},
+			{
+				type = "hidden",
+				label = "hidden-label",
+				name = "hidden-field",
+				value = "hidden-value",
+			},
+			{
+				type = "jid-multi",
+				label = "jid-multi-label",
+				name = "jid-multi-field",
+				value = {
+					"jid@multi/value#1",
+					"jid@multi/value#2",
+				},
+			},
+			{
+				type = "jid-single",
+				label = "jid-single-label",
+				name = "jid-single-field",
+				value = "jid@single/value",
+			},
+			{
+				type = "list-multi",
+				label = "list-multi-label",
+				name = "list-multi-field",
+				value = {
+					"list-multi-option-value#1",
+					"list-multi-option-value#3",
+				},
+				options = {
+					{
+						label = "list-multi-option-label#1",
+						value = "list-multi-option-value#1",
+						default = true,
+					},
+					{
+						label = "list-multi-option-label#2",
+						value = "list-multi-option-value#2",
+						default = false,
+					},
+					{
+						label = "list-multi-option-label#3",
+						value = "list-multi-option-value#3",
+						default = true,
+					},
+				}
+			},
+			{
+				type = "list-single",
+				label = "list-single-label",
+				name = "list-single-field",
+				value = "list-single-value",
+				options = {
+					"list-single-value",
+					"list-single-value#2",
+					"list-single-value#3",
+				}
+			},
+			{
+				type = "text-multi",
+				label = "text-multi-label",
+				name = "text-multi-field",
+				value = "text\nmulti\nvalue",
+			},
+			{
+				type = "text-private",
+				label = "text-private-label",
+				name = "text-private-field",
+				value = "text-private-value",
+			},
+			{
+				type = "text-single",
+				label = "text-single-label",
+				name = "text-single-field",
+				value = "text-single-value",
+			},
+		});
+		xform = some_form:form();
+	end);
+
+	it("works", function ()
+		assert.truthy(xform);
+		assert.truthy(st.is_stanza(xform));
+		assert.equal("x", xform.name);
+		assert.equal("jabber:x:data", xform.attr.xmlns);
+		assert.equal("FORM_TYPE", xform:find("field@var"));
+		assert.equal("xmpp:prosody.im/spec/util.dataforms#1", xform:find("field/value#"));
+		local allowed_direct_children = {
+			title = true,
+			instructions = true,
+			field = true,
+		}
+		for tag in xform:childtags() do
+			assert.truthy(allowed_direct_children[tag.name], "unknown direct child");
+		end
+	end);
+
+	it("produced boolean field correctly", function ()
+		local f;
+		for field in xform:childtags("field") do
+			if field.attr.var == "boolean-field" then
+				f = field;
+				break;
+			end
+		end
+
+		assert.truthy(st.is_stanza(f));
+		assert.equal("boolean-field", f.attr.var);
+		assert.equal("boolean", f.attr.type);
+		assert.equal("boolean-label", f.attr.label);
+		assert.equal(1, iter.count(f:childtags("value")));
+		local val = f:get_child_text("value");
+		assert.truthy(val == "true" or val == "1");
+	end);
+
+	it("produced fixed field correctly", function ()
+		local f;
+		for field in xform:childtags("field") do
+			if field.attr.var == "fixed-field" then
+				f = field;
+				break;
+			end
+		end
+
+		assert.truthy(st.is_stanza(f));
+		assert.equal("fixed-field", f.attr.var);
+		assert.equal("fixed", f.attr.type);
+		assert.equal("fixed-label", f.attr.label);
+		assert.equal(1, iter.count(f:childtags("value")));
+		assert.equal("fixed-value", f:get_child_text("value"));
+	end);
+
+	it("produced hidden field correctly", function ()
+		local f;
+		for field in xform:childtags("field") do
+			if field.attr.var == "hidden-field" then
+				f = field;
+				break;
+			end
+		end
+
+		assert.truthy(st.is_stanza(f));
+		assert.equal("hidden-field", f.attr.var);
+		assert.equal("hidden", f.attr.type);
+		assert.equal("hidden-label", f.attr.label);
+		assert.equal(1, iter.count(f:childtags("value")));
+		assert.equal("hidden-value", f:get_child_text("value"));
+	end);
+
+	it("produced jid-multi field correctly", function ()
+		local f;
+		for field in xform:childtags("field") do
+			if field.attr.var == "jid-multi-field" then
+				f = field;
+				break;
+			end
+		end
+
+		assert.truthy(st.is_stanza(f));
+		assert.equal("jid-multi-field", f.attr.var);
+		assert.equal("jid-multi", f.attr.type);
+		assert.equal("jid-multi-label", f.attr.label);
+		assert.equal(2, iter.count(f:childtags("value")));
+
+		local i = 0;
+		for value in f:childtags("value") do
+			i = i + 1;
+			assert.equal(("jid@multi/value#%d"):format(i), value:get_text());
+		end
+	end);
+
+	it("produced jid-single field correctly", function ()
+		local f;
+		for field in xform:childtags("field") do
+			if field.attr.var == "jid-single-field" then
+				f = field;
+				break;
+			end
+		end
+
+		assert.truthy(st.is_stanza(f));
+		assert.equal("jid-single-field", f.attr.var);
+		assert.equal("jid-single", f.attr.type);
+		assert.equal("jid-single-label", f.attr.label);
+		assert.equal(1, iter.count(f:childtags("value")));
+		assert.equal("jid@single/value", f:get_child_text("value"));
+		assert.truthy(jid.prep(f:get_child_text("value")));
+	end);
+
+	it("produced list-multi field correctly", function ()
+		local f;
+		for field in xform:childtags("field") do
+			if field.attr.var == "list-multi-field" then
+				f = field;
+				break;
+			end
+		end
+
+		assert.truthy(st.is_stanza(f));
+		assert.equal("list-multi-field", f.attr.var);
+		assert.equal("list-multi", f.attr.type);
+		assert.equal("list-multi-label", f.attr.label);
+		assert.equal(2, iter.count(f:childtags("value")));
+		assert.equal("list-multi-option-value#1", f:get_child_text("value"));
+		assert.equal(3, iter.count(f:childtags("option")));
+	end);
+
+	it("produced list-single field correctly", function ()
+		local f;
+		for field in xform:childtags("field") do
+			if field.attr.var == "list-single-field" then
+				f = field;
+				break;
+			end
+		end
+
+		assert.truthy(st.is_stanza(f));
+		assert.equal("list-single-field", f.attr.var);
+		assert.equal("list-single", f.attr.type);
+		assert.equal("list-single-label", f.attr.label);
+		assert.equal(1, iter.count(f:childtags("value")));
+		assert.equal("list-single-value", f:get_child_text("value"));
+		assert.equal(3, iter.count(f:childtags("option")));
+	end);
+
+	it("produced text-multi field correctly", function ()
+		local f;
+		for field in xform:childtags("field") do
+			if field.attr.var == "text-multi-field" then
+				f = field;
+				break;
+			end
+		end
+
+		assert.truthy(st.is_stanza(f));
+		assert.equal("text-multi-field", f.attr.var);
+		assert.equal("text-multi", f.attr.type);
+		assert.equal("text-multi-label", f.attr.label);
+		assert.equal(3, iter.count(f:childtags("value")));
+	end);
+
+	it("produced text-private field correctly", function ()
+		local f;
+		for field in xform:childtags("field") do
+			if field.attr.var == "text-private-field" then
+				f = field;
+				break;
+			end
+		end
+
+		assert.truthy(st.is_stanza(f));
+		assert.equal("text-private-field", f.attr.var);
+		assert.equal("text-private", f.attr.type);
+		assert.equal("text-private-label", f.attr.label);
+		assert.equal(1, iter.count(f:childtags("value")));
+		assert.equal("text-private-value", f:get_child_text("value"));
+	end);
+
+	it("produced text-single field correctly", function ()
+		local f;
+		for field in xform:childtags("field") do
+			if field.attr.var == "text-single-field" then
+				f = field;
+				break;
+			end
+		end
+
+		assert.truthy(st.is_stanza(f));
+		assert.equal("text-single-field", f.attr.var);
+		assert.equal("text-single", f.attr.type);
+		assert.equal("text-single-label", f.attr.label);
+		assert.equal(1, iter.count(f:childtags("value")));
+		assert.equal("text-single-value", f:get_child_text("value"));
+	end);
+
+	describe("get_type()", function ()
+		it("identifes dataforms", function ()
+			assert.equal(nil, dataforms.get_type(nil));
+			assert.equal(nil, dataforms.get_type(""));
+			assert.equal(nil, dataforms.get_type({}));
+			assert.equal(nil, dataforms.get_type(st.stanza("no-a-form")));
+			assert.equal("xmpp:prosody.im/spec/util.dataforms#1", dataforms.get_type(xform));
+		end);
+	end);
+
+	describe(":data", function ()
+		it("works", function ()
+			assert.truthy(some_form:data(xform));
+		end);
+	end);
+
+	describe("issue1177", function ()
+		local form_with_stuff;
+		setup(function ()
+			form_with_stuff = dataforms.new({
+				{
+					type = "list-single";
+					name = "abtest";
+					label = "A or B?";
+					options = {
+						{ label = "A", value = "a", default = true },
+						{ label = "B", value = "b" },
+					};
+				},
+			});
+		end);
+
+		it("includes options when value is included", function ()
+			local f = form_with_stuff:form({ abtest = "a" });
+			assert.truthy(f:find("field/option"));
+		end);
+
+		it("includes options when value is excluded", function ()
+			local f = form_with_stuff:form({});
+			assert.truthy(f:find("field/option"));
+		end);
+	end);
+
+	describe("using current values in place of missing fields", function ()
+		it("gets back the previous values when given an empty form", function ()
+			local current = {
+				["list-multi-field"] = {
+					"list-multi-option-value#2";
+				};
+				["list-single-field"] = "list-single-value#2";
+				["hidden-field"] = "hidden-value";
+				["boolean-field"] = false;
+				["text-multi-field"] = "words\ngo\nhere";
+				["jid-single-field"] = "alice@example.com";
+				["text-private-field"] = "hunter2";
+				["text-single-field"] = "text-single-value";
+				["jid-multi-field"] = {
+					"bob@example.net";
+				};
+			};
+			local expect = {
+				-- FORM_TYPE = "xmpp:prosody.im/spec/util.dataforms#1"; -- does this need to be included?
+				["list-multi-field"] = {
+					"list-multi-option-value#2";
+				};
+				["list-single-field"] = "list-single-value#2";
+				["hidden-field"] = "hidden-value";
+				["boolean-field"] = false;
+				["text-multi-field"] = "words\ngo\nhere";
+				["jid-single-field"] = "alice@example.com";
+				["text-private-field"] = "hunter2";
+				["text-single-field"] = "text-single-value";
+				["jid-multi-field"] = {
+					"bob@example.net";
+				};
+			};
+			local data, err = some_form:data(st.stanza("x", {xmlns="jabber:x:data"}), current);
+			assert.is.table(data, err);
+			assert.same(expect, data, "got back the same data");
+		end);
+	end);
+
+	describe("field 'var' property", function ()
+		it("works as expected", function ()
+			local f = dataforms.new {
+				{
+					var = "someprefix#the-field",
+					name = "the_field",
+					type = "text-single",
+				}
+			};
+			local x = f:form({the_field = "hello"});
+			assert.equal("someprefix#the-field", x:find"field@var");
+			assert.equal("hello", x:find"field/value#");
+		end);
+	end);
+
+	describe("validation", function ()
+		local f = dataforms.new {
+			{
+				name = "number",
+				type = "text-single",
+				datatype = "xs:integer",
+			},
+		};
+
+		it("works", function ()
+			local d = f:data(f:form({number = 1}));
+			assert.equal(1, d.number);
+		end);
+
+		it("works", function ()
+			local d,e = f:data(f:form({number = "nan"}));
+			assert.not_equal(1, d.number);
+			assert.table(e);
+			assert.string(e.number);
+		end);
+	end);
+end);
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_datetime_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,76 @@
+local util_datetime = require "util.datetime";
+
+describe("util.datetime", function ()
+	it("should have been loaded", function ()
+		assert.is_table(util_datetime);
+	end);
+	describe("#date", function ()
+		local date = util_datetime.date;
+		it("should exist", function ()
+			assert.is_function(date);
+		end);
+		it("should return a string", function ()
+			assert.is_string(date());
+		end);
+		it("should look like a date", function ()
+			assert.truthy(string.find(date(), "^%d%d%d%d%-%d%d%-%d%d$"));
+		end);
+		it("should work", function ()
+			assert.equals(date(1136239445), "2006-01-02");
+		end);
+	end);
+	describe("#time", function ()
+		local time = util_datetime.time;
+		it("should exist", function ()
+			assert.is_function(time);
+		end);
+		it("should return a string", function ()
+			assert.is_string(time());
+		end);
+		it("should look like a timestamp", function ()
+			-- Note: Sub-second precision and timezones are ignored
+			assert.truthy(string.find(time(), "^%d%d:%d%d:%d%d"));
+		end);
+		it("should work", function ()
+			assert.equals(time(1136239445), "22:04:05");
+		end);
+	end);
+	describe("#datetime", function ()
+		local datetime = util_datetime.datetime;
+		it("should exist", function ()
+			assert.is_function(datetime);
+		end);
+		it("should return a string", function ()
+			assert.is_string(datetime());
+		end);
+		it("should look like a timestamp", function ()
+			-- Note: Sub-second precision and timezones are ignored
+			assert.truthy(string.find(datetime(), "^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d:%d%d"));
+		end);
+		it("should work", function ()
+			assert.equals(datetime(1136239445), "2006-01-02T22:04:05Z");
+		end);
+	end);
+	describe("#legacy", function ()
+		local legacy = util_datetime.legacy;
+		it("should exist", function ()
+			assert.is_function(legacy);
+		end);
+	end);
+	describe("#parse", function ()
+		local parse = util_datetime.parse;
+		it("should exist", function ()
+			assert.is_function(parse);
+		end);
+		it("should work", function ()
+			-- Timestamp used by Go
+			assert.equals(parse("2017-11-19T17:58:13Z"),     1511114293);
+			assert.equals(parse("2017-11-19T18:58:50+0100"), 1511114330);
+			assert.equals(parse("2006-01-02T15:04:05-0700"), 1136239445);
+		end);
+		it("should handle timezones", function ()
+			-- https://xmpp.org/extensions/xep-0082.html#example-2 and 3
+			assert.equals(parse("1969-07-21T02:56:15Z"), parse("1969-07-20T21:56:15-05:00"));
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_encodings_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,41 @@
+
+local encodings = require "util.encodings";
+local utf8 = assert(encodings.utf8, "no encodings.utf8 module");
+
+describe("util.encodings", function ()
+	describe("#encode()", function()
+		it("should work", function ()
+			assert.is.equal(encodings.base64.encode(""), "");
+			assert.is.equal(encodings.base64.encode('coucou'), "Y291Y291");
+			assert.is.equal(encodings.base64.encode("\0\0\0"), "AAAA");
+			assert.is.equal(encodings.base64.encode("\255\255\255"), "////");
+		end);
+	end);
+	describe("#decode()", function()
+		it("should work", function ()
+			assert.is.equal(encodings.base64.decode(""), "");
+			assert.is.equal(encodings.base64.decode("="), "");
+			assert.is.equal(encodings.base64.decode('Y291Y291'), "coucou");
+			assert.is.equal(encodings.base64.decode("AAAA"), "\0\0\0");
+			assert.is.equal(encodings.base64.decode("////"), "\255\255\255");
+		end);
+	end);
+end);
+describe("util.encodings.utf8", function()
+	describe("#valid()", function()
+		it("should work", function()
+
+			for line in io.lines("spec/utf8_sequences.txt") do
+				local data = line:match(":%s*([^#]+)"):gsub("%s+", ""):gsub("..", function (c) return string.char(tonumber(c, 16)); end)
+				local expect = line:match("(%S+):");
+
+				assert(expect == "pass" or expect == "fail", "unknown expectation: "..line:match("^[^:]+"));
+
+				local valid = utf8.valid(data);
+				assert.is.equal(valid, utf8.valid(data.." "));
+				assert.is.equal(valid, expect == "pass", line);
+			end
+
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_events_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,212 @@
+local events = require "util.events";
+
+describe("util.events", function ()
+	it("should export a new() function", function ()
+		assert.is_function(events.new);
+	end);
+	describe("new()", function ()
+		it("should return return a new events object", function ()
+			local e = events.new();
+			assert.is_function(e.add_handler);
+			assert.is_function(e.remove_handler);
+		end);
+	end);
+
+	local e, h;
+
+
+	describe("API", function ()
+		before_each(function ()
+			e = events.new();
+			h = spy.new(function () end);
+		end);
+
+		it("should call handlers when an event is fired", function ()
+			e.add_handler("myevent", h);
+			e.fire_event("myevent");
+			assert.spy(h).was_called();
+		end);
+
+		it("should not call handlers when a different event is fired", function ()
+			e.add_handler("myevent", h);
+			e.fire_event("notmyevent");
+			assert.spy(h).was_not_called();
+		end);
+
+		it("should pass the data argument to handlers", function ()
+			e.add_handler("myevent", h);
+			e.fire_event("myevent", "mydata");
+			assert.spy(h).was_called_with("mydata");
+		end);
+
+		it("should support non-string events", function ()
+			local myevent = {};
+			e.add_handler(myevent, h);
+			e.fire_event(myevent, "mydata");
+			assert.spy(h).was_called_with("mydata");
+		end);
+
+		it("should call handlers in priority order", function ()
+			local data = {};
+			e.add_handler("myevent", function () table.insert(data, "h1"); end, 5);
+			e.add_handler("myevent", function () table.insert(data, "h2"); end, 3);
+			e.add_handler("myevent", function () table.insert(data, "h3"); end);
+			e.fire_event("myevent", "mydata");
+			assert.same(data, { "h1", "h2", "h3" });
+		end);
+
+		it("should support non-integer priority values", function ()
+			local data = {};
+			e.add_handler("myevent", function () table.insert(data, "h1"); end, 1);
+			e.add_handler("myevent", function () table.insert(data, "h2"); end, 0.5);
+			e.add_handler("myevent", function () table.insert(data, "h3"); end, 0.25);
+			e.fire_event("myevent", "mydata");
+			assert.same(data, { "h1", "h2", "h3" });
+		end);
+
+		it("should support negative priority values", function ()
+			local data = {};
+			e.add_handler("myevent", function () table.insert(data, "h1"); end, 1);
+			e.add_handler("myevent", function () table.insert(data, "h2"); end, 0);
+			e.add_handler("myevent", function () table.insert(data, "h3"); end, -1);
+			e.fire_event("myevent", "mydata");
+			assert.same(data, { "h1", "h2", "h3" });
+		end);
+
+		it("should support removing handlers", function ()
+			e.add_handler("myevent", h);
+			e.fire_event("myevent");
+			e.remove_handler("myevent", h);
+			e.fire_event("myevent");
+			assert.spy(h).was_called(1);
+		end);
+
+		it("should support adding multiple handlers at the same time", function ()
+			local ht = {
+				myevent1 = spy.new(function () end);
+				myevent2 = spy.new(function () end);
+				myevent3 = spy.new(function () end);
+			};
+			e.add_handlers(ht);
+			e.fire_event("myevent1");
+			e.fire_event("myevent2");
+			assert.spy(ht.myevent1).was_called();
+			assert.spy(ht.myevent2).was_called();
+			assert.spy(ht.myevent3).was_not_called();
+		end);
+
+		it("should support removing multiple handlers at the same time", function ()
+			local ht = {
+				myevent1 = spy.new(function () end);
+				myevent2 = spy.new(function () end);
+				myevent3 = spy.new(function () end);
+			};
+			e.add_handlers(ht);
+			e.remove_handlers(ht);
+			e.fire_event("myevent1");
+			e.fire_event("myevent2");
+			assert.spy(ht.myevent1).was_not_called();
+			assert.spy(ht.myevent2).was_not_called();
+			assert.spy(ht.myevent3).was_not_called();
+		end);
+
+		pending("should support adding handlers within an event handler")
+		pending("should support removing handlers within an event handler")
+
+		it("should support getting the current handlers for an event", function ()
+			e.add_handler("myevent", h);
+			local handlers = e.get_handlers("myevent");
+			assert.equal(h, handlers[1]);
+		end);
+
+		describe("wrappers", function ()
+			local w
+			before_each(function ()
+				w = spy.new(function (handlers, event_name, event_data)
+					assert.is_function(handlers);
+					assert.equal("myevent", event_name)
+					assert.equal("abc", event_data);
+					return handlers(event_name, event_data);
+				end);
+			end);
+
+			it("should get called", function ()
+				e.add_wrapper("myevent", w);
+				e.add_handler("myevent", h);
+				e.fire_event("myevent", "abc");
+				assert.spy(w).was_called(1);
+				assert.spy(h).was_called(1);
+			end);
+
+			it("should be removable", function ()
+				e.add_wrapper("myevent", w);
+				e.add_handler("myevent", h);
+				e.fire_event("myevent", "abc");
+				e.remove_wrapper("myevent", w);
+				e.fire_event("myevent", "abc");
+				assert.spy(w).was_called(1);
+				assert.spy(h).was_called(2);
+			end);
+
+			it("should allow multiple wrappers", function ()
+				local w2 = spy.new(function (handlers, event_name, event_data)
+					return handlers(event_name, event_data);
+				end);
+				e.add_wrapper("myevent", w);
+				e.add_handler("myevent", h);
+				e.add_wrapper("myevent", w2);
+				e.fire_event("myevent", "abc");
+				e.remove_wrapper("myevent", w);
+				e.fire_event("myevent", "abc");
+				assert.spy(w).was_called(1);
+				assert.spy(w2).was_called(2);
+				assert.spy(h).was_called(2);
+			end);
+
+			it("should support a mix of global and event wrappers", function ()
+				local w2 = spy.new(function (handlers, event_name, event_data)
+					return handlers(event_name, event_data);
+				end);
+				e.add_wrapper(false, w);
+				e.add_handler("myevent", h);
+				e.add_wrapper("myevent", w2);
+				e.fire_event("myevent", "abc");
+				e.remove_wrapper(false, w);
+				e.fire_event("myevent", "abc");
+				assert.spy(w).was_called(1);
+				assert.spy(w2).was_called(2);
+				assert.spy(h).was_called(2);
+			end);
+		end);
+
+		describe("global wrappers", function ()
+			local w
+			before_each(function ()
+				w = spy.new(function (handlers, event_name, event_data)
+					assert.is_function(handlers);
+					assert.equal("myevent", event_name)
+					assert.equal("abc", event_data);
+					return handlers(event_name, event_data);
+				end);
+			end);
+
+			it("should get called", function ()
+				e.add_wrapper(false, w);
+				e.add_handler("myevent", h);
+				e.fire_event("myevent", "abc");
+				assert.spy(w).was_called(1);
+				assert.spy(h).was_called(1);
+			end);
+
+			it("should be removable", function ()
+				e.add_wrapper(false, w);
+				e.add_handler("myevent", h);
+				e.fire_event("myevent", "abc");
+				e.remove_wrapper(false, w);
+				e.fire_event("myevent", "abc");
+				assert.spy(w).was_called(1);
+				assert.spy(h).was_called(2);
+			end);
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_format_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,14 @@
+local format = require "util.format".format;
+
+describe("util.format", function()
+	describe("#format()", function()
+		it("should work", function()
+			assert.equal("hello", format("%s", "hello"));
+			assert.equal("<nil>", format("%s"));
+			assert.equal(" [<nil>]", format("", nil));
+			assert.equal("true", format("%s", true));
+			assert.equal("[true]", format("%d", true));
+			assert.equal("% [true]", format("%%", true));
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_http_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,87 @@
+
+local http = require "util.http";
+
+describe("util.http", function()
+	describe("#urlencode()", function()
+		it("should not change normal characters", function()
+			assert.are.equal(http.urlencode("helloworld123"), "helloworld123");
+		end);
+
+		it("should escape spaces", function()
+			assert.are.equal(http.urlencode("hello world"), "hello%20world");
+		end);
+
+		it("should escape important URL characters", function()
+			assert.are.equal(http.urlencode("This & that = something"), "This%20%26%20that%20%3d%20something");
+		end);
+	end);
+
+	describe("#urldecode()", function()
+		it("should not change normal characters", function()
+			assert.are.equal("helloworld123", http.urldecode("helloworld123"), "Normal characters not escaped");
+		end);
+
+		it("should decode spaces", function()
+			assert.are.equal("hello world", http.urldecode("hello%20world"), "Spaces escaped");
+		end);
+
+		it("should decode important URL characters", function()
+			assert.are.equal("This & that = something", http.urldecode("This%20%26%20that%20%3d%20something"), "Important URL chars escaped");
+		end);
+	end);
+
+	describe("#formencode()", function()
+		it("should encode basic data", function()
+			assert.are.equal(http.formencode({ { name = "one", value = "1"}, { name = "two", value = "2" } }), "one=1&two=2", "Form encoded");
+		end);
+
+		it("should encode special characters with escaping", function()
+			assert.are.equal(http.formencode({ { name = "one two", value = "1"}, { name = "two one&", value = "2" } }), "one+two=1&two+one%26=2", "Form encoded");
+		end);
+	end);
+
+	describe("#formdecode()", function()
+		it("should decode basic data", function()
+			local t = http.formdecode("one=1&two=2");
+			assert.are.same(t, {
+				{ name = "one", value = "1" };
+				{ name = "two", value = "2" };
+				one = "1";
+				two = "2";
+			});
+		end);
+
+		it("should decode special characters", function()
+			local t = http.formdecode("one+two=1&two+one%26=2");
+			assert.are.same(t, {
+				{ name = "one two", value = "1" };
+				{ name = "two one&", value = "2" };
+				["one two"] = "1";
+				["two one&"] = "2";
+			});
+		end);
+	end);
+
+	describe("normalize_path", function ()
+		it("root path is always '/'", function ()
+			assert.equal("/", http.normalize_path("/"));
+			assert.equal("/", http.normalize_path(""));
+			assert.equal("/", http.normalize_path("/", true));
+			assert.equal("/", http.normalize_path("", true));
+		end);
+
+		it("works", function ()
+			assert.equal("/foo", http.normalize_path("foo"));
+			assert.equal("/foo", http.normalize_path("/foo"));
+			assert.equal("/foo", http.normalize_path("foo/"));
+			assert.equal("/foo", http.normalize_path("/foo/"));
+		end);
+
+		it("is_dir works", function ()
+			assert.equal("/foo/", http.normalize_path("foo", true));
+			assert.equal("/foo/", http.normalize_path("/foo", true));
+			assert.equal("/foo/", http.normalize_path("foo/", true));
+			assert.equal("/foo/", http.normalize_path("/foo/", true));
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_ip_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,103 @@
+
+local ip = require "util.ip";
+
+local new_ip = ip.new_ip;
+local match = ip.match;
+local parse_cidr = ip.parse_cidr;
+local commonPrefixLength = ip.commonPrefixLength;
+
+describe("util.ip", function()
+	describe("#match()", function()
+		it("should work", function()
+			local _ = new_ip;
+			local ip = _"10.20.30.40";
+			assert.are.equal(match(ip, _"10.0.0.0", 8), true);
+			assert.are.equal(match(ip, _"10.0.0.0", 16), false);
+			assert.are.equal(match(ip, _"10.0.0.0", 24), false);
+			assert.are.equal(match(ip, _"10.0.0.0", 32), false);
+
+			assert.are.equal(match(ip, _"10.20.0.0", 8), true);
+			assert.are.equal(match(ip, _"10.20.0.0", 16), true);
+			assert.are.equal(match(ip, _"10.20.0.0", 24), false);
+			assert.are.equal(match(ip, _"10.20.0.0", 32), false);
+
+			assert.are.equal(match(ip, _"0.0.0.0", 32), false);
+			assert.are.equal(match(ip, _"0.0.0.0", 0), true);
+			assert.are.equal(match(ip, _"0.0.0.0"), false);
+
+			assert.are.equal(match(ip, _"10.0.0.0", 255), false, "excessive number of bits");
+			assert.are.equal(match(ip, _"10.0.0.0", -8), true, "negative number of bits");
+			assert.are.equal(match(ip, _"10.0.0.0", -32), true, "negative number of bits");
+			assert.are.equal(match(ip, _"10.0.0.0", 0), true, "zero bits");
+			assert.are.equal(match(ip, _"10.0.0.0"), false, "no specified number of bits (differing ip)");
+			assert.are.equal(match(ip, _"10.20.30.40"), true, "no specified number of bits (same ip)");
+
+			assert.are.equal(match(_"127.0.0.1", _"127.0.0.1"), true, "simple ip");
+
+			assert.are.equal(match(_"8.8.8.8", _"8.8.0.0", 16), true);
+			assert.are.equal(match(_"8.8.4.4", _"8.8.0.0", 16), true);
+		end);
+	end);
+
+	describe("#parse_cidr()", function()
+		it("should work", function()
+			assert.are.equal(new_ip"0.0.0.0", new_ip"0.0.0.0")
+
+			local function assert_cidr(cidr, ip, bits)
+				local parsed_ip, parsed_bits = parse_cidr(cidr);
+				assert.are.equal(new_ip(ip), parsed_ip, cidr.." parsed ip is "..ip);
+				assert.are.equal(bits, parsed_bits, cidr.." parsed bits is "..tostring(bits));
+			end
+			assert_cidr("0.0.0.0", "0.0.0.0", nil);
+			assert_cidr("127.0.0.1", "127.0.0.1", nil);
+			assert_cidr("127.0.0.1/0", "127.0.0.1", 0);
+			assert_cidr("127.0.0.1/8", "127.0.0.1", 8);
+			assert_cidr("127.0.0.1/32", "127.0.0.1", 32);
+			assert_cidr("127.0.0.1/256", "127.0.0.1", 256);
+			assert_cidr("::/48", "::", 48);
+		end);
+	end);
+
+	describe("#new_ip()", function()
+		it("should work", function()
+			local v4, v6 = "IPv4", "IPv6";
+			local function assert_proto(s, proto)
+				local ip = new_ip(s);
+				if proto then
+					assert.are.equal(ip and ip.proto, proto, "protocol is correct for "..("%q"):format(s));
+				else
+					assert.are.equal(ip, nil, "address is invalid");
+				end
+			end
+			assert_proto("127.0.0.1", v4);
+			assert_proto("::1", v6);
+			assert_proto("", nil);
+			assert_proto("abc", nil);
+			assert_proto("   ", nil);
+		end);
+	end);
+
+	describe("#commonPrefixLength()", function()
+		it("should work", function()
+			local function assert_cpl6(a, b, len, v4)
+				local ipa, ipb = new_ip(a), new_ip(b);
+				if v4 then len = len+96; end
+				assert.are.equal(commonPrefixLength(ipa, ipb), len, "common prefix length of "..a.." and "..b.." is "..len);
+				assert.are.equal(commonPrefixLength(ipb, ipa), len, "common prefix length of "..b.." and "..a.." is "..len);
+			end
+			local function assert_cpl4(a, b, len)
+				return assert_cpl6(a, b, len, "IPv4");
+			end
+			assert_cpl4("0.0.0.0", "0.0.0.0", 32);
+			assert_cpl4("255.255.255.255", "0.0.0.0", 0);
+			assert_cpl4("255.255.255.255", "255.255.0.0", 16);
+			assert_cpl4("255.255.255.255", "255.255.255.255", 32);
+			assert_cpl4("255.255.255.255", "255.255.255.255", 32);
+
+			assert_cpl6("::1", "::1", 128);
+			assert_cpl6("abcd::1", "abcd::1", 128);
+			assert_cpl6("abcd::abcd", "abcd::", 112);
+			assert_cpl6("abcd::abcd", "abcd::abcd:abcd", 96);
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_iterators_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,46 @@
+local iter = require "util.iterators";
+
+describe("util.iterators", function ()
+	describe("join", function ()
+		it("should produce a joined iterator", function ()
+			local expect = { "a", "b", "c", 1, 2, 3 };
+			local output = {};
+			for x in iter.join(iter.values({"a", "b", "c"})):append(iter.values({1, 2, 3})) do
+				table.insert(output, x);
+			end
+			assert.same(output, expect);
+		end);
+	end);
+
+	describe("sorted_pairs", function ()
+		it("should produce sorted pairs", function ()
+			local orig = { b = 1, c = 2, a = "foo", d = false };
+			local n, last_key = 0, nil;
+			for k, v in iter.sorted_pairs(orig) do
+				n = n + 1;
+				if last_key then
+					assert(k > last_key, "Expected "..k.." > "..last_key)
+				end
+				assert.equal(orig[k], v);
+				last_key = k;
+			end
+			assert.equal("d", last_key);
+			assert.equal(4, n);
+		end);
+
+		it("should allow a custom sort function", function ()
+			local orig = { b = 1, c = 2, a = "foo", d = false };
+			local n, last_key = 0, nil;
+			for k, v in iter.sorted_pairs(orig, function (a, b) return a > b end) do
+				n = n + 1;
+				if last_key then
+					assert(k < last_key, "Expected "..k.." > "..last_key)
+				end
+				assert.equal(orig[k], v);
+				last_key = k;
+			end
+			assert.equal("a", last_key);
+			assert.equal(4, n);
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_jid_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,146 @@
+
+local jid = require "util.jid";
+
+describe("util.jid", function()
+	describe("#join()", function()
+		it("should work", function()
+			assert.are.equal(jid.join("a", "b", "c"), "a@b/c", "builds full JID");
+			assert.are.equal(jid.join("a", "b", nil), "a@b", "builds bare JID");
+			assert.are.equal(jid.join(nil, "b", "c"), "b/c", "builds full host JID");
+			assert.are.equal(jid.join(nil, "b", nil), "b", "builds bare host JID");
+			assert.are.equal(jid.join(nil, nil, nil), nil, "invalid JID is nil");
+			assert.are.equal(jid.join("a", nil, nil), nil, "invalid JID is nil");
+			assert.are.equal(jid.join(nil, nil, "c"), nil, "invalid JID is nil");
+			assert.are.equal(jid.join("a", nil, "c"), nil, "invalid JID is nil");
+		end);
+	end);
+	describe("#split()", function()
+		it("should work", function()
+			local function test(input_jid, expected_node, expected_server, expected_resource)
+				local rnode, rserver, rresource = jid.split(input_jid);
+				assert.are.equal(expected_node, rnode, "split("..tostring(input_jid)..") failed");
+				assert.are.equal(expected_server, rserver, "split("..tostring(input_jid)..") failed");
+				assert.are.equal(expected_resource, rresource, "split("..tostring(input_jid)..") failed");
+			end
+
+			-- Valid JIDs
+			test("node@server", 		"node", "server", nil		);
+			test("node@server/resource", 	"node", "server", "resource"        );
+			test("server", 			nil, 	"server", nil               );
+			test("server/resource", 	nil, 	"server", "resource"        );
+			test("server/resource@foo", 	nil, 	"server", "resource@foo"    );
+			test("server/resource@foo/bar",	nil, 	"server", "resource@foo/bar");
+
+			-- Always invalid JIDs
+			test(nil,                nil, nil, nil);
+			test("node@/server",     nil, nil, nil);
+			test("@server",          nil, nil, nil);
+			test("@server/resource", nil, nil, nil);
+			test("@/resource", nil, nil, nil);
+		end);
+	end);
+
+
+	describe("#bare()", function()
+		it("should work", function()
+			assert.are.equal(jid.bare("user@host"), "user@host", "bare JID remains bare");
+			assert.are.equal(jid.bare("host"), "host", "Host JID remains host");
+			assert.are.equal(jid.bare("host/resource"), "host", "Host JID with resource becomes host");
+			assert.are.equal(jid.bare("user@host/resource"), "user@host", "user@host JID with resource becomes user@host");
+			assert.are.equal(jid.bare("user@/resource"), nil, "invalid JID is nil");
+			assert.are.equal(jid.bare("@/resource"), nil, "invalid JID is nil");
+			assert.are.equal(jid.bare("@/"), nil, "invalid JID is nil");
+			assert.are.equal(jid.bare("/"), nil, "invalid JID is nil");
+			assert.are.equal(jid.bare(""), nil, "invalid JID is nil");
+			assert.are.equal(jid.bare("@"), nil, "invalid JID is nil");
+			assert.are.equal(jid.bare("user@"), nil, "invalid JID is nil");
+			assert.are.equal(jid.bare("user@@"), nil, "invalid JID is nil");
+			assert.are.equal(jid.bare("user@@host"), nil, "invalid JID is nil");
+			assert.are.equal(jid.bare("user@@host/resource"), nil, "invalid JID is nil");
+			assert.are.equal(jid.bare("user@host/"), nil, "invalid JID is nil");
+		end);
+	end);
+
+	describe("#compare()", function()
+		it("should work", function()
+			assert.are.equal(jid.compare("host", "host"), true, "host should match");
+			assert.are.equal(jid.compare("host", "other-host"), false, "host should not match");
+			assert.are.equal(jid.compare("other-user@host/resource", "host"), true, "host should match");
+			assert.are.equal(jid.compare("other-user@host", "user@host"), false, "user should not match");
+			assert.are.equal(jid.compare("user@host", "host"), true, "host should match");
+			assert.are.equal(jid.compare("user@host/resource", "host"), true, "host should match");
+			assert.are.equal(jid.compare("user@host/resource", "user@host"), true, "user and host should match");
+			assert.are.equal(jid.compare("user@other-host", "host"), false, "host should not match");
+			assert.are.equal(jid.compare("user@other-host", "user@host"), false, "host should not match");
+		end);
+	end);
+
+	it("should work with nodes", function()
+		local function test(_jid, expected_node)
+			assert.are.equal(jid.node(_jid), expected_node, "Unexpected node for "..tostring(_jid));
+		end
+
+		test("example.com", nil);
+		test("foo.example.com", nil);
+		test("foo.example.com/resource", nil);
+		test("foo.example.com/some resource", nil);
+		test("foo.example.com/some@resource", nil);
+
+		test("foo@foo.example.com/some@resource", "foo");
+		test("foo@example/some@resource", "foo");
+
+		test("foo@example/@resource", "foo");
+		test("foo@example@resource", nil);
+		test("foo@example", "foo");
+		test("foo", nil);
+
+		test(nil, nil);
+	end);
+
+	it("should work with hosts", function()
+		local function test(_jid, expected_host)
+			assert.are.equal(jid.host(_jid), expected_host, "Unexpected host for "..tostring(_jid));
+		end
+
+		test("example.com", "example.com");
+		test("foo.example.com", "foo.example.com");
+		test("foo.example.com/resource", "foo.example.com");
+		test("foo.example.com/some resource", "foo.example.com");
+		test("foo.example.com/some@resource", "foo.example.com");
+
+		test("foo@foo.example.com/some@resource", "foo.example.com");
+		test("foo@example/some@resource", "example");
+
+		test("foo@example/@resource", "example");
+		test("foo@example@resource", nil);
+		test("foo@example", "example");
+		test("foo", "foo");
+
+		test(nil, nil);
+	end);
+
+	it("should work with resources", function()
+		local function test(_jid, expected_resource)
+			assert.are.equal(jid.resource(_jid), expected_resource, "Unexpected resource for "..tostring(_jid));
+		end
+
+		test("example.com", nil);
+		test("foo.example.com", nil);
+		test("foo.example.com/resource", "resource");
+		test("foo.example.com/some resource", "some resource");
+		test("foo.example.com/some@resource", "some@resource");
+
+		test("foo@foo.example.com/some@resource", "some@resource");
+		test("foo@example/some@resource", "some@resource");
+
+		test("foo@example/@resource", "@resource");
+		test("foo@example@resource", nil);
+		test("foo@example", nil);
+		test("foo", nil);
+		test("/foo", nil);
+		test("@x/foo", nil);
+		test("@/foo", nil);
+
+		test(nil, nil);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_json_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,70 @@
+
+local json = require "util.json";
+
+describe("util.json", function()
+	describe("#encode()", function()
+		it("should work", function()
+			local function test(f, j, e)
+				if e then
+					assert.are.equal(f(j), e);
+				end
+				assert.are.equal(f(j), f(json.decode(f(j))));
+			end
+			test(json.encode, json.null, "null")
+			test(json.encode, {}, "{}")
+			test(json.encode, {a=1});
+			test(json.encode, {a={1,2,3}});
+			test(json.encode, {1}, "[1]");
+		end);
+	end);
+
+	describe("#decode()", function()
+		it("should work", function()
+			local empty_array = json.decode("[]");
+			assert.are.equal(type(empty_array), "table");
+			assert.are.equal(#empty_array, 0);
+			assert.are.equal(next(empty_array), nil);
+		end);
+	end);
+
+	describe("testcases", function()
+
+		local valid_data = {};
+		local invalid_data = {};
+
+		local skip = "fail1.json fail9.json fail18.json fail15.json fail13.json fail25.json fail26.json fail27.json fail28.json fail17.json pass1.json";
+
+		setup(function()
+			local lfs = require "lfs";
+			local path = "spec/json";
+			for name in lfs.dir(path) do
+				if name:match("%.json$") then
+					local f = assert(io.open(path.."/"..name));
+					local content = assert(f:read("*a"));
+					assert(f:close());
+					if skip:find(name) then --luacheck: ignore 542
+						-- Skip
+					elseif name:match("^pass") then
+						valid_data[name] = content;
+					elseif name:match("^fail") then
+						invalid_data[name] = content;
+					end
+				end
+			end
+		end)
+
+		it("should pass valid testcases", function()
+			for name, content in pairs(valid_data) do
+				local parsed, err = json.decode(content);
+				assert(parsed, name..": "..tostring(err));
+			end
+		end);
+
+		it("should fail invalid testcases", function()
+			for name, content in pairs(invalid_data) do
+				local parsed, err = json.decode(content);
+				assert(not parsed, name..": "..tostring(err));
+			end
+		end);
+	end)
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_multitable_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,60 @@
+
+local multitable = require "util.multitable";
+
+describe("util.multitable", function()
+	describe("#new()", function()
+		it("should create a multitable", function()
+			local mt = multitable.new();
+			assert.is_table(mt, "Multitable is a table");
+			assert.is_function(mt.add, "Multitable has method add");
+			assert.is_function(mt.get, "Multitable has method get");
+			assert.is_function(mt.remove, "Multitable has method remove");
+		end);
+	end);
+
+	describe("#get()", function()
+		it("should allow getting correctly", function()
+			local function has_items(list, ...)
+				local should_have = {};
+				if select('#', ...) > 0 then
+					assert.is_table(list, "has_items: list is table", 3);
+				else
+					assert.is.falsy(list and #list > 0, "No items, and no list");
+					return true, "has-all";
+				end
+				for n=1,select('#', ...) do should_have[select(n, ...)] = true; end
+				for _, item in ipairs(list) do
+					if not should_have[item] then return false, "too-many"; end
+					should_have[item] = nil;
+				end
+				if next(should_have) then
+					return false, "not-enough";
+				end
+				return true, "has-all";
+			end
+			local function assert_has_all(message, list, ...)
+				return assert.are.equal(select(2, has_items(list, ...)), "has-all", message or "List has all expected items, and no more", 2);
+			end
+
+			local mt = multitable.new();
+
+			local trigger1, trigger2, trigger3 = {}, {}, {};
+			local item1, item2, item3 = {}, {}, {};
+
+			assert_has_all("Has no items with trigger1", mt:get(trigger1));
+
+
+			mt:add(1, 2, 3, item1);
+
+			assert_has_all("Has item1 for 1, 2, 3", mt:get(1, 2, 3), item1);
+		end);
+	end);
+
+	-- Doesn't support nil
+	--[[	mt:add(nil, item1);
+		mt:add(nil, item2);
+		mt:add(nil, item3);
+
+		assert_has_all("Has all items with (nil)", mt:get(nil), item1, item2, item3);
+	]]
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_poll_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,6 @@
+describe("util.poll", function ()
+	it("loads", function ()
+		require "util.poll"
+	end);
+end);
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_promise_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,497 @@
+local promise = require "util.promise";
+
+describe("util.promise", function ()
+	--luacheck: ignore 212/resolve 212/reject
+	describe("new()", function ()
+		it("returns a promise object", function ()
+			assert(promise.new());
+		end);
+	end);
+	it("notifies immediately for fulfilled promises", function ()
+		local p = promise.new(function (resolve)
+			resolve("foo");
+		end);
+		local cb = spy.new(function (v)
+			assert.equal("foo", v);
+		end);
+		p:next(cb);
+		assert.spy(cb).was_called(1);
+	end);
+	it("notifies on fulfilment of pending promises", function ()
+		local r;
+		local p = promise.new(function (resolve)
+			r = resolve;
+		end);
+		local cb = spy.new(function (v)
+			assert.equal("foo", v);
+		end);
+		p:next(cb);
+		assert.spy(cb).was_called(0);
+		r("foo");
+		assert.spy(cb).was_called(1);
+	end);
+	it("allows chaining :next() calls", function ()
+		local r;
+		local result;
+		local p = promise.new(function (resolve)
+			r = resolve;
+		end);
+		local cb1 = spy.new(function (v)
+			assert.equal("foo", v);
+			return "bar";
+		end);
+		local cb2 = spy.new(function (v)
+			assert.equal("bar", v);
+			result = v;
+		end);
+		p:next(cb1):next(cb2);
+		assert.spy(cb1).was_called(0);
+		assert.spy(cb2).was_called(0);
+		r("foo");
+		assert.spy(cb1).was_called(1);
+		assert.spy(cb2).was_called(1);
+		assert.equal("bar", result);
+	end);
+	it("supports multiple :next() calls on the same promise", function ()
+		local r;
+		local result;
+		local p = promise.new(function (resolve)
+			r = resolve;
+		end);
+		local cb1 = spy.new(function (v)
+			assert.equal("foo", v);
+			result = v;
+		end);
+		local cb2 = spy.new(function (v)
+			assert.equal("foo", v);
+			result = v;
+		end);
+		p:next(cb1);
+		p:next(cb2);
+		assert.spy(cb1).was_called(0);
+		assert.spy(cb2).was_called(0);
+		r("foo");
+		assert.spy(cb1).was_called(1);
+		assert.spy(cb2).was_called(1);
+		assert.equal("foo", result);
+	end);
+	it("automatically rejects on error", function ()
+		local r;
+		local p = promise.new(function (resolve)
+			r = resolve;
+			error("oh no");
+		end);
+		local cb = spy.new(function () end);
+		local err_cb = spy.new(function (v)
+			assert.equal("oh no", v);
+		end);
+		p:next(cb, err_cb);
+		assert.spy(cb).was_called(0);
+		assert.spy(err_cb).was_called(1);
+		r("foo");
+		assert.spy(cb).was_called(0);
+		assert.spy(err_cb).was_called(1);
+	end);
+	it("supports reject()", function ()
+		local r, result;
+		local p = promise.new(function (resolve, reject)
+			r = reject;
+		end);
+		local cb = spy.new(function () end);
+		local err_cb = spy.new(function (v)
+			result = v;
+			assert.equal("oh doh", v);
+		end);
+		p:next(cb, err_cb);
+		assert.spy(cb).was_called(0);
+		assert.spy(err_cb).was_called(0);
+		r("oh doh");
+		assert.spy(cb).was_called(0);
+		assert.spy(err_cb).was_called(1);
+		assert.equal("oh doh", result);
+	end);
+	it("supports chaining of rejected promises", function ()
+		local r, result;
+		local p = promise.new(function (resolve, reject)
+			r = reject;
+		end);
+		local cb = spy.new(function () end);
+		local err_cb = spy.new(function (v)
+			result = v;
+			assert.equal("oh doh", v);
+			return "ok"
+		end);
+		local cb2 = spy.new(function (v)
+			result = v;
+		end);
+		local err_cb2 = spy.new(function () end);
+		p:next(cb, err_cb):next(cb2, err_cb2)
+		assert.spy(cb).was_called(0);
+		assert.spy(err_cb).was_called(0);
+		assert.spy(cb2).was_called(0);
+		assert.spy(err_cb2).was_called(0);
+		r("oh doh");
+		assert.spy(cb).was_called(0);
+		assert.spy(err_cb).was_called(1);
+		assert.spy(cb2).was_called(1);
+		assert.spy(err_cb2).was_called(0);
+		assert.equal("ok", result);
+	end);
+
+	it("propagates errors down the chain, even when some handlers are not provided", function ()
+		local r, result;
+		local test_error = {};
+		local p = promise.new(function (resolve, reject)
+			r = reject;
+		end);
+		local cb = spy.new(function () end);
+		local err_cb = spy.new(function (e) result = e end);
+		local p2 = p:next(function () error(test_error) end);
+		local p3 = p2:next(cb)
+		p3:catch(err_cb);
+		assert.spy(cb).was_called(0);
+		assert.spy(err_cb).was_called(0);
+		r("oh doh");
+		assert.spy(cb).was_called(0);
+		assert.spy(err_cb).was_called(1);
+		assert.spy(err_cb).was_called_with("oh doh");
+		assert.equal("oh doh", result);
+	end);
+
+	it("propagates values down the chain, even when some handlers are not provided", function ()
+		local r;
+		local p = promise.new(function (resolve, reject)
+			r = resolve;
+		end);
+		local cb = spy.new(function () end);
+		local err_cb = spy.new(function () end);
+		local p2 = p:next(function (v) return v; end);
+		local p3 = p2:catch(err_cb)
+		p3:next(cb);
+		assert.spy(cb).was_called(0);
+		assert.spy(err_cb).was_called(0);
+		r(1337);
+		assert.spy(cb).was_called(1);
+		assert.spy(cb).was_called_with(1337);
+		assert.spy(err_cb).was_called(0);
+	end);
+
+	it("fulfilled promises do not call error handlers and do propagate value", function ()
+		local p = promise.resolve("foo");
+		local cb = spy.new(function () end);
+		local p2 = p:catch(cb);
+		assert.spy(cb).was_called(0);
+
+		local cb2 = spy.new(function () end);
+		p2:catch(cb2);
+		assert.spy(cb2).was_called(0);
+	end);
+
+	it("rejected promises do not call fulfilled handlers and do propagate reason", function ()
+		local p = promise.reject("foo");
+		local cb = spy.new(function () end);
+		local p2 = p:next(cb);
+		assert.spy(cb).was_called(0);
+
+		local cb2 = spy.new(function () end);
+		local cb2_err = spy.new(function () end);
+		p2:next(cb2, cb2_err);
+		assert.spy(cb2).was_called(0);
+		assert.spy(cb2_err).was_called(1);
+		assert.spy(cb2_err).was_called_with("foo");
+	end);
+
+	describe("allows callbacks to return", function ()
+		it("pending promises", function ()
+			local r;
+			local p = promise.resolve()
+			local cb = spy.new(function ()
+				return promise.new(function (resolve)
+					r = resolve;
+				end);
+			end);
+			local cb2 = spy.new(function () end);
+			p:next(cb):next(cb2);
+			assert.spy(cb).was_called(1);
+			assert.spy(cb2).was_called(0);
+			r("hello");
+			assert.spy(cb).was_called(1);
+			assert.spy(cb2).was_called(1);
+			assert.spy(cb2).was_called_with("hello");
+		end);
+
+		it("resolved promises", function ()
+			local p = promise.resolve()
+			local cb = spy.new(function ()
+				return promise.resolve("hello");
+			end);
+			local cb2 = spy.new(function () end);
+			p:next(cb):next(cb2);
+			assert.spy(cb).was_called(1);
+			assert.spy(cb2).was_called(1);
+			assert.spy(cb2).was_called_with("hello");
+		end);
+
+		it("rejected promises", function ()
+			local p = promise.resolve()
+			local cb = spy.new(function ()
+				return promise.reject("hello");
+			end);
+			local cb2 = spy.new(function ()
+				return promise.reject("goodbye");
+			end);
+			local cb3 = spy.new(function () end);
+			p:next(cb):catch(cb2):catch(cb3);
+			assert.spy(cb).was_called(1);
+			assert.spy(cb2).was_called(1);
+			assert.spy(cb2).was_called_with("hello");
+			assert.spy(cb3).was_called(1);
+			assert.spy(cb3).was_called_with("goodbye");
+		end);
+	end);
+
+	describe("race()", function ()
+		it("works with fulfilled promises", function ()
+			local p1, p2 = promise.resolve("yep"), promise.resolve("nope");
+			local p = promise.race({ p1, p2 });
+			local result;
+			p:next(function (v)
+				result = v;
+			end);
+			assert.equal("yep", result);
+		end);
+		it("works with pending promises", function ()
+			local r1, r2;
+			local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (resolve) r2 = resolve end);
+			local p = promise.race({ p1, p2 });
+
+			local result;
+			local cb = spy.new(function (v)
+				result = v;
+			end);
+			p:next(cb);
+			assert.spy(cb).was_called(0);
+			r2("yep");
+			r1("nope");
+			assert.spy(cb).was_called(1);
+			assert.equal("yep", result);
+		end);
+	end);
+	describe("all()", function ()
+		it("works with fulfilled promises", function ()
+			local p1, p2 = promise.resolve("yep"), promise.resolve("nope");
+			local p = promise.all({ p1, p2 });
+			local result;
+			p:next(function (v)
+				result = v;
+			end);
+			assert.same({ "yep", "nope" }, result);
+		end);
+		it("works with pending promises", function ()
+			local r1, r2;
+			local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (resolve) r2 = resolve end);
+			local p = promise.all({ p1, p2 });
+
+			local result;
+			local cb = spy.new(function (v)
+				result = v;
+			end);
+			p:next(cb);
+			assert.spy(cb).was_called(0);
+			r2("yep");
+			assert.spy(cb).was_called(0);
+			r1("nope");
+			assert.spy(cb).was_called(1);
+			assert.same({ "nope", "yep" }, result);
+		end);
+		it("rejects if any promise rejects", function ()
+			local r1, r2;
+			local p1 = promise.new(function (resolve, reject) r1 = reject end);
+			local p2 = promise.new(function (resolve, reject) r2 = reject end);
+			local p = promise.all({ p1, p2 });
+
+			local result;
+			local cb = spy.new(function (v)
+				result = v;
+			end);
+			local cb_err = spy.new(function (v)
+				result = v;
+			end);
+			p:next(cb, cb_err);
+			assert.spy(cb).was_called(0);
+			assert.spy(cb_err).was_called(0);
+			r2("fail");
+			assert.spy(cb).was_called(0);
+			assert.spy(cb_err).was_called(1);
+			r1("nope");
+			assert.spy(cb).was_called(0);
+			assert.spy(cb_err).was_called(1);
+			assert.equal("fail", result);
+		end);
+	end);
+	describe("catch()", function ()
+		it("works", function ()
+			local result;
+			local p = promise.new(function (resolve)
+				error({ foo = true });
+			end);
+			local cb1 = spy.new(function (v)
+				result = v;
+			end);
+			assert.spy(cb1).was_called(0);
+			p:catch(cb1);
+			assert.spy(cb1).was_called(1);
+			assert.same({ foo = true }, result);
+		end);
+	end);
+	it("promises may be resolved by other promises", function ()
+		local r1, r2;
+		local p1, p2 = promise.new(function (resolve) r1 = resolve end), promise.new(function (resolve) r2 = resolve end);
+
+		local result;
+		local cb = spy.new(function (v)
+			result = v;
+		end);
+		p1:next(cb);
+		assert.spy(cb).was_called(0);
+
+		r1(p2);
+		assert.spy(cb).was_called(0);
+		r2("yep");
+		assert.spy(cb).was_called(1);
+		assert.equal("yep", result);
+	end);
+	describe("reject()", function ()
+		it("returns a rejected promise", function ()
+			local p = promise.reject("foo");
+			local cb = spy.new(function () end);
+			p:catch(cb);
+			assert.spy(cb).was_called(1);
+			assert.spy(cb).was_called_with("foo");
+		end);
+		it("returns a rejected promise and does not call on_fulfilled", function ()
+			local p = promise.reject("foo");
+			local cb = spy.new(function () end);
+			p:next(cb);
+			assert.spy(cb).was_called(0);
+		end);
+	end);
+	describe("finally()", function ()
+		local p, p2, resolve, reject, on_finally;
+		before_each(function ()
+			p = promise.new(function (_resolve, _reject)
+				resolve, reject = _resolve, _reject;
+			end);
+			on_finally = spy.new(function () end);
+			p2 = p:finally(on_finally);
+		end);
+		it("runs when a promise is resolved", function ()
+			assert.spy(on_finally).was_called(0);
+			resolve("foo");
+			assert.spy(on_finally).was_called(1);
+			assert.spy(on_finally).was_not_called_with("foo");
+		end);
+		it("runs when a promise is rejected", function ()
+			assert.spy(on_finally).was_called(0);
+			reject("foo");
+			assert.spy(on_finally).was_called(1);
+			assert.spy(on_finally).was_not_called_with("foo");
+		end);
+		it("returns a promise that fulfills with the original value", function ()
+			local cb2 = spy.new(function () end);
+			p2:next(cb2);
+			assert.spy(on_finally).was_called(0);
+			assert.spy(cb2).was_called(0);
+			resolve("foo");
+			assert.spy(on_finally).was_called(1);
+			assert.spy(cb2).was_called(1);
+			assert.spy(on_finally).was_not_called_with("foo");
+			assert.spy(cb2).was_called_with("foo");
+		end);
+		it("returns a promise that rejects with the original error", function ()
+			local on_finally_err = spy.new(function () end);
+			local on_finally_ok = spy.new(function () end);
+			p2:catch(on_finally_err);
+			p2:next(on_finally_ok);
+			assert.spy(on_finally).was_called(0);
+			assert.spy(on_finally_err).was_called(0);
+			reject("foo");
+			assert.spy(on_finally).was_called(1);
+			-- Since the original promise was rejected, the finally promise should also be
+			assert.spy(on_finally_ok).was_called(0);
+			assert.spy(on_finally_err).was_called(1);
+			assert.spy(on_finally).was_not_called_with("foo");
+			assert.spy(on_finally_err).was_called_with("foo");
+		end);
+		it("returns a promise that rejects with an uncaught error inside on_finally", function ()
+			p = promise.new(function (_resolve, _reject)
+				resolve, reject = _resolve, _reject;
+			end);
+			local test_error = {};
+			on_finally = spy.new(function () error(test_error) end);
+			p2 = p:finally(on_finally);
+
+			local on_finally_err = spy.new(function () end);
+			p2:catch(on_finally_err);
+			assert.spy(on_finally).was_called(0);
+			assert.spy(on_finally_err).was_called(0);
+			reject("foo");
+			assert.spy(on_finally).was_called(1);
+			assert.spy(on_finally_err).was_called(1);
+			assert.spy(on_finally).was_not_called_with("foo");
+			assert.spy(on_finally).was_not_called_with(test_error);
+			assert.spy(on_finally_err).was_called_with(test_error);
+		end);
+	end);
+	describe("try()", function ()
+		it("works with functions that return a promise", function ()
+			local resolve;
+			local p = promise.try(function ()
+				return promise.new(function (_resolve)
+					resolve = _resolve;
+				end);
+			end);
+			assert.is_function(resolve);
+			local on_resolved = spy.new(function () end);
+			p:next(on_resolved);
+			assert.spy(on_resolved).was_not_called();
+			resolve("foo");
+			assert.spy(on_resolved).was_called_with("foo");
+		end);
+
+		it("works with functions that return a value", function ()
+			local p = promise.try(function ()
+				return "foo";
+			end);
+			local on_resolved = spy.new(function () end);
+			p:next(on_resolved);
+			assert.spy(on_resolved).was_called_with("foo");
+		end);
+
+		it("works with functions that return a promise that rejects", function ()
+			local reject;
+			local p = promise.try(function ()
+				return promise.new(function (_, _reject)
+					reject = _reject;
+				end);
+			end);
+			assert.is_function(reject);
+			local on_rejected = spy.new(function () end);
+			p:catch(on_rejected);
+			assert.spy(on_rejected).was_not_called();
+			reject("foo");
+			assert.spy(on_rejected).was_called_with("foo");
+		end);
+
+		it("works with functions that throw errors", function ()
+			local test_error = {};
+			local p = promise.try(function ()
+				error(test_error);
+			end);
+			local on_rejected = spy.new(function () end);
+			p:catch(on_rejected);
+			assert.spy(on_rejected).was_called(1);
+			assert.spy(on_rejected).was_called_with(test_error);
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_pubsub_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,408 @@
+local pubsub;
+setup(function ()
+	pubsub = require "util.pubsub";
+end);
+
+--[[TODO:
+ Retract
+ Purge
+ auto-create/auto-subscribe
+ Item store/node store
+ resize on max_items change
+ service creation config provides alternative node_defaults
+ get subscriptions
+]]
+
+describe("util.pubsub", function ()
+	describe("simple node creation and deletion", function ()
+		randomize(false); -- These tests are ordered
+
+		-- Roughly a port of scansion/scripts/pubsub_createdelete.scs
+		local service = pubsub.new();
+
+		describe("#create", function ()
+			randomize(false); -- These tests are ordered
+			it("creates a new node", function ()
+				assert.truthy(service:create("princely_musings", true));
+			end);
+
+			it("fails to create the same node again", function ()
+				assert.falsy(service:create("princely_musings", true));
+			end);
+		end);
+
+		describe("#delete", function ()
+			randomize(false); -- These tests are ordered
+			it("deletes the node", function ()
+				assert.truthy(service:delete("princely_musings", true));
+			end);
+
+			it("can't delete an already deleted node", function ()
+				assert.falsy(service:delete("princely_musings", true));
+			end);
+		end);
+	end);
+
+	describe("simple publishing", function ()
+		randomize(false); -- These tests are ordered
+
+		local notified;
+		local broadcaster = spy.new(function (notif_type, node_name, subscribers, item) -- luacheck: ignore 212
+			notified = subscribers;
+		end);
+		local service = pubsub.new({
+			broadcaster = broadcaster;
+		});
+
+		it("creates a node", function ()
+			assert.truthy(service:create("node", true));
+		end);
+
+		it("lets someone subscribe", function ()
+			assert.truthy(service:add_subscription("node", true, "someone"));
+		end);
+
+		it("publishes an item", function ()
+			assert.truthy(service:publish("node", true, "1", "item 1"));
+			assert.truthy(notified["someone"]);
+		end);
+
+		it("called the broadcaster", function ()
+			assert.spy(broadcaster).was_called();
+		end);
+
+		it("should return one item", function ()
+			local ok, ret = service:get_items("node", true);
+			assert.truthy(ok);
+			assert.same({ "1", ["1"] = "item 1" }, ret);
+		end);
+
+		it("lets someone unsubscribe", function ()
+			assert.truthy(service:remove_subscription("node", true, "someone"));
+		end);
+
+		it("does not send notifications after subscription is removed", function ()
+			assert.truthy(service:publish("node", true, "1", "item 1"));
+			assert.is_nil(notified["someone"]);
+		end);
+	end);
+
+	describe("publish with config", function ()
+		randomize(false); -- These tests are ordered
+
+		local broadcaster = spy.new(function (notif_type, node_name, subscribers, item) -- luacheck: ignore 212
+		end);
+		local service = pubsub.new({
+			broadcaster = broadcaster;
+			autocreate_on_publish = true;
+		});
+
+		it("automatically creates node with requested config", function ()
+			assert(service:publish("node", true, "1", "item 1", { myoption = true }));
+
+			local ok, config = assert(service:get_node_config("node", true));
+			assert.equals(true, config.myoption);
+		end);
+
+		it("fails to publish to a node with differing config", function ()
+			local ok, err = service:publish("node", true, "1", "item 2", { myoption = false });
+			assert.falsy(ok);
+			assert.equals("precondition-not-met", err);
+		end);
+
+		it("allows to publish to a node with differing config when only defaults are suggested", function ()
+			assert(service:publish("node", true, "1", "item 2", { _defaults_only = true, myoption = false }));
+		end);
+	end);
+
+	describe("#issue1082", function ()
+		randomize(false); -- These tests are ordered
+
+		local service = pubsub.new();
+
+		it("creates a node with max_items = 1", function ()
+			assert.truthy(service:create("node", true, { max_items = 1 }));
+		end);
+
+		it("changes max_items to 2", function ()
+			assert.truthy(service:set_node_config("node", true, { max_items = 2 }));
+		end);
+
+		it("publishes one item", function ()
+			assert.truthy(service:publish("node", true, "1", "item 1"));
+		end);
+
+		it("should return one item", function ()
+			local ok, ret = service:get_items("node", true);
+			assert.truthy(ok);
+			assert.same({ "1", ["1"] = "item 1" }, ret);
+		end);
+
+		it("publishes another item", function ()
+			assert.truthy(service:publish("node", true, "2", "item 2"));
+		end);
+
+		it("should return two items", function ()
+			local ok, ret = service:get_items("node", true);
+			assert.truthy(ok);
+			assert.same({
+				"2",
+				"1",
+				["1"] = "item 1",
+				["2"] = "item 2",
+			}, ret);
+		end);
+
+		it("publishes yet another item", function ()
+			assert.truthy(service:publish("node", true, "3", "item 3"));
+		end);
+
+		it("should still return only two items", function ()
+			local ok, ret = service:get_items("node", true);
+			assert.truthy(ok);
+			assert.same({
+				"3",
+				"2",
+				["2"] = "item 2",
+				["3"] = "item 3",
+			}, ret);
+		end);
+
+	end);
+
+	describe("node config", function ()
+		local service;
+		before_each(function ()
+			service = pubsub.new();
+			service:create("test", true);
+		end);
+		it("access is forbidden for unaffiliated entities", function ()
+			local ok, err = service:get_node_config("test", "stranger");
+			assert.is_falsy(ok);
+			assert.equals("forbidden", err);
+		end);
+		it("returns an error for nodes that do not exist", function ()
+			local ok, err = service:get_node_config("nonexistent", true);
+			assert.is_falsy(ok);
+			assert.equals("item-not-found", err);
+		end);
+	end);
+
+	describe("access model", function ()
+		describe("open", function ()
+			local service;
+			before_each(function ()
+				service = pubsub.new();
+				-- Do not supply any config, 'open' should be default
+				service:create("test", true);
+			end);
+			it("should be the default", function ()
+				local ok, config = service:get_node_config("test", true);
+				assert.equal("open", config.access_model);
+			end);
+			it("should allow anyone to subscribe", function ()
+				local ok = service:add_subscription("test", "stranger", "stranger");
+				assert.is_true(ok);
+			end);
+			it("should still reject outcast-affiliated entities", function ()
+				assert(service:set_affiliation("test", true, "enemy", "outcast"));
+				local ok, err = service:add_subscription("test", "enemy", "enemy");
+				assert.is_falsy(ok);
+				assert.equal("forbidden", err);
+			end);
+		end);
+		describe("whitelist", function ()
+			local service;
+			before_each(function ()
+				service = assert(pubsub.new());
+				assert.is_true(service:create("test", true, { access_model = "whitelist" }));
+			end);
+			it("should be present in the configuration", function ()
+				local ok, config = service:get_node_config("test", true);
+				assert.equal("whitelist", config.access_model);
+			end);
+			it("should not allow anyone to subscribe", function ()
+				local ok, err = service:add_subscription("test", "stranger", "stranger");
+				assert.is_false(ok);
+				assert.equals("forbidden", err);
+			end);
+		end);
+		describe("change", function ()
+			local service;
+			before_each(function ()
+				service = pubsub.new();
+				service:create("test", true, { access_model = "open" });
+			end);
+			it("affects existing subscriptions", function ()
+				do
+					local ok = service:add_subscription("test", "stranger", "stranger");
+					assert.is_true(ok);
+				end
+				do
+					local ok, sub = service:get_subscription("test", "stranger", "stranger");
+					assert.is_true(ok);
+					assert.is_true(sub);
+				end
+				assert(service:set_node_config("test", true, { access_model = "whitelist" }));
+				do
+					local ok, sub = service:get_subscription("test", "stranger", "stranger");
+					assert.is_true(ok);
+					assert.is_nil(sub);
+				end
+			end);
+		end);
+	end);
+
+	describe("publish model", function ()
+		describe("publishers", function ()
+			local service;
+			before_each(function ()
+				service = pubsub.new();
+				-- Do not supply any config, 'publishers' should be default
+				service:create("test", true);
+			end);
+			it("should be the default", function ()
+				local ok, config = service:get_node_config("test", true);
+				assert.equal("publishers", config.publish_model);
+			end);
+			it("should not allow anyone to publish", function ()
+				assert.is_true(service:add_subscription("test", "stranger", "stranger"));
+				local ok, err = service:publish("test", "stranger", "item1", "foo");
+				assert.is_falsy(ok);
+				assert.equals("forbidden", err);
+			end);
+			it("should allow publishers to publish", function ()
+				assert(service:set_affiliation("test", true, "mypublisher", "publisher"));
+				local ok, err = service:publish("test", "mypublisher", "item1", "foo");
+				assert.is_true(ok);
+			end);
+			it("should allow owners to publish", function ()
+				assert(service:set_affiliation("test", true, "myowner", "owner"));
+				local ok = service:publish("test", "myowner", "item1", "foo");
+				assert.is_true(ok);
+			end);
+		end);
+		describe("open", function ()
+			local service;
+			before_each(function ()
+				service = pubsub.new();
+				service:create("test", true, { publish_model = "open" });
+			end);
+			it("should allow anyone to publish", function ()
+				local ok = service:publish("test", "stranger", "item1", "foo");
+				assert.is_true(ok);
+			end);
+		end);
+		describe("subscribers", function ()
+			local service;
+			before_each(function ()
+				service = pubsub.new();
+				service:create("test", true, { publish_model = "subscribers" });
+			end);
+			it("should not allow non-subscribers to publish", function ()
+				local ok, err = service:publish("test", "stranger", "item1", "foo");
+				assert.is_falsy(ok);
+				assert.equals("forbidden", err);
+			end);
+			it("should allow subscribers to publish without an affiliation", function ()
+				assert.is_true(service:add_subscription("test", "stranger", "stranger"));
+				local ok = service:publish("test", "stranger", "item1", "foo");
+				assert.is_true(ok);
+			end);
+			it("should allow publishers to publish without a subscription", function ()
+				assert(service:set_affiliation("test", true, "mypublisher", "publisher"));
+				local ok, err = service:publish("test", "mypublisher", "item1", "foo");
+				assert.is_true(ok);
+			end);
+			it("should allow owners to publish without a subscription", function ()
+				assert(service:set_affiliation("test", true, "myowner", "owner"));
+				local ok = service:publish("test", "myowner", "item1", "foo");
+				assert.is_true(ok);
+			end);
+		end);
+	end);
+
+	describe("item API", function ()
+		local service;
+		before_each(function ()
+			service = pubsub.new();
+			service:create("test", true, { publish_model = "subscribers" });
+		end);
+		describe("get_last_item()", function ()
+			it("succeeds with nil on empty nodes", function ()
+				local ok, id, item = service:get_last_item("test", true);
+				assert.is_true(ok);
+				assert.is_nil(id);
+				assert.is_nil(item);
+			end);
+			it("succeeds and returns the last item", function ()
+				service:publish("test", true, "one", "hello world");
+				service:publish("test", true, "two", "hello again");
+				service:publish("test", true, "three", "hey");
+				service:publish("test", true, "one", "bye");
+				local ok, id, item = service:get_last_item("test", true);
+				assert.is_true(ok);
+				assert.equal("one", id);
+				assert.equal("bye", item);
+			end);
+		end);
+		describe("get_items()", function ()
+			it("fails on non-existent nodes", function ()
+				local ok, err = service:get_items("no-node", true);
+				assert.is_falsy(ok);
+				assert.equal("item-not-found", err);
+			end);
+			it("returns no items on an empty node", function ()
+				local ok, items = service:get_items("test", true);
+				assert.is_true(ok);
+				assert.equal(0, #items);
+				assert.is_nil(next(items));
+			end);
+			it("returns no items on an empty node", function ()
+				local ok, items = service:get_items("test", true);
+				assert.is_true(ok);
+				assert.equal(0, #items);
+				assert.is_nil((next(items)));
+			end);
+			it("returns all published items", function ()
+				service:publish("test", true, "one", "hello world");
+				service:publish("test", true, "two", "hello again");
+				service:publish("test", true, "three", "hey");
+				service:publish("test", true, "one", "bye");
+				local ok, items = service:get_items("test", true);
+				assert.is_true(ok);
+				assert.same({ "one", "three", "two", two = "hello again", three = "hey", one = "bye" }, items);
+			end);
+		end);
+	end);
+
+	describe("restoring data from nodestore", function ()
+		local nodestore = {
+			data = {
+				test = {
+					name = "test";
+					config = {};
+					affiliations = {};
+					subscribers = {
+						["someone"] = true;
+					};
+				}
+			}
+		};
+		function nodestore:users()
+			return pairs(self.data)
+		end
+		function nodestore:get(key)
+			return self.data[key];
+		end
+		local service = pubsub.new({
+			nodestore = nodestore;
+		});
+		it("subscriptions", function ()
+			local ok, ret = service:get_subscriptions(nil, true, nil)
+			assert.is_true(ok);
+			assert.same({ { node = "test", jid = "someone", subscription = true, } }, ret);
+		end);
+	end);
+
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_queue_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,103 @@
+
+local queue = require "util.queue";
+
+describe("util.queue", function()
+	describe("#new()", function()
+		it("should work", function()
+
+			do
+				local q = queue.new(10);
+
+				assert.are.equal(q.size, 10);
+				assert.are.equal(q:count(), 0);
+
+				assert.is_true(q:push("one"));
+				assert.is_true(q:push("two"));
+				assert.is_true(q:push("three"));
+
+				for i = 4, 10 do
+					assert.is_true(q:push("hello"));
+					assert.are.equal(q:count(), i, "count is not "..i.."("..q:count()..")");
+				end
+				assert.are.equal(q:push("hello"), nil, "queue overfull!");
+				assert.are.equal(q:push("hello"), nil, "queue overfull!");
+				assert.are.equal(q:pop(), "one", "queue item incorrect");
+				assert.are.equal(q:pop(), "two", "queue item incorrect");
+				assert.is_true(q:push("hello"));
+				assert.is_true(q:push("hello"));
+				assert.are.equal(q:pop(), "three", "queue item incorrect");
+				assert.is_true(q:push("hello"));
+				assert.are.equal(q:push("hello"), nil, "queue overfull!");
+				assert.are.equal(q:push("hello"), nil, "queue overfull!");
+
+				assert.are.equal(q:count(), 10, "queue count incorrect");
+
+				for _ = 1, 10 do
+					assert.are.equal(q:pop(), "hello", "queue item incorrect");
+				end
+
+				assert.are.equal(q:count(), 0, "queue count incorrect");
+				assert.are.equal(q:pop(), nil, "empty queue pops non-nil result");
+				assert.are.equal(q:count(), 0, "popping empty queue affects count");
+
+				assert.are.equal(q:peek(), nil, "empty queue peeks non-nil result");
+				assert.are.equal(q:count(), 0, "peeking empty queue affects count");
+
+				assert.is_true(q:push(1));
+				for i = 1, 1001 do
+					assert.are.equal(q:pop(), i);
+					assert.are.equal(q:count(), 0);
+					assert.is_true(q:push(i+1));
+					assert.are.equal(q:count(), 1);
+				end
+				assert.are.equal(q:pop(), 1002);
+				assert.is_true(q:push(1));
+				for i = 1, 1000 do
+					assert.are.equal(q:pop(), i);
+					assert.is_true(q:push(i+1));
+				end
+				assert.are.equal(q:pop(), 1001);
+				assert.are.equal(q:count(), 0);
+			end
+
+			do
+				-- Test queues that purge old items when pushing to a full queue
+				local q = queue.new(10, true);
+
+				for i = 1, 10 do
+					q:push(i);
+				end
+
+				assert.are.equal(q:count(), 10);
+
+				assert.is_true(q:push(11));
+				assert.are.equal(q:count(), 10);
+				assert.are.equal(q:pop(), 2); -- First item should have been purged
+				assert.are.equal(q:peek(), 3);
+
+				for i = 12, 32 do
+					assert.is_true(q:push(i));
+				end
+
+				assert.are.equal(q:count(), 10);
+				assert.are.equal(q:pop(), 23);
+			end
+
+			do
+				-- Test iterator
+				local q = queue.new(10, true);
+
+				for i = 1, 10 do
+					q:push(i);
+				end
+
+				local i = 0;
+				for item in q:items() do
+					i = i + 1;
+					assert.are.equal(item, i, "unexpected item returned by iterator")
+				end
+			end
+
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_random_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,19 @@
+
+local random = require "util.random";
+
+describe("util.random", function()
+	describe("#bytes()", function()
+		it("should return a string", function()
+			assert.is_string(random.bytes(16));
+		end);
+
+		it("should return the requested number of bytes", function()
+			-- Makes no attempt at testing how random the bytes are,
+			-- just that it returns the number of bytes requested
+
+			for i = 1, 20 do
+				assert.are.equal(2^i, #random.bytes(2^i));
+			end
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_rfc6724_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,97 @@
+
+local rfc6724 = require "util.rfc6724";
+local new_ip = require"util.ip".new_ip;
+
+describe("util.rfc6724", function()
+	describe("#source()", function()
+		it("should work", function()
+			assert.are.equal(rfc6724.source(new_ip("2001:db8:1::1", "IPv6"),
+					{new_ip("2001:db8:3::1", "IPv6"), new_ip("fe80::1", "IPv6")}).addr,
+				"2001:db8:3::1",
+				"prefer appropriate scope");
+			assert.are.equal(rfc6724.source(new_ip("ff05::1", "IPv6"),
+					{new_ip("2001:db8:3::1", "IPv6"), new_ip("fe80::1", "IPv6")}).addr,
+				"2001:db8:3::1",
+				"prefer appropriate scope");
+			assert.are.equal(rfc6724.source(new_ip("2001:db8:1::1", "IPv6"),
+					{new_ip("2001:db8:1::1", "IPv6"), new_ip("2001:db8:2::1", "IPv6")}).addr,
+				"2001:db8:1::1",
+				"prefer same address"); -- "2001:db8:1::1" should be marked "deprecated" here, we don't handle that right now
+			assert.are.equal(rfc6724.source(new_ip("fe80::1", "IPv6"),
+					{new_ip("fe80::2", "IPv6"), new_ip("2001:db8:1::1", "IPv6")}).addr,
+				"fe80::2",
+				"prefer appropriate scope"); -- "fe80::2" should be marked "deprecated" here, we don't handle that right now
+			assert.are.equal(rfc6724.source(new_ip("2001:db8:1::1", "IPv6"),
+					{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3::2", "IPv6")}).addr,
+				"2001:db8:1::2",
+				"longest matching prefix");
+		--[[ "2001:db8:1::2" should be a care-of address and "2001:db8:3::2" a home address, we can't handle this and would fail
+			assert.are.equal(rfc6724.source(new_ip("2001:db8:1::1", "IPv6"),
+					{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3::2", "IPv6")}).addr,
+				"2001:db8:3::2",
+				"prefer home address");
+		]]
+			assert.are.equal(rfc6724.source(new_ip("2002:c633:6401::1", "IPv6"),
+					{new_ip("2002:c633:6401::d5e3:7953:13eb:22e8", "IPv6"), new_ip("2001:db8:1::2", "IPv6")}).addr,
+				"2002:c633:6401::d5e3:7953:13eb:22e8",
+				"prefer matching label"); -- "2002:c633:6401::d5e3:7953:13eb:22e8" should be marked "temporary" here, we don't handle that right now
+			assert.are.equal(rfc6724.source(new_ip("2001:db8:1::d5e3:0:0:1", "IPv6"),
+					{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:1::d5e3:7953:13eb:22e8", "IPv6")}).addr,
+				"2001:db8:1::d5e3:7953:13eb:22e8",
+				"prefer temporary address") -- "2001:db8:1::2" should be marked "public" and "2001:db8:1::d5e3:7953:13eb:22e8" should be marked "temporary" here, we don't handle that right now
+		end);
+	end);
+	describe("#destination()", function()
+		it("should work", function()
+			local order;
+			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("198.51.100.121", "IPv4")},
+				{new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::1", "IPv6"), new_ip("169.254.13.78", "IPv4")})
+			assert.are.equal(order[1].addr, "2001:db8:1::1", "prefer matching scope");
+			assert.are.equal(order[2].addr, "198.51.100.121", "prefer matching scope");
+
+			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("198.51.100.121", "IPv4")},
+				{new_ip("fe80::1", "IPv6"), new_ip("198.51.100.117", "IPv4")})
+			assert.are.equal(order[1].addr, "198.51.100.121", "prefer matching scope");
+			assert.are.equal(order[2].addr, "2001:db8:1::1", "prefer matching scope");
+
+			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("10.1.2.3", "IPv4")},
+				{new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::1", "IPv6"), new_ip("10.1.2.4", "IPv4")})
+			assert.are.equal(order[1].addr, "2001:db8:1::1", "prefer higher precedence");
+			assert.are.equal(order[2].addr, "10.1.2.3", "prefer higher precedence");
+
+			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("fe80::1", "IPv6")},
+				{new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::2", "IPv6")})
+			assert.are.equal(order[1].addr, "fe80::1", "prefer smaller scope");
+			assert.are.equal(order[2].addr, "2001:db8:1::1", "prefer smaller scope");
+
+		--[[ "2001:db8:1::2" and "fe80::2" should be marked "care-of address", while "2001:db8:3::1" should be marked "home address", we can't currently handle this and would fail the test
+			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("fe80::1", "IPv6")},
+				{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3::1", "IPv6"), new_ip("fe80::2", "IPv6")})
+			assert.are.equal(order[1].addr, "2001:db8:1::1", "prefer home address");
+			assert.are.equal(order[2].addr, "fe80::1", "prefer home address");
+		]]
+
+		--[[ "fe80::2" should be marked "deprecated", we can't currently handle this and would fail the test
+			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("fe80::1", "IPv6")},
+				{new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::2", "IPv6")})
+			assert.are.equal(order[1].addr, "2001:db8:1::1", "avoid deprecated addresses");
+			assert.are.equal(order[2].addr, "fe80::1", "avoid deprecated addresses");
+		]]
+
+			order = rfc6724.destination({new_ip("2001:db8:1::1", "IPv6"), new_ip("2001:db8:3ffe::1", "IPv6")},
+				{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3f44::2", "IPv6"), new_ip("fe80::2", "IPv6")})
+			assert.are.equal(order[1].addr, "2001:db8:1::1", "longest matching prefix");
+			assert.are.equal(order[2].addr, "2001:db8:3ffe::1", "longest matching prefix");
+
+			order = rfc6724.destination({new_ip("2002:c633:6401::1", "IPv6"), new_ip("2001:db8:1::1", "IPv6")},
+				{new_ip("2002:c633:6401::2", "IPv6"), new_ip("fe80::2", "IPv6")})
+			assert.are.equal(order[1].addr, "2002:c633:6401::1", "prefer matching label");
+			assert.are.equal(order[2].addr, "2001:db8:1::1", "prefer matching label");
+
+			order = rfc6724.destination({new_ip("2002:c633:6401::1", "IPv6"), new_ip("2001:db8:1::1", "IPv6")},
+				{new_ip("2002:c633:6401::2", "IPv6"), new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::2", "IPv6")})
+			assert.are.equal(order[1].addr, "2001:db8:1::1", "prefer higher precedence");
+			assert.are.equal(order[2].addr, "2002:c633:6401::1", "prefer higher precedence");
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_serialization_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,79 @@
+local serialization = require "util.serialization";
+
+describe("util.serialization", function ()
+	describe("serialize", function ()
+		it("makes a string", function ()
+			assert.is_string(serialization.serialize({}));
+			assert.is_string(serialization.serialize(nil));
+			assert.is_string(serialization.serialize(1));
+			assert.is_string(serialization.serialize(true));
+		end);
+
+		it("rejects function by default", function ()
+			assert.has_error(function ()
+				serialization.serialize(function () end)
+			end);
+		end);
+
+		it("makes a string in debug mode", function ()
+			assert.is_string(serialization.serialize(function () end, "debug"));
+		end);
+
+		it("rejects cycles", function ()
+			assert.has_error(function ()
+				local t = {}
+				t[t] = { t };
+				serialization.serialize(t)
+			end);
+			-- also with multirefs allowed
+			assert.has_error(function ()
+				local t = {}
+				t[t] = { t };
+				serialization.serialize(t, { multirefs = true })
+			end);
+		end);
+
+		it("rejects multiple references to same table", function ()
+			assert.has_error(function ()
+				local t1 = {};
+				local t2 = { t1, t1 };
+				serialization.serialize(t2, { multirefs = false });
+			end);
+		end);
+
+		it("optionally allows multiple references to same table", function ()
+			assert.has_error(function ()
+				local t1 = {};
+				local t2 = { t1, t1 };
+				serialization.serialize(t2, { multirefs = true });
+			end);
+		end);
+
+		it("roundtrips", function ()
+			local function test(data)
+				local serialized = serialization.serialize(data);
+				assert.is_string(serialized);
+				local deserialized, err = serialization.deserialize(serialized);
+				assert.same(data, deserialized, err);
+			end
+
+			test({});
+			test({hello="world"});
+			test("foobar")
+			test("\0\1\2\3");
+			test("nödåtgärd");
+			test({1,2,3,4});
+			test({foo={[100]={{"bar"},{baz=1}}}});
+			test({["goto"] = {["function"]={["do"]="keywords"}}});
+		end);
+
+		it("can serialize with metatables", function ()
+			local s = serialization.new({ freeze = true });
+			local t = setmetatable({ a = "hi" }, { __freeze = function (t) return { t.a } end });
+			local rt = serialization.deserialize(s(t));
+			assert.same({"hi"}, rt);
+		end);
+
+	end);
+end);
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_stanza_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,373 @@
+
+local st = require "util.stanza";
+
+describe("util.stanza", function()
+	describe("#preserialize()", function()
+		it("should work", function()
+			local stanza = st.stanza("message", { type = "chat" }):text_tag("body", "Hello");
+			local stanza2 = st.preserialize(stanza);
+			assert.is_table(stanza2, "Preserialized stanza is a table");
+			assert.is_nil(getmetatable(stanza2), "Preserialized stanza has no metatable");
+			assert.is_string(stanza2.name, "Preserialized stanza has a name field");
+			assert.equal(stanza.name, stanza2.name, "Preserialized stanza has same name as the input stanza");
+			assert.same(stanza.attr, stanza2.attr, "Preserialized stanza same attr table as input stanza");
+			assert.is_nil(stanza2.tags, "Preserialized stanza has no tag list");
+			assert.is_nil(stanza2.last_add, "Preserialized stanza has no last_add marker");
+			assert.is_table(stanza2[1], "Preserialized child element preserved");
+			assert.equal("body", stanza2[1].name, "Preserialized child element name preserved");
+		end);
+	end);
+
+	describe("#deserialize()", function()
+		it("should work", function()
+			local stanza = { name = "message", attr = { type = "chat" }, { name = "body", attr = { }, "Hello" } };
+			local stanza2 = st.deserialize(st.preserialize(stanza));
+
+			assert.is_table(stanza2, "Deserialized stanza is a table");
+			assert.equal(st.stanza_mt, getmetatable(stanza2), "Deserialized stanza has stanza metatable");
+			assert.is_string(stanza2.name, "Deserialized stanza has a name field");
+			assert.equal(stanza.name, stanza2.name, "Deserialized stanza has same name as the input table");
+			assert.same(stanza.attr, stanza2.attr, "Deserialized stanza same attr table as input table");
+			assert.is_table(stanza2.tags, "Deserialized stanza has tag list");
+			assert.is_table(stanza2[1], "Deserialized child element preserved");
+			assert.equal("body", stanza2[1].name, "Deserialized child element name preserved");
+		end);
+	end);
+
+	describe("#stanza()", function()
+		it("should work", function()
+			local s = st.stanza("foo", { xmlns = "myxmlns", a = "attr-a" });
+			assert.are.equal(s.name, "foo");
+			assert.are.equal(s.attr.xmlns, "myxmlns");
+			assert.are.equal(s.attr.a, "attr-a");
+
+			local s1 = st.stanza("s1");
+			assert.are.equal(s1.name, "s1");
+			assert.are.equal(s1.attr.xmlns, nil);
+			assert.are.equal(#s1, 0);
+			assert.are.equal(#s1.tags, 0);
+
+			s1:tag("child1");
+			assert.are.equal(#s1.tags, 1);
+			assert.are.equal(s1.tags[1].name, "child1");
+
+			s1:tag("grandchild1"):up();
+			assert.are.equal(#s1.tags, 1);
+			assert.are.equal(s1.tags[1].name, "child1");
+			assert.are.equal(#s1.tags[1], 1);
+			assert.are.equal(s1.tags[1][1].name, "grandchild1");
+
+			s1:up():tag("child2");
+			assert.are.equal(#s1.tags, 2, tostring(s1));
+			assert.are.equal(s1.tags[1].name, "child1");
+			assert.are.equal(s1.tags[2].name, "child2");
+			assert.are.equal(#s1.tags[1], 1);
+			assert.are.equal(s1.tags[1][1].name, "grandchild1");
+
+			s1:up():text("Hello world");
+			assert.are.equal(#s1.tags, 2);
+			assert.are.equal(#s1, 3);
+			assert.are.equal(s1.tags[1].name, "child1");
+			assert.are.equal(s1.tags[2].name, "child2");
+			assert.are.equal(#s1.tags[1], 1);
+			assert.are.equal(s1.tags[1][1].name, "grandchild1");
+		end);
+		it("should work with unicode values", function ()
+			local s = st.stanza("Объект", { xmlns = "myxmlns", ["Объект"] = "&" });
+			assert.are.equal(s.name, "Объект");
+			assert.are.equal(s.attr.xmlns, "myxmlns");
+			assert.are.equal(s.attr["Объект"], "&");
+		end);
+		it("should allow :text() with nil and empty strings", function ()
+			local s_control = st.stanza("foo");
+			assert.same(st.stanza("foo"):text(), s_control);
+			assert.same(st.stanza("foo"):text(nil), s_control);
+			assert.same(st.stanza("foo"):text(""), s_control);
+		end);
+	end);
+
+	describe("#message()", function()
+		it("should work", function()
+			local m = st.message();
+			assert.are.equal(m.name, "message");
+		end);
+	end);
+
+	describe("#iq()", function()
+		it("should create an iq stanza", function()
+			local i = st.iq({ id = "foo" });
+			assert.are.equal("iq", i.name);
+			assert.are.equal("foo", i.attr.id);
+		end);
+
+		it("should reject stanzas with no id", function ()
+			assert.has.error_match(function ()
+				st.iq();
+			end, "id attribute");
+
+			assert.has.error_match(function ()
+				st.iq({ foo = "bar" });
+			end, "id attribute");
+		end);
+	end);
+
+	describe("#presence()", function ()
+		it("should work", function()
+			local p = st.presence();
+			assert.are.equal(p.name, "presence");
+		end);
+	end);
+
+	describe("#reply()", function()
+		it("should work for <s>", function()
+			-- Test stanza
+			local s = st.stanza("s", { to = "touser", from = "fromuser", id = "123" })
+				:tag("child1");
+			-- Make reply stanza
+			local r = st.reply(s);
+			assert.are.equal(r.name, s.name);
+			assert.are.equal(r.id, s.id);
+			assert.are.equal(r.attr.to, s.attr.from);
+			assert.are.equal(r.attr.from, s.attr.to);
+			assert.are.equal(#r.tags, 0, "A reply should not include children of the original stanza");
+		end);
+
+		it("should work for <iq get>", function()
+			-- Test stanza
+			local s = st.stanza("iq", { to = "touser", from = "fromuser", id = "123", type = "get" })
+				:tag("child1");
+			-- Make reply stanza
+			local r = st.reply(s);
+			assert.are.equal(r.name, s.name);
+			assert.are.equal(r.id, s.id);
+			assert.are.equal(r.attr.to, s.attr.from);
+			assert.are.equal(r.attr.from, s.attr.to);
+			assert.are.equal(r.attr.type, "result");
+			assert.are.equal(#r.tags, 0, "A reply should not include children of the original stanza");
+		end);
+
+		it("should work for <iq set>", function()
+			-- Test stanza
+			local s = st.stanza("iq", { to = "touser", from = "fromuser", id = "123", type = "set" })
+				:tag("child1");
+			-- Make reply stanza
+			local r = st.reply(s);
+			assert.are.equal(r.name, s.name);
+			assert.are.equal(r.id, s.id);
+			assert.are.equal(r.attr.to, s.attr.from);
+			assert.are.equal(r.attr.from, s.attr.to);
+			assert.are.equal(r.attr.type, "result");
+			assert.are.equal(#r.tags, 0, "A reply should not include children of the original stanza");
+		end);
+	end);
+
+	describe("#error_reply()", function()
+		it("should work for <s>", function()
+			-- Test stanza
+			local s = st.stanza("s", { to = "touser", from = "fromuser", id = "123" })
+				:tag("child1");
+			-- Make reply stanza
+			local r = st.error_reply(s, "cancel", "service-unavailable");
+			assert.are.equal(r.name, s.name);
+			assert.are.equal(r.id, s.id);
+			assert.are.equal(r.attr.to, s.attr.from);
+			assert.are.equal(r.attr.from, s.attr.to);
+			assert.are.equal(#r.tags, 1);
+			assert.are.equal(r.tags[1].tags[1].name, "service-unavailable");
+		end);
+
+		it("should work for <iq get>", function()
+			-- Test stanza
+			local s = st.stanza("iq", { to = "touser", from = "fromuser", id = "123", type = "get" })
+				:tag("child1");
+			-- Make reply stanza
+			local r = st.error_reply(s, "cancel", "service-unavailable");
+			assert.are.equal(r.name, s.name);
+			assert.are.equal(r.id, s.id);
+			assert.are.equal(r.attr.to, s.attr.from);
+			assert.are.equal(r.attr.from, s.attr.to);
+			assert.are.equal(r.attr.type, "error");
+			assert.are.equal(#r.tags, 1);
+			assert.are.equal(r.tags[1].tags[1].name, "service-unavailable");
+		end);
+	end);
+
+	describe("should reject #invalid", function ()
+		local invalid_names = {
+			["empty string"] = "", ["characters"] = "<>";
+		}
+		local invalid_data = {
+			["number"] = 1234, ["table"] = {};
+			["utf8"] = string.char(0xF4, 0x90, 0x80, 0x80);
+			["nil"] = "nil"; ["boolean"] = true;
+		};
+
+		for value_type, value in pairs(invalid_names) do
+			it(value_type.." in tag names", function ()
+				assert.error_matches(function ()
+					st.stanza(value);
+				end, value_type);
+			end);
+			it(value_type.." in attribute names", function ()
+				assert.error_matches(function ()
+					st.stanza("valid", { [value] = "valid" });
+				end, value_type);
+			end);
+		end
+		for value_type, value in pairs(invalid_data) do
+			if value == "nil" then value = nil; end
+			it(value_type.." in tag names", function ()
+				assert.error_matches(function ()
+					st.stanza(value);
+				end, value_type);
+			end);
+			it(value_type.." in attribute names", function ()
+				assert.error_matches(function ()
+					st.stanza("valid", { [value] = "valid" });
+				end, value_type);
+			end);
+			if value ~= nil then
+				it(value_type.." in attribute values", function ()
+					assert.error_matches(function ()
+						st.stanza("valid", { valid = value });
+					end, value_type);
+				end);
+				it(value_type.." in text node", function ()
+					assert.error_matches(function ()
+						st.stanza("valid"):text(value);
+					end, value_type);
+				end);
+			end
+		end
+	end);
+
+	describe("#is_stanza", function ()
+		-- is_stanza(any) -> boolean
+		it("identifies stanzas as stanzas", function ()
+			assert.truthy(st.is_stanza(st.stanza("x")));
+		end);
+		it("identifies strings as not stanzas", function ()
+			assert.falsy(st.is_stanza(""));
+		end);
+		it("identifies numbers as not stanzas", function ()
+			assert.falsy(st.is_stanza(1));
+		end);
+		it("identifies tables as not stanzas", function ()
+			assert.falsy(st.is_stanza({}));
+		end);
+	end);
+
+	describe("#remove_children", function ()
+		it("should work", function ()
+			local s = st.stanza("x", {xmlns="test"})
+				:tag("y", {xmlns="test"}):up()
+				:tag("z", {xmlns="test2"}):up()
+				:tag("x", {xmlns="test2"}):up()
+
+			s:remove_children("x");
+			assert.falsy(s:get_child("x"))
+			assert.truthy(s:get_child("z","test2"));
+			assert.truthy(s:get_child("x","test2"));
+
+			s:remove_children(nil, "test2");
+			assert.truthy(s:get_child("y"))
+			assert.falsy(s:get_child(nil,"test2"));
+
+			s:remove_children();
+			assert.falsy(s.tags[1]);
+		end);
+	end);
+
+	describe("#maptags", function ()
+		it("should work", function ()
+			local s = st.stanza("test")
+				:tag("one"):up()
+				:tag("two"):up()
+				:tag("one"):up()
+				:tag("three"):up();
+
+			local function one_filter(tag)
+				if tag.name == "one" then
+					return nil;
+				end
+				return tag;
+			end
+			assert.equal(4, #s.tags);
+			s:maptags(one_filter);
+			assert.equal(2, #s.tags);
+		end);
+
+		it("should work with multiple consecutive text nodes", function ()
+			local s = st.deserialize({
+				"\n";
+				{
+					"away";
+					name = "show";
+					attr = {};
+				};
+				"\n";
+				{
+					"I am away";
+					name = "status";
+					attr = {};
+				};
+				"\n";
+				{
+					"0";
+					name = "priority";
+					attr = {};
+				};
+				"\n";
+				{
+					name = "c";
+					attr = {
+						xmlns = "http://jabber.org/protocol/caps";
+						node = "http://psi-im.org";
+						hash = "sha-1";
+					};
+				};
+				"\n";
+				"\n";
+				name = "presence";
+				attr = {
+					to = "user@example.com/jflsjfld";
+					from = "room@chat.example.org/nick";
+				};
+			});
+
+			assert.equal(4, #s.tags);
+
+			s:maptags(function (tag) return tag; end);
+			assert.equal(4, #s.tags);
+
+			s:maptags(function (tag)
+				if tag.name == "c" then
+					return nil;
+				end
+				return tag;
+			end);
+			assert.equal(3, #s.tags);
+		end);
+		it("errors on invalid data - #981", function ()
+			local s = st.message({}, "Hello");
+			s.tags[1] = st.clone(s.tags[1]);
+			assert.has_error_match(function ()
+				s:maptags(function () end);
+			end, "Invalid stanza");
+		end);
+	end);
+
+	describe("#clone", function ()
+		it("works", function ()
+			local s = st.message({type="chat"}, "Hello"):reset();
+			local c = st.clone(s);
+			assert.same(s, c);
+		end);
+
+		it("works", function ()
+			assert.has_error(function ()
+				st.clone("this is not a stanza");
+			end);
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_throttle_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,150 @@
+
+
+-- Mock util.time
+local now = 0; -- wibbly-wobbly... timey-wimey... stuff
+local function later(n)
+	now = now + n; -- time passes at a different rate
+end
+package.loaded["util.time"] = {
+	now = function() return now; end
+}
+
+
+local throttle = require "util.throttle";
+
+describe("util.throttle", function()
+	describe("#create()", function()
+		it("should be created with correct values", function()
+			now = 5;
+			local a = throttle.create(3, 10);
+			assert.same(a, { balance = 3, max = 3, rate = 0.3, t = 5 });
+
+			local a = throttle.create(3, 5);
+			assert.same(a, { balance = 3, max = 3, rate = 0.6, t = 5 });
+
+			local a = throttle.create(1, 1);
+			assert.same(a, { balance = 1, max = 1, rate = 1, t = 5 });
+
+			local a = throttle.create(10, 10);
+			assert.same(a, { balance = 10, max = 10, rate = 1, t = 5 });
+
+			local a = throttle.create(10, 1);
+			assert.same(a, { balance = 10, max = 10, rate = 10, t = 5 });
+		end);
+	end);
+
+	describe("#update()", function()
+		it("does nothing when no time has passed, even if balance is not full", function()
+			now = 5;
+			local a = throttle.create(10, 10);
+			for i=1,5 do
+				a:update();
+				assert.same(a, { balance = 10, max = 10, rate = 1, t = 5 });
+			end
+			a.balance = 0;
+			for i=1,5 do
+				a:update();
+				assert.same(a, { balance = 0, max = 10, rate = 1, t = 5 });
+			end
+		end);
+		it("updates only time when time passes but balance is full", function()
+			now = 5;
+			local a = throttle.create(10, 10);
+			for i=1,5 do
+				later(5);
+				a:update();
+				assert.same(a, { balance = 10, max = 10, rate = 1, t = 5 + i*5 });
+			end
+		end);
+		it("updates balance when balance has room to grow as time passes", function()
+			now = 5;
+			local a = throttle.create(10, 10);
+			a.balance = 0;
+			assert.same(a, { balance = 0, max = 10, rate = 1, t = 5 });
+
+			later(1);
+			a:update();
+			assert.same(a, { balance = 1, max = 10, rate = 1, t = 6 });
+
+			later(3);
+			a:update();
+			assert.same(a, { balance = 4, max = 10, rate = 1, t = 9 });
+
+			later(10);
+			a:update();
+			assert.same(a, { balance = 10, max = 10, rate = 1, t = 19 });
+		end);
+		it("handles 10 x 0.1s updates the same as 1 x 1s update ", function()
+			now = 5;
+			local a = throttle.create(1, 1);
+
+			a.balance = 0;
+			later(1);
+			a:update();
+			assert.same(a, { balance = 1, max = 1, rate = 1, t = now });
+
+			a.balance = 0;
+			for i=1,10 do
+				later(0.1);
+				a:update();
+			end
+			assert(math.abs(a.balance - 1) < 0.0001); -- incremental updates cause rouding errors
+		end);
+	end);
+
+	-- describe("po")
+
+	describe("#poll()", function()
+		it("should only allow successful polls until cost is hit", function()
+			now = 5;
+
+			local a = throttle.create(3, 10);
+			assert.same(a, { balance = 3, max = 3, rate = 0.3, t = 5 });
+
+			assert.is_true(a:poll(1));  -- 3 -> 2
+			assert.same(a, { balance = 2, max = 3, rate = 0.3, t = 5 });
+
+			assert.is_true(a:poll(2));  -- 2 -> 1
+			assert.same(a, { balance = 0, max = 3, rate = 0.3, t = 5 });
+
+			assert.is_false(a:poll(1)); -- MEEP, out of credits!
+			assert.is_false(a:poll(1)); -- MEEP, out of credits!
+			assert.same(a, { balance = 0, max = 3, rate = 0.3, t = 5 });
+		end);
+
+		it("should not allow polls more than the cost", function()
+			now = 0;
+
+			local a = throttle.create(10, 10);
+			assert.same(a, { balance = 10, max = 10, rate = 1, t = 0 });
+
+			assert.is_false(a:poll(11));
+			assert.same(a, { balance = 10, max = 10, rate = 1, t = 0 });
+
+			assert.is_true(a:poll(6));
+			assert.same(a, { balance = 4, max = 10, rate = 1, t = 0 });
+
+			assert.is_false(a:poll(5));
+			assert.same(a, { balance = 4, max = 10, rate = 1, t = 0 });
+
+			-- fractional
+			assert.is_true(a:poll(3.5));
+			assert.same(a, { balance = 0.5, max = 10, rate = 1, t = 0 });
+
+			assert.is_true(a:poll(0.25));
+			assert.same(a, { balance = 0.25, max = 10, rate = 1, t = 0 });
+
+			assert.is_false(a:poll(0.3));
+			assert.same(a, { balance = 0.25, max = 10, rate = 1, t = 0 });
+
+			assert.is_true(a:poll(0.25));
+			assert.same(a, { balance = 0, max = 10, rate = 1, t = 0 });
+
+			assert.is_false(a:poll(0.1));
+			assert.same(a, { balance = 0, max = 10, rate = 1, t = 0 });
+
+			assert.is_true(a:poll(0));
+			assert.same(a, { balance = 0, max = 10, rate = 1, t = 0 });
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_time_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,31 @@
+describe("util.time", function ()
+	local time;
+	setup(function ()
+		time = require "util.time";
+	end);
+	describe("now()", function ()
+		it("exists", function ()
+			assert.is_function(time.now);
+		end);
+		it("returns a number", function ()
+			assert.is_number(time.now());
+		end);
+	end);
+	describe("monotonic()", function ()
+		it("exists", function ()
+			assert.is_function(time.monotonic);
+		end);
+		it("returns a number", function ()
+			assert.is_number(time.monotonic());
+		end);
+		it("time goes in one direction", function ()
+			local a = time.monotonic();
+			local b	= time.monotonic();
+			assert.truthy(a <= b);
+		end);
+	end);
+end);
+
+
+
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_uuid_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,25 @@
+-- This tests the format, not the randomness
+
+local uuid = require "util.uuid";
+
+describe("util.uuid", function()
+	describe("#generate()", function()
+		it("should work follow the UUID pattern", function()
+			-- https://tools.ietf.org/html/rfc4122#section-4.4
+
+			local pattern = "^" .. table.concat({
+				string.rep("%x", 8),
+				string.rep("%x", 4),
+				"4" .. -- version
+				string.rep("%x", 3),
+				"[89ab]" .. -- reserved bits of 1 and 0
+				string.rep("%x", 3),
+				string.rep("%x", 12),
+			}, "%-") .. "$";
+
+			for _ = 1, 100 do
+				assert.is_string(uuid.generate():match(pattern));
+			end
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_xml_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,20 @@
+
+local xml = require "util.xml";
+
+describe("util.xml", function()
+	describe("#parse()", function()
+		it("should work", function()
+			local x =
+[[<x xmlns:a="b">
+	<y xmlns:a="c"> <!-- this overwrites 'a' -->
+	    <a:z/>
+	</y>
+	<a:z/> <!-- prefix 'a' is nil here, but should be 'b' -->
+</x>
+]]
+			local stanza = xml.parse(x);
+			assert.are.equal(stanza.tags[2].attr.xmlns, "b");
+			assert.are.equal(stanza.tags[2].namespaces["a"], "b");
+		end);
+	end);
+end);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/spec/util_xmppstream_spec.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,136 @@
+
+local xmppstream = require "util.xmppstream";
+
+describe("util.xmppstream", function()
+	local function test(xml, expect_success, ex)
+		local stanzas = {};
+		local session = { notopen = true };
+		local callbacks = {
+			stream_ns = "streamns";
+			stream_tag = "stream";
+			default_ns = "stanzans";
+			streamopened = function (_session)
+				assert.are.equal(session, _session);
+				assert.are.equal(session.notopen, true);
+				_session.notopen = nil;
+				return true;
+			end;
+			handlestanza = function (_session, stanza)
+				assert.are.equal(session, _session);
+				assert.are.equal(_session.notopen, nil);
+				table.insert(stanzas, stanza);
+			end;
+			streamclosed = function (_session)
+				assert.are.equal(session, _session);
+				assert.are.equal(_session.notopen, nil);
+				_session.notopen = nil;
+			end;
+		}
+		if type(ex) == "table" then
+			for k, v in pairs(ex) do
+				if k ~= "_size_limit" then
+					callbacks[k] = v;
+				end
+			end
+		end
+		local stream = xmppstream.new(session, callbacks, ex and ex._size_limit or nil);
+		local ok, err = pcall(function ()
+			assert(stream:feed(xml));
+		end);
+
+		if ok and type(expect_success) == "function" then
+			expect_success(stanzas);
+		end
+		assert.are.equal(not not ok, not not expect_success, "Expected "..(expect_success and ("success ("..tostring(err)..")") or "failure"));
+	end
+
+	local function test_stanza(stanza, expect_success, ex)
+		return test([[<stream:stream xmlns:stream="streamns" xmlns="stanzans">]]..stanza, expect_success, ex);
+	end
+
+	describe("#new()", function()
+		it("should work", function()
+			test([[<stream:stream xmlns:stream="streamns"/>]], true);
+			test([[<stream xmlns="streamns"/>]], true);
+
+			-- Incorrect stream tag name should be rejected
+			test([[<stream1 xmlns="streamns"/>]], false);
+			-- Incorrect stream namespace should be rejected
+			test([[<stream xmlns="streamns1"/>]], false);
+			-- Invalid XML should be rejected
+			test("<>", false);
+
+			test_stanza("<message/>", function (stanzas)
+				assert.are.equal(#stanzas, 1);
+				assert.are.equal(stanzas[1].name, "message");
+			end);
+			test_stanza("< message>>>>/>\n", false);
+
+			test_stanza([[<x xmlns:a="b">
+				<y xmlns:a="c">
+					<a:z/>
+				</y>
+				<a:z/>
+			</x>]], function (stanzas)
+				assert.are.equal(#stanzas, 1);
+				local s = stanzas[1];
+				assert.are.equal(s.name, "x");
+				assert.are.equal(#s.tags, 2);
+
+				assert.are.equal(s.tags[1].name, "y");
+				assert.are.equal(s.tags[1].attr.xmlns, nil);
+
+				assert.are.equal(s.tags[1].tags[1].name, "z");
+				assert.are.equal(s.tags[1].tags[1].attr.xmlns, "c");
+
+				assert.are.equal(s.tags[2].name, "z");
+				assert.are.equal(s.tags[2].attr.xmlns, "b");
+
+				assert.are.equal(s.namespaces, nil);
+			end);
+		end);
+	end);
+
+	it("should allow an XML declaration", function ()
+		test([[<?xml version="1.0" encoding="UTF-8"?><stream xmlns="streamns"/>]], true);
+		test([[<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><stream xmlns="streamns"/>]], true);
+		test([[<?xml version="1.0" encoding="utf-8" ?><stream xmlns="streamns"/>]], true);
+	end);
+
+	it("should not accept XML versions other than 1.0", function ()
+		test([[<?xml version="1.1" encoding="utf-8" ?><stream xmlns="streamns"/>]], false);
+	end);
+
+	it("should not allow a misplaced XML declaration", function ()
+		test([[<stream xmlns="streamns"><?xml version="1.0" encoding="UTF-8"?></stream>]], false);
+	end);
+
+	describe("should forbid restricted XML:", function ()
+		it("comments", function ()
+			test_stanza("<!-- hello world -->", false);
+		end);
+		it("DOCTYPE", function ()
+			test([[<?xml version="1.0" encoding="UTF-8"?><!DOCTYPE stream SYSTEM "mydtd.dtd">]], false);
+		end);
+		it("incorrect encoding specification", function ()
+			-- This is actually caught by the underlying XML parser
+			test([[<?xml version="1.0" encoding="UTF-16"?><stream xmlns="streamns"/>]], false);
+		end);
+		it("non-UTF8 encodings: ISO-8859-1", function ()
+			test([[<?xml version="1.0" encoding="ISO-8859-1"?><stream xmlns="streamns"/>]], false);
+		end);
+		it("non-UTF8 encodings: UTF-16", function ()
+			-- <?xml version="1.0" encoding="UTF-16"?><stream xmlns="streamns"/>
+			-- encoded into UTF-16
+			local hx = ([[fffe3c003f0078006d006c002000760065007200730069006f006e003d00
+			220031002e0030002200200065006e0063006f00640069006e0067003d00
+			22005500540046002d003100360022003f003e003c007300740072006500
+			61006d00200078006d006c006e0073003d00220073007400720065006100
+			6d006e00730022002f003e00]]):gsub("%x%x", function (c) return string.char(tonumber(c, 16)); end);
+			test(hx, false);
+		end);
+		it("processing instructions", function ()
+			test([[<stream xmlns="streamns"><?xml-stylesheet type="text/xsl" href="style.xsl"?></stream>]], false);
+		end);
+	end);
+end);
--- a/tests/json/fail1.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-"A JSON payload should be an object or array, not a string."
\ No newline at end of file
--- a/tests/json/fail10.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-{"Extra value after close": true} "misplaced quoted value"
\ No newline at end of file
--- a/tests/json/fail11.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-{"Illegal expression": 1 + 2}
\ No newline at end of file
--- a/tests/json/fail12.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-{"Illegal invocation": alert()}
\ No newline at end of file
--- a/tests/json/fail13.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-{"Numbers cannot have leading zeroes": 013}
\ No newline at end of file
--- a/tests/json/fail14.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-{"Numbers cannot be hex": 0x14}
\ No newline at end of file
--- a/tests/json/fail15.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-["Illegal backslash escape: \x15"]
\ No newline at end of file
--- a/tests/json/fail16.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-[\naked]
\ No newline at end of file
--- a/tests/json/fail17.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-["Illegal backslash escape: \017"]
\ No newline at end of file
--- a/tests/json/fail18.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-[[[[[[[[[[[[[[[[[[[["Too deep"]]]]]]]]]]]]]]]]]]]]
\ No newline at end of file
--- a/tests/json/fail19.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-{"Missing colon" null}
\ No newline at end of file
--- a/tests/json/fail2.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-["Unclosed array"
\ No newline at end of file
--- a/tests/json/fail20.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-{"Double colon":: null}
\ No newline at end of file
--- a/tests/json/fail21.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-{"Comma instead of colon", null}
\ No newline at end of file
--- a/tests/json/fail22.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-["Colon instead of comma": false]
\ No newline at end of file
--- a/tests/json/fail23.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-["Bad value", truth]
\ No newline at end of file
--- a/tests/json/fail24.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-['single quote']
\ No newline at end of file
--- a/tests/json/fail25.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-["	tab	character	in	string	"]
\ No newline at end of file
--- a/tests/json/fail26.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-["tab\   character\   in\  string\  "]
\ No newline at end of file
--- a/tests/json/fail27.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2 +0,0 @@
-["line
-break"]
\ No newline at end of file
--- a/tests/json/fail28.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2 +0,0 @@
-["line\
-break"]
\ No newline at end of file
--- a/tests/json/fail29.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-[0e]
\ No newline at end of file
--- a/tests/json/fail3.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-{unquoted_key: "keys must be quoted"}
\ No newline at end of file
--- a/tests/json/fail30.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-[0e+]
\ No newline at end of file
--- a/tests/json/fail31.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-[0e+-1]
\ No newline at end of file
--- a/tests/json/fail32.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-{"Comma instead if closing brace": true,
\ No newline at end of file
--- a/tests/json/fail33.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-["mismatch"}
\ No newline at end of file
--- a/tests/json/fail4.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-["extra comma",]
\ No newline at end of file
--- a/tests/json/fail5.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-["double extra comma",,]
\ No newline at end of file
--- a/tests/json/fail6.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-[   , "<-- missing value"]
\ No newline at end of file
--- a/tests/json/fail7.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-["Comma after the close"],
\ No newline at end of file
--- a/tests/json/fail8.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-["Extra close"]]
\ No newline at end of file
--- a/tests/json/fail9.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-{"Extra comma": true,}
\ No newline at end of file
--- a/tests/json/pass1.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,58 +0,0 @@
-[
-    "JSON Test Pattern pass1",
-    {"object with 1 member":["array with 1 element"]},
-    {},
-    [],
-    -42,
-    true,
-    false,
-    null,
-    {
-        "integer": 1234567890,
-        "real": -9876.543210,
-        "e": 0.123456789e-12,
-        "E": 1.234567890E+34,
-        "":  23456789012E66,
-        "zero": 0,
-        "one": 1,
-        "space": " ",
-        "quote": "\"",
-        "backslash": "\\",
-        "controls": "\b\f\n\r\t",
-        "slash": "/ & \/",
-        "alpha": "abcdefghijklmnopqrstuvwyz",
-        "ALPHA": "ABCDEFGHIJKLMNOPQRSTUVWYZ",
-        "digit": "0123456789",
-        "0123456789": "digit",
-        "special": "`1~!@#$%^&*()_+-={':[,]}|;.</>?",
-        "hex": "\u0123\u4567\u89AB\uCDEF\uabcd\uef4A",
-        "true": true,
-        "false": false,
-        "null": null,
-        "array":[  ],
-        "object":{  },
-        "address": "50 St. James Street",
-        "url": "http://www.JSON.org/",
-        "comment": "// /* <!-- --",
-        "# -- --> */": " ",
-        " s p a c e d " :[1,2 , 3
-
-,
-
-4 , 5        ,          6           ,7        ],"compact":[1,2,3,4,5,6,7],
-        "jsontext": "{\"object with 1 member\":[\"array with 1 element\"]}",
-        "quotes": "&#34; \u0022 %22 0x22 034 &#x22;",
-        "\/\\\"\uCAFE\uBABE\uAB98\uFCDE\ubcda\uef4A\b\f\n\r\t`1~!@#$%^&*()_+-=[]{}|;:',./<>?"
-: "A key can be any string"
-    },
-    0.5 ,98.6
-,
-99.44
-,
-
-1066,
-1e1,
-0.1e1,
-1e-1,
-1e00,2e+00,2e-00
-,"rosebud"]
\ No newline at end of file
--- a/tests/json/pass2.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-[[[[[[[[[[[[[[[[[[["Not too deep"]]]]]]]]]]]]]]]]]]]
\ No newline at end of file
--- a/tests/json/pass3.json	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
-{
-    "JSON Test Pattern pass3": {
-        "The outermost value": "must be an object or array.",
-        "In this test": "It is an object."
-    }
-}
--- a/tests/modulemanager_option_conversion.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,55 +0,0 @@
-package.path = "../?.lua;"..package.path;
-
-local api = require "core.modulemanager".api;
-
-local module = setmetatable({}, {__index = api});
-local opt = nil;
-function module:log() end
-function module:get_option(name)
-	if name == "opt" then
-		return opt;
-	else
-		return nil;
-	end
-end
-
-function test_value(value, returns)
-	opt = value;
-	assert(module:get_option_number("opt") == returns.number, "number doesn't match");
-	assert(module:get_option_string("opt") == returns.string, "string doesn't match");
-	assert(module:get_option_boolean("opt") == returns.boolean, "boolean doesn't match");
-
-	if type(returns.array) == "table" then
-		local target_array, returned_array = returns.array, module:get_option_array("opt");
-		assert(#target_array == #returned_array, "array length doesn't match");
-		for i=1,#target_array do
-			assert(target_array[i] == returned_array[i], "array item doesn't match");
-		end
-	else
-		assert(module:get_option_array("opt") == returns.array, "array is returned (not nil)");
-	end
-
-	if type(returns.set) == "table" then
-		local target_items, returned_items = set.new(returns.set), module:get_option_set("opt");
-		assert(target_items == returned_items, "set doesn't match");
-	else
-		assert(module:get_option_set("opt") == returns.set, "set is returned (not nil)");
-	end
-end
-
-test_value(nil, {});
-
-test_value(true, { boolean = true, string = "true", array = {true}, set = {true} });
-test_value(false, { boolean = false, string = "false", array = {false}, set = {false} });
-test_value("true", { boolean = true, string = "true", array = {"true"}, set = {"true"} });
-test_value("false", { boolean = false, string = "false", array = {"false"}, set = {"false"} });
-test_value(1, { boolean = true, string = "1", array = {1}, set = {1}, number = 1 });
-test_value(0, { boolean = false, string = "0", array = {0}, set = {0}, number = 0 });
-
-test_value("hello world", { string = "hello world", array = {"hello world"}, set = {"hello world"} });
-test_value(1234, { string = "1234", number = 1234, array = {1234}, set = {1234} });
-
-test_value({1, 2, 3}, { boolean = true, string = "1", number = 1, array = {1, 2, 3}, set = {1, 2, 3} });
-test_value({1, 2, 3, 3, 4}, {boolean = true, string = "1", number = 1, array = {1, 2, 3, 3, 4}, set = {1, 2, 3, 4} });
-test_value({0, 1, 2, 3}, { boolean = false, string = "0", number = 0, array = {0, 1, 2, 3}, set = {0, 1, 2, 3} });
-
--- a/tests/reports/empty	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-This file was intentionally left blank.
--- a/tests/run_tests.bat	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,10 +0,0 @@
-@echo off
-
-set oldpath=%path%
-set path=%path%;..;..\lualibs
-
-del reports\*.report
-lua test.lua %*
-
-set path=%oldpath%
-set oldpath=
\ No newline at end of file
--- a/tests/run_tests.sh	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,3 +0,0 @@
-#!/bin/sh
-rm reports/*.report
-exec lua test.lua "$@"
--- a/tests/test.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,255 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-local tests_passed = true;
-
-function run_all_tests()
-	package.loaded["net.connlisteners"] = { get = function () return {} end };
-	dotest "util.jid"
-	dotest "util.multitable"
-	dotest "util.rfc6724"
-	dotest "util.http"
-	dotest "core.stanza_router"
-	dotest "core.s2smanager"
-	dotest "core.configmanager"
-	dotest "util.ip"
-	dotest "util.json"
-	dotest "util.stanza"
-	dotest "util.sasl.scram"
-	dotest "util.cache"
-	dotest "util.throttle"
-	dotest "util.uuid"
-	dotest "util.random"
-	dotest "util.xml"
-	dotest "util.xmppstream"
-	dotest "util.queue"
-	dotest "net.http.parser"
-
-	dosingletest("test_sasl.lua", "latin1toutf8");
-	dosingletest("test_utf8.lua", "valid");
-end
-
-local verbosity = tonumber(arg[1]) or 2;
-
-if os.getenv("WINDIR") then
-	package.path = package.path..";..\\?.lua";
-	package.cpath = package.cpath..";..\\?.dll";
-else
-	package.path = package.path..";../?.lua";
-	package.cpath = package.cpath..";../?.so";
-end
-
-local _realG = _G;
-
-require "util.import"
-
-local envloadfile = require "util.envload".envloadfile;
-
-local env_mt = { __index = function (t,k) return rawget(_realG, k) or print("WARNING: Attempt to access nil global '"..tostring(k).."'"); end };
-function testlib_new_env(t)
-	return setmetatable(t or {}, env_mt);
-end
-
-function assert_equal(a, b, message, level)
-	if not (a == b) then
-		error("\n   assert_equal failed: "..tostring(a).." ~= "..tostring(b)..(message and ("\n   Message: "..message) or ""), (level or 1) + 1);
-	elseif verbosity >= 4 then
-		print("assert_equal succeeded: "..tostring(a).." == "..tostring(b));
-	end
-end
-
-function assert_table(a, message, level)
-	assert_equal(type(a), "table", message, (level or 1) + 1);
-end
-function assert_function(a, message, level)
-	assert_equal(type(a), "function", message, (level or 1) + 1);
-end
-function assert_string(a, message, level)
-	assert_equal(type(a), "string", message, (level or 1) + 1);
-end
-function assert_boolean(a, message)
-	assert_equal(type(a), "boolean", message);
-end
-function assert_is(a, message)
-	assert_equal(not not a, true, message);
-end
-function assert_is_not(a, message)
-	assert_equal(not not a, false, message);
-end
-
-
-function dosingletest(testname, fname)
-	local tests = setmetatable({}, { __index = _realG });
-	tests.__unit = testname;
-	tests.__test = fname;
-	local chunk, err = envloadfile(testname, tests);
-	if not chunk then
-		print("WARNING: ", "Failed to load tests for "..testname, err);
-		return;
-	end
-
-	local success, err = pcall(chunk);
-	if not success then
-		print("WARNING: ", "Failed to initialise tests for "..testname, err);
-		return;
-	end
-
-	if type(tests[fname]) ~= "function" then
-		error(testname.." has no test '"..fname.."'", 0);
-	end
-
-
-	local line_hook, line_info = new_line_coverage_monitor(testname);
-	debug.sethook(line_hook, "l")
-	local success, ret = pcall(tests[fname]);
-	debug.sethook();
-	if not success then
-		tests_passed = false;
-		print("TEST FAILED! Unit: ["..testname.."] Function: ["..fname.."]");
-		print("   Location: "..ret:gsub(":%s*\n", "\n"));
-		line_info(fname, false, report_file);
-	elseif verbosity >= 2 then
-		print("TEST SUCCEEDED: ", testname, fname);
-		print(string.format("TEST COVERED %d/%d lines", line_info(fname, true, report_file)));
-	else
-		line_info(name, success, report_file);
-	end
-end
-
-function dotest(unitname)
-	local _fakeG = setmetatable({}, {__index = _realG});
-	_fakeG._G = _fakeG;
-	local tests = setmetatable({}, { __index = _fakeG });
-	tests.__unit = unitname;
-	local chunk, err = envloadfile("test_"..unitname:gsub("%.", "_")..".lua", tests);
-	if not chunk then
-		print("WARNING: ", "Failed to load tests for "..unitname, err);
-		return;
-	end
-
-	local success, err = pcall(chunk);
-	if not success then
-		print("WARNING: ", "Failed to initialise tests for "..unitname, err);
-		return;
-	end
-	if tests.env then setmetatable(tests.env, { __index = _realG }); end
-	local unit = setmetatable({}, { __index = setmetatable({ _G = tests.env or _fakeG }, { __index = tests.env or _fakeG }) });
-	local fn = "../"..unitname:gsub("%.", "/")..".lua";
-	local chunk, err = envloadfile(fn, unit);
-	if not chunk then
-		print("WARNING: ", "Failed to load module: "..unitname, err);
-		return;
-	end
-
-	local oldmodule, old_M = _fakeG.module, _fakeG._M;
-	_fakeG.module = function ()
-		setmetatable(unit, nil);
-		unit._M = unit;
-	end
-	local success, ret = pcall(chunk);
-	_fakeG.module, _fakeG._M = oldmodule, old_M;
-	if not success then
-		print("WARNING: ", "Failed to initialise module: "..unitname, ret);
-		return;
-	end
-
-	if type(ret) == "table" then
-		for k,v in pairs(ret) do
-			unit[k] = v;
-		end
-	end
-
-	for name, f in pairs(unit) do
-		local test = rawget(tests, name);
-		if type(f) ~= "function" then
-			if verbosity >= 3 then
-				print("INFO: ", "Skipping "..unitname.."."..name.." because it is not a function");
-			end
-		elseif type(test) ~= "function" then
-			if verbosity >= 1 then
-				print("WARNING: ", unitname.."."..name.." has no test!");
-			end
-		else
-			if verbosity >= 4 then
-				print("INFO: ", "Testing "..unitname.."."..name);
-			end
-			local line_hook, line_info = new_line_coverage_monitor(fn);
-			debug.sethook(line_hook, "l")
-			local success, ret = pcall(test, f, unit);
-			debug.sethook();
-			if not success then
-				tests_passed = false;
-				print("TEST FAILED! Unit: ["..unitname.."] Function: ["..name.."]");
-				print("   Location: "..ret:gsub(":%s*\n", "\n"));
-				line_info(name, false, report_file);
-			elseif verbosity >= 2 then
-				print("TEST SUCCEEDED: ", unitname, name);
-				print(string.format("TEST COVERED %d/%d lines", line_info(name, true, report_file)));
-			else
-				line_info(name, success, report_file);
-			end
-		end
-	end
-end
-
-function runtest(f, msg)
-	if not f then print("SUBTEST NOT FOUND: "..(msg or "(no description)")); return; end
-	local success, ret = pcall(f);
-	if success and verbosity >= 2 then
-		print("SUBTEST PASSED: "..(msg or "(no description)"));
-	elseif (not success) and verbosity >= 0 then
-		tests_passed = false;
-		print("SUBTEST FAILED: "..(msg or "(no description)"));
-		error(ret, 0);
-	end
-end
-
-function new_line_coverage_monitor(file)
-	local lines_hit, funcs_hit = {}, {};
-	local total_lines, covered_lines = 0, 0;
-
-	for line in io.lines(file) do
-		total_lines = total_lines + 1;
-	end
-
-	return function (event, line) -- Line hook
-			if not lines_hit[line] then
-				local info = debug.getinfo(2, "fSL")
-				if not info.source:find(file) then return; end
-				if not funcs_hit[info.func] and info.activelines then
-					funcs_hit[info.func] = true;
-					for line in pairs(info.activelines) do
-						lines_hit[line] = false; -- Marks it as hittable, but not hit yet
-					end
-				end
-				if lines_hit[line] == false then
-					--print("New line hit: "..line.." in "..debug.getinfo(2, "S").source);
-					lines_hit[line] = true;
-					covered_lines = covered_lines + 1;
-				end
-			end
-		end,
-		function (test_name, success) -- Get info
-			local fn = file:gsub("^%W*", "");
-			local total_active_lines = 0;
-			local coverage_file = io.open("reports/coverage_"..fn:gsub("%W+", "_")..".report", "a+");
-			for line, active in pairs(lines_hit) do
-				if active ~= nil then total_active_lines = total_active_lines + 1; end
-				if coverage_file then
-					if active == false then coverage_file:write(fn, "|", line, "|", name or "", "|miss\n");
-					else coverage_file:write(fn, "|", line, "|", name or "", "|", tostring(success), "\n"); end
-				end
-			end
-			if coverage_file then coverage_file:close(); end
-			return covered_lines, total_active_lines, lines_hit;
-		end
-end
-
-run_all_tests()
-
-os.exit(tests_passed and 0 or 1);
--- a/tests/test_core_configmanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,33 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-
-
-function get(get, config)
-	config.set("example.com", "testkey", 123);
-	assert_equal(get("example.com", "testkey"), 123, "Retrieving a set key");
-
-	config.set("*", "testkey1", 321);
-	assert_equal(get("*", "testkey1"), 321, "Retrieving a set global key");
-	assert_equal(get("example.com", "testkey1"), 321, "Retrieving a set key of undefined host, of which only a globally set one exists");
-
-	config.set("example.com", ""); -- Creates example.com host in config
-	assert_equal(get("example.com", "testkey1"), 321, "Retrieving a set key, of which only a globally set one exists");
-
-	assert_equal(get(), nil, "No parameters to get()");
-	assert_equal(get("undefined host"), nil, "Getting for undefined host");
-	assert_equal(get("undefined host", "undefined key"), nil, "Getting for undefined host & key");
-end
-
-function set(set, u)
-	assert_equal(set("*"), false, "Set with no key");
-
-	assert_equal(set("*", "set_test", "testkey"), true, "Setting a nil global value");
-	assert_equal(set("*", "set_test", "testkey", 123), true, "Setting a global value");
-end
-
--- a/tests/test_core_s2smanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,50 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-env = {
-	prosody = { events = require "util.events".new() };
-};
-
-function compare_srv_priorities(csp)
-	local r1 = { priority = 10, weight = 0 }
-	local r2 = { priority = 100, weight = 0 }
-	local r3 = { priority = 1000, weight = 2 }
-	local r4 = { priority = 1000, weight = 2 }
-	local r5 = { priority = 1000, weight = 5 }
-
-	assert_equal(csp(r1, r1), false);
-	assert_equal(csp(r1, r2), true);
-	assert_equal(csp(r1, r3), true);
-	assert_equal(csp(r1, r4), true);
-	assert_equal(csp(r1, r5), true);
-
-	assert_equal(csp(r2, r1), false);
-	assert_equal(csp(r2, r2), false);
-	assert_equal(csp(r2, r3), true);
-	assert_equal(csp(r2, r4), true);
-	assert_equal(csp(r2, r5), true);
-
-	assert_equal(csp(r3, r1), false);
-	assert_equal(csp(r3, r2), false);
-	assert_equal(csp(r3, r3), false);
-	assert_equal(csp(r3, r4), false);
-	assert_equal(csp(r3, r5), false);
-
-	assert_equal(csp(r4, r1), false);
-	assert_equal(csp(r4, r2), false);
-	assert_equal(csp(r4, r3), false);
-	assert_equal(csp(r4, r4), false);
-	assert_equal(csp(r4, r5), false);
-
-	assert_equal(csp(r5, r1), false);
-	assert_equal(csp(r5, r2), false);
-	assert_equal(csp(r5, r3), true);
-	assert_equal(csp(r5, r4), true);
-	assert_equal(csp(r5, r5), false);
-
-end
--- a/tests/test_core_stanza_router.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,232 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-_G.prosody = { full_sessions = {}; bare_sessions = {}; hosts = {}; };
-
-function core_process_stanza(core_process_stanza, u)
-	local stanza = require "util.stanza";
-	local s2sout_session = { to_host = "remotehost", from_host = "localhost", type = "s2sout" }
-	local s2sin_session = { from_host = "remotehost", to_host = "localhost", type = "s2sin", hosts = { ["remotehost"] = { authed = true } } }
-	local local_host_session = { host = "localhost", type = "local", s2sout = { ["remotehost"] = s2sout_session } }
-	local local_user_session = { username = "user", host = "localhost", resource = "resource", full_jid = "user@localhost/resource", type = "c2s" }
-
-	_G.prosody.hosts["localhost"] = local_host_session;
-	_G.prosody.full_sessions["user@localhost/resource"] = local_user_session;
-	_G.prosody.bare_sessions["user@localhost"] = { sessions = { resource = local_user_session } };
-
-	-- Test message routing
-	local function test_message_full_jid()
-		local env = testlib_new_env();
-		local msg = stanza.stanza("message", { to = "user@localhost/resource", type = "chat" }):tag("body"):text("Hello world");
-
-		local target_routed;
-
-		function env.core_post_stanza(p_origin, p_stanza)
-			assert_equal(p_origin, local_user_session, "origin of routed stanza is not correct");
-			assert_equal(p_stanza, msg, "routed stanza is not correct one: "..p_stanza:pretty_print());
-			target_routed = true;
-		end
-
-		env.hosts = hosts;
-		env.prosody = { hosts = hosts };
-		setfenv(core_process_stanza, env);
-		assert_equal(core_process_stanza(local_user_session, msg), nil, "core_process_stanza returned incorrect value");
-		assert_equal(target_routed, true, "stanza was not routed successfully");
-	end
-
-	local function test_message_bare_jid()
-		local env = testlib_new_env();
-		local msg = stanza.stanza("message", { to = "user@localhost", type = "chat" }):tag("body"):text("Hello world");
-
-		local target_routed;
-
-		function env.core_post_stanza(p_origin, p_stanza)
-			assert_equal(p_origin, local_user_session, "origin of routed stanza is not correct");
-			assert_equal(p_stanza, msg, "routed stanza is not correct one: "..p_stanza:pretty_print());
-			target_routed = true;
-		end
-
-		env.hosts = hosts;
-		setfenv(core_process_stanza, env);
-		assert_equal(core_process_stanza(local_user_session, msg), nil, "core_process_stanza returned incorrect value");
-		assert_equal(target_routed, true, "stanza was not routed successfully");
-	end
-
-	local function test_message_no_to()
-		local env = testlib_new_env();
-		local msg = stanza.stanza("message", { type = "chat" }):tag("body"):text("Hello world");
-
-		local target_handled;
-
-		function env.core_post_stanza(p_origin, p_stanza)
-			assert_equal(p_origin, local_user_session, "origin of handled stanza is not correct");
-			assert_equal(p_stanza, msg, "handled stanza is not correct one: "..p_stanza:pretty_print());
-			target_handled = true;
-		end
-
-		env.hosts = hosts;
-		setfenv(core_process_stanza, env);
-		assert_equal(core_process_stanza(local_user_session, msg), nil, "core_process_stanza returned incorrect value");
-		assert_equal(target_handled, true, "stanza was not handled successfully");
-	end
-
-	local function test_message_to_remote_bare()
-		local env = testlib_new_env();
-		local msg = stanza.stanza("message", { to = "user@remotehost", type = "chat" }):tag("body"):text("Hello world");
-
-		local target_routed;
-
-		function env.core_route_stanza(p_origin, p_stanza)
-			assert_equal(p_origin, local_user_session, "origin of handled stanza is not correct");
-			assert_equal(p_stanza, msg, "handled stanza is not correct one: "..p_stanza:pretty_print());
-			target_routed = true;
-		end
-
-		function env.core_post_stanza(...) env.core_route_stanza(...); end
-
-		env.hosts = hosts;
-		setfenv(core_process_stanza, env);
-		assert_equal(core_process_stanza(local_user_session, msg), nil, "core_process_stanza returned incorrect value");
-		assert_equal(target_routed, true, "stanza was not routed successfully");
-	end
-
-	local function test_message_to_remote_server()
-		local env = testlib_new_env();
-		local msg = stanza.stanza("message", { to = "remotehost", type = "chat" }):tag("body"):text("Hello world");
-
-		local target_routed;
-
-		function env.core_route_stanza(p_origin, p_stanza)
-			assert_equal(p_origin, local_user_session, "origin of handled stanza is not correct");
-			assert_equal(p_stanza, msg, "handled stanza is not correct one: "..p_stanza:pretty_print());
-			target_routed = true;
-		end
-
-		function env.core_post_stanza(...)
-			env.core_route_stanza(...);
-		end
-
-		env.hosts = hosts;
-		setfenv(core_process_stanza, env);
-		assert_equal(core_process_stanza(local_user_session, msg), nil, "core_process_stanza returned incorrect value");
-		assert_equal(target_routed, true, "stanza was not routed successfully");
-	end
-
-	--IQ tests
-
-
-	local function test_iq_to_remote_server()
-		local env = testlib_new_env();
-		local msg = stanza.stanza("iq", { to = "remotehost", type = "get", id = "id" }):tag("body"):text("Hello world");
-
-		local target_routed;
-
-		function env.core_route_stanza(p_origin, p_stanza)
-			assert_equal(p_origin, local_user_session, "origin of handled stanza is not correct");
-			assert_equal(p_stanza, msg, "handled stanza is not correct one: "..p_stanza:pretty_print());
-			target_routed = true;
-		end
-
-		function env.core_post_stanza(...)
-			env.core_route_stanza(...);
-		end
-
-		env.hosts = hosts;
-		setfenv(core_process_stanza, env);
-		assert_equal(core_process_stanza(local_user_session, msg), nil, "core_process_stanza returned incorrect value");
-		assert_equal(target_routed, true, "stanza was not routed successfully");
-	end
-
-	local function test_iq_error_to_local_user()
-		local env = testlib_new_env();
-		local msg = stanza.stanza("iq", { to = "user@localhost/resource", from = "user@remotehost", type = "error", id = "id" }):tag("error", { type = 'cancel' }):tag("item-not-found", { xmlns='urn:ietf:params:xml:ns:xmpp-stanzas' });
-
-		local target_routed;
-
-		function env.core_route_stanza(p_origin, p_stanza)
-			assert_equal(p_origin, s2sin_session, "origin of handled stanza is not correct");
-			assert_equal(p_stanza, msg, "handled stanza is not correct one: "..p_stanza:pretty_print());
-			target_routed = true;
-		end
-
-		function env.core_post_stanza(...)
-			env.core_route_stanza(...);
-		end
-
-		env.hosts = hosts;
-		setfenv(core_process_stanza, env);
-		assert_equal(core_process_stanza(s2sin_session, msg), nil, "core_process_stanza returned incorrect value");
-		assert_equal(target_routed, true, "stanza was not routed successfully");
-	end
-
-	local function test_iq_to_local_bare()
-		local env = testlib_new_env();
-		local msg = stanza.stanza("iq", { to = "user@localhost", from = "user@localhost", type = "get", id = "id" }):tag("ping", { xmlns = "urn:xmpp:ping:0" });
-
-		local target_handled;
-
-		function env.core_post_stanza(p_origin, p_stanza)
-			assert_equal(p_origin, local_user_session, "origin of handled stanza is not correct");
-			assert_equal(p_stanza, msg, "handled stanza is not correct one: "..p_stanza:pretty_print());
-			target_handled = true;
-		end
-
-		env.hosts = hosts;
-		setfenv(core_process_stanza, env);
-		assert_equal(core_process_stanza(local_user_session, msg), nil, "core_process_stanza returned incorrect value");
-		assert_equal(target_handled, true, "stanza was not handled successfully");
-	end
-
-	runtest(test_message_full_jid, "Messages with full JID destinations get routed");
-	runtest(test_message_bare_jid, "Messages with bare JID destinations get routed");
-	runtest(test_message_no_to, "Messages with no destination are handled by the server");
-	runtest(test_message_to_remote_bare, "Messages to a remote user are routed by the server");
-	runtest(test_message_to_remote_server, "Messages to a remote server's JID are routed");
-
-	runtest(test_iq_to_remote_server, "iq to a remote server's JID are routed");
-	runtest(test_iq_to_local_bare, "iq from a local user to a local user's bare JID are handled");
-	runtest(test_iq_error_to_local_user, "iq type=error to a local user's JID are routed");
-end
-
-function core_route_stanza(core_route_stanza)
-	local stanza = require "util.stanza";
-	local s2sout_session = { to_host = "remotehost", from_host = "localhost", type = "s2sout" }
-	local s2sin_session = { from_host = "remotehost", to_host = "localhost", type = "s2sin", hosts = { ["remotehost"] = { authed = true } } }
-	local local_host_session = { host = "localhost", type = "local", s2sout = { ["remotehost"] = s2sout_session }, sessions = {} }
-	local local_user_session = { username = "user", host = "localhost", resource = "resource", full_jid = "user@localhost/resource", type = "c2s" }
-	local hosts = {
-			["localhost"] = local_host_session;
-			}
-
-	local function test_iq_result_to_offline_user()
-		local env = testlib_new_env();
-		local msg = stanza.stanza("iq", { to = "user@localhost/foo", from = "user@localhost", type = "result" }):tag("ping", { xmlns = "urn:xmpp:ping:0" });
-		local msg2 = stanza.stanza("iq", { to = "user@localhost/foo", from = "user@localhost", type = "error" }):tag("ping", { xmlns = "urn:xmpp:ping:0" });
-		--package.loaded["core.usermanager"] = { user_exists = function (user, host) print("RAR!") return true or user == "user" and host == "localhost" and true; end };
-		local target_handled, target_replied;
-
-		function env.core_post_stanza(p_origin, p_stanza)
-			target_handled = true;
-		end
-
-		function local_user_session.send(data)
-			--print("Replying with: ", tostring(data));
-			--print(debug.traceback())
-			target_replied = true;
-		end
-
-		env.hosts = hosts;
-		setfenv(core_route_stanza, env);
-		assert_equal(core_route_stanza(local_user_session, msg), nil, "core_route_stanza returned incorrect value");
-		assert_equal(target_handled, nil, "stanza was handled and not dropped");
-		assert_equal(target_replied, nil, "stanza was replied to and not dropped");
-		package.loaded["core.usermanager"] = nil;
-	end
-
-	--runtest(test_iq_result_to_offline_user, "iq type=result|error to an offline user are not replied to");
-end
--- a/tests/test_net_http_parser.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,47 +0,0 @@
-local httpstreams = { [[
-GET / HTTP/1.1
-Host: example.com
-
-]], [[
-HTTP/1.1 200 OK
-Content-Length: 0
-
-]], [[
-HTTP/1.1 200 OK
-Content-Length: 7
-
-Hello
-HTTP/1.1 200 OK
-Transfer-Encoding: chunked
-
-1
-H
-1
-e
-2
-ll
-1
-o
-0
-
-
-]]
-}
-
-function new(new)
-
-	for _, stream in ipairs(httpstreams) do
-		local success;
-		local function success_cb(packet)
-			success = true;
-		end
-		stream = stream:gsub("\n", "\r\n");
-		local parser = new(success_cb, error, stream:sub(1,4) == "HTTP" and "client" or "server")
-		for chunk in stream:gmatch("..?.?") do
-			parser:feed(chunk);
-		end
-
-		assert_is(success);
-	end
-
-end
--- a/tests/test_sasl.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,38 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-local gmatch = string.gmatch;
-local t_concat, t_insert = table.concat, table.insert;
-local to_byte, to_char = string.byte, string.char;
-
-local function _latin1toutf8(str)
-	if not str then return str; end
-	local p = {};
-	for ch in gmatch(str, ".") do
-		ch = to_byte(ch);
-		if (ch < 0x80) then
-			t_insert(p, to_char(ch));
-		elseif (ch < 0xC0) then
-			t_insert(p, to_char(0xC2, ch));
-		else
-			t_insert(p, to_char(0xC3, ch - 64));
-		end
-	end
-	return t_concat(p);
-end
-
-function latin1toutf8()
-	local function assert_utf8(latin, utf8)
-			assert_equal(_latin1toutf8(latin), utf8, "Incorrect UTF8 from Latin1: "..tostring(latin));
-	end
-
-	assert_utf8("", "")
-	assert_utf8("test", "test")
-	assert_utf8(nil, nil)
-	assert_utf8("foobar.r\229kat.se", "foobar.r\195\165kat.se")
-end
--- a/tests/test_utf8.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,18 +0,0 @@
-package.cpath = "../?.so"
-package.path = "../?.lua";
-
-function valid()
-	local encodings = require "util.encodings";
-	local utf8 = assert(encodings.utf8, "no encodings.utf8 module");
-
-	for line in io.lines("utf8_sequences.txt") do
-		local data = line:match(":%s*([^#]+)"):gsub("%s+", ""):gsub("..", function (c) return string.char(tonumber(c, 16)); end)
-		local expect = line:match("(%S+):");
-		if expect ~= "pass" and expect ~= "fail" then
-			error("unknown expectation: "..line:match("^[^:]+"));
-		end
-		local valid = utf8.valid(data);
-		assert_equal(valid, utf8.valid(data.." "));
-		assert_equal(valid, expect == "pass", line);
-	end
-end
--- a/tests/test_util_cache.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,309 +0,0 @@
-function new(new)
-	local c = new(5);
-
-	local function expect_kv(key, value, actual_key, actual_value)
-		assert_equal(key, actual_key, "key incorrect");
-		assert_equal(value, actual_value, "value incorrect");
-	end
-
-	expect_kv(nil, nil, c:head());
-	expect_kv(nil, nil, c:tail());
-
-	assert_equal(c:count(), 0);
-
-	c:set("one", 1)
-	assert_equal(c:count(), 1);
-	expect_kv("one", 1, c:head());
-	expect_kv("one", 1, c:tail());
-
-	c:set("two", 2)
-	expect_kv("two", 2, c:head());
-	expect_kv("one", 1, c:tail());
-
-	c:set("three", 3)
-	expect_kv("three", 3, c:head());
-	expect_kv("one", 1, c:tail());
-
-	c:set("four", 4)
-	c:set("five", 5);
-	assert_equal(c:count(), 5);
-	expect_kv("five", 5, c:head());
-	expect_kv("one", 1, c:tail());
-
-	c:set("foo", nil);
-	assert_equal(c:count(), 5);
-	expect_kv("five", 5, c:head());
-	expect_kv("one", 1, c:tail());
-
-	assert_equal(c:get("one"), 1);
-	expect_kv("five", 5, c:head());
-	expect_kv("one", 1, c:tail());
-
-	assert_equal(c:get("two"), 2);
-	assert_equal(c:get("three"), 3);
-	assert_equal(c:get("four"), 4);
-	assert_equal(c:get("five"), 5);
-
-	assert_equal(c:get("foo"), nil);
-	assert_equal(c:get("bar"), nil);
-
-	c:set("six", 6);
-	assert_equal(c:count(), 5);
-	expect_kv("six", 6, c:head());
-	expect_kv("two", 2, c:tail());
-
-	assert_equal(c:get("one"), nil);
-	assert_equal(c:get("two"), 2);
-	assert_equal(c:get("three"), 3);
-	assert_equal(c:get("four"), 4);
-	assert_equal(c:get("five"), 5);
-	assert_equal(c:get("six"), 6);
-
-	c:set("three", nil);
-	assert_equal(c:count(), 4);
-
-	assert_equal(c:get("one"), nil);
-	assert_equal(c:get("two"), 2);
-	assert_equal(c:get("three"), nil);
-	assert_equal(c:get("four"), 4);
-	assert_equal(c:get("five"), 5);
-	assert_equal(c:get("six"), 6);
-
-	c:set("seven", 7);
-	assert_equal(c:count(), 5);
-
-	assert_equal(c:get("one"), nil);
-	assert_equal(c:get("two"), 2);
-	assert_equal(c:get("three"), nil);
-	assert_equal(c:get("four"), 4);
-	assert_equal(c:get("five"), 5);
-	assert_equal(c:get("six"), 6);
-	assert_equal(c:get("seven"), 7);
-
-	c:set("eight", 8);
-	assert_equal(c:count(), 5);
-
-	assert_equal(c:get("one"), nil);
-	assert_equal(c:get("two"), nil);
-	assert_equal(c:get("three"), nil);
-	assert_equal(c:get("four"), 4);
-	assert_equal(c:get("five"), 5);
-	assert_equal(c:get("six"), 6);
-	assert_equal(c:get("seven"), 7);
-	assert_equal(c:get("eight"), 8);
-
-	c:set("four", 4);
-	assert_equal(c:count(), 5);
-
-	assert_equal(c:get("one"), nil);
-	assert_equal(c:get("two"), nil);
-	assert_equal(c:get("three"), nil);
-	assert_equal(c:get("four"), 4);
-	assert_equal(c:get("five"), 5);
-	assert_equal(c:get("six"), 6);
-	assert_equal(c:get("seven"), 7);
-	assert_equal(c:get("eight"), 8);
-
-	c:set("nine", 9);
-	assert_equal(c:count(), 5);
-
-	assert_equal(c:get("one"), nil);
-	assert_equal(c:get("two"), nil);
-	assert_equal(c:get("three"), nil);
-	assert_equal(c:get("four"), 4);
-	assert_equal(c:get("five"), nil);
-	assert_equal(c:get("six"), 6);
-	assert_equal(c:get("seven"), 7);
-	assert_equal(c:get("eight"), 8);
-	assert_equal(c:get("nine"), 9);
-
-	do
-		local keys = { "nine", "four", "eight", "seven", "six" };
-		local values = { 9, 4, 8, 7, 6 };
-		local i = 0;
-		for k, v in c:items() do
-			i = i + 1;
-			assert_equal(k, keys[i]);
-			assert_equal(v, values[i]);
-		end
-		assert_equal(i, 5);
-
-		c:set("four", "2+2");
-		assert_equal(c:count(), 5);
-
-		assert_equal(c:get("one"), nil);
-		assert_equal(c:get("two"), nil);
-		assert_equal(c:get("three"), nil);
-		assert_equal(c:get("four"), "2+2");
-		assert_equal(c:get("five"), nil);
-		assert_equal(c:get("six"), 6);
-		assert_equal(c:get("seven"), 7);
-		assert_equal(c:get("eight"), 8);
-		assert_equal(c:get("nine"), 9);
-	end
-
-	do
-		local keys = { "four", "nine", "eight", "seven", "six" };
-		local values = { "2+2", 9, 8, 7, 6 };
-		local i = 0;
-		for k, v in c:items() do
-			i = i + 1;
-			assert_equal(k, keys[i]);
-			assert_equal(v, values[i]);
-		end
-		assert_equal(i, 5);
-
-		c:set("foo", nil);
-		assert_equal(c:count(), 5);
-
-		assert_equal(c:get("one"), nil);
-		assert_equal(c:get("two"), nil);
-		assert_equal(c:get("three"), nil);
-		assert_equal(c:get("four"), "2+2");
-		assert_equal(c:get("five"), nil);
-		assert_equal(c:get("six"), 6);
-		assert_equal(c:get("seven"), 7);
-		assert_equal(c:get("eight"), 8);
-		assert_equal(c:get("nine"), 9);
-	end
-
-	do
-		local keys = { "four", "nine", "eight", "seven", "six" };
-		local values = { "2+2", 9, 8, 7, 6 };
-		local i = 0;
-		for k, v in c:items() do
-			i = i + 1;
-			assert_equal(k, keys[i]);
-			assert_equal(v, values[i]);
-		end
-		assert_equal(i, 5);
-
-		c:set("four", nil);
-
-		assert_equal(c:get("one"), nil);
-		assert_equal(c:get("two"), nil);
-		assert_equal(c:get("three"), nil);
-		assert_equal(c:get("four"), nil);
-		assert_equal(c:get("five"), nil);
-		assert_equal(c:get("six"), 6);
-		assert_equal(c:get("seven"), 7);
-		assert_equal(c:get("eight"), 8);
-		assert_equal(c:get("nine"), 9);
-	end
-
-	do
-		local keys = { "nine", "eight", "seven", "six" };
-		local values = { 9, 8, 7, 6 };
-		local i = 0;
-		for k, v in c:items() do
-			i = i + 1;
-			assert_equal(k, keys[i]);
-			assert_equal(v, values[i]);
-		end
-		assert_equal(i, 4);
-	end
-
-	do
-		local evicted_key, evicted_value;
-		local c2 = new(3, function (_key, _value)
-			evicted_key, evicted_value = _key, _value;
-		end);
-		local function set(k, v, should_evict_key, should_evict_value)
-			evicted_key, evicted_value = nil, nil;
-			c2:set(k, v);
-			assert_equal(evicted_key, should_evict_key);
-			assert_equal(evicted_value, should_evict_value);
-		end
-		set("a", 1)
-		set("a", 1)
-		set("a", 1)
-		set("a", 1)
-		set("a", 1)
-
-		set("b", 2)
-		set("c", 3)
-		set("b", 2)
-		set("d", 4, "a", 1)
-		set("e", 5, "c", 3)
-	end
-
-	do
-		local evicted_key, evicted_value;
-		local c3 = new(1, function (_key, _value)
-			evicted_key, evicted_value = _key, _value;
-			if _key == "a" then
-				-- Sanity check for what we're evicting
-				assert_equal(_key, "a");
-				assert_equal(_value, 1);
-				-- We're going to block eviction of this key/value, so set to nil...
-				evicted_key, evicted_value = nil, nil;
-				-- Returning false to block eviction
-				return false
-			end
-		end);
-		local function set(k, v, should_evict_key, should_evict_value)
-			evicted_key, evicted_value = nil, nil;
-			local ret = c3:set(k, v);
-			assert_equal(evicted_key, should_evict_key);
-			assert_equal(evicted_value, should_evict_value);
-			return ret;
-		end
-		set("a", 1)
-		set("a", 1)
-		set("a", 1)
-		set("a", 1)
-		set("a", 1)
-
-		-- Our on_evict prevents "a" from being evicted, causing this to fail...
-		assert_equal(set("b", 2), false, "Failed to prevent eviction, or signal result");
-
-		expect_kv("a", 1, c3:head());
-		expect_kv("a", 1, c3:tail());
-
-		-- Check the final state is what we expect
-		assert_equal(c3:get("a"), 1);
-		assert_equal(c3:get("b"), nil);
-		assert_equal(c3:count(), 1);
-	end
-
-
-	local c4 = new(3, false);
-
-	assert_equal(c4:set("a", 1), true);
-	assert_equal(c4:set("a", 1), true);
-	assert_equal(c4:set("a", 1), true);
-	assert_equal(c4:set("a", 1), true);
-	assert_equal(c4:set("b", 2), true);
-	assert_equal(c4:set("c", 3), true);
-	assert_equal(c4:set("d", 4), false);
-	assert_equal(c4:set("d", 4), false);
-	assert_equal(c4:set("d", 4), false);
-
-	expect_kv("c", 3, c4:head());
-	expect_kv("a", 1, c4:tail());
-
-	local c5 = new(3, function (k, v)
-		if k == "a" then
-			return nil;
-		elseif k == "b" then
-			return true;
-		end
-		return false;
-	end);
-
-	assert_equal(c5:set("a", 1), true);
-	assert_equal(c5:set("a", 1), true);
-	assert_equal(c5:set("a", 1), true);
-	assert_equal(c5:set("a", 1), true);
-	assert_equal(c5:set("b", 2), true);
-	assert_equal(c5:set("c", 3), true);
-	assert_equal(c5:set("d", 4), true); -- "a" evicted (cb returned nil)
-	assert_equal(c5:set("d", 4), true); -- nop
-	assert_equal(c5:set("d", 4), true); -- nop
-	assert_equal(c5:set("e", 5), true); -- "b" evicted (cb returned true)
-	assert_equal(c5:set("f", 6), false); -- "c" won't evict (cb returned false)
-
-	expect_kv("e", 5, c5:head());
-	expect_kv("c", 3, c5:tail());
-
-end
--- a/tests/test_util_http.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,41 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-function urlencode(urlencode)
-	assert_equal(urlencode("helloworld123"), "helloworld123", "Normal characters not escaped");
-	assert_equal(urlencode("hello world"), "hello%20world", "Spaces escaped");
-	assert_equal(urlencode("This & that = something"), "This%20%26%20that%20%3d%20something", "Important URL chars escaped");
-end
-
-function urldecode(urldecode)
-	assert_equal("helloworld123", urldecode("helloworld123"), "Normal characters not escaped");
-	assert_equal("hello world", urldecode("hello%20world"), "Spaces escaped");
-	assert_equal("This & that = something", urldecode("This%20%26%20that%20%3d%20something"), "Important URL chars escaped");
-	assert_equal("This & that = something", urldecode("This%20%26%20that%20%3D%20something"), "Important URL chars escaped");
-end
-
-function formencode(formencode)
-	assert_equal(formencode({ { name = "one", value = "1"}, { name = "two", value = "2" } }), "one=1&two=2", "Form encoded");
-	assert_equal(formencode({ { name = "one two", value = "1"}, { name = "two one&", value = "2" } }), "one+two=1&two+one%26=2", "Form encoded");
-end
-
-function formdecode(formdecode)
-	do
-		local t = formdecode("one=1&two=2");
-		assert_table(t[1]);
-		assert_equal(t[1].name, "one"); assert_equal(t[1].value, "1");
-		assert_table(t[2]);
-		assert_equal(t[2].name, "two"); assert_equal(t[2].value, "2");
-	end
-
-	do
-		local t = formdecode("one+two=1&two+one%26=2");
-		assert_equal(t[1].name, "one two"); assert_equal(t[1].value, "1");
-		assert_equal(t[2].name, "two one&"); assert_equal(t[2].value, "2");
-	end
-end
--- a/tests/test_util_ip.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,89 +0,0 @@
-
-function match(match, _M)
-	local _ = _M.new_ip;
-	local ip = _"10.20.30.40";
-	assert_equal(match(ip, _"10.0.0.0", 8), true);
-	assert_equal(match(ip, _"10.0.0.0", 16), false);
-	assert_equal(match(ip, _"10.0.0.0", 24), false);
-	assert_equal(match(ip, _"10.0.0.0", 32), false);
-
-	assert_equal(match(ip, _"10.20.0.0", 8), true);
-	assert_equal(match(ip, _"10.20.0.0", 16), true);
-	assert_equal(match(ip, _"10.20.0.0", 24), false);
-	assert_equal(match(ip, _"10.20.0.0", 32), false);
-
-	assert_equal(match(ip, _"0.0.0.0", 32), false);
-	assert_equal(match(ip, _"0.0.0.0", 0), true);
-	assert_equal(match(ip, _"0.0.0.0"), false);
-
-	assert_equal(match(ip, _"10.0.0.0", 255), false, "excessive number of bits");
-	assert_equal(match(ip, _"10.0.0.0", -8), true, "negative number of bits");
-	assert_equal(match(ip, _"10.0.0.0", -32), true, "negative number of bits");
-	assert_equal(match(ip, _"10.0.0.0", 0), true, "zero bits");
-	assert_equal(match(ip, _"10.0.0.0"), false, "no specified number of bits (differing ip)");
-	assert_equal(match(ip, _"10.20.30.40"), true, "no specified number of bits (same ip)");
-
-	assert_equal(match(_"127.0.0.1", _"127.0.0.1"), true, "simple ip");
-
-	assert_equal(match(_"8.8.8.8", _"8.8.0.0", 16), true);
-	assert_equal(match(_"8.8.4.4", _"8.8.0.0", 16), true);
-end
-
-function parse_cidr(parse_cidr, _M)
-	local new_ip = _M.new_ip;
-
-	assert_equal(new_ip"0.0.0.0", new_ip"0.0.0.0")
-
-	local function assert_cidr(cidr, ip, bits)
-		local parsed_ip, parsed_bits = parse_cidr(cidr);
-		assert_equal(new_ip(ip), parsed_ip, cidr.." parsed ip is "..ip);
-		assert_equal(bits, parsed_bits, cidr.." parsed bits is "..tostring(bits));
-	end
-	assert_cidr("0.0.0.0", "0.0.0.0", nil);
-	assert_cidr("127.0.0.1", "127.0.0.1", nil);
-	assert_cidr("127.0.0.1/0", "127.0.0.1", 0);
-	assert_cidr("127.0.0.1/8", "127.0.0.1", 8);
-	assert_cidr("127.0.0.1/32", "127.0.0.1", 32);
-	assert_cidr("127.0.0.1/256", "127.0.0.1", 256);
-	assert_cidr("::/48", "::", 48);
-end
-
-function new_ip(new_ip)
-	local v4, v6 = "IPv4", "IPv6";
-	local function assert_proto(s, proto)
-		local ip = new_ip(s);
-		if proto then
-			assert_equal(ip and ip.proto, proto, "protocol is correct for "..("%q"):format(s));
-		else
-			assert_equal(ip, nil, "address is invalid");
-		end
-	end
-	assert_proto("127.0.0.1", v4);
-	assert_proto("::1", v6);
-	assert_proto("", nil);
-	assert_proto("abc", nil);
-	assert_proto("   ", nil);
-end
-
-function commonPrefixLength(cpl, _M)
-	local new_ip = _M.new_ip;
-	local function assert_cpl6(a, b, len, v4)
-		local ipa, ipb = new_ip(a), new_ip(b);
-		if v4 then len = len+96; end
-		assert_equal(cpl(ipa, ipb), len, "common prefix length of "..a.." and "..b.." is "..len);
-		assert_equal(cpl(ipb, ipa), len, "common prefix length of "..b.." and "..a.." is "..len);
-	end
-	local function assert_cpl4(a, b, len)
-		return assert_cpl6(a, b, len, "IPv4");
-	end
-	assert_cpl4("0.0.0.0", "0.0.0.0", 32);
-	assert_cpl4("255.255.255.255", "0.0.0.0", 0);
-	assert_cpl4("255.255.255.255", "255.255.0.0", 16);
-	assert_cpl4("255.255.255.255", "255.255.255.255", 32);
-	assert_cpl4("255.255.255.255", "255.255.255.255", 32);
-
-	assert_cpl6("::1", "::1", 128);
-	assert_cpl6("abcd::1", "abcd::1", 128);
-	assert_cpl6("abcd::abcd", "abcd::", 112);
-	assert_cpl6("abcd::abcd", "abcd::abcd:abcd", 96);
-end
--- a/tests/test_util_jid.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,143 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-function join(join)
-	assert_equal(join("a", "b", "c"), "a@b/c", "builds full JID");
-	assert_equal(join("a", "b", nil), "a@b", "builds bare JID");
-	assert_equal(join(nil, "b", "c"), "b/c", "builds full host JID");
-	assert_equal(join(nil, "b", nil), "b", "builds bare host JID");
-	assert_equal(join(nil, nil, nil), nil, "invalid JID is nil");
-	assert_equal(join("a", nil, nil), nil, "invalid JID is nil");
-	assert_equal(join(nil, nil, "c"), nil, "invalid JID is nil");
-	assert_equal(join("a", nil, "c"), nil, "invalid JID is nil");
-end
-
-
-function split(split)
-	local function test(input_jid, expected_node, expected_server, expected_resource)
-		local rnode, rserver, rresource = split(input_jid);
-		assert_equal(expected_node, rnode, "split("..tostring(input_jid)..") failed");
-		assert_equal(expected_server, rserver, "split("..tostring(input_jid)..") failed");
-		assert_equal(expected_resource, rresource, "split("..tostring(input_jid)..") failed");
-	end
-
-	-- Valid JIDs
-	test("node@server", 		"node", "server", nil		);
-	test("node@server/resource", 	"node", "server", "resource"        );
-	test("server", 			nil, 	"server", nil               );
-	test("server/resource", 	nil, 	"server", "resource"        );
-	test("server/resource@foo", 	nil, 	"server", "resource@foo"    );
-	test("server/resource@foo/bar",	nil, 	"server", "resource@foo/bar");
-
-	-- Always invalid JIDs
-	test(nil,                nil, nil, nil);
-	test("node@/server",     nil, nil, nil);
-	test("@server",          nil, nil, nil);
-	test("@server/resource", nil, nil, nil);
-	test("@/resource", nil, nil, nil);
-end
-
-function bare(bare)
-	assert_equal(bare("user@host"), "user@host", "bare JID remains bare");
-	assert_equal(bare("host"), "host", "Host JID remains host");
-	assert_equal(bare("host/resource"), "host", "Host JID with resource becomes host");
-	assert_equal(bare("user@host/resource"), "user@host", "user@host JID with resource becomes user@host");
-	assert_equal(bare("user@/resource"), nil, "invalid JID is nil");
-	assert_equal(bare("@/resource"), nil, "invalid JID is nil");
-	assert_equal(bare("@/"), nil, "invalid JID is nil");
-	assert_equal(bare("/"), nil, "invalid JID is nil");
-	assert_equal(bare(""), nil, "invalid JID is nil");
-	assert_equal(bare("@"), nil, "invalid JID is nil");
-	assert_equal(bare("user@"), nil, "invalid JID is nil");
-	assert_equal(bare("user@@"), nil, "invalid JID is nil");
-	assert_equal(bare("user@@host"), nil, "invalid JID is nil");
-	assert_equal(bare("user@@host/resource"), nil, "invalid JID is nil");
-	assert_equal(bare("user@host/"), nil, "invalid JID is nil");
-end
-
-function compare(compare)
-	assert_equal(compare("host", "host"), true, "host should match");
-	assert_equal(compare("host", "other-host"), false, "host should not match");
-	assert_equal(compare("other-user@host/resource", "host"), true, "host should match");
-	assert_equal(compare("other-user@host", "user@host"), false, "user should not match");
-	assert_equal(compare("user@host", "host"), true, "host should match");
-	assert_equal(compare("user@host/resource", "host"), true, "host should match");
-	assert_equal(compare("user@host/resource", "user@host"), true, "user and host should match");
-	assert_equal(compare("user@other-host", "host"), false, "host should not match");
-	assert_equal(compare("user@other-host", "user@host"), false, "host should not match");
-end
-
-function node(node)
-	local function test(jid, expected_node)
-		assert_equal(node(jid), expected_node, "Unexpected node for "..tostring(jid));
-	end
-
-	test("example.com", nil);
-	test("foo.example.com", nil);
-	test("foo.example.com/resource", nil);
-	test("foo.example.com/some resource", nil);
-	test("foo.example.com/some@resource", nil);
-
-	test("foo@foo.example.com/some@resource", "foo");
-	test("foo@example/some@resource", "foo");
-
-	test("foo@example/@resource", "foo");
-	test("foo@example@resource", nil);
-	test("foo@example", "foo");
-	test("foo", nil);
-
-	test(nil, nil);
-end
-
-function host(host)
-	local function test(jid, expected_host)
-		assert_equal(host(jid), expected_host, "Unexpected host for "..tostring(jid));
-	end
-
-	test("example.com", "example.com");
-	test("foo.example.com", "foo.example.com");
-	test("foo.example.com/resource", "foo.example.com");
-	test("foo.example.com/some resource", "foo.example.com");
-	test("foo.example.com/some@resource", "foo.example.com");
-
-	test("foo@foo.example.com/some@resource", "foo.example.com");
-	test("foo@example/some@resource", "example");
-
-	test("foo@example/@resource", "example");
-	test("foo@example@resource", nil);
-	test("foo@example", "example");
-	test("foo", "foo");
-
-	test(nil, nil);
-end
-
-function resource(resource)
-	local function test(jid, expected_resource)
-		assert_equal(resource(jid), expected_resource, "Unexpected resource for "..tostring(jid));
-	end
-
-	test("example.com", nil);
-	test("foo.example.com", nil);
-	test("foo.example.com/resource", "resource");
-	test("foo.example.com/some resource", "some resource");
-	test("foo.example.com/some@resource", "some@resource");
-
-	test("foo@foo.example.com/some@resource", "some@resource");
-	test("foo@example/some@resource", "some@resource");
-
-	test("foo@example/@resource", "@resource");
-	test("foo@example@resource", nil);
-	test("foo@example", nil);
-	test("foo", nil);
-	test("/foo", nil);
-	test("@x/foo", nil);
-	test("@/foo", nil);
-
-	test(nil, nil);
-end
-
--- a/tests/test_util_json.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,21 +0,0 @@
-
-function encode(encode, json)
-	local function test(f, j, e)
-		if e then
-			assert_equal(f(j), e);
-		end
-		assert_equal(f(j), f(json.decode(f(j))));
-	end
-	test(encode, json.null, "null")
-	test(encode, {}, "{}")
-	test(encode, {a=1});
-	test(encode, {a={1,2,3}});
-	test(encode, {1}, "[1]");
-end
-
-function decode(decode)
-	local empty_array = decode("[]");
-	assert_equal(type(empty_array), "table");
-	assert_equal(#empty_array, 0);
-	assert_equal(next(empty_array), nil);
-end
--- a/tests/test_util_json.sh	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,39 +0,0 @@
-#!/bin/bash
-
-export LUA_PATH="../?.lua;;"
-export LUA_CPATH="../?.so;;"
-
-#set -x
-
-if ! which "$RUNWITH"; then
-	echo "Unable to find interpreter $RUNWITH";
-	exit 1;
-fi
-
-if ! $RUNWITH -e 'assert(require"util.json")' 2>/dev/null; then
-	echo "Unable to find util.json";
-	exit 1;
-fi
-
-FAIL=0
-
-for f in json/pass*.json; do
-	if ! $RUNWITH -e 'local j=require"util.json" assert(j.decode(io.read("*a"))~=nil)' <"$f" 2>/dev/null; then
-		echo "Failed to decode valid JSON: $f";
-		FAIL=1
-	fi
-done
-
-for f in json/fail*.json; do
-	if ! $RUNWITH -e 'local j=require"util.json" assert(j.decode(io.read("*a"))==nil)' <"$f" 2>/dev/null; then
-		echo "Invalid JSON decoded without error: $f";
-		FAIL=1
-	fi
-done
-
-if [ "$FAIL" == "1" ]; then
-	echo "JSON tests failed"
-	exit 1;
-fi
-
-exit 0;
--- a/tests/test_util_multitable.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,62 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-
-function new(new, multitable)
-	local mt = new();
-	assert_table(mt, "Multitable is a table");
-	assert_function(mt.add, "Multitable has method add");
-	assert_function(mt.get, "Multitable has method get");
-	assert_function(mt.remove, "Multitable has method remove");
-
-	get(mt.get, multitable);
-end
-
-function get(get, multitable)
-	local function has_items(list, ...)
-		local should_have = {};
-		if select('#', ...) > 0 then
-			assert_table(list, "has_items: list is table", 3);
-		else
-			assert_is_not(list and #list > 0, "No items, and no list");
-			return true, "has-all";
-		end
-		for n=1,select('#', ...) do should_have[select(n, ...)] = true; end
-		for _, item in ipairs(list) do
-			if not should_have[item] then return false, "too-many"; end
-			should_have[item] = nil;
-		end
-		if next(should_have) then
-			return false, "not-enough";
-		end
-		return true, "has-all";
-	end
-	local function assert_has_all(message, list, ...)
-		return assert_equal(select(2, has_items(list, ...)), "has-all", message or "List has all expected items, and no more", 2);
-	end
-
-	local mt = multitable.new();
-
-	local trigger1, trigger2, trigger3 = {}, {}, {};
-	local item1, item2, item3 = {}, {}, {};
-
-	assert_has_all("Has no items with trigger1", mt:get(trigger1));
-
-
-	mt:add(1, 2, 3, item1);
-
-	assert_has_all("Has item1 for 1, 2, 3", mt:get(1, 2, 3), item1);
-
--- Doesn't support nil
---[[	mt:add(nil, item1);
-	mt:add(nil, item2);
-	mt:add(nil, item3);
-
-	assert_has_all("Has all items with (nil)", mt:get(nil), item1, item2, item3);
-]]
-end
--- a/tests/test_util_queue.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,74 +0,0 @@
-
-function new(new)
-	do
-		local q = new(10);
-
-		assert_equal(q.size, 10);
-		assert_equal(q:count(), 0);
-
-		assert_is(q:push("one"));
-		assert_is(q:push("two"));
-		assert_is(q:push("three"));
-
-		for i = 4, 10 do
-			assert_is(q:push("hello"));
-			assert_equal(q:count(), i, "count is not "..i.."("..q:count()..")");
-		end
-		assert_equal(q:push("hello"), nil, "queue overfull!");
-		assert_equal(q:push("hello"), nil, "queue overfull!");
-		assert_equal(q:pop(), "one", "queue item incorrect");
-		assert_equal(q:pop(), "two", "queue item incorrect");
-		assert_is(q:push("hello"));
-		assert_is(q:push("hello"));
-		assert_equal(q:pop(), "three", "queue item incorrect");
-		assert_is(q:push("hello"));
-		assert_equal(q:push("hello"), nil, "queue overfull!");
-		assert_equal(q:push("hello"), nil, "queue overfull!");
-
-		assert_equal(q:count(), 10, "queue count incorrect");
-
-		for _ = 1, 10 do
-			assert_equal(q:pop(), "hello", "queue item incorrect");
-		end
-
-		assert_equal(q:count(), 0, "queue count incorrect");
-
-		assert_is(q:push(1));
-		for i = 1, 1001 do
-			assert_equal(q:pop(), i);
-			assert_equal(q:count(), 0);
-			assert_is(q:push(i+1));
-			assert_equal(q:count(), 1);
-		end
-		assert_equal(q:pop(), 1002);
-		assert_is(q:push(1));
-		for i = 1, 1000 do
-			assert_equal(q:pop(), i);
-			assert_is(q:push(i+1));
-		end
-		assert_equal(q:pop(), 1001);
-		assert_equal(q:count(), 0);
-	end
-
-	do
-		-- Test queues that purge old items when pushing to a full queue
-		local q = new(10, true);
-
-		for i = 1, 10 do
-			q:push(i);
-		end
-
-		assert_equal(q:count(), 10);
-
-		assert_is(q:push(11));
-		assert_equal(q:count(), 10);
-		assert_equal(q:pop(), 2); -- First item should have been purged
-
-		for i = 12, 32 do
-			assert_is(q:push(i));
-		end
-
-		assert_equal(q:count(), 10);
-		assert_equal(q:pop(), 23);
-	end
-end
--- a/tests/test_util_random.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,10 +0,0 @@
--- Makes no attempt at testing how random the bytes are,
--- just that it returns the number of bytes requested
-
-function bytes(bytes)
-	assert_is(bytes(16));
-
-	for i = 1, 255 do
-		assert_equal(i, #bytes(i));
-	end
-end
--- a/tests/test_util_rfc6724.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,97 +0,0 @@
--- Prosody IM
--- Copyright (C) 2011-2013 Florian Zeitz
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-function source(source)
-	local new_ip = require"util.ip".new_ip;
-	assert_equal(source(new_ip("2001:db8:1::1", "IPv6"),
-			{new_ip("2001:db8:3::1", "IPv6"), new_ip("fe80::1", "IPv6")}).addr,
-		"2001:db8:3::1",
-		"prefer appropriate scope");
-	assert_equal(source(new_ip("ff05::1", "IPv6"),
-			{new_ip("2001:db8:3::1", "IPv6"), new_ip("fe80::1", "IPv6")}).addr,
-		"2001:db8:3::1",
-		"prefer appropriate scope");
-	assert_equal(source(new_ip("2001:db8:1::1", "IPv6"),
-			{new_ip("2001:db8:1::1", "IPv6"), new_ip("2001:db8:2::1", "IPv6")}).addr,
-		"2001:db8:1::1",
-		"prefer same address"); -- "2001:db8:1::1" should be marked "deprecated" here, we don't handle that right now
-	assert_equal(source(new_ip("fe80::1", "IPv6"),
-			{new_ip("fe80::2", "IPv6"), new_ip("2001:db8:1::1", "IPv6")}).addr,
-		"fe80::2",
-		"prefer appropriate scope"); -- "fe80::2" should be marked "deprecated" here, we don't handle that right now
-	assert_equal(source(new_ip("2001:db8:1::1", "IPv6"),
-			{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3::2", "IPv6")}).addr,
-		"2001:db8:1::2",
-		"longest matching prefix");
---[[ "2001:db8:1::2" should be a care-of address and "2001:db8:3::2" a home address, we can't handle this and would fail
-	assert_equal(source(new_ip("2001:db8:1::1", "IPv6"),
-			{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3::2", "IPv6")}).addr,
-		"2001:db8:3::2",
-		"prefer home address");
-]]
-	assert_equal(source(new_ip("2002:c633:6401::1", "IPv6"),
-			{new_ip("2002:c633:6401::d5e3:7953:13eb:22e8", "IPv6"), new_ip("2001:db8:1::2", "IPv6")}).addr,
-		"2002:c633:6401::d5e3:7953:13eb:22e8",
-		"prefer matching label"); -- "2002:c633:6401::d5e3:7953:13eb:22e8" should be marked "temporary" here, we don't handle that right now
-	assert_equal(source(new_ip("2001:db8:1::d5e3:0:0:1", "IPv6"),
-			{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:1::d5e3:7953:13eb:22e8", "IPv6")}).addr,
-		"2001:db8:1::d5e3:7953:13eb:22e8",
-		"prefer temporary address") -- "2001:db8:1::2" should be marked "public" and "2001:db8:1::d5e3:7953:13eb:22e8" should be marked "temporary" here, we don't handle that right now
-end
-
-function destination(dest)
-	local order;
-	local new_ip = require"util.ip".new_ip;
-	order = dest({new_ip("2001:db8:1::1", "IPv6"), new_ip("198.51.100.121", "IPv4")},
-		{new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::1", "IPv6"), new_ip("169.254.13.78", "IPv4")})
-	assert_equal(order[1].addr, "2001:db8:1::1", "prefer matching scope");
-	assert_equal(order[2].addr, "198.51.100.121", "prefer matching scope");
-
-	order = dest({new_ip("2001:db8:1::1", "IPv6"), new_ip("198.51.100.121", "IPv4")},
-		{new_ip("fe80::1", "IPv6"), new_ip("198.51.100.117", "IPv4")})
-	assert_equal(order[1].addr, "198.51.100.121", "prefer matching scope");
-	assert_equal(order[2].addr, "2001:db8:1::1", "prefer matching scope");
-
-	order = dest({new_ip("2001:db8:1::1", "IPv6"), new_ip("10.1.2.3", "IPv4")},
-		{new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::1", "IPv6"), new_ip("10.1.2.4", "IPv4")})
-	assert_equal(order[1].addr, "2001:db8:1::1", "prefer higher precedence");
-	assert_equal(order[2].addr, "10.1.2.3", "prefer higher precedence");
-
-	order = dest({new_ip("2001:db8:1::1", "IPv6"), new_ip("fe80::1", "IPv6")},
-		{new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::2", "IPv6")})
-	assert_equal(order[1].addr, "fe80::1", "prefer smaller scope");
-	assert_equal(order[2].addr, "2001:db8:1::1", "prefer smaller scope");
-
---[[ "2001:db8:1::2" and "fe80::2" should be marked "care-of address", while "2001:db8:3::1" should be marked "home address", we can't currently handle this and would fail the test
-	order = dest({new_ip("2001:db8:1::1", "IPv6"), new_ip("fe80::1", "IPv6")},
-		{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3::1", "IPv6"), new_ip("fe80::2", "IPv6")})
-	assert_equal(order[1].addr, "2001:db8:1::1", "prefer home address");
-	assert_equal(order[2].addr, "fe80::1", "prefer home address");
-]]
-
---[[ "fe80::2" should be marked "deprecated", we can't currently handle this and would fail the test
-	order = dest({new_ip("2001:db8:1::1", "IPv6"), new_ip("fe80::1", "IPv6")},
-		{new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::2", "IPv6")})
-	assert_equal(order[1].addr, "2001:db8:1::1", "avoid deprecated addresses");
-	assert_equal(order[2].addr, "fe80::1", "avoid deprecated addresses");
-]]
-
-	order = dest({new_ip("2001:db8:1::1", "IPv6"), new_ip("2001:db8:3ffe::1", "IPv6")},
-		{new_ip("2001:db8:1::2", "IPv6"), new_ip("2001:db8:3f44::2", "IPv6"), new_ip("fe80::2", "IPv6")})
-	assert_equal(order[1].addr, "2001:db8:1::1", "longest matching prefix");
-	assert_equal(order[2].addr, "2001:db8:3ffe::1", "longest matching prefix");
-
-	order = dest({new_ip("2002:c633:6401::1", "IPv6"), new_ip("2001:db8:1::1", "IPv6")},
-		{new_ip("2002:c633:6401::2", "IPv6"), new_ip("fe80::2", "IPv6")})
-	assert_equal(order[1].addr, "2002:c633:6401::1", "prefer matching label");
-	assert_equal(order[2].addr, "2001:db8:1::1", "prefer matching label");
-
-	order = dest({new_ip("2002:c633:6401::1", "IPv6"), new_ip("2001:db8:1::1", "IPv6")},
-		{new_ip("2002:c633:6401::2", "IPv6"), new_ip("2001:db8:1::2", "IPv6"), new_ip("fe80::2", "IPv6")})
-	assert_equal(order[1].addr, "2001:db8:1::1", "prefer higher precedence");
-	assert_equal(order[2].addr, "2002:c633:6401::1", "prefer higher precedence");
-end
--- a/tests/test_util_sasl_scram.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,23 +0,0 @@
-
-
-local hmac_sha1 = require "util.hashes".hmac_sha1;
-local function toHex(s)
-	return s and (s:gsub(".", function (c) return ("%02x"):format(c:byte()); end));
-end
-
-function Hi(Hi)
-	assert( toHex(Hi(hmac_sha1, "password", "salt", 1)) == "0c60c80f961f0e71f3a9b524af6012062fe037a6",
-	[[FAIL: toHex(Hi(hmac_sha1, "password", "salt", 1)) == "0c60c80f961f0e71f3a9b524af6012062fe037a6"]])
-	assert( toHex(Hi(hmac_sha1, "password", "salt", 2)) == "ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957",
-	[[FAIL: toHex(Hi(hmac_sha1, "password", "salt", 2)) == "ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957"]])
-	assert( toHex(Hi(hmac_sha1, "password", "salt", 64)) == "a7bc9b6efea2cbd717da72d83bfcc4e17d0b6280",
-	[[FAIL: toHex(Hi(hmac_sha1, "password", "salt", 64)) == "a7bc9b6efea2cbd717da72d83bfcc4e17d0b6280"]])
-	assert( toHex(Hi(hmac_sha1, "password", "salt", 4096)) == "4b007901b765489abead49d926f721d065a429c1",
-	[[FAIL: toHex(Hi(hmac_sha1, "password", "salt", 4096)) == "4b007901b765489abead49d926f721d065a429c1"]])
-	-- assert( toHex(Hi(hmac_sha1, "password", "salt", 16777216)) == "eefe3d61cd4da4e4e9945b3d6ba2158c2634e984",
-	-- [[FAIL: toHex(Hi(hmac_sha1, "password", "salt", 16777216)) == "eefe3d61cd4da4e4e9945b3d6ba2158c2634e984"]])
-end
-
-function init(init)
-	-- no tests
-end
--- a/tests/test_util_stanza.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,152 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-
-function preserialize(preserialize, st)
-	local stanza = st.stanza("message", { a = "a" });
-	local stanza2 = preserialize(stanza);
-	assert_is(stanza2 and stanza.name, "preserialize returns a stanza");
-	assert_is_not(stanza2.tags, "Preserialized stanza has no tag list");
-	assert_is_not(stanza2.last_add, "Preserialized stanza has no last_add marker");
-	assert_is_not(getmetatable(stanza2), "Preserialized stanza has no metatable");
-end
-
-function deserialize(deserialize, st)
-	local stanza = st.stanza("message", { a = "a" });
-
-	local stanza2 = deserialize(st.preserialize(stanza));
-	assert_is(stanza2 and stanza.name, "deserialize returns a stanza");
-	assert_table(stanza2.attr, "Deserialized stanza has attributes");
-	assert_equal(stanza2.attr.a, "a", "Deserialized stanza retains attributes");
-	assert_table(getmetatable(stanza2), "Deserialized stanza has metatable");
-end
-
-function stanza(stanza)
-	local s = stanza("foo", { xmlns = "myxmlns", a = "attr-a" });
-	assert_equal(s.name, "foo");
-	assert_equal(s.attr.xmlns, "myxmlns");
-	assert_equal(s.attr.a, "attr-a");
-
-	local s1 = stanza("s1");
-	assert_equal(s1.name, "s1");
-	assert_equal(s1.attr.xmlns, nil);
-	assert_equal(#s1, 0);
-	assert_equal(#s1.tags, 0);
-
-	s1:tag("child1");
-	assert_equal(#s1.tags, 1);
-	assert_equal(s1.tags[1].name, "child1");
-
-	s1:tag("grandchild1"):up();
-	assert_equal(#s1.tags, 1);
-	assert_equal(s1.tags[1].name, "child1");
-	assert_equal(#s1.tags[1], 1);
-	assert_equal(s1.tags[1][1].name, "grandchild1");
-
-	s1:up():tag("child2");
-	assert_equal(#s1.tags, 2, tostring(s1));
-	assert_equal(s1.tags[1].name, "child1");
-	assert_equal(s1.tags[2].name, "child2");
-	assert_equal(#s1.tags[1], 1);
-	assert_equal(s1.tags[1][1].name, "grandchild1");
-
-	s1:up():text("Hello world");
-	assert_equal(#s1.tags, 2);
-	assert_equal(#s1, 3);
-	assert_equal(s1.tags[1].name, "child1");
-	assert_equal(s1.tags[2].name, "child2");
-	assert_equal(#s1.tags[1], 1);
-	assert_equal(s1.tags[1][1].name, "grandchild1");
-end
-
-function message(message)
-	local m = message();
-	assert_equal(m.name, "message");
-end
-
-function iq(iq)
-	local i = iq();
-	assert_equal(i.name, "iq");
-end
-
-function presence(presence)
-	local p = presence();
-	assert_equal(p.name, "presence");
-end
-
-function reply(reply, _M)
-	do
-		-- Test stanza
-		local s = _M.stanza("s", { to = "touser", from = "fromuser", id = "123" })
-			:tag("child1");
-		-- Make reply stanza
-		local r = reply(s);
-		assert_equal(r.name, s.name);
-		assert_equal(r.id, s.id);
-		assert_equal(r.attr.to, s.attr.from);
-		assert_equal(r.attr.from, s.attr.to);
-		assert_equal(#r.tags, 0, "A reply should not include children of the original stanza");
-	end
-
-	do
-		-- Test stanza
-		local s = _M.stanza("iq", { to = "touser", from = "fromuser", id = "123", type = "get" })
-			:tag("child1");
-		-- Make reply stanza
-		local r = reply(s);
-		assert_equal(r.name, s.name);
-		assert_equal(r.id, s.id);
-		assert_equal(r.attr.to, s.attr.from);
-		assert_equal(r.attr.from, s.attr.to);
-		assert_equal(r.attr.type, "result");
-		assert_equal(#r.tags, 0, "A reply should not include children of the original stanza");
-	end
-
-	do
-		-- Test stanza
-		local s = _M.stanza("iq", { to = "touser", from = "fromuser", id = "123", type = "set" })
-			:tag("child1");
-		-- Make reply stanza
-		local r = reply(s);
-		assert_equal(r.name, s.name);
-		assert_equal(r.id, s.id);
-		assert_equal(r.attr.to, s.attr.from);
-		assert_equal(r.attr.from, s.attr.to);
-		assert_equal(r.attr.type, "result");
-		assert_equal(#r.tags, 0, "A reply should not include children of the original stanza");
-	end
-end
-
-function error_reply(error_reply, _M)
-	do
-		-- Test stanza
-		local s = _M.stanza("s", { to = "touser", from = "fromuser", id = "123" })
-			:tag("child1");
-		-- Make reply stanza
-		local r = error_reply(s);
-		assert_equal(r.name, s.name);
-		assert_equal(r.id, s.id);
-		assert_equal(r.attr.to, s.attr.from);
-		assert_equal(r.attr.from, s.attr.to);
-		assert_equal(#r.tags, 1);
-	end
-
-	do
-		-- Test stanza
-		local s = _M.stanza("iq", { to = "touser", from = "fromuser", id = "123", type = "get" })
-			:tag("child1");
-		-- Make reply stanza
-		local r = error_reply(s);
-		assert_equal(r.name, s.name);
-		assert_equal(r.id, s.id);
-		assert_equal(r.attr.to, s.attr.from);
-		assert_equal(r.attr.from, s.attr.to);
-		assert_equal(r.attr.type, "error");
-		assert_equal(#r.tags, 1);
-	end
-end
--- a/tests/test_util_throttle.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,26 +0,0 @@
-
-local now = 0; -- wibbly-wobbly... timey-wimey... stuff
-local function predictable_gettime()
-	return now;
-end
-local function later(n)
-	now = now + n; -- time passes at a different rate
-end
-
-package.loaded["util.time"] = {
-	now = predictable_gettime;
-}
-
-function create(create)
-	local a = create(3, 10);
-
-	assert_equal(a:poll(1), true);  -- 3 -> 2
-	assert_equal(a:poll(1), true);  -- 2 -> 1
-	assert_equal(a:poll(1), true);  -- 1 -> 0
-	assert_equal(a:poll(1), false); -- MEEP, out of credits!
-	later(1);                       -- ... what about
-	assert_equal(a:poll(1), false); -- now? - Still no!
-	later(9);                       -- Later that day
-	assert_equal(a:poll(1), true);  -- Should be back at 3 credits ... 2
-end
-
--- a/tests/test_util_uuid.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,24 +0,0 @@
--- This tests the format, not the randomness
-
--- https://tools.ietf.org/html/rfc4122#section-4.4
-
-local pattern = "^" .. table.concat({
-	string.rep("%x", 8),
-	string.rep("%x", 4),
-	"4" .. -- version
-	string.rep("%x", 3),
-	"[89ab]" .. -- reserved bits of 1 and 0
-	string.rep("%x", 3),
-	string.rep("%x", 12),
-}, "%-") .. "$";
-
-function generate(generate)
-	for _ = 1, 100 do
-		assert_is(generate():match(pattern));
-	end
-end
-
-function seed(seed)
-	assert_equal(seed("random string here"), nil, "seed doesn't return anything");
-end
-
--- a/tests/test_util_xml.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,12 +0,0 @@
-function parse(parse)
-	local x =
-[[<x xmlns:a="b">
-	<y xmlns:a="c"> <!-- this overwrites 'a' -->
-	    <a:z/>
-	</y>
-	<a:z/> <!-- prefix 'a' is nil here, but should be 'b' -->
-</x>
-]]
-	local stanza = parse(x);
-	assert_equal(stanza.tags[2].attr.xmlns, "b");
-end
--- a/tests/test_util_xmppstream.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,83 +0,0 @@
-function new(new_stream, _M)
-	local function test(xml, expect_success, ex)
-		local stanzas = {};
-		local session = { notopen = true };
-		local callbacks = {
-			stream_ns = "streamns";
-			stream_tag = "stream";
-			default_ns = "stanzans";
-			streamopened = function (_session)
-				assert_equal(session, _session);
-				assert_equal(session.notopen, true);
-				_session.notopen = nil;
-				return true;
-			end;
-			handlestanza = function (_session, stanza)
-				assert_equal(session, _session);
-				assert_equal(_session.notopen, nil);
-				table.insert(stanzas, stanza);
-			end;
-			streamclosed = function (_session)
-				assert_equal(session, _session);
-				assert_equal(_session.notopen, nil);
-				_session.notopen = nil;
-			end;
-		}
-		if type(ex) == "table" then
-			for k, v in pairs(ex) do
-				if k ~= "_size_limit" then
-					callbacks[k] = v;
-				end
-			end
-		end
-		local stream = new_stream(session, callbacks, size_limit);
-		local ok, err = pcall(function ()
-			assert(stream:feed(xml));
-		end);
-
-		if ok and type(expect_success) == "function" then
-			expect_success(stanzas);
-		end
-		assert_equal(not not ok, not not expect_success, "Expected "..(expect_success and ("success ("..tostring(err)..")") or "failure"));
-	end
-
-	local function test_stanza(stanza, expect_success, ex)
-		return test([[<stream:stream xmlns:stream="streamns" xmlns="stanzans">]]..stanza, expect_success, ex);
-	end
-
-	test([[<stream:stream xmlns:stream="streamns"/>]], true);
-	test([[<stream xmlns="streamns"/>]], true);
-
-	test([[<stream1 xmlns="streamns"/>]], false);
-	test([[<stream xmlns="streamns1"/>]], false);
-	test("<>", false);
-
-	test_stanza("<message/>", function (stanzas)
-		assert_equal(#stanzas, 1);
-		assert_equal(stanzas[1].name, "message");
-	end);
-	test_stanza("< message>>>>/>\n", false);
-
-	test_stanza([[<x xmlns:a="b">
-		<y xmlns:a="c">
-			<a:z/>
-		</y>
-		<a:z/>
-	</x>]], function (stanzas)
-		assert_equal(#stanzas, 1);
-		local s = stanzas[1];
-		assert_equal(s.name, "x");
-		assert_equal(#s.tags, 2);
-
-		assert_equal(s.tags[1].name, "y");
-		assert_equal(s.tags[1].attr.xmlns, nil);
-
-		assert_equal(s.tags[1].tags[1].name, "z");
-		assert_equal(s.tags[1].tags[1].attr.xmlns, "c");
-
-		assert_equal(s.tags[2].name, "z");
-		assert_equal(s.tags[2].attr.xmlns, "b");
-
-		assert_equal(s.namespaces, nil);
-	end);
-end
--- a/tests/utf8_sequences.txt	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,52 +0,0 @@
-Should pass: 41 42 43               # Simple ASCII - abc
-Should pass: 41 42 c3 87            # "ABÇ"
-Should pass: 41 42 e1 b8 88         # "ABḈ"
-Should pass: 41 42 f0 9d 9c 8d      # "AB𝜍"
-Should pass: F4 8F BF BF            # Last valid sequence (U+10FFFF)
-Should fail: F4 90 80 80            # First invalid sequence (U+110000)
-Should fail: 80 81 82 83            # Invalid sequence (invalid start byte)
-Should fail: C2 C3                  # Invalid sequence (invalid continuation byte)
-Should fail: C0 43                  # Overlong sequence
-Should fail: F5 80 80 80            # U+140000 (out of range)
-Should fail: ED A0 80               # U+D800 (forbidden by RFC 3629)
-Should fail: ED BF BF               # U+DFFF (forbidden by RFC 3629)
-Should pass: ED 9F BF               # U+D7FF (U+D800 minus 1: allowed)
-Should pass: EE 80 80               # U+E000 (U+D7FF plus 1: allowed)
-Should fail: C0                     # Invalid start byte
-Should fail: C1                     # Invalid start byte
-Should fail: C2                     # Incomplete sequence
-Should fail: F8 88 80 80 80         # 6-byte sequence
-Should pass: 7F                     # Last valid 1-byte sequence (U+00007F)
-Should pass: DF BF                  # Last valid 2-byte sequence (U+0007FF)
-Should pass: EF BF BF               # Last valid 3-byte sequence (U+00FFFF)
-Should pass: 00                     # First valid 1-byte sequence (U+000000)
-Should pass: C2 80                  # First valid 2-byte sequence (U+000080)
-Should pass: E0 A0 80               # First valid 3-byte sequence (U+000800)
-Should pass: F0 90 80 80            # First valid 4-byte sequence (U+000800)
-Should fail: F8 88 80 80 80         # First 5-byte sequence - invalid per RFC 3629
-Should fail: FC 84 80 80 80 80      # First 6-byte sequence - invalid per RFC 3629
-Should pass: EF BF BD               # U+00FFFD (replacement character)
-Should fail: 80                     # First continuation byte
-Should fail: BF                     # Last continuation byte
-Should fail: 80 BF                  # 2 continuation bytes
-Should fail: 80 BF 80               # 3 continuation bytes
-Should fail: 80 BF 80 BF            # 4 continuation bytes
-Should fail: 80 BF 80 BF 80         # 5 continuation bytes
-Should fail: 80 BF 80 BF 80 BF      # 6 continuation bytes
-Should fail: 80 BF 80 BF 80 BF 80   # 7 continuation bytes
-Should fail: FE                     # Impossible byte
-Should fail: FF                     # Impossible byte
-Should fail: FE FE FF FF            # Impossible bytes
-Should fail: C0 AF                  # Overlong "/"
-Should fail: E0 80 AF               # Overlong "/"
-Should fail: F0 80 80 AF            # Overlong "/"
-Should fail: F8 80 80 80 AF         # Overlong "/"
-Should fail: FC 80 80 80 80 AF      # Overlong "/"
-Should fail: C0 80 AF               # Overlong "/" (invalid)
-Should fail: C1 BF                  # Overlong
-Should fail: E0 9F BF               # Overlong
-Should fail: F0 8F BF BF            # Overlong
-Should fail: F8 87 BF BF BF         # Overlong
-Should fail: FC 83 BF BF BF BF      # Overlong
-Should pass: EF BF BE               # U+FFFE (invalid unicode, valid UTF-8)
-Should pass: EF BF BF               # U+FFFF (invalid unicode, valid UTF-8)
--- a/tests/util/logger.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,45 +0,0 @@
--- Prosody IM
--- Copyright (C) 2008-2010 Matthew Wild
--- Copyright (C) 2008-2010 Waqas Hussain
---
--- This project is MIT/X11 licensed. Please see the
--- COPYING file in the source package for more information.
---
-
-local format = string.format;
-local print = print;
-local debug = debug;
-local tostring = tostring;
-
-local getstyle, getstring = require "util.termcolours".getstyle, require "util.termcolours".getstring;
-local do_pretty_printing = not os.getenv("WINDIR");
-
-local _ENV = nil
-local _M = {}
-
-local logstyles = {};
-
---TODO: This should be done in config, but we don't have proper config yet
-if do_pretty_printing then
-	logstyles["info"] = getstyle("bold");
-	logstyles["warn"] = getstyle("bold", "yellow");
-	logstyles["error"] = getstyle("bold", "red");
-end
-
-function _M.init(name)
-	--name = nil; -- While this line is not commented, will automatically fill in file/line number info
-	return 	function (level, message, ...)
-				if level == "debug" or level == "info" then return; end
-				if not name then
-					local inf = debug.getinfo(3, 'Snl');
-					level = level .. ","..tostring(inf.short_src):match("[^/]*$")..":"..inf.currentline;
-				end
-				if ... then
-					print(name, getstring(logstyles[level], level), format(message, ...));
-				else
-					print(name, getstring(logstyles[level], level), message);
-				end
-			end
-end
-
-return _M;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tools/http-status-codes.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,43 @@
+-- Generate net/http/codes.lua from IANA HTTP status code registry
+
+local xml = require "util.xml";
+local registry = xml.parse(io.read("*a"));
+
+io.write([[
+
+local response_codes = {
+	-- Source: http://www.iana.org/assignments/http-status-codes
+]]);
+
+for record in registry:get_child("registry"):childtags("record") do
+	-- Extract values
+	local value = record:get_child_text("value");
+	local description = record:get_child_text("description");
+	local ref = record:get_child_text("xref");
+	local code = tonumber(value);
+
+	-- Space between major groups
+	if code and code % 100 == 0 then
+		io.write("\n");
+	end
+
+	-- Reserved and Unassigned entries should be not be included
+	if description == "Reserved" or description == "Unassigned" or description == "(Unused)" then
+		code = nil;
+	end
+
+	-- Non-empty references become comments
+	if ref and ref:find("%S") then
+		ref = " -- " .. ref;
+	else
+		ref = "";
+	end
+
+	io.write((code and "\t[%d] = %q;%s\n" or "\t-- [%s] = %q;%s\n"):format(code or value, description, ref));
+end
+
+io.write([[};
+
+for k,v in pairs(response_codes) do response_codes[k] = k.." "..v; end
+return setmetatable(response_codes, { __index = function(_, k) return k.." Unassigned"; end })
+]]);
--- a/tools/migration/migrator/prosody_sql.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/tools/migration/migrator/prosody_sql.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -12,7 +12,7 @@
 local tonumber = tonumber;
 
 if not have_DBI then
-	error("LuaDBI (required for SQL support) was not found, please see http://prosody.im/doc/depends#luadbi", 0);
+	error("LuaDBI (required for SQL support) was not found, please see https://prosody.im/doc/depends#luadbi", 0);
 end
 
 local sql = require "util.sql";
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util-src/GNUmakefile	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,35 @@
+
+include ../config.unix
+
+CFLAGS+=-I$(LUA_INCDIR)
+
+INSTALL_DATA=install -m644
+TARGET?=../util/
+
+ALL=encodings.so hashes.so net.so pposix.so signal.so table.so \
+    ringbuffer.so time.so poll.so compat.so
+
+ifdef RANDOM
+ALL+=crand.so
+endif
+
+.PHONY: all install clean
+.SUFFIXES: .c .o .so
+
+all: $(ALL)
+
+install: $(ALL)
+	$(INSTALL_DATA) $? $(TARGET)
+
+clean:
+	rm -f $(ALL) $(patsubst %.so,%.o,$(ALL))
+
+encodings.so: LDLIBS+=$(IDNA_LIBS)
+
+hashes.so: LDLIBS+=$(OPENSSL_LIBS)
+
+crand.o: CFLAGS+=-DWITH_$(RANDOM)
+crand.so: LDLIBS+=$(RANDOM_LIBS)
+
+%.so: %.o
+	$(LD) $(LDFLAGS) -o $@ $^ $(LDLIBS)
--- a/util-src/Makefile	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,34 +0,0 @@
-
-include ../config.unix
-
-CFLAGS+=-I$(LUA_INCDIR)
-
-INSTALL_DATA=install -m644
-TARGET?=../util/
-
-ALL=encodings.so hashes.so net.so pposix.so signal.so table.so ringbuffer.so
-
-ifdef RANDOM
-ALL+=crand.so
-endif
-
-.PHONY: all install clean
-.SUFFIXES: .c .o .so
-
-all: $(ALL)
-
-install: $(ALL)
-	$(INSTALL_DATA) $^ $(TARGET)
-
-clean:
-	rm -f $(ALL) $(patsubst %.so,%.o,$(ALL))
-
-encodings.so: LDLIBS+=$(IDNA_LIBS)
-
-hashes.so: LDLIBS+=$(OPENSSL_LIBS)
-
-crand.o: CFLAGS+=-DWITH_$(RANDOM)
-crand.so: LDLIBS+=$(RANDOM_LIBS)
-
-%.so: %.o
-	$(LD) $(LDFLAGS) -o $@ $^ $(LDLIBS)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util-src/compat.c	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,29 @@
+
+#include <lua.h>
+#include <lauxlib.h>
+
+
+static int lc_xpcall (lua_State *L) {
+  int ret;
+  int n_arg = lua_gettop(L);
+  /* f, msgh, p1, p2... */
+  luaL_argcheck(L, n_arg >= 2, 2, "value expected");
+  lua_pushvalue(L, 1);  /* f to top */
+  lua_pushvalue(L, 2);  /* msgh to top */
+  lua_replace(L, 1); /* msgh to 1 */
+  lua_replace(L, 2); /* f to 2 */
+  /* msgh, f, p1, p2... */
+  ret = lua_pcall(L, n_arg - 2, LUA_MULTRET, 1);
+  lua_pushboolean(L, ret == 0);
+  lua_replace(L, 1);
+  return lua_gettop(L);
+}
+
+int luaopen_util_compat(lua_State *L) {
+	lua_createtable(L, 0, 2);
+	{
+		lua_pushcfunction(L, lc_xpcall);
+		lua_setfield(L, -2, "xpcall");
+	}
+	return 1;
+}
--- a/util-src/crand.c	Wed Nov 28 16:55:27 2018 +0000
+++ b/util-src/crand.c	Mon Jan 07 15:34:23 2019 +0000
@@ -21,19 +21,22 @@
 
 #define _DEFAULT_SOURCE
 
+#include <stdlib.h>
+#include <string.h>
+#include <errno.h>
+
 #include "lualib.h"
 #include "lauxlib.h"
 
-#include <string.h>
-#include <errno.h>
-
 #if defined(WITH_GETRANDOM)
 
 #ifndef __GLIBC_PREREQ
+/* Not compiled with glibc at all */
 #define __GLIBC_PREREQ(a,b) 0
 #endif
 
 #if ! __GLIBC_PREREQ(2,25)
+/* Not compiled with a glibc that provides getrandom() */
 #include <unistd.h>
 #include <sys/syscall.h>
 
@@ -49,45 +52,66 @@
 #include <sys/random.h>
 #endif
 
-#elif defined(WITH_ARC4RANDOM)
-#include <stdlib.h>
 #elif defined(WITH_OPENSSL)
 #include <openssl/rand.h>
+#elif defined(WITH_ARC4RANDOM)
+#ifdef __linux__
+#include <bsd/stdlib.h>
+#endif
 #else
 #error util.crand compiled without a random source
 #endif
 
+#ifndef SMALLBUFSIZ
+#define SMALLBUFSIZ 32
+#endif
+
 int Lrandom(lua_State *L) {
-	int ret = 0;
-	size_t len = (size_t)luaL_checkinteger(L, 1);
-	void *buf = lua_newuserdata(L, len);
+	char smallbuf[SMALLBUFSIZ];
+	char *buf = &smallbuf[0];
+	const lua_Integer l = luaL_checkinteger(L, 1);
+	const size_t len = l;
+	luaL_argcheck(L, l >= 0, 1, "must be > 0");
+
+	if(len == 0) {
+		lua_pushliteral(L, "");
+		return 1;
+	}
+
+	if(len > SMALLBUFSIZ) {
+		buf = lua_newuserdata(L, len);
+	}
 
 #if defined(WITH_GETRANDOM)
 	/*
 	 * This acts like a read from /dev/urandom with the exception that it
 	 * *does* block if the entropy pool is not yet initialized.
 	 */
-	ret = getrandom(buf, len, 0);
+	int left = len;
+	char *p = buf;
+
+	do {
+		int ret = getrandom(p, left, 0);
 
-	if(ret < 0) {
-		lua_pushstring(L, strerror(errno));
-		return lua_error(L);
-	}
+		if(ret < 0) {
+			lua_pushstring(L, strerror(errno));
+			return lua_error(L);
+		}
+
+		p += ret;
+		left -= ret;
+	} while(left > 0);
 
 #elif defined(WITH_ARC4RANDOM)
 	arc4random_buf(buf, len);
-	ret = len;
 #elif defined(WITH_OPENSSL)
+
 	if(!RAND_status()) {
 		lua_pushliteral(L, "OpenSSL PRNG not seeded");
 		return lua_error(L);
 	}
 
-	ret = RAND_bytes(buf, len);
-
-	if(ret == 1) {
-		ret = len;
-	} else {
+	if(RAND_bytes((unsigned char *)buf, len) != 1) {
 		/* TODO ERR_get_error() */
 		lua_pushstring(L, "RAND_bytes() failed");
 		return lua_error(L);
@@ -95,7 +119,7 @@
 
 #endif
 
-	lua_pushlstring(L, buf, ret);
+	lua_pushlstring(L, buf, len);
 	return 1;
 }
 
--- a/util-src/encodings.c	Wed Nov 28 16:55:27 2018 +0000
+++ b/util-src/encodings.c	Mon Jan 07 15:34:23 2019 +0000
@@ -79,9 +79,11 @@
 	switch(--n) {
 		case 3:
 			s[2] = (char) tuple;
+			/* Falls through. */
 
 		case 2:
 			s[1] = (char)(tuple >> 8);
+			/* Falls through. */
 
 		case 1:
 			s[0] = (char)(tuple >> 16);
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util-src/makefile	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,45 @@
+include ../config.unix
+
+CFLAGS+=-I$(LUA_INCDIR)
+
+INSTALL_DATA=install -m644
+TARGET?=../util/
+
+ALL=encodings.so hashes.so net.so pposix.so signal.so table.so \
+    ringbuffer.so time.so poll.so compat.so
+
+.ifdef $(RANDOM)
+ALL+=crand.so
+.endif
+
+.PHONY: all install clean
+.SUFFIXES: .c .o .so
+
+all: $(ALL)
+
+install: $(ALL)
+	$(INSTALL_DATA) $(ALL) $(TARGET)
+
+clean:
+	rm -f $(ALL) $(patsubst %.so,%.o,$(ALL))
+
+encodings.so: encodings.o
+	$(LD) $(LDFLAGS) -o $@ $< $(LDLIBS) $(IDNA_LIBS)
+
+hashes.so: hashes.o
+	$(LD) $(LDFLAGS) -o $@ $< $(LDLIBS) $(OPENSSL_LIBS)
+
+crand.o: crand.c
+	$(CC) $(CFLAGS) -DWITH_$(RANDOM) -c -o $@ $<
+
+crand.so: crand.o
+	$(LD) $(LDFLAGS) -o $@ $< $(LDLIBS) $(RANDOM_LIBS)
+
+%.so: %.o
+	$(LD) $(LDFLAGS) -o $@ $< $(LDLIBS)
+
+.c.o:
+	$(CC) $(CFLAGS) -c -o $@ $<
+
+.o.so:
+	$(LD) $(LDFLAGS) -o $@ $< $(LDLIBS)
--- a/util-src/net.c	Wed Nov 28 16:55:27 2018 +0000
+++ b/util-src/net.c	Mon Jan 07 15:34:23 2019 +0000
@@ -9,7 +9,10 @@
 --
 */
 
+#ifndef _GNU_SOURCE
 #define _GNU_SOURCE
+#endif
+
 #include <stddef.h>
 #include <string.h>
 #include <errno.h>
@@ -125,12 +128,75 @@
 	return 1;
 }
 
+static int lc_pton(lua_State *L) {
+	char buf[16];
+	const char *ipaddr = luaL_checkstring(L, 1);
+	int errno_ = 0;
+	int family = strchr(ipaddr, ':') ? AF_INET6 : AF_INET;
+
+	switch(inet_pton(family, ipaddr, &buf)) {
+		case 1:
+			lua_pushlstring(L, buf, family == AF_INET6 ? 16 : 4);
+			return 1;
+
+		case -1:
+			errno_ = errno;
+			lua_pushnil(L);
+			lua_pushstring(L, strerror(errno_));
+			lua_pushinteger(L, errno_);
+			return 3;
+
+		default:
+		case 0:
+			lua_pushnil(L);
+			lua_pushstring(L, strerror(EINVAL));
+			lua_pushinteger(L, EINVAL);
+			return 3;
+	}
+
+}
+
+static int lc_ntop(lua_State *L) {
+	char buf[INET6_ADDRSTRLEN];
+	int family;
+	int errno_;
+	size_t l;
+	const char *ipaddr = luaL_checklstring(L, 1, &l);
+
+	if(l == 16) {
+		family = AF_INET6;
+	}
+	else if(l == 4) {
+		family = AF_INET;
+	}
+	else {
+		lua_pushnil(L);
+		lua_pushstring(L, strerror(EAFNOSUPPORT));
+		lua_pushinteger(L, EAFNOSUPPORT);
+		return 3;
+	}
+
+	if(!inet_ntop(family, ipaddr, buf, INET6_ADDRSTRLEN))
+	{
+		errno_ = errno;
+		lua_pushnil(L);
+		lua_pushstring(L, strerror(errno_));
+		lua_pushinteger(L, errno_);
+		return 3;
+	}
+
+	lua_pushstring(L, (const char *)(&buf));
+	return 1;
+}
+
 int luaopen_util_net(lua_State *L) {
 #if (LUA_VERSION_NUM > 501)
 	luaL_checkversion(L);
 #endif
 	luaL_Reg exports[] = {
 		{ "local_addresses", lc_local_addresses },
+		{ "pton", lc_pton },
+		{ "ntop", lc_ntop },
 		{ NULL, NULL }
 	};
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util-src/poll.c	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,474 @@
+
+/*
+ * Lua polling library
+ * Copyright (C) 2017-2018 Kim Alvefur
+ *
+ * This project is MIT licensed. Please see the
+ * COPYING file in the source package for more information.
+ *
+ */
+
+#include <unistd.h>
+#include <string.h>
+#include <errno.h>
+
+#ifdef __linux__
+#define USE_EPOLL
+#endif
+
+#ifdef USE_EPOLL
+#include <sys/epoll.h>
+#ifndef MAX_EVENTS
+#define MAX_EVENTS 64
+#endif
+#else
+#include <sys/select.h>
+#endif
+
+#include <lualib.h>
+#include <lauxlib.h>
+
+#ifdef USE_EPOLL
+#define STATE_MT "util.poll<epoll>"
+#else
+#define STATE_MT "util.poll<select>"
+#endif
+
+#if (LUA_VERSION_NUM == 501)
+#define luaL_setmetatable(L, tname) luaL_getmetatable(L, tname); lua_setmetatable(L, -2)
+#endif
+
+/*
+ * Structure to keep state for each type of API
+ */
+typedef struct Lpoll_state {
+	int processed;
+#ifdef USE_EPOLL
+	int epoll_fd;
+	struct epoll_event events[MAX_EVENTS];
+#else
+	fd_set wantread;
+	fd_set wantwrite;
+	fd_set readable;
+	fd_set writable;
+	fd_set all;
+	fd_set err;
+#endif
+} Lpoll_state;
+
+/*
+ * Add an FD to be watched
+ */
+int Ladd(lua_State *L) {
+	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
+	int fd = luaL_checkinteger(L, 2);
+
+	int wantread = lua_toboolean(L, 3);
+	int wantwrite = lua_toboolean(L, 4);
+
+	if(fd < 0) {
+		lua_pushnil(L);
+		lua_pushstring(L, strerror(EBADF));
+		lua_pushinteger(L, EBADF);
+		return 3;
+	}
+
+#ifdef USE_EPOLL
+	struct epoll_event event;
+	event.data.fd = fd;
+	event.events = (wantread ? EPOLLIN : 0) | (wantwrite ? EPOLLOUT : 0);
+
+	event.events |= EPOLLERR | EPOLLHUP | EPOLLRDHUP;
+
+	int ret = epoll_ctl(state->epoll_fd, EPOLL_CTL_ADD, fd, &event);
+
+	if(ret < 0) {
+		ret = errno;
+		lua_pushnil(L);
+		lua_pushstring(L, strerror(ret));
+		lua_pushinteger(L, ret);
+		return 3;
+	}
+
+	lua_pushboolean(L, 1);
+	return 1;
+
+#else
+
+	if(fd > FD_SETSIZE) {
+		lua_pushnil(L);
+		lua_pushstring(L, strerror(EBADF));
+		lua_pushinteger(L, EBADF);
+		return 3;
+	}
+
+	if(FD_ISSET(fd, &state->all)) {
+		lua_pushnil(L);
+		lua_pushstring(L, strerror(EEXIST));
+		lua_pushinteger(L, EEXIST);
+		return 3;
+	}
+
+	FD_CLR(fd, &state->readable);
+	FD_CLR(fd, &state->writable);
+	FD_CLR(fd, &state->err);
+
+	FD_SET(fd, &state->all);
+
+	if(wantread) {
+		FD_SET(fd, &state->wantread);
+	}
+	else {
+		FD_CLR(fd, &state->wantread);
+	}
+
+	if(wantwrite) {
+		FD_SET(fd, &state->wantwrite);
+	}
+	else {
+		FD_CLR(fd, &state->wantwrite);
+	}
+
+	lua_pushboolean(L, 1);
+	return 1;
+#endif
+}
+
+/*
+ * Set events to watch for, readable and/or writable
+ */
+int Lset(lua_State *L) {
+	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
+	int fd = luaL_checkinteger(L, 2);
+
+#ifdef USE_EPOLL
+
+	int wantread = lua_toboolean(L, 3);
+	int wantwrite = lua_toboolean(L, 4);
+
+	struct epoll_event event;
+	event.data.fd = fd;
+	event.events = (wantread ? EPOLLIN : 0) | (wantwrite ? EPOLLOUT : 0);
+
+	event.events |= EPOLLERR | EPOLLHUP | EPOLLRDHUP;
+
+	int ret = epoll_ctl(state->epoll_fd, EPOLL_CTL_MOD, fd, &event);
+
+	if(ret == 0) {
+		lua_pushboolean(L, 1);
+		return 1;
+	}
+	else {
+		ret = errno;
+		lua_pushnil(L);
+		lua_pushstring(L, strerror(ret));
+		lua_pushinteger(L, ret);
+		return 3;
+	}
+
+#else
+
+	if(!FD_ISSET(fd, &state->all)) {
+		lua_pushnil(L);
+		lua_pushstring(L, strerror(ENOENT));
+		lua_pushinteger(L, ENOENT);
+	}
+
+	if(!lua_isnoneornil(L, 3)) {
+		if(lua_toboolean(L, 3)) {
+			FD_SET(fd, &state->wantread);
+		}
+		else {
+			FD_CLR(fd, &state->wantread);
+		}
+	}
+
+	if(!lua_isnoneornil(L, 4)) {
+		if(lua_toboolean(L, 4)) {
+			FD_SET(fd, &state->wantwrite);
+		}
+		else {
+			FD_CLR(fd, &state->wantwrite);
+		}
+	}
+
+	lua_pushboolean(L, 1);
+	return 1;
+#endif
+}
+
+/*
+ * Remove FDs
+ */
+int Ldel(lua_State *L) {
+	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
+	int fd = luaL_checkinteger(L, 2);
+
+#ifdef USE_EPOLL
+
+	struct epoll_event event;
+	event.data.fd = fd;
+
+	int ret = epoll_ctl(state->epoll_fd, EPOLL_CTL_DEL, fd, &event);
+
+	if(ret == 0) {
+		lua_pushboolean(L, 1);
+		return 1;
+	}
+	else {
+		ret = errno;
+		lua_pushnil(L);
+		lua_pushstring(L, strerror(ret));
+		lua_pushinteger(L, ret);
+		return 3;
+	}
+
+#else
+
+	if(!FD_ISSET(fd, &state->all)) {
+		lua_pushnil(L);
+		lua_pushstring(L, strerror(ENOENT));
+		lua_pushinteger(L, ENOENT);
+	}
+
+	FD_CLR(fd, &state->wantread);
+	FD_CLR(fd, &state->wantwrite);
+	FD_CLR(fd, &state->readable);
+	FD_CLR(fd, &state->writable);
+	FD_CLR(fd, &state->all);
+	FD_CLR(fd, &state->err);
+
+	lua_pushboolean(L, 1);
+	return 1;
+#endif
+}
+
+
+/*
+ * Check previously manipulated event state for FDs ready for reading or writing
+ */
+int Lpushevent(lua_State *L, struct Lpoll_state *state) {
+#ifdef USE_EPOLL
+
+	if(state->processed > 0) {
+		state->processed--;
+		struct epoll_event event = state->events[state->processed];
+		lua_pushinteger(L, event.data.fd);
+		lua_pushboolean(L, event.events & (EPOLLIN | EPOLLHUP | EPOLLRDHUP | EPOLLERR));
+		lua_pushboolean(L, event.events & EPOLLOUT);
+		return 3;
+	}
+
+#else
+
+	for(int fd = state->processed + 1; fd < FD_SETSIZE; fd++) {
+		if(FD_ISSET(fd, &state->readable) || FD_ISSET(fd, &state->writable) || FD_ISSET(fd, &state->err)) {
+			lua_pushinteger(L, fd);
+			lua_pushboolean(L, FD_ISSET(fd, &state->readable) | FD_ISSET(fd, &state->err));
+			lua_pushboolean(L, FD_ISSET(fd, &state->writable));
+			FD_CLR(fd, &state->readable);
+			FD_CLR(fd, &state->writable);
+			FD_CLR(fd, &state->err);
+			state->processed = fd;
+			return 3;
+		}
+	}
+
+#endif
+	return 0;
+}
+
+/*
+ * Wait for event
+ */
+int Lwait(lua_State *L) {
+	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
+
+	int ret = Lpushevent(L, state);
+
+	if(ret != 0) {
+		return ret;
+	}
+
+	lua_Number timeout = luaL_checknumber(L, 2);
+	luaL_argcheck(L, timeout >= 0, 1, "positive number expected");
+
+#ifdef USE_EPOLL
+	ret = epoll_wait(state->epoll_fd, state->events, MAX_EVENTS, timeout * 1000);
+#else
+	/*
+	 * select(2) mutates the fd_sets passed to it so in order to not
+	 * have to recreate it manually every time a copy is made.
+	 */
+	memcpy(&state->readable, &state->wantread, sizeof(fd_set));
+	memcpy(&state->writable, &state->wantwrite, sizeof(fd_set));
+	memcpy(&state->err, &state->all, sizeof(fd_set));
+
+	struct timeval tv;
+	tv.tv_sec = (time_t)timeout;
+	tv.tv_usec = ((suseconds_t)(timeout * 1000000)) % 1000000;
+
+	ret = select(FD_SETSIZE, &state->readable, &state->writable, &state->err, &tv);
+#endif
+
+	if(ret == 0) {
+		lua_pushnil(L);
+		lua_pushstring(L, "timeout");
+		return 2;
+	}
+	else if(ret < 0 && errno == EINTR) {
+		lua_pushnil(L);
+		lua_pushstring(L, "signal");
+		return 2;
+	}
+	else if(ret < 0) {
+		ret = errno;
+		lua_pushnil(L);
+		lua_pushstring(L, strerror(ret));
+		lua_pushinteger(L, ret);
+		return 3;
+	}
+
+	/*
+	 * Search for the first ready FD and return it
+	 */
+#ifdef USE_EPOLL
+	state->processed = ret;
+#else
+	state->processed = -1;
+#endif
+	return Lpushevent(L, state);
+}
+
+#ifdef USE_EPOLL
+/*
+ * Return Epoll FD
+ */
+int Lgetfd(lua_State *L) {
+	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
+	lua_pushinteger(L, state->epoll_fd);
+	return 1;
+}
+
+/*
+ * Close epoll FD
+ */
+int Lgc(lua_State *L) {
+	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
+
+	if(state->epoll_fd == -1) {
+		return 0;
+	}
+
+	if(close(state->epoll_fd) == 0) {
+		state->epoll_fd = -1;
+	}
+	else {
+		lua_pushstring(L, strerror(errno));
+		lua_error(L);
+	}
+
+	return 0;
+}
+#endif
+
+/*
+ * String representation
+ */
+int Ltos(lua_State *L) {
+	struct Lpoll_state *state = luaL_checkudata(L, 1, STATE_MT);
+	lua_pushfstring(L, "%s: %p", STATE_MT, state);
+	return 1;
+}
+
+/*
+ * Create a new context
+ */
+int Lnew(lua_State *L) {
+	/* Allocate state */
+	Lpoll_state *state = lua_newuserdata(L, sizeof(Lpoll_state));
+	luaL_setmetatable(L, STATE_MT);
+
+	/* Initialize state */
+#ifdef USE_EPOLL
+	state->epoll_fd = -1;
+	state->processed = 0;
+
+	int epoll_fd = epoll_create1(EPOLL_CLOEXEC);
+
+	if(epoll_fd <= 0) {
+		lua_pushnil(L);
+		lua_pushstring(L, strerror(errno));
+		lua_pushinteger(L, errno);
+		return 3;
+	}
+
+	state->epoll_fd = epoll_fd;
+#else
+	FD_ZERO(&state->wantread);
+	FD_ZERO(&state->wantwrite);
+	FD_ZERO(&state->readable);
+	FD_ZERO(&state->writable);
+	FD_ZERO(&state->all);
+	FD_ZERO(&state->err);
+	state->processed = FD_SETSIZE;
+#endif
+
+	return 1;
+}
+
+/*
+ * Open library
+ */
+int luaopen_util_poll(lua_State *L) {
+#if (LUA_VERSION_NUM > 501)
+	luaL_checkversion(L);
+#endif
+
+	luaL_newmetatable(L, STATE_MT);
+	{
+
+		lua_pushliteral(L, STATE_MT);
+		lua_setfield(L, -2, "__name");
+
+		lua_pushcfunction(L, Ltos);
+		lua_setfield(L, -2, "__tostring");
+
+		lua_createtable(L, 0, 2);
+		{
+			lua_pushcfunction(L, Ladd);
+			lua_setfield(L, -2, "add");
+			lua_pushcfunction(L, Lset);
+			lua_setfield(L, -2, "set");
+			lua_pushcfunction(L, Ldel);
+			lua_setfield(L, -2, "del");
+			lua_pushcfunction(L, Lwait);
+			lua_setfield(L, -2, "wait");
+#ifdef USE_EPOLL
+			lua_pushcfunction(L, Lgetfd);
+			lua_setfield(L, -2, "getfd");
+#endif
+		}
+		lua_setfield(L, -2, "__index");
+
+#ifdef USE_EPOLL
+		lua_pushcfunction(L, Lgc);
+		lua_setfield(L, -2, "__gc");
+#endif
+	}
+
+	lua_createtable(L, 0, 3);
+	{
+		lua_pushcfunction(L, Lnew);
+		lua_setfield(L, -2, "new");
+
+#define push_errno(named_error) lua_pushinteger(L, named_error);\
+		lua_setfield(L, -2, #named_error);
+
+		push_errno(EEXIST);
+		push_errno(ENOENT);
+
+	}
+	return 1;
+}
+
--- a/util-src/pposix.c	Wed Nov 28 16:55:27 2018 +0000
+++ b/util-src/pposix.c	Mon Jan 07 15:34:23 2019 +0000
@@ -17,14 +17,22 @@
 
 
 #if defined(__linux__)
+#ifndef _GNU_SOURCE
 #define _GNU_SOURCE
+#endif
 #else
+#ifndef _DEFAULT_SOURCE
 #define _DEFAULT_SOURCE
 #endif
+#endif
 #if defined(__APPLE__)
+#ifndef _DARWIN_C_SOURCE
 #define _DARWIN_C_SOURCE
 #endif
+#endif
+#ifndef _POSIX_C_SOURCE
 #define _POSIX_C_SOURCE 200809L
+#endif
 
 #include <stdlib.h>
 #include <math.h>
@@ -55,7 +63,7 @@
 #include <linux/falloc.h>
 #endif
 
-#if !defined(WITHOUT_MALLINFO) && defined(__linux__)
+#if !defined(WITHOUT_MALLINFO) && defined(__linux__) && defined(__GLIBC__)
 #include <malloc.h>
 #define WITH_MALLINFO
 #endif
@@ -107,14 +115,10 @@
 		return 2;
 	}
 
-	/* Close stdin, stdout, stderr */
-	close(0);
-	close(1);
-	close(2);
 	/* Make sure accidental use of FDs 0, 1, 2 don't cause weirdness */
-	open("/dev/null", O_RDONLY);
-	open("/dev/null", O_WRONLY);
-	open("/dev/null", O_WRONLY);
+	freopen("/dev/null", "r", stdin);
+	freopen("/dev/null", "w", stdout);
+	freopen("/dev/null", "w", stderr);
 
 	/* Final fork, use it wisely */
 	if(fork()) {
@@ -238,6 +242,7 @@
 }
 
 int lc_syslog_close(lua_State *L) {
+	(void)L;
 	closelog();
 
 	if(syslog_ident) {
@@ -552,6 +557,7 @@
 			if(strcmp(lua_tostring(L, idx), "unlimited") == 0) {
 				return RLIM_INFINITY;
 			}
+			return luaL_argerror(L, idx, "unexpected type");
 
 		case LUA_TNUMBER:
 			return lua_tointeger(L, idx);
@@ -650,6 +656,7 @@
 }
 
 int lc_abort(lua_State *L) {
+	(void)L;
 	abort();
 	return 0;
 }
--- a/util-src/ringbuffer.c	Wed Nov 28 16:55:27 2018 +0000
+++ b/util-src/ringbuffer.c	Mon Jan 07 15:34:23 2019 +0000
@@ -39,10 +39,12 @@
 		return 0;
 	}
 
+	/* look for a matching first byte */
 	for(i = 0; i <= b->blen - l; i++) {
 		if(b->buffer[(b->rpos + i) % b->alen] == *s) {
 			m = 1;
 
+			/* check if the following byte also match */
 			for(j = 1; j < l; j++)
 				if(b->buffer[(b->rpos + i + j) % b->alen] != s[j]) {
 					m = 0;
@@ -58,6 +60,10 @@
 	return 0;
 }
 
+/*
+ * Find first position of a substring in buffer
+ * (buffer, string) -> number
+ */
 int rb_find(lua_State *L) {
 	size_t l, m;
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
@@ -72,6 +78,31 @@
 	return 0;
 }
 
+/*
+ * Move read position forward without returning the data
+ * (buffer, number) -> boolean
+ */
+int rb_discard(lua_State *L) {
+	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
+	size_t r = luaL_checkinteger(L, 2);
+
+	if(r > b->blen) {
+		lua_pushboolean(L, 0);
+		return 1;
+	}
+
+	b->blen -= r;
+	b->rpos += r;
+	modpos(b);
+
+	lua_pushboolean(L, 1);
+	return 1;
+}
+
+/*
+ * Read bytes from buffer
+ * (buffer, number, boolean?) -> string
+ */
 int rb_read(lua_State *L) {
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
 	size_t r = luaL_checkinteger(L, 2);
@@ -83,6 +114,7 @@
 	}
 
 	if((b->rpos + r) > b->alen) {
+		/* Substring wraps around to the beginning of the buffer */
 		lua_pushlstring(L, &b->buffer[b->rpos], b->alen - b->rpos);
 		lua_pushlstring(L, b->buffer, r - (b->alen - b->rpos));
 		lua_concat(L, 2);
@@ -99,6 +131,10 @@
 	return 1;
 }
 
+/*
+ * Read buffer until first occurrence of a substring
+ * (buffer, string) -> string
+ */
 int rb_readuntil(lua_State *L) {
 	size_t l, m;
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
@@ -114,6 +150,10 @@
 	return 0;
 }
 
+/*
+ * Write bytes into the buffer
+ * (buffer, string) -> integer
+ */
 int rb_write(lua_State *L) {
 	size_t l, w = 0;
 	ringbuffer *b = luaL_checkudata(L, 1, "ringbuffer_mt");
@@ -191,6 +231,8 @@
 		{
 			lua_pushcfunction(L, rb_find);
 			lua_setfield(L, -2, "find");
+			lua_pushcfunction(L, rb_discard);
+			lua_setfield(L, -2, "discard");
 			lua_pushcfunction(L, rb_read);
 			lua_setfield(L, -2, "read");
 			lua_pushcfunction(L, rb_readuntil);
--- a/util-src/signal.c	Wed Nov 28 16:55:27 2018 +0000
+++ b/util-src/signal.c	Mon Jan 07 15:34:23 2019 +0000
@@ -26,7 +26,9 @@
  * OTHER DEALINGS IN THE SOFTWARE.
 */
 
+#ifndef _GNU_SOURCE
 #define _GNU_SOURCE
+#endif
 
 #include <signal.h>
 #include <stdlib.h>
@@ -166,6 +168,7 @@
 int nsig = 0;
 
 static void sighook(lua_State *L, lua_Debug *ar) {
+	(void)ar;
 	/* restore the old hook */
 	lua_sethook(L, Hsig, Hmask, Hcount);
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util-src/time.c	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,35 @@
+#ifndef _POSIX_C_SOURCE
+#define _POSIX_C_SOURCE 199309L
+#endif
+
+#include <time.h>
+#include <lua.h>
+
+lua_Number tv2number(struct timespec *tv) {
+	return tv->tv_sec + tv->tv_nsec * 1e-9;
+}
+
+int lc_time_realtime(lua_State *L) {
+	struct timespec t;
+	clock_gettime(CLOCK_REALTIME, &t);
+	lua_pushnumber(L, tv2number(&t));
+	return 1;
+}
+
+int lc_time_monotonic(lua_State *L) {
+	struct timespec t;
+	clock_gettime(CLOCK_MONOTONIC, &t);
+	lua_pushnumber(L, tv2number(&t));
+	return 1;
+}
+
+int luaopen_util_time(lua_State *L) {
+	lua_createtable(L, 0, 2);
+	{
+		lua_pushcfunction(L, lc_time_realtime);
+		lua_setfield(L, -2, "now");
+		lua_pushcfunction(L, lc_time_monotonic);
+		lua_setfield(L, -2, "monotonic");
+	}
+	return 1;
+}
--- a/util/adhoc.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/adhoc.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -1,3 +1,5 @@
+-- luacheck: ignore 212/self
+
 local function new_simple_form(form, result_handler)
 	return function(self, data, state)
 		if state then
--- a/util/array.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/array.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -19,7 +19,13 @@
 local array = {};
 local array_base = {};
 local array_methods = {};
-local array_mt = { __index = array_methods, __tostring = function (self) return "{"..self:concat(", ").."}"; end };
+local array_mt = {
+	__index = array_methods;
+	__name = "array";
+	__tostring = function (self) return "{"..self:concat(", ").."}"; end;
+};
+
+function array_mt:__freeze() return self; end
 
 local function new_array(self, t, _s, _var)
 	if type(t) == "function" then -- Assume iterator
@@ -46,6 +52,19 @@
 	return true;
 end
 
+function array_mt.__div(a1, func)
+	local a2 = new_array();
+	local o = 0;
+	for i = 1, #a1 do
+		local new_value = func(a1[i]);
+		if new_value ~= nil then
+			o = o + 1;
+			a2[o] = new_value;
+		end
+	end
+	return a2;
+end
+
 setmetatable(array, { __call = new_array });
 
 -- Read-only methods
@@ -53,6 +72,12 @@
 	return self[math_random(1, #self)];
 end
 
+-- Return a random value excluding the one at idx
+function array_methods:random_other(idx)
+	local max = #self;
+	return self[((math.random(1, max-1)+(idx-1))%max)+1];
+end
+
 -- These methods can be called two ways:
 --   array.method(existing_array, [params [, ...]]) -- Create new array for result
 --   existing_array:method([params, ...]) -- Transform existing array into result
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/async.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,254 @@
+local logger = require "util.logger";
+local log = logger.init("util.async");
+local new_id = require "util.id".short;
+local xpcall = require "util.xpcall".xpcall;
+
+local function checkthread()
+	local thread, main = coroutine.running();
+	if not thread or main then
+		error("Not running in an async context, see https://prosody.im/doc/developers/util/async");
+	end
+	return thread;
+end
+
+local function runner_from_thread(thread)
+	local level = 0;
+	-- Find the 'level' of the top-most function (0 == current level, 1 == caller, ...)
+	while debug.getinfo(thread, level, "") do level = level + 1; end
+	local name, runner = debug.getlocal(thread, level-1, 1);
+	if name ~= "self" or type(runner) ~= "table" or runner.thread ~= thread then
+		return nil;
+	end
+	return runner;
+end
+
+local function call_watcher(runner, watcher_name, ...)
+	local watcher = runner.watchers[watcher_name];
+	if not watcher then
+		return false;
+	end
+	runner:log("debug", "Calling '%s' watcher", watcher_name);
+	local ok, err = xpcall(watcher, debug.traceback, runner, ...);
+	if not ok then
+		runner:log("error", "Error in '%s' watcher: %s", watcher_name, err);
+		return nil, err;
+	end
+	return true;
+end
+
+local function runner_continue(thread)
+	-- ASSUMPTION: runner is in 'waiting' state (but we don't have the runner to know for sure)
+	if coroutine.status(thread) ~= "suspended" then -- This should suffice
+		log("error", "unexpected async state: thread not suspended");
+		return false;
+	end
+	local ok, state, runner = coroutine.resume(thread);
+	if not ok then
+		local err = state;
+		-- Running the coroutine failed, which means we have to find the runner manually,
+		-- in order to inform the error handler
+		runner = runner_from_thread(thread);
+		if not runner then
+			log("error", "unexpected async state: unable to locate runner during error handling");
+			return false;
+		end
+		call_watcher(runner, "error", debug.traceback(thread, err));
+		runner.state, runner.thread = "ready", nil;
+		return runner:run();
+	elseif state == "ready" then
+		-- If state is 'ready', it is our responsibility to update runner.state from 'waiting'.
+		-- We also have to :run(), because the queue might have further items that will not be
+		-- processed otherwise. FIXME: It's probably best to do this in a nexttick (0 timer).
+		runner.state = "ready";
+		runner:run();
+	end
+	return true;
+end
+
+local function waiter(num)
+	local thread = checkthread();
+	num = num or 1;
+	local waiting;
+	return function ()
+		if num == 0 then return; end -- already done
+		waiting = true;
+		coroutine.yield("wait");
+	end, function ()
+		num = num - 1;
+		if num == 0 and waiting then
+			runner_continue(thread);
+		elseif num < 0 then
+			error("done() called too many times");
+		end
+	end;
+end
+
+local function guarder()
+	local guards = {};
+	local default_id = {};
+	return function (id, func)
+		id = id or default_id;
+		local thread = checkthread();
+		local guard = guards[id];
+		if not guard then
+			guard = {};
+			guards[id] = guard;
+			log("debug", "New guard!");
+		else
+			table.insert(guard, thread);
+			log("debug", "Guarded. %d threads waiting.", #guard)
+			coroutine.yield("wait");
+		end
+		local function exit()
+			local next_waiting = table.remove(guard, 1);
+			if next_waiting then
+				log("debug", "guard: Executing next waiting thread (%d left)", #guard)
+				runner_continue(next_waiting);
+			else
+				log("debug", "Guard off duty.")
+				guards[id] = nil;
+			end
+		end
+		if func then
+			func();
+			exit();
+			return;
+		end
+		return exit;
+	end;
+end
+
+local runner_mt = {};
+runner_mt.__index = runner_mt;
+
+local function runner_create_thread(func, self)
+	local thread = coroutine.create(function (self) -- luacheck: ignore 432/self
+		while true do
+			func(coroutine.yield("ready", self));
+		end
+	end);
+	debug.sethook(thread, debug.gethook());
+	assert(coroutine.resume(thread, self)); -- Start it up, it will return instantly to wait for the first input
+	return thread;
+end
+
+local function default_error_watcher(runner, err)
+	runner:log("error", "Encountered error: %s", err);
+	error(err);
+end
+local function default_func(f) f(); end
+local function runner(func, watchers, data)
+	local id = new_id();
+	local _log = logger.init("runner" .. id);
+	return setmetatable({ func = func or default_func, thread = false, state = "ready", notified_state = "ready",
+		queue = {}, watchers = watchers or { error = default_error_watcher }, data = data, id = id, _log = _log; }
+	, runner_mt);
+end
+
+-- Add a task item for the runner to process
+function runner_mt:run(input)
+	if input ~= nil then
+		table.insert(self.queue, input);
+		--self:log("debug", "queued new work item, %d items queued", #self.queue);
+	end
+	if self.state ~= "ready" then
+		-- The runner is busy. Indicate that the task item has been
+		-- queued, and return information about the current runner state
+		return true, self.state, #self.queue;
+	end
+
+	local q, thread = self.queue, self.thread;
+	if not thread or coroutine.status(thread) == "dead" then
+		self:log("debug", "creating new coroutine");
+		-- Create a new coroutine for this runner
+		thread = runner_create_thread(self.func, self);
+		self.thread = thread;
+	end
+
+	-- Process task item(s) while the queue is not empty, and we're not blocked
+	local n, state, err = #q, self.state, nil;
+	self.state = "running";
+	--self:log("debug", "running main loop");
+	while n > 0 and state == "ready" and not err do
+		local consumed;
+		-- Loop through queue items, and attempt to run them
+		for i = 1,n do
+			local queued_input = q[i];
+			local ok, new_state = coroutine.resume(thread, queued_input);
+			if not ok then
+				-- There was an error running the coroutine, save the error, mark runner as ready to begin again
+				consumed, state, err = i, "ready", debug.traceback(thread, new_state);
+				self.thread = nil;
+				break;
+			elseif new_state == "wait" then
+				 -- Runner is blocked on waiting for a task item to complete
+				consumed, state = i, "waiting";
+				break;
+			end
+		end
+		-- Loop ended - either queue empty because all tasks passed without blocking (consumed == nil)
+		-- or runner is blocked/errored, and consumed will contain the number of tasks processed so far
+		if not consumed then consumed = n; end
+		-- Remove consumed items from the queue array
+		if q[n+1] ~= nil then
+			n = #q;
+		end
+		for i = 1, n do
+			q[i] = q[consumed+i];
+		end
+		n = #q;
+	end
+	-- Runner processed all items it can, so save current runner state
+	self.state = state;
+	if err or state ~= self.notified_state then
+		self:log("debug", "changed state from %s to %s", self.notified_state, err and ("error ("..state..")") or state);
+		if err then
+			state = "error"
+		else
+			self.notified_state = state;
+		end
+		local handler = self.watchers[state];
+		if handler then handler(self, err); end
+	end
+	if n > 0 then
+		return self:run();
+	end
+	return true, state, n;
+end
+
+-- Add a task item to the queue without invoking the runner, even if it is idle
+function runner_mt:enqueue(input)
+	table.insert(self.queue, input);
+	self:log("debug", "queued new work item, %d items queued", #self.queue);
+	return self;
+end
+
+function runner_mt:log(level, fmt, ...)
+	return self._log(level, fmt, ...);
+end
+
+function runner_mt:onready(f)
+	self.watchers.ready = f;
+	return self;
+end
+
+function runner_mt:onwaiting(f)
+	self.watchers.waiting = f;
+	return self;
+end
+
+function runner_mt:onerror(f)
+	self.watchers.error = f;
+	return self;
+end
+
+local function ready()
+	return pcall(checkthread);
+end
+
+return {
+	ready = ready;
+	waiter = waiter;
+	guarder = guarder;
+	runner = runner;
+};
--- a/util/cache.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/cache.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -116,6 +116,25 @@
 	return tail.key, tail.value;
 end
 
+function cache_methods:resize(new_size)
+	new_size = assert(tonumber(new_size), "cache size must be a number");
+	new_size = math.floor(new_size);
+	assert(new_size > 0, "cache size must be greater than zero");
+	local on_evict = self._on_evict;
+	while self._count > new_size do
+		local tail = self._tail;
+		local evicted_key, evicted_value = tail.key, tail.value;
+		if on_evict ~= nil and (on_evict == false or on_evict(evicted_key, evicted_value) == false) then
+			-- Cache is full, and we're not allowed to evict
+			return false;
+		end
+		_remove(self, tail);
+		self._data[evicted_key] = nil;
+	end
+	self.size = new_size;
+	return true;
+end
+
 function cache_methods:table()
 	--luacheck: ignore 212/t
 	if not self.proxy_table then
@@ -139,6 +158,13 @@
 	return self.proxy_table;
 end
 
+function cache_methods:clear()
+	self._data = {};
+	self._count = 0;
+	self._head = nil;
+	self._tail = nil;
+end
+
 local function new(size, on_evict)
 	size = assert(tonumber(size), "cache size must be a number");
 	size = math.floor(size);
--- a/util/caps.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/caps.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -13,6 +13,7 @@
 local ipairs = ipairs;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local function calculate_hash(disco_info)
 	local identities, features, extensions = {}, {}, {};
--- a/util/dataforms.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/dataforms.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -8,14 +8,17 @@
 
 local setmetatable = setmetatable;
 local ipairs = ipairs;
-local tostring, type, next = tostring, type, next;
+local type, next = type, next;
+local tonumber = tonumber;
 local t_concat = table.concat;
 local st = require "util.stanza";
 local jid_prep = require "util.jid".prep;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local xmlns_forms = 'jabber:x:data';
+local xmlns_validate = 'http://jabber.org/protocol/xdata-validate';
 
 local form_t = {};
 local form_mt = { __index = form_t };
@@ -25,21 +28,76 @@
 end
 
 function form_t.form(layout, data, formtype)
-	local form = st.stanza("x", { xmlns = xmlns_forms, type = formtype or "form" });
-	if layout.title then
-		form:tag("title"):text(layout.title):up();
+	if not formtype then formtype = "form" end
+	local form = st.stanza("x", { xmlns = xmlns_forms, type = formtype });
+	if formtype == "cancel" then
+		return form;
 	end
-	if layout.instructions then
-		form:tag("instructions"):text(layout.instructions):up();
+	if formtype ~= "submit" then
+		if layout.title then
+			form:tag("title"):text(layout.title):up();
+		end
+		if layout.instructions then
+			form:tag("instructions"):text(layout.instructions):up();
+		end
 	end
 	for _, field in ipairs(layout) do
 		local field_type = field.type or "text-single";
 		-- Add field tag
-		form:tag("field", { type = field_type, var = field.name, label = field.label });
+		form:tag("field", { type = field_type, var = field.var or field.name, label = formtype ~= "submit" and field.label or nil });
+
+		if formtype ~= "submit" then
+			if field.desc then
+				form:text_tag("desc", field.desc);
+			end
+		end
+
+		if formtype == "form" and field.datatype then
+			form:tag("validate", { xmlns = xmlns_validate, datatype = field.datatype });
+			-- <basic/> assumed
+			form:up();
+		end
+
+
+		local value = field.value;
+		local options = field.options;
+
+		if data and data[field.name] ~= nil then
+			value = data[field.name];
 
-		local value = (data and data[field.name]) or field.value;
+			if formtype == "form" and type(value) == "table"
+				and (field_type == "list-single" or field_type == "list-multi") then
+				-- Allow passing dynamically generated options as values
+				options, value = value, nil;
+			end
+		end
 
-		if value then
+		if formtype == "form" and options then
+			local defaults = {};
+			for _, val in ipairs(options) do
+				if type(val) == "table" then
+					form:tag("option", { label = val.label }):tag("value"):text(val.value):up():up();
+					if val.default then
+						defaults[#defaults+1] = val.value;
+					end
+				else
+					form:tag("option", { label= val }):tag("value"):text(val):up():up();
+				end
+			end
+			if not value then
+				if field_type == "list-single" then
+					value = defaults[1];
+				elseif field_type == "list-multi" then
+					value = defaults;
+				end
+			end
+		end
+
+		if value ~= nil then
+			if type(value) == "number" then
+				-- TODO validate that this is ok somehow, eg check field.datatype
+				value = ("%g"):format(value);
+			end
 			-- Add value, depending on type
 			if field_type == "hidden" then
 				if type(value) == "table" then
@@ -48,7 +106,7 @@
 						:add_child(value)
 						:up();
 				else
-					form:tag("value"):text(tostring(value)):up();
+					form:tag("value"):text(value):up();
 				end
 			elseif field_type == "boolean" then
 				form:tag("value"):text((value and "1") or "0"):up();
@@ -68,40 +126,10 @@
 					form:tag("value"):text(line):up();
 				end
 			elseif field_type == "list-single" then
-				if formtype ~= "result" then
-					local has_default = false;
-					for _, val in ipairs(field.options or value) do
-						if type(val) == "table" then
-							form:tag("option", { label = val.label }):tag("value"):text(val.value):up():up();
-							if value == val.value or val.default and (not has_default) then
-								form:tag("value"):text(val.value):up();
-								has_default = true;
-							end
-						else
-							form:tag("option", { label= val }):tag("value"):text(tostring(val)):up():up();
-						end
-					end
-				end
-				if (field.options or formtype == "result") and value then
-					form:tag("value"):text(value):up();
-				end
+				form:tag("value"):text(value):up();
 			elseif field_type == "list-multi" then
-				if formtype ~= "result" then
-					for _, val in ipairs(field.options or value) do
-						if type(val) == "table" then
-							form:tag("option", { label = val.label }):tag("value"):text(val.value):up():up();
-							if not field.options and val.default then
-								form:tag("value"):text(val.value):up();
-							end
-						else
-							form:tag("option", { label= val }):tag("value"):text(tostring(val)):up():up();
-						end
-					end
-				end
-				if (field.options or formtype == "result") and value then
-					for _, val in ipairs(value) do
-						form:tag("value"):text(val):up();
-					end
+				for _, val in ipairs(value) do
+					form:tag("value"):text(val):up();
 				end
 			end
 		end
@@ -115,7 +143,7 @@
 			form:up();
 		end
 
-		if field.required then
+		if formtype == "form" and field.required then
 			form:tag("required"):up();
 		end
 
@@ -126,8 +154,9 @@
 end
 
 local field_readers = {};
+local data_validators = {};
 
-function form_t.data(layout, stanza)
+function form_t.data(layout, stanza, current)
 	local data = {};
 	local errors = {};
 	local present = {};
@@ -135,21 +164,33 @@
 	for _, field in ipairs(layout) do
 		local tag;
 		for field_tag in stanza:childtags("field") do
-			if field.name == field_tag.attr.var then
+			if (field.var or field.name) == field_tag.attr.var then
 				tag = field_tag;
 				break;
 			end
 		end
 
 		if not tag then
-			if field.required then
+			if current and current[field.name] ~= nil then
+				data[field.name] = current[field.name];
+			elseif field.required then
 				errors[field.name] = "Required value missing";
 			end
-		else
+		elseif field.name then
 			present[field.name] = true;
 			local reader = field_readers[field.type];
 			if reader then
-				data[field.name], errors[field.name] = reader(tag, field.required);
+				local value, err = reader(tag, field.required);
+				local validator = field.datatype and data_validators[field.datatype];
+				if value ~= nil and validator then
+					local valid, ret = validator(value, field);
+					if valid then
+						value = ret;
+					else
+						value, err = nil, ret or ("Invalid value for data of type " .. field.datatype);
+					end
+				end
+				data[field.name], errors[field.name] = value, err;
 			end
 		end
 	end
@@ -248,8 +289,35 @@
 		return field_tag:get_child_text("value");
 	end
 
+data_validators["xs:integer"] =
+	function (data)
+		local n = tonumber(data);
+		if not n then
+			return false, "not a number";
+		elseif n % 1 ~= 0 then
+			return false, "not an integer";
+		end
+		return true, n;
+	end
+
+
+local function get_form_type(form)
+	if not st.is_stanza(form) then
+		return nil, "not a stanza object";
+	elseif form.attr.xmlns ~= "jabber:x:data" or form.name ~= "x" then
+		return nil, "not a dataform element";
+	end
+	for field in form:childtags("field") do
+		if field.attr.var == "FORM_TYPE" then
+			return field:get_child_text("value");
+		end
+	end
+	return "";
+end
+
 return {
 	new = new;
+	get_type = get_form_type;
 };
 
 
--- a/util/datamanager.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/datamanager.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -40,9 +40,10 @@
 end);
 
 local _ENV = nil;
+-- luacheck: std none
 
 ---- utils -----
-local encode, decode;
+local encode, decode, store_encode;
 do
 	local urlcodes = setmetatable({}, { __index = function (t, k) t[k] = char(tonumber(k, 16)); return t[k]; end });
 
@@ -53,6 +54,12 @@
 	encode = function (s)
 		return s and (s:gsub("%W", function (c) return format("%%%02x", c:byte()); end));
 	end
+
+	-- Special encode function for store names, which historically were unencoded.
+	-- All currently known stores use a-z and underscore, so this one preserves underscores.
+	store_encode = function (s)
+		return s and (s:gsub("[^_%w]", function (c) return format("%%%02x", c:byte()); end));
+	end
 end
 
 if not atomic_append then
@@ -119,6 +126,7 @@
 	ext = ext or "dat";
 	host = (host and encode(host)) or "_global";
 	username = username and encode(username);
+	datastore = store_encode(datastore);
 	if username then
 		if create then mkdir(mkdir(mkdir(data_path).."/"..host).."/"..datastore); end
 		return format("%s/%s/%s/%s.%s", data_path, host, datastore, username, ext);
--- a/util/datetime.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/datetime.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -15,6 +15,7 @@
 local tonumber = tonumber;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local function date(t)
 	return os_date("!%Y-%m-%d", t);
@@ -37,7 +38,8 @@
 		local year, month, day, hour, min, sec, tzd;
 		year, month, day, hour, min, sec, tzd = s:match("^(%d%d%d%d)%-?(%d%d)%-?(%d%d)T(%d%d):(%d%d):(%d%d)%.?%d*([Z+%-]?.*)$");
 		if year then
-			local time_offset = os_difftime(os_time(os_date("*t")), os_time(os_date("!*t"))); -- to deal with local timezone
+			local now = os_time();
+			local time_offset = os_difftime(os_time(os_date("*t", now)), os_time(os_date("!*t", now))); -- to deal with local timezone
 			local tzd_offset = 0;
 			if tzd ~= "" and tzd ~= "Z" then
 				local sign, h, m = tzd:match("([+%-])(%d%d):?(%d*)");
--- a/util/debug.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/debug.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -47,6 +47,7 @@
 		for upvalue_num = 1, math.huge do
 			local name, value = debug.getupvalue(func, upvalue_num);
 			if not name then break; end
+			if name == "" then name = ("[%d]"):format(upvalue_num); end
 			table.insert(upvalues, { name = name, value = value });
 		end
 	end
@@ -112,7 +113,9 @@
 
 local function build_source_boundary_marker(last_source_desc)
 	local padding = string.rep("-", math.floor(((optimal_line_length - 6) - #last_source_desc)/2));
-	return getstring(styles.boundary_padding, "v"..padding).." "..getstring(styles.filename, last_source_desc).." "..getstring(styles.boundary_padding, padding..(#last_source_desc%2==0 and "-v" or "v "));
+	return getstring(styles.boundary_padding, "v"..padding).." "..
+		getstring(styles.filename, last_source_desc).." "..
+		getstring(styles.boundary_padding, padding..(#last_source_desc%2==0 and "-v" or "v "));
 end
 
 local function _traceback(thread, message, level)
@@ -142,9 +145,9 @@
 	local last_source_desc;
 
 	local lines = {};
-	for nlevel, level in ipairs(levels) do
-		local info = level.info;
-		local line = "...";
+	for nlevel, current_level in ipairs(levels) do
+		local info = current_level.info;
+		local line;
 		local func_type = info.namewhat.." ";
 		local source_desc = (info.short_src == "[C]" and "C code") or info.short_src or "Unknown";
 		if func_type == " " then func_type = ""; end;
@@ -160,7 +163,9 @@
 			if func_type == "global " or func_type == "local " then
 				func_type = func_type.."function ";
 			end
-			line = "[Lua] "..getstring(styles.location, info.short_src.." line "..info.currentline).." in "..func_type..getstring(styles.funcname, name).." (defined on line "..info.linedefined..")";
+			line = "[Lua] "..getstring(styles.location, info.short_src.." line "..
+				info.currentline).." in "..func_type..getstring(styles.funcname, name)..
+				" (defined on line "..info.linedefined..")";
 		end
 		if source_desc ~= last_source_desc then -- Venturing into a new source, add marker for previous
 			last_source_desc = source_desc;
@@ -169,13 +174,13 @@
 		nlevel = nlevel-1;
 		table.insert(lines, "\t"..(nlevel==0 and ">" or " ")..getstring(styles.level_num, "("..nlevel..") ")..line);
 		local npadding = (" "):rep(#tostring(nlevel));
-		if level.locals then
-			local locals_str = string_from_var_table(level.locals, optimal_line_length, "\t            "..npadding);
+		if current_level.locals then
+			local locals_str = string_from_var_table(current_level.locals, optimal_line_length, "\t            "..npadding);
 			if locals_str then
 				table.insert(lines, "\t    "..npadding.."Locals: "..locals_str);
 			end
 		end
-		local upvalues_str = string_from_var_table(level.upvalues, optimal_line_length, "\t            "..npadding);
+		local upvalues_str = string_from_var_table(current_level.upvalues, optimal_line_length, "\t            "..npadding);
 		if upvalues_str then
 			table.insert(lines, "\t    "..npadding.."Upvals: "..upvalues_str);
 		end
--- a/util/dependencies.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/dependencies.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -28,24 +28,11 @@
 	end
 	print("");
 	print(msg or (name.." is required for Prosody to run, so we will now exit."));
-	print("More help can be found on our website, at http://prosody.im/doc/depends");
+	print("More help can be found on our website, at https://prosody.im/doc/depends");
 	print("**************************");
 	print("");
 end
 
--- COMPAT w/pre-0.8 Debian: The Debian config file used to use
--- util.ztact, which has been removed from Prosody in 0.8. This
--- is to log an error for people who still use it, so they can
--- update their configs.
-package.preload["util.ztact"] = function ()
-	if not package.loaded["core.loggingmanager"] then
-		error("util.ztact has been removed from Prosody and you need to fix your config "
-		    .."file. More information can be found at http://prosody.im/doc/packagers#ztact", 0);
-	else
-		error("module 'util.ztact' has been deprecated in Prosody 0.8.");
-	end
-end;
-
 local function check_dependencies()
 	if _VERSION < "Lua 5.1" then
 		print "***********************************"
@@ -77,6 +64,10 @@
 				["Source"] = "http://www.tecgraf.puc-rio.br/~diego/professional/luasocket/";
 			});
 		fatal = true;
+	elseif not socket.tcp4 then
+		-- COMPAT LuaSocket before being IP-version agnostic
+		socket.tcp4 = socket.tcp;
+		socket.udp4 = socket.udp;
 	end
 
 	local lfs, err = softreq "lfs"
@@ -156,7 +147,7 @@
 	if ssl then
 		local major, minor, veryminor, patched = ssl._VERSION:match("(%d+)%.(%d+)%.?(%d*)(M?)");
 		if not major or ((tonumber(major) == 0 and (tonumber(minor) or 0) <= 3 and (tonumber(veryminor) or 0) <= 2) and patched ~= "M") then
-			prosody.log("error", "This version of LuaSec contains a known bug that causes disconnects, see http://prosody.im/doc/depends");
+			prosody.log("error", "This version of LuaSec contains a known bug that causes disconnects, see https://prosody.im/doc/depends");
 		end
 	end
 	local lxp = softreq"lxp";
@@ -165,7 +156,7 @@
 			prosody.log("error", "The version of LuaExpat on your system leaves Prosody "
 				.."vulnerable to denial-of-service attacks. You should upgrade to "
 				.."LuaExpat 1.3.0 or higher as soon as possible. See "
-				.."http://prosody.im/doc/depends#luaexpat for more information.");
+				.."https://prosody.im/doc/depends#luaexpat for more information.");
 		end
 		if not lxp.new({}).getcurrentbytecount then
 			prosody.log("error", "The version of LuaExpat on your system does not support "
@@ -173,7 +164,7 @@
 				.."networks (e.g. the internet) vulnerable to denial-of-service "
 				.."attacks. You should upgrade to LuaExpat 1.3.0 or higher as "
 				.."soon as possible. See "
-				.."http://prosody.im/doc/depends#luaexpat for more information.");
+				.."https://prosody.im/doc/depends#luaexpat for more information.");
 		end
 	end
 end
--- a/util/envload.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/envload.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -4,7 +4,7 @@
 -- This project is MIT/X11 licensed. Please see the
 -- COPYING file in the source package for more information.
 --
--- luacheck: ignore 113/setfenv
+-- luacheck: ignore 113/setfenv 113/loadstring
 
 local load, loadstring, setfenv = load, loadstring, setfenv;
 local io_open = io.open;
--- a/util/events.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/events.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -15,6 +15,7 @@
 local next = next;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local function new()
 	-- Map event name to ordered list of handlers (lazily built): handlers[event_name] = array_of_handler_functions
@@ -26,7 +27,7 @@
 	-- Event map: event_map[handler_function] = priority_number
 	local event_map = {};
 	-- Called on-demand to build handlers entries
-	local function _rebuild_index(handlers, event)
+	local function _rebuild_index(self, event)
 		local _handlers = event_map[event];
 		if not _handlers or next(_handlers) == nil then return; end
 		local index = {};
@@ -34,7 +35,7 @@
 			t_insert(index, handler);
 		end
 		t_sort(index, function(a, b) return _handlers[a] > _handlers[b]; end);
-		handlers[event] = index;
+		self[event] = index;
 		return index;
 	end;
 	setmetatable(handlers, { __index = _rebuild_index });
@@ -61,13 +62,13 @@
 	local function get_handlers(event)
 		return handlers[event];
 	end;
-	local function add_handlers(handlers)
-		for event, handler in pairs(handlers) do
+	local function add_handlers(self)
+		for event, handler in pairs(self) do
 			add_handler(event, handler);
 		end
 	end;
-	local function remove_handlers(handlers)
-		for event, handler in pairs(handlers) do
+	local function remove_handlers(self)
+		for event, handler in pairs(self) do
 			remove_handler(event, handler);
 		end
 	end;
@@ -81,6 +82,7 @@
 		end
 	end;
 	local function fire_event(event_name, event_data)
+		-- luacheck: ignore 432/event_name 432/event_data
 		local w = wrappers[event_name] or global_wrappers;
 		if w then
 			local curr_wrapper = #w;
--- a/util/filters.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/filters.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -9,6 +9,7 @@
 local t_insert, t_remove = table.insert, table.remove;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local new_filter_hooks = {};
 
--- a/util/format.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/format.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -4,11 +4,10 @@
 
 local tostring = tostring;
 local select = select;
-local assert = assert;
-local unpack = unpack;
+local unpack = table.unpack or unpack; -- luacheck: ignore 113/unpack
 local type = type;
 
-local function format(format, ...)
+local function format(formatstring, ...)
 	local args, args_length = { ... }, select('#', ...);
 
 	-- format specifier spec:
@@ -25,7 +24,7 @@
 
 	-- process each format specifier
 	local i = 0;
-	format = format:gsub("%%[^cdiouxXaAeEfgGqs%%]*[cdiouxXaAeEfgGqs%%]", function(spec)
+	formatstring = formatstring:gsub("%%[^cdiouxXaAeEfgGqs%%]*[cdiouxXaAeEfgGqs%%]", function(spec)
 		if spec ~= "%%" then
 			i = i + 1;
 			local arg = args[i];
@@ -54,21 +53,12 @@
 		else
 			args[i] = tostring(arg);
 		end
-		format = format .. " [%s]"
+		formatstring = formatstring .. " [%s]"
 	end
 
-	return format:format(unpack(args));
-end
-
-local function test()
-	assert(format("%s", "hello") == "hello");
-	assert(format("%s") == "<nil>");
-	assert(format("%s", true) == "true");
-	assert(format("%d", true) == "[true]");
-	assert(format("%%", true) == "% [true]");
+	return formatstring:format(unpack(args));
 end
 
 return {
 	format = format;
-	test = test;
 };
--- a/util/http.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/http.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -57,8 +57,19 @@
 	return field:find(","..token:lower()..",", 1, true) ~= nil;
 end
 
+local function normalize_path(path, is_dir)
+	if is_dir then
+		if path:sub(-1,-1) ~= "/" then path = path.."/"; end
+	else
+		if path:sub(-1,-1) == "/" then path = path:sub(1, -2); end
+	end
+	if path:sub(1,1) ~= "/" then path = "/"..path; end
+	return path;
+end
+
 return {
 	urlencode = urlencode, urldecode = urldecode;
 	formencode = formencode, formdecode = formdecode;
 	contains_token = contains_token;
+	normalize_path = normalize_path;
 };
--- a/util/import.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/import.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -8,9 +8,9 @@
 
 
 
-local unpack = table.unpack or unpack; --luacheck: ignore 113
+local unpack = table.unpack or unpack; --luacheck: ignore 113 143
 local t_insert = table.insert;
-function import(module, ...)
+function _G.import(module, ...)
 	local m = package.loaded[module] or require(module);
 	if type(m) == "table" and ... then
 		local ret = {};
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/indexedbheap.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,157 @@
+
+local setmetatable = setmetatable;
+local math_floor = math.floor;
+local t_remove = table.remove;
+
+local function _heap_insert(self, item, sync, item2, index)
+	local pos = #self + 1;
+	while true do
+		local half_pos = math_floor(pos / 2);
+		if half_pos == 0 or item > self[half_pos] then break; end
+		self[pos] = self[half_pos];
+		sync[pos] = sync[half_pos];
+		index[sync[pos]] = pos;
+		pos = half_pos;
+	end
+	self[pos] = item;
+	sync[pos] = item2;
+	index[item2] = pos;
+end
+
+local function _percolate_up(self, k, sync, index)
+	local tmp = self[k];
+	local tmp_sync = sync[k];
+	while k ~= 1 do
+		local parent = math_floor(k/2);
+		if tmp < self[parent] then break; end
+		self[k] = self[parent];
+		sync[k] = sync[parent];
+		index[sync[k]] = k;
+		k = parent;
+	end
+	self[k] = tmp;
+	sync[k] = tmp_sync;
+	index[tmp_sync] = k;
+	return k;
+end
+
+local function _percolate_down(self, k, sync, index)
+	local tmp = self[k];
+	local tmp_sync = sync[k];
+	local size = #self;
+	local child = 2*k;
+	while 2*k <= size do
+		if child ~= size and self[child] > self[child + 1] then
+			child = child + 1;
+		end
+		if tmp > self[child] then
+			self[k] = self[child];
+			sync[k] = sync[child];
+			index[sync[k]] = k;
+		else
+			break;
+		end
+
+		k = child;
+		child = 2*k;
+	end
+	self[k] = tmp;
+	sync[k] = tmp_sync;
+	index[tmp_sync] = k;
+	return k;
+end
+
+local function _heap_pop(self, sync, index)
+	local size = #self;
+	if size == 0 then return nil; end
+
+	local result = self[1];
+	local result_sync = sync[1];
+	index[result_sync] = nil;
+	if size == 1 then
+		self[1] = nil;
+		sync[1] = nil;
+		return result, result_sync;
+	end
+	self[1] = t_remove(self);
+	sync[1] = t_remove(sync);
+	index[sync[1]] = 1;
+
+	_percolate_down(self, 1, sync, index);
+
+	return result, result_sync;
+end
+
+local indexed_heap = {};
+
+function indexed_heap:insert(item, priority, id)
+	if id == nil then
+		id = self.current_id;
+		self.current_id = id + 1;
+	end
+	self.items[id] = item;
+	_heap_insert(self.priorities, priority, self.ids, id, self.index);
+	return id;
+end
+function indexed_heap:pop()
+	local priority, id = _heap_pop(self.priorities, self.ids, self.index);
+	if id then
+		local item = self.items[id];
+		self.items[id] = nil;
+		return priority, item, id;
+	end
+end
+function indexed_heap:peek()
+	return self.priorities[1];
+end
+function indexed_heap:reprioritize(id, priority)
+	local k = self.index[id];
+	if k == nil then return; end
+	self.priorities[k] = priority;
+
+	k = _percolate_up(self.priorities, k, self.ids, self.index);
+	_percolate_down(self.priorities, k, self.ids, self.index);
+end
+function indexed_heap:remove_index(k)
+	local result = self.priorities[k];
+	if result == nil then return; end
+
+	local result_sync = self.ids[k];
+	local item = self.items[result_sync];
+	local size = #self.priorities;
+
+	self.priorities[k] = self.priorities[size];
+	self.ids[k] = self.ids[size];
+	self.index[self.ids[k]] = k;
+
+	t_remove(self.priorities);
+	t_remove(self.ids);
+
+	self.index[result_sync] = nil;
+	self.items[result_sync] = nil;
+
+	if size > k then
+		k = _percolate_up(self.priorities, k, self.ids, self.index);
+		_percolate_down(self.priorities, k, self.ids, self.index);
+	end
+
+	return result, item, result_sync;
+end
+function indexed_heap:remove(id)
+	return self:remove_index(self.index[id]);
+end
+
+local mt = { __index = indexed_heap };
+
+local _M = {
+	create = function()
+		return setmetatable({
+			ids = {}; -- heap of ids, sync'd with priorities
+			items = {}; -- map id->items
+			priorities = {}; -- heap of priorities
+			index = {}; -- map of id->index of id in ids
+			current_id = 1.5
+		}, mt);
+	end
+};
+return _M;
--- a/util/ip.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/ip.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -5,69 +5,76 @@
 -- COPYING file in the source package for more information.
 --
 
+local net = require "util.net";
+local hex = require "util.hex";
+
 local ip_methods = {};
-local ip_mt = { __index = function (ip, key) return (ip_methods[key])(ip); end,
-		__tostring = function (ip) return ip.addr; end,
-		__eq = function (ipA, ipB) return ipA.addr == ipB.addr; end};
-local hex2bits = { ["0"] = "0000", ["1"] = "0001", ["2"] = "0010", ["3"] = "0011", ["4"] = "0100", ["5"] = "0101", ["6"] = "0110", ["7"] = "0111", ["8"] = "1000", ["9"] = "1001", ["A"] = "1010", ["B"] = "1011", ["C"] = "1100", ["D"] = "1101", ["E"] = "1110", ["F"] = "1111" };
+
+local ip_mt = {
+	__index = function (ip, key)
+		local method = ip_methods[key];
+		if not method then return nil; end
+		local ret = method(ip);
+		ip[key] = ret;
+		return ret;
+	end,
+	__tostring = function (ip) return ip.addr; end,
+	__eq = function (ipA, ipB) return ipA.packed == ipB.packed; end
+};
+
+local hex2bits = {
+	["0"] = "0000", ["1"] = "0001", ["2"] = "0010", ["3"] = "0011",
+	["4"] = "0100", ["5"] = "0101", ["6"] = "0110", ["7"] = "0111",
+	["8"] = "1000", ["9"] = "1001", ["A"] = "1010", ["B"] = "1011",
+	["C"] = "1100", ["D"] = "1101", ["E"] = "1110", ["F"] = "1111",
+};
 
 local function new_ip(ipStr, proto)
-	if not proto then
-		local sep = ipStr:match("^%x+(.)");
-		if sep == ":" or (not(sep) and ipStr:sub(1,1) == ":") then
-			proto = "IPv6"
-		elseif sep == "." then
-			proto = "IPv4"
+	local zone;
+	if (not proto or proto == "IPv6") and ipStr:find('%', 1, true) then
+		ipStr, zone = ipStr:match("^(.-)%%(.*)");
+	end
+
+	local packed, err = net.pton(ipStr);
+	if not packed then return packed, err end
+	if proto == "IPv6" and #packed ~= 16 then
+		return nil, "invalid-ipv6";
+	elseif proto == "IPv4" and #packed ~= 4 then
+		return nil, "invalid-ipv4";
+	elseif not proto then
+		if #packed == 16 then
+			proto = "IPv6";
+		elseif #packed == 4 then
+			proto = "IPv4";
+		else
+			return nil, "unknown protocol";
 		end
-		if not proto then
-			return nil, "invalid address";
-		end
-	elseif proto ~= "IPv4" and proto ~= "IPv6" then
+	elseif proto ~= "IPv6" and proto ~= "IPv4" then
 		return nil, "invalid protocol";
 	end
-	local zone;
-	if proto == "IPv6" and ipStr:find('%', 1, true) then
-		ipStr, zone = ipStr:match("^(.-)%%(.*)");
-	end
-	if proto == "IPv6" and ipStr:find('.', 1, true) then
-		local changed;
-		ipStr, changed = ipStr:gsub(":(%d+)%.(%d+)%.(%d+)%.(%d+)$", function(a,b,c,d)
-			return (":%04X:%04X"):format(a*256+b,c*256+d);
-		end);
-		if changed ~= 1 then return nil, "invalid-address"; end
-	end
 
-	return setmetatable({ addr = ipStr, proto = proto, zone = zone }, ip_mt);
+	return setmetatable({ addr = ipStr, packed = packed, proto = proto, zone = zone }, ip_mt);
 end
 
-local function toBits(ip)
-	local result = "";
-	local fields = {};
+function ip_methods:normal()
+	return net.ntop(self.packed);
+end
+
+function ip_methods.bits(ip)
+	return hex.to(ip.packed):upper():gsub(".", hex2bits);
+end
+
+function ip_methods.bits_full(ip)
 	if ip.proto == "IPv4" then
 		ip = ip.toV4mapped;
 	end
-	ip = (ip.addr):upper();
-	ip:gsub("([^:]*):?", function (c) fields[#fields + 1] = c end);
-	if not ip:match(":$") then fields[#fields] = nil; end
-	for i, field in ipairs(fields) do
-		if field:len() == 0 and i ~= 1 and i ~= #fields then
-			for _ = 1, 16 * (9 - #fields) do
-				result = result .. "0";
-			end
-		else
-			for _ = 1, 4 - field:len() do
-				result = result .. "0000";
-			end
-			for j = 1, field:len() do
-				result = result .. hex2bits[field:sub(j, j)];
-			end
-		end
-	end
-	return result;
+	return ip.bits;
 end
 
+local match;
+
 local function commonPrefixLength(ipA, ipB)
-	ipA, ipB = toBits(ipA), toBits(ipB);
+	ipA, ipB = ipA.bits_full, ipB.bits_full;
 	for i = 1, 128 do
 		if ipA:sub(i,i) ~= ipB:sub(i,i) then
 			return i-1;
@@ -76,56 +83,60 @@
 	return 128;
 end
 
+-- Instantiate once
+local loopback = new_ip("::1");
+local loopback4 = new_ip("127.0.0.0");
+local sixtofour = new_ip("2002::");
+local teredo = new_ip("2001::");
+local linklocal = new_ip("fe80::");
+local linklocal4 = new_ip("169.254.0.0");
+local uniquelocal = new_ip("fc00::");
+local sitelocal = new_ip("fec0::");
+local sixbone = new_ip("3ffe::");
+local defaultunicast = new_ip("::");
+local multicast = new_ip("ff00::");
+local ipv6mapped = new_ip("::ffff:0:0");
+
 local function v4scope(ip)
-	local fields = {};
-	ip:gsub("([^.]*).?", function (c) fields[#fields + 1] = tonumber(c) end);
-	-- Loopback:
-	if fields[1] == 127 then
+	if match(ip, loopback4, 8) then
 		return 0x2;
-	-- Link-local unicast:
-	elseif fields[1] == 169 and fields[2] == 254 then
+	elseif match(ip, linklocal4) then
 		return 0x2;
-	-- Global unicast:
-	else
+	else -- Global unicast
 		return 0xE;
 	end
 end
 
 local function v6scope(ip)
-	-- Loopback:
-	if ip:match("^[0:]*1$") then
+	if ip == loopback then
 		return 0x2;
-	-- Link-local unicast:
-	elseif ip:match("^[Ff][Ee][89ABab]") then
+	elseif match(ip, linklocal, 10) then
 		return 0x2;
-	-- Site-local unicast:
-	elseif ip:match("^[Ff][Ee][CcDdEeFf]") then
+	elseif match(ip, sitelocal, 10) then
 		return 0x5;
-	-- Multicast:
-	elseif ip:match("^[Ff][Ff]") then
-		return tonumber("0x"..ip:sub(4,4));
-	-- Global unicast:
-	else
+	elseif match(ip, multicast, 10) then
+		return ip.packed:byte(2) % 0x10;
+	else -- Global unicast
 		return 0xE;
 	end
 end
 
 local function label(ip)
-	if commonPrefixLength(ip, new_ip("::1", "IPv6")) == 128 then
+	if ip == loopback then
 		return 0;
-	elseif commonPrefixLength(ip, new_ip("2002::", "IPv6")) >= 16 then
+	elseif match(ip, sixtofour, 16) then
 		return 2;
-	elseif commonPrefixLength(ip, new_ip("2001::", "IPv6")) >= 32 then
+	elseif match(ip, teredo, 32) then
 		return 5;
-	elseif commonPrefixLength(ip, new_ip("fc00::", "IPv6")) >= 7 then
+	elseif match(ip, uniquelocal, 7) then
 		return 13;
-	elseif commonPrefixLength(ip, new_ip("fec0::", "IPv6")) >= 10 then
+	elseif match(ip, sitelocal, 10) then
 		return 11;
-	elseif commonPrefixLength(ip, new_ip("3ffe::", "IPv6")) >= 16 then
+	elseif match(ip, sixbone, 16) then
 		return 12;
-	elseif commonPrefixLength(ip, new_ip("::", "IPv6")) >= 96 then
+	elseif match(ip, defaultunicast, 96) then
 		return 3;
-	elseif commonPrefixLength(ip, new_ip("::ffff:0:0", "IPv6")) >= 96 then
+	elseif match(ip, ipv6mapped, 96) then
 		return 4;
 	else
 		return 1;
@@ -133,91 +144,67 @@
 end
 
 local function precedence(ip)
-	if commonPrefixLength(ip, new_ip("::1", "IPv6")) == 128 then
+	if ip == loopback then
 		return 50;
-	elseif commonPrefixLength(ip, new_ip("2002::", "IPv6")) >= 16 then
+	elseif match(ip, sixtofour, 16) then
 		return 30;
-	elseif commonPrefixLength(ip, new_ip("2001::", "IPv6")) >= 32 then
+	elseif match(ip, teredo, 32) then
 		return 5;
-	elseif commonPrefixLength(ip, new_ip("fc00::", "IPv6")) >= 7 then
+	elseif match(ip, uniquelocal, 7) then
 		return 3;
-	elseif commonPrefixLength(ip, new_ip("fec0::", "IPv6")) >= 10 then
+	elseif match(ip, sitelocal, 10) then
 		return 1;
-	elseif commonPrefixLength(ip, new_ip("3ffe::", "IPv6")) >= 16 then
+	elseif match(ip, sixbone, 16) then
 		return 1;
-	elseif commonPrefixLength(ip, new_ip("::", "IPv6")) >= 96 then
+	elseif match(ip, defaultunicast, 96) then
 		return 1;
-	elseif commonPrefixLength(ip, new_ip("::ffff:0:0", "IPv6")) >= 96 then
+	elseif match(ip, ipv6mapped, 96) then
 		return 35;
 	else
 		return 40;
 	end
 end
 
-local function toV4mapped(ip)
-	local fields = {};
-	local ret = "::ffff:";
-	ip:gsub("([^.]*).?", function (c) fields[#fields + 1] = tonumber(c) end);
-	ret = ret .. ("%02x"):format(fields[1]);
-	ret = ret .. ("%02x"):format(fields[2]);
-	ret = ret .. ":"
-	ret = ret .. ("%02x"):format(fields[3]);
-	ret = ret .. ("%02x"):format(fields[4]);
-	return new_ip(ret, "IPv6");
-end
-
 function ip_methods:toV4mapped()
 	if self.proto ~= "IPv4" then return nil, "No IPv4 address" end
-	local value = toV4mapped(self.addr);
-	self.toV4mapped = value;
+	local value = new_ip("::ffff:" .. self.normal);
 	return value;
 end
 
 function ip_methods:label()
-	local value;
 	if self.proto == "IPv4" then
-		value = label(self.toV4mapped);
+		return label(self.toV4mapped);
 	else
-		value = label(self);
+		return label(self);
 	end
-	self.label = value;
-	return value;
 end
 
 function ip_methods:precedence()
-	local value;
 	if self.proto == "IPv4" then
-		value = precedence(self.toV4mapped);
+		return precedence(self.toV4mapped);
 	else
-		value = precedence(self);
+		return precedence(self);
 	end
-	self.precedence = value;
-	return value;
 end
 
 function ip_methods:scope()
-	local value;
 	if self.proto == "IPv4" then
-		value = v4scope(self.addr);
+		return v4scope(self);
 	else
-		value = v6scope(self.addr);
+		return v6scope(self);
 	end
-	self.scope = value;
-	return value;
 end
 
+local rfc1918_8 = new_ip("10.0.0.0");
+local rfc1918_12 = new_ip("172.16.0.0");
+local rfc1918_16 = new_ip("192.168.0.0");
+local rfc6598 = new_ip("100.64.0.0");
+
 function ip_methods:private()
 	local private = self.scope ~= 0xE;
 	if not private and self.proto == "IPv4" then
-		local ip = self.addr;
-		local fields = {};
-		ip:gsub("([^.]*).?", function (c) fields[#fields + 1] = tonumber(c) end);
-		if fields[1] == 127 or fields[1] == 10 or (fields[1] == 192 and fields[2] == 168)
-		or (fields[1] == 172 and (fields[2] >= 16 or fields[2] <= 32)) then
-			private = true;
-		end
+		return match(self, rfc1918_8, 8) or match(self, rfc1918_12, 12) or match(self, rfc1918_16) or match(self, rfc6598, 10);
 	end
-	self.private = private;
 	return private;
 end
 
@@ -231,15 +218,26 @@
 	return new_ip(cidr), bits;
 end
 
-local function match(ipA, ipB, bits)
-	local common_bits = commonPrefixLength(ipA, ipB);
-	if bits and ipB.proto == "IPv4" then
-		common_bits = common_bits - 96; -- v6 mapped addresses always share these bits
+function match(ipA, ipB, bits)
+	if not bits or bits >= 128 or ipB.proto == "IPv4" and bits >= 32 then
+		return ipA == ipB;
+	elseif bits < 1 then
+		return true;
 	end
-	return common_bits >= (bits or 128);
+	if ipA.proto ~= ipB.proto then
+		if ipA.proto == "IPv4" then
+			ipA = ipA.toV4mapped;
+		elseif ipB.proto == "IPv4" then
+			ipB = ipB.toV4mapped;
+			bits = bits + (128 - 32);
+		end
+	end
+	return ipA.bits:sub(1, bits) == ipB.bits:sub(1, bits);
 end
 
-return {new_ip = new_ip,
+return {
+	new_ip = new_ip,
 	commonPrefixLength = commonPrefixLength,
 	parse_cidr = parse_cidr,
-	match=match};
+	match = match,
+};
--- a/util/iterators.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/iterators.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -12,8 +12,13 @@
 
 local t_insert = table.insert;
 local select, next = select, next;
-local unpack = table.unpack or unpack; --luacheck: ignore 113
-local pack = table.pack or function (...) return { n = select("#", ...), ... }; end
+local unpack = table.unpack or unpack; --luacheck: ignore 113 143
+local pack = table.pack or function (...) return { n = select("#", ...), ... }; end -- luacheck: ignore 143
+local type = type;
+local table, setmetatable = table, setmetatable;
+
+local _ENV = nil;
+--luacheck: std none
 
 -- Reverse an iterator
 function it.reverse(f, s, var)
@@ -172,6 +177,19 @@
 	return t;
 end
 
+function it.sorted_pairs(t, sort_func)
+	local keys = it.to_array(it.keys(t));
+	table.sort(keys, sort_func);
+	local i = 0;
+	return function ()
+		i = i + 1;
+		local key = keys[i];
+		if key ~= nil then
+			return key, t[key];
+		end
+	end;
+end
+
 -- Treat the return of an iterator as key,value pairs,
 -- and build a table
 function it.to_table(f, s, var)
@@ -184,4 +202,45 @@
 	return t;
 end
 
+local function _join_iter(j_s, j_var)
+	local iterators, current_idx = j_s[1], j_s[2];
+	local f, s, var = unpack(iterators[current_idx], 1, 3);
+	if j_var ~= nil then
+		var = j_var;
+	end
+	local ret = pack(f(s, var));
+	local var1 = ret[1];
+	if var1 == nil then
+		-- End of this iterator, advance to next
+		if current_idx == #iterators then
+			-- No more iterators, return nil
+			return;
+		end
+		j_s[2] = current_idx + 1;
+		return _join_iter(j_s);
+	end
+	return unpack(ret, 1, ret.n);
+end
+local join_methods = {};
+local join_mt = {
+	__index = join_methods;
+	__call = function (t, s, var) --luacheck: ignore 212/t
+		return _join_iter(s, var);
+	end;
+};
+
+function join_methods:append(f, s, var)
+	table.insert(self, { f, s, var });
+	return self, { self, 1 };
+end
+
+function join_methods:prepend(f, s, var)
+	table.insert(self, { f, s, var }, 1);
+	return self, { self, 1 };
+end
+
+function it.join(f, s, var)
+	return setmetatable({ {f, s, var} }, join_mt);
+end
+
 return it;
--- a/util/jid.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/jid.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -25,11 +25,12 @@
 for k,v in pairs(escapes) do unescapes[v] = k; end
 
 local _ENV = nil;
+-- luacheck: std none
 
 local function split(jid)
 	if not jid then return; end
 	local node, nodepos = match(jid, "^([^@/]+)@()");
-	local host, hostpos = match(jid, "^([^@/]+)()", nodepos)
+	local host, hostpos = match(jid, "^([^@/]+)()", nodepos);
 	if node and not host then return nil, nil, nil; end
 	local resource = match(jid, "^/(.+)$", hostpos);
 	if (not host) or ((not resource) and #jid >= hostpos) then return nil, nil, nil; end
--- a/util/json.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/json.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -7,10 +7,10 @@
 --
 
 local type = type;
-local t_insert, t_concat, t_remove, t_sort = table.insert, table.concat, table.remove, table.sort;
+local t_insert, t_concat, t_remove = table.insert, table.concat, table.remove;
 local s_char = string.char;
 local tostring, tonumber = tostring, tonumber;
-local pairs, ipairs = pairs, ipairs;
+local pairs, ipairs, spairs = pairs, ipairs, require "util.iterators".sorted_pairs;
 local next = next;
 local getmetatable, setmetatable = getmetatable, setmetatable;
 local print = print;
@@ -27,9 +27,6 @@
 local escapes = {
 	["\""] = "\\\"", ["\\"] = "\\\\", ["\b"] = "\\b",
 	["\f"] = "\\f", ["\n"] = "\\n", ["\r"] = "\\r", ["\t"] = "\\t"};
-local unescapes = {
-	["\""] = "\"", ["\\"] = "\\", ["/"] = "/",
-	b = "\b", f = "\f", n = "\n", r = "\r", t = "\t"};
 for i=0,31 do
 	local ch = s_char(i);
 	if not escapes[ch] then escapes[ch] = ("\\u%.4X"):format(i); end
@@ -98,25 +95,12 @@
 	if next(__hash) ~= nil or next(hash) ~= nil or next(__array) == nil then
 		t_insert(buffer, "{");
 		local mark = #buffer;
-		if buffer.ordered then
-			local keys = {};
-			for k in pairs(hash) do
-				t_insert(keys, k);
-			end
-			t_sort(keys);
-			for _,k in ipairs(keys) do
-				stringsave(k, buffer);
-				t_insert(buffer, ":");
-				simplesave(hash[k], buffer);
-				t_insert(buffer, ",");
-			end
-		else
-			for k,v in pairs(hash) do
-				stringsave(k, buffer);
-				t_insert(buffer, ":");
-				simplesave(v, buffer);
-				t_insert(buffer, ",");
-			end
+		local _pairs = buffer.ordered and spairs or pairs;
+		for k,v in _pairs(hash) do
+			stringsave(k, buffer);
+			t_insert(buffer, ":");
+			simplesave(v, buffer);
+			t_insert(buffer, ",");
 		end
 		if next(__hash) ~= nil then
 			t_insert(buffer, "\"__hash\":[");
@@ -263,8 +247,9 @@
 local function _unescape_func(x)
 	x = x:match("%x%x%x%x", 3);
 	if x then
-		--if x >= 0xD800 and x <= 0xDFFF then _unescape_error = true; end -- bad surrogate pair
-		return codepoint_to_utf8(tonumber(x, 16));
+		local codepoint = tonumber(x, 16)
+		if codepoint >= 0xD800 and codepoint <= 0xDFFF then _unescape_error = true; end -- bad surrogate pair
+		return codepoint_to_utf8(codepoint);
 	end
 	_unescape_error = true;
 end
@@ -276,7 +261,7 @@
 		--if s:find("[%z-\31]") then return nil, "control char in string"; end
 		-- FIXME handle control characters
 		_unescape_error = nil;
-		--s = s:gsub("\\u[dD][89abAB]%x%x\\u[dD][cdefCDEF]%x%x", _unescape_surrogate_func);
+		s = s:gsub("\\u[dD][89abAB]%x%x\\u[dD][cdefCDEF]%x%x", _unescape_surrogate_func);
 		-- FIXME handle escapes beyond BMP
 		s = s:gsub("\\u.?.?.?.?", _unescape_func);
 		if _unescape_error then return nil, "invalid escape"; end
--- a/util/logger.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/logger.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -8,8 +8,11 @@
 -- luacheck: ignore 213/level
 
 local pairs = pairs;
+local ipairs = ipairs;
+local require = require;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local level_sinks = {};
 
@@ -67,10 +70,21 @@
 	end
 end
 
+local function add_simple_sink(simple_sink_function, levels)
+	local format = require "util.format".format;
+	local function sink_function(name, level, msg, ...)
+		return simple_sink_function(name, level, format(msg, ...));
+	end
+	for _, level in ipairs(levels or {"debug", "info", "warn", "error"}) do
+		add_level_sink(level, sink_function);
+	end
+end
+
 return {
 	init = init;
 	make_logger = make_logger;
 	reset = reset;
 	add_level_sink = add_level_sink;
+	add_simple_sink = add_simple_sink;
 	new = make_logger;
 };
--- a/util/multitable.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/multitable.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -9,9 +9,10 @@
 local select = select;
 local t_insert = table.insert;
 local pairs, next, type = pairs, next, type;
-local unpack = table.unpack or unpack; --luacheck: ignore 113
+local unpack = table.unpack or unpack; --luacheck: ignore 113 143
 
 local _ENV = nil;
+-- luacheck: std none
 
 local function get(self, ...)
 	local t = self.data;
@@ -132,7 +133,7 @@
 	local maxdepth = select("#", ...);
 	local stack = { self.data };
 	local keys = { };
-	local function it(self)
+	local function it(self) -- luacheck: ignore 432/self
 		local depth = #stack;
 		local key = next(stack[depth], keys[depth]);
 		if key == nil then -- Go up the stack
--- a/util/openssl.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/openssl.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -114,7 +114,7 @@
 		s_format("%s;%s", oid_xmppaddr, utf8string(host)));
 end
 
-function ssl_config:from_prosody(hosts, config, certhosts)
+function ssl_config:from_prosody(hosts, config, certhosts) -- luacheck: ignore 431/config
 	-- TODO Decide if this should go elsewhere
 	local found_matching_hosts = false;
 	for i = 1, #certhosts do
--- a/util/pluginloader.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/pluginloader.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -5,6 +5,7 @@
 -- This project is MIT/X11 licensed. Please see the
 -- COPYING file in the source package for more information.
 --
+-- luacheck: ignore 113/CFG_PLUGINDIR
 
 local dir_sep, path_sep = package.config:match("^(%S+)%s(%S+)");
 local plugin_dir = {};
--- a/util/presence.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/presence.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -13,7 +13,6 @@
 	local recipients = {};
 	for _, session in pairs(user.sessions) do -- find resource with greatest priority
 		if session.presence then
-			-- TODO check active privacy list for session
 			local p = session.priority;
 			if p > priority then
 				priority = p;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/promise.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,152 @@
+local promise_methods = {};
+local promise_mt = { __name = "promise", __index = promise_methods };
+
+local xpcall = require "util.xpcall".xpcall;
+
+function promise_mt:__tostring()
+	return  "promise (" .. (self._state or "invalid") .. ")";
+end
+
+local function is_promise(o)
+	local mt = getmetatable(o);
+	return mt == promise_mt;
+end
+
+local function wrap_handler(f, resolve, reject, default)
+	if not f then
+		return default;
+	end
+	return function (param)
+		local ok, ret = xpcall(f, debug.traceback, param);
+		if ok then
+			resolve(ret);
+		else
+			reject(ret);
+		end
+		return true;
+	end;
+end
+
+local function next_pending(self, on_fulfilled, on_rejected, resolve, reject)
+	table.insert(self._pending_on_fulfilled, wrap_handler(on_fulfilled, resolve, reject, resolve));
+	table.insert(self._pending_on_rejected, wrap_handler(on_rejected, resolve, reject, reject));
+end
+
+local function next_fulfilled(promise, on_fulfilled, on_rejected, resolve, reject) -- luacheck: ignore 212/on_rejected
+	wrap_handler(on_fulfilled, resolve, reject, resolve)(promise.value);
+end
+
+local function next_rejected(promise, on_fulfilled, on_rejected, resolve, reject) -- luacheck: ignore 212/on_fulfilled
+	wrap_handler(on_rejected, resolve, reject, reject)(promise.reason);
+end
+
+local function promise_settle(promise, new_state, new_next, cbs, value)
+	if promise._state ~= "pending" then
+		return;
+	end
+	promise._state = new_state;
+	promise._next = new_next;
+	for _, cb in ipairs(cbs) do
+		cb(value);
+	end
+	return true;
+end
+
+local function new_resolve_functions(p)
+	local resolved = false;
+	local function _resolve(v)
+		if resolved then return; end
+		resolved = true;
+		if is_promise(v) then
+			v:next(new_resolve_functions(p));
+		elseif promise_settle(p, "fulfilled", next_fulfilled, p._pending_on_fulfilled, v) then
+			p.value = v;
+		end
+
+	end
+	local function _reject(e)
+		if resolved then return; end
+		resolved = true;
+		if promise_settle(p, "rejected", next_rejected, p._pending_on_rejected, e) then
+			p.reason = e;
+		end
+	end
+	return _resolve, _reject;
+end
+
+local function new(f)
+	local p = setmetatable({ _state = "pending", _next = next_pending, _pending_on_fulfilled = {}, _pending_on_rejected = {} }, promise_mt);
+	if f then
+		local resolve, reject = new_resolve_functions(p);
+		local ok, ret = pcall(f, resolve, reject);
+		if not ok and p._state == "pending" then
+			reject(ret);
+		end
+	end
+	return p;
+end
+
+local function all(promises)
+	return new(function (resolve, reject)
+		local count, total, results = 0, #promises, {};
+		for i = 1, total do
+			promises[i]:next(function (v)
+				results[i] = v;
+				count = count + 1;
+				if count == total then
+					resolve(results);
+				end
+			end, reject);
+		end
+	end);
+end
+
+local function race(promises)
+	return new(function (resolve, reject)
+		for i = 1, #promises do
+			promises[i]:next(resolve, reject);
+		end
+	end);
+end
+
+local function resolve(v)
+	return new(function (_resolve)
+		_resolve(v);
+	end);
+end
+
+local function reject(v)
+	return new(function (_, _reject)
+		_reject(v);
+	end);
+end
+
+local function try(f)
+	return resolve():next(function () return f(); end);
+end
+
+function promise_methods:next(on_fulfilled, on_rejected)
+	return new(function (resolve, reject) --luacheck: ignore 431/resolve 431/reject
+		self:_next(on_fulfilled, on_rejected, resolve, reject);
+	end);
+end
+
+function promise_methods:catch(on_rejected)
+	return self:next(nil, on_rejected);
+end
+
+function promise_methods:finally(on_finally)
+	local function _on_finally(value) on_finally(); return value; end
+	local function _on_catch_finally(err) on_finally(); return reject(err); end
+	return self:next(_on_finally, _on_catch_finally);
+end
+
+return {
+	new = new;
+	resolve = resolve;
+	reject = reject;
+	all = all;
+	race = race;
+	try = try;
+	is_promise = is_promise;
+}
--- a/util/prosodyctl.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/prosodyctl.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -24,8 +24,6 @@
 local print = print;
 local tonumber = tonumber;
 
-local CFG_SOURCEDIR = _G.CFG_SOURCEDIR;
-
 local _G = _G;
 local prosody = prosody;
 
@@ -66,7 +64,10 @@
 end
 
 local function getpass()
-	local stty_ret = os.execute("stty -echo 2>/dev/null");
+	local stty_ret, _, status_code = os.execute("stty -echo 2>/dev/null");
+	if status_code then -- COMPAT w/ Lua 5.1
+		stty_ret = status_code;
+	end
 	if stty_ret ~= 0 then
 		io.write("\027[08m"); -- ANSI 'hidden' text attribute
 	end
@@ -189,8 +190,8 @@
 
 	pidfile = config.resolve_relative_path(prosody.paths.data, pidfile);
 
-	local modules_enabled = set.new(config.get("*", "modules_disabled"));
-	if prosody.platform ~= "posix" or modules_enabled:contains("posix") then
+	local modules_disabled = set.new(config.get("*", "modules_disabled"));
+	if prosody.platform ~= "posix" or modules_disabled:contains("posix") then
 		return false, "no-posix";
 	end
 
@@ -228,7 +229,7 @@
 	return true, signal.kill(pid, 0) == 0;
 end
 
-local function start()
+local function start(source_dir)
 	local ok, ret = isrunning();
 	if not ok then
 		return ok, ret;
@@ -236,10 +237,10 @@
 	if ret then
 		return false, "already-running";
 	end
-	if not CFG_SOURCEDIR then
+	if not source_dir then
 		os.execute("./prosody");
 	else
-		os.execute(CFG_SOURCEDIR.."/../../bin/prosody");
+		os.execute(source_dir.."/../../bin/prosody");
 	end
 	return true;
 end
--- a/util/pubsub.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/pubsub.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -1,50 +1,226 @@
 local events = require "util.events";
 local cache = require "util.cache";
 
-local service = {};
-local service_mt = { __index = service };
+local service_mt = {};
 
-local default_config = { __index = {
-	itemstore = function (config) return cache.new(tonumber(config["pubsub#max_items"])) end;
+local default_config = {
+	itemstore = function (config, _) return cache.new(config["max_items"]) end;
 	broadcaster = function () end;
+	itemcheck = function () return true; end;
 	get_affiliation = function () end;
-	capabilities = {};
-} };
-local default_node_config = { __index = {
-	["pubsub#max_items"] = "20";
-} };
+	normalize_jid = function (jid) return jid; end;
+	capabilities = {
+		outcast = {
+			create = false;
+			publish = false;
+			retract = false;
+			get_nodes = false;
+
+			subscribe = false;
+			unsubscribe = false;
+			get_subscription = true;
+			get_subscriptions = true;
+			get_items = false;
+
+			subscribe_other = false;
+			unsubscribe_other = false;
+			get_subscription_other = false;
+			get_subscriptions_other = false;
+
+			be_subscribed = false;
+			be_unsubscribed = true;
+
+			set_affiliation = false;
+		};
+		none = {
+			create = false;
+			publish = false;
+			retract = false;
+			get_nodes = true;
+
+			subscribe = true;
+			unsubscribe = true;
+			get_subscription = true;
+			get_subscriptions = true;
+			get_items = false;
+
+			subscribe_other = false;
+			unsubscribe_other = false;
+			get_subscription_other = false;
+			get_subscriptions_other = false;
+
+			be_subscribed = true;
+			be_unsubscribed = true;
+
+			set_affiliation = false;
+		};
+		member = {
+			create = false;
+			publish = false;
+			retract = false;
+			get_nodes = true;
+
+			subscribe = true;
+			unsubscribe = true;
+			get_subscription = true;
+			get_subscriptions = true;
+			get_items = true;
+
+			subscribe_other = false;
+			unsubscribe_other = false;
+			get_subscription_other = false;
+			get_subscriptions_other = false;
+
+			be_subscribed = true;
+			be_unsubscribed = true;
+
+			set_affiliation = false;
+		};
+		publisher = {
+			create = false;
+			publish = true;
+			retract = true;
+			get_nodes = true;
+			get_configuration = true;
 
+			subscribe = true;
+			unsubscribe = true;
+			get_subscription = true;
+			get_subscriptions = true;
+			get_items = true;
+
+			subscribe_other = false;
+			unsubscribe_other = false;
+			get_subscription_other = false;
+			get_subscriptions_other = false;
+
+			be_subscribed = true;
+			be_unsubscribed = true;
+
+			set_affiliation = false;
+		};
+		owner = {
+			create = true;
+			publish = true;
+			retract = true;
+			delete = true;
+			get_nodes = true;
+			configure = true;
+			get_configuration = true;
+
+			subscribe = true;
+			unsubscribe = true;
+			get_subscription = true;
+			get_subscriptions = true;
+			get_items = true;
+
+
+			subscribe_other = true;
+			unsubscribe_other = true;
+			get_subscription_other = true;
+			get_subscriptions_other = true;
+
+			be_subscribed = true;
+			be_unsubscribed = true;
+
+			set_affiliation = true;
+		};
+	};
+};
+local default_config_mt = { __index = default_config };
+
+local default_node_config = {
+	["persist_items"] = false;
+	["max_items"] = 20;
+	["access_model"] = "open";
+	["publish_model"] = "publishers";
+};
+local default_node_config_mt = { __index = default_node_config };
+
+-- Storage helper functions
+
+local function load_node_from_store(service, node_name)
+	local node = service.config.nodestore:get(node_name);
+	node.config = setmetatable(node.config or {}, {__index=service.node_defaults});
+	return node;
+end
+
+local function save_node_to_store(service, node)
+	return service.config.nodestore:set(node.name, {
+		name = node.name;
+		config = node.config;
+		subscribers = node.subscribers;
+		affiliations = node.affiliations;
+	});
+end
+
+local function delete_node_in_store(service, node_name)
+	return service.config.nodestore:set(node_name, nil);
+end
+
+-- Create and return a new service object
 local function new(config)
 	config = config or {};
-	return setmetatable({
-		config = setmetatable(config, default_config);
-		node_defaults = setmetatable(config.node_defaults or {}, default_node_config);
+
+	local service = setmetatable({
+		config = setmetatable(config, default_config_mt);
+		node_defaults = setmetatable(config.node_defaults or {}, default_node_config_mt);
 		affiliations = {};
 		subscriptions = {};
 		nodes = {};
 		data = {};
 		events = events.new();
 	}, service_mt);
+
+	-- Load nodes from storage, if we have a store and it supports iterating over stored items
+	if config.nodestore and config.nodestore.users then
+		for node_name in config.nodestore:users() do
+			service.nodes[node_name] = load_node_from_store(service, node_name);
+			service.data[node_name] = config.itemstore(service.nodes[node_name].config, node_name);
+
+			for jid in pairs(service.nodes[node_name].subscribers) do
+				local normal_jid = service.config.normalize_jid(jid);
+				local subs = service.subscriptions[normal_jid];
+				if subs then
+					if not subs[jid] then
+						subs[jid] = { [node_name] = true };
+					else
+						subs[jid][node_name] = true;
+					end
+				else
+					service.subscriptions[normal_jid] = { [jid] = { [node_name] = true } };
+				end
+			end
+		end
+	end
+
+	return service;
 end
 
-function service:jids_equal(jid1, jid2)
+--- Service methods
+
+local service = {};
+service_mt.__index = service;
+
+function service:jids_equal(jid1, jid2) --> boolean
 	local normalize = self.config.normalize_jid;
 	return normalize(jid1) == normalize(jid2);
 end
 
-function service:may(node, actor, action)
+function service:may(node, actor, action) --> boolean
 	if actor == true then return true; end
 
 	local node_obj = self.nodes[node];
-	local node_aff = node_obj and node_obj.affiliations[actor];
+	local node_aff = node_obj and (node_obj.affiliations[actor]
+	              or node_obj.affiliations[self.config.normalize_jid(actor)]);
 	local service_aff = self.affiliations[actor]
-	                 or self.config.get_affiliation(actor, node, action)
-	                 or "none";
+	                 or self.config.get_affiliation(actor, node, action);
+	local default_aff = self:get_default_affiliation(node, actor) or "none";
 
 	-- Check if node allows/forbids it
 	local node_capabilities = node_obj and node_obj.capabilities;
 	if node_capabilities then
-		local caps = node_capabilities[node_aff or service_aff];
+		local caps = node_capabilities[node_aff or service_aff or default_aff];
 		if caps then
 			local can = caps[action];
 			if can ~= nil then
@@ -55,7 +231,7 @@
 
 	-- Check service-wide capabilities instead
 	local service_capabilities = self.config.capabilities;
-	local caps = service_capabilities[node_aff or service_aff];
+	local caps = service_capabilities[node_aff or service_aff or default_aff];
 	if caps then
 		local can = caps[action];
 		if can ~= nil then
@@ -66,7 +242,29 @@
 	return false;
 end
 
-function service:set_affiliation(node, actor, jid, affiliation)
+function service:get_default_affiliation(node, actor) --> affiliation
+	local node_obj = self.nodes[node];
+	local access_model = node_obj and node_obj.config.access_model
+		or self.node_defaults.access_model;
+
+	if access_model == "open" then
+		return "member";
+	elseif access_model == "whitelist" then
+		return "outcast";
+	end
+
+	if self.config.access_models then
+		local check = self.config.access_models[access_model];
+		if check then
+			local aff = check(actor);
+			if aff then
+				return aff;
+			end
+		end
+	end
+end
+
+function service:set_affiliation(node, actor, jid, affiliation) --> ok, err
 	-- Access checking
 	if not self:may(node, actor, "set_affiliation") then
 		return false, "forbidden";
@@ -76,7 +274,18 @@
 	if not node_obj then
 		return false, "item-not-found";
 	end
+	jid = self.config.normalize_jid(jid);
+	local old_affiliation = node_obj.affiliations[jid];
 	node_obj.affiliations[jid] = affiliation;
+
+	if self.config.nodestore then
+		local ok, err = save_node_to_store(self, node_obj);
+		if not ok then
+			node_obj.affiliations[jid] = old_affiliation;
+			return ok, "internal-server-error";
+		end
+	end
+
 	local _, jid_sub = self:get_subscription(node, true, jid);
 	if not jid_sub and not self:may(node, jid, "be_unsubscribed") then
 		local ok, err = self:add_subscription(node, true, jid);
@@ -92,7 +301,7 @@
 	return true;
 end
 
-function service:add_subscription(node, actor, jid, options)
+function service:add_subscription(node, actor, jid, options) --> ok, err
 	-- Access checking
 	local cap;
 	if actor == true or jid == actor or self:jids_equal(actor, jid) then
@@ -119,6 +328,7 @@
 			node_obj = self.nodes[node];
 		end
 	end
+	local old_subscription = node_obj.subscribers[jid];
 	node_obj.subscribers[jid] = options or true;
 	local normal_jid = self.config.normalize_jid(jid);
 	local subs = self.subscriptions[normal_jid];
@@ -131,11 +341,21 @@
 	else
 		self.subscriptions[normal_jid] = { [jid] = { [node] = true } };
 	end
-	self.events.fire_event("subscription-added", { node = node, jid = jid, normalized_jid = normal_jid, options = options });
+
+	if self.config.nodestore then
+		local ok, err = save_node_to_store(self, node_obj);
+		if not ok then
+			node_obj.subscribers[jid] = old_subscription;
+			self.subscriptions[normal_jid][jid][node] = old_subscription and true or nil;
+			return ok, "internal-server-error";
+		end
+	end
+
+	self.events.fire_event("subscription-added", { service = self, node = node, jid = jid, normalized_jid = normal_jid, options = options });
 	return true;
 end
 
-function service:remove_subscription(node, actor, jid)
+function service:remove_subscription(node, actor, jid) --> ok, err
 	-- Access checking
 	local cap;
 	if actor == true or jid == actor or self:jids_equal(actor, jid) then
@@ -157,6 +377,7 @@
 	if not node_obj.subscribers[jid] then
 		return false, "not-subscribed";
 	end
+	local old_subscription = node_obj.subscribers[jid];
 	node_obj.subscribers[jid] = nil;
 	local normal_jid = self.config.normalize_jid(jid);
 	local subs = self.subscriptions[normal_jid];
@@ -172,23 +393,21 @@
 			self.subscriptions[normal_jid] = nil;
 		end
 	end
-	self.events.fire_event("subscription-removed", { node = node, jid = jid, normalized_jid = normal_jid });
+
+	if self.config.nodestore then
+		local ok, err = save_node_to_store(self, node_obj);
+		if not ok then
+			node_obj.subscribers[jid] = old_subscription;
+			self.subscriptions[normal_jid][jid][node] = old_subscription and true or nil;
+			return ok, "internal-server-error";
+		end
+	end
+
+	self.events.fire_event("subscription-removed", { service = self, node = node, jid = jid, normalized_jid = normal_jid });
 	return true;
 end
 
-function service:remove_all_subscriptions(actor, jid)
-	local normal_jid = self.config.normalize_jid(jid);
-	local subs = self.subscriptions[normal_jid]
-	subs = subs and subs[jid];
-	if subs then
-		for node in pairs(subs) do
-			self:remove_subscription(node, true, jid);
-		end
-	end
-	return true;
-end
-
-function service:get_subscription(node, actor, jid)
+function service:get_subscription(node, actor, jid) --> (true, subscription) or (false, err)
 	-- Access checking
 	local cap;
 	if actor == true or jid == actor or self:jids_equal(actor, jid) then
@@ -207,7 +426,7 @@
 	return true, node_obj.subscribers[jid];
 end
 
-function service:create(node, actor, options)
+function service:create(node, actor, options) --> ok, err
 	-- Access checking
 	if not self:may(node, actor, "create") then
 		return false, "forbidden";
@@ -223,17 +442,30 @@
 		config = setmetatable(options or {}, {__index=self.node_defaults});
 		affiliations = {};
 	};
-	self.data[node] = self.config.itemstore(self.nodes[node].config);
-	self.events.fire_event("node-created", { node = node, actor = actor });
-	local ok, err = self:set_affiliation(node, true, actor, "owner");
-	if not ok then
-		self.nodes[node] = nil;
-		self.data[node] = nil;
+
+	if self.config.nodestore then
+		local ok, err = save_node_to_store(self, self.nodes[node]);
+		if not ok then
+			self.nodes[node] = nil;
+			return ok, "internal-server-error";
+		end
 	end
-	return ok, err;
+
+	self.data[node] = self.config.itemstore(self.nodes[node].config, node);
+	self.events.fire_event("node-created", { service = self, node = node, actor = actor });
+	if actor ~= true then
+		local ok, err = self:set_affiliation(node, true, actor, "owner");
+		if not ok then
+			self.nodes[node] = nil;
+			self.data[node] = nil;
+			return ok, err;
+		end
+	end
+
+	return true;
 end
 
-function service:delete(node, actor)
+function service:delete(node, actor) --> ok, err
 	-- Access checking
 	if not self:may(node, actor, "delete") then
 		return false, "forbidden";
@@ -244,15 +476,52 @@
 		return false, "item-not-found";
 	end
 	self.nodes[node] = nil;
+	if self.data[node] and self.data[node].clear then
+		self.data[node]:clear();
+	end
 	self.data[node] = nil;
-	self.events.fire_event("node-deleted", { node = node, actor = actor });
-	self.config.broadcaster("delete", node, node_obj.subscribers);
+
+	if self.config.nodestore then
+		local ok, err = delete_node_in_store(self, node);
+		if not ok then
+			self.nodes[node] = nil;
+			return ok, err;
+		end
+	end
+
+	self.events.fire_event("node-deleted", { service = self, node = node, actor = actor });
+	self.config.broadcaster("delete", node, node_obj.subscribers, nil, actor, node_obj, self);
 	return true;
 end
 
-function service:publish(node, actor, id, item)
+-- Used to check that the config of a node is as expected (i.e. 'publish-options')
+local function check_preconditions(node_config, required_config)
+	if not (node_config and required_config) then
+		return false;
+	end
+	for config_field, value in pairs(required_config) do
+		if node_config[config_field] ~= value then
+			return false;
+		end
+	end
+	return true;
+end
+
+function service:publish(node, actor, id, item, requested_config) --> ok, err
 	-- Access checking
-	if not self:may(node, actor, "publish") then
+	local may_publish = false;
+
+	if self:may(node, actor, "publish") then
+		may_publish = true;
+	else
+		local node_obj = self.nodes[node];
+		local publish_model = node_obj and node_obj.config.publish_model;
+		if publish_model == "open"
+		or (publish_model == "subscribers" and node_obj.subscribers[actor]) then
+			may_publish = true;
+		end
+	end
+	if not may_publish then
 		return false, "forbidden";
 	end
 	--
@@ -261,23 +530,34 @@
 		if not self.config.autocreate_on_publish then
 			return false, "item-not-found";
 		end
-		local ok, err = self:create(node, true);
+		local ok, err = self:create(node, true, requested_config);
 		if not ok then
 			return ok, err;
 		end
 		node_obj = self.nodes[node];
+	elseif requested_config and not requested_config._defaults_only then
+		-- Check that node has the requested config before we publish
+		if not check_preconditions(node_obj.config, requested_config) then
+			return false, "precondition-not-met";
+		end
+	end
+	if not self.config.itemcheck(item) then
+		return nil, "invalid-item";
 	end
 	local node_data = self.data[node];
 	local ok = node_data:set(id, item);
 	if not ok then
 		return nil, "internal-server-error";
 	end
-	self.events.fire_event("item-published", { node = node, actor = actor, id = id, item = item });
-	self.config.broadcaster("items", node, node_obj.subscribers, item, actor);
+	if type(ok) == "string" then id = ok; end
+	local event_data = { service = self, node = node, actor = actor, id = id, item = item };
+	self.events.fire_event("item-published/"..node, event_data);
+	self.events.fire_event("item-published", event_data);
+	self.config.broadcaster("items", node, node_obj.subscribers, item, actor, node_obj, self);
 	return true;
 end
 
-function service:retract(node, actor, id, retract)
+function service:retract(node, actor, id, retract) --> ok, err
 	-- Access checking
 	if not self:may(node, actor, "retract") then
 		return false, "forbidden";
@@ -291,14 +571,14 @@
 	if not ok then
 		return nil, "internal-server-error";
 	end
-	self.events.fire_event("item-retracted", { node = node, actor = actor, id = id });
+	self.events.fire_event("item-retracted", { service = self, node = node, actor = actor, id = id });
 	if retract then
-		self.config.broadcaster("items", node, node_obj.subscribers, retract);
+		self.config.broadcaster("retract", node, node_obj.subscribers, retract, actor, node_obj, self);
 	end
 	return true
 end
 
-function service:purge(node, actor, notify)
+function service:purge(node, actor, notify) --> ok, err
 	-- Access checking
 	if not self:may(node, actor, "retract") then
 		return false, "forbidden";
@@ -308,15 +588,19 @@
 	if not node_obj then
 		return false, "item-not-found";
 	end
-	self.data[node] = self.config.itemstore(self.nodes[node].config);
-	self.events.fire_event("node-purged", { node = node, actor = actor });
+	if self.data[node] and self.data[node].clear then
+		self.data[node]:clear()
+	else
+		self.data[node] = self.config.itemstore(self.nodes[node].config, node);
+	end
+	self.events.fire_event("node-purged", { service = self, node = node, actor = actor });
 	if notify then
-		self.config.broadcaster("purge", node, node_obj.subscribers);
+		self.config.broadcaster("purge", node, node_obj.subscribers, nil, actor, node_obj, self);
 	end
 	return true
 end
 
-function service:get_items(node, actor, id)
+function service:get_items(node, actor, id) --> (true, { id, [id] = node }) or (false, err)
 	-- Access checking
 	if not self:may(node, actor, "get_items") then
 		return false, "forbidden";
@@ -327,7 +611,11 @@
 		return false, "item-not-found";
 	end
 	if id then -- Restrict results to a single specific item
-		return true, { id, [id] = self.data[node]:get(id) };
+		local with_id = self.data[node]:get(id);
+		if not with_id then
+			return true, { };
+		end
+		return true, { id, [id] = with_id };
 	else
 		local data = {}
 		for key, value in self.data[node]:items() do
@@ -338,7 +626,23 @@
 	end
 end
 
-function service:get_nodes(actor)
+function service:get_last_item(node, actor) --> (true, id, node) or (false, err)
+	-- Access checking
+	if not self:may(node, actor, "get_items") then
+		return false, "forbidden";
+	end
+	--
+
+	-- Check node exists
+	if not self.nodes[node] then
+		return false, "item-not-found";
+	end
+
+	-- Returns success, id, item
+	return true, self.data[node]:head();
+end
+
+function service:get_nodes(actor) --> (true, map) or (false, err)
 	-- Access checking
 	if not self:may(nil, actor, "get_nodes") then
 		return false, "forbidden";
@@ -347,7 +651,30 @@
 	return true, self.nodes;
 end
 
-function service:get_subscriptions(node, actor, jid)
+local function flatten_subscriptions(ret, serv, subs, node, node_obj)
+	for subscribed_jid, subscribed_nodes in pairs(subs) do
+		if node then -- Return only subscriptions to this node
+			if subscribed_nodes[node] then
+				ret[#ret+1] = {
+					node = node;
+					jid = subscribed_jid;
+					subscription = node_obj.subscribers[subscribed_jid];
+				};
+			end
+		else -- Return subscriptions to all nodes
+			local nodes = serv.nodes;
+			for subscribed_node in pairs(subscribed_nodes) do
+				ret[#ret+1] = {
+					node = subscribed_node;
+					jid = subscribed_jid;
+					subscription = nodes[subscribed_node].subscribers[subscribed_jid];
+				};
+			end
+		end
+	end
+end
+
+function service:get_subscriptions(node, actor, jid) --> (true, array) or (false, err)
 	-- Access checking
 	local cap;
 	if actor == true or jid == actor or self:jids_equal(actor, jid) then
@@ -366,38 +693,25 @@
 			return false, "item-not-found";
 		end
 	end
+	local ret = {};
+	if jid == nil then
+		for _, subs in pairs(self.subscriptions) do
+			flatten_subscriptions(ret, self, subs, node, node_obj)
+		end
+		return true, ret;
+	end
 	local normal_jid = self.config.normalize_jid(jid);
 	local subs = self.subscriptions[normal_jid];
 	-- We return the subscription object from the node to save
 	-- a get_subscription() call for each node.
-	local ret = {};
 	if subs then
-		for subscribed_jid, subscribed_nodes in pairs(subs) do
-			if node then -- Return only subscriptions to this node
-				if subscribed_nodes[node] then
-					ret[#ret+1] = {
-						node = node;
-						jid = subscribed_jid;
-						subscription = node_obj.subscribers[subscribed_jid];
-					};
-				end
-			else -- Return subscriptions to all nodes
-				local nodes = self.nodes;
-				for subscribed_node in pairs(subscribed_nodes) do
-					ret[#ret+1] = {
-						node = subscribed_node;
-						jid = subscribed_jid;
-						subscription = nodes[subscribed_node].subscribers[subscribed_jid];
-					};
-				end
-			end
-		end
+		flatten_subscriptions(ret, self, subs, node, node_obj)
 	end
 	return true, ret;
 end
 
 -- Access models only affect 'none' affiliation caps, service/default access level...
-function service:set_node_capabilities(node, actor, capabilities)
+function service:set_node_capabilities(node, actor, capabilities) --> ok, err
 	-- Access checking
 	if not self:may(node, actor, "configure") then
 		return false, "forbidden";
@@ -411,7 +725,7 @@
 	return true;
 end
 
-function service:set_node_config(node, actor, new_config)
+function service:set_node_config(node, actor, new_config) --> ok, err
 	if not self:may(node, actor, "configure") then
 		return false, "forbidden";
 	end
@@ -421,15 +735,69 @@
 		return false, "item-not-found";
 	end
 
-	for k,v in pairs(new_config) do
-		node_obj.config[k] = v;
+	setmetatable(new_config, {__index=self.node_defaults})
+
+	if self.config.check_node_config then
+		local ok = self.config.check_node_config(node, actor, new_config);
+		if not ok then
+			return false, "not-acceptable";
+		end
+	end
+
+	local old_config = node_obj.config;
+	node_obj.config = new_config;
+
+	if self.config.nodestore then
+		local ok, err = save_node_to_store(self, node_obj);
+		if not ok then
+			node_obj.config = old_config;
+			return ok, "internal-server-error";
+		end
+	end
+
+	if old_config["access_model"] ~= node_obj.config["access_model"] then
+		for subscriber in pairs(node_obj.subscribers) do
+			if not self:may(node, subscriber, "be_subscribed") then
+				local ok, err = self:remove_subscription(node, true, subscriber);
+				if not ok then
+					node_obj.config = old_config;
+					return ok, err;
+				end
+			end
+		end
 	end
-	local new_data = self.config.itemstore(self.nodes[node].config);
-	for key, value in self.data[node]:items() do
-		new_data:set(key, value);
+
+	if old_config["persist_items"] ~= node_obj.config["persist_items"] then
+		self.data[node] = self.config.itemstore(self.nodes[node].config, node);
+	elseif old_config["max_items"] ~= node_obj.config["max_items"] then
+		self.data[node]:resize(self.nodes[node].config["max_items"]);
+	end
+
+	return true;
+end
+
+function service:get_node_config(node, actor) --> (true, config) or (false, err)
+	if not self:may(node, actor, "get_configuration") then
+		return false, "forbidden";
 	end
-	self.data[node] = new_data;
-	return true;
+
+	local node_obj = self.nodes[node];
+	if not node_obj then
+		return false, "item-not-found";
+	end
+
+	local config_table = {};
+	for k, v in pairs(default_node_config) do
+		config_table[k] = v;
+	end
+	for k, v in pairs(self.node_defaults) do
+		config_table[k] = v;
+	end
+	for k, v in pairs(node_obj.config) do
+		config_table[k] = v;
+	end
+
+	return true, config_table;
 end
 
 return {
--- a/util/random.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/random.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -11,9 +11,6 @@
 
 local urandom, urandom_err = io.open("/dev/urandom", "r");
 
-local function seed()
-end
-
 local function bytes(n)
 	return urandom:read(n);
 end
@@ -25,7 +22,6 @@
 end
 
 return {
-	seed = seed;
 	bytes = bytes;
 	_source = "/dev/urandom";
 };
--- a/util/sasl.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/sasl.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -20,6 +20,7 @@
 local require = require;
 
 local _ENV = nil;
+-- luacheck: std none
 
 --[[
 Authentication Backend Prototypes:
@@ -42,7 +43,7 @@
 
 local method = {};
 method.__index = method;
-local mechanisms = {};
+local registered_mechanisms = {};
 local backend_mechanism = {};
 local mechanism_channelbindings = {};
 
@@ -52,7 +53,7 @@
 	assert(type(backends) == "string" or type(backends) == "table", "Parameter backends MUST be either a string or a table.");
 	assert(type(f) == "function", "Parameter f MUST be a function.");
 	if cb_backends then assert(type(cb_backends) == "table"); end
-	mechanisms[name] = f
+	registered_mechanisms[name] = f
 	if cb_backends then
 		mechanism_channelbindings[name] = {};
 		for _, cb_name in ipairs(cb_backends) do
@@ -70,7 +71,7 @@
 	local mechanisms = profile.mechanisms;
 	if not mechanisms then
 		mechanisms = {};
-		for backend, f in pairs(profile) do
+		for backend in pairs(profile) do
 			if backend_mechanism[backend] then
 				for _, mechanism in ipairs(backend_mechanism[backend]) do
 					mechanisms[mechanism] = true;
@@ -128,7 +129,7 @@
 -- feed new messages to process into the library
 function method:process(message)
 	--if message == "" or message == nil then return "failure", "malformed-request" end
-	return mechanisms[self.selected](self, message);
+	return registered_mechanisms[self.selected](self, message);
 end
 
 -- load the mechanisms
--- a/util/sasl/anonymous.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/sasl/anonymous.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -12,9 +12,10 @@
 --    THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 
 
-local generate_uuid = require "util.uuid".generate;
+local generate_random_id = require "util.id".medium;
 
 local _ENV = nil;
+-- luacheck: std none
 
 --=========================
 --SASL ANONYMOUS according to RFC 4505
@@ -28,10 +29,10 @@
 	end
 ]]
 
-local function anonymous(self, message)
+local function anonymous(self, message) -- luacheck: ignore 212/message
 	local username;
 	repeat
-		username = generate_uuid();
+		username = generate_random_id():lower();
 	until self.profile.anonymous(self, username, self.realm);
 	self.username = username;
 	return "success"
--- a/util/sasl/digest-md5.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/sasl/digest-md5.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -26,6 +26,7 @@
 local nodeprep = require "util.encodings".stringprep.nodeprep;
 
 local _ENV = nil;
+-- luacheck: std none
 
 --=========================
 --SASL DIGEST-MD5 according to RFC 2831
--- a/util/sasl/external.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/sasl/external.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -1,6 +1,7 @@
 local saslprep = require "util.encodings".stringprep.saslprep;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local function external(self, message)
 	message = saslprep(message);
--- a/util/sasl/plain.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/sasl/plain.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -17,6 +17,7 @@
 local log = require "util.logger".init("sasl");
 
 local _ENV = nil;
+-- luacheck: std none
 
 -- ================================
 -- SASL PLAIN according to RFC 4616
--- a/util/sasl/scram.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/sasl/scram.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -26,6 +26,7 @@
 local byte = string.byte;
 
 local _ENV = nil;
+-- luacheck: std none
 
 --=========================
 --SASL SCRAM-SHA-1 according to RFC 5802
@@ -46,7 +47,18 @@
 
 local default_i = 4096
 
-local xor_map = {0;1;2;3;4;5;6;7;8;9;10;11;12;13;14;15;1;0;3;2;5;4;7;6;9;8;11;10;13;12;15;14;2;3;0;1;6;7;4;5;10;11;8;9;14;15;12;13;3;2;1;0;7;6;5;4;11;10;9;8;15;14;13;12;4;5;6;7;0;1;2;3;12;13;14;15;8;9;10;11;5;4;7;6;1;0;3;2;13;12;15;14;9;8;11;10;6;7;4;5;2;3;0;1;14;15;12;13;10;11;8;9;7;6;5;4;3;2;1;0;15;14;13;12;11;10;9;8;8;9;10;11;12;13;14;15;0;1;2;3;4;5;6;7;9;8;11;10;13;12;15;14;1;0;3;2;5;4;7;6;10;11;8;9;14;15;12;13;2;3;0;1;6;7;4;5;11;10;9;8;15;14;13;12;3;2;1;0;7;6;5;4;12;13;14;15;8;9;10;11;4;5;6;7;0;1;2;3;13;12;15;14;9;8;11;10;5;4;7;6;1;0;3;2;14;15;12;13;10;11;8;9;6;7;4;5;2;3;0;1;15;14;13;12;11;10;9;8;7;6;5;4;3;2;1;0;};
+local xor_map = {
+	0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,1,0,3,2,5,4,7,6,9,8,11,10,
+	13,12,15,14,2,3,0,1,6,7,4,5,10,11,8,9,14,15,12,13,3,2,1,0,7,6,5,
+	4,11,10,9,8,15,14,13,12,4,5,6,7,0,1,2,3,12,13,14,15,8,9,10,11,5,
+	4,7,6,1,0,3,2,13,12,15,14,9,8,11,10,6,7,4,5,2,3,0,1,14,15,12,13,
+	10,11,8,9,7,6,5,4,3,2,1,0,15,14,13,12,11,10,9,8,8,9,10,11,12,13,
+	14,15,0,1,2,3,4,5,6,7,9,8,11,10,13,12,15,14,1,0,3,2,5,4,7,6,10,
+	11,8,9,14,15,12,13,2,3,0,1,6,7,4,5,11,10,9,8,15,14,13,12,3,2,1,
+	0,7,6,5,4,12,13,14,15,8,9,10,11,4,5,6,7,0,1,2,3,13,12,15,14,9,8,
+	11,10,5,4,7,6,1,0,3,2,14,15,12,13,10,11,8,9,6,7,4,5,2,3,0,1,15,
+	14,13,12,11,10,9,8,7,6,5,4,3,2,1,0,
+};
 
 local result = {};
 local function binaryXOR( a, b )
@@ -148,7 +160,7 @@
 			end
 			self.username = username;
 
-			-- retreive credentials
+			-- retrieve credentials
 			local stored_key, server_key, salt, iteration_count;
 			if self.profile.plain then
 				local password, status = self.profile.plain(self, username, self.realm)
@@ -237,10 +249,14 @@
 
 local function init(registerMechanism)
 	local function registerSCRAMMechanism(hash_name, hash, hmac_hash)
-		registerMechanism("SCRAM-"..hash_name, {"plain", "scram_"..(hashprep(hash_name))}, scram_gen(hash_name:lower(), hash, hmac_hash));
+		registerMechanism("SCRAM-"..hash_name,
+			{"plain", "scram_"..(hashprep(hash_name))},
+			scram_gen(hash_name:lower(), hash, hmac_hash));
 
 		-- register channel binding equivalent
-		registerMechanism("SCRAM-"..hash_name.."-PLUS", {"plain", "scram_"..(hashprep(hash_name))}, scram_gen(hash_name:lower(), hash, hmac_hash), {"tls-unique"});
+		registerMechanism("SCRAM-"..hash_name.."-PLUS",
+			{"plain", "scram_"..(hashprep(hash_name))},
+			scram_gen(hash_name:lower(), hash, hmac_hash), {"tls-unique"});
 	end
 
 	registerSCRAMMechanism("SHA-1", sha1, hmac_sha1);
--- a/util/sasl_cyrus.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/sasl_cyrus.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -61,6 +61,7 @@
 setmetatable(sasl_errstring, { __index = function() return "undefined error!" end });
 
 local _ENV = nil;
+-- luacheck: std none
 
 local method = {};
 method.__index = method;
--- a/util/serialization.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/serialization.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -1,89 +1,271 @@
 -- Prosody IM
 -- Copyright (C) 2008-2010 Matthew Wild
 -- Copyright (C) 2008-2010 Waqas Hussain
+-- Copyright (C) 2018 Kim Alvefur
 --
 -- This project is MIT/X11 licensed. Please see the
 -- COPYING file in the source package for more information.
 --
 
-local string_rep = string.rep;
-local type = type;
-local tostring = tostring;
-local t_insert = table.insert;
+local getmetatable = getmetatable;
+local next, type = next, type;
+local s_format = string.format;
+local s_gsub = string.gsub;
+local s_rep = string.rep;
+local s_char = string.char;
+local s_match = string.match;
 local t_concat = table.concat;
-local pairs = pairs;
-local next = next;
 
 local pcall = pcall;
-
-local debug_traceback = debug.traceback;
-local log = require "util.logger".init("serialization");
 local envload = require"util.envload".envload;
 
-local _ENV = nil;
+local pos_inf, neg_inf = math.huge, -math.huge;
+-- luacheck: ignore 143/math
+local m_type = math.type or function (n)
+	return n % 1 == 0 and n <= 9007199254740992 and n >= -9007199254740992 and "integer" or "float";
+end;
 
-local indent = function(i)
-	return string_rep("\t", i);
+local char_to_hex = {};
+for i = 0,255 do
+	char_to_hex[s_char(i)] = s_format("%02x", i);
 end
-local function basicSerialize (o)
-	if type(o) == "number" or type(o) == "boolean" then
-		-- no need to check for NaN, as that's not a valid table index
-		if o == 1/0 then return "(1/0)";
-		elseif o == -1/0 then return "(-1/0)";
-		else return tostring(o); end
-	else -- assume it is a string -- FIXME make sure it's a string. throw an error otherwise.
-		return (("%q"):format(tostring(o)):gsub("\\\n", "\\n"));
-	end
+
+local function to_hex(s)
+	return (s_gsub(s, ".", char_to_hex));
+end
+
+local function fatal_error(obj, why)
+	error("Can't serialize "..type(obj) .. (why and ": ".. why or ""));
 end
-local function _simplesave(o, ind, t, func)
-	if type(o) == "number" then
-		if o ~= o then func(t, "(0/0)");
-		elseif o == 1/0 then func(t, "(1/0)");
-		elseif o == -1/0 then func(t, "(-1/0)");
-		else func(t, tostring(o)); end
-	elseif type(o) == "string" then
-		func(t, (("%q"):format(o):gsub("\\\n", "\\n")));
-	elseif type(o) == "table" then
-		if next(o) ~= nil then
-			func(t, "{\n");
-			for k,v in pairs(o) do
-				func(t, indent(ind));
-				func(t, "[");
-				func(t, basicSerialize(k));
-				func(t, "] = ");
-				if ind == 0 then
-					_simplesave(v, 0, t, func);
-				else
-					_simplesave(v, ind+1, t, func);
-				end
-				func(t, ";\n");
-			end
-			func(t, indent(ind-1));
-			func(t, "}");
-		else
-			func(t, "{}");
-		end
-	elseif type(o) == "boolean" then
-		func(t, (o and "true" or "false"));
-	else
-		log("error", "cannot serialize a %s: %s", type(o), debug_traceback())
-		func(t, "nil");
+
+local function nonfatal_fallback(x, why)
+	return s_format("{__type=%q,__error=%q}", type(x), why or "fail");
+end
+
+local string_escapes = {
+	['\a'] = [[\a]]; ['\b'] = [[\b]];
+	['\f'] = [[\f]]; ['\n'] = [[\n]];
+	['\r'] = [[\r]]; ['\t'] = [[\t]];
+	['\v'] = [[\v]]; ['\\'] = [[\\]];
+	['\"'] = [[\"]]; ['\''] = [[\']];
+}
+
+for i = 0, 255 do
+	local c = s_char(i);
+	if not string_escapes[c] then
+		string_escapes[c] = s_format("\\%03d", i);
 	end
 end
 
-local function append(t, o)
-	_simplesave(o, 1, t, t.write or t_insert);
-	return t;
-end
+local default_keywords = {
+	["do"] = true; ["and"] = true; ["else"] = true; ["break"] = true;
+	["if"] = true; ["end"] = true; ["goto"] = true; ["false"] = true;
+	["in"] = true; ["for"] = true; ["then"] = true; ["local"] = true;
+	["or"] = true; ["nil"] = true; ["true"] = true; ["until"] = true;
+	["elseif"] = true; ["function"] = true; ["not"] = true;
+	["repeat"] = true; ["return"] = true; ["while"] = true;
+};
+
+local function new(opt)
+	if type(opt) ~= "table" then
+		opt = { preset = opt };
+	end
+
+	local types = {
+		table = true;
+		string = true;
+		number = true;
+		boolean = true;
+		["nil"] = true;
+	};
+
+	-- presets
+	if opt.preset == "debug" then
+		opt.preset = "oneline";
+		opt.freeze = true;
+		opt.fatal = false;
+		opt.fallback = nonfatal_fallback;
+		opt.unquoted = true;
+	end
+	if opt.preset == "oneline" then
+		opt.indentwith = opt.indentwith or "";
+		opt.itemstart = opt.itemstart or " ";
+		opt.itemlast = opt.itemlast or "";
+		opt.tend = opt.tend or " }";
+	elseif opt.preset == "compact" then
+		opt.indentwith = opt.indentwith or "";
+		opt.itemstart = opt.itemstart or "";
+		opt.itemlast = opt.itemlast or "";
+		opt.equals = opt.equals or "=";
+		opt.unquoted = true;
+	end
+
+	local fallback = opt.fallback or opt.fatal == false and nonfatal_fallback or fatal_error;
+
+	local function ser(v)
+		return (types[type(v)] or fallback)(v);
+	end
+
+	local keywords = opt.keywords or default_keywords;
+
+	-- indented
+	local indentwith = opt.indentwith or "\t";
+	local itemstart = opt.itemstart or "\n";
+	local itemsep = opt.itemsep or ";";
+	local itemlast = opt.itemlast or ";\n";
+	local tstart = opt.tstart or "{";
+	local tend = opt.tend or "}";
+	local kstart = opt.kstart or "[";
+	local kend = opt.kend or "]";
+	local equals = opt.equals or " = ";
+	local unquoted = opt.unquoted == true and "^[%a_][%w_]*$" or opt.unquoted;
+	local hex = opt.hex;
+	local freeze = opt.freeze;
+	local maxdepth = opt.maxdepth or 127;
+	local multirefs = opt.multiref;
+
+	-- serialize one table, recursively
+	-- t - table being serialized
+	-- o - array where tokens are added, concatenate to get final result
+	--   - also used to detect cycles
+	-- l - position in o of where to insert next token
+	-- d - depth, used for indentation
+	local function serialize_table(t, o, l, d)
+		if o[t] then
+			o[l], l = fallback(t, "table has multiple references"), l + 1;
+			return l;
+		elseif d > maxdepth then
+			o[l], l = fallback(t, "max table depth reached"), l + 1;
+			return l;
+		end
+
+		-- Keep track of table loops
+		local ot = t; -- reference pre-freeze
+		o[t] = true;
+		o[ot] = true;
+
+		if freeze == true then
+			-- opportunity to do pre-serialization
+			local mt = getmetatable(t);
+			if type(mt) == "table" then
+				local tag = mt.__name;
+				local fr = mt.__freeze;
 
-local function serialize(o)
-	return t_concat(append({}, o));
+				if type(fr) == "function" then
+					t = fr(t);
+					if type(tag) == "string" then
+						o[l], l = tag, l + 1;
+					end
+				end
+			end
+		end
+
+		o[l], l = tstart, l + 1;
+		local indent = s_rep(indentwith, d);
+		local numkey = 1;
+		local ktyp, vtyp;
+		for k,v in next,t do
+			o[l], l = itemstart, l + 1;
+			o[l], l = indent, l + 1;
+			ktyp, vtyp = type(k), type(v);
+			if k == numkey then
+				-- next index in array part
+				-- assuming that these are found in order
+				numkey = numkey + 1;
+			elseif unquoted and ktyp == "string" and
+				not keywords[k] and s_match(k, unquoted) then
+				-- unquoted keys
+				o[l], l = k, l + 1;
+				o[l], l = equals, l + 1;
+			else
+				-- quoted keys
+				o[l], l = kstart, l + 1;
+				if ktyp == "table" then
+					l = serialize_table(k, o, l, d+1);
+				else
+					o[l], l = ser(k), l + 1;
+				end
+				-- =
+				o[l], o[l+1], l = kend, equals, l + 2;
+			end
+
+			-- the value
+			if vtyp == "table" then
+				l = serialize_table(v, o, l, d+1);
+			else
+				o[l], l = ser(v), l + 1;
+			end
+			-- last item?
+			if next(t, k) ~= nil then
+				o[l], l = itemsep, l + 1;
+			else
+				o[l], l = itemlast, l + 1;
+			end
+		end
+		if next(t) ~= nil then
+			o[l], l = s_rep(indentwith, d-1), l + 1;
+		end
+		o[l], l = tend, l +1;
+
+		if multirefs then
+			o[t] = nil;
+			o[ot] = nil;
+		end
+
+		return l;
+	end
+
+	function types.table(t)
+		local o = {};
+		serialize_table(t, o, 1, 1);
+		return t_concat(o);
+	end
+
+	local function serialize_string(s)
+		return '"' .. s_gsub(s, "[%z\1-\31\"\'\\\127-\255]", string_escapes) .. '"';
+	end
+
+	if type(hex) == "string" then
+		function types.string(s)
+			local esc = serialize_string(s);
+			if #esc > (#s*2+2+#hex) then
+				return hex .. '"' .. to_hex(s) .. '"';
+			end
+			return esc;
+		end
+	else
+		types.string = serialize_string;
+	end
+
+	function types.number(t)
+		if m_type(t) == "integer" then
+			return s_format("%d", t);
+		elseif t == pos_inf then
+			return "(1/0)";
+		elseif t == neg_inf then
+			return "(-1/0)";
+		elseif t ~= t then
+			return "(0/0)";
+		end
+		return s_format("%.18g", t);
+	end
+
+	-- Are these faster than tostring?
+	types["nil"] = function()
+		return "nil";
+	end
+
+	function types.boolean(t)
+		return t and "true" or "false";
+	end
+
+	return ser;
 end
 
 local function deserialize(str)
 	if type(str) ~= "string" then return nil; end
 	str = "return "..str;
-	local f, err = envload(str, "@data", {});
+	local f, err = envload(str, "=serialized data", {});
 	if not f then return nil, err; end
 	local success, ret = pcall(f);
 	if not success then return nil, ret; end
@@ -91,7 +273,9 @@
 end
 
 return {
-	append = append;
-	serialize = serialize;
+	new = new;
+	serialize = function (x, opt)
+		return new(opt)(x);
+	end;
 	deserialize = deserialize;
 };
--- a/util/set.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/set.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -11,8 +11,9 @@
 local t_concat = table.concat;
 
 local _ENV = nil;
+-- luacheck: std none
 
-local set_mt = {};
+local set_mt = { __name = "set" };
 function set_mt.__call(set, _, k)
 	return next(set._items, k);
 end
@@ -22,6 +23,14 @@
 	return next(items, k);
 end
 
+function set_mt:__freeze()
+	local a, i = {}, 1;
+	for item in self._items do
+		a[i], i = item, i+1;
+	end
+	return a;
+end
+
 local function new(list)
 	local items = setmetatable({}, items_mt);
 	local set = { _items = items };
--- a/util/sql.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/sql.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -1,11 +1,11 @@
 
 local setmetatable, getmetatable = setmetatable, getmetatable;
-local ipairs, unpack, select = ipairs, table.unpack or unpack, select; --luacheck: ignore 113
-local tonumber, tostring = tonumber, tostring;
+local ipairs = ipairs;
+local tostring = tostring;
 local type = type;
-local assert, pcall, xpcall, debug_traceback = assert, pcall, xpcall, debug.traceback;
+local assert, pcall, debug_traceback = assert, pcall, debug.traceback;
+local xpcall = require "util.xpcall".xpcall;
 local t_concat = table.concat;
-local s_char = string.char;
 local log = require "util.logger".init("sql");
 
 local DBI = require "DBI";
@@ -15,6 +15,7 @@
 local build_url = require "socket.url".build;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local column_mt = {};
 local table_mt = {};
@@ -58,9 +59,6 @@
 function table_mt.__index:create(engine)
 	return engine:_create_table(self);
 end
-function table_mt:__call(...)
-	-- TODO
-end
 function column_mt:__tostring()
 	return 'Column{ name="'..self.name..'", type="'..self.type..'" }'
 end
@@ -71,31 +69,6 @@
 --	return 'Index{ name="'..self.name..'", type="'..self.type..'" }'
 end
 
-local function urldecode(s) return s and (s:gsub("%%(%x%x)", function (c) return s_char(tonumber(c,16)); end)); end
-local function parse_url(url)
-	local scheme, secondpart, database = url:match("^([%w%+]+)://([^/]*)/?(.*)");
-	assert(scheme, "Invalid URL format");
-	local username, password, host, port;
-	local authpart, hostpart = secondpart:match("([^@]+)@([^@+])");
-	if not authpart then hostpart = secondpart; end
-	if authpart then
-		username, password = authpart:match("([^:]*):(.*)");
-		username = username or authpart;
-		password = password and urldecode(password);
-	end
-	if hostpart then
-		host, port = hostpart:match("([^:]*):(.*)");
-		host = host or hostpart;
-		port = port and assert(tonumber(port), "Invalid URL format");
-	end
-	return {
-		scheme = scheme:lower();
-		username = username; password = password;
-		host = host; port = port;
-		database = #database > 0 and database or nil;
-	};
-end
-
 local engine = {};
 function engine:connect()
 	if self.conn then return true; end
@@ -123,7 +96,7 @@
 	end
 	return true;
 end
-function engine:onconnect()
+function engine:onconnect() -- luacheck: ignore 212/self
 	-- Override from create_engine()
 end
 
@@ -148,6 +121,7 @@
 		prepared[sql] = stmt;
 	end
 
+	-- luacheck: ignore 411/success
 	local success, err = stmt:execute(...);
 	if not success then return success, err; end
 	return stmt;
@@ -161,14 +135,14 @@
 local function debugquery(where, sql, ...)
 	local i = 0; local a = {...}
 	sql = sql:gsub("\n?\t+", " ");
-	log("debug", "[%s] %s", where, sql:gsub("%?", function ()
+	log("debug", "[%s] %s", where, (sql:gsub("%?", function ()
 		i = i + 1;
 		local v = a[i];
 		if type(v) == "string" then
 			v = ("'%s'"):format(v:gsub("'", "''"));
 		end
 		return tostring(v);
-	end));
+	end)));
 end
 
 function engine:execute_query(sql, ...)
@@ -227,11 +201,9 @@
 		if not ok then return ok, err; end
 	end
 	--assert(not self.__transaction, "Recursive transactions not allowed");
-	local args, n_args = {...}, select("#", ...);
-	local function f() return func(unpack(args, 1, n_args)); end
 	log("debug", "SQL transaction begin [%s]", tostring(func));
 	self.__transaction = true;
-	local success, a, b, c = xpcall(f, handleerr);
+	local success, a, b, c = xpcall(func, handleerr, ...);
 	self.__transaction = nil;
 	if success then
 		log("debug", "SQL transaction success [%s]", tostring(func));
@@ -335,7 +307,12 @@
 	local charset = "utf8";
 	if driver == "MySQL" then
 		self:transaction(function()
-			for row in self:select"SELECT \"CHARACTER_SET_NAME\" FROM \"information_schema\".\"CHARACTER_SETS\" WHERE \"CHARACTER_SET_NAME\" LIKE 'utf8%' ORDER BY MAXLEN DESC LIMIT 1;" do
+			for row in self:select[[
+				SELECT "CHARACTER_SET_NAME"
+				FROM "information_schema"."CHARACTER_SETS"
+				WHERE "CHARACTER_SET_NAME" LIKE 'utf8%'
+				ORDER BY MAXLEN DESC LIMIT 1;
+				]] do
 				charset = row and row[1] or charset;
 			end
 		end);
@@ -379,7 +356,7 @@
 	};
 end
 
-local function create_engine(self, params, onconnect)
+local function create_engine(_, params, onconnect)
 	return setmetatable({ url = db2uri(params), params = params, onconnect = onconnect }, engine_mt);
 end
 
--- a/util/sslconfig.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/sslconfig.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -8,6 +8,7 @@
 local setmetatable = setmetatable;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local handlers = { };
 local finalisers = { };
@@ -69,7 +70,7 @@
 -- protocol = "x" should enable only that protocol
 -- protocol = "x+" should enable x and later versions
 
-local protocols = { "sslv2", "sslv3", "tlsv1", "tlsv1_1", "tlsv1_2" };
+local protocols = { "sslv2", "sslv3", "tlsv1", "tlsv1_1", "tlsv1_2", "tlsv1_3" };
 for i = 1, #protocols do protocols[protocols[i] .. "+"] = i - 1; end
 
 -- this interacts with ssl.options as well to add no_x
--- a/util/stanza.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/stanza.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -7,6 +7,7 @@
 --
 
 
+local error         =         error;
 local t_insert      =  table.insert;
 local t_remove      =  table.remove;
 local t_concat      =  table.concat;
@@ -23,6 +24,8 @@
 local s_find        =   string.find;
 local os            =            os;
 
+local valid_utf8 = require "util.encodings".utf8.valid;
+
 local do_pretty_printing = not os.getenv("WINDIR");
 local getstyle, getstring;
 if do_pretty_printing then
@@ -37,12 +40,52 @@
 local xmlns_stanzas = "urn:ietf:params:xml:ns:xmpp-stanzas";
 
 local _ENV = nil;
+-- luacheck: std none
 
-local stanza_mt = { __type = "stanza" };
+local stanza_mt = { __name = "stanza" };
 stanza_mt.__index = stanza_mt;
 
-local function new_stanza(name, attr)
-	local stanza = { name = name, attr = attr or {}, tags = {} };
+local function check_name(name, name_type)
+	if type(name) ~= "string" then
+		error("invalid "..name_type.." name: expected string, got "..type(name));
+	elseif #name == 0 then
+		error("invalid "..name_type.." name: empty string");
+	elseif s_find(name, "[<>& '\"]") then
+		error("invalid "..name_type.." name: contains invalid characters");
+	elseif not valid_utf8(name) then
+		error("invalid "..name_type.." name: contains invalid utf8");
+	end
+end
+
+local function check_text(text, text_type)
+	if type(text) ~= "string" then
+		error("invalid "..text_type.." value: expected string, got "..type(text));
+	elseif not valid_utf8(text) then
+		error("invalid "..text_type.." value: contains invalid utf8");
+	end
+end
+
+local function check_attr(attr)
+	if attr ~= nil then
+		if type(attr) ~= "table" then
+			error("invalid attributes, expected table got "..type(attr));
+		end
+		for k, v in pairs(attr) do
+			check_name(k, "attribute");
+			check_text(v, "attribute");
+			if type(v) ~= "string" then
+				error("invalid attribute value for '"..k.."': expected string, got "..type(v));
+			elseif not valid_utf8(v) then
+				error("invalid attribute value for '"..k.."': contains invalid utf8");
+			end
+		end
+	end
+end
+
+local function new_stanza(name, attr, namespaces)
+	check_name(name, "tag");
+	check_attr(attr);
+	local stanza = { name = name, attr = attr or {}, namespaces = namespaces, tags = {} };
 	return setmetatable(stanza, stanza_mt);
 end
 
@@ -58,8 +101,12 @@
 	return self:tag("body", attr):text(text);
 end
 
-function stanza_mt:tag(name, attrs)
-	local s = new_stanza(name, attrs);
+function stanza_mt:text_tag(name, text, attr, namespaces)
+	return self:tag(name, attr, namespaces):text(text):up();
+end
+
+function stanza_mt:tag(name, attr, namespaces)
+	local s = new_stanza(name, attr, namespaces);
 	local last_add = self.last_add;
 	if not last_add then last_add = {}; self.last_add = last_add; end
 	(last_add[#last_add] or self):add_direct_child(s);
@@ -68,8 +115,10 @@
 end
 
 function stanza_mt:text(text)
-	local last_add = self.last_add;
-	(last_add and last_add[#last_add] or self):add_direct_child(text);
+	if text ~= nil and text ~= "" then
+		local last_add = self.last_add;
+		(last_add and last_add[#last_add] or self):add_direct_child(text);
+	end
 	return self;
 end
 
@@ -85,10 +134,13 @@
 end
 
 function stanza_mt:add_direct_child(child)
-	if type(child) == "table" then
+	if is_stanza(child) then
 		t_insert(self.tags, child);
+		t_insert(self, child);
+	else
+		check_text(child, "text");
+		t_insert(self, child);
 	end
-	t_insert(self, child);
 end
 
 function stanza_mt:add_child(child)
@@ -165,6 +217,7 @@
 function stanza_mt:maptags(callback)
 	local tags, curr_tag = self.tags, 1;
 	local n_children, n_tags = #self, #tags;
+	local max_iterations = n_children + 1;
 
 	local i = 1;
 	while curr_tag <= n_tags and n_tags > 0 do
@@ -184,6 +237,11 @@
 			curr_tag = curr_tag + 1;
 		end
 		i = i + 1;
+		if i > max_iterations then
+			-- COMPAT: Hopefully temporary guard against #981 while we
+			-- figure out the root cause
+			error("Invalid stanza state! Please report this error.");
+		end
 	end
 	return self;
 end
@@ -289,12 +347,6 @@
 	return error_type, condition or "undefined-condition", text;
 end
 
-local id = 0;
-local function new_id()
-	id = id + 1;
-	return "lx"..id;
-end
-
 local function preserialize(stanza)
 	local s = { name = stanza.name, attr = stanza.attr };
 	for _, child in ipairs(stanza) do
@@ -307,51 +359,48 @@
 	return s;
 end
 
-local function deserialize(stanza)
+stanza_mt.__freeze = preserialize;
+
+local function deserialize(serialized)
 	-- Set metatable
-	if stanza then
-		local attr = stanza.attr;
-		for i=1,#attr do attr[i] = nil; end
+	if serialized then
+		local attr = serialized.attr;
 		local attrx = {};
-		for att in pairs(attr) do
-			if s_find(att, "|", 1, true) and not s_find(att, "\1", 1, true) then
-				local ns,na = s_match(att, "^([^|]+)|(.+)$");
-				attrx[ns.."\1"..na] = attr[att];
-				attr[att] = nil;
+		for att, val in pairs(attr) do
+			if type(att) == "string" then
+				if s_find(att, "|", 1, true) and not s_find(att, "\1", 1, true) then
+					local ns,na = s_match(att, "^([^|]+)|(.+)$");
+					attrx[ns.."\1"..na] = val;
+				else
+					attrx[att] = val;
+				end
 			end
 		end
-		for a,v in pairs(attrx) do
-			attr[a] = v;
-		end
-		setmetatable(stanza, stanza_mt);
-		for _, child in ipairs(stanza) do
+		local stanza = new_stanza(serialized.name, attrx);
+		for _, child in ipairs(serialized) do
 			if type(child) == "table" then
-				deserialize(child);
+				stanza:add_direct_child(deserialize(child));
+			elseif type(child) == "string" then
+				stanza:add_direct_child(child);
 			end
 		end
-		if not stanza.tags then
-			-- Rebuild tags
-			local tags = {};
-			for _, child in ipairs(stanza) do
-				if type(child) == "table" then
-					t_insert(tags, child);
-				end
-			end
-			stanza.tags = tags;
-		end
+		return stanza;
 	end
-
-	return stanza;
 end
 
-local function clone(stanza)
+local function _clone(stanza)
 	local attr, tags = {}, {};
 	for k,v in pairs(stanza.attr) do attr[k] = v; end
-	local new = { name = stanza.name, attr = attr, tags = tags };
+	local old_namespaces, namespaces = stanza.namespaces;
+	if old_namespaces then
+		namespaces = {};
+		for k,v in pairs(old_namespaces) do namespaces[k] = v; end
+	end
+	local new = { name = stanza.name, attr = attr, namespaces = namespaces, tags = tags };
 	for i=1,#stanza do
 		local child = stanza[i];
 		if child.name then
-			child = clone(child);
+			child = _clone(child);
 			t_insert(tags, child);
 		end
 		t_insert(new, child);
@@ -359,6 +408,13 @@
 	return setmetatable(new, stanza_mt);
 end
 
+local function clone(stanza)
+	if not is_stanza(stanza) then
+		error("bad argument to clone: expected stanza, got "..type(stanza));
+	end
+	return _clone(stanza);
+end
+
 local function message(attr, body)
 	if not body then
 		return new_stanza("message", attr);
@@ -367,12 +423,20 @@
 	end
 end
 local function iq(attr)
-	if attr and not attr.id then attr.id = new_id(); end
-	return new_stanza("iq", attr or { id = new_id() });
+	if not (attr and attr.id) then
+		error("iq stanzas require an id attribute");
+	end
+	return new_stanza("iq", attr);
 end
 
 local function reply(orig)
-	return new_stanza(orig.name, orig.attr and { to = orig.attr.from, from = orig.attr.to, id = orig.attr.id, type = ((orig.name == "iq" and "result") or orig.attr.type) });
+	return new_stanza(orig.name,
+		orig.attr and {
+			to = orig.attr.from,
+			from = orig.attr.to,
+			id = orig.attr.id,
+			type = ((orig.name == "iq" and "result") or orig.attr.type)
+		});
 end
 
 local xmpp_stanzas_attr = { xmlns = xmlns_stanzas };
@@ -433,7 +497,6 @@
 	stanza_mt = stanza_mt;
 	stanza = new_stanza;
 	is_stanza = is_stanza;
-	new_id = new_id;
 	preserialize = preserialize;
 	deserialize = deserialize;
 	clone = clone;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/startup.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,555 @@
+-- Ignore the CFG_* variables
+-- luacheck: ignore 113/CFG_CONFIGDIR 113/CFG_SOURCEDIR 113/CFG_DATADIR 113/CFG_PLUGINDIR
+local startup = {};
+
+local prosody = { events = require "util.events".new() };
+local logger = require "util.logger";
+local log = logger.init("startup");
+
+local config = require "core.configmanager";
+
+local dependencies = require "util.dependencies";
+
+local original_logging_config;
+
+function startup.read_config()
+	local filenames = {};
+
+	local filename;
+	if arg[1] == "--config" and arg[2] then
+		table.insert(filenames, arg[2]);
+		if CFG_CONFIGDIR then
+			table.insert(filenames, CFG_CONFIGDIR.."/"..arg[2]);
+		end
+		table.remove(arg, 1); table.remove(arg, 1);
+	elseif os.getenv("PROSODY_CONFIG") then -- Passed by prosodyctl
+			table.insert(filenames, os.getenv("PROSODY_CONFIG"));
+	else
+		table.insert(filenames, (CFG_CONFIGDIR or ".").."/prosody.cfg.lua");
+	end
+	for _,_filename in ipairs(filenames) do
+		filename = _filename;
+		local file = io.open(filename);
+		if file then
+			file:close();
+			prosody.config_file = filename;
+			CFG_CONFIGDIR = filename:match("^(.*)[\\/][^\\/]*$"); -- luacheck: ignore 111
+			break;
+		end
+	end
+	prosody.config_file = filename
+	local ok, level, err = config.load(filename);
+	if not ok then
+		print("\n");
+		print("**************************");
+		if level == "parser" then
+			print("A problem occurred while reading the config file "..filename);
+			print("");
+			local err_line, err_message = tostring(err):match("%[string .-%]:(%d*): (.*)");
+			if err:match("chunk has too many syntax levels$") then
+				print("An Include statement in a config file is including an already-included");
+				print("file and causing an infinite loop. An Include statement in a config file is...");
+			else
+				print("Error"..(err_line and (" on line "..err_line) or "")..": "..(err_message or tostring(err)));
+			end
+			print("");
+		elseif level == "file" then
+			print("Prosody was unable to find the configuration file.");
+			print("We looked for: "..filename);
+			print("A sample config file is included in the Prosody download called prosody.cfg.lua.dist");
+			print("Copy or rename it to prosody.cfg.lua and edit as necessary.");
+		end
+		print("More help on configuring Prosody can be found at https://prosody.im/doc/configure");
+		print("Good luck!");
+		print("**************************");
+		print("");
+		os.exit(1);
+	end
+	prosody.config_loaded = true;
+end
+
+function startup.check_dependencies()
+	if not dependencies.check_dependencies() then
+		os.exit(1);
+	end
+end
+
+-- luacheck: globals socket server
+
+function startup.load_libraries()
+	-- Load socket framework
+	-- luacheck: ignore 111/server 111/socket
+	socket = require "socket";
+	server = require "net.server"
+end
+
+function startup.init_logging()
+	-- Initialize logging
+	local loggingmanager = require "core.loggingmanager"
+	loggingmanager.reload_logging();
+	prosody.events.add_handler("config-reloaded", function ()
+		prosody.events.fire_event("reopen-log-files");
+	end);
+	prosody.events.add_handler("reopen-log-files", function ()
+		loggingmanager.reload_logging();
+		prosody.events.fire_event("logging-reloaded");
+	end);
+end
+
+function startup.log_dependency_warnings()
+	dependencies.log_warnings();
+end
+
+function startup.sanity_check()
+	for host, host_config in pairs(config.getconfig()) do
+		if host ~= "*"
+		and host_config.enabled ~= false
+		and not host_config.component_module then
+			return;
+		end
+	end
+	log("error", "No enabled VirtualHost entries found in the config file.");
+	log("error", "At least one active host is required for Prosody to function. Exiting...");
+	os.exit(1);
+end
+
+function startup.sandbox_require()
+	-- Replace require() with one that doesn't pollute _G, required
+	-- for neat sandboxing of modules
+	-- luacheck: ignore 113/getfenv 111/require
+	local _realG = _G;
+	local _real_require = require;
+	local getfenv = getfenv or function (f)
+		-- FIXME: This is a hack to replace getfenv() in Lua 5.2
+		local name, env = debug.getupvalue(debug.getinfo(f or 1).func, 1);
+		if name == "_ENV" then
+			return env;
+		end
+	end
+	function require(...) -- luacheck: ignore 121
+		local curr_env = getfenv(2);
+		local curr_env_mt = getmetatable(curr_env);
+		local _realG_mt = getmetatable(_realG);
+		if curr_env_mt and curr_env_mt.__index and not curr_env_mt.__newindex and _realG_mt then
+			local old_newindex, old_index;
+			old_newindex, _realG_mt.__newindex = _realG_mt.__newindex, curr_env;
+			old_index, _realG_mt.__index = _realG_mt.__index, function (_G, k) -- luacheck: ignore 212/_G
+				return rawget(curr_env, k);
+			end;
+			local ret = _real_require(...);
+			_realG_mt.__newindex = old_newindex;
+			_realG_mt.__index = old_index;
+			return ret;
+		end
+		return _real_require(...);
+	end
+end
+
+function startup.set_function_metatable()
+	local mt = {};
+	function mt.__index(f, upvalue)
+		local i, name, value = 0;
+		repeat
+			i = i + 1;
+			name, value = debug.getupvalue(f, i);
+		until name == upvalue or name == nil;
+		return value;
+	end
+	function mt.__newindex(f, upvalue, value)
+		local i, name = 0;
+		repeat
+			i = i + 1;
+			name = debug.getupvalue(f, i);
+		until name == upvalue or name == nil;
+		if name then
+			debug.setupvalue(f, i, value);
+		end
+	end
+	function mt.__tostring(f)
+		local info = debug.getinfo(f);
+		return ("function(%s:%d)"):format(info.short_src:match("[^\\/]*$"), info.linedefined);
+	end
+	debug.setmetatable(function() end, mt);
+end
+
+function startup.detect_platform()
+	prosody.platform = "unknown";
+	if os.getenv("WINDIR") then
+		prosody.platform = "windows";
+	elseif package.config:sub(1,1) == "/" then
+		prosody.platform = "posix";
+	end
+end
+
+function startup.detect_installed()
+	prosody.installed = nil;
+	if CFG_SOURCEDIR and (prosody.platform == "windows" or CFG_SOURCEDIR:match("^/")) then
+		prosody.installed = true;
+	end
+end
+
+function startup.init_global_state()
+	-- luacheck: ignore 121
+	prosody.bare_sessions = {};
+	prosody.full_sessions = {};
+	prosody.hosts = {};
+
+	-- COMPAT: These globals are deprecated
+	-- luacheck: ignore 111/bare_sessions 111/full_sessions 111/hosts
+	bare_sessions = prosody.bare_sessions;
+	full_sessions = prosody.full_sessions;
+	hosts = prosody.hosts;
+
+	prosody.paths = { source = CFG_SOURCEDIR, config = CFG_CONFIGDIR or ".",
+	                  plugins = CFG_PLUGINDIR or "plugins", data = "data" };
+
+	prosody.arg = _G.arg;
+
+	_G.log = logger.init("general");
+	prosody.log = logger.init("general");
+
+	startup.detect_platform();
+	startup.detect_installed();
+	_G.prosody = prosody;
+end
+
+function startup.setup_datadir()
+	prosody.paths.data = config.get("*", "data_path") or CFG_DATADIR or "data";
+end
+
+function startup.setup_plugindir()
+	local custom_plugin_paths = config.get("*", "plugin_paths");
+	if custom_plugin_paths then
+		local path_sep = package.config:sub(3,3);
+		-- path1;path2;path3;defaultpath...
+		-- luacheck: ignore 111
+		CFG_PLUGINDIR = table.concat(custom_plugin_paths, path_sep)..path_sep..(CFG_PLUGINDIR or "plugins");
+		prosody.paths.plugins = CFG_PLUGINDIR;
+	end
+end
+
+function startup.chdir()
+	if prosody.installed then
+		-- Change working directory to data path.
+		require "lfs".chdir(prosody.paths.data);
+	end
+end
+
+function startup.add_global_prosody_functions()
+	-- Function to reload the config file
+	function prosody.reload_config()
+		log("info", "Reloading configuration file");
+		prosody.events.fire_event("reloading-config");
+		local ok, level, err = config.load(prosody.config_file);
+		if not ok then
+			if level == "parser" then
+				log("error", "There was an error parsing the configuration file: %s", tostring(err));
+			elseif level == "file" then
+				log("error", "Couldn't read the config file when trying to reload: %s", tostring(err));
+			end
+		else
+			prosody.events.fire_event("config-reloaded", {
+				filename = prosody.config_file,
+				config = config.getconfig(),
+			});
+		end
+		return ok, (err and tostring(level)..": "..tostring(err)) or nil;
+	end
+
+	-- Function to reopen logfiles
+	function prosody.reopen_logfiles()
+		log("info", "Re-opening log files");
+		prosody.events.fire_event("reopen-log-files");
+	end
+
+	-- Function to initiate prosody shutdown
+	function prosody.shutdown(reason, code)
+		log("info", "Shutting down: %s", reason or "unknown reason");
+		prosody.shutdown_reason = reason;
+		prosody.shutdown_code = code;
+		prosody.events.fire_event("server-stopping", {
+			reason = reason;
+			code = code;
+		});
+		server.setquitting(true);
+	end
+end
+
+function startup.load_secondary_libraries()
+	--- Load and initialise core modules
+	require "util.import"
+	require "util.xmppstream"
+	require "core.stanza_router"
+	require "core.statsmanager"
+	require "core.hostmanager"
+	require "core.portmanager"
+	require "core.modulemanager"
+	require "core.usermanager"
+	require "core.rostermanager"
+	require "core.sessionmanager"
+	package.loaded['core.componentmanager'] = setmetatable({},{__index=function()
+		-- COMPAT which version?
+		log("warn", "componentmanager is deprecated: %s", debug.traceback():match("\n[^\n]*\n[ \t]*([^\n]*)"));
+		return function() end
+	end});
+
+	require "util.array"
+	require "util.datetime"
+	require "util.iterators"
+	require "util.timer"
+	require "util.helpers"
+
+	pcall(require, "util.signal") -- Not on Windows
+
+	-- Commented to protect us from
+	-- the second kind of people
+	--[[
+	pcall(require, "remdebug.engine");
+	if remdebug then remdebug.engine.start() end
+	]]
+
+	require "util.stanza"
+	require "util.jid"
+end
+
+function startup.init_http_client()
+	local http = require "net.http"
+	local config_ssl = config.get("*", "ssl") or {}
+	local https_client = config.get("*", "client_https_ssl")
+	http.default.options.sslctx = require "core.certmanager".create_context("client_https port 0", "client",
+		{ capath = config_ssl.capath, cafile = config_ssl.cafile, verify = "peer", }, https_client);
+end
+
+function startup.init_data_store()
+	require "core.storagemanager";
+end
+
+function startup.prepare_to_start()
+	log("info", "Prosody is using the %s backend for connection handling", server.get_backend());
+	-- Signal to modules that we are ready to start
+	prosody.events.fire_event("server-starting");
+	prosody.start_time = os.time();
+end
+
+function startup.init_global_protection()
+	-- Catch global accesses
+	-- luacheck: ignore 212/t
+	local locked_globals_mt = {
+		__index = function (t, k) log("warn", "%s", debug.traceback("Attempt to read a non-existent global '"..tostring(k).."'", 2)); end;
+		__newindex = function (t, k, v) error("Attempt to set a global: "..tostring(k).." = "..tostring(v), 2); end;
+	};
+
+	function prosody.unlock_globals()
+		setmetatable(_G, nil);
+	end
+
+	function prosody.lock_globals()
+		setmetatable(_G, locked_globals_mt);
+	end
+
+	-- And lock now...
+	prosody.lock_globals();
+end
+
+function startup.read_version()
+	-- Try to determine version
+	local version_file = io.open((CFG_SOURCEDIR or ".").."/prosody.version");
+	prosody.version = "unknown";
+	if version_file then
+		prosody.version = version_file:read("*a"):gsub("%s*$", "");
+		version_file:close();
+		if #prosody.version == 12 and prosody.version:match("^[a-f0-9]+$") then
+			prosody.version = "hg:"..prosody.version;
+		end
+	else
+		local hg = require"util.mercurial";
+		local hgid = hg.check_id(CFG_SOURCEDIR or ".");
+		if hgid then prosody.version = "hg:" .. hgid; end
+	end
+end
+
+function startup.log_greeting()
+	log("info", "Hello and welcome to Prosody version %s", prosody.version);
+end
+
+function startup.notify_started()
+	prosody.events.fire_event("server-started");
+end
+
+-- Override logging config (used by prosodyctl)
+function startup.force_console_logging()
+	original_logging_config = config.get("*", "log");
+	config.set("*", "log", { { levels = { min = os.getenv("PROSODYCTL_LOG_LEVEL") or "info" }, to = "console" } });
+end
+
+function startup.switch_user()
+	-- Switch away from root and into the prosody user --
+	-- NOTE: This function is only used by prosodyctl.
+	-- The prosody process is built with the assumption that
+	-- it is already started as the appropriate user.
+
+	local want_pposix_version = "0.4.0";
+	local have_pposix, pposix = pcall(require, "util.pposix");
+
+	if have_pposix and pposix then
+		if pposix._VERSION ~= want_pposix_version then
+			print(string.format("Unknown version (%s) of binary pposix module, expected %s",
+				tostring(pposix._VERSION), want_pposix_version));
+			os.exit(1);
+		end
+		prosody.current_uid = pposix.getuid();
+		local arg_root = arg[1] == "--root";
+		if arg_root then table.remove(arg, 1); end
+		if prosody.current_uid == 0 and config.get("*", "run_as_root") ~= true and not arg_root then
+			-- We haz root!
+			local desired_user = config.get("*", "prosody_user") or "prosody";
+			local desired_group = config.get("*", "prosody_group") or desired_user;
+			local ok, err = pposix.setgid(desired_group);
+			if ok then
+				ok, err = pposix.initgroups(desired_user);
+			end
+			if ok then
+				ok, err = pposix.setuid(desired_user);
+				if ok then
+					-- Yay!
+					prosody.switched_user = true;
+				end
+			end
+			if not prosody.switched_user then
+				-- Boo!
+				print("Warning: Couldn't switch to Prosody user/group '"..tostring(desired_user).."'/'"..tostring(desired_group).."': "..tostring(err));
+			else
+				-- Make sure the Prosody user can read the config
+				local conf, err, errno = io.open(prosody.config_file);
+				if conf then
+					conf:close();
+				else
+					print("The config file is not readable by the '"..desired_user.."' user.");
+					print("Prosody will not be able to read it.");
+					print("Error was "..err);
+					os.exit(1);
+				end
+			end
+		end
+
+		-- Set our umask to protect data files
+		pposix.umask(config.get("*", "umask") or "027");
+		pposix.setenv("HOME", prosody.paths.data);
+		pposix.setenv("PROSODY_CONFIG", prosody.config_file);
+	else
+		print("Error: Unable to load pposix module. Check that Prosody is installed correctly.")
+		print("For more help send the below error to us through https://prosody.im/discuss");
+		print(tostring(pposix))
+		os.exit(1);
+	end
+end
+
+function startup.check_unwriteable()
+	local function test_writeable(filename)
+		local f, err = io.open(filename, "a");
+		if not f then
+			return false, err;
+		end
+		f:close();
+		return true;
+	end
+
+	local unwriteable_files = {};
+	if type(original_logging_config) == "string" and original_logging_config:sub(1,1) ~= "*" then
+		local ok, err = test_writeable(original_logging_config);
+		if not ok then
+			table.insert(unwriteable_files, err);
+		end
+	elseif type(original_logging_config) == "table" then
+		for _, rule in ipairs(original_logging_config) do
+			if rule.filename then
+				local ok, err = test_writeable(rule.filename);
+				if not ok then
+					table.insert(unwriteable_files, err);
+				end
+			end
+		end
+	end
+
+	if #unwriteable_files > 0 then
+		print("One of more of the Prosody log files are not");
+		print("writeable, please correct the errors and try");
+		print("starting prosodyctl again.");
+		print("");
+		for _, err in ipairs(unwriteable_files) do
+			print(err);
+		end
+		print("");
+		os.exit(1);
+	end
+end
+
+function startup.make_host(hostname)
+	return {
+		type = "local",
+		events = prosody.events,
+		modules = {},
+		sessions = {},
+		users = require "core.usermanager".new_null_provider(hostname)
+	};
+end
+
+function startup.make_dummy_hosts()
+	-- When running under prosodyctl, we don't want to
+	-- fully initialize the server, so we populate prosody.hosts
+	-- with just enough things for most code to work correctly
+	-- luacheck: ignore 122/hosts
+	prosody.core_post_stanza = function () end; -- TODO: mod_router!
+
+	for hostname in pairs(config.getconfig()) do
+		prosody.hosts[hostname] = startup.make_host(hostname);
+	end
+end
+
+-- prosodyctl only
+function startup.prosodyctl()
+	startup.init_global_state();
+	startup.read_config();
+	startup.force_console_logging();
+	startup.init_logging();
+	startup.setup_plugindir();
+	startup.setup_datadir();
+	startup.chdir();
+	startup.read_version();
+	startup.switch_user();
+	startup.check_dependencies();
+	startup.log_dependency_warnings();
+	startup.check_unwriteable();
+	startup.load_libraries();
+	startup.init_http_client();
+	startup.make_dummy_hosts();
+end
+
+function startup.prosody()
+	-- These actions are in a strict order, as many depend on
+	-- previous steps to have already been performed
+	startup.init_global_state();
+	startup.read_config();
+	startup.init_logging();
+	startup.sanity_check();
+	startup.sandbox_require();
+	startup.set_function_metatable();
+	startup.check_dependencies();
+	startup.init_logging();
+	startup.load_libraries();
+	startup.setup_plugindir();
+	startup.setup_datadir();
+	startup.chdir();
+	startup.add_global_prosody_functions();
+	startup.read_version();
+	startup.log_greeting();
+	startup.log_dependency_warnings();
+	startup.load_secondary_libraries();
+	startup.init_http_client();
+	startup.init_data_store();
+	startup.init_global_protection();
+	startup.prepare_to_start();
+	startup.notify_started();
+end
+
+return startup;
--- a/util/template.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/template.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -4,12 +4,13 @@
 local pairs = pairs;
 local ipairs = ipairs;
 local error = error;
-local loadstring = loadstring;
+local envload = require "util.envload".envload;
 local debug = debug;
 local t_remove = table.remove;
 local parse_xml = require "util.xml".parse;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local function trim_xml(stanza)
 	for i=#stanza,1,-1 do
@@ -72,7 +73,7 @@
 		src = src.."local _"..i.."="..lookup[i]..";";
 	end
 	src = src.."return "..name..";end";
-	local f,err = loadstring(src, chunkname);
+	local f,err = envload(src, chunkname);
 	if not f then error(err); end
 	return f(setmetatable, stanza_mt);
 end
--- a/util/termcolours.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/termcolours.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -26,6 +26,7 @@
 local orig_color = windows and windows.get_consolecolor and windows.get_consolecolor();
 
 local _ENV = nil;
+-- luacheck: std none
 
 local stylemap = {
 			reset = 0; bright = 1, dim = 2, underscore = 4, blink = 5, reverse = 7, hidden = 8;
--- a/util/throttle.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/throttle.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -3,6 +3,7 @@
 local setmetatable = setmetatable;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local throttle = {};
 local throttle_mt = { __index = throttle };
--- a/util/time.lua	Wed Nov 28 16:55:27 2018 +0000
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,8 +0,0 @@
--- Import gettime() from LuaSocket, as a way to access high-resolution time
--- in a platform-independent way
-
-local socket_gettime = require "socket".gettime;
-
-return {
-	now = socket_gettime;
-}
--- a/util/timer.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/timer.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -6,78 +6,102 @@
 -- COPYING file in the source package for more information.
 --
 
+local indexedbheap = require "util.indexedbheap";
+local log = require "util.logger".init("timer");
 local server = require "net.server";
-local math_min = math.min
-local math_huge = math.huge
 local get_time = require "util.time".now
-local t_insert = table.insert;
-local pairs = pairs;
 local type = type;
-
-local data = {};
-local new_data = {};
+local debug_traceback = debug.traceback;
+local tostring = tostring;
+local xpcall = require "util.xpcall".xpcall;
+local math_max = math.max;
 
 local _ENV = nil;
+-- luacheck: std none
 
-local _add_task;
-if not server.event then
-	function _add_task(delay, callback)
-		local current_time = get_time();
-		delay = delay + current_time;
-		if delay >= current_time then
-			t_insert(new_data, {delay, callback});
-		else
-			local r = callback(current_time);
-			if r and type(r) == "number" then
-				return _add_task(r, callback);
-			end
+local _add_task = server.add_task;
+
+local _server_timer;
+local _active_timers = 0;
+local h = indexedbheap.create();
+local params = {};
+local next_time = nil;
+local function _traceback_handler(err) log("error", "Traceback[timer]: %s", debug_traceback(tostring(err), 2)); end
+local function _on_timer(now)
+	local peek;
+	while true do
+		peek = h:peek();
+		if peek == nil or peek > now then break; end
+		local _, callback, id = h:pop();
+		local param = params[id];
+		params[id] = nil;
+		--item(now, id, _param);
+		local success, err = xpcall(callback, _traceback_handler, now, id, param);
+		if success and type(err) == "number" then
+			h:insert(callback, err + now, id); -- re-add
+			params[id] = param;
 		end
 	end
 
-	server._addtimer(function()
-		local current_time = get_time();
-		if #new_data > 0 then
-			for _, d in pairs(new_data) do
-				t_insert(data, d);
-			end
-			new_data = {};
-		end
+	if peek ~= nil and _active_timers > 1 and peek == next_time then
+		-- Another instance of _on_timer already set next_time to the same value,
+		-- so it should be safe to not renew this timer event
+		peek = nil;
+	else
+		next_time = peek;
+	end
+
+	if peek then
+		-- peek is the time of the next event
+		return peek - now;
+	end
+	_active_timers = _active_timers - 1;
+end
+local function add_task(delay, callback, param)
+	local current_time = get_time();
+	local event_time = current_time + delay;
 
-		local next_time = math_huge;
-		for i, d in pairs(data) do
-			local t, callback = d[1], d[2];
-			if t <= current_time then
-				data[i] = nil;
-				local r = callback(current_time);
-				if type(r) == "number" then
-					_add_task(r, callback);
-					next_time = math_min(next_time, r);
-				end
-			else
-				next_time = math_min(next_time, t - current_time);
-			end
+	local id = h:insert(callback, event_time);
+	params[id] = param;
+	if next_time == nil or event_time < next_time then
+		next_time = event_time;
+		if _server_timer then
+			_server_timer:close();
+			_server_timer = nil;
+		else
+			_active_timers = _active_timers + 1;
 		end
-		return next_time;
-	end);
-else
-	local event = server.event;
-	local event_base = server.event_base;
-	local EVENT_LEAVE = (event.core and event.core.LEAVE) or -1;
-
-	function _add_task(delay, callback)
-		local event_handle;
-		event_handle = event_base:addevent(nil, 0, function ()
-			local ret = callback(get_time());
-			if ret then
-				return 0, ret;
-			elseif event_handle then
-				return EVENT_LEAVE;
-			end
+		_server_timer = _add_task(next_time - current_time, _on_timer);
+	end
+	return id;
+end
+local function stop(id)
+	params[id] = nil;
+	local result, item, result_sync = h:remove(id);
+	local peek = h:peek();
+	if peek ~= next_time and _server_timer then
+		next_time = peek;
+		_server_timer:close();
+		if next_time ~= nil then
+			_server_timer = _add_task(math_max(next_time - get_time(), 0), _on_timer);
 		end
-		, delay);
 	end
+	return result, item, result_sync;
+end
+local function reschedule(id, delay)
+	local current_time = get_time();
+	local event_time = current_time + delay;
+	h:reprioritize(id, delay);
+	if next_time == nil or event_time < next_time then
+		next_time = event_time;
+		_add_task(next_time - current_time, _on_timer);
+	end
+	return id;
 end
 
 return {
-	add_task = _add_task;
+	add_task = add_task;
+	stop = stop;
+	reschedule = reschedule;
 };
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/vcard.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,574 @@
+-- Copyright (C) 2011-2014 Kim Alvefur
+--
+-- This project is MIT/X11 licensed. Please see the
+-- COPYING file in the source package for more information.
+--
+
+-- TODO
+-- Fix folding.
+
+local st = require "util.stanza";
+local t_insert, t_concat = table.insert, table.concat;
+local type = type;
+local pairs, ipairs = pairs, ipairs;
+
+local from_text, to_text, from_xep54, to_xep54;
+
+local line_sep = "\n";
+
+local vCard_dtd; -- See end of file
+local vCard4_dtd;
+
+local function vCard_esc(s)
+	return s:gsub("[,:;\\]", "\\%1"):gsub("\n","\\n");
+end
+
+local function vCard_unesc(s)
+	return s:gsub("\\?[\\nt:;,]", {
+		["\\\\"] = "\\",
+		["\\n"] = "\n",
+		["\\r"] = "\r",
+		["\\t"] = "\t",
+		["\\:"] = ":", -- FIXME Shouldn't need to espace : in values, just params
+		["\\;"] = ";",
+		["\\,"] = ",",
+		[":"] = "\29",
+		[";"] = "\30",
+		[","] = "\31",
+	});
+end
+
+local function item_to_xep54(item)
+	local t = st.stanza(item.name, { xmlns = "vcard-temp" });
+
+	local prop_def = vCard_dtd[item.name];
+	if prop_def == "text" then
+		t:text(item[1]);
+	elseif type(prop_def) == "table" then
+		if prop_def.types and item.TYPE then
+			if type(item.TYPE) == "table" then
+				for _,v in pairs(prop_def.types) do
+					for _,typ in pairs(item.TYPE) do
+						if typ:upper() == v then
+							t:tag(v):up();
+							break;
+						end
+					end
+				end
+			else
+				t:tag(item.TYPE:upper()):up();
+			end
+		end
+
+		if prop_def.props then
+			for _,prop in pairs(prop_def.props) do
+				if item[prop] then
+					for _, v in ipairs(item[prop]) do
+						t:text_tag(prop, v);
+					end
+				end
+			end
+		end
+
+		if prop_def.value then
+			t:text_tag(prop_def.value, item[1]);
+		elseif prop_def.values then
+			local prop_def_values = prop_def.values;
+			local repeat_last = prop_def_values.behaviour == "repeat-last" and prop_def_values[#prop_def_values];
+			for i=1,#item do
+				t:text_tag(prop_def.values[i] or repeat_last, item[i]);
+			end
+		end
+	end
+
+	return t;
+end
+
+local function vcard_to_xep54(vCard)
+	local t = st.stanza("vCard", { xmlns = "vcard-temp" });
+	for i=1,#vCard do
+		t:add_child(item_to_xep54(vCard[i]));
+	end
+	return t;
+end
+
+function to_xep54(vCards)
+	if not vCards[1] or vCards[1].name then
+		return vcard_to_xep54(vCards)
+	else
+		local t = st.stanza("xCard", { xmlns = "vcard-temp" });
+		for i=1,#vCards do
+			t:add_child(vcard_to_xep54(vCards[i]));
+		end
+		return t;
+	end
+end
+
+function from_text(data)
+	data = data -- unfold and remove empty lines
+		:gsub("\r\n","\n")
+		:gsub("\n ", "")
+		:gsub("\n\n+","\n");
+	local vCards = {};
+	local current;
+	for line in data:gmatch("[^\n]+") do
+		line = vCard_unesc(line);
+		local name, params, value = line:match("^([-%a]+)(\30?[^\29]*)\29(.*)$");
+		value = value:gsub("\29",":");
+		if #params > 0 then
+			local _params = {};
+			for k,isval,v in params:gmatch("\30([^=]+)(=?)([^\30]*)") do
+				k = k:upper();
+				local _vt = {};
+				for _p in v:gmatch("[^\31]+") do
+					_vt[#_vt+1]=_p
+					_vt[_p]=true;
+				end
+				if isval == "=" then
+					_params[k]=_vt;
+				else
+					_params[k]=true;
+				end
+			end
+			params = _params;
+		end
+		if name == "BEGIN" and value == "VCARD" then
+			current = {};
+			vCards[#vCards+1] = current;
+		elseif name == "END" and value == "VCARD" then
+			current = nil;
+		elseif current and vCard_dtd[name] then
+			local dtd = vCard_dtd[name];
+			local item = { name = name };
+			t_insert(current, item);
+			local up = current;
+			current = item;
+			if dtd.types then
+				for _, t in ipairs(dtd.types) do
+					t = t:lower();
+					if ( params.TYPE and params.TYPE[t] == true)
+							or params[t] == true then
+						current.TYPE=t;
+					end
+				end
+			end
+			if dtd.props then
+				for _, p in ipairs(dtd.props) do
+					if params[p] then
+						if params[p] == true then
+							current[p]=true;
+						else
+							for _, prop in ipairs(params[p]) do
+								current[p]=prop;
+							end
+						end
+					end
+				end
+			end
+			if dtd == "text" or dtd.value then
+				t_insert(current, value);
+			elseif dtd.values then
+				for p in ("\30"..value):gmatch("\30([^\30]*)") do
+					t_insert(current, p);
+				end
+			end
+			current = up;
+		end
+	end
+	return vCards;
+end
+
+local function item_to_text(item)
+	local value = {};
+	for i=1,#item do
+		value[i] = vCard_esc(item[i]);
+	end
+	value = t_concat(value, ";");
+
+	local params = "";
+	for k,v in pairs(item) do
+		if type(k) == "string" and k ~= "name" then
+			params = params .. (";%s=%s"):format(k, type(v) == "table" and t_concat(v,",") or v);
+		end
+	end
+
+	return ("%s%s:%s"):format(item.name, params, value)
+end
+
+local function vcard_to_text(vcard)
+	local t={};
+	t_insert(t, "BEGIN:VCARD")
+	for i=1,#vcard do
+		t_insert(t, item_to_text(vcard[i]));
+	end
+	t_insert(t, "END:VCARD")
+	return t_concat(t, line_sep);
+end
+
+function to_text(vCards)
+	if vCards[1] and vCards[1].name then
+		return vcard_to_text(vCards)
+	else
+		local t = {};
+		for i=1,#vCards do
+			t[i]=vcard_to_text(vCards[i]);
+		end
+		return t_concat(t, line_sep);
+	end
+end
+
+local function from_xep54_item(item)
+	local prop_name = item.name;
+	local prop_def = vCard_dtd[prop_name];
+
+	local prop = { name = prop_name };
+
+	if prop_def == "text" then
+		prop[1] = item:get_text();
+	elseif type(prop_def) == "table" then
+		if prop_def.value then --single item
+			prop[1] = item:get_child_text(prop_def.value) or "";
+		elseif prop_def.values then --array
+			local value_names = prop_def.values;
+			if value_names.behaviour == "repeat-last" then
+				for i=1,#item.tags do
+					t_insert(prop, item.tags[i]:get_text() or "");
+				end
+			else
+				for i=1,#value_names do
+					t_insert(prop, item:get_child_text(value_names[i]) or "");
+				end
+			end
+		elseif prop_def.names then
+			local names = prop_def.names;
+			for i=1,#names do
+				if item:get_child(names[i]) then
+					prop[1] = names[i];
+					break;
+				end
+			end
+		end
+
+		if prop_def.props_verbatim then
+			for k,v in pairs(prop_def.props_verbatim) do
+				prop[k] = v;
+			end
+		end
+
+		if prop_def.types then
+			local types = prop_def.types;
+			prop.TYPE = {};
+			for i=1,#types do
+				if item:get_child(types[i]) then
+					t_insert(prop.TYPE, types[i]:lower());
+				end
+			end
+			if #prop.TYPE == 0 then
+				prop.TYPE = nil;
+			end
+		end
+
+		-- A key-value pair, within a key-value pair?
+		if prop_def.props then
+			local params = prop_def.props;
+			for i=1,#params do
+				local name = params[i]
+				local data = item:get_child_text(name);
+				if data then
+					prop[name] = prop[name] or {};
+					t_insert(prop[name], data);
+				end
+			end
+		end
+	else
+		return nil
+	end
+
+	return prop;
+end
+
+local function from_xep54_vCard(vCard)
+	local tags = vCard.tags;
+	local t = {};
+	for i=1,#tags do
+		t_insert(t, from_xep54_item(tags[i]));
+	end
+	return t
+end
+
+function from_xep54(vCard)
+	if vCard.attr.xmlns ~= "vcard-temp" then
+		return nil, "wrong-xmlns";
+	end
+	if vCard.name == "xCard" then -- A collection of vCards
+		local t = {};
+		local vCards = vCard.tags;
+		for i=1,#vCards do
+			t[i] = from_xep54_vCard(vCards[i]);
+		end
+		return t
+	elseif vCard.name == "vCard" then -- A single vCard
+		return from_xep54_vCard(vCard)
+	end
+end
+
+local vcard4 = { }
+
+function vcard4:text(node, params, value) -- luacheck: ignore 212/params
+	self:tag(node:lower())
+	-- FIXME params
+	if type(value) == "string" then
+		self:text_tag("text", value);
+	elseif vcard4[node] then
+		vcard4[node](value);
+	end
+	self:up();
+end
+
+function vcard4.N(value)
+	for i, k in ipairs(vCard_dtd.N.values) do
+		value:text_tag(k, value[i]);
+	end
+end
+
+local xmlns_vcard4 = "urn:ietf:params:xml:ns:vcard-4.0"
+
+local function item_to_vcard4(item)
+	local typ = item.name:lower();
+	local t = st.stanza(typ, { xmlns = xmlns_vcard4 });
+
+	local prop_def = vCard4_dtd[typ];
+	if prop_def == "text" then
+		t:text_tag("text", item[1]);
+	elseif prop_def == "uri" then
+		if item.ENCODING and item.ENCODING[1] == 'b' then
+			t:text_tag("uri", "data:;base64," .. item[1]);
+		else
+			t:text_tag("uri", item[1]);
+		end
+	elseif type(prop_def) == "table" then
+		if prop_def.values then
+			for i, v in ipairs(prop_def.values) do
+				t:text_tag(v:lower(), item[i]);
+			end
+		else
+			t:tag("unsupported",{xmlns="http://zash.se/protocol/vcardlib"})
+		end
+	else
+		t:tag("unsupported",{xmlns="http://zash.se/protocol/vcardlib"})
+	end
+	return t;
+end
+
+local function vcard_to_vcard4xml(vCard)
+	local t = st.stanza("vcard", { xmlns = xmlns_vcard4 });
+	for i=1,#vCard do
+		t:add_child(item_to_vcard4(vCard[i]));
+	end
+	return t;
+end
+
+local function vcards_to_vcard4xml(vCards)
+	if not vCards[1] or vCards[1].name then
+		return vcard_to_vcard4xml(vCards)
+	else
+		local t = st.stanza("vcards", { xmlns = xmlns_vcard4 });
+		for i=1,#vCards do
+			t:add_child(vcard_to_vcard4xml(vCards[i]));
+		end
+		return t;
+	end
+end
+
+-- This was adapted from http://xmpp.org/extensions/xep-0054.html#dtd
+vCard_dtd = {
+	VERSION = "text", --MUST be 3.0, so parsing is redundant
+	FN = "text",
+	N = {
+		values = {
+			"FAMILY",
+			"GIVEN",
+			"MIDDLE",
+			"PREFIX",
+			"SUFFIX",
+		},
+	},
+	NICKNAME = "text",
+	PHOTO = {
+		props_verbatim = { ENCODING = { "b" } },
+		props = { "TYPE" },
+		value = "BINVAL", --{ "EXTVAL", },
+	},
+	BDAY = "text",
+	ADR = {
+		types = {
+			"HOME",
+			"WORK",
+			"POSTAL",
+			"PARCEL",
+			"DOM",
+			"INTL",
+			"PREF",
+		},
+		values = {
+			"POBOX",
+			"EXTADD",
+			"STREET",
+			"LOCALITY",
+			"REGION",
+			"PCODE",
+			"CTRY",
+		}
+	},
+	LABEL = {
+		types = {
+			"HOME",
+			"WORK",
+			"POSTAL",
+			"PARCEL",
+			"DOM",
+			"INTL",
+			"PREF",
+		},
+		value = "LINE",
+	},
+	TEL = {
+		types = {
+			"HOME",
+			"WORK",
+			"VOICE",
+			"FAX",
+			"PAGER",
+			"MSG",
+			"CELL",
+			"VIDEO",
+			"BBS",
+			"MODEM",
+			"ISDN",
+			"PCS",
+			"PREF",
+		},
+		value = "NUMBER",
+	},
+	EMAIL = {
+		types = {
+			"HOME",
+			"WORK",
+			"INTERNET",
+			"PREF",
+			"X400",
+		},
+		value = "USERID",
+	},
+	JABBERID = "text",
+	MAILER = "text",
+	TZ = "text",
+	GEO = {
+		values = {
+			"LAT",
+			"LON",
+		},
+	},
+	TITLE = "text",
+	ROLE = "text",
+	LOGO = "copy of PHOTO",
+	AGENT = "text",
+	ORG = {
+		values = {
+			behaviour = "repeat-last",
+			"ORGNAME",
+			"ORGUNIT",
+		}
+	},
+	CATEGORIES = {
+		values = "KEYWORD",
+	},
+	NOTE = "text",
+	PRODID = "text",
+	REV = "text",
+	SORTSTRING = "text",
+	SOUND = "copy of PHOTO",
+	UID = "text",
+	URL = "text",
+	CLASS = {
+		names = { -- The item.name is the value if it's one of these.
+			"PUBLIC",
+			"PRIVATE",
+			"CONFIDENTIAL",
+		},
+	},
+	KEY = {
+		props = { "TYPE" },
+		value = "CRED",
+	},
+	DESC = "text",
+};
+vCard_dtd.LOGO = vCard_dtd.PHOTO;
+vCard_dtd.SOUND = vCard_dtd.PHOTO;
+
+vCard4_dtd = {
+	source = "uri",
+	kind = "text",
+	xml = "text",
+	fn = "text",
+	n = {
+		values = {
+			"family",
+			"given",
+			"middle",
+			"prefix",
+			"suffix",
+		},
+	},
+	nickname = "text",
+	photo = "uri",
+	bday = "date-and-or-time",
+	anniversary = "date-and-or-time",
+	gender = "text",
+	adr = {
+		values = {
+			"pobox",
+			"ext",
+			"street",
+			"locality",
+			"region",
+			"code",
+			"country",
+		}
+	},
+	tel = "text",
+	email = "text",
+	impp = "uri",
+	lang = "language-tag",
+	tz = "text",
+	geo = "uri",
+	title = "text",
+	role = "text",
+	logo = "uri",
+	org = "text",
+	member = "uri",
+	related = "uri",
+	categories = "text",
+	note = "text",
+	prodid = "text",
+	rev = "timestamp",
+	sound = "uri",
+	uid = "uri",
+	clientpidmap = "number, uuid",
+	url = "uri",
+	version = "text",
+	key = "uri",
+	fburl = "uri",
+	caladruri = "uri",
+	caluri = "uri",
+};
+
+return {
+	from_text = from_text;
+	to_text = to_text;
+
+	from_xep54 = from_xep54;
+	to_xep54 = to_xep54;
+
+	to_vcard4 = vcards_to_vcard4xml;
+};
--- a/util/watchdog.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/watchdog.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -3,6 +3,7 @@
 local os_time = os.time;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local watchdog_methods = {};
 local watchdog_mt = { __index = watchdog_methods };
--- a/util/x509.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/x509.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -25,6 +25,7 @@
 local s_format = string.format;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local oid_commonname = "2.5.4.3"; -- [LDAP] 2.3
 local oid_subjectaltname = "2.5.29.17"; -- [PKIX] 4.2.1.6
--- a/util/xml.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/xml.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -1,8 +1,11 @@
 
 local st = require "util.stanza";
 local lxp = require "lxp";
+local t_insert = table.insert;
+local t_remove = table.remove;
 
 local _ENV = nil;
+-- luacheck: std none
 
 local parse_xml = (function()
 	local ns_prefixes = {
@@ -14,6 +17,21 @@
 		--luacheck: ignore 212/self
 		local handler = {};
 		local stanza = st.stanza("root");
+		local namespaces = {};
+		local prefixes = {};
+		function handler:StartNamespaceDecl(prefix, url)
+			if prefix ~= nil then
+				t_insert(namespaces, url);
+				t_insert(prefixes, prefix);
+			end
+		end
+		function handler:EndNamespaceDecl(prefix)
+			if prefix ~= nil then
+				-- we depend on each StartNamespaceDecl having a paired EndNamespaceDecl
+				t_remove(namespaces);
+				t_remove(prefixes);
+			end
+		end
 		function handler:StartElement(tagname, attr)
 			local curr_ns,name = tagname:match(ns_pattern);
 			if name == "" then
@@ -34,7 +52,11 @@
 					end
 				end
 			end
-			stanza:tag(name, attr);
+			local n = {}
+			for i=1,#namespaces do
+				n[prefixes[i]] = namespaces[i];
+			end
+			stanza:tag(name, attr, n);
 		end
 		function handler:CharacterData(data)
 			stanza:text(data);
--- a/util/xmppstream.lua	Wed Nov 28 16:55:27 2018 +0000
+++ b/util/xmppstream.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -25,6 +25,7 @@
 local default_stanza_size_limit = 1024*1024*10; -- 10MB
 
 local _ENV = nil;
+-- luacheck: std none
 
 local new_parser = lxp.new;
 
@@ -47,7 +48,10 @@
 
 	local cb_streamopened = stream_callbacks.streamopened;
 	local cb_streamclosed = stream_callbacks.streamclosed;
-	local cb_error = stream_callbacks.error or function(session, e, stanza) error("XML stream error: "..tostring(e)..(stanza and ": "..tostring(stanza) or ""),2); end;
+	local cb_error = stream_callbacks.error or
+		function(_, e, stanza)
+			error("XML stream error: "..tostring(e)..(stanza and ": "..tostring(stanza) or ""),2);
+		end;
 	local cb_handlestanza = stream_callbacks.handlestanza;
 	cb_handleprogress = cb_handleprogress or dummy_cb;
 
@@ -126,13 +130,7 @@
 			t_insert(oldstanza.tags, stanza);
 		end
 	end
-	if lxp_supports_xmldecl then
-		function xml_handlers:XmlDecl(version, encoding, standalone)
-			if lxp_supports_bytecount then
-				cb_handleprogress(self:getcurrentbytecount());
-			end
-		end
-	end
+
 	function xml_handlers:StartCdataSection()
 		if lxp_supports_bytecount then
 			if stanza then
@@ -203,6 +201,18 @@
 		end
 	end
 
+	if lxp_supports_xmldecl then
+		function xml_handlers:XmlDecl(version, encoding, standalone)
+			if lxp_supports_bytecount then
+				cb_handleprogress(self:getcurrentbytecount());
+			end
+			if (encoding and encoding:lower() ~= "utf-8")
+			or (standalone == "no")
+			or (version and version ~= "1.0") then
+				return restricted_handler(self);
+			end
+		end
+	end
 	if lxp_supports_doctype then
 		xml_handlers.StartDoctypeDecl = restricted_handler;
 	end
@@ -214,7 +224,7 @@
 		stack = {};
 	end
 
-	local function set_session(stream, new_session)
+	local function set_session(stream, new_session) -- luacheck: ignore 212/stream
 		session = new_session;
 	end
 
@@ -238,7 +248,7 @@
 	local parser = new_parser(handlers, ns_separator, false);
 	local parse = parser.parse;
 
-	function session.open_stream(session, from, to)
+	function session.open_stream(session, from, to) -- luacheck: ignore 432/session
 		local send = session.sends2s or session.send;
 
 		local attr = {
@@ -264,14 +274,19 @@
 			n_outstanding_bytes = 0;
 			meta.reset();
 		end,
-		feed = function (self, data)
+		feed = function (self, data) -- luacheck: ignore 212/self
 			if lxp_supports_bytecount then
 				n_outstanding_bytes = n_outstanding_bytes + #data;
 			end
-			local ok, err = parse(parser, data);
+			local _parser = parser;
+			local ok, err = parse(_parser, data);
 			if lxp_supports_bytecount and n_outstanding_bytes > stanza_size_limit then
 				return nil, "stanza-too-large";
 			end
+			if parser ~= _parser then
+				_parser:parse();
+				_parser:close();
+			end
 			return ok, err;
 		end,
 		set_session = meta.set_session;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/util/xpcall.lua	Mon Jan 07 15:34:23 2019 +0000
@@ -0,0 +1,9 @@
+local xpcall = xpcall;
+
+if select(2, xpcall(function (x) return x end, function () end,  "test")) ~= "test" then
+	xpcall = require"util.compat".xpcall;
+end
+
+return {
+	xpcall = xpcall;
+};