🗝
summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--.gitignore4
-rw-r--r--Cargo.lock2036
-rw-r--r--Cargo.toml26
-rw-r--r--src/ipc.rs83
-rw-r--r--src/main.rs12
-rw-r--r--src/server/admin.rs93
-rw-r--r--src/server/config.rs29
-rw-r--r--src/server/login.rs208
-rw-r--r--src/server/mod.rs160
-rw-r--r--src/server/nginx_check.rs41
-rw-r--r--src/server/panel.rs30
-rw-r--r--src/server/store.rs330
-rw-r--r--src/tui/accounts.rs169
-rw-r--r--src/tui/mod.rs159
14 files changed, 3380 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..f35ea30
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+/target
+/*.json
+/*.sock
+/config.toml
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..021b24f
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,2036 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 3
+
+[[package]]
+name = "addr2line"
+version = "0.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb"
+dependencies = [
+ "gimli",
+]
+
+[[package]]
+name = "adler"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
+
+[[package]]
+name = "ahash"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011"
+dependencies = [
+ "cfg-if",
+ "getrandom",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "aliasable"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "250f629c0161ad8107cf89319e990051fae62832fd343083bea452d93e2205fd"
+
+[[package]]
+name = "android-tzdata"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0"
+
+[[package]]
+name = "android_system_properties"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures",
+ "password-hash",
+]
+
+[[package]]
+name = "arrayref"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b4930d2cb77ce62f89ee5d5289b4ac049559b1c45539271f5ed4fdc7db34545"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
+
+[[package]]
+name = "async-trait"
+version = "0.1.77"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c980ee35e870bd1a4d2c8294d4c04d0499e67bca1e4b5cefcc693c2fa00caea9"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa"
+
+[[package]]
+name = "axum"
+version = "0.7.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1236b4b292f6c4d6dc34604bb5120d85c3fe1d1aa596bd5cc52ca054d13e7b9e"
+dependencies = [
+ "async-trait",
+ "axum-core",
+ "axum-macros",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "rustversion",
+ "serde",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a15c63fd72d41492dc4f497196f5da1fb04fb7529e631d73630d1b491e47a2e3"
+dependencies = [
+ "async-trait",
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "rustversion",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-extra"
+version = "0.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "895ff42f72016617773af68fb90da2a9677d89c62338ec09162d4909d86fdd8f"
+dependencies = [
+ "axum",
+ "axum-core",
+ "bytes",
+ "cookie",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "serde",
+ "tower",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "axum-macros"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00c055ee2d014ae5981ce1016374e8213682aa14d9bf40e48ab48b5f3ef20eaa"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "backtrace"
+version = "0.3.69"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837"
+dependencies = [
+ "addr2line",
+ "cc",
+ "cfg-if",
+ "libc",
+ "miniz_oxide",
+ "object",
+ "rustc-demangle",
+]
+
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64ct"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b"
+
+[[package]]
+name = "bincode"
+version = "2.0.0-rc.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f11ea1a0346b94ef188834a65c068a03aec181c94896d481d7a0a40d85b0ce95"
+dependencies = [
+ "bincode_derive",
+ "serde",
+]
+
+[[package]]
+name = "bincode_derive"
+version = "2.0.0-rc.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e30759b3b99a1b802a7a3aa21c85c3ded5c28e1c83170d82d70f08bbf7f3e4c"
+dependencies = [
+ "virtue",
+]
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "blake2b_simd"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23285ad32269793932e830392f2fe2f83e26488fd3ec778883a93c8323735780"
+dependencies = [
+ "arrayref",
+ "arrayvec",
+ "constant_time_eq",
+]
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.15.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ea184aa71bb362a1157c896979544cc23974e08fd265f29ea96b59f0b4a555b"
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "bytes"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223"
+
+[[package]]
+name = "cc"
+version = "1.0.88"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02f341c093d19155a6e41631ce5971aac4e9a868262212153124c15fa22d1cdc"
+
+[[package]]
+name = "cfg-if"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
+
+[[package]]
+name = "chrono"
+version = "0.4.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b"
+dependencies = [
+ "android-tzdata",
+ "iana-time-zone",
+ "num-traits",
+ "windows-targets 0.52.4",
+]
+
+[[package]]
+name = "constant_time_eq"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7144d30dcf0fafbce74250a3963025d8d52177934239851c917d29f1df280c2"
+
+[[package]]
+name = "cookie"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3cd91cf61412820176e137621345ee43b3f4423e589e7ae4e50d601d93e35ef8"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[package]]
+name = "core-foundation-sys"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f"
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crossbeam-channel"
+version = "0.5.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab3db02a9c5b5121e1e42fbdb1aeb65f5e02624cc58c43f2884c6ccac0b82f95"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345"
+
+[[package]]
+name = "crossterm"
+version = "0.25.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e64e6c0fbe2c17357405f7c758c1ef960fce08bdfb2c03d88d2a18d7e09c4b67"
+dependencies = [
+ "bitflags",
+ "crossterm_winapi",
+ "libc",
+ "mio",
+ "parking_lot",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "cursive"
+version = "0.20.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5438eb16bdd8af51b31e74764fef5d0a9260227a5ec82ba75c9d11ce46595839"
+dependencies = [
+ "ahash",
+ "cfg-if",
+ "crossbeam-channel",
+ "crossterm",
+ "cursive_core",
+ "lazy_static",
+ "libc",
+ "log",
+ "signal-hook",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "cursive_core"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4db3b58161228d0dcb45c7968c5e74c3f03ad39e8983e58ad7d57061aa2cd94d"
+dependencies = [
+ "ahash",
+ "crossbeam-channel",
+ "enum-map",
+ "enumset",
+ "lazy_static",
+ "log",
+ "num",
+ "owning_ref",
+ "time",
+ "unicode-segmentation",
+ "unicode-width",
+ "xi-unicode",
+]
+
+[[package]]
+name = "darling"
+version = "0.20.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54e36fcd13ed84ffdfda6f5be89b31287cbb80c439841fe69e04841435464391"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.20.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c2cf1c23a687a1feeb728783b993c4e1ad83d99f351801977dd809b48d0a70f"
+dependencies = [
+ "fnv",
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.20.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a668eda54683121533a393014d8692171709ff57a7d61f187b6e782719f8933f"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "deranged"
+version = "0.3.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4"
+dependencies = [
+ "powerfmt",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+ "subtle",
+]
+
+[[package]]
+name = "dissociate"
+version = "0.1.0"
+dependencies = [
+ "argon2",
+ "axum",
+ "axum-extra",
+ "bincode",
+ "cursive",
+ "eyre",
+ "maud",
+ "nanoid",
+ "ouroboros",
+ "oxide-auth",
+ "oxide-auth-axum",
+ "paste",
+ "rand",
+ "serde",
+ "serde_json",
+ "tap",
+ "tokio",
+ "toml",
+]
+
+[[package]]
+name = "either"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11157ac094ffbdde99aa67b23417ebdd801842852b500e395a45a9c0aac03e4a"
+
+[[package]]
+name = "enum-map"
+version = "2.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
+dependencies = [
+ "enum-map-derive",
+]
+
+[[package]]
+name = "enum-map-derive"
+version = "0.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "enumset"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "226c0da7462c13fb57e5cc9e0dc8f0635e7d27f276a3a7fd30054647f669007d"
+dependencies = [
+ "enumset_derive",
+]
+
+[[package]]
+name = "enumset_derive"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08b6c6ab82d70f08844964ba10c7babb716de2ecaeab9be5717918a5177d3af"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5"
+
+[[package]]
+name = "eyre"
+version = "0.6.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec"
+dependencies = [
+ "indenter",
+ "once_cell",
+]
+
+[[package]]
+name = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5"
+
+[[package]]
+name = "futures-task"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004"
+
+[[package]]
+name = "futures-util"
+version = "0.3.30"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "pin-utils",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
+[[package]]
+name = "gimli"
+version = "0.28.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253"
+
+[[package]]
+name = "h2"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31d030e59af851932b72ceebadf4a2b5986dba4c3b99dd2493f8273a0f151943"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http",
+ "indexmap",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.14.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
+
+[[package]]
+name = "heck"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8"
+
+[[package]]
+name = "hermit-abi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest",
+]
+
+[[package]]
+name = "http"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1cac85db508abc24a2e48553ba12a996e87244a0395ce011e62b37158745d643"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41cb79eb393015dadd30fc252023adb0b2400a0caee0fa2a077e6e21a551e840"
+dependencies = [
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "httparse"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904"
+
+[[package]]
+name = "httpdate"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
+
+[[package]]
+name = "hyper"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "186548d73ac615b32a73aafe38fb4f56c0d340e110e5a200bcadbaf2e199263a"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "h2",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca38ef113da30126bbff9cd1705f9273e15d45498615d138b0c20279ac7a76aa"
+dependencies = [
+ "bytes",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+]
+
+[[package]]
+name = "iana-time-zone"
+version = "0.1.60"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141"
+dependencies = [
+ "android_system_properties",
+ "core-foundation-sys",
+ "iana-time-zone-haiku",
+ "js-sys",
+ "wasm-bindgen",
+ "windows-core",
+]
+
+[[package]]
+name = "iana-time-zone-haiku"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[package]]
+name = "idna"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6"
+dependencies = [
+ "unicode-bidi",
+ "unicode-normalization",
+]
+
+[[package]]
+name = "indenter"
+version = "0.3.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683"
+
+[[package]]
+name = "indexmap"
+version = "2.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7b0b929d511467233429c45a44ac1dcaa21ba0f5ba11e4879e6ed28ddb4f9df4"
+dependencies = [
+ "equivalent",
+ "hashbrown",
+]
+
+[[package]]
+name = "itertools"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c"
+
+[[package]]
+name = "js-sys"
+version = "0.3.68"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "406cda4b368d531c842222cf9d2600a9a4acce8d29423695379c6868a143a9ee"
+dependencies = [
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
+
+[[package]]
+name = "libc"
+version = "0.2.153"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd"
+
+[[package]]
+name = "lock_api"
+version = "0.4.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45"
+dependencies = [
+ "autocfg",
+ "scopeguard",
+]
+
+[[package]]
+name = "log"
+version = "0.4.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c"
+
+[[package]]
+name = "matchit"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
+
+[[package]]
+name = "maud"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa"
+dependencies = [
+ "itoa",
+ "maud_macros",
+]
+
+[[package]]
+name = "maud_macros"
+version = "0.26.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18"
+dependencies = [
+ "proc-macro-error",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "memchr"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149"
+
+[[package]]
+name = "mime"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+
+[[package]]
+name = "miniz_oxide"
+version = "0.7.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7"
+dependencies = [
+ "adler",
+]
+
+[[package]]
+name = "mio"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
+dependencies = [
+ "libc",
+ "log",
+ "wasi",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "nanoid"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ffa00dec017b5b1a8b7cf5e2c008bfda1aa7e0697ac1508b491fdf2622fb4d8"
+dependencies = [
+ "rand",
+]
+
+[[package]]
+name = "num"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b05180d69e3da0e530ba2a1dae5110317e49e3b7f3d41be227dc5f92e49ee7af"
+dependencies = [
+ "num-complex",
+ "num-integer",
+ "num-iter",
+ "num-rational",
+ "num-traits",
+]
+
+[[package]]
+name = "num-complex"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23c6602fda94a57c990fe0df199a035d83576b496aa29f4e634a8ac6004e68a6"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
+
+[[package]]
+name = "num-integer"
+version = "0.1.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "num-iter"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d869c01cc0c455284163fd0092f1f93835385ccab5a98a0dcc497b2f8bf055a9"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-rational"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0638a1c9d0a3c0914158145bc76cff373a75a627e6ecbfb71cbe6f453a5a19b0"
+dependencies = [
+ "autocfg",
+ "num-integer",
+ "num-traits",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_cpus"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
+dependencies = [
+ "hermit-abi",
+ "libc",
+]
+
+[[package]]
+name = "num_threads"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "object"
+version = "0.32.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
+
+[[package]]
+name = "ouroboros"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97b7be5a8a3462b752f4be3ff2b2bf2f7f1d00834902e46be2a4d68b87b0573c"
+dependencies = [
+ "aliasable",
+ "ouroboros_macro",
+ "static_assertions",
+]
+
+[[package]]
+name = "ouroboros_macro"
+version = "0.18.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b645dcde5f119c2c454a92d0dfa271a2a3b205da92e4292a68ead4bdbfde1f33"
+dependencies = [
+ "heck",
+ "itertools",
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "owning_ref"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ff55baddef9e4ad00f88b6c743a2a8062d4c6ade126c2a528644b8e444d52ce"
+dependencies = [
+ "stable_deref_trait",
+]
+
+[[package]]
+name = "oxide-auth"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06729d9f5ee0fcade59be20aef48231be773808ea3d2eda2fcac394f678cfd95"
+dependencies = [
+ "base64 0.13.1",
+ "chrono",
+ "hmac",
+ "once_cell",
+ "rand",
+ "rmp-serde",
+ "rust-argon2",
+ "serde",
+ "serde_derive",
+ "serde_json",
+ "sha2",
+ "subtle",
+ "url",
+]
+
+[[package]]
+name = "oxide-auth-axum"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3592c190977a91610618349bd947fec5bf10b2ca1cdc882f6623031de4cd34e1"
+dependencies = [
+ "axum",
+ "oxide-auth",
+]
+
+[[package]]
+name = "parking_lot"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f"
+dependencies = [
+ "lock_api",
+ "parking_lot_core",
+]
+
+[[package]]
+name = "parking_lot_core"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "redox_syscall",
+ "smallvec",
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core",
+ "subtle",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de3145af08024dea9fa9914f381a17b8fc6034dfb00f3a84013f7ff43f29ed4c"
+
+[[package]]
+name = "percent-encoding"
+version = "2.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e"
+
+[[package]]
+name = "pin-project"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0302c4a0442c456bd56f841aee5c3bfd17967563f6fadc9ceb9f9c23cf3807e0"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "266c042b60c9c76b8d53061e52b2e0d1116abc57cefc8c5cd671619a56ac3690"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "powerfmt"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
+
+[[package]]
+name = "proc-macro-error"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
+dependencies = [
+ "proc-macro-error-attr",
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro-error-attr"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "version_check",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.78"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "version_check",
+ "yansi",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "rmp"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f9860a6cc38ed1da53456442089b4dfa35e7cedaa326df63017af88385e6b20"
+dependencies = [
+ "byteorder",
+ "num-traits",
+ "paste",
+]
+
+[[package]]
+name = "rmp-serde"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bffea85eea980d8a74453e5d02a8d93028f3c34725de143085a844ebe953258a"
+dependencies = [
+ "byteorder",
+ "rmp",
+ "serde",
+]
+
+[[package]]
+name = "rust-argon2"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5885493fdf0be6cdff808d1533ce878d21cfa49c7086fa00c66355cd9141bfc"
+dependencies = [
+ "base64 0.21.7",
+ "blake2b_simd",
+ "constant_time_eq",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "rustc-demangle"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76"
+
+[[package]]
+name = "rustversion"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4"
+
+[[package]]
+name = "ryu"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e86697c916019a8588c99b5fac3cead74ec0b4b819707a682fd4d23fa0ce1ba1"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.197"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.197"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0"
+dependencies = [
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebd154a240de39fdebcf5775d2675c204d7c13cf39a4c697be6493c8e734337c"
+dependencies = [
+ "itoa",
+ "serde",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eb3622f419d1296904700073ea6cc23ad690adbd66f13ea683df73298736f0c1"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "signal-hook"
+version = "0.3.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8621587d4798caf8eb44879d42e56b9a93ea5dcd315a6487c357130095b62801"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29ad2e15f37ec9a6cc544097b78a1ec90001e9f71b81338ca39f430adaca99af"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook",
+]
+
+[[package]]
+name = "signal-hook-registry"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7"
+
+[[package]]
+name = "socket2"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05ffd9c0a93b7543e062e759284fcf5f5e3b098501104bfbdde4d404db792871"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3"
+
+[[package]]
+name = "static_assertions"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+
+[[package]]
+name = "subtle"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "81cdd64d312baedb58e21336b31bc043b77e01cc99033ce76ef539f78e965ebc"
+
+[[package]]
+name = "syn"
+version = "2.0.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
+
+[[package]]
+name = "tap"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
+
+[[package]]
+name = "time"
+version = "0.3.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749"
+dependencies = [
+ "deranged",
+ "itoa",
+ "libc",
+ "num-conv",
+ "num_threads",
+ "powerfmt",
+ "serde",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3"
+
+[[package]]
+name = "time-macros"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50"
+dependencies = [
+ "tinyvec_macros",
+]
+
+[[package]]
+name = "tinyvec_macros"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
+
+[[package]]
+name = "tokio"
+version = "1.36.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931"
+dependencies = [
+ "backtrace",
+ "bytes",
+ "libc",
+ "mio",
+ "num_cpus",
+ "pin-project-lite",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.48.0",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5419f34732d9eb6ee4c3578b7989078579b7f039cbbb9ca2c4da015749371e15"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "toml"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a9aad4a3066010876e8dcf5a8a06e70a558751117a145c6ce2b82c2e2054290"
+dependencies = [
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_edit",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3550f4e9685620ac18a50ed434eb3aec30db8ba93b0287467bca5826ea25baf1"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.22.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c1b5fd4128cc8d3e0cb74d4ed9a9cc7c7284becd4df68f5f940e1ad123606f6"
+dependencies = [
+ "indexmap",
+ "serde",
+ "serde_spanned",
+ "toml_datetime",
+ "winnow",
+]
+
+[[package]]
+name = "tower"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c"
+dependencies = [
+ "futures-core",
+ "futures-util",
+ "pin-project",
+ "pin-project-lite",
+ "tokio",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "tower-layer"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c20c8dbed6283a09604c3e69b4b7eeb54e298b8a600d4d5ecb5ad39de609f1d0"
+
+[[package]]
+name = "tower-service"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
+
+[[package]]
+name = "tracing"
+version = "0.1.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06d3da6113f116aaee68e4d601191614c9053067f9ab7f6edbcb161237daa54"
+dependencies = [
+ "once_cell",
+]
+
+[[package]]
+name = "typenum"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825"
+
+[[package]]
+name = "unicode-bidi"
+version = "0.3.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
+
+[[package]]
+name = "unicode-normalization"
+version = "0.1.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a56d1686db2308d901306f92a263857ef59ea39678a5458e7cb17f01415101f5"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e51733f11c9c4f72aa0c160008246859e340b00807569a0da0e7a1079b27ba85"
+
+[[package]]
+name = "url"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "version_check"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f"
+
+[[package]]
+name = "virtue"
+version = "0.0.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dcc60c0624df774c82a0ef104151231d37da4962957d691c011c852b2473314"
+
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1e124130aee3fb58c5bdd6b639a0509486b0338acaaae0c84a5124b0f588b7f"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen-macro",
+]
+
+[[package]]
+name = "wasm-bindgen-backend"
+version = "0.2.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9e7e1900c352b609c8488ad12639a311045f40a35491fb69ba8c12f758af70b"
+dependencies = [
+ "bumpalo",
+ "log",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b30af9e2d358182b5c7449424f017eba305ed32a7010509ede96cdc4696c46ed"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-backend",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4f186bd2dcf04330886ce82d6f33dd75a7bfcf69ecf5763b89fcde53b6ac9838"
+
+[[package]]
+name = "winapi"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419"
+dependencies = [
+ "winapi-i686-pc-windows-gnu",
+ "winapi-x86_64-pc-windows-gnu",
+]
+
+[[package]]
+name = "winapi-i686-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6"
+
+[[package]]
+name = "winapi-x86_64-pc-windows-gnu"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
+
+[[package]]
+name = "windows-core"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
+dependencies = [
+ "windows-targets 0.52.4",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.48.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
+dependencies = [
+ "windows-targets 0.48.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.4",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
+dependencies = [
+ "windows_aarch64_gnullvm 0.48.5",
+ "windows_aarch64_msvc 0.48.5",
+ "windows_i686_gnu 0.48.5",
+ "windows_i686_msvc 0.48.5",
+ "windows_x86_64_gnu 0.48.5",
+ "windows_x86_64_gnullvm 0.48.5",
+ "windows_x86_64_msvc 0.48.5",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.4",
+ "windows_aarch64_msvc 0.52.4",
+ "windows_i686_gnu 0.52.4",
+ "windows_i686_msvc 0.52.4",
+ "windows_x86_64_gnu 0.52.4",
+ "windows_x86_64_gnullvm 0.52.4",
+ "windows_x86_64_msvc 0.52.4",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.48.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8"
+
+[[package]]
+name = "winnow"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dffa400e67ed5a4dd237983829e66475f0a4a26938c4b04c21baede6262215b8"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "xi-unicode"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a67300977d3dc3f8034dae89778f502b6ba20b269527b3223ba59c0cf393bb8a"
+
+[[package]]
+name = "yansi"
+version = "1.0.0-rc.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1367295b8f788d371ce2dbc842c7b709c73ee1364d30351dd300ec2203b12377"
+
+[[package]]
+name = "zerocopy"
+version = "0.7.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74d4d3961e53fa4c9a25a8637fc2bfaf2595b3d3ae34875568a5cf64787716be"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.7.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce1b18ccd8e73a9321186f97e46f9f04b778851177567b1975109d26a08d2a6"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..7f5317e
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "dissociate"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+argon2 = "0.5.3"
+axum = { version = "0.7.4", features = ["macros"] }
+axum-extra = { version = "0.9.2", features = ["cookie"] }
+bincode = "2.0.0-rc.3"
+cursive = { version = "0.20.0", default-features = false, features = [
+    "crossterm-backend",
+] }
+eyre = "0.6.12"
+maud = "0.26.0"
+nanoid = "0.4.0"
+ouroboros = "0.18.3"
+oxide-auth = "0.5.4"
+oxide-auth-axum = "0.4.0"
+paste = "1.0.14"
+rand = "0.8.5"
+serde = { version = "1.0.197", features = ["derive"] }
+serde_json = "1.0.114"
+tap = "1.0.1"
+tokio = { version = "1.36.0", features = ["rt-multi-thread", "macros", "fs"] }
+toml = "0.8.10"
diff --git a/src/ipc.rs b/src/ipc.rs
new file mode 100644
index 0000000..f3e7d0a
--- /dev/null
+++ b/src/ipc.rs
@@ -0,0 +1,83 @@
+use std::fmt::{Display, Formatter};
+
+use bincode::{Decode, Encode};
+use paste::paste;
+
+pub trait RequestDefinition: Encode {
+    type Response: ResponseDefinition;
+    fn into_request(self) -> IPCRequest;
+}
+
+pub trait ResponseDefinition: Sized + Decode {
+    type Error: std::error::Error;
+    fn from_response(response: IPCResponse) -> Option<Result<Self, Self::Error>>;
+    fn into_response(self) -> IPCResponse;
+}
+
+macro_rules! define {
+    ($(@$name:ident { $($req_n:ident: $req_t:ty),* } => { $($resp_n:ident: $resp_t:ty)* } | $($error:ident),*)*) => {
+        $(
+            paste! {
+                #[derive(Encode, Decode)]
+                pub struct [<$name Request>] { $(pub $req_n: $req_t),* }
+                impl RequestDefinition for [<$name Request>] {
+                    type Response = [<$name Response>];
+                    fn into_request(self) -> IPCRequest {
+                        IPCRequest::$name(self)
+                    }
+                }
+
+                #[derive(Encode, Decode)]
+                pub struct [<$name Response>] { $(pub $resp_n: $resp_t),* }
+                impl ResponseDefinition for [<$name Response>] {
+                    type Error = [<$name Error>];
+                    fn from_response(response: IPCResponse) -> Option<Result<Self, Self::Error>> {
+                        match response {
+                            IPCResponse::$name(inner) => Some(inner),
+                            _ => None,
+                        }
+                    }
+                    fn into_response(self) -> IPCResponse {
+                        IPCResponse::$name(Ok(self))
+                    }
+                }
+
+                #[derive(Encode, Decode, Debug)]
+                pub enum [<$name Error>] {
+                    $($error,)*
+                }
+
+                impl Display for [<$name Error>] {
+                    #[allow(unused)]
+                    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+                        match *self {
+                            $(Self::$error => f.write_str(stringify!($error)),)*
+                        }
+                    }
+                }
+
+                impl std::error::Error for [<$name Error>] {}
+            }
+        )*
+        #[derive(Encode, Decode)]
+        pub enum IPCRequest {
+            $(
+                $name(paste!([<$name Request>])),
+            )*
+        }
+        #[derive(Encode, Decode)]
+        pub enum IPCResponse {
+            $(
+                $name(Result<paste!([<$name Response>]), paste!([<$name Error>])>),
+            )*
+        }
+    };
+}
+
+define! {
+    @ListAccounts {} => { names: Vec<String> } |
+    @GetAccount { name: String } => { scopes: Vec<String> } | NotFound
+    @DeleteAccount { name: String } => {} |
+    @CreateInvite {} => { link: String } |
+    @UpdateScopes { account: String, scopes: Vec<String> } => {} | NotFound
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..936f37c
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,12 @@
+mod ipc;
+mod server;
+mod tui;
+
+#[tokio::main]
+async fn main() -> eyre::Result<()> {
+    match std::env::args().skip(1).next().as_deref() {
+        Some("serve") | None => server::serve().await,
+        Some("ctl") => tui::run(),
+        _ => Err(eyre::eyre!("unknown command")),
+    }
+}
diff --git a/src/server/admin.rs b/src/server/admin.rs
new file mode 100644
index 0000000..54bdf12
--- /dev/null
+++ b/src/server/admin.rs
@@ -0,0 +1,93 @@
+use std::{future::Future, path::Path};
+
+use tap::Pipe;
+use tokio::{
+    io::{AsyncReadExt, AsyncWriteExt},
+    net::{UnixListener, UnixStream},
+};
+
+use crate::ipc::*;
+
+use super::store::Store;
+
+pub fn serve(
+    bind: &Path,
+    web_base: String,
+    store: Store,
+) -> eyre::Result<impl Future<Output = eyre::Result<()>>> {
+    if bind.exists() {
+        std::fs::remove_file(bind)?;
+    }
+    let listener = UnixListener::bind(bind)?;
+
+    Ok(async move {
+        loop {
+            match listener.accept().await {
+                Ok((stream, _)) => tokio::spawn({
+                    let store = store.clone();
+                    let web_base = web_base.clone();
+                    async move {
+                        if let Err(err) = handle(stream, store, &web_base).await {
+                            eprintln!("error handling admin connection: {err:?}");
+                        }
+                    }
+                }),
+                Err(err) => {
+                    eprintln!("error accepting admin connection: {err:?}");
+                    continue;
+                }
+            };
+        }
+    })
+}
+
+async fn handle(mut stream: UnixStream, store: Store, web_base: &str) -> eyre::Result<()> {
+    while let Ok(request_length) = stream.read_u16().await {
+        let mut request_buffer = vec![0u8; request_length as usize];
+        stream.read_exact(&mut request_buffer).await?;
+        let (request, _): (IPCRequest, _) =
+            bincode::decode_from_slice(&request_buffer, bincode::config::standard())?;
+        let response = match request {
+            IPCRequest::ListAccounts(_) => ListAccountsResponse {
+                names: store.list_accounts().await,
+            }
+            .into_response(),
+            IPCRequest::GetAccount(GetAccountRequest { name }) => {
+                IPCResponse::GetAccount(match store.get_account(&name).await {
+                    Some(account) => Ok(GetAccountResponse {
+                        scopes: account.scopes.clone(),
+                    }),
+                    None => Err(GetAccountError::NotFound),
+                })
+            }
+            IPCRequest::DeleteAccount(DeleteAccountRequest { name }) => {
+                store.delete_account(&name).await?;
+                DeleteAccountResponse {}.into_response()
+            }
+            IPCRequest::CreateInvite(_) => store
+                .create_invite()
+                .await?
+                .pipe(|invite| CreateInviteResponse {
+                    link: format!("{web_base}/invite/{invite}"),
+                })
+                .into_response(),
+            IPCRequest::UpdateScopes(UpdateScopesRequest { account, scopes }) => {
+                IPCResponse::UpdateScopes(
+                    match store
+                        .update_account(&account, |account| {
+                            account.scopes = scopes;
+                        })
+                        .await?
+                    {
+                        true => Ok(UpdateScopesResponse {}),
+                        false => Err(UpdateScopesError::NotFound),
+                    },
+                )
+            }
+        };
+        let response_buffer = bincode::encode_to_vec(response, bincode::config::standard())?;
+        stream.write_u16(response_buffer.len() as u16).await?;
+        stream.write_all(&response_buffer).await?;
+    }
+    Ok(())
+}
diff --git a/src/server/config.rs b/src/server/config.rs
new file mode 100644
index 0000000..4563f34
--- /dev/null
+++ b/src/server/config.rs
@@ -0,0 +1,29 @@
+use std::net::SocketAddr;
+use std::path::{Path, PathBuf};
+
+use eyre::Context;
+use tap::Pipe;
+
+#[derive(serde::Deserialize)]
+pub struct Config {
+    pub web_socket: SocketAddr,
+    pub web_base: String,
+    pub cookie_domain: Option<String>,
+    pub admin_socket: PathBuf,
+    pub data: PathBuf,
+}
+
+impl Config {
+    pub fn load(path: &Path) -> eyre::Result<Self> {
+        let mut config: Config = path
+            .pipe(std::fs::read_to_string)
+            .wrap_err("reading config file")?
+            .as_str()
+            .pipe(toml::from_str)
+            .wrap_err("parsing config file")?;
+        if config.web_base.ends_with('/') {
+            config.web_base = config.web_base.trim_end_matches('/').to_string();
+        }
+        Ok(config)
+    }
+}
diff --git a/src/server/login.rs b/src/server/login.rs
new file mode 100644
index 0000000..f9cee70
--- /dev/null
+++ b/src/server/login.rs
@@ -0,0 +1,208 @@
+use std::time::Duration;
+
+use argon2::{Argon2, PasswordVerifier};
+use axum::{
+    extract::{Path, State},
+    http::StatusCode,
+    response::{IntoResponse, Redirect, Response},
+    routing::get,
+    Form, Router,
+};
+use axum_extra::extract::{
+    cookie::{Cookie, SameSite},
+    CookieJar,
+};
+use maud::{html, PreEscaped};
+use tap::Tap;
+
+use crate::server::store::Store;
+
+use super::{render_html, ApiState, CookieDomain, MakeError, MakeErrorMessage};
+
+pub fn bind(app: Router<ApiState>) -> Router<ApiState> {
+    app.route("/login", get(handle_login_get).post(handle_login_post))
+        .route("/logout", get(logout))
+        .route(
+            "/invite/:invite",
+            get(handle_invite_get).post(handle_invite_post),
+        )
+}
+
+fn make_form(status: StatusCode, error: Option<&str>, submit: &str) -> Response {
+    render_html(
+        html!(title {"login"}),
+        html! {
+            @if let Some(error) = error {
+                p { "error: " (PreEscaped(error)) }
+            }
+            form action="" method="post" {
+                label for="name" { "name:" }
+                input type="text" id="name" name="name" {}
+                label for="password" { "password:" }
+                input type="password" id="password" name="password" {}
+                input type="submit" value=(submit) {}
+            }
+        },
+    )
+    .tap_mut(|response| *response.status_mut() = status)
+}
+
+#[axum::debug_handler]
+async fn handle_login_get() -> Response {
+    make_form(StatusCode::OK, None, "login")
+}
+
+#[derive(serde::Deserialize)]
+struct FormData {
+    name: String,
+    password: String,
+}
+
+#[axum::debug_handler(state = ApiState)]
+async fn handle_login_post(
+    jar: CookieJar,
+    State(store): State<Store>,
+    State(CookieDomain(cookie_domain)): State<CookieDomain>,
+    Form(form): Form<FormData>,
+) -> Result<Response, Response> {
+    // read lock scope
+    {
+        let account = store.get_account(&form.name).await.ok_or_else(|| {
+            make_form(StatusCode::FORBIDDEN, Some("invalid credentials"), "login")
+        })?;
+        Argon2::default()
+            .verify_password(form.password.as_bytes(), &account.password.parsed())
+            .ok()
+            .ok_or_else(|| {
+                make_form(StatusCode::FORBIDDEN, Some("invalid credentials"), "login")
+            })?;
+    }
+    let token = store
+        .create_token(&form.name)
+        .await
+        .make_error()?
+        .error_message(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            "account disappeared while generating token",
+        )?;
+
+    let mut cookie = Cookie::new("dissociate-token", token);
+    cookie.set_http_only(true);
+    cookie.set_max_age(Duration::from_secs(60 * 60 * 24 * 29).try_into().ok());
+    cookie.set_path("/");
+    cookie.set_same_site(SameSite::Strict);
+    cookie.set_secure(true);
+    if let Some(cookie_domain) = cookie_domain {
+        cookie.set_domain(cookie_domain);
+    }
+
+    Ok((jar.add(cookie), Redirect::to("/")).into_response())
+}
+
+#[axum::debug_handler]
+async fn logout(jar: CookieJar) -> Response {
+    (
+        jar.remove(Cookie::from("dissociate-token")),
+        Redirect::to("/login"),
+    )
+        .into_response()
+}
+
+fn invite_error() -> Response {
+    render_html(
+        html!(),
+        html! {
+            h1 { "unknown invite" }
+            a href="/" { "return home" }
+        },
+    )
+    .tap_mut(|response| *response.status_mut() = StatusCode::NOT_FOUND)
+}
+
+#[axum::debug_handler]
+async fn handle_invite_get(Path(invite): Path<String>, State(store): State<Store>) -> Response {
+    if !store.check_invite(&invite).await {
+        return invite_error();
+    }
+    make_form(StatusCode::OK, None, "create account")
+}
+
+const NAME_ERROR: &str = "invalid name
+requirements:
+must not be empty
+must not exceed 32 characters
+must not contain control characters";
+#[axum::debug_handler(state = ApiState)]
+async fn handle_invite_post(
+    jar: CookieJar,
+    Path(invite): Path<String>,
+    State(store): State<Store>,
+    State(CookieDomain(cookie_domain)): State<CookieDomain>,
+    Form(form): Form<FormData>,
+) -> Result<Response, Response> {
+    if form.name.to_ascii_lowercase() == "empty" || form.password.to_ascii_lowercase() == "empty" {
+        return Err(make_form(
+            StatusCode::BAD_REQUEST,
+            Some("are you jokester?"),
+            "create account",
+        ));
+    }
+    if form.name.is_empty() || form.name.len() > 32 || form.name.chars().any(|ch| ch.is_control()) {
+        return Err(make_form(
+            StatusCode::BAD_REQUEST,
+            Some(&NAME_ERROR.replace('\n', "<br>")),
+            "create account",
+        ));
+    }
+    if form.password.is_empty() {
+        return Err(make_form(
+            StatusCode::BAD_REQUEST,
+            Some("password cannot be empty"),
+            "create account",
+        ));
+    }
+
+    if store.get_account(&form.name).await.is_some() {
+        return Err(make_form(
+            StatusCode::CONFLICT,
+            Some("name taken"),
+            "create account",
+        ));
+    }
+
+    if !store.use_invite(&invite).await.make_error()? {
+        return Err(invite_error());
+    }
+
+    if !store
+        .create_account(&form.name, &form.password)
+        .await
+        .make_error()?
+    {
+        return Err(make_form(
+            StatusCode::CONFLICT,
+            Some("name taken"),
+            "create account",
+        ));
+    }
+    let token = store
+        .create_token(&form.name)
+        .await
+        .make_error()?
+        .error_message(
+            StatusCode::INTERNAL_SERVER_ERROR,
+            "account disappeared while generating token",
+        )?;
+
+    let mut cookie = Cookie::new("dissociate-token", token);
+    cookie.set_http_only(true);
+    cookie.set_max_age(Duration::from_secs(60 * 60 * 24 * 29).try_into().ok());
+    cookie.set_path("/");
+    cookie.set_same_site(SameSite::Strict);
+    cookie.set_secure(true);
+    if let Some(cookie_domain) = cookie_domain {
+        cookie.set_domain(cookie_domain);
+    }
+
+    Ok((jar.add(cookie), Redirect::to("/")).into_response())
+}
diff --git a/src/server/mod.rs b/src/server/mod.rs
new file mode 100644
index 0000000..d9b3beb
--- /dev/null
+++ b/src/server/mod.rs
@@ -0,0 +1,160 @@
+mod admin;
+mod config;
+mod login;
+mod nginx_check;
+mod panel;
+mod store;
+
+use std::{future::IntoFuture, path::PathBuf};
+
+use axum::{
+    body::Body,
+    extract::FromRef,
+    http::{header::CONTENT_TYPE, StatusCode},
+    response::{IntoResponse, Redirect, Response},
+    routing::get,
+    Router,
+};
+use axum_extra::extract::CookieJar;
+use eyre::Context;
+use maud::{html, PreEscaped};
+use tap::Pipe;
+use tokio::{net::TcpListener, select};
+
+use crate::server::store::Store;
+
+use self::config::Config;
+
+pub async fn serve() -> eyre::Result<()> {
+    let config_path = std::env::args()
+        .skip(2)
+        .next()
+        .map(PathBuf::from)
+        .unwrap_or_else(|| std::env::current_dir().unwrap().join("dissociate.toml"));
+    let config = Config::load(&config_path)?;
+
+    let store = Store::load(config.data).await?;
+    println!("admin listening on {:?}", config.admin_socket);
+    let admin_serve = admin::serve(&config.admin_socket, config.web_base.clone(), store.clone())?;
+
+    let listener = TcpListener::bind(config.web_socket).await?;
+
+    let app = Router::new()
+        .pipe(login::bind)
+        .pipe(nginx_check::bind)
+        .pipe(panel::bind)
+        .with_state(ApiState {
+            store,
+            cookie_domain: CookieDomain(config.cookie_domain),
+            web_base: WebBase(config.web_base),
+        })
+        .fallback(get(|| async {
+            render_html(
+                html!(title { "not found" }),
+                html! {
+                    h1 { "404 not found" }
+                    p {"sowwy :("}
+                },
+            )
+        }));
+
+    println!("web listening on {:?}", config.web_socket);
+    let web_serve = axum::serve(listener, app).into_future();
+
+    select! {
+        res = admin_serve => res.wrap_err("in admin"),
+        res = web_serve => res.wrap_err("in web"),
+    }
+}
+
+#[derive(Clone, FromRef)]
+struct ApiState {
+    pub store: Store,
+    pub cookie_domain: CookieDomain,
+    pub web_base: WebBase,
+}
+
+#[derive(Clone)]
+struct CookieDomain(Option<String>);
+
+#[derive(Clone)]
+struct WebBase(String);
+
+fn render_html(head: PreEscaped<impl AsRef<str>>, body: PreEscaped<impl AsRef<str>>) -> Response {
+    let html = html! {
+        (PreEscaped("<!doctype html>"))
+        html {
+            head {(head)}
+            body {(body)}
+        }
+    }
+    .into_string();
+    Response::builder()
+        .header(CONTENT_TYPE, "text/html; charset=utf-8")
+        .body(Body::new(html))
+        .unwrap()
+}
+
+trait MakeErrorMessage<T> {
+    fn error_message(self, status: StatusCode, message: impl ToString) -> Result<T, Response>;
+}
+
+impl<T, E: ToString> MakeErrorMessage<T> for Result<T, E> {
+    fn error_message(self, status: StatusCode, message: impl ToString) -> Result<T, Response> {
+        self.map_err(|err| {
+            render_html(
+                html!(title { "error" }),
+                html! {
+                    h1 { (status.canonical_reason().unwrap_or_else(|| status.as_str())) }
+                    pre { (err.to_string()) ": " (message.to_string()) }
+                },
+            )
+        })
+    }
+}
+
+impl<T> MakeErrorMessage<T> for Option<T> {
+    fn error_message(self, status: StatusCode, message: impl ToString) -> Result<T, Response> {
+        self.ok_or_else(|| {
+            render_html(
+                html!(title { "error" }),
+                html! {
+                    h1 { (status.canonical_reason().unwrap_or_else(|| status.as_str())) }
+                    pre { (message.to_string()) }
+                },
+            )
+        })
+    }
+}
+
+trait MakeError<T> {
+    fn make_error(self) -> Result<T, Response>;
+}
+
+impl<T> MakeError<T> for std::io::Result<T> {
+    fn make_error(self) -> Result<T, Response> {
+        self.error_message(StatusCode::INTERNAL_SERVER_ERROR, "internal io error")
+    }
+}
+
+trait Nevermind<T> {
+    fn prompt_login(self) -> Result<T, Response>;
+    fn prompt_logout(self) -> Result<T, Response>;
+}
+
+impl<T> Nevermind<T> for Option<T> {
+    fn prompt_login(self) -> Result<T, Response> {
+        self.ok_or_else(|| Redirect::to("/login").into_response())
+    }
+
+    fn prompt_logout(self) -> Result<T, Response> {
+        self.ok_or_else(|| Redirect::to("/login").into_response())
+    }
+}
+
+async fn account_auth(jar: &CookieJar, store: &Store) -> Option<String> {
+    let cookie = jar.get("dissociate-token")?;
+    let token = cookie.value();
+    let name = store.check_token(token).await?;
+    Some(name)
+}
diff --git a/src/server/nginx_check.rs b/src/server/nginx_check.rs
new file mode 100644
index 0000000..7b67f26
--- /dev/null
+++ b/src/server/nginx_check.rs
@@ -0,0 +1,41 @@
+// for ngx_http_auth_request_module authentication
+// make sure you have cookie_domain set properly
+// depends on https://git.mia.jetzt/sysconf/tree/patches/nginx_auth_redirect.patch
+
+use axum::{
+    extract::{Path, State},
+    http::StatusCode,
+    response::{IntoResponse, Redirect, Response},
+    routing::get,
+    Router,
+};
+use axum_extra::extract::CookieJar;
+
+use crate::server::{account_auth, store::Store};
+
+use super::{ApiState, WebBase};
+
+pub fn bind(app: Router<ApiState>) -> Router<ApiState> {
+    app.route("/nginx_check/:scope", get(nginx_check))
+}
+
+#[axum::debug_handler(state = ApiState)]
+async fn nginx_check(
+    jar: CookieJar,
+    Path(scope): Path<String>,
+    State(store): State<Store>,
+    State(WebBase(web_base)): State<WebBase>,
+) -> Response {
+    let nevermind = || Redirect::to(&format!("{web_base}/logout")).into_response();
+    let Some(name) = account_auth(&jar, &store).await else {
+        return nevermind();
+    };
+    let Some(account) = store.get_account(&name).await else {
+        return nevermind();
+    };
+    if account.scopes.contains(&scope) {
+        StatusCode::OK.into_response()
+    } else {
+        StatusCode::FORBIDDEN.into_response()
+    }
+}
diff --git a/src/server/panel.rs b/src/server/panel.rs
new file mode 100644
index 0000000..addb0d8
--- /dev/null
+++ b/src/server/panel.rs
@@ -0,0 +1,30 @@
+use axum::{extract::State, response::Response, routing::get, Router};
+use axum_extra::extract::CookieJar;
+use maud::html;
+use tap::Pipe;
+
+use crate::server::{store::Store, Nevermind};
+
+use super::{account_auth, render_html, ApiState};
+
+pub fn bind(app: Router<ApiState>) -> Router<ApiState> {
+    app.route("/", get(get_panel))
+}
+
+#[axum::debug_handler(state = ApiState)]
+async fn get_panel(jar: CookieJar, State(store): State<Store>) -> Result<Response, Response> {
+    Ok(account_auth(&jar, &store)
+        .await
+        .prompt_login()?
+        .pipe(render_normal_panel))
+}
+
+fn render_normal_panel(name: String) -> Response {
+    render_html(
+        html!(title { "dissociate" }),
+        html! {
+            p { "currently logged in as " (name) }
+            a href="/logout" { button { "log out" } }
+        },
+    )
+}
diff --git a/src/server/store.rs b/src/server/store.rs
new file mode 100644
index 0000000..98c1bcc
--- /dev/null
+++ b/src/server/store.rs
@@ -0,0 +1,330 @@
+use std::{
+    collections::HashMap,
+    path::PathBuf,
+    sync::Arc,
+    time::{Duration, SystemTime, UNIX_EPOCH},
+};
+
+use argon2::{
+    password_hash::{rand_core::OsRng, Encoding, PasswordHashString, SaltString},
+    Argon2, PasswordHash, PasswordHasher,
+};
+use nanoid::nanoid;
+use serde::{de::Unexpected, Deserialize, Serialize};
+use serde_json::{Map, Value};
+use tap::{Pipe, Tap};
+use tokio::sync::{RwLock, RwLockReadGuard};
+
+#[derive(Clone)]
+pub struct Store(Arc<RwLock<StoreInner>>);
+
+impl Store {
+    pub async fn load(path: PathBuf) -> eyre::Result<Self> {
+        let mut inner = StoreInner::new(path.clone());
+        if path.try_exists()? {
+            let mut map: Map<String, Value> =
+                { std::fs::File::open(&path)?.pipe(serde_json::from_reader)? };
+            let accounts: Vec<Account> =
+                serde_json::from_value(map.remove("accounts").unwrap_or_default())?;
+            let apps: Vec<App> = serde_json::from_value(map.remove("apps").unwrap_or_default())?;
+            for mut account in accounts {
+                account.tokens = account
+                    .tokens
+                    .into_iter()
+                    .filter(|token| token.expires > SystemTime::now())
+                    .collect();
+                for token in &account.tokens {
+                    inner.token_map.insert(
+                        token.value.clone(),
+                        (account.name.clone(), token.expires.clone()),
+                    );
+                }
+                inner.accounts.insert(account.name.clone(), account);
+            }
+            for app in apps {
+                inner.apps.insert(app.name.clone(), app);
+            }
+            inner.invites = serde_json::from_value(map.remove("invites").unwrap_or_default())?;
+            inner.invites = inner
+                .invites
+                .into_iter()
+                .filter(|invite| invite.expires > SystemTime::now())
+                .collect();
+        } else {
+            Self::save(&mut inner).await?;
+        }
+        Ok(Store(Arc::new(RwLock::new(inner))))
+    }
+
+    async fn save(inner: &mut StoreInner) -> std::io::Result<()> {
+        let mut map: Map<String, Value> = Map::new();
+        map.insert(
+            "accounts".into(),
+            serde_json::to_value(inner.accounts.values().collect::<Vec<_>>()).unwrap(),
+        );
+        map.insert(
+            "apps".into(),
+            serde_json::to_value(inner.apps.values().collect::<Vec<_>>()).unwrap(),
+        );
+        map.insert(
+            "invites".into(),
+            serde_json::to_value(&inner.invites).unwrap(),
+        );
+        let data = serde_json::to_vec_pretty(&map).unwrap();
+        let mut temp = inner.path.clone();
+        temp.set_file_name(
+            temp.file_name()
+                .unwrap_or_default()
+                .to_os_string()
+                .tap_mut(|name| name.push(".tmp")),
+        );
+        tokio::fs::write(&temp, data).await?;
+        tokio::fs::rename(temp, &inner.path).await?;
+        Ok(())
+    }
+
+    pub async fn get_account(&self, name: &str) -> Option<RwLockReadGuard<'_, Account>> {
+        let guard = self.0.read().await;
+        RwLockReadGuard::try_map(guard, |guard| guard.accounts.get(name)).ok()
+    }
+
+    pub async fn create_account(&self, name: &str, password: &str) -> std::io::Result<bool> {
+        let hash = Argon2::default()
+            .hash_password(password.as_bytes(), &SaltString::generate(&mut OsRng))
+            .unwrap()
+            .pipe(|hash| hash.serialize())
+            .pipe(OwnedPasswordHash::from);
+        let mut guard = self.0.write().await;
+        if guard.accounts.get(name).is_some() {
+            return Ok(false);
+        }
+        guard.accounts.insert(
+            name.to_string(),
+            Account {
+                name: name.to_string(),
+                password: hash,
+                tokens: Default::default(),
+                scopes: Default::default(),
+            },
+        );
+        Self::save(&mut guard).await?;
+        Ok(true)
+    }
+
+    pub async fn delete_account(&self, name: &str) -> std::io::Result<()> {
+        let mut guard = self.0.write().await;
+        if let Some(account) = guard.accounts.remove(name) {
+            for token in account.tokens {
+                guard.token_map.remove(&token.value);
+            }
+            Self::save(&mut guard).await
+        } else {
+            Ok(())
+        }
+    }
+
+    pub async fn update_account(
+        &self,
+        name: &str,
+        with: impl FnOnce(&mut Account),
+    ) -> std::io::Result<bool> {
+        let mut guard = self.0.write().await;
+        if let Some(account) = guard.accounts.get_mut(name) {
+            with(account);
+            Self::save(&mut guard).await.map(|_| true)
+        } else {
+            Ok(false)
+        }
+    }
+
+    pub async fn list_accounts(&self) -> Vec<String> {
+        self.0
+            .read()
+            .await
+            .accounts
+            .values()
+            .map(|account| account.name.clone())
+            .collect()
+    }
+
+    pub async fn create_token(&self, name: &str) -> std::io::Result<Option<String>> {
+        let mut guard = self.0.write().await;
+        let token = nanoid!(32);
+        if let Some(account) = guard.accounts.get_mut(name) {
+            let expires = SystemTime::now() + Duration::from_secs(60 * 60 * 24 * 30);
+            account.tokens.push(ExpiringValue {
+                value: token.clone(),
+                expires: expires.clone(),
+            });
+            guard
+                .token_map
+                .insert(token.clone(), (name.to_string(), expires));
+            Self::save(&mut guard).await?;
+            Ok(Some(token))
+        } else {
+            Ok(None)
+        }
+    }
+
+    pub async fn check_token(&self, token: &str) -> Option<String> {
+        let guard = self.0.read().await;
+        let Some((name, expires)) = guard.token_map.get(token) else {
+            return None;
+        };
+        if *expires < SystemTime::now() {
+            return None;
+        }
+        Some(name.clone())
+    }
+
+    pub async fn create_invite(&self) -> std::io::Result<String> {
+        let mut guard = self.0.write().await;
+        let invite = nanoid!(32);
+        let expires = SystemTime::now() + Duration::from_secs(60 * 60 * 24 * 14);
+        guard.invites.push(ExpiringValue {
+            value: invite.clone(),
+            expires,
+        });
+        Self::save(&mut guard).await?;
+        Ok(invite)
+    }
+
+    pub async fn check_invite(&self, invite: &str) -> bool {
+        let guard = self.0.read().await;
+        let now = SystemTime::now();
+        guard
+            .invites
+            .iter()
+            .any(|check| check.expires > now && check.value == invite)
+    }
+
+    pub async fn use_invite(&self, invite: &str) -> std::io::Result<bool> {
+        let mut guard = self.0.write().await;
+        let now = SystemTime::now();
+        if let Some((index, _)) = guard
+            .invites
+            .iter()
+            .enumerate()
+            .find(|(_, check)| check.expires > now && check.value == invite)
+        {
+            guard.invites.swap_remove(index);
+            Self::save(&mut guard).await?;
+            Ok(true)
+        } else {
+            Ok(false)
+        }
+    }
+}
+
+pub struct StoreInner {
+    path: PathBuf,
+    accounts: HashMap<String, Account>,
+    apps: HashMap<String, App>,
+    invites: Vec<ExpiringValue>,
+    token_map: HashMap<String, (String, SystemTime)>,
+}
+
+impl StoreInner {
+    fn new(at: PathBuf) -> Self {
+        Self {
+            path: at,
+            accounts: Default::default(),
+            apps: Default::default(),
+            invites: Default::default(),
+            token_map: Default::default(),
+        }
+    }
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+pub struct Account {
+    pub name: String,
+    pub password: OwnedPasswordHash,
+    pub tokens: Vec<ExpiringValue>,
+    pub scopes: Vec<String>,
+}
+
+#[ouroboros::self_referencing]
+pub struct OwnedPasswordHash {
+    owned: PasswordHashString,
+    #[borrows(owned)]
+    #[not_covariant]
+    parsed: PasswordHash<'this>,
+}
+
+impl OwnedPasswordHash {
+    pub fn from(inner: PasswordHashString) -> Self {
+        OwnedPasswordHash::new(inner, |inner| inner.password_hash())
+    }
+
+    pub fn parsed(&self) -> PasswordHash {
+        self.with_parsed(|x| x.clone())
+    }
+}
+
+impl Serialize for OwnedPasswordHash {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        self.with_owned(|owned| owned.as_str().serialize(serializer))
+    }
+}
+
+impl<'de> Deserialize<'de> for OwnedPasswordHash {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        Ok(String::deserialize(deserializer)?
+            .pipe(|hash| {
+                PasswordHashString::parse(&hash, Encoding::B64).map_err(|_| {
+                    <D::Error as serde::de::Error>::invalid_value(
+                        Unexpected::Str(&hash),
+                        &"valid password hash",
+                    )
+                })
+            })?
+            .pipe(OwnedPasswordHash::from))
+    }
+}
+
+pub struct ExpiringValue {
+    pub value: String,
+    pub expires: SystemTime,
+}
+
+impl Serialize for ExpiringValue {
+    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
+    where
+        S: serde::Serializer,
+    {
+        <(&str, u64)>::serialize(
+            &(
+                &self.value,
+                (self.expires.duration_since(SystemTime::UNIX_EPOCH))
+                    .unwrap()
+                    .as_secs(),
+            ),
+            serializer,
+        )
+    }
+}
+
+impl<'de> Deserialize<'de> for ExpiringValue {
+    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        <(String, u64)>::deserialize(deserializer).map(|(token, unix)| ExpiringValue {
+            value: token,
+            expires: UNIX_EPOCH + Duration::from_secs(unix),
+        })
+    }
+}
+
+#[derive(serde::Serialize, serde::Deserialize)]
+pub struct App {
+    name: String,
+    secret: String,
+}
diff --git a/src/tui/accounts.rs b/src/tui/accounts.rs
new file mode 100644
index 0000000..a43028c
--- /dev/null
+++ b/src/tui/accounts.rs
@@ -0,0 +1,169 @@
+use std::{cell::RefCell, rc::Rc};
+
+use cursive::{
+    view::Nameable,
+    views::{Button, Dialog, LinearLayout, SelectView, TextArea},
+    Cursive,
+};
+
+use crate::ipc::{
+    DeleteAccountRequest, GetAccountRequest, ListAccountsRequest, UpdateScopesRequest,
+};
+
+use super::{CursiveIpc, ToTextView, ViewExt};
+
+pub fn show(siv: &mut Cursive) {
+    if let Some(accounts) = siv.ipc(ListAccountsRequest {}) {
+        siv.add_layer(
+            SelectView::new()
+                .autojump()
+                .with_all_str(accounts.names)
+                .on_submit(|siv, name: &str| {
+                    manage_account(siv, name);
+                })
+                .float("accounts"),
+        );
+    }
+}
+
+fn manage_account(siv: &mut Cursive, name: &str) {
+    let name = name.to_string();
+    if let Some(_) = siv.ipc(GetAccountRequest { name: name.clone() }) {
+        siv.add_layer(
+            LinearLayout::vertical()
+                .child({
+                    let name = name.clone();
+                    enum SelectOption {
+                        Back,
+                        Scopes,
+                        Delete,
+                    }
+                    SelectView::new()
+                        .item("back", SelectOption::Back)
+                        .item("scopes", SelectOption::Scopes)
+                        .item("delete", SelectOption::Delete)
+                        .on_submit(move |siv, opt| match opt {
+                            SelectOption::Back => {
+                                siv.pop_layer();
+                            }
+                            SelectOption::Scopes => edit_scopes(siv, &name),
+                            SelectOption::Delete => delete_account(siv, &name),
+                        })
+                })
+                .float(&name),
+        )
+    }
+}
+
+fn edit_scopes(siv: &mut Cursive, name: &str) {
+    let name = name.to_string();
+    let Some(account) = siv.ipc(GetAccountRequest { name: name.clone() }) else {
+        siv.pop_layer();
+        return; // it was just there!
+    };
+    let scopes = Rc::new(RefCell::new(account.scopes));
+    enum SelectOption {
+        Scope(String),
+        Back,
+        Add,
+    }
+    siv.add_layer(
+        SelectView::new()
+            .autojump()
+            .item("back", SelectOption::Back)
+            .item("add", SelectOption::Add)
+            .with_all(
+                scopes
+                    .clone()
+                    .borrow()
+                    .iter()
+                    .map(|scope| (scope.to_string(), SelectOption::Scope(scope.clone()))),
+            )
+            .on_submit(move |siv, opt| {
+                match opt {
+                    SelectOption::Scope(scope) => {
+                        let mut scopes = scopes.borrow_mut();
+                        let index = scopes
+                            .iter()
+                            .enumerate()
+                            .find(|(_, check)| *check == scope)
+                            .unwrap()
+                            .0;
+                        scopes.swap_remove(index);
+                        ipc_update_scopes(siv, &name, &scopes);
+                        siv.pop_layer();
+                        edit_scopes(siv, &name);
+                    }
+                    SelectOption::Back => {
+                        siv.pop_layer();
+                    }
+                    SelectOption::Add => add_scope(siv, name.clone(), scopes.clone()),
+                };
+            })
+            .float("edit scopes"),
+    );
+}
+
+fn add_scope(siv: &mut Cursive, name: String, scopes: Rc<RefCell<Vec<String>>>) {
+    siv.add_layer(
+        LinearLayout::vertical()
+            .child(TextArea::new().with_name("scope"))
+            .child(Button::new("add", {
+                move |siv| {
+                    let text_area = siv.find_name::<TextArea>("scope").unwrap();
+                    let scope = text_area.get_content();
+                    if scope.is_empty() {
+                        siv.pop_layer();
+                        return;
+                    }
+                    let mut scopes = scopes.borrow_mut();
+                    scopes.push(scope.to_string());
+                    ipc_update_scopes(siv, &name, &scopes);
+                    siv.pop_layer();
+                    siv.pop_layer();
+                    edit_scopes(siv, &name);
+                }
+            }))
+            .float("add scope"),
+    )
+}
+
+fn ipc_update_scopes(siv: &mut Cursive, name: &str, scopes: &Vec<String>) {
+    siv.ipc(UpdateScopesRequest {
+        account: name.to_string(),
+        scopes: scopes.clone(),
+    });
+}
+
+fn delete_account(siv: &mut Cursive, name: &str) {
+    let name = name.to_string();
+    siv.add_layer(
+        LinearLayout::vertical()
+            .child(format!("delete {name}?").text_view())
+            .child(TextArea::new().with_name("confirm"))
+            .child(Button::new("submit", move |siv| {
+                if let Some(confirm) = siv.find_name::<TextArea>("confirm") {
+                    if confirm.get_content() == "yes" {
+                        if siv
+                            .ipc(DeleteAccountRequest { name: name.clone() })
+                            .is_some()
+                        {
+                            siv.add_layer(Dialog::around("success".text_view()).button(
+                                "ok",
+                                |siv| {
+                                    // all the way back to the root
+                                    siv.pop_layer();
+                                    siv.pop_layer();
+                                    siv.pop_layer();
+                                    siv.pop_layer();
+                                },
+                            ))
+                        }
+                    } else {
+                        siv.pop_layer();
+                    }
+                }
+            }))
+            .float("are you sure"),
+    );
+}
diff --git a/src/tui/mod.rs b/src/tui/mod.rs
new file mode 100644
index 0000000..7c748c1
--- /dev/null
+++ b/src/tui/mod.rs
@@ -0,0 +1,159 @@
+mod accounts;
+
+use std::{
+    io::{Read, Write},
+    os::unix::net::UnixStream,
+};
+
+use cursive::{
+    event::Key,
+    theme::{BorderStyle, Palette, Theme},
+    utils::markup::StyledString,
+    view::Margins,
+    views::{Dialog, PaddedView, Panel, ScrollView, SelectView, TextView},
+    Cursive, CursiveExt,
+};
+use eyre::ContextCompat;
+
+use crate::ipc::{CreateInviteRequest, IPCResponse, RequestDefinition, ResponseDefinition};
+
+pub fn run() -> eyre::Result<()> {
+    let Some(socket_path) = std::env::args().skip(2).next() else {
+        eyre::bail!("must specify socket path");
+    };
+    let stream = UnixStream::connect(socket_path)?;
+    let ipc = Ipc(stream);
+
+    let mut siv = Cursive::new();
+    siv.set_user_data(ipc);
+
+    siv.set_theme(Theme {
+        shadow: false,
+        borders: BorderStyle::Simple,
+        palette: Palette::terminal_default(),
+    });
+
+    enum SelectItem {
+        Accounts,
+        Invite,
+    }
+    siv.add_layer(
+        SelectView::new()
+            .item("accounts", SelectItem::Accounts)
+            .item("invite", SelectItem::Invite)
+            .on_submit(move |siv, item| match item {
+                SelectItem::Accounts => accounts::show(siv),
+                SelectItem::Invite => invite(siv),
+            })
+            .float("dissociate"),
+    );
+
+    siv.add_global_callback(Key::Esc, |siv| {
+        if siv.pop_layer().is_none() || siv.screen().is_empty() {
+            siv.quit();
+        }
+    });
+
+    siv.run();
+
+    Ok(())
+}
+
+fn invite(siv: &mut Cursive) {
+    if let Some(invite) = siv.ipc(CreateInviteRequest {}) {
+        siv.add_layer(
+            Dialog::around(invite.link.text_view().hpad())
+                .button("ok", |siv| {
+                    siv.pop_layer();
+                })
+                .title("invite")
+                .pad(),
+        );
+    }
+}
+
+struct Ipc(UnixStream);
+
+impl Ipc {
+    fn exec<T: RequestDefinition>(
+        &mut self,
+        request: T,
+    ) -> eyre::Result<Result<T::Response, <T::Response as ResponseDefinition>::Error>> {
+        let request_buffer =
+            bincode::encode_to_vec(request.into_request(), bincode::config::standard())?;
+        self.0
+            .write_all(&(request_buffer.len() as u16).to_be_bytes())?;
+        self.0.write_all(&request_buffer)?;
+        let mut response_len_buffer = [0u8; 2];
+        self.0.read_exact(&mut response_len_buffer)?;
+        let response_len = u16::from_be_bytes(response_len_buffer) as usize;
+        let mut response_buffer = vec![0u8; response_len];
+        self.0.read_exact(&mut response_buffer)?;
+        let (response, _): (IPCResponse, _) =
+            bincode::decode_from_slice(&response_buffer, bincode::config::standard())?;
+        T::Response::from_response(response).wrap_err("got wrong response type")
+    }
+}
+
+trait CursiveIpc {
+    fn ipc<T: RequestDefinition>(&mut self, request: T) -> Option<T::Response>;
+}
+
+impl CursiveIpc for Cursive {
+    fn ipc<T: RequestDefinition>(&mut self, request: T) -> Option<T::Response> {
+        match self.user_data::<Ipc>().unwrap().exec(request) {
+            Ok(Ok(resp)) => Some(resp),
+            Err(err) => {
+                self.add_layer(
+                    Dialog::new()
+                        .content(format!("ipc error: {err:?}").text_view())
+                        .button("quit", |siv| siv.quit()),
+                );
+                None
+            }
+            Ok(Err(err)) => {
+                self.add_layer(
+                    Dialog::new()
+                        .content(format!("error: {err}").text_view())
+                        .button("ok", |siv| {
+                            siv.pop_layer();
+                        }),
+                );
+                None
+            }
+        }
+    }
+}
+
+trait ViewExt: Sized {
+    fn float(self, title: &str) -> PaddedView<Panel<ScrollView<PaddedView<Self>>>>;
+    fn pad(self) -> PaddedView<Self>;
+    fn hpad(self) -> PaddedView<Self>;
+}
+
+impl<V: Sized> ViewExt for V {
+    fn float(self, title: &str) -> PaddedView<Panel<ScrollView<PaddedView<Self>>>> {
+        PaddedView::new(
+            Margins::lrtb(1, 1, 1, 1),
+            Panel::new(ScrollView::new(PaddedView::new(Margins::lr(1, 1), self))).title(title),
+        )
+    }
+
+    fn pad(self) -> PaddedView<Self> {
+        PaddedView::new(Margins::lrtb(1, 1, 1, 1), self)
+    }
+
+    fn hpad(self) -> PaddedView<Self> {
+        PaddedView::new(Margins::lr(1, 1), self)
+    }
+}
+
+trait ToTextView {
+    fn text_view(self) -> TextView;
+}
+
+impl<S: Into<StyledString>> ToTextView for S {
+    fn text_view(self) -> TextView {
+        TextView::new(self)
+    }
+}