diff --git a/.env.example b/.env.example
index 7626471c..7d6bd0e3 100644
--- a/.env.example
+++ b/.env.example
@@ -23,3 +23,15 @@ DATABASE_DSN="null:"
 # But to make things more understandable, the value for Flashii is also included
 #DOMAIN_ROLES="flashii.net=main; fii.moe=redirect"
 DOMAIN_ROLES="localhost=main,redirect:/go"
+
+# Local storage path
+# This determines where uploaded files are stored. If left unset, the storage folder in the project tree will be used.
+#STORAGE_PATH_LOCAL="/path/to/storage"
+
+# Remote storage path
+# Path on which the storage folder is exposed to the web for NGINX.
+#STORAGE_PATH_REMOTE="/_storage"
+
+# Template cache directory
+# Writeable directory path to which template files are cached.
+#TEMPLATE_CACHE="/tmp/msz-tpl-cache"
diff --git a/composer.json b/composer.json
index 5f575a6a..611ea43e 100644
--- a/composer.json
+++ b/composer.json
@@ -2,7 +2,7 @@
     "require": {
         "php": ">=8.4",
         "ext-mbstring": "*",
-        "flashwave/index": "^0.2503",
+        "flashwave/index": "^0.2504",
         "erusev/parsedown": "~1.7",
         "chillerlan/php-qrcode": "~5.0",
         "symfony/mailer": "~7.2",
diff --git a/composer.lock b/composer.lock
index 33fef3ce..b6a42d3b 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "cd96df7b2981a653c33b100a8b3837cd",
+    "content-hash": "600e14fc77ff00755761d6e9b6add998",
     "packages": [
         {
             "name": "carbonphp/carbon-doctrine-types",
@@ -501,11 +501,11 @@
         },
         {
             "name": "flashwave/index",
-            "version": "v0.2503.260138",
+            "version": "v0.2504.21040",
             "source": {
                 "type": "git",
                 "url": "https://patchii.net/flash/index.git",
-                "reference": "ea549dd0eb7cc7e7348bfcfb0e95da880dd2c039"
+                "reference": "42adbe5da8325d66a996e5d69dbb01def80743fc"
             },
             "require": {
                 "ext-mbstring": "*",
@@ -554,7 +554,7 @@
             ],
             "description": "Composer package for the common library for my projects.",
             "homepage": "https://railgun.sh/index",
-            "time": "2025-03-26T01:40:42+00:00"
+            "time": "2025-04-02T10:41:10+00:00"
         },
         {
             "name": "graham-campbell/result-type",
@@ -620,16 +620,16 @@
         },
         {
             "name": "guzzlehttp/guzzle",
-            "version": "7.9.2",
+            "version": "7.9.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/guzzle.git",
-                "reference": "d281ed313b989f213357e3be1a179f02196ac99b"
+                "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b",
-                "reference": "d281ed313b989f213357e3be1a179f02196ac99b",
+                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
+                "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
                 "shasum": ""
             },
             "require": {
@@ -726,7 +726,7 @@
             ],
             "support": {
                 "issues": "https://github.com/guzzle/guzzle/issues",
-                "source": "https://github.com/guzzle/guzzle/tree/7.9.2"
+                "source": "https://github.com/guzzle/guzzle/tree/7.9.3"
             },
             "funding": [
                 {
@@ -742,20 +742,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-07-24T11:22:20+00:00"
+            "time": "2025-03-27T13:37:11+00:00"
         },
         {
             "name": "guzzlehttp/promises",
-            "version": "2.0.4",
+            "version": "2.2.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/promises.git",
-                "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455"
+                "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/promises/zipball/f9c436286ab2892c7db7be8c8da4ef61ccf7b455",
-                "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455",
+                "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c",
+                "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c",
                 "shasum": ""
             },
             "require": {
@@ -809,7 +809,7 @@
             ],
             "support": {
                 "issues": "https://github.com/guzzle/promises/issues",
-                "source": "https://github.com/guzzle/promises/tree/2.0.4"
+                "source": "https://github.com/guzzle/promises/tree/2.2.0"
             },
             "funding": [
                 {
@@ -825,20 +825,20 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-10-17T10:06:22+00:00"
+            "time": "2025-03-27T13:27:01+00:00"
         },
         {
             "name": "guzzlehttp/psr7",
-            "version": "2.7.0",
+            "version": "2.7.1",
             "source": {
                 "type": "git",
                 "url": "https://github.com/guzzle/psr7.git",
-                "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201"
+                "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201",
-                "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201",
+                "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16",
+                "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16",
                 "shasum": ""
             },
             "require": {
@@ -925,7 +925,7 @@
             ],
             "support": {
                 "issues": "https://github.com/guzzle/psr7/issues",
-                "source": "https://github.com/guzzle/psr7/tree/2.7.0"
+                "source": "https://github.com/guzzle/psr7/tree/2.7.1"
             },
             "funding": [
                 {
@@ -941,7 +941,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2024-07-18T11:15:46+00:00"
+            "time": "2025-03-27T12:30:47+00:00"
         },
         {
             "name": "jean85/pretty-package-versions",
@@ -1129,16 +1129,16 @@
         },
         {
             "name": "nesbot/carbon",
-            "version": "3.8.6",
+            "version": "3.9.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/CarbonPHP/carbon.git",
-                "reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd"
+                "reference": "6d16a8a015166fe54e22c042e0805c5363aef50d"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/ff2f20cf83bd4d503720632ce8a426dc747bf7fd",
-                "reference": "ff2f20cf83bd4d503720632ce8a426dc747bf7fd",
+                "url": "https://api.github.com/repos/CarbonPHP/carbon/zipball/6d16a8a015166fe54e22c042e0805c5363aef50d",
+                "reference": "6d16a8a015166fe54e22c042e0805c5363aef50d",
                 "shasum": ""
             },
             "require": {
@@ -1231,7 +1231,7 @@
                     "type": "tidelift"
                 }
             ],
-            "time": "2025-02-20T17:33:38+00:00"
+            "time": "2025-03-27T12:57:33+00:00"
         },
         {
             "name": "paragonie/constant_time_encoding",
diff --git a/misuzu.php b/misuzu.php
index 9853af83..0211a960 100644
--- a/misuzu.php
+++ b/misuzu.php
@@ -27,4 +27,5 @@ $msz = new MisuzuContext(
     $_ENV['DOMAIN_ROLES'] ?? 'localhost=main,redirect,storage',
     $_ENV['STORAGE_PATH_LOCAL'] ?? Misuzu::PATH_STORAGE,
     $_ENV['STORAGE_PATH_REMOTE'] ?? '/_storage',
+    $_ENV['TEMPLATE_CACHE'] ?? '',
 );
diff --git a/package-lock.json b/package-lock.json
index 05ba9dda..9b03128f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -80,14 +80,14 @@
       }
     },
     "node_modules/@swc/core": {
-      "version": "1.10.17",
-      "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.10.17.tgz",
-      "integrity": "sha512-FXZx7jHpiwz4fTuuueWwsvN7VFLSoeS3mcxCTPUNOHs/K2ecaBO+slh5T5Xvt/KGuD2I/2T8G6Zts0maPkt2lQ==",
+      "version": "1.11.16",
+      "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.11.16.tgz",
+      "integrity": "sha512-wgjrJqVUss8Lxqilg0vkiE0tkEKU3mZkoybQM1Ehy+PKWwwB6lFAwKi20cAEFlSSWo8jFR8hRo19ZELAoLDowg==",
       "hasInstallScript": true,
       "license": "Apache-2.0",
       "dependencies": {
         "@swc/counter": "^0.1.3",
-        "@swc/types": "^0.1.17"
+        "@swc/types": "^0.1.21"
       },
       "engines": {
         "node": ">=10"
@@ -97,16 +97,16 @@
         "url": "https://opencollective.com/swc"
       },
       "optionalDependencies": {
-        "@swc/core-darwin-arm64": "1.10.17",
-        "@swc/core-darwin-x64": "1.10.17",
-        "@swc/core-linux-arm-gnueabihf": "1.10.17",
-        "@swc/core-linux-arm64-gnu": "1.10.17",
-        "@swc/core-linux-arm64-musl": "1.10.17",
-        "@swc/core-linux-x64-gnu": "1.10.17",
-        "@swc/core-linux-x64-musl": "1.10.17",
-        "@swc/core-win32-arm64-msvc": "1.10.17",
-        "@swc/core-win32-ia32-msvc": "1.10.17",
-        "@swc/core-win32-x64-msvc": "1.10.17"
+        "@swc/core-darwin-arm64": "1.11.16",
+        "@swc/core-darwin-x64": "1.11.16",
+        "@swc/core-linux-arm-gnueabihf": "1.11.16",
+        "@swc/core-linux-arm64-gnu": "1.11.16",
+        "@swc/core-linux-arm64-musl": "1.11.16",
+        "@swc/core-linux-x64-gnu": "1.11.16",
+        "@swc/core-linux-x64-musl": "1.11.16",
+        "@swc/core-win32-arm64-msvc": "1.11.16",
+        "@swc/core-win32-ia32-msvc": "1.11.16",
+        "@swc/core-win32-x64-msvc": "1.11.16"
       },
       "peerDependencies": {
         "@swc/helpers": "*"
@@ -118,9 +118,9 @@
       }
     },
     "node_modules/@swc/core-darwin-arm64": {
-      "version": "1.10.17",
-      "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.17.tgz",
-      "integrity": "sha512-LSQhSjESleTc0c45BnVKRacp9Nl4zhJMlV/nmhpFCOv/CqHI5YBDX5c9bPk9jTRNHIf0QH92uTtswt8yN++TCQ==",
+      "version": "1.11.16",
+      "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.11.16.tgz",
+      "integrity": "sha512-l6uWMU+MUdfLHCl3dJgtVEdsUHPskoA4BSu0L1hh9SGBwPZ8xeOz8iLIqZM27lTuXxL4KsYH6GQR/OdQ/vhLtg==",
       "cpu": [
         "arm64"
       ],
@@ -134,9 +134,9 @@
       }
     },
     "node_modules/@swc/core-darwin-x64": {
-      "version": "1.10.17",
-      "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.10.17.tgz",
-      "integrity": "sha512-TTaZFS4jLuA3y6+D2HYv4yVGhmjkOGG6KyAwBiJEeoUaazX5MYOyQwaZBPhRGtzHZFrzi4t4jNix4kAkMajPkQ==",
+      "version": "1.11.16",
+      "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.11.16.tgz",
+      "integrity": "sha512-TH0IW8Ao1WZ4ARFHIh29dAQHYBEl4YnP74n++rjppmlCjY+8v3s5nXMA7IqxO3b5LVHyggWtU4+46DXTyMJM7g==",
       "cpu": [
         "x64"
       ],
@@ -150,9 +150,9 @@
       }
     },
     "node_modules/@swc/core-linux-arm-gnueabihf": {
-      "version": "1.10.17",
-      "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.17.tgz",
-      "integrity": "sha512-8P+ESJyGnVdJi0nUcQfxkbTiB/7hnu6N3U72KbvHFBcuroherwzW4DId1XD4RTU2Cjsh1dztZoCcOLY8W9RW1Q==",
+      "version": "1.11.16",
+      "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.11.16.tgz",
+      "integrity": "sha512-2IxD9t09oNZrbv37p4cJ9cTHMUAK6qNiShi9s2FJ9LcqSnZSN4iS4hvaaX6KZuG54d58vWnMU7yycjkdOTQcMg==",
       "cpu": [
         "arm"
       ],
@@ -166,9 +166,9 @@
       }
     },
     "node_modules/@swc/core-linux-arm64-gnu": {
-      "version": "1.10.17",
-      "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.17.tgz",
-      "integrity": "sha512-zT21jDQCe+IslzOtw+BD/9ElO/H4qU4fkkOeVQ68PcxuqYS2gwyDxWqa9IGwpzWexYM+Lzi1rAbl/1BM6nGW8Q==",
+      "version": "1.11.16",
+      "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.11.16.tgz",
+      "integrity": "sha512-AYkN23DOiPh1bf3XBf/xzZQDKSsgZTxlbyTyUIhprLJpAAAT0ZCGAUcS5mHqydk0nWQ13ABUymodvHoroutNzw==",
       "cpu": [
         "arm64"
       ],
@@ -182,9 +182,9 @@
       }
     },
     "node_modules/@swc/core-linux-arm64-musl": {
-      "version": "1.10.17",
-      "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.17.tgz",
-      "integrity": "sha512-C2jaW1X+93HscVcesKYgSuZ9GaKqKcQvwvD+q+4JZkaKF4Zopt/aguc6Tmn/nuavRk0WV8yVCpHXoP7lz/2akA==",
+      "version": "1.11.16",
+      "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.11.16.tgz",
+      "integrity": "sha512-n/nWXDRCIhM51dDGELfBcTMNnCiFatE7LDvsbYxb7DJt1HGjaCNvHHCKURb/apJTh/YNtWfgFap9dbsTgw8yPA==",
       "cpu": [
         "arm64"
       ],
@@ -198,9 +198,9 @@
       }
     },
     "node_modules/@swc/core-linux-x64-gnu": {
-      "version": "1.10.17",
-      "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.17.tgz",
-      "integrity": "sha512-vfyxqV5gddurG2NVJLemR/68s7GTe0QruozrZiDpNqr9V4VX9t3PadDKMDAvQz6jKrtiqMtshNXQTNRKAKlzFw==",
+      "version": "1.11.16",
+      "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.11.16.tgz",
+      "integrity": "sha512-xr182YQrF47n7Awxj+/ruI21bYw+xO/B26KFVnb+i3ezF9NOhqoqTX+33RL1ZLA/uFTq8ksPZO/y+ZVS/odtQA==",
       "cpu": [
         "x64"
       ],
@@ -214,9 +214,9 @@
       }
     },
     "node_modules/@swc/core-linux-x64-musl": {
-      "version": "1.10.17",
-      "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.17.tgz",
-      "integrity": "sha512-8M+nI5MHZGQUnXyfTLsGw85a3oQRXMsFjgMZuOEJO9ZGBIEnYVuWOxENfcP6MmlJmTOW+cJxHnMGhKY+fjcntw==",
+      "version": "1.11.16",
+      "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.11.16.tgz",
+      "integrity": "sha512-k2JBfiwWfXCIKrBRjFO9/vEdLSYq0QLJ+iNSLdfrejZ/aENNkbEg8O7O2GKUSb30RBacn6k8HMfJrcPLFiEyCQ==",
       "cpu": [
         "x64"
       ],
@@ -230,9 +230,9 @@
       }
     },
     "node_modules/@swc/core-win32-arm64-msvc": {
-      "version": "1.10.17",
-      "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.17.tgz",
-      "integrity": "sha512-iUeIBFM6c/NwsreLFSAH395Dahc+54mSi0Kq//IrZ2Y16VlqCV7VHdOIMrdAyDoBFUvh0jKuLJPWt+jlKGtSLg==",
+      "version": "1.11.16",
+      "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.11.16.tgz",
+      "integrity": "sha512-taOb5U+abyEhQgex+hr6cI48BoqSvSdfmdirWcxprIEUBHCxa1dSriVwnJRAJOFI9T+5BEz88by6rgbB9MjbHA==",
       "cpu": [
         "arm64"
       ],
@@ -246,9 +246,9 @@
       }
     },
     "node_modules/@swc/core-win32-ia32-msvc": {
-      "version": "1.10.17",
-      "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.17.tgz",
-      "integrity": "sha512-lPXYFvkfYIN8HdNmG6dCnQqgA+rOSTgeAjIhGsYCEyLsYkkhF2FQw34OF6PnWawQ6hOdOE9v6Bw3T4enj3Lb6w==",
+      "version": "1.11.16",
+      "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.11.16.tgz",
+      "integrity": "sha512-b7yYggM9LBDiMY+XUt5kYWvs5sn0U3PXSOGvF3CbLufD/N/YQiDcYON2N3lrWHYL8aYnwbuZl45ojmQHSQPcdA==",
       "cpu": [
         "ia32"
       ],
@@ -262,9 +262,9 @@
       }
     },
     "node_modules/@swc/core-win32-x64-msvc": {
-      "version": "1.10.17",
-      "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.17.tgz",
-      "integrity": "sha512-KrnkFEWpBmxSe8LixhAZXeeUwTNDVukrPeXJ1PiG+pmb5nI989I9J9IQVIgBv+JXXaK+rmiWjlcIkphaDJJEAA==",
+      "version": "1.11.16",
+      "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.11.16.tgz",
+      "integrity": "sha512-/ibq/YDc3B5AROkpOKPGxVkSyCKOg+ml8k11RxrW7FAPy6a9y5y9KPcWIqV74Ahq4RuaMNslTQqHWAGSm0xJsQ==",
       "cpu": [
         "x64"
       ],
@@ -284,9 +284,9 @@
       "license": "Apache-2.0"
     },
     "node_modules/@swc/types": {
-      "version": "0.1.17",
-      "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.17.tgz",
-      "integrity": "sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ==",
+      "version": "0.1.21",
+      "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.21.tgz",
+      "integrity": "sha512-2YEtj5HJVbKivud9N4bpPBAyZhj4S2Ipe5LkUG94alTpr7in/GU/EARgPAd3BwU+YOmFVJC2+kjqhGRi3r0ZpQ==",
       "license": "Apache-2.0",
       "dependencies": {
         "@swc/counter": "^0.1.3"
@@ -302,9 +302,9 @@
       }
     },
     "node_modules/acorn": {
-      "version": "8.14.0",
-      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz",
-      "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==",
+      "version": "8.14.1",
+      "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz",
+      "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==",
       "license": "MIT",
       "bin": {
         "acorn": "bin/acorn"
@@ -314,9 +314,9 @@
       }
     },
     "node_modules/autoprefixer": {
-      "version": "10.4.20",
-      "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz",
-      "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==",
+      "version": "10.4.21",
+      "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz",
+      "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==",
       "funding": [
         {
           "type": "opencollective",
@@ -333,11 +333,11 @@
       ],
       "license": "MIT",
       "dependencies": {
-        "browserslist": "^4.23.3",
-        "caniuse-lite": "^1.0.30001646",
+        "browserslist": "^4.24.4",
+        "caniuse-lite": "^1.0.30001702",
         "fraction.js": "^4.3.7",
         "normalize-range": "^0.1.2",
-        "picocolors": "^1.0.1",
+        "picocolors": "^1.1.1",
         "postcss-value-parser": "^4.2.0"
       },
       "bin": {
@@ -417,9 +417,9 @@
       }
     },
     "node_modules/caniuse-lite": {
-      "version": "1.0.30001700",
-      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001700.tgz",
-      "integrity": "sha512-2S6XIXwaE7K7erT8dY+kLQcpa5ms63XlRkMkReXjle+kf6c5g38vyMl+Z5y8dSxOFDhcFe+nxnn261PLxBSQsQ==",
+      "version": "1.0.30001709",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001709.tgz",
+      "integrity": "sha512-NgL3vUTnDrPCZ3zTahp4fsugQ4dc7EKTSzwQDPEel6DMoMnfH2jhry9n2Zm8onbSR+f/QtKHFOA+iAQu4kbtWA==",
       "funding": [
         {
           "type": "opencollective",
@@ -703,9 +703,9 @@
       }
     },
     "node_modules/electron-to-chromium": {
-      "version": "1.5.102",
-      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.102.tgz",
-      "integrity": "sha512-eHhqaja8tE/FNpIiBrvBjFV/SSKpyWHLvxuR9dPTdo+3V9ppdLmFB7ZZQ98qNovcngPLYIz0oOBF9P0FfZef5Q==",
+      "version": "1.5.130",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.130.tgz",
+      "integrity": "sha512-Ou2u7L9j2XLZbhqzyX0jWDj6gA8D3jIfVzt4rikLf3cGBa0VdReuFimBKS9tQJA4+XpeCxj1NoWlfBXzbMa9IA==",
       "license": "ISC"
     },
     "node_modules/entities": {
@@ -803,9 +803,9 @@
       "license": "CC0-1.0"
     },
     "node_modules/nanoid": {
-      "version": "3.3.8",
-      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz",
-      "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==",
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
       "funding": [
         {
           "type": "github",
@@ -884,9 +884,9 @@
       "license": "ISC"
     },
     "node_modules/postcss": {
-      "version": "8.5.2",
-      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.2.tgz",
-      "integrity": "sha512-MjOadfU3Ys9KYoX0AdkBlFEF1Vx37uCCeN4ZHnmwm9FfpbsGWMZeBLMmmpY+6Ocqod7mkdZ0DT31OlbsFrLlkA==",
+      "version": "8.5.3",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz",
+      "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==",
       "funding": [
         {
           "type": "opencollective",
@@ -1524,9 +1524,9 @@
       "license": "0BSD"
     },
     "node_modules/update-browserslist-db": {
-      "version": "1.1.2",
-      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.2.tgz",
-      "integrity": "sha512-PPypAm5qvlD7XMZC3BujecnaOxwhrtoFR+Dqkk5Aa/6DssiH0ibKoketaj9w8LP7Bont1rYeoV5plxD7RTEPRg==",
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz",
+      "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==",
       "funding": [
         {
           "type": "opencollective",
diff --git a/public-legacy/auth/login.php b/public-legacy/auth/login.php
index ad278b7a..09715142 100644
--- a/public-legacy/auth/login.php
+++ b/public-legacy/auth/login.php
@@ -8,7 +8,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
 if($msz->authInfo->loggedIn) {
-    Tools::redirect($msz->urls->format('index'));
+    Tools::redirect($msz->routingCtx->urls->format('index'));
     return;
 }
 
@@ -27,7 +27,7 @@ if(!empty($_GET['resolve'])) {
         echo json_encode([
             'id' => 0,
             'name' => '',
-            'avatar' => $msz->urls->format('user-avatar', ['res' => 200, 'user' => 0]),
+            'avatar' => $msz->routingCtx->urls->format('user-avatar', ['res' => 200, 'user' => 0]),
         ]);
         return;
     }
@@ -35,7 +35,7 @@ if(!empty($_GET['resolve'])) {
     echo json_encode([
         'id' => (int)$userInfo->id,
         'name' => $userInfo->name,
-        'avatar' => $msz->urls->format('user-avatar', ['user' => $userInfo->id, 'res' => 200]),
+        'avatar' => $msz->routingCtx->urls->format('user-avatar', ['user' => $userInfo->id, 'res' => 200]),
     ]);
     return;
 }
@@ -132,7 +132,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST') {
 
     if($msz->usersCtx->totps->hasUserTotp($userInfo)) {
         $tfaToken = $msz->authCtx->tfaSessions->createToken($userInfo);
-        Tools::redirect($msz->urls->format('auth-two-factor', ['token' => $tfaToken, 'redirect' => $loginRedirect]));
+        Tools::redirect($msz->routingCtx->urls->format('auth-two-factor', ['token' => $tfaToken, 'redirect' => $loginRedirect]));
         return;
     }
 
@@ -154,7 +154,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST') {
     AuthTokenCookie::apply($msz->authCtx->createAuthTokenPacker()->pack($tokenInfo));
 
     if(!Tools::isLocalURL($loginRedirect))
-        $loginRedirect = $msz->urls->format('index');
+        $loginRedirect = $msz->routingCtx->urls->format('index');
 
     Tools::redirect($loginRedirect);
     return;
@@ -165,7 +165,7 @@ $oauth2Mode = !empty($_GET['oauth2']);
 $loginUsername = !empty($_POST['username']) && is_string($_POST['username']) ? $_POST['username'] : (
     !empty($_GET['username']) && is_string($_GET['username']) ? $_GET['username'] : ''
 );
-$loginRedirect = $welcomeMode ? $msz->urls->format('index') : (!empty($_GET['redirect']) && is_string($_GET['redirect']) ? $_GET['redirect'] : null) ?? $_SERVER['HTTP_REFERER'] ?? $msz->urls->format('index');
+$loginRedirect = $welcomeMode ? $msz->routingCtx->urls->format('index') : (!empty($_GET['redirect']) && is_string($_GET['redirect']) ? $_GET['redirect'] : null) ?? $_SERVER['HTTP_REFERER'] ?? $msz->routingCtx->urls->format('index');
 $canRegisterAccount = !$siteIsPrivate;
 
 Template::render('auth.login', [
diff --git a/public-legacy/auth/logout.php b/public-legacy/auth/logout.php
index 5995bc34..a24ca5e4 100644
--- a/public-legacy/auth/logout.php
+++ b/public-legacy/auth/logout.php
@@ -24,4 +24,4 @@ if($msz->authInfo->loggedIn) {
     AuthTokenCookie::apply($msz->authCtx->createAuthTokenPacker()->pack($tokenInfo));
 }
 
-Tools::redirect($msz->urls->format('index'));;
+Tools::redirect($msz->routingCtx->urls->format('index'));;
diff --git a/public-legacy/auth/password.php b/public-legacy/auth/password.php
index 2787c640..dc793817 100644
--- a/public-legacy/auth/password.php
+++ b/public-legacy/auth/password.php
@@ -8,7 +8,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
 if($msz->authInfo->loggedIn) {
-    Tools::redirect($msz->urls->format('settings-account'));
+    Tools::redirect($msz->routingCtx->urls->format('settings-account'));
     return;
 }
 
@@ -21,7 +21,7 @@ if($userId > 0)
     try {
         $userInfo = $msz->usersCtx->users->getUser((string)$userId, 'id');
     } catch(RuntimeException $ex) {
-        Tools::redirect($msz->urls->format('auth-forgot'));
+        Tools::redirect($msz->routingCtx->urls->format('auth-forgot'));
         return;
     }
 
@@ -75,7 +75,7 @@ while($canResetPassword) {
 
         $msz->authCtx->recoveryTokens->invalidateToken($tokenInfo);
 
-        Tools::redirect($msz->urls->format('auth-login', ['redirect' => '/']));
+        Tools::redirect($msz->routingCtx->urls->format('auth-login', ['redirect' => '/']));
         return;
     }
 
@@ -106,8 +106,6 @@ while($canResetPassword) {
         } catch(RuntimeException $ex) {
             $tokenInfo = $msz->authCtx->recoveryTokens->createToken($forgotUser, $ipAddress);
 
-            $msz->initMailer();
-
             $recoveryMessage = Mailer::template('password-recovery', [
                 'username' => $forgotUser->name,
                 'token' => $tokenInfo->code,
@@ -125,7 +123,7 @@ while($canResetPassword) {
             }
         }
 
-        Tools::redirect($msz->urls->format('auth-reset', ['user' => $forgotUser->id]));
+        Tools::redirect($msz->routingCtx->urls->format('auth-reset', ['user' => $forgotUser->id]));
         return;
     }
 
diff --git a/public-legacy/auth/register.php b/public-legacy/auth/register.php
index 286ae01b..4462edbb 100644
--- a/public-legacy/auth/register.php
+++ b/public-legacy/auth/register.php
@@ -8,7 +8,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
 if($msz->authInfo->loggedIn) {
-    Tools::redirect($msz->urls->format('index'));
+    Tools::redirect($msz->routingCtx->urls->format('index'));
     return;
 }
 
@@ -86,13 +86,13 @@ while($_SERVER['REQUEST_METHOD'] === 'POST') {
     }
 
     $msz->usersCtx->users->addRoles($userInfo, $defaultRoleInfo);
-    $msz->config->setString('users.newest', $userInfo->id);
+    $msz->usersCtx->newestUserId = $userInfo->id;
     $msz->perms->precalculatePermissions(
         $msz->forumCtx->categories,
         [$userInfo->id]
     );
 
-    Tools::redirect($msz->urls->format('auth-login-welcome', ['username' => $userInfo->name]));
+    Tools::redirect($msz->routingCtx->urls->format('auth-login-welcome', ['username' => $userInfo->name]));
     return;
 }
 
diff --git a/public-legacy/auth/revert.php b/public-legacy/auth/revert.php
index a1a0f79c..e48de30b 100644
--- a/public-legacy/auth/revert.php
+++ b/public-legacy/auth/revert.php
@@ -17,9 +17,9 @@ if($msz->csrfCtx->verifyLegacy()) {
 
         $tokenInfo = $tokenBuilder->toInfo();
         AuthTokenCookie::apply($msz->authCtx->createAuthTokenPacker()->pack($tokenInfo));
-        Tools::redirect($msz->urls->format('manage-user', ['user' => $impUserId]));
+        Tools::redirect($msz->routingCtx->urls->format('manage-user', ['user' => $impUserId]));
         return;
     }
 }
 
-Tools::redirect($msz->urls->format('index'));
+Tools::redirect($msz->routingCtx->urls->format('index'));
diff --git a/public-legacy/auth/twofactor.php b/public-legacy/auth/twofactor.php
index a396e82c..08af4df4 100644
--- a/public-legacy/auth/twofactor.php
+++ b/public-legacy/auth/twofactor.php
@@ -9,7 +9,7 @@ if(!isset($msz) || !($msz instanceof \Misuzu\MisuzuContext))
     die('Script must be called through the Misuzu route dispatcher.');
 
 if($msz->authInfo->loggedIn) {
-    Tools::redirect($msz->urls->format('index'));
+    Tools::redirect($msz->routingCtx->urls->format('index'));
     return;
 }
 
@@ -26,13 +26,13 @@ $tokenString = !empty($_GET['token']) && is_scalar($_GET['token']) ? (string)$_G
 
 $tokenUserId = $msz->authCtx->tfaSessions->getTokenUserId($tokenString);
 if(empty($tokenUserId)) {
-    Tools::redirect($msz->urls->format('auth-login'));
+    Tools::redirect($msz->routingCtx->urls->format('auth-login'));
     return;
 }
 
 $totpInfo = $msz->usersCtx->totps->getUserTotp($tokenUserId);
 if($totpInfo === null) {
-    Tools::redirect($msz->urls->format('auth-login'));
+    Tools::redirect($msz->routingCtx->urls->format('auth-login'));
     return;
 }
 
@@ -87,7 +87,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST') {
     AuthTokenCookie::apply($msz->authCtx->createAuthTokenPacker()->pack($tokenInfo));
 
     if(!Tools::isLocalURL($redirect))
-        $redirect = $msz->urls->format('index');
+        $redirect = $msz->routingCtx->urls->format('index');
 
     Tools::redirect($redirect);
     return;
@@ -95,7 +95,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST') {
 
 Template::render('auth.twofactor', [
     'twofactor_notices' => $notices,
-    'twofactor_redirect' => !empty($_GET['redirect']) && is_string($_GET['redirect']) ? $_GET['redirect'] : $msz->urls->format('index'),
+    'twofactor_redirect' => !empty($_GET['redirect']) && is_string($_GET['redirect']) ? $_GET['redirect'] : $msz->routingCtx->urls->format('index'),
     'twofactor_attempts_remaining' => $remainingAttempts,
     'twofactor_token' => $tokenString,
 ]);
diff --git a/public-legacy/forum/leaderboard.php b/public-legacy/forum/leaderboard.php
index 68236fc1..09d04ea7 100644
--- a/public-legacy/forum/leaderboard.php
+++ b/public-legacy/forum/leaderboard.php
@@ -93,9 +93,9 @@ MD;
     foreach($rankings as $ranking) {
         $totalPostsCount += $ranking->postsCount;
         $markdown .= sprintf("| %s | [%s](%s%s) | %s |\r\n", $ranking->position,
-            $ranking->user?->name ?? 'Deleted User', // @phpstan-ignore-line: no, it can be null
+            $ranking->user?->name ?? 'Deleted User', // @phpstan-ignore nullsafe.neverNull
             $msz->siteInfo->url,
-            $msz->urls->format('user-profile', ['user' => $ranking->userId]),
+            $msz->routingCtx->urls->format('user-profile', ['user' => $ranking->userId]),
             number_format($ranking->postsCount));
     }
 
diff --git a/public-legacy/forum/posting.php b/public-legacy/forum/posting.php
index 5c679b24..fcc8f3b4 100644
--- a/public-legacy/forum/posting.php
+++ b/public-legacy/forum/posting.php
@@ -168,9 +168,9 @@ if(!empty($_POST)) {
         }
 
         if($isEditingTopic) {
-            $originalTopicTitle = $topicInfo?->title ?? null; // @phpstan-ignore-line: nope it can be null
+            $originalTopicTitle = $topicInfo?->title ?? null; // @phpstan-ignore nullsafe.neverNull
             $topicTitleChanged = $topicTitle !== $originalTopicTitle;
-            $originalTopicType = $topicInfo?->typeString ?? 'discussion'; // @phpstan-ignore-line: this also
+            $originalTopicType = $topicInfo?->typeString ?? 'discussion'; // @phpstan-ignore nullsafe.neverNull
             $topicTypeChanged = $topicType !== null && $topicType !== $originalTopicType;
 
             $topicTitleLengths = $msz->config->getValues([
@@ -255,9 +255,9 @@ if(!empty($_POST)) {
                     break;
             }
 
-            if(empty($notices)) { // @phpstan-ignore-line: i'm guessing it gets the type confused at this point
+            if(empty($notices)) { // @phpstan-ignore empty.variable
                 // does this ternary ever return forum-topic?
-                $redirect = $msz->urls->format(empty($topicInfo) ? 'forum-topic' : 'forum-post', [
+                $redirect = $msz->routingCtx->urls->format(empty($topicInfo) ? 'forum-topic' : 'forum-post', [
                     'topic' => $topicId,
                     'post' => $postId,
                 ]);
diff --git a/public-legacy/manage/changelog/change.php b/public-legacy/manage/changelog/change.php
index 7d8eb00f..7fda07b9 100644
--- a/public-legacy/manage/changelog/change.php
+++ b/public-legacy/manage/changelog/change.php
@@ -39,7 +39,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
 
     $msz->changelog->deleteChange($changeInfo);
     $msz->logsCtx->createAuthedLog('CHANGELOG_ENTRY_DELETE', [$changeInfo->id]);
-    Tools::redirect($msz->urls->format('manage-changelog-changes'));
+    Tools::redirect($msz->routingCtx->urls->format('manage-changelog-changes'));
     return;
 }
 
@@ -105,7 +105,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
         [$changeInfo->id],
     );
 
-    Tools::redirect($msz->urls->format('manage-changelog-change', ['change' => $changeInfo->id]));
+    Tools::redirect($msz->routingCtx->urls->format('manage-changelog-change', ['change' => $changeInfo->id]));
     return;
 }
 
diff --git a/public-legacy/manage/changelog/tag.php b/public-legacy/manage/changelog/tag.php
index d0699d9d..032c6b6e 100644
--- a/public-legacy/manage/changelog/tag.php
+++ b/public-legacy/manage/changelog/tag.php
@@ -28,7 +28,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
 
     $msz->changelog->deleteTag($tagInfo);
     $msz->logsCtx->createAuthedLog('CHANGELOG_TAG_DELETE', [$tagInfo->id]);
-    Tools::redirect($msz->urls->format('manage-changelog-tags'));
+    Tools::redirect($msz->routingCtx->urls->format('manage-changelog-tags'));
     return;
 }
 
@@ -57,7 +57,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     );
 
     if($isNew) {
-        Tools::redirect($msz->urls->format('manage-changelog-tag', ['tag' => $tagInfo->id]));
+        Tools::redirect($msz->routingCtx->urls->format('manage-changelog-tag', ['tag' => $tagInfo->id]));
         return;
     } else $tagInfo = $loadTagInfo();
     break;
diff --git a/public-legacy/manage/forum/redirs.php b/public-legacy/manage/forum/redirs.php
index d55ac1fa..7e80903a 100644
--- a/public-legacy/manage/forum/redirs.php
+++ b/public-legacy/manage/forum/redirs.php
@@ -16,7 +16,7 @@ if($_SERVER['REQUEST_METHOD'] === 'POST') {
 
     $msz->logsCtx->createAuthedLog('FORUM_TOPIC_REDIR_CREATE', [$rTopicId]);
     $msz->forumCtx->topicRedirects->createTopicRedirect($rTopicId, $msz->authInfo->userInfo, $rTopicURL);
-    Tools::redirect($msz->urls->format('manage-forum-topic-redirs'));
+    Tools::redirect($msz->routingCtx->urls->format('manage-forum-topic-redirs'));
     return;
 }
 
@@ -27,7 +27,7 @@ if(!empty($_GET['m']) && $_GET['m'] === 'explode') {
     $rTopicId = !empty($_GET['t']) && is_scalar($_GET['t']) ? (string)$_GET['t'] : '';
     $msz->logsCtx->createAuthedLog('FORUM_TOPIC_REDIR_REMOVE', [$rTopicId]);
     $msz->forumCtx->topicRedirects->deleteTopicRedirect($rTopicId);
-    Tools::redirect($msz->urls->format('manage-forum-topic-redirs'));
+    Tools::redirect($msz->routingCtx->urls->format('manage-forum-topic-redirs'));
     return;
 }
 
diff --git a/public-legacy/manage/general/emoticon.php b/public-legacy/manage/general/emoticon.php
index f11bf509..58cf185a 100644
--- a/public-legacy/manage/general/emoticon.php
+++ b/public-legacy/manage/general/emoticon.php
@@ -99,7 +99,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
         [$emoteInfo->id],
     );
 
-    Tools::redirect($msz->urls->format('manage-general-emoticon', ['emote' => $emoteInfo->id]));
+    Tools::redirect($msz->routingCtx->urls->format('manage-general-emoticon', ['emote' => $emoteInfo->id]));
     return;
 }
 
diff --git a/public-legacy/manage/general/emoticons.php b/public-legacy/manage/general/emoticons.php
index a0824d58..a3745a1a 100644
--- a/public-legacy/manage/general/emoticons.php
+++ b/public-legacy/manage/general/emoticons.php
@@ -38,7 +38,7 @@ if($msz->csrfCtx->verifyLegacy() && !empty($_GET['emote'])) {
         }
     }
 
-    Tools::redirect($msz->urls->format('manage-general-emoticons'));
+    Tools::redirect($msz->routingCtx->urls->format('manage-general-emoticons'));
     return;
 }
 
diff --git a/public-legacy/manage/general/setting-delete.php b/public-legacy/manage/general/setting-delete.php
index 4aaafce9..9df28bd4 100644
--- a/public-legacy/manage/general/setting-delete.php
+++ b/public-legacy/manage/general/setting-delete.php
@@ -14,7 +14,7 @@ if($valueInfo === null)
 if($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     $msz->logsCtx->createAuthedLog('CONFIG_DELETE', [$valueInfo->name]);
     $msz->config->removeValues($valueInfo->name);
-    Tools::redirect($msz->urls->format('manage-general-settings'));
+    Tools::redirect($msz->routingCtx->urls->format('manage-general-settings'));
     return;
 }
 
diff --git a/public-legacy/manage/general/setting.php b/public-legacy/manage/general/setting.php
index 2f797d2e..75b87d55 100644
--- a/public-legacy/manage/general/setting.php
+++ b/public-legacy/manage/general/setting.php
@@ -76,7 +76,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
 
     $msz->logsCtx->createAuthedLog($isNew ? 'CONFIG_CREATE' : 'CONFIG_UPDATE', [$sName]);
     $applyFunc($sName, $sValue);
-    Tools::redirect($msz->urls->format('manage-general-settings'));
+    Tools::redirect($msz->routingCtx->urls->format('manage-general-settings'));
     return;
 }
 
diff --git a/public-legacy/manage/news/category.php b/public-legacy/manage/news/category.php
index fa697df2..5eabdfdd 100644
--- a/public-legacy/manage/news/category.php
+++ b/public-legacy/manage/news/category.php
@@ -28,7 +28,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
 
     $msz->news->deleteCategory($categoryInfo);
     $msz->logsCtx->createAuthedLog('NEWS_CATEGORY_DELETE', [$categoryInfo->id]);
-    Tools::redirect($msz->urls->format('manage-news-categories'));
+    Tools::redirect($msz->routingCtx->urls->format('manage-news-categories'));
     return;
 }
 
@@ -57,7 +57,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     );
 
     if($isNew) {
-        Tools::redirect($msz->urls->format('manage-news-category', ['category' => $categoryInfo->id]));
+        Tools::redirect($msz->routingCtx->urls->format('manage-news-category', ['category' => $categoryInfo->id]));
         return;
     } else $categoryInfo = $loadCategoryInfo();
     break;
diff --git a/public-legacy/manage/news/post.php b/public-legacy/manage/news/post.php
index f0969b8c..0f636435 100644
--- a/public-legacy/manage/news/post.php
+++ b/public-legacy/manage/news/post.php
@@ -29,7 +29,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
 
     $msz->news->deletePost($postInfo);
     $msz->logsCtx->createAuthedLog('NEWS_POST_DELETE', [$postInfo->id]);
-    Tools::redirect($msz->urls->format('manage-news-posts'));
+    Tools::redirect($msz->routingCtx->urls->format('manage-news-posts'));
     return;
 }
 
@@ -75,7 +75,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
 
                 if(isset($session->accessJwt) && is_string($session->accessJwt)
                     && isset($session->did) && is_string($session->did)) {
-                    $url = $msz->siteInfo->url . $msz->urls->format('news-post', ['post' => $postInfo->id]);
+                    $url = $msz->siteInfo->url . $msz->routingCtx->urls->format('news-post', ['post' => $postInfo->id]);
                     $body = sprintf("News :: %s\n", $postInfo->title);
                     $urlStart = strlen($body);
                     $body .= $url;
@@ -129,7 +129,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
             } catch(RuntimeException $ex) {}
         }
 
-        Tools::redirect($msz->urls->format('manage-news-post', ['post' => $postInfo->id]));
+        Tools::redirect($msz->routingCtx->urls->format('manage-news-post', ['post' => $postInfo->id]));
         return;
     } else $postInfo = $loadPostInfo();
     break;
diff --git a/public-legacy/manage/users/ban.php b/public-legacy/manage/users/ban.php
index cb322d7f..2c9e75a6 100644
--- a/public-legacy/manage/users/ban.php
+++ b/public-legacy/manage/users/ban.php
@@ -23,7 +23,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
 
     $msz->usersCtx->bans->deleteBans($banInfo);
     $msz->logsCtx->createAuthedLog('BAN_DELETE', [$banInfo->id, $banInfo->userId]);
-    Tools::redirect($msz->urls->format('manage-users-bans', ['user' => $banInfo->userId]));
+    Tools::redirect($msz->routingCtx->urls->format('manage-users-bans', ['user' => $banInfo->userId]));
     return;
 }
 
@@ -68,7 +68,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     );
 
     $msz->logsCtx->createAuthedLog('BAN_CREATE', [$banInfo->id, $userInfo->id]);
-    Tools::redirect($msz->urls->format('manage-users-bans', ['user' => $userInfo->id]));
+    Tools::redirect($msz->routingCtx->urls->format('manage-users-bans', ['user' => $userInfo->id]));
     return;
 }
 
diff --git a/public-legacy/manage/users/note.php b/public-legacy/manage/users/note.php
index 066cbec2..dad36579 100644
--- a/public-legacy/manage/users/note.php
+++ b/public-legacy/manage/users/note.php
@@ -40,7 +40,7 @@ if($hasUserId) {
 
         $msz->usersCtx->modNotes->deleteNotes($noteInfo);
         $msz->logsCtx->createAuthedLog('MOD_NOTE_DELETE', [$noteInfo->id, $noteInfo->userId]);
-        Tools::redirect($msz->urls->format('manage-users-notes', ['user' => $noteInfo->userId]));
+        Tools::redirect($msz->routingCtx->urls->format('manage-users-notes', ['user' => $noteInfo->userId]));
         return;
     }
 
@@ -70,7 +70,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     );
 
     // this is easier
-    Tools::redirect($msz->urls->format('manage-users-note', ['note' => $noteInfo->id]));
+    Tools::redirect($msz->routingCtx->urls->format('manage-users-note', ['note' => $noteInfo->id]));
     return;
 }
 
diff --git a/public-legacy/manage/users/role.php b/public-legacy/manage/users/role.php
index dc2ec42f..28fc8c45 100644
--- a/public-legacy/manage/users/role.php
+++ b/public-legacy/manage/users/role.php
@@ -162,7 +162,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
         $msz->config->setBoolean('perms.needsRecalc', true);
     }
 
-    Tools::redirect($msz->urls->format('manage-role', ['role' => $roleInfo->id]));
+    Tools::redirect($msz->routingCtx->urls->format('manage-role', ['role' => $roleInfo->id]));
     return;
 }
 
diff --git a/public-legacy/manage/users/user.php b/public-legacy/manage/users/user.php
index 1f0e99f5..d4d3f142 100644
--- a/public-legacy/manage/users/user.php
+++ b/public-legacy/manage/users/user.php
@@ -53,26 +53,17 @@ if($msz->csrfCtx->verifyLegacy() && $canEdit) {
             $notices[] = 'You must be a super user to do this.';
         } elseif(!is_string($_POST['impersonate_user']) || $_POST['impersonate_user'] !== 'meow') {
             $notices[] = "You didn't say the magic word!";
-        } else {
-            $allowToImpersonate = $currentUser->super;
+        } elseif($msz->usersCtx->canImpersonateUser($currentUser, $userInfo)) {
+            $msz->logsCtx->createAuthedLog('USER_IMPERSONATE', [$userInfo->id, $userInfo->name]);
 
-            if(!$allowToImpersonate) {
-                $allowImpersonateUsers = $msz->config->getArray(sprintf('impersonate.allow.u%s', $currentUser->id));
-                $allowToImpersonate = in_array($userInfo->id, $allowImpersonateUsers, true);
-            }
+            $tokenBuilder = $msz->authInfo->tokenInfo->toBuilder();
+            $tokenBuilder->setImpersonatedUserId($userInfo->id);
+            $tokenInfo = $tokenBuilder->toInfo();
 
-            if($allowToImpersonate) {
-                $msz->logsCtx->createAuthedLog('USER_IMPERSONATE', [$userInfo->id, $userInfo->name]);
-
-                $tokenBuilder = $msz->authInfo->tokenInfo->toBuilder();
-                $tokenBuilder->setImpersonatedUserId($userInfo->id);
-                $tokenInfo = $tokenBuilder->toInfo();
-
-                AuthTokenCookie::apply($msz->authCtx->createAuthTokenPacker()->pack($tokenInfo));
-                Tools::redirect($msz->urls->format('index'));
-                return;
-            } else $notices[] = "You aren't allowed to impersonate this user.";
-        }
+            AuthTokenCookie::apply($msz->authCtx->createAuthTokenPacker()->pack($tokenInfo));
+            Tools::redirect($msz->routingCtx->urls->format('index'));
+            return;
+        } else $notices[] = "You aren't allowed to impersonate this user.";
     }
 
     if(!empty($_POST['send_test_email'])) {
@@ -81,7 +72,6 @@ if($msz->csrfCtx->verifyLegacy() && $canEdit) {
         } elseif(!is_string($_POST['send_test_email']) || $_POST['send_test_email'] !== 'yes_send_it') {
             $notices[] = 'Invalid request thing shut the fuck up.';
         } else {
-            $msz->initMailer();
             $testMail = Mailer::sendMessage(
                 [$userInfo->emailAddress => $userInfo->name],
                 'Flashii Test E-mail',
@@ -215,7 +205,7 @@ if($msz->csrfCtx->verifyLegacy() && $canEdit) {
             [$userInfo->id]
         );
 
-    Tools::redirect($msz->urls->format('manage-user', ['user' => $userInfo->id]));
+    Tools::redirect($msz->routingCtx->urls->format('manage-user', ['user' => $userInfo->id]));
     return;
 }
 
diff --git a/public-legacy/manage/users/warning.php b/public-legacy/manage/users/warning.php
index 4d797e02..1be96d3d 100644
--- a/public-legacy/manage/users/warning.php
+++ b/public-legacy/manage/users/warning.php
@@ -21,7 +21,7 @@ if($_SERVER['REQUEST_METHOD'] === 'GET' && !empty($_GET['delete'])) {
 
     $msz->usersCtx->warnings->deleteWarnings($warnInfo);
     $msz->logsCtx->createAuthedLog('WARN_DELETE', [$warnInfo->id, $warnInfo->userId]);
-    Tools::redirect($msz->urls->format('manage-users-warnings', ['user' => $warnInfo->userId]));
+    Tools::redirect($msz->routingCtx->urls->format('manage-users-warnings', ['user' => $warnInfo->userId]));
     return;
 }
 
@@ -42,7 +42,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     );
 
     $msz->logsCtx->createAuthedLog('WARN_CREATE', [$warnInfo->id, $userInfo->id]);
-    Tools::redirect($msz->urls->format('manage-users-warnings', ['user' => $userInfo->id]));
+    Tools::redirect($msz->routingCtx->urls->format('manage-users-warnings', ['user' => $userInfo->id]));
     return;
 }
 
diff --git a/public-legacy/profile.php b/public-legacy/profile.php
index 7d0fc2c4..aa65f9f7 100644
--- a/public-legacy/profile.php
+++ b/public-legacy/profile.php
@@ -30,7 +30,7 @@ try {
 } catch(RuntimeException $ex) {
     $userId = $msz->usersCtx->namesHistory->resolvePastUserName($userId);
     if($userId !== null) {
-        header(sprintf('Location: %s', $msz->urls->format('user-profile', ['user' => $userId])));
+        header(sprintf('Location: %s', $msz->routingCtx->urls->format('user-profile', ['user' => $userId])));
         return;
     }
 
@@ -58,11 +58,11 @@ switch($profileMode) {
         Template::throwError(404);
 
     case 'forum-topics':
-        Tools::redirect($msz->urls->format('search-query', ['query' => sprintf('type:forum:topic author:%s', $userInfo->name), 'section' => 'topics']));
+        Tools::redirect($msz->routingCtx->urls->format('search-query', ['query' => sprintf('type:forum:topic author:%s', $userInfo->name), 'section' => 'topics']));
         return;
 
     case 'forum-posts':
-        Tools::redirect($msz->urls->format('search-query', ['query' => sprintf('type:forum:post author:%s', $userInfo->name), 'section' => 'posts']));
+        Tools::redirect($msz->routingCtx->urls->format('search-query', ['query' => sprintf('type:forum:post author:%s', $userInfo->name), 'section' => 'posts']));
         return;
 
     case '':
diff --git a/public-legacy/settings/sessions.php b/public-legacy/settings/sessions.php
index 0adb7a08..1e396668 100644
--- a/public-legacy/settings/sessions.php
+++ b/public-legacy/settings/sessions.php
@@ -37,7 +37,7 @@ while($_SERVER['REQUEST_METHOD'] === 'POST' && $msz->csrfCtx->verifyLegacy()) {
     }
 
     if($activeSessionKilled) {
-        Tools::redirect($msz->urls->format('index'));
+        Tools::redirect($msz->routingCtx->urls->format('index'));
         return;
     } else break;
 }
diff --git a/public/index.php b/public/index.php
index 7a2654ba..7145f851 100644
--- a/public/index.php
+++ b/public/index.php
@@ -27,12 +27,9 @@ if(is_file($msz->dbCtx->getMigrateLockPath())) {
 }
 
 $request = \Index\Http\HttpRequest::fromRequest();
+$msz->registerRequestRoutes($request);
 
-// order for these two currently matters i think: it shouldn't.
-$router = $msz->createRouting($request);
-$msz->startTemplating();
-
-if($msz->domainRoles->hasRole($request->getHeaderLine('Host'), 'main')) {
+if($msz->routingCtx->domainRoles->hasRole($request->getHeaderLine('Host'), 'main')) {
     $mszRequestPath = substr($request->requestTarget, 1);
     $mszLegacyPathPrefix = Misuzu::PATH_PUBLIC_LEGACY . '/';
     $mszLegacyPath = $mszLegacyPathPrefix . $mszRequestPath;
@@ -41,7 +38,7 @@ if($msz->domainRoles->hasRole($request->getHeaderLine('Host'), 'main')) {
         $mszLegacyPathReal = realpath($mszLegacyPath);
         if($mszLegacyPath === $mszLegacyPathReal || $mszLegacyPath === $mszLegacyPathReal . '/') {
             // this is here so filters can run...
-            $router->router->route(RouteInfo::exact(
+            $msz->routingCtx->router->route(RouteInfo::exact(
                 $request->method,
                 $request->requestTarget,
                 #[Before('authz:cookie')]
@@ -50,7 +47,7 @@ if($msz->domainRoles->hasRole($request->getHeaderLine('Host'), 'main')) {
                         return 403;
                 },
             ));
-            $response = $router->router->handle($request);
+            $response = $msz->routingCtx->router->handle($request);
             if($response->getBody()->getSize() > 0) {
                 Router::output($response);
                 exit;
@@ -112,4 +109,4 @@ if($msz->domainRoles->hasRole($request->getHeaderLine('Host'), 'main')) {
     }
 }
 
-$router->dispatch($request);
+$msz->routingCtx->dispatch($request);
diff --git a/src/AssetInfo.php b/src/AssetInfo.php
index 442b58d4..53a7d864 100644
--- a/src/AssetInfo.php
+++ b/src/AssetInfo.php
@@ -1,7 +1,13 @@
 <?php
 namespace Misuzu;
 
-class AssetInfo {
+use Index\Templating\Extension\TplExtensionCommon;
+use Twig\TwigFunction;
+use Twig\Extension\LastModifiedExtensionInterface;
+
+class AssetInfo implements LastModifiedExtensionInterface {
+    use TplExtensionCommon;
+
     public const string CURRENT_PATH = Misuzu::PATH_ASSETS . DIRECTORY_SEPARATOR . 'current.json';
 
     /** @var array<string, string> */
@@ -21,4 +27,10 @@ class AssetInfo {
     public function getAssetUrl(string $name): string {
         return array_key_exists($name, $this->assets) ? $this->assets[$name] : '';
     }
+
+    public function getFunctions() {
+        return [
+            new TwigFunction('asset', $this->getAssetUrl(...)),
+        ];
+    }
 }
diff --git a/src/Auth/AuthInfo.php b/src/Auth/AuthInfo.php
index b5292413..e6b51e38 100644
--- a/src/Auth/AuthInfo.php
+++ b/src/Auth/AuthInfo.php
@@ -1,14 +1,20 @@
 <?php
 namespace Misuzu\Auth;
 
+use Misuzu\{Misuzu,Perm};
 use Misuzu\Apps\AppInfo;
 use Misuzu\Auth\SessionInfo;
 use Misuzu\Forum\ForumCategoryInfo;
 use Misuzu\OAuth2\OAuth2AccessInfo;
 use Misuzu\Perms\{IPermissionResult,PermissionsData,PermissionResult};
-use Misuzu\Users\UserInfo;
+use Misuzu\Users\{BanInfo,UsersContext,UserInfo};
+use Index\Templating\Extension\TplExtensionCommon;
+use Twig\TwigFunction;
+use Twig\Extension\LastModifiedExtensionInterface;
+
+class AuthInfo implements LastModifiedExtensionInterface {
+    use TplExtensionCommon;
 
-class AuthInfo {
     public private(set) AuthTokenInfo $tokenInfo;
     public private(set) ?UserInfo $userInfo;
     public private(set) ?SessionInfo $sessionInfo;
@@ -20,7 +26,8 @@ class AuthInfo {
     public private(set) array $perms;
 
     public function __construct(
-        private PermissionsData $permissions
+        private PermissionsData $permissions,
+        private UsersContext $usersCtx,
     ) {
         $this->removeInfo();
     }
@@ -131,4 +138,50 @@ class AuthInfo {
 
         return $this->perms[$cacheKey] = $this->permissions->getPermissions($category, $this->userInfo, $forumCategoryInfo);
     }
+
+    private function getLoggedIn(): bool {
+        return $this->loggedIn;
+    }
+
+    private function getImpersonating(): bool {
+        return $this->impersonating;
+    }
+
+    private function getInfo(?object $object, ?string $name = null, string|int|null $default = null): object|string|int|null {
+        if($name === null)
+            return $object;
+
+        if($object === null || !property_exists($object, $name))
+            return $default;
+
+        return $object->{$name} ?? $default;
+    }
+
+    private function getUserInfo(?string $name = null, string|int|null $default = null): object|string|int|null {
+        return $this->getInfo($this->userInfo, $name, $default);
+    }
+
+    private function getRealUserInfo(?string $name = null, string|int|null $default = null): object|string|int|null {
+        return $this->getInfo($this->realUserInfo ?? $this->userInfo, $name, $default);
+    }
+
+    private function getBanInfo(): ?BanInfo {
+        return $this->usersCtx->tryGetActiveBan($this->userInfo);
+    }
+
+    public function getDisplayTimings(): bool {
+        return Misuzu::debug() || $this->getPerms('global')->check(Perm::G_TIMINGS_VIEW);
+    }
+
+    #[\Override]
+    public function getFunctions() {
+        return [
+            new TwigFunction('auth_logged_in', $this->getLoggedIn(...)),
+            new TwigFunction('auth_get_user_info', $this->getUserInfo(...)),
+            new TwigFunction('auth_impersonating', $this->getImpersonating(...)),
+            new TwigFunction('auth_get_real_user_info', $this->getRealUserInfo(...)),
+            new TwigFunction('auth_get_ban_info', $this->getBanInfo(...)),
+            new TwigFunction('auth_display_timings', $this->getDisplayTimings(...)),
+        ];
+    }
 }
diff --git a/src/Auth/AuthProcessors.php b/src/Auth/AuthProcessors.php
index afc778bb..04bd7104 100644
--- a/src/Auth/AuthProcessors.php
+++ b/src/Auth/AuthProcessors.php
@@ -3,7 +3,6 @@ namespace Misuzu\Auth;
 
 use RuntimeException;
 use Carbon\Carbon;
-use Index\Config\Config;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Content\FormContent;
 use Index\Http\Routing\{HandlerContext,RouteHandler,RouteHandlerCommon};
@@ -16,7 +15,6 @@ final class AuthProcessors implements RouteHandler {
     use RouteHandlerCommon;
 
     public function __construct(
-        private Config $impersonateConfig,
         private UsersContext $usersCtx,
         private OAuth2Context $oauth2Ctx,
         private CsrfContext $csrfCtx,
@@ -24,14 +22,6 @@ final class AuthProcessors implements RouteHandler {
         private AuthInfo $authInfo,
     ) {}
 
-    private function canImpersonateUserId(UserInfo $impersonator, string $targetId): bool {
-        if($impersonator->super)
-            return true;
-
-        $whitelist = $this->impersonateConfig->getArray(sprintf('allow.u%s', $impersonator->id));
-        return in_array($targetId, $whitelist, true);
-    }
-
     /** @param array<string, string> $error */
     private static function applyErrorHeader(HttpResponseBuilder $response, string $method, array $error): void {
         $parts = [];
@@ -184,7 +174,7 @@ final class AuthProcessors implements RouteHandler {
                     if($sessionInfo->shouldBumpExpires)
                         $builder->setEdited();
 
-                    if($tokenInfo->hasImpersonatedUserId && $this->canImpersonateUserId($userInfo, $tokenInfo->impersonatedUserId)) {
+                    if($tokenInfo->hasImpersonatedUserId && $this->usersCtx->canImpersonateUser($userInfo, $tokenInfo->impersonatedUserId)) {
                         $userInfoReal = $userInfo;
 
                         try {
@@ -295,7 +285,7 @@ final class AuthProcessors implements RouteHandler {
             $this->authCtx->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $request->remoteAddress);
 
             $userInfoReal = null;
-            if($tokenInfo->hasImpersonatedUserId && $this->canImpersonateUserId($userInfo, $tokenInfo->impersonatedUserId)) {
+            if($tokenInfo->hasImpersonatedUserId && $this->usersCtx->canImpersonateUser($userInfo, $tokenInfo->impersonatedUserId)) {
                 $userInfoReal = $userInfo;
 
                 try {
diff --git a/src/Changelog/ChangelogRoutes.php b/src/Changelog/ChangelogRoutes.php
index 8b91a295..633ca032 100644
--- a/src/Changelog/ChangelogRoutes.php
+++ b/src/Changelog/ChangelogRoutes.php
@@ -4,15 +4,16 @@ namespace Misuzu\Changelog;
 use ErrorException;
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Syndication\FeedBuilder;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{Pagination,SiteInfo,Template};
 use Misuzu\Comments\CommentsContext;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\UsersContext;
 
+#[HandlerRoles('main')]
 final class ChangelogRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
diff --git a/src/Colours/ColoursApiRoutes.php b/src/Colours/ColoursApiRoutes.php
index 6a71763f..4a4675c6 100644
--- a/src/Colours/ColoursApiRoutes.php
+++ b/src/Colours/ColoursApiRoutes.php
@@ -4,11 +4,12 @@ namespace Misuzu\Colours;
 use RuntimeException;
 use Index\XArray;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\AccessControl\AccessControl;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Misuzu\FieldTransformer;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('main')]
 final class ColoursApiRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
diff --git a/src/Comments/CommentsRoutes.php b/src/Comments/CommentsRoutes.php
index 18ddd0ca..df7df013 100644
--- a/src/Comments/CommentsRoutes.php
+++ b/src/Comments/CommentsRoutes.php
@@ -5,15 +5,16 @@ use RuntimeException;
 use Index\XArray;
 use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder};
 use Index\Http\Content\FormContent;
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\Perm;
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Perms\{PermissionResult,IPermissionResult};
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\{UserInfo,UsersContext,UsersData};
 
+#[HandlerRoles('main')]
 class CommentsRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
diff --git a/src/CsrfContext.php b/src/CsrfContext.php
index cbc1509b..bdd2bd88 100644
--- a/src/CsrfContext.php
+++ b/src/CsrfContext.php
@@ -6,11 +6,12 @@ use Index\Config\Config;
 use Index\Http\Content\FormContent;
 use Index\Http\Routing\{HandlerContext,RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Preprocessor;
+use Index\Templating\Extension\TplExtensionCommon;
 use Twig\TwigFunction;
-use Twig\Extension\AbstractExtension;
+use Twig\Extension\LastModifiedExtensionInterface;
 
-class CsrfContext extends AbstractExtension implements RouteHandler {
-    use RouteHandlerCommon;
+class CsrfContext implements RouteHandler, LastModifiedExtensionInterface {
+    use RouteHandlerCommon, TplExtensionCommon;
 
     private ?CsrfToken $instance = null;
 
diff --git a/src/DatabaseContext.php b/src/DatabaseContext.php
index 75420785..4cfe8050 100644
--- a/src/DatabaseContext.php
+++ b/src/DatabaseContext.php
@@ -6,9 +6,12 @@ use Index\Db\{DbBackends,DbConnection};
 use Index\Db\Migration\{DbMigrationManager,DbMigrationRepo,FsDbMigrationRepo};
 use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Filters\PrefixFilter;
+use Index\Templating\Extension\TplExtensionCommon;
+use Twig\TwigFunction;
+use Twig\Extension\LastModifiedExtensionInterface;
 
-class DatabaseContext implements RouteHandler {
-    use RouteHandlerCommon;
+class DatabaseContext implements RouteHandler, LastModifiedExtensionInterface {
+    use RouteHandlerCommon, TplExtensionCommon;
 
     public private(set) DbConnection $conn;
 
@@ -46,4 +49,11 @@ class DatabaseContext implements RouteHandler {
         if(is_file($this->getMigrateLockPath()))
             return 503;
     }
+
+    #[\Override]
+    public function getFunctions() {
+        return [
+            new TwigFunction('sql_query_count', $this->getQueryCount(...)),
+        ];
+    }
 }
diff --git a/src/Emoticons/EmotesApiRoutes.php b/src/Emoticons/EmotesApiRoutes.php
index 7c318d7a..248de7d8 100644
--- a/src/Emoticons/EmotesApiRoutes.php
+++ b/src/Emoticons/EmotesApiRoutes.php
@@ -4,11 +4,12 @@ namespace Misuzu\Emoticons;
 use RuntimeException;
 use Index\XArray;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\AccessControl\AccessControl;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Misuzu\FieldTransformer;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('main')]
 final class EmotesApiRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
diff --git a/src/Forum/ForumCategoriesRoutes.php b/src/Forum/ForumCategoriesRoutes.php
index a6529546..f8863ed1 100644
--- a/src/Forum/ForumCategoriesRoutes.php
+++ b/src/Forum/ForumCategoriesRoutes.php
@@ -4,14 +4,15 @@ namespace Misuzu\Forum;
 use stdClass;
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
 use Misuzu\{Pagination,Perm,Template};
 use Misuzu\Auth\AuthInfo;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\UsersContext;
 
+#[HandlerRoles('main')]
 class ForumCategoriesRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
diff --git a/src/Forum/ForumContext.php b/src/Forum/ForumContext.php
index 3d6539ab..07995741 100644
--- a/src/Forum/ForumContext.php
+++ b/src/Forum/ForumContext.php
@@ -5,9 +5,10 @@ use stdClass;
 use Index\Config\Config;
 use Index\Db\DbConnection;
 use Misuzu\Parsers\TextFormat;
+use Misuzu\Templating\TemplatingMetaVariableProvider;
 use Misuzu\Users\UserInfo;
 
-class ForumContext {
+class ForumContext implements TemplatingMetaVariableProvider {
     public private(set) ForumCategoriesData $categories;
     public private(set) ForumTopicsData $topics;
     public private(set) ForumTopicRedirectsData $topicRedirects;
@@ -38,6 +39,10 @@ class ForumContext {
         $this->signatures = new ForumSignaturesData($dbConn);
     }
 
+    public function getTemplatingMetaVariables(): array {
+        return ['forum-storage-pool' => $this->storagePoolName];
+    }
+
     // should be replaced by a static counter
     public function countTotalUserTopics(UserInfo|string|null $userInfo): int {
         if($userInfo === null)
diff --git a/src/Forum/ForumPostsRoutes.php b/src/Forum/ForumPostsRoutes.php
index bb436a21..d3f16704 100644
--- a/src/Forum/ForumPostsRoutes.php
+++ b/src/Forum/ForumPostsRoutes.php
@@ -3,14 +3,15 @@ namespace Misuzu\Forum;
 
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\PatternRoute;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\Perm;
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Logs\LogsContext;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('main')]
 class ForumPostsRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
diff --git a/src/Forum/ForumTopicsRoutes.php b/src/Forum/ForumTopicsRoutes.php
index e173500c..7fa250f7 100644
--- a/src/Forum/ForumTopicsRoutes.php
+++ b/src/Forum/ForumTopicsRoutes.php
@@ -5,15 +5,16 @@ use stdClass;
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Content\FormContent;
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\PatternRoute;
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{Pagination,Perm,Template};
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Logs\LogsContext;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\UsersContext;
 
+#[HandlerRoles('main')]
 class ForumTopicsRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
diff --git a/src/Home/HomeRoutes.php b/src/Home/HomeRoutes.php
index 96c3d55e..b329e83c 100644
--- a/src/Home/HomeRoutes.php
+++ b/src/Home/HomeRoutes.php
@@ -7,7 +7,6 @@ use Index\Config\Config;
 use Index\Colour\Colour;
 use Index\Db\{DbConnection,DbTools};
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\ExactRoute;
 use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
@@ -17,8 +16,10 @@ use Misuzu\Changelog\ChangelogData;
 use Misuzu\Comments\CommentsContext;
 use Misuzu\Counters\CountersData;
 use Misuzu\News\{NewsData,NewsCategoryInfo,NewsPostInfo};
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\{UsersContext,UserInfo};
 
+#[HandlerRoles('main')]
 class HomeRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
@@ -226,14 +227,14 @@ class HomeRoutes implements RouteHandler, UrlSource {
 
         $newestMember = [];
         if(empty($birthdays)) {
-            $newestMemberId = $this->config->getString('users.newest');
+            $newestMemberId = $this->usersCtx->newestUserId;
             if(!empty($newestMemberId))
                 try {
                     $newestMember['info'] = $this->usersCtx->getUserInfo($newestMemberId);
                     $newestMember['colour'] = $this->usersCtx->getUserColour($newestMemberId);
                 } catch(RuntimeException $ex) {
                     $newestMember = [];
-                    $this->config->removeValues('users.newest');
+                    $this->usersCtx->newestUserId = '';
                 }
         }
 
diff --git a/src/Info/InfoRoutes.php b/src/Info/InfoRoutes.php
index d6c108ab..92f5edb7 100644
--- a/src/Info/InfoRoutes.php
+++ b/src/Info/InfoRoutes.php
@@ -3,13 +3,14 @@ namespace Misuzu\Info;
 
 use Index\Index;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
 use Misuzu\{Misuzu,Template};
 use Misuzu\Parsers\{Parsers,TextFormat};
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('main')]
 class InfoRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
diff --git a/src/LegacyRoutes.php b/src/LegacyRoutes.php
index 0c4d4b9c..8e68c634 100644
--- a/src/LegacyRoutes.php
+++ b/src/LegacyRoutes.php
@@ -2,10 +2,11 @@
 namespace Misuzu;
 
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource};
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('main')]
 class LegacyRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon;
 
diff --git a/src/Messages/MessagesContext.php b/src/Messages/MessagesContext.php
index 87f10174..d06c7463 100644
--- a/src/Messages/MessagesContext.php
+++ b/src/Messages/MessagesContext.php
@@ -3,8 +3,9 @@ namespace Misuzu\Messages;
 
 use Index\Config\Config;
 use Index\Db\DbConnection;
+use Misuzu\Templating\TemplatingMetaVariableProvider;
 
-class MessagesContext {
+class MessagesContext implements TemplatingMetaVariableProvider {
     public private(set) MessagesData $database;
 
     public string $storagePoolName {
@@ -17,4 +18,8 @@ class MessagesContext {
     ) {
         $this->database = new MessagesData($dbConn);
     }
+
+    public function getTemplatingMetaVariables(): array {
+        return ['messages-storage-pool' => $this->storagePoolName];
+    }
 }
diff --git a/src/Messages/MessagesRoutes.php b/src/Messages/MessagesRoutes.php
index 34c36056..59530874 100644
--- a/src/Messages/MessagesRoutes.php
+++ b/src/Messages/MessagesRoutes.php
@@ -9,7 +9,6 @@ use Index\Config\Config;
 use Index\Colour\Colour;
 use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder};
 use Index\Http\Content\FormContent;
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\{After,Before};
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
@@ -17,8 +16,10 @@ use Misuzu\{Misuzu,Pagination,Perm,Template};
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Parsers\TextFormat;
 use Misuzu\Perms\PermissionsData;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\{UsersContext,UserInfo};
 
+#[HandlerRoles('main')]
 class MessagesRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
diff --git a/src/MisuzuContext.php b/src/MisuzuContext.php
index a6946bed..8d24e4ed 100644
--- a/src/MisuzuContext.php
+++ b/src/MisuzuContext.php
@@ -4,28 +4,31 @@ namespace Misuzu;
 use Index\Dependencies;
 use Index\Config\Config;
 use Index\Config\Db\DbConfig;
-use Index\Db\DbConnection;
-use Index\Db\Migration\{DbMigrationManager,DbMigrationRepo,FsDbMigrationRepo};
-use Index\Http\HttpRequest;
-use Index\Http\Routing\RouteHandler;
+use Index\Http\{HttpRequest,HttpResponseBuilder};
+use Index\Http\Routing\{Router,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Filters\PrefixFilter;
 use Index\Snowflake\{BinarySnowflake,RandomSnowflake,SnowflakeGenerator};
-use Index\Templating\TplEnvironment;
-use Index\Urls\UrlRegistry;
-use Misuzu\Routing\RoutingContext;
-use Misuzu\Users\UserInfo;
-use Twig\Extension\ExtensionInterface;
+use Index\Urls\{ArrayUrlRegistry,UrlRegistry,UrlSource};
+
+class MisuzuContext implements RouteHandler {
+    use RouteHandlerCommon;
 
-class MisuzuContext {
     public private(set) Dependencies $deps;
 
     public private(set) Config $config;
-    public private(set) DomainRoles $domainRoles;
-    public private(set) TplEnvironment $templating;
+    public private(set) Router $router;
+    public private(set) UrlRegistry $urls;
 
     public private(set) SnowflakeGenerator $snowflake;
     public private(set) BinarySnowflake $sfBinary;
     public private(set) RandomSnowflake $sfRandom;
 
+    public private(set) AssetInfo $assetInfo;
+    public private(set) SiteInfo $siteInfo;
+    public private(set) Auth\AuthInfo $authInfo;
+    public private(set) Perms\PermissionsData $perms;
+    public private(set) WebFinger\WebFingerRegistry $webfinger;
+
     public private(set) Changelog\ChangelogData $changelog;
     public private(set) Counters\CountersData $counters;
     public private(set) News\NewsData $news;
@@ -42,23 +45,18 @@ class MisuzuContext {
     public private(set) Messages\MessagesContext $messagesCtx;
     public private(set) OAuth2\OAuth2Context $oauth2Ctx;
     public private(set) Profile\ProfileContext $profileCtx;
-    public private(set) Storage\StorageContext $storageCtx;
-    public private(set) Users\UsersContext $usersCtx;
     public private(set) Redirects\RedirectsContext $redirectsCtx;
-
-    public private(set) Perms\PermissionsData $perms;
-    public private(set) Auth\AuthInfo $authInfo;
-    public private(set) SiteInfo $siteInfo;
-    public private(set) AssetInfo $assetInfo;
-
-    // this probably shouldn't be available
-    public private(set) UrlRegistry $urls;
+    public private(set) Routing\RoutingContext $routingCtx;
+    public private(set) Storage\StorageContext $storageCtx;
+    public private(set) Templating\TemplatingContext $tplCtx;
+    public private(set) Users\UsersContext $usersCtx;
 
     public function __construct(
         #[\SensitiveParameter] string $dsn,
         string $domainRoles,
         string $storageLocalPath,
         string $storageRemotePath,
+        string $templateCachePath,
     ) {
         $this->deps = new Dependencies;
         $this->deps->register($this);
@@ -71,10 +69,6 @@ class MisuzuContext {
             DbConfig::class,
             tableName: 'msz_config',
         ));
-        $this->deps->register($this->domainRoles = $this->deps->constructLazy(
-            DomainRoles::class,
-            domainRoles: $domainRoles,
-        ));
 
         $this->deps->register($this->snowflake = $this->deps->constructLazy(SnowflakeGenerator::class));
         $this->deps->register($this->sfBinary = $this->deps->constructLazy(BinarySnowflake::class));
@@ -90,6 +84,7 @@ class MisuzuContext {
             AssetInfo::class,
             pathOrAssets: AssetInfo::CURRENT_PATH,
         ));
+        $this->deps->register($this->webfinger = $this->deps->constructLazy(WebFinger\WebFingerRegistry::class));
 
         $this->deps->register($this->appsCtx = $this->deps->constructLazy(Apps\AppsContext::class));
         $this->deps->register($this->authCtx = $this->deps->constructLazy(
@@ -117,7 +112,14 @@ class MisuzuContext {
             config: $this->config->scopeTo('oauth2'),
         ));
         $this->deps->register($this->profileCtx = $this->deps->constructLazy(Profile\ProfileContext::class));
-        $this->deps->register($this->usersCtx = $this->deps->constructLazy(Users\UsersContext::class));
+        $this->deps->register($this->tplCtx = $this->deps->constructLazy(
+            Templating\TemplatingContext::class,
+            cachePath: $templateCachePath,
+        ));
+        $this->deps->register($this->usersCtx = $this->deps->constructLazy(
+            Users\UsersContext::class,
+            config: $this->config->scopeTo('users'),
+        ));
         $this->deps->register($this->redirectsCtx = $this->deps->constructLazy(
             Redirects\RedirectsContext::class,
             config: $this->config->scopeTo('redirects'),
@@ -127,15 +129,23 @@ class MisuzuContext {
         $this->deps->register($this->counters = $this->deps->constructLazy(Counters\CountersData::class));
         $this->deps->register($this->news = $this->deps->constructLazy(News\NewsData::class));
 
+        $this->deps->register($this->routingCtx = $this->deps->construct(
+            Routing\RoutingContext::class,
+            domainRoles: $domainRoles,
+        ));
         $this->deps->register($this->storageCtx = $this->deps->construct(
             Storage\StorageContext::class,
             localPath: $storageLocalPath,
             remotePath: $storageRemotePath,
         ));
+
+        Mailer::init($this->config->scopeTo('mail'));
+        Template::init($this);
     }
 
-    public function initMailer(): void {
-        Mailer::init($this->config->scopeTo('mail'));
+    #[PrefixFilter('/')]
+    public function filterPoweredBy(HttpResponseBuilder $response): void {
+        $response->setPoweredBy(__NAMESPACE__);
     }
 
     private ?bool $hasManageAccess = null;
@@ -146,128 +156,75 @@ class MisuzuContext {
         return $this->hasManageAccess;
     }
 
-    private ?string $chatUrl = null;
-    public function getChatURL(): string {
-        $this->chatUrl ??= $this->config->getString('sockChat.chatPath.normal');
-        return $this->chatUrl;
-    }
-
-    public function startTemplating(bool $cache = true): void {
-        $isDebug = Misuzu::debug();
-        $globals = [
-            'site_info' => $this->siteInfo,
-            'auth_info' => $this->authInfo,
-            'active_ban_info' => $this->usersCtx->tryGetActiveBan($this->authInfo->userInfo),
-            'display_timings_info' => $isDebug || $this->authInfo->getPerms('global')->check(Perm::G_TIMINGS_VIEW),
-            'meta' => [
-                'forum-storage-pool' => $this->forumCtx->storagePoolName,
-                'messages-storage-pool' => $this->messagesCtx->storagePoolName,
-            ],
-        ];
-
-        $this->templating = new TplEnvironment(
-            Misuzu::PATH_TEMPLATES,
-            cache: $isDebug || !$cache ? null : ['Misuzu', GitInfo::hash(true)],
-            debug: $isDebug
-        );
-
-        $exts = $this->deps->all(ExtensionInterface::class);
-        foreach($exts as $ext)
-            $this->templating->addExtension($ext);
-
-        $this->templating->addExtension($this->deps->construct(TemplatingExtension::class));
-        $this->templating->addGlobal('globals', $globals);
-
-        Template::init($this->templating);
-    }
-
-    public function createRouting(HttpRequest $request): RoutingContext {
-        $host = $request->getHeaderLine('Host');
-        $roles = $this->domainRoles->getRoles($host);
-
-        $routingCtx = $this->deps->construct(RoutingContext::class);
-        $this->deps->register($this->urls = $routingCtx->urls);
+    public function registerRequestRoutes(HttpRequest $request): void {
+        $roles = $this->routingCtx->domainRoles->getRoles($request->getHeaderLine('Host'));
 
         $handlers = $this->deps->all(RouteHandler::class);
         foreach($handlers as $handler)
-            $routingCtx->register($handler);
+            if($handler instanceof Routing\RouteHandler)
+                $handler->registerRoleRoutes($this->routingCtx->router, $roles);
+            else
+                $handler->registerRoutes($this->routingCtx->router);
 
-        if(in_array('main', $roles))
-            $this->registerMainRoutes($routingCtx);
+        $sources = $this->deps->all(UrlSource::class);
+        foreach($sources as $source)
+            $source->registerUrls($this->routingCtx->urls);
 
-        if(in_array('redirect', $roles))
-            $this->registerRedirectRoutes($routingCtx);
-
-        if(in_array('storage', $roles))
-            $this->registerStorageRoutes($routingCtx);
-
-        return $routingCtx;
-    }
-
-    public function registerMainRoutes(
-        RoutingContext $routingCtx
-    ): void {
-        $this->deps->register($wf = new WebFinger\WebFingerRegistry);
-        $wf->register($this->deps->constructLazy(
-            Users\UsersWebFingerResolver::class,
-            config: $this->config->scopeTo('users')
+        // needs better registration process
+        // hijack registerRoutes for WebFingerRoutes to automate this
+        $this->webfinger->register($this->deps->constructLazy(
+            Users\UsersWebFingerResolver::class
         ));
-        $routingCtx->register($this->deps->constructLazy(WebFinger\WebFingerRoutes::class));
 
-        $routingCtx->register($this->deps->constructLazy(
-            Auth\AuthProcessors::class,
-            impersonateConfig: $this->config->scopeTo('impersonate')
-        ));
-        $routingCtx->register($this->deps->constructLazy(Colours\ColoursApiRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(Emoticons\EmotesApiRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(Users\UsersApiRoutes::class));
+        $this->routingCtx->register($this->deps->constructLazy(Auth\AuthProcessors::class), $roles);
 
-        $routingCtx->register($this->deps->constructLazy(Home\HomeRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(Users\Assets\AssetsRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(Info\InfoRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(News\NewsRoutes::class));
+        $this->routingCtx->register($this->deps->constructLazy(WebFinger\WebFingerRoutes::class), $roles);
 
-        $routingCtx->register($this->deps->constructLazy(Comments\CommentsRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(
+        $this->routingCtx->register($this->deps->constructLazy(Colours\ColoursApiRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(Emoticons\EmotesApiRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(Users\UsersApiRoutes::class), $roles);
+
+        $this->routingCtx->register($this->deps->constructLazy(Home\HomeRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(Users\Assets\AssetsRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(Info\InfoRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(News\NewsRoutes::class), $roles);
+
+        $this->routingCtx->register($this->deps->constructLazy(Comments\CommentsRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(
             Messages\MessagesRoutes::class,
             config: $this->config->scopeTo('messages')
-        ));
+        ), $roles);
 
-        $routingCtx->register($this->deps->constructLazy(Storage\Tasks\TasksRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(Storage\Uploads\UploadsLegacyRoutes::class));
+        $this->routingCtx->register($this->deps->constructLazy(Storage\Tasks\TasksRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(Storage\Uploads\UploadsLegacyRoutes::class), $roles);
 
-        $routingCtx->register($this->deps->constructLazy(OAuth2\OAuth2ApiRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(OAuth2\OAuth2WebRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(WebFinger\WebFingerRoutes::class));
+        $this->routingCtx->register($this->deps->constructLazy(OAuth2\OAuth2ApiRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(OAuth2\OAuth2WebRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(WebFinger\WebFingerRoutes::class), $roles);
 
-        $routingCtx->register($this->deps->constructLazy(Forum\ForumCategoriesRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(Forum\ForumTopicsRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(Forum\ForumPostsRoutes::class));
+        $this->routingCtx->register($this->deps->constructLazy(Forum\ForumCategoriesRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(Forum\ForumTopicsRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(Forum\ForumPostsRoutes::class), $roles);
 
-        $routingCtx->register($this->deps->constructLazy(Changelog\ChangelogRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(
+        $this->routingCtx->register($this->deps->constructLazy(Changelog\ChangelogRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(
             SharpChat\SharpChatRoutes::class,
             config: $this->config->scopeTo('sockChat'),
-            impersonateConfig: $this->config->scopeTo('impersonate')
-        ));
+        ), $roles);
 
-        $routingCtx->register($this->deps->constructLazy(
+        $this->routingCtx->register($this->deps->constructLazy(
             Satori\SatoriRoutes::class,
             config: $this->config->scopeTo('satori')
-        ));
+        ), $roles);
 
-        $routingCtx->register($this->deps->constructLazy(LegacyRoutes::class));
-    }
+        $this->routingCtx->register($this->deps->constructLazy(LegacyRoutes::class), $roles);
 
-    public function registerRedirectRoutes(RoutingContext $routingCtx): void {
-        $routingCtx->register($this->deps->constructLazy(Redirects\LandingRedirectsRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(Redirects\AliasRedirectsRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(Redirects\IncrementalRedirectsRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(Redirects\SocialRedirectsRoutes::class));
-        $routingCtx->register($this->deps->constructLazy(Redirects\NamedRedirectsRoutes::class));
-    }
+        $this->routingCtx->register($this->deps->constructLazy(Redirects\LandingRedirectsRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(Redirects\AliasRedirectsRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(Redirects\IncrementalRedirectsRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(Redirects\SocialRedirectsRoutes::class), $roles);
+        $this->routingCtx->register($this->deps->constructLazy(Redirects\NamedRedirectsRoutes::class), $roles);
 
-    public function registerStorageRoutes(RoutingContext $routingCtx): void {
-        $routingCtx->register($this->deps->constructLazy(Storage\Uploads\UploadsViewRoutes::class));
+        $this->routingCtx->register($this->deps->constructLazy(Storage\Uploads\UploadsViewRoutes::class), $roles);
     }
 }
diff --git a/src/News/NewsRoutes.php b/src/News/NewsRoutes.php
index bf1a4837..de98bfaa 100644
--- a/src/News/NewsRoutes.php
+++ b/src/News/NewsRoutes.php
@@ -4,7 +4,6 @@ namespace Misuzu\News;
 use RuntimeException;
 use Index\Colour\Colour;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Syndication\FeedBuilder;
@@ -12,8 +11,10 @@ use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{Pagination,SiteInfo,Template};
 use Misuzu\Comments\CommentsContext;
 use Misuzu\Parsers\{Parsers,TextFormat};
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\{UsersContext,UserInfo};
 
+#[HandlerRoles('main')]
 class NewsRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
@@ -120,6 +121,7 @@ class NewsRoutes implements RouteHandler, UrlSource {
     }
 
     #[PatternRoute('GET', '/news/([0-9]+)(?:\.(xml|rss|atom))?')]
+    #[Before('authz:cookie')]
     #[UrlFormat('news-category', '/news/<category>', ['p' => '<page>'])]
     public function getCategory(HttpResponseBuilder $response, HttpRequest $request, string $categoryId, string $type = ''): int|string {
         try {
diff --git a/src/OAuth2/OAuth2ApiRoutes.php b/src/OAuth2/OAuth2ApiRoutes.php
index ecac8aad..575db8db 100644
--- a/src/OAuth2/OAuth2ApiRoutes.php
+++ b/src/OAuth2/OAuth2ApiRoutes.php
@@ -6,7 +6,6 @@ use Index\XArray;
 use Index\Colour\{Colour,ColourRgb};
 use Index\Http\{HttpResponseBuilder,HttpRequest};
 use Index\Http\Content\FormContent;
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\AccessControl\AccessControl;
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
@@ -15,8 +14,10 @@ use Misuzu\SiteInfo;
 use Misuzu\Apps\AppsContext;
 use Misuzu\Auth\AuthInfo;
 use Misuzu\Profile\ProfileContext;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\UsersContext;
 
+#[HandlerRoles('main')]
 final class OAuth2ApiRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
diff --git a/src/OAuth2/OAuth2WebRoutes.php b/src/OAuth2/OAuth2WebRoutes.php
index 6e944bc9..50db5e1a 100644
--- a/src/OAuth2/OAuth2WebRoutes.php
+++ b/src/OAuth2/OAuth2WebRoutes.php
@@ -6,14 +6,15 @@ use RuntimeException;
 use Index\XArray;
 use Index\Http\{HttpResponseBuilder,HttpRequest};
 use Index\Http\Content\FormContent;
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{CsrfContext,Template};
 use Misuzu\Auth\AuthInfo;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\UsersContext;
 
+#[HandlerRoles('main')]
 final class OAuth2WebRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
diff --git a/src/Redirects/AliasRedirectsRoutes.php b/src/Redirects/AliasRedirectsRoutes.php
index 0d120a53..23cdb9f5 100644
--- a/src/Redirects/AliasRedirectsRoutes.php
+++ b/src/Redirects/AliasRedirectsRoutes.php
@@ -3,9 +3,10 @@ namespace Misuzu\Redirects;
 
 use Index\Config\Config;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Routes\PatternRoute;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('redirect')]
 class AliasRedirectsRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
diff --git a/src/Redirects/IncrementalRedirectsRoutes.php b/src/Redirects/IncrementalRedirectsRoutes.php
index 5d75dcfc..a2c32b98 100644
--- a/src/Redirects/IncrementalRedirectsRoutes.php
+++ b/src/Redirects/IncrementalRedirectsRoutes.php
@@ -5,10 +5,11 @@ use RuntimeException;
 use Index\XNumber;
 use Index\Http\HttpResponseBuilder;
 use Index\Http\Content\FormContent;
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('redirect')]
 class IncrementalRedirectsRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
diff --git a/src/Redirects/LandingRedirectsRoutes.php b/src/Redirects/LandingRedirectsRoutes.php
index 01b45e61..d1cd5589 100644
--- a/src/Redirects/LandingRedirectsRoutes.php
+++ b/src/Redirects/LandingRedirectsRoutes.php
@@ -1,10 +1,11 @@
 <?php
 namespace Misuzu\Redirects;
 
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Routes\ExactRoute;
 use Misuzu\Template;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('redirect')]
 class LandingRedirectsRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
diff --git a/src/Redirects/NamedRedirectsRoutes.php b/src/Redirects/NamedRedirectsRoutes.php
index d6c6d54d..f4c0a0a3 100644
--- a/src/Redirects/NamedRedirectsRoutes.php
+++ b/src/Redirects/NamedRedirectsRoutes.php
@@ -3,9 +3,10 @@ namespace Misuzu\Redirects;
 
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Routes\PatternRoute;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('redirect')]
 class NamedRedirectsRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
diff --git a/src/Redirects/SocialRedirectsRoutes.php b/src/Redirects/SocialRedirectsRoutes.php
index 173ed7bd..d288fe43 100644
--- a/src/Redirects/SocialRedirectsRoutes.php
+++ b/src/Redirects/SocialRedirectsRoutes.php
@@ -2,10 +2,11 @@
 namespace Misuzu\Redirects;
 
 use Index\Http\HttpResponseBuilder;
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Routes\PatternRoute;
 use Misuzu\Template;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('redirect')]
 class SocialRedirectsRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
diff --git a/src/DomainRoles.php b/src/Routing/DomainRoles.php
similarity index 98%
rename from src/DomainRoles.php
rename to src/Routing/DomainRoles.php
index 40d4090e..dd412853 100644
--- a/src/DomainRoles.php
+++ b/src/Routing/DomainRoles.php
@@ -1,5 +1,5 @@
 <?php
-namespace Misuzu;
+namespace Misuzu\Routing;
 
 use Index\XArray;
 
diff --git a/src/Routing/HandlerRoles.php b/src/Routing/HandlerRoles.php
new file mode 100644
index 00000000..6e4089fc
--- /dev/null
+++ b/src/Routing/HandlerRoles.php
@@ -0,0 +1,36 @@
+<?php
+namespace Misuzu\Routing;
+
+use Attribute;
+use ReflectionAttribute;
+use ReflectionObject;
+use Index\Http\Routing\{Router,RouteHandler as IndexRouteHandler};
+
+#[Attribute(Attribute::TARGET_CLASS)]
+final class HandlerRoles {
+    /** @var string[] $roles */
+    public private(set) array $roles;
+
+    public function __construct(string ...$roles) {
+        $this->roles = $roles;
+    }
+
+    public function hasRole(string $role): bool {
+        return in_array($role, $this->roles);
+    }
+
+    /** @param string[] $roles */
+    public static function register(Router $router, IndexRouteHandler $handler, array $roles): void {
+        $object = new ReflectionObject($handler);
+        $attrs = $object->getAttributes(self::class, ReflectionAttribute::IS_INSTANCEOF);
+        if(empty($attrs))
+            return;
+
+        $info = $attrs[0]->newInstance();
+        foreach($roles as $role)
+            if($info->hasRole($role)) {
+                $handler->registerRoutes($router);
+                break;
+            }
+    }
+}
diff --git a/src/Routing/RouteHandler.php b/src/Routing/RouteHandler.php
new file mode 100644
index 00000000..c239bd34
--- /dev/null
+++ b/src/Routing/RouteHandler.php
@@ -0,0 +1,9 @@
+<?php
+namespace Misuzu\Routing;
+
+use Index\Http\Routing\{Router,RouteHandler as IndexRouteHandler};
+
+interface RouteHandler extends IndexRouteHandler {
+    /** @param string[] $roles */
+    public function registerRoleRoutes(Router $router, array $roles): void;
+}
diff --git a/src/Routing/RouteHandlerCommon.php b/src/Routing/RouteHandlerCommon.php
new file mode 100644
index 00000000..0eb5a08d
--- /dev/null
+++ b/src/Routing/RouteHandlerCommon.php
@@ -0,0 +1,13 @@
+<?php
+namespace Misuzu\Routing;
+
+use Index\Http\Routing\{Router,RouteHandlerCommon as IndexRouteHandlerCommon};
+
+trait RouteHandlerCommon {
+    use IndexRouteHandlerCommon;
+
+    /** @param string[] $roles */
+    public function registerRoleRoutes(Router $router, array $roles): void {
+        HandlerRoles::register($router, $this, $roles);
+    }
+}
diff --git a/src/Routing/RoutingContext.php b/src/Routing/RoutingContext.php
index bcd718b1..b7849969 100644
--- a/src/Routing/RoutingContext.php
+++ b/src/Routing/RoutingContext.php
@@ -1,33 +1,59 @@
 <?php
 namespace Misuzu\Routing;
 
+use Index\Dependencies;
 use Index\Config\Config;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{Router,RouteHandler};
+use Index\Http\Routing\{Router,RouteHandler as IndexRouteHandler};
 use Index\Http\Routing\Filters\FilterInfo;
+use Index\Templating\Extension\TplExtensionCommon;
 use Index\Urls\{ArrayUrlRegistry,UrlRegistry,UrlSource};
+use Twig\TwigFunction;
+use Twig\Extension\LastModifiedExtensionInterface;
+
+
+class RoutingContext implements LastModifiedExtensionInterface {
+    use TplExtensionCommon;
 
-class RoutingContext {
     public private(set) Router $router;
     public private(set) UrlRegistry $urls;
+    public private(set) DomainRoles $domainRoles;
 
-    public function __construct(Config $config) {
-        $this->urls = new ArrayUrlRegistry;
-        $this->router = new Router(
-            new RoutingErrorHandler,
-            new RoutingAccessControlHandler($config),
-        );
-        $this->router->filter(FilterInfo::prefix('/', fn(HttpResponseBuilder $response) => $response->setPoweredBy('Misuzu')));
+    public function __construct(
+        Dependencies $deps,
+        string $domainRoles,
+    ) {
+        $deps->register($this->urls = $deps->constructLazy(ArrayUrlRegistry::class));
+        $deps->register($this->router = $deps->constructLazy(
+            Router::class,
+            errorHandler: $deps->constructLazy(RoutingErrorHandler::class),
+            accessControlHandler: $deps->constructLazy(RoutingAccessControlHandler::class),
+        ));
+        $deps->register($this->domainRoles = $deps->constructLazy(
+            DomainRoles::class,
+            domainRoles: $domainRoles,
+        ));
     }
 
-    public function register(RouteHandler|UrlSource $handler): void {
+    /** @param string[] $roles */
+    public function register(IndexRouteHandler|UrlSource $handler, array $roles): void {
         if($handler instanceof RouteHandler)
-            $this->router->register($handler);
+            $handler->registerRoleRoutes($this->router, $roles);
+        elseif($handler instanceof IndexRouteHandler)
+            $handler->registerRoutes($this->router);
+
         if($handler instanceof UrlSource)
-            $this->urls->register($handler);
+            $handler->registerUrls($this->urls);
     }
 
     public function dispatch(?HttpRequest $request = null): void {
         $this->router->dispatch($request);
     }
+
+    #[\Override]
+    public function getFunctions() {
+        return [
+            new TwigFunction('url', $this->urls->format(...)),
+        ];
+    }
 }
diff --git a/src/Satori/SatoriRoutes.php b/src/Satori/SatoriRoutes.php
index aa133853..eb48e43e 100644
--- a/src/Satori/SatoriRoutes.php
+++ b/src/Satori/SatoriRoutes.php
@@ -6,7 +6,6 @@ use Index\Colour\Colour;
 use Index\Config\Config;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Content\FormContent;
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\AccessControl\AccessControl;
 use Index\Http\Routing\Filters\PrefixFilter;
 use Index\Http\Routing\Processors\Before;
@@ -14,8 +13,10 @@ use Index\Http\Routing\Routes\ExactRoute;
 use Misuzu\Pagination;
 use Misuzu\Forum\ForumContext;
 use Misuzu\Profile\ProfileContext;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\UsersContext;
 
+#[HandlerRoles('main')]
 final class SatoriRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
diff --git a/src/SharpChat/SharpChatRoutes.php b/src/SharpChat/SharpChatRoutes.php
index 7d6de6df..4caf4e1e 100644
--- a/src/SharpChat/SharpChatRoutes.php
+++ b/src/SharpChat/SharpChatRoutes.php
@@ -6,7 +6,6 @@ use Index\Colour\Colour;
 use Index\Config\Config;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
 use Index\Http\Content\{FormContent,UrlEncodedFormContent};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\AccessControl\AccessControl;
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\ExactRoute;
@@ -16,8 +15,10 @@ use Misuzu\Counters\CountersData;
 use Misuzu\Emoticons\EmotesContext;
 use Misuzu\OAuth2\{OAuth2AccessInfoGetField,OAuth2Context};
 use Misuzu\Perms\PermissionsData;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\{BansData,UsersContext,UserInfo};
 
+#[HandlerRoles('main')]
 final class SharpChatRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
@@ -25,7 +26,6 @@ final class SharpChatRoutes implements RouteHandler {
 
     public function __construct(
         private Config $config,
-        private Config $impersonateConfig, // this sucks lol
         private UrlRegistry $urls,
         private UsersContext $usersCtx,
         private AuthContext $authCtx,
@@ -77,14 +77,6 @@ final class SharpChatRoutes implements RouteHandler {
         ));
     }
 
-    private function canImpersonateUserId(UserInfo $impersonator, string $targetId): bool {
-        if($impersonator->super)
-            return true;
-
-        $whitelist = $this->impersonateConfig->getArray(sprintf('allow.u%s', $impersonator->id));
-        return in_array($targetId, $whitelist, true);
-    }
-
     /** @return array{ok: false, err: string}|array{ok: true, usr: int, tkn: string} */
     #[AccessControl(credentials: true)]
     #[ExactRoute('GET', '/_sockchat/token')]
@@ -106,7 +98,7 @@ final class SharpChatRoutes implements RouteHandler {
             return ['ok' => false, 'err' => 'user'];
 
         $userInfo = $this->usersCtx->users->getUser($sessionInfo->userId, 'id');
-        $userId = $tokenInfo->hasImpersonatedUserId && $this->canImpersonateUserId($userInfo, $tokenInfo->impersonatedUserId)
+        $userId = $tokenInfo->hasImpersonatedUserId && $this->usersCtx->canImpersonateUser($userInfo, $tokenInfo->impersonatedUserId)
             ? $tokenInfo->impersonatedUserId
             : $userInfo->id;
 
@@ -227,7 +219,7 @@ final class SharpChatRoutes implements RouteHandler {
             $this->authCtx->sessions->recordSessionActivity(sessionInfo: $sessionInfo, remoteAddr: $ipAddress);
 
             $userInfo = $this->usersCtx->users->getUser($sessionInfo->userId, 'id');
-            if($tokenInfo->hasImpersonatedUserId && $this->canImpersonateUserId($userInfo, $tokenInfo->impersonatedUserId)) {
+            if($tokenInfo->hasImpersonatedUserId && $this->usersCtx->canImpersonateUser($userInfo, $tokenInfo->impersonatedUserId)) {
                 $userInfoReal = $userInfo;
 
                 try {
diff --git a/src/SiteInfo.php b/src/SiteInfo.php
index 0024a0f9..bcc08d91 100644
--- a/src/SiteInfo.php
+++ b/src/SiteInfo.php
@@ -2,48 +2,58 @@
 namespace Misuzu;
 
 use Index\Config\Config;
+use Index\Templating\Extension\TplExtensionCommon;
+use Twig\TwigFunction;
+use Twig\Extension\LastModifiedExtensionInterface;
 
-class SiteInfo {
-    /** @var array<string, string> */
-    private array $props;
+class SiteInfo implements LastModifiedExtensionInterface {
+    use TplExtensionCommon;
 
-    public function __construct(Config $config) {
-        $this->props = $config->getValues([
-            ['name:s', 'Misuzu'],
-            'desc:s',
-            'domain:s',
-            'url:s',
-            'email:s',
-            'bsky:s',
-            'ext_logo:s',
-        ]);
-    }
+    public function __construct(
+        private Config $config,
+    ) {}
 
     public string $name {
-        get => $this->props['name'];
+        get => $this->config->getString('name', 'Misuzu');
     }
 
     public string $description {
-        get => $this->props['desc'];
+        get => $this->config->getString('desc');
     }
 
     public string $url {
-        get => rtrim($this->props['url'], '/');
+        get => $this->config->getString('url');
     }
 
     public string $email {
-        get => $this->props['email'];
+        get => $this->config->getString('email');
     }
 
     public string $bsky {
-        get => $this->props['bsky'];
+        get => $this->config->getString('bsky');
     }
 
     public string $domain {
-        get => $this->props['domain'];
+        get => $this->config->getString('domain');
     }
 
     public string $externalLogo {
-        get => $this->props['ext_logo'];
+        get => $this->config->getString('ext_logo');
+    }
+
+    public function getProperty(string $name): string {
+        return $this->config->getString($name);
+    }
+
+    public function hasProperty(string $name): bool {
+        return $this->config->hasValues($name);
+    }
+
+    #[\Override]
+    public function getFunctions() {
+        return [
+            new TwigFunction('site_has_info', $this->getProperty(...)),
+            new TwigFunction('site_get_info', $this->getProperty(...)),
+        ];
     }
 }
diff --git a/src/Storage/Tasks/TasksRoutes.php b/src/Storage/Tasks/TasksRoutes.php
index 154108f8..30efc87f 100644
--- a/src/Storage/Tasks/TasksRoutes.php
+++ b/src/Storage/Tasks/TasksRoutes.php
@@ -4,10 +4,11 @@ namespace Misuzu\Storage\Tasks;
 use RuntimeException;
 use Index\{ByteFormat,XNumber};
 use Index\Http\{HttpResponseBuilder,HttpRequest};
-use Index\Http\Routing\{HttpPut,RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\AccessControl\AccessControl;
 use Index\Http\Routing\Routes\PatternRoute;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('main')]
 class TasksRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
diff --git a/src/Storage/Uploads/UploadsLegacyRoutes.php b/src/Storage/Uploads/UploadsLegacyRoutes.php
index 952daedc..c4ac83ce 100644
--- a/src/Storage/Uploads/UploadsLegacyRoutes.php
+++ b/src/Storage/Uploads/UploadsLegacyRoutes.php
@@ -6,19 +6,20 @@ use Index\{ByteFormat,XArray,XNumber};
 use Index\Http\{HttpResponseBuilder,HttpRequest};
 use Index\Http\Content\MultipartFormContent;
 use Index\Http\Content\Multipart\FileMultipartFormData;
-use Index\Http\Routing\{HttpDelete,HttpOptions,HttpPost,RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\AccessControl\AccessControl;
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlSource,UrlSourceCommon};
 use Misuzu\Perm;
 use Misuzu\Auth\AuthInfo;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Storage\Denylist\{DenylistContext,DenylistReason};
 use Misuzu\Storage\Pools\{PoolsContext,PoolInfoGetField};
 use Misuzu\Storage\Pools\Rules\{ConstrainSizeRule,EnsureVariantRule,EnsureVariantRuleThumb};
 use Misuzu\Storage\Files\{FilesContext,FileImportMode,FileInfoGetFileField};
 use Misuzu\Users\UsersContext;
 
+#[HandlerRoles('main')]
 class UploadsLegacyRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
diff --git a/src/Storage/Uploads/UploadsViewRoutes.php b/src/Storage/Uploads/UploadsViewRoutes.php
index c0c373ff..1178a66b 100644
--- a/src/Storage/Uploads/UploadsViewRoutes.php
+++ b/src/Storage/Uploads/UploadsViewRoutes.php
@@ -4,14 +4,16 @@ namespace Misuzu\Storage\Uploads;
 use RuntimeException;
 use Index\{XArray,XNumber};
 use Index\Http\{HttpResponseBuilder,HttpRequest};
-use Index\Http\Routing\{Router,RouteHandler,RouteHandlerCommon};
+use Index\Http\Routing\Router;
 use Index\Http\Routing\AccessControl\AccessControl;
 use Index\Http\Routing\Routes\PatternRoute;
 use Misuzu\Storage\Denylist\DenylistContext;
 use Misuzu\Storage\Pools\PoolsContext;
 use Misuzu\Storage\Pools\Rules\{EnsureVariantRule,EnsureVariantRuleThumb};
 use Misuzu\Storage\Files\FilesContext;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('storage')]
 class UploadsViewRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
diff --git a/src/Template.php b/src/Template.php
index 7ab430ca..655ad104 100644
--- a/src/Template.php
+++ b/src/Template.php
@@ -12,8 +12,8 @@ final class Template {
     /** @var array<string, mixed> */
     private static array $vars = [];
 
-    public static function init(TplEnvironment $env): void {
-        self::$env = $env;
+    public static function init(MisuzuContext $env): void {
+        self::$env = $env->tplCtx->env;
     }
 
     public static function addFunction(string $name, callable $body): void {
diff --git a/src/Templating/TemplatingContext.php b/src/Templating/TemplatingContext.php
new file mode 100644
index 00000000..67630dc5
--- /dev/null
+++ b/src/Templating/TemplatingContext.php
@@ -0,0 +1,34 @@
+<?php
+namespace Misuzu\Templating;
+
+use Index\Dependencies;
+use Index\Templating\{TplContext,TplEnvironment};
+use Misuzu\Misuzu;
+use Twig\Extension\ExtensionInterface;
+
+class TemplatingContext {
+    public private(set) TplEnvironment $env;
+
+    public function __construct(
+        Dependencies $deps,
+        string $cachePath,
+    ) {
+        $this->env = new TplEnvironment(
+            Misuzu::PATH_TEMPLATES,
+            cache: Misuzu::cli() || empty($cachePath) ? null : $cachePath,
+            debug: Misuzu::debug(),
+        );
+
+        $deps->register($this->env);
+        $this->env->addExtension($deps->construct(TemplatingExtension::class));
+
+        $exts = $deps->all(ExtensionInterface::class);
+        foreach($exts as $ext)
+            $this->env->addExtension($ext);
+    }
+
+    /** @param array<string, mixed> $vars Context local variables to add right away. */
+    public function loadTemplate(string $name, array $vars = []): TplContext {
+        return $this->env->load($name, $vars);
+    }
+}
diff --git a/src/TemplatingExtension.php b/src/Templating/TemplatingExtension.php
similarity index 58%
rename from src/TemplatingExtension.php
rename to src/Templating/TemplatingExtension.php
index ef81f226..45abaa2b 100644
--- a/src/TemplatingExtension.php
+++ b/src/Templating/TemplatingExtension.php
@@ -1,18 +1,33 @@
 <?php
-namespace Misuzu;
+namespace Misuzu\Templating;
 
 use DateTimeInterface;
 use Carbon\CarbonImmutable;
+use Index\Dependencies;
+use Index\Config\Config;
+use Index\Templating\Extension\TplExtensionCommon;
+use Index\Urls\UrlRegistry;
+use Misuzu\{CsrfContext,GitInfo,Perm,SiteInfo,Tools};
+use Misuzu\Auth\AuthInfo;
 use Misuzu\Parsers\{Parsers,TextFormat};
+use Misuzu\Users\UsersContext;
 use Twig\{TwigFilter,TwigFunction};
-use Twig\Extension\AbstractExtension;
+use Twig\Extension\LastModifiedExtensionInterface;
+
+final class TemplatingExtension implements LastModifiedExtensionInterface {
+    use TplExtensionCommon;
 
-final class TemplatingExtension extends AbstractExtension {
     public function __construct(
-        private MisuzuContext $ctx,
-        private AssetInfo $assetInfo
+        private Config $config,
+        private Dependencies $deps,
+        private UrlRegistry $urls,
+        private CsrfContext $csrfCtx,
+        private UsersContext $usersCtx,
+        private AuthInfo $authInfo,
+        private SiteInfo $siteInfo,
     ) {}
 
+    #[\Override]
     public function getFilters() {
         return [
             new TwigFilter('country_name', Tools::countryName(...)),
@@ -22,15 +37,14 @@ final class TemplatingExtension extends AbstractExtension {
         ];
     }
 
+    #[\Override]
     public function getFunctions() {
         return [
-            new TwigFunction('asset', $this->assetInfo->getAssetUrl(...)),
-            new TwigFunction('url', $this->ctx->urls->format(...)),
+            new TwigFunction('get_meta_vars', $this->getMetaVariables(...)),
             new TwigFunction('git_commit_hash', GitInfo::hash(...)),
             new TwigFunction('git_tag', GitInfo::tag(...)),
             new TwigFunction('git_branch', GitInfo::branch(...)),
             new TwigFunction('startup_time', fn(float $time = MSZ_STARTUP) => microtime(true) - $time),
-            new TwigFunction('sql_query_count', $this->ctx->dbCtx->getQueryCount(...)),
             new TwigFunction('msz_header_menu', $this->getHeaderMenu(...)),
             new TwigFunction('msz_user_menu', $this->getUserMenu(...)),
             new TwigFunction('msz_manage_menu', $this->getManageMenu(...)),
@@ -44,6 +58,14 @@ final class TemplatingExtension extends AbstractExtension {
         ];
     }
 
+    /** @return array<string, string> */
+    public function getMetaVariables(): array {
+        return array_merge(...array_map(
+            fn($provider) => $provider->getTemplatingMetaVariables(),
+            $this->deps->all(TemplatingMetaVariableProvider::class),
+        ));
+    }
+
     public function timeFormat(DateTimeInterface|string|int|null $dateTime): string {
         if($dateTime === null)
             return 'never';
@@ -73,56 +95,56 @@ final class TemplatingExtension extends AbstractExtension {
 
         $home = [
             'title' => 'Home',
-            'url' => $this->ctx->urls->format('index'),
+            'url' => $this->urls->format('index'),
             'menu' => [],
         ];
 
-        if($this->ctx->authInfo->loggedIn)
+        if($this->authInfo->loggedIn)
             $home['menu'][] = [
                 'title' => 'Members',
-                'url' => $this->ctx->urls->format('user-list'),
+                'url' => $this->urls->format('user-list'),
             ];
 
         $home['menu'][] = [
             'title' => 'Changelog',
-            'url' => $this->ctx->urls->format('changelog-index'),
+            'url' => $this->urls->format('changelog-index'),
         ];
         $home['menu'][] = [
             'title' => 'Contact',
-            'url' => $this->ctx->urls->format('info', ['title' => 'contact']),
+            'url' => $this->urls->format('info', ['title' => 'contact']),
         ];
         $home['menu'][] = [
             'title' => 'Rules',
-            'url' => $this->ctx->urls->format('info', ['title' => 'rules']),
+            'url' => $this->urls->format('info', ['title' => 'rules']),
         ];
-        if(!empty($this->ctx->siteInfo->bsky))
+        if(!empty($this->siteInfo->bsky))
             $home['menu'][] = [
                 'title' => 'Bluesky',
-                'url' => $this->ctx->siteInfo->bsky,
+                'url' => $this->siteInfo->bsky,
             ];
 
         $menu[] = $home;
 
         $menu[] = [
             'title' => 'News',
-            'url' => $this->ctx->urls->format('news-index'),
+            'url' => $this->urls->format('news-index'),
         ];
 
         $forum = [
             'title' => 'Forum',
-            'url' => $this->ctx->urls->format('forum-index'),
+            'url' => $this->urls->format('forum-index'),
             'menu' => [],
         ];
 
-        if($this->ctx->authInfo->getPerms('global')->check(Perm::G_FORUM_LEADERBOARD_VIEW))
+        if($this->authInfo->getPerms('global')->check(Perm::G_FORUM_LEADERBOARD_VIEW))
             $forum['menu'][] = [
                 'title' => 'Leaderboard',
-                'url' => $this->ctx->urls->format('forum-leaderboard'),
+                'url' => $this->urls->format('forum-leaderboard'),
             ];
 
         $menu[] = $forum;
 
-        $chatPath = $this->ctx->getChatURL();
+        $chatPath = $this->config->getString('sockChat.chatPath.normal');
         if(!empty($chatPath))
             $menu[] = [
                 'title' => 'Chat',
@@ -142,64 +164,64 @@ final class TemplatingExtension extends AbstractExtension {
     public function getUserMenu(bool $inBroomCloset, string $manageUrl = ''): array {
         $menu = [];
 
-        if($this->ctx->authInfo->loggedIn) {
-            $userInfo = $this->ctx->authInfo->userInfo;
-            $globalPerms = $this->ctx->authInfo->getPerms('global');
+        if($this->authInfo->loggedIn) {
+            $userInfo = $this->authInfo->userInfo;
+            $globalPerms = $this->authInfo->getPerms('global');
 
             $menu[] = [
                 'title' => 'Profile',
-                'url' => $this->ctx->urls->format('user-profile', ['user' => $userInfo->id]),
+                'url' => $this->urls->format('user-profile', ['user' => $userInfo->id]),
                 'icon' => 'fas fa-user fa-fw',
             ];
             if($globalPerms->check(Perm::G_MESSAGES_VIEW))
                 $menu[] = [
                     'title' => 'Messages',
-                    'url' => $this->ctx->urls->format('messages-index'),
+                    'url' => $this->urls->format('messages-index'),
                     'icon' => 'fas fa-envelope fa-fw',
                     'class' => 'js-header-pms-button',
                 ];
             $menu[] = [
                 'title' => 'Settings',
-                'url' => $this->ctx->urls->format('settings-index'),
+                'url' => $this->urls->format('settings-index'),
                 'icon' => 'fas fa-cog fa-fw',
             ];
             $menu[] = [
                 'title' => 'Search',
-                'url' => $this->ctx->urls->format('search-index'),
+                'url' => $this->urls->format('search-index'),
                 'icon' => 'fas fa-search fa-fw',
             ];
 
-            if(!$this->ctx->usersCtx->hasActiveBan($userInfo) && $globalPerms->check(Perm::G_IS_JANITOR)) {
+            if(!$this->usersCtx->hasActiveBan($userInfo) && $globalPerms->check(Perm::G_IS_JANITOR)) {
                 // restore behaviour where clicking this button switches between
                 // site version and broom version
                 if($inBroomCloset)
                     $menu[] = [
                         'title' => 'Exit Broom Closet',
-                        'url' => $manageUrl === '' ? $this->ctx->urls->format('index') : $manageUrl,
+                        'url' => $manageUrl === '' ? $this->urls->format('index') : $manageUrl,
                         'icon' => 'fas fa-door-open fa-fw',
                     ];
                 else
                     $menu[] = [
                         'title' => 'Enter Broom Closet',
-                        'url' => $manageUrl === '' ? $this->ctx->urls->format('manage-index') : $manageUrl,
+                        'url' => $manageUrl === '' ? $this->urls->format('manage-index') : $manageUrl,
                         'icon' => 'fas fa-door-closed fa-fw',
                     ];
             }
 
             $menu[] = [
                 'title' => 'Log out',
-                'url' => $this->ctx->urls->format('auth-logout', ['csrf' => $this->ctx->csrfCtx->createToken()]),
+                'url' => $this->urls->format('auth-logout', ['csrf' => $this->csrfCtx->createToken()]),
                 'icon' => 'fas fa-sign-out-alt fa-fw',
             ];
         } else {
             $menu[] = [
                 'title' => 'Register',
-                'url' => $this->ctx->urls->format('auth-register'),
+                'url' => $this->urls->format('auth-register'),
                 'icon' => 'fas fa-user-plus fa-fw',
             ];
             $menu[] = [
                 'title' => 'Log in',
-                'url' => $this->ctx->urls->format('auth-login'),
+                'url' => $this->urls->format('auth-login'),
                 'icon' => 'fas fa-sign-in-alt fa-fw',
             ];
         }
@@ -209,49 +231,49 @@ final class TemplatingExtension extends AbstractExtension {
 
     /** @return array<string, array<string, string>> */
     public function getManageMenu(): array {
-        $globalPerms = $this->ctx->authInfo->getPerms('global');
-        if(!$this->ctx->authInfo->loggedIn || !$globalPerms->check(Perm::G_IS_JANITOR))
+        $globalPerms = $this->authInfo->getPerms('global');
+        if(!$this->authInfo->loggedIn || !$globalPerms->check(Perm::G_IS_JANITOR))
             return [];
 
         $menu = [
             'General' => [
-                'Overview' => $this->ctx->urls->format('manage-general-overview'),
+                'Overview' => $this->urls->format('manage-general-overview'),
             ],
         ];
 
         if($globalPerms->check(Perm::G_LOGS_VIEW))
-            $menu['General']['Logs'] = $this->ctx->urls->format('manage-general-logs');
+            $menu['General']['Logs'] = $this->urls->format('manage-general-logs');
         if($globalPerms->check(Perm::G_EMOTES_MANAGE))
-            $menu['General']['Emoticons'] = $this->ctx->urls->format('manage-general-emoticons');
+            $menu['General']['Emoticons'] = $this->urls->format('manage-general-emoticons');
         if($globalPerms->check(Perm::G_CONFIG_MANAGE))
-            $menu['General']['Settings'] = $this->ctx->urls->format('manage-general-settings');
+            $menu['General']['Settings'] = $this->urls->format('manage-general-settings');
 
-        $userPerms = $this->ctx->authInfo->getPerms('user');
+        $userPerms = $this->authInfo->getPerms('user');
         if($userPerms->check(Perm::U_USERS_MANAGE))
-            $menu['Users & Roles']['Users'] = $this->ctx->urls->format('manage-users');
+            $menu['Users & Roles']['Users'] = $this->urls->format('manage-users');
         if($userPerms->check(Perm::U_ROLES_MANAGE))
-            $menu['Users & Roles']['Roles'] = $this->ctx->urls->format('manage-roles');
+            $menu['Users & Roles']['Roles'] = $this->urls->format('manage-roles');
         if($userPerms->check(Perm::U_NOTES_MANAGE))
-            $menu['Users & Roles']['Notes'] = $this->ctx->urls->format('manage-users-notes');
+            $menu['Users & Roles']['Notes'] = $this->urls->format('manage-users-notes');
         if($userPerms->check(Perm::U_WARNINGS_MANAGE))
-            $menu['Users & Roles']['Warnings'] = $this->ctx->urls->format('manage-users-warnings');
+            $menu['Users & Roles']['Warnings'] = $this->urls->format('manage-users-warnings');
         if($userPerms->check(Perm::U_BANS_MANAGE))
-            $menu['Users & Roles']['Bans'] = $this->ctx->urls->format('manage-users-bans');
+            $menu['Users & Roles']['Bans'] = $this->urls->format('manage-users-bans');
 
         if($globalPerms->check(Perm::G_NEWS_POSTS_MANAGE))
-            $menu['News']['Posts'] = $this->ctx->urls->format('manage-news-posts');
+            $menu['News']['Posts'] = $this->urls->format('manage-news-posts');
         if($globalPerms->check(Perm::G_NEWS_CATEGORIES_MANAGE))
-            $menu['News']['Categories'] = $this->ctx->urls->format('manage-news-categories');
+            $menu['News']['Categories'] = $this->urls->format('manage-news-categories');
 
         if($globalPerms->check(Perm::G_FORUM_CATEGORIES_MANAGE))
-            $menu['Forum']['Permission Calculator'] = $this->ctx->urls->format('manage-forum-categories');
+            $menu['Forum']['Permission Calculator'] = $this->urls->format('manage-forum-categories');
         if($globalPerms->check(Perm::G_FORUM_TOPIC_REDIRS_MANAGE))
-            $menu['Forum']['Topic Redirects'] = $this->ctx->urls->format('manage-forum-topic-redirs');
+            $menu['Forum']['Topic Redirects'] = $this->urls->format('manage-forum-topic-redirs');
 
         if($globalPerms->check(Perm::G_CL_CHANGES_MANAGE))
-            $menu['Changelog']['Changes'] = $this->ctx->urls->format('manage-changelog-changes');
+            $menu['Changelog']['Changes'] = $this->urls->format('manage-changelog-changes');
         if($globalPerms->check(Perm::G_CL_TAGS_MANAGE))
-            $menu['Changelog']['Tags'] = $this->ctx->urls->format('manage-changelog-tags');
+            $menu['Changelog']['Tags'] = $this->urls->format('manage-changelog-tags');
 
         return $menu;
     }
diff --git a/src/Templating/TemplatingMetaVariableProvider.php b/src/Templating/TemplatingMetaVariableProvider.php
new file mode 100644
index 00000000..c1efec21
--- /dev/null
+++ b/src/Templating/TemplatingMetaVariableProvider.php
@@ -0,0 +1,7 @@
+<?php
+namespace Misuzu\Templating;
+
+interface TemplatingMetaVariableProvider {
+    /** @return array<string, string> */
+    public function getTemplatingMetaVariables(): array;
+}
diff --git a/src/Users/Assets/AssetsRoutes.php b/src/Users/Assets/AssetsRoutes.php
index 393b5c0b..0df2e89f 100644
--- a/src/Users/Assets/AssetsRoutes.php
+++ b/src/Users/Assets/AssetsRoutes.php
@@ -4,14 +4,15 @@ namespace Misuzu\Users\Assets;
 use InvalidArgumentException;
 use RuntimeException;
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{HttpGet,RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
 use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
 use Misuzu\{Misuzu,Perm};
 use Misuzu\Auth\AuthInfo;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\{UsersContext,UserInfo};
 
+#[HandlerRoles('main')]
 class AssetsRoutes implements RouteHandler, UrlSource {
     use RouteHandlerCommon, UrlSourceCommon;
 
diff --git a/src/Users/UsersApiRoutes.php b/src/Users/UsersApiRoutes.php
index 70ba2fa0..b2fe0dac 100644
--- a/src/Users/UsersApiRoutes.php
+++ b/src/Users/UsersApiRoutes.php
@@ -5,16 +5,17 @@ use RuntimeException;
 use Index\XArray;
 use Index\Colour\{Colour,ColourRgb};
 use Index\Http\{HttpRequest,HttpResponseBuilder};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\AccessControl\AccessControl;
 use Index\Http\Routing\Processors\Before;
 use Index\Http\Routing\Routes\ExactRoute;
 use Index\Urls\UrlRegistry;
 use Misuzu\{FieldTransformer,SiteInfo};
 use Misuzu\Auth\AuthInfo;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 use Misuzu\Users\UserInfo;
 use Misuzu\Users\Assets\UserAvatarAsset;
 
+#[HandlerRoles('main')]
 final class UsersApiRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
diff --git a/src/Users/UsersContext.php b/src/Users/UsersContext.php
index 82ffdeb4..74f6c3da 100644
--- a/src/Users/UsersContext.php
+++ b/src/Users/UsersContext.php
@@ -1,7 +1,10 @@
 <?php
 namespace Misuzu\Users;
 
+use InvalidArgumentException;
+use RuntimeException;
 use Index\Colour\Colour;
+use Index\Config\Config;
 use Index\Db\DbConnection;
 
 class UsersContext {
@@ -27,7 +30,24 @@ class UsersContext {
     /** @var array<string, ?BanInfo> */
     private array $activeBans = [];
 
-    public function __construct(DbConnection $dbConn) {
+    public string $accountHost {
+        get => $this->config->getString('accthost');
+    }
+
+    public string $newestUserId {
+        get => $this->config->getString('newest');
+        set(string $value) {
+            if($value === '')
+                $this->config->removeValues('newest');
+            else
+                $this->config->setString('newest', $value);
+        }
+    }
+
+    public function __construct(
+        DbConnection $dbConn,
+        private Config $config,
+    ) {
         $this->users = new UsersData($dbConn);
         $this->roles = new RolesData($dbConn);
         $this->bans = new BansData($dbConn);
@@ -94,4 +114,42 @@ class UsersContext {
     ): bool {
         return $this->tryGetActiveBan($userInfo, $minimumSeverity) !== null;
     }
+
+    /** @return string[] */
+    public function getAllowedImpersonationIds(UserInfo|string $userInfo): array {
+        return $this->config->getArray(
+            sprintf('allow_impersonate.u%s', $userInfo instanceof UserInfo ? $userInfo->id : $userInfo)
+        );
+    }
+
+    /** @param array<UserInfo|string> $users */
+    public function setAllowedImpersonationIds(UserInfo|string $userInfo, array $users): void {
+        foreach($users as $key => $value)
+            if($value instanceof UserInfo)
+                $users[$key] = $value->id;
+            elseif(!is_string($value))
+                throw new InvalidArgumentException('$users must be an array of UserInfo instances or id strings');
+
+        $name = sprintf('allow_impersonate.u%s', $userInfo instanceof UserInfo ? $userInfo->id : $userInfo);
+
+        if(empty($users))
+            $this->config->removeValues($name);
+        else
+            $this->config->setArray($name, $users);
+    }
+
+    public function canImpersonateUser(UserInfo|string $actorInfo, UserInfo|string $targetInfo): bool {
+        if(is_string($actorInfo))
+            try {
+                $actorInfo = $this->getUserInfo($actorInfo);
+            } catch(RuntimeException $ex) {
+                return false;
+            }
+
+        return $actorInfo->super || in_array(
+            $targetInfo instanceof UserInfo ? $targetInfo->id : $targetInfo,
+            $this->getAllowedImpersonationIds($actorInfo),
+            true,
+        );
+    }
 }
diff --git a/src/Users/UsersWebFingerResolver.php b/src/Users/UsersWebFingerResolver.php
index 095ec5bf..7f5200ed 100644
--- a/src/Users/UsersWebFingerResolver.php
+++ b/src/Users/UsersWebFingerResolver.php
@@ -2,7 +2,6 @@
 namespace Misuzu\Users;
 
 use Exception;
-use Index\Config\Config;
 use Index\Urls\UrlRegistry;
 use Misuzu\SiteInfo;
 use Misuzu\WebFinger\{
@@ -15,11 +14,10 @@ class UsersWebFingerResolver implements WebFingerResolver {
         private UsersContext $usersCtx,
         private SiteInfo $siteInfo,
         private UrlRegistry $urls,
-        private Config $config
     ) {}
 
     public function resolve(string $uri, array $components): ?WebFingerResourceInfo {
-        $acctHost = $this->config->getString('accthost');
+        $acctHost = $this->usersCtx->accountHost;
         $getValue = null;
 
         if(isset($components['scheme']) && isset($components['path'])) {
diff --git a/src/WebFinger/WebFingerRoutes.php b/src/WebFinger/WebFingerRoutes.php
index 81de66a3..88a054bf 100644
--- a/src/WebFinger/WebFingerRoutes.php
+++ b/src/WebFinger/WebFingerRoutes.php
@@ -1,17 +1,18 @@
 <?php
 namespace Misuzu\WebFinger;
 
-use Index\XArray;
+use Index\{Dependencies,XArray};
 use Index\Http\{HttpResponseBuilder,HttpRequest};
-use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
 use Index\Http\Routing\AccessControl\AccessControl;
 use Index\Http\Routing\Routes\ExactRoute;
+use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
 
+#[HandlerRoles('main')]
 class WebFingerRoutes implements RouteHandler {
     use RouteHandlerCommon;
 
     public function __construct(
-        private WebFingerRegistry $registry
+        private WebFingerRegistry $registry,
     ) {}
 
     /**
diff --git a/templates/_layout/footer.twig b/templates/_layout/footer.twig
index 37d707d4..74281deb 100644
--- a/templates/_layout/footer.twig
+++ b/templates/_layout/footer.twig
@@ -12,7 +12,7 @@
                 <a href="https://patchii.net/flashii/misuzu/src/tag/{{ git_tag }}" target="_blank" rel="noopener" class="footer__link">{{ git_tag }}</a>
             {% endif %}
             # <a href="https://patchii.net/flashii/misuzu/commit/{{ git_commit_hash(true) }}" target="_blank" rel="noopener" class="footer__link">{{ git_commit_hash() }}</a>
-            {% if globals.display_timings_info %}
+            {% if auth_display_timings() %}
                 / Index {{ ndx_version() }}
                 / {{ sql_query_count()|number_format }} queries
                 / {{ (startup_time() - startup_time(constant('MSZ_TPL_RENDER')))|number_format(5) }} load
diff --git a/templates/_layout/header.twig b/templates/_layout/header.twig
index 62fe99ce..41bab99f 100644
--- a/templates/_layout/header.twig
+++ b/templates/_layout/header.twig
@@ -1,14 +1,15 @@
 {% from 'macros.twig' import avatar %}
 {% from '_layout/input.twig' import input_checkbox_raw %}
 
-{% if globals.auth_info.impersonating %}
-    {% set real_user_info = globals.auth_info.realUserInfo %}
+{% if auth_impersonating() %}
+    {% set real_user_id = auth_get_real_user_info('id') %}
+    {% set real_user_name = auth_get_real_user_info('name') %}
     <div class="impersonate">
         <div class="impersonate-content">
             <div class="impersonate-user">
-                You are <a href="{{ url('user-profile', {'user': real_user_info.id}) }}" class="impersonate-user-link">
-                    <div class="avatar impersonate-user-avatar">{{ avatar(real_user_info.id, 20, real_user_info.name) }}</div>
-                    {{ real_user_info.name }}
+                You are <a href="{{ url('user-profile', {'user': real_user_id}) }}" class="impersonate-user-link">
+                    <div class="avatar impersonate-user-avatar">{{ avatar(real_user_id, 20, real_user_name) }}</div>
+                    {{ real_user_name }}
                 </a>
             </div>
             <div class="impersonate-options">
@@ -26,8 +27,8 @@
     <div class="header__background"></div>
 
     <div class="header__desktop">
-        <a class="header__desktop__logo" href="{{ url('index') }}" title="{{ globals.site_info.name }}">
-            {{ globals.site_info.name }}
+        <a class="header__desktop__logo" href="{{ url('index') }}" title="{{ site_get_info('name') }}">
+            {{ site_get_info('name') }}
         </a>
 
         <div class="header__desktop__menus">
@@ -56,10 +57,11 @@
                 </a>
             {% endfor %}
 
-            {% if globals.auth_info.loggedIn %}
-                {% set user_info = globals.auth_info.userInfo %}
-                <a href="{{ url('user-profile', {'user': user_info.id}) }}" class="avatar header__desktop__user__avatar" title="{{ user_info.name }}">
-                    {{ avatar(user_info.id, 60, user_info.name) }}
+            {% if auth_logged_in() %}
+                {% set header_user_id = auth_get_user_info('id') %}
+                {% set header_user_name = auth_get_user_info('name') %}
+                <a href="{{ url('user-profile', {'user': header_user_id}) }}" class="avatar header__desktop__user__avatar" title="{{ header_user_name }}">
+                    {{ avatar(header_user_id, 60, header_user_name) }}
                 </a>
             {% else %}
                 <a href="{{ url('auth-login') }}" class="avatar header__desktop__user__avatar">
@@ -76,13 +78,12 @@
             </label>
 
             <a class="header__mobile__logo header__mobile__icon" href="{{ url('index') }}">
-                {{ globals.site_info.name }}
+                {{ site_get_info('name') }}
             </a>
 
             <label class="header__mobile__icon header__mobile__avatar" for="toggle-mobile-header">
-                {% if globals.auth_info.loggedIn %}
-                    {% set user_info = globals.auth_info.userInfo %}
-                    {{ avatar(user_info.id, 40, user_info.name) }}
+                {% if auth_logged_in() %}
+                    {{ avatar(auth_get_user_info('id'), 40, auth_get_user_info('name')) }}
                 {% else %}
                     {{ avatar(0, 40, 'Log in') }}
                 {% endif %}
diff --git a/templates/_layout/meta.twig b/templates/_layout/meta.twig
index f25d28f0..9225cf4c 100644
--- a/templates/_layout/meta.twig
+++ b/templates/_layout/meta.twig
@@ -1,27 +1,29 @@
 {% apply spaceless %}
-    {% set description = description|default(globals.site_info.description) %}
+    {% set site_name = site_get_info('name') %}
+    {% set site_description = description|default(site_get_info('desc')) %}
+    {% set site_url = site_get_info('url') %}
 
     {% if title is defined %}
-        {% set browser_title = title ~ ' :: ' ~ globals.site_info.name %}
+        {% set browser_title = title ~ ' :: ' ~ site_name %}
     {% else %}
-        {% set browser_title = globals.site_info.name %}
+        {% set browser_title = site_name %}
     {% endif %}
 
     <title>{{ browser_title }}</title>
 
-    <meta property="og:title" content="{{ title|default(globals.site_info.name) }}">
-    <meta property="og:site_name" content="{{ globals.site_info.name }}">
+    <meta property="og:title" content="{{ title|default(site_name) }}">
+    <meta property="og:site_name" content="{{ site_name }}">
 
-    {% if description|length > 0 %}
-        <meta name="description" content="{{ description }}">
-        <meta property="og:description" content="{{ description }}">
+    {% if site_description|length > 0 %}
+        <meta name="description" content="{{ site_description }}">
+        <meta property="og:description" content="{{ site_description }}">
     {% endif %}
 
     <meta property="og:type" content="object">
 
     {% if image is defined %}
         {% if image|slice(0, 1) == '/' %}
-            {% set image = globals.site_info.url is not empty ? (globals.site_info.url ~ image) : '' %}
+            {% set image = site_url ~ image %}
         {% endif %}
 
         {% if image|length > 0 %}
@@ -31,7 +33,7 @@
 
     {% if canonical_url is defined %}
         {% if canonical_url|slice(0, 1) == '/' %}
-            {% set canonical_url = globals.site_info.url is not empty ? (globals.site_info.url ~ canonical_url) : '' %}
+            {% set canonical_url = site_url ~ canonical_url %}
         {% endif %}
 
         {% if canonical_url|length > 0 %}
diff --git a/templates/errors/master.twig b/templates/errors/master.twig
index 7d1de4b6..2314deb4 100644
--- a/templates/errors/master.twig
+++ b/templates/errors/master.twig
@@ -2,7 +2,7 @@
 {% from 'macros.twig' import container_title %}
 
 {% set error_string = '%03d %s'|format(error_code, error_text) %}
-{% set html_title = error_string ~ ' :: ' ~ globals.site_info.name %}
+{% set html_title = error_string ~ ' :: ' ~ site_get_info('name') %}
 
 {% block html_head %}
     <meta name="description" content="{{ error_blerb }}">
@@ -20,15 +20,15 @@
         <div class="error-wrapper">
             <div class="error-container">
                 <nav class="error-top">
-                    <div class="error-logo"><a href="{{ globals.site_info.url }}"><img src="/images/logos/imouto-default.png" alt=""></a></div>
-                    <div class="error-home"><a href="{{ globals.site_info.url }}">{{ globals.site_info.name }}</a></div>
+                    <div class="error-logo"><a href="{{ site_get_info('url') }}"><img src="/images/logos/imouto-default.png" alt=""></a></div>
+                    <div class="error-home"><a href="{{ site_get_info('url') }}">{{ site_get_info('name') }}</a></div>
                     {% if error_code >= 500 %}
                         <div class="error-nav">
-                            {% if globals.site_info.email is not empty %}
-                                <a href="mailto:{{ globals.site_info.email }}" rel="noopener" target="_blank">E-mail</a>
+                            {% if site_has_info('email') %}
+                                <a href="mailto:{{ site_get_info('email') }}" rel="noopener" target="_blank">E-mail</a>
                             {% endif %}
-                            {% if globals.site_info.bsky is not empty %}
-                                <a href="{{ globals.site_info.bsky }}" rel="noopener" target="_blank">Bluesky</a>
+                            {% if site_has_info('bsky') %}
+                                <a href="{{ site_get_info('bsky') }}" rel="noopener" target="_blank">Bluesky</a>
                             {% endif %}
                         </div>
                     {% endif %}
diff --git a/templates/home/landing.twig b/templates/home/landing.twig
index 59908cad..6142cfb6 100644
--- a/templates/home/landing.twig
+++ b/templates/home/landing.twig
@@ -44,7 +44,7 @@
         <div class="landingv2-header-content">
             <div class="landingv2-welcome">
                 <a href="{{ url('index') }}">
-                    <img src="/images/landing-logo.png" alt="{{ globals.site_info.name }}">
+                    <img src="/images/landing-logo.png" alt="{{ site_get_info('name') }}">
                 </a>
             </div>
 
@@ -89,7 +89,7 @@
 {% endif %}
                     # <a href="https://github.com/flashwave/misuzu/commit/{{ git_commit_hash(true) }}" target="_blank" rel="noreferrer noopener">{{ git_commit_hash() }}</a>
                 </div>
-{% if globals.display_timings_info %}
+{% if auth_display_timings() %}
                 <div class="landingv2-footer-copyright-line">
                     {{ sql_query_count()|number_format }} queries / {{ (startup_time() - startup_time(constant('MSZ_TPL_RENDER')))|number_format(5) }} load / {{ startup_time(constant('MSZ_TPL_RENDER'))|number_format(5) }} template / {{ startup_time()|number_format(5) }} total
                 </div>
diff --git a/templates/master.twig b/templates/master.twig
index d3d4778f..73f4c1a9 100644
--- a/templates/master.twig
+++ b/templates/master.twig
@@ -5,7 +5,7 @@
     <link href="/vendor/fontawesome/css/all.min.css" rel="stylesheet">
     <link href="{{ asset('common.css') }}" rel="stylesheet">
     <link href="{{ asset('misuzu.css') }}" rel="stylesheet">
-    {% for name, value in globals.meta %}
+    {% for name, value in get_meta_vars() %}
         {% if value is not empty %}<meta name="msz-{{ name }}" content="{{ value }}">{% endif %}
     {% endfor %}
     {% if main_css_vars is defined and main_css_vars is iterable and main_css_vars is not empty %}
@@ -36,12 +36,13 @@
             </div>
         </noscript>
 
-        {% if globals.active_ban_info is not null %}
+        {% set active_ban_info = auth_get_ban_info() %}
+        {% if active_ban_info is not null %}
             <div class="warning warning--red">
                 <div class="warning__content">
-                    <p>You have been banned {% if globals.active_ban_info.permanent %}<strong>permanently</strong>{% else %}for <strong title="{{ globals.active_ban_info.expiresTime|date('r') }}">{{ globals.active_ban_info.remainingString }}</strong>{% endif %} since <strong><time datetime="{{ globals.active_ban_info.createdTime|date('c') }}" title="{{ globals.active_ban_info.createdTime|date('r') }}">{{ globals.active_ban_info.createdTime|time_format }}</time></strong>.</p>
-                    {% if globals.active_ban_info.publicReason is not empty %}
-                        <p>Reason: {{ globals.active_ban_info.publicReason }}</p>
+                    <p>You have been banned {% if active_ban_info.permanent %}<strong>permanently</strong>{% else %}for <strong title="{{ active_ban_info.expiresTime|date('r') }}">{{ active_ban_info.remainingString }}</strong>{% endif %} since <strong><time datetime="{{ active_ban_info.createdTime|date('c') }}" title="{{ active_ban_info.createdTime|date('r') }}">{{ active_ban_info.createdTime|time_format }}</time></strong>.</p>
+                    {% if active_ban_info.publicReason is not empty %}
+                        <p>Reason: {{ active_ban_info.publicReason }}</p>
                     {% endif %}
                 </div>
             </div>
diff --git a/templates/oauth2/master.twig b/templates/oauth2/master.twig
index 342c7a93..20735f5d 100644
--- a/templates/oauth2/master.twig
+++ b/templates/oauth2/master.twig
@@ -1,6 +1,6 @@
 {% extends 'html.twig' %}
 
-{% set html_title = (title is defined ? (title ~ ' :: ') : '') ~ globals.site_info.name %}
+{% set html_title = (title is defined ? (title ~ ' :: ') : '') ~ site_get_info('name') %}
 
 {% block html_head %}
     <link href="{{ asset('common.css') }}" rel="stylesheet">
@@ -27,7 +27,7 @@
                     <div class="oauth2-body">
                         <div class="oauth2-banner">
                             <div class="oauth2-banner-text">{{ body_title|default('') }}</div>
-                            <div class="oauth2-banner-logo">{{ globals.site_info.name }}</div>
+                            <div class="oauth2-banner-logo">{{ site_get_info('name') }}</div>
                         </div>
 
                         {% block body_content %}{% endblock %}
diff --git a/templates/redirects/landing.twig b/templates/redirects/landing.twig
index 886a408c..9165ed3d 100644
--- a/templates/redirects/landing.twig
+++ b/templates/redirects/landing.twig
@@ -1,6 +1,6 @@
 {% extends 'redirects/master.twig' %}
 
-{% set html_title = globals.site_info.name ~ ' Redirect Service' %}
+{% set html_title = site_get_info('name') ~ ' Redirect Service' %}
 
 {% block content %}
     <div class="redir-landing">
@@ -8,7 +8,7 @@
             <article class="redir-landing-body">
                 <p><div class="redir-landing-logo"></div></p>
                 <h1>{{ html_title }}</h1>
-                <p>Short URL Service for <a href="{{ globals.site_info.url }}" rel="noopener">{{ globals.site_info.name }}</a></p>
+                <p>Short URL Service for <a href="{{ site_get_info('url') }}/" rel="noopener">{{ site_get_info('name') }}</a></p>
             </article>
             <footer class="redir-landing-footer">
                 <p>
diff --git a/tools/render-tpl b/tools/render-tpl
index 9207b77a..20e04aa4 100755
--- a/tools/render-tpl
+++ b/tools/render-tpl
@@ -77,10 +77,11 @@ handleValue:
 $hostName ??= 'localhost';
 
 // this should really not be necessary, mostly done to make sure the url registry is available
-$msz->createRouting(new HttpRequest('1.1', ['Host' => [$hostName]], NullStream::instance(), [], 'GET', HttpUri::createUri('/'), [], []));
-$msz->startTemplating(false);
+$msz->registerRequestRoutes(
+    new HttpRequest('1.1', ['Host' => [$hostName]], NullStream::instance(), [], 'GET', HttpUri::createUri('/'), [], [])
+);
 
-$ctx = $msz->templating->load(implode(' ', array_slice($argv, $pathIndex)));
+$ctx = $msz->tplCtx->loadTemplate(implode(' ', array_slice($argv, $pathIndex)));
 
 foreach($tplArgs as $name => $value)
     $ctx->setVar($name, $value);