commit c37ca9ba52fe9bee8d06df249ba51be4b7302b89 Author: Bailey Taylor Date: Thu Feb 19 13:51:07 2026 +0800 First commit in rust diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..cb4daf0 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,11 @@ +{ + "permissions": { + "allow": [ + "Bash(git -C /c/Users/btaylor/Git/psg-backchannel log --oneline -10)", + "WebFetch(domain:crates.io)", + "Bash(cargo check:*)", + "Bash(echo:*)", + "Bash(~/.cargo/bin/cargo check:*)" + ] + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..49bf8c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +/target/ +**/*.db +**/*.db-shm +**/*.db-wal +.env +*.pem +*.key +*.crt +*.p12 +/backchannel-server/backchannel.db +/backchannel-web/node_modules/ +/backchannel-web/.vite/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2eae226 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3532 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[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 = "anyhow" +version = "1.0.101" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" + +[[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 = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "aws-lc-rs" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9a7b350e3bb1767102698302bc37256cbd48422809984b98d292c40e2579aa9" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.37.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b092fe214090261288111db7a2b2c2118e5a7f30dc2569f1732c4069a6840549" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +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 = "backchannel-client" +version = "0.1.0" +dependencies = [ + "anyhow", + "backchannel-common", + "base64", + "chacha20poly1305", + "chrono", + "crossterm", + "ed25519-dalek", + "futures-util", + "hkdf", + "rand", + "ratatui", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "tokio", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "uuid", + "x25519-dalek", +] + +[[package]] +name = "backchannel-common" +version = "0.1.0" +dependencies = [ + "base64", + "chacha20poly1305", + "chrono", + "ed25519-dalek", + "hkdf", + "rand", + "serde", + "serde_json", + "sha2", + "thiserror 1.0.69", + "uuid", + "x25519-dalek", +] + +[[package]] +name = "backchannel-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "backchannel-common", + "base64", + "chrono", + "ed25519-dalek", + "futures-util", + "jsonwebtoken", + "mime_guess", + "rand", + "rust-embed", + "rustls 0.23.36", + "rustls-pemfile", + "serde", + "serde_json", + "sqlx", + "thiserror 1.0.69", + "tokio", + "tokio-rustls 0.26.4", + "tokio-tungstenite", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[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.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c6f81257d10a0f602a294ae4182251151ff97dbb504ef9afcdda4a64b24d9b4" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "chrono" +version = "0.4.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + +[[package]] +name = "compact_str" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86b9c4c00838774a6d902ef931eff7470720c51d90c2e32cfe15dc304737b3f" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "ryu", + "static_assertions", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crossbeam-queue" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crossterm" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f476fe445d41c9e991fd07515a6f463074b782242ccf4a5b7b1d1012e70824df" +dependencies = [ + "bitflags", + "crossterm_winapi", + "libc", + "mio 0.8.11", + "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.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "rand_core", + "typenum", +] + +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc3dc5ad92c2e2d1c193bbbbdf2ea477cb81331de4f3103f267ca18368b988c4" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "rand_core", + "serde", + "sha2", + "subtle", + "zeroize", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[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.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "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 = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.85" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +dependencies = [ + "bitflags", + "libc", + "redox_syscall 0.7.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[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 = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[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.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[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.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[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.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[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 0.2.17", +] + +[[package]] +name = "ratatui" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f44c9e68fd46eda15c646fbb85e1040b657a58cdc8c98db1d97a55930d991eef" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "itertools 0.12.1", + "lru", + "paste", + "stability", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rust-embed" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +dependencies = [ + "proc-macro2", + "quote", + "rust-embed-utils", + "syn", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +dependencies = [ + "sha2", + "walkdir", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustls" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf4ef73721ac7bcd79b2b315da7779d8fc09718c6b3d2d1b2d94850eb8c18432" +dependencies = [ + "log", + "ring", + "rustls-pki-types", + "rustls-webpki 0.102.8", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "aws-lc-rs", + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5bfb394eeed242e909609f56089eecfe5fda225042e8b171791b9c95f5931e5" +dependencies = [ + "openssl-probe", + "rustls-pemfile", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.102.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "321c8673b092a9a42605034a9879d73cb79101ed5fd117bc9a597b89b4e9e61a" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[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 = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio 0.8.11", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +dependencies = [ + "libc", + "windows-sys 0.60.2", +] + +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls 0.23.36", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "stability" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d904e7009df136af5297832a3ace3370cd14ff1546a232f4f185036c2736fcac" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3df424c70518695237746f84cede799c9c58fcb37450d7b23716568cc8bc69cb" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +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.49.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +dependencies = [ + "bytes", + "libc", + "mio 1.1.1", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-rustls" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "775e0c0f0adb3a2f22a00c4745d728b479985fc15ee7ca6a2608388c5569860f" +dependencies = [ + "rustls 0.22.4", + "rustls-pki-types", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls 0.23.36", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "rustls 0.22.4", + "rustls-native-certs", + "rustls-pki-types", + "tokio", + "tokio-rustls 0.25.0", + "tungstenite", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "rustls 0.22.4", + "rustls-pki-types", + "sha1", + "thiserror 1.0.69", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools 0.13.0", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +dependencies = [ + "getrandom 0.4.1", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[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-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[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.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[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.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek", + "rand_core", + "serde", + "zeroize", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.39" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..b202488 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,42 @@ +[workspace] +resolver = "2" +members = [ + "backchannel-common", + "backchannel-server", + "backchannel-client", +] + +[workspace.dependencies] +# Serialization +serde = { version = "1", features = ["derive"] } +serde_json = "1" + +# IDs / time +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } + +# Error handling +thiserror = "1" +anyhow = "1" + +# Observability +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Encoding +base64 = "0.22" + +# Crypto +rand = "0.8" +argon2 = "0.5" +ed25519-dalek = { version = "2", features = ["rand_core"] } +x25519-dalek = { version = "2", features = ["static_secrets"] } +chacha20poly1305 = "0.10" +hkdf = "0.12" +sha2 = "0.10" + +# Async runtime +tokio = { version = "1", features = ["full"] } + +# WebSocket +tokio-tungstenite = "0.21" diff --git a/backchannel-client/Cargo.toml b/backchannel-client/Cargo.toml new file mode 100644 index 0000000..a5748b7 --- /dev/null +++ b/backchannel-client/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "backchannel-client" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "backchannel" +path = "src/main.rs" + +[dependencies] +backchannel-common = { path = "../backchannel-common" } + +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +base64 = { workspace = true } +rand = { workspace = true } +ed25519-dalek = { workspace = true } +x25519-dalek = { workspace = true } +chacha20poly1305 = { workspace = true } +hkdf = { workspace = true } +sha2 = { workspace = true } +tokio = { workspace = true } +tokio-tungstenite = { version = "0.21", features = ["rustls-tls-native-roots"] } +futures-util = "0.3" + +ratatui = "0.26" +crossterm = "0.27" diff --git a/backchannel-client/src/config.rs b/backchannel-client/src/config.rs new file mode 100644 index 0000000..f36b7ae --- /dev/null +++ b/backchannel-client/src/config.rs @@ -0,0 +1,24 @@ +/// Runtime configuration for the BackChannel client. +pub struct ClientConfig { + /// WebSocket server URL, e.g. `ws://127.0.0.1:7777` or `wss://server:7777`. + pub server_url: String, + + /// Path to the keystore JSON file (holds the Ed25519 identity keypair). + pub keystore_path: String, +} + +impl ClientConfig { + pub fn from_env() -> Self { + let server_url = std::env::var("BC_SERVER_URL") + .unwrap_or_else(|_| "ws://127.0.0.1:7777".into()); + + let keystore_path = std::env::var("BC_KEYSTORE").unwrap_or_else(|_| { + let home = std::env::var("USERPROFILE") + .or_else(|_| std::env::var("HOME")) + .unwrap_or_else(|_| ".".into()); + format!("{}/.backchannel/keystore.json", home) + }); + + Self { server_url, keystore_path } + } +} diff --git a/backchannel-client/src/crypto/dm.rs b/backchannel-client/src/crypto/dm.rs new file mode 100644 index 0000000..f2bf7af --- /dev/null +++ b/backchannel-client/src/crypto/dm.rs @@ -0,0 +1,36 @@ +use backchannel_common::crypto::{aead, ecdh}; +use x25519_dalek::EphemeralSecret; + +use crate::error::Result; + +/// Generate an ephemeral X25519 keypair to initiate a DM key exchange. +/// +/// Returns the `EphemeralSecret` (store in `Keystore::pending_ecdh`) and the +/// base64-encoded public key to send to the server. +pub fn generate_exchange() -> (EphemeralSecret, String) { + let (secret, pubkey) = ecdh::generate_ephemeral(); + let pubkey_b64 = ecdh::pubkey_to_b64(&pubkey); + (secret, pubkey_b64) +} + +/// Complete a key exchange given our ephemeral secret and the peer's base64 +/// public key. Returns the 32-byte derived DM session key. +/// +/// The `EphemeralSecret` is consumed here — it cannot be reused. +pub fn complete_exchange(our_secret: EphemeralSecret, their_pubkey_b64: &str) -> Result<[u8; 32]> { + let their_pubkey = ecdh::b64_to_pubkey(their_pubkey_b64)?; + let raw_secret = ecdh::diffie_hellman(our_secret, &their_pubkey); + Ok(aead::derive_dm_key(&raw_secret)) +} + +/// Encrypt a plaintext DM using a derived session key. +/// Returns `(ciphertext_b64, nonce_b64)`. +pub fn encrypt(key: &[u8; 32], plaintext: &str) -> Result<(String, String)> { + aead::encrypt(key, plaintext.as_bytes()).map_err(Into::into) +} + +/// Decrypt a received DM ciphertext using a derived session key. +pub fn decrypt(key: &[u8; 32], nonce_b64: &str, ciphertext_b64: &str) -> Result { + let bytes = aead::decrypt(key, nonce_b64, ciphertext_b64)?; + Ok(String::from_utf8(bytes)?) +} diff --git a/backchannel-client/src/crypto/mod.rs b/backchannel-client/src/crypto/mod.rs new file mode 100644 index 0000000..49336a7 --- /dev/null +++ b/backchannel-client/src/crypto/mod.rs @@ -0,0 +1 @@ +pub mod dm; diff --git a/backchannel-client/src/error.rs b/backchannel-client/src/error.rs new file mode 100644 index 0000000..05c3249 --- /dev/null +++ b/backchannel-client/src/error.rs @@ -0,0 +1,30 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum ClientError { + #[error("not authenticated")] + NotAuthenticated, + + #[error("server error {code}: {message}")] + Server { code: u16, message: String }, + + #[error("no DM key for peer — initiate key exchange first")] + NoDmKey, + + #[error("crypto error: {0}")] + Crypto(#[from] backchannel_common::BackchannelError), + + #[error("serialization error: {0}")] + Json(#[from] serde_json::Error), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("UTF-8 error: {0}")] + Utf8(#[from] std::string::FromUtf8Error), + + #[error("{0}")] + Other(String), +} diff --git a/backchannel-client/src/keystore.rs b/backchannel-client/src/keystore.rs new file mode 100644 index 0000000..b557841 --- /dev/null +++ b/backchannel-client/src/keystore.rs @@ -0,0 +1,91 @@ +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use ed25519_dalek::{SigningKey, VerifyingKey}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use x25519_dalek::EphemeralSecret; + +use backchannel_common::crypto::identity; + +use crate::error::{ClientError, Result}; + +/// On-disk representation of the keystore. +#[derive(Serialize, Deserialize)] +struct KeystoreFile { + /// Base64-encoded Ed25519 signing key (32 bytes). + signing_key_b64: String, +} + +/// Holds cryptographic material for the local client. +pub struct Keystore { + pub signing_key: SigningKey, + pub verifying_key: VerifyingKey, + + /// Derived DM session keys cached in memory: peer_user_id → 32-byte key. + /// Populated after completing an X25519 key exchange. + pub dm_keys: HashMap, + + /// Ephemeral secrets awaiting the remote party's public key. + /// Keyed by the peer's user UUID. Consumed on exchange completion. + pub pending_ecdh: HashMap, +} + +impl Keystore { + /// Load an existing keystore from `path`, or generate a fresh one and save it. + pub fn load_or_create(path: &str) -> Result { + if Path::new(path).exists() { + let contents = fs::read_to_string(path)?; + let file: KeystoreFile = + serde_json::from_str(&contents).map_err(ClientError::Json)?; + let signing_key = identity::b64_to_signing_key(&file.signing_key_b64)?; + let verifying_key = signing_key.verifying_key(); + tracing::debug!("Loaded keystore from {}", path); + Ok(Self::from_signing_key(signing_key, verifying_key)) + } else { + let (signing_key, verifying_key) = identity::generate_keypair(); + let file = KeystoreFile { + signing_key_b64: identity::signing_key_to_b64(&signing_key), + }; + if let Some(parent) = Path::new(path).parent() { + fs::create_dir_all(parent)?; + } + fs::write(path, serde_json::to_string_pretty(&file)?)?; + tracing::info!("Generated new identity keypair, saved to {}", path); + Ok(Self::from_signing_key(signing_key, verifying_key)) + } + } + + fn from_signing_key(signing_key: SigningKey, verifying_key: VerifyingKey) -> Self { + Self { + signing_key, + verifying_key, + dm_keys: HashMap::new(), + pending_ecdh: HashMap::new(), + } + } + + /// Base64-encoded Ed25519 public key, suitable for sending to the server. + pub fn identity_pubkey_b64(&self) -> String { + identity::pubkey_to_b64(&self.verifying_key) + } + + /// Store an `EphemeralSecret` while waiting for the remote peer's response. + pub fn store_pending_ecdh(&mut self, peer_id: Uuid, secret: EphemeralSecret) { + self.pending_ecdh.insert(peer_id, secret); + } + + /// Retrieve and remove a pending ephemeral secret (consumed by ECDH). + pub fn take_pending_ecdh(&mut self, peer_id: Uuid) -> Option { + self.pending_ecdh.remove(&peer_id) + } + + pub fn cache_dm_key(&mut self, peer_id: Uuid, key: [u8; 32]) { + self.dm_keys.insert(peer_id, key); + } + + pub fn get_dm_key(&self, peer_id: &Uuid) -> Option<&[u8; 32]> { + self.dm_keys.get(peer_id) + } +} diff --git a/backchannel-client/src/main.rs b/backchannel-client/src/main.rs new file mode 100644 index 0000000..3ccd582 --- /dev/null +++ b/backchannel-client/src/main.rs @@ -0,0 +1,53 @@ +mod config; +mod crypto; +mod error; +mod keystore; +mod net; +mod tui; + +use std::io; + +use anyhow::Result; +use crossterm::{ + execute, + terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}, +}; +use ratatui::{backend::CrosstermBackend, Terminal}; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "backchannel=info".into()), + ) + .with_writer(std::io::stderr) // keep TUI output clean + .init(); + + let cfg = config::ClientConfig::from_env(); + + let keystore = keystore::Keystore::load_or_create(&cfg.keystore_path)?; + + tracing::info!("Connecting to {}", cfg.server_url); + let (net_tx, mut server_rx) = net::connection::connect(&cfg.server_url).await?; + + let app = &mut tui::app::App::new(net_tx, keystore); + + // ── Terminal setup ──────────────────────────────────────────────────── + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + terminal.clear()?; + + // ── Main event loop ─────────────────────────────────────────────────── + let result = tui::events::run(app, &mut terminal, &mut server_rx).await; + + // ── Cleanup (always run, even on error) ─────────────────────────────── + disable_raw_mode()?; + execute!(terminal.backend_mut(), LeaveAlternateScreen)?; + terminal.show_cursor()?; + + result +} diff --git a/backchannel-client/src/net/connection.rs b/backchannel-client/src/net/connection.rs new file mode 100644 index 0000000..c7b940e --- /dev/null +++ b/backchannel-client/src/net/connection.rs @@ -0,0 +1,66 @@ +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use tokio::sync::mpsc; +use tokio_tungstenite::{connect_async, tungstenite::Message as WsMessage}; + +use backchannel_common::protocol::{ClientMessage, ServerMessage}; + +/// Connect to the BackChannel server and return a pair of mpsc channels: +/// +/// - `UnboundedSender` — send messages **to** the server +/// - `UnboundedReceiver` — receive messages **from** the server +/// +/// Two background tasks handle the actual WebSocket I/O so the caller never +/// touches the raw stream. +pub async fn connect( + server_url: &str, +) -> Result<( + mpsc::UnboundedSender, + mpsc::UnboundedReceiver, +)> { + let (ws_stream, _) = connect_async(server_url) + .await + .with_context(|| format!("Failed to connect to {}", server_url))?; + + let (mut ws_sink, mut ws_source) = ws_stream.split(); + + let (client_tx, mut client_rx) = mpsc::unbounded_channel::(); + let (server_tx, server_rx) = mpsc::unbounded_channel::(); + + // Outbound: client_rx → WS sink + tokio::spawn(async move { + while let Some(msg) = client_rx.recv().await { + match serde_json::to_string(&msg) { + Ok(json) => { + if ws_sink.send(WsMessage::Text(json)).await.is_err() { + break; + } + } + Err(e) => tracing::error!("Failed to serialize outbound message: {}", e), + } + } + }); + + // Inbound: WS source → server_tx + tokio::spawn(async move { + while let Some(item) = ws_source.next().await { + match item { + Ok(WsMessage::Text(text)) => { + match serde_json::from_str::(&text) { + Ok(msg) => { + if server_tx.send(msg).is_err() { + break; // receiver dropped; shut down + } + } + Err(e) => tracing::warn!("Failed to parse server message: {}", e), + } + } + Ok(WsMessage::Close(_)) | Err(_) => break, + Ok(_) => {} + } + } + tracing::info!("Connection to server closed"); + }); + + Ok((client_tx, server_rx)) +} diff --git a/backchannel-client/src/net/mod.rs b/backchannel-client/src/net/mod.rs new file mode 100644 index 0000000..b3b606b --- /dev/null +++ b/backchannel-client/src/net/mod.rs @@ -0,0 +1 @@ +pub mod connection; diff --git a/backchannel-client/src/tui/app.rs b/backchannel-client/src/tui/app.rs new file mode 100644 index 0000000..0f6c99d --- /dev/null +++ b/backchannel-client/src/tui/app.rs @@ -0,0 +1,351 @@ +use std::collections::{HashMap, VecDeque}; + +use backchannel_common::protocol::{ClientMessage, ServerMessage}; +use chrono::Local; +use tokio::sync::mpsc; +use uuid::Uuid; + +use crate::keystore::Keystore; + +const MAX_MESSAGES: usize = 500; + +/// What the TUI is currently displaying. +#[derive(Debug, Clone, PartialEq)] +pub enum View { + Login, + Channel, + Dm, +} + +/// Which input field is focused on the login screen. +#[derive(Debug, Clone, PartialEq)] +pub enum LoginField { + Username, + Password, +} + +/// A single chat message for display. +#[derive(Debug, Clone)] +pub struct ChatMessage { + pub author: String, + pub content: String, + pub timestamp: String, +} + +/// All mutable state for the TUI application. +pub struct App { + // ── Navigation ──────────────────────────────────────────────────────── + pub view: View, + pub should_quit: bool, + + // ── Login screen ───────────────────────────────────────────────────── + pub login_username: String, + pub login_password: String, + pub login_field: LoginField, + pub login_register: bool, // toggle between Login and Register mode + pub status_msg: Option, + + // ── Auth state ──────────────────────────────────────────────────────── + pub my_user_id: Option, + pub my_username: Option, + + // ── Channel state ───────────────────────────────────────────────────── + pub channels: Vec<(Uuid, String)>, // (id, name) + pub selected_channel: usize, + pub channel_messages: HashMap>, + + // ── DM state ────────────────────────────────────────────────────────── + pub dm_peer_id: Option, + pub dm_peer_name: Option, + pub dm_messages: HashMap>, + + // ── Text input (shared between channel + DM views) ──────────────────── + pub input: String, + + // ── Online users ────────────────────────────────────────────────────── + pub online_users: Vec<(Uuid, String)>, + + // ── Network ─────────────────────────────────────────────────────────── + /// Send channel messages to the server connection task. + pub net_tx: mpsc::UnboundedSender, + + /// Keystore for crypto operations. + pub keystore: Keystore, +} + +impl App { + pub fn new(net_tx: mpsc::UnboundedSender, keystore: Keystore) -> Self { + Self { + view: View::Login, + should_quit: false, + + login_username: String::new(), + login_password: String::new(), + login_field: LoginField::Username, + login_register: false, + status_msg: None, + + my_user_id: None, + my_username: None, + + channels: Vec::new(), + selected_channel: 0, + channel_messages: HashMap::new(), + + dm_peer_id: None, + dm_peer_name: None, + dm_messages: HashMap::new(), + + input: String::new(), + + online_users: Vec::new(), + + net_tx, + keystore, + } + } + + // ── Network message handling ────────────────────────────────────────── + + pub fn handle_server_message(&mut self, msg: ServerMessage) { + match msg { + ServerMessage::AuthSuccess { user_id, username, .. } => { + self.my_user_id = Some(user_id); + self.my_username = Some(username.clone()); + self.status_msg = None; + self.view = View::Channel; + self.status_msg = Some(format!("Logged in as {}", username)); + } + + ServerMessage::AuthError { reason } => { + self.status_msg = Some(format!("Auth failed: {}", reason)); + } + + ServerMessage::ChannelMessage { + channel_id, + author_username, + content, + timestamp, + .. + } => { + let entry = self + .channel_messages + .entry(channel_id) + .or_default(); + + entry.push_back(ChatMessage { + author: author_username, + content, + timestamp: timestamp.with_timezone(&Local).format("%H:%M").to_string(), + }); + if entry.len() > MAX_MESSAGES { + entry.pop_front(); + } + } + + ServerMessage::ChannelCreated { channel_id, name, .. } => { + if !self.channels.iter().any(|(id, _)| *id == channel_id) { + self.channels.push((channel_id, name)); + } + } + + ServerMessage::ChannelDeleted { channel_id } => { + self.channels.retain(|(id, _)| *id != channel_id); + self.channel_messages.remove(&channel_id); + } + + ServerMessage::DirectMessage { + sender_id, + sender_username, + ciphertext, + nonce, + timestamp, + .. + } => { + // Determine the peer UUID (the other party in the conversation). + let peer_id = if Some(sender_id) == self.my_user_id { + self.dm_peer_id.unwrap_or(sender_id) + } else { + sender_id + }; + + // Try to decrypt; fall back to a placeholder if the key is missing. + let plaintext = self.keystore + .get_dm_key(&peer_id) + .and_then(|key| { + crate::crypto::dm::decrypt(key, &nonce, &ciphertext).ok() + }) + .unwrap_or_else(|| "[encrypted]".into()); + + let entry = self.dm_messages.entry(peer_id).or_default(); + entry.push_back(ChatMessage { + author: sender_username, + content: plaintext, + timestamp: timestamp.with_timezone(&Local).format("%H:%M").to_string(), + }); + if entry.len() > MAX_MESSAGES { + entry.pop_front(); + } + } + + ServerMessage::DmKeyExchangeRequest { + initiator_id, + initiator_username, + sender_ephemeral_pubkey, + } => { + // Bob receives this — generate our ephemeral pair and respond. + let (secret, our_pubkey_b64) = crate::crypto::dm::generate_exchange(); + + // Derive the shared key now (Bob has both keys). + if let Ok(key) = crate::crypto::dm::complete_exchange(secret, &sender_ephemeral_pubkey) { + self.keystore.cache_dm_key(initiator_id, key); + } + + let _ = self.net_tx.send(ClientMessage::AcceptDmKeyExchange { + initiator_id, + recipient_ephemeral_pubkey: our_pubkey_b64, + }); + + self.status_msg = Some(format!("{} wants to DM you", initiator_username)); + } + + ServerMessage::DmKeyExchangeResponse { + recipient_id, + recipient_ephemeral_pubkey, + .. + } => { + // Alice receives this — complete the exchange. + if let Some(secret) = self.keystore.take_pending_ecdh(recipient_id) { + if let Ok(key) = + crate::crypto::dm::complete_exchange(secret, &recipient_ephemeral_pubkey) + { + self.keystore.cache_dm_key(recipient_id, key); + self.status_msg = Some("DM key exchange complete — you can now send encrypted messages".into()); + } + } + } + + ServerMessage::UserOnline { user_id, username } => { + if !self.online_users.iter().any(|(id, _)| *id == user_id) { + self.online_users.push((user_id, username)); + } + } + + ServerMessage::UserOffline { user_id, .. } => { + self.online_users.retain(|(id, _)| *id != user_id); + } + + ServerMessage::Error { code, message } => { + self.status_msg = Some(format!("Error {}: {}", code, message)); + } + + // Other messages (history, role events, etc.) are acknowledged but + // not yet wired into the TUI — they will be handled in future iterations. + _ => {} + } + } + + // ── Input helpers ───────────────────────────────────────────────────── + + /// Submit the current channel text input. + pub fn submit_channel_message(&mut self) { + let content = self.input.trim().to_string(); + if content.is_empty() { + return; + } + if let Some((channel_id, _)) = self.channels.get(self.selected_channel) { + let _ = self.net_tx.send(ClientMessage::SendChannelMessage { + channel_id: *channel_id, + content, + }); + } + self.input.clear(); + } + + /// Submit the current DM text input (encrypted). + pub fn submit_dm_message(&mut self) { + let content = self.input.trim().to_string(); + if content.is_empty() { + return; + } + let Some(peer_id) = self.dm_peer_id else { return }; + + match self.keystore.get_dm_key(&peer_id) { + Some(key) => { + let key = *key; + match crate::crypto::dm::encrypt(&key, &content) { + Ok((ciphertext, nonce)) => { + let _ = self.net_tx.send(ClientMessage::SendDm { + recipient_id: peer_id, + ciphertext, + nonce, + }); + } + Err(e) => { + self.status_msg = Some(format!("Encryption error: {}", e)); + } + } + } + None => { + self.status_msg = Some( + "No DM key — initiate key exchange with /dm @user first".into(), + ); + } + } + self.input.clear(); + } + + /// Initiate a DM key exchange with a peer by their UUID. + pub fn initiate_dm(&mut self, peer_id: Uuid, peer_name: String) { + let (secret, our_pubkey_b64) = crate::crypto::dm::generate_exchange(); + self.keystore.store_pending_ecdh(peer_id, secret); + + let _ = self.net_tx.send(ClientMessage::InitDmKeyExchange { + recipient_id: peer_id, + sender_ephemeral_pubkey: our_pubkey_b64, + }); + + self.dm_peer_id = Some(peer_id); + self.dm_peer_name = Some(peer_name); + self.view = View::Dm; + self.status_msg = Some("Key exchange initiated — waiting for peer...".into()); + } + + /// Submit the login/register form. + pub fn submit_login(&mut self) { + let username = self.login_username.trim().to_string(); + let password = self.login_password.trim().to_string(); + if username.is_empty() || password.is_empty() { + self.status_msg = Some("Username and password are required".into()); + return; + } + + let identity_pubkey = self.keystore.identity_pubkey_b64(); + + let msg = if self.login_register { + ClientMessage::Register { username, password, identity_pubkey } + } else { + ClientMessage::Login { username, password, identity_pubkey } + }; + + let _ = self.net_tx.send(msg); + self.status_msg = Some("Connecting...".into()); + } + + /// Current channel (Uuid, name), if any channel is selected. + pub fn current_channel(&self) -> Option<&(Uuid, String)> { + self.channels.get(self.selected_channel) + } + + /// Messages for the currently viewed channel. + pub fn current_channel_messages(&self) -> Option<&VecDeque> { + self.current_channel() + .and_then(|(id, _)| self.channel_messages.get(id)) + } + + /// Messages for the current DM peer. + pub fn current_dm_messages(&self) -> Option<&VecDeque> { + self.dm_peer_id + .and_then(|id| self.dm_messages.get(&id)) + } +} diff --git a/backchannel-client/src/tui/events.rs b/backchannel-client/src/tui/events.rs new file mode 100644 index 0000000..430f9bf --- /dev/null +++ b/backchannel-client/src/tui/events.rs @@ -0,0 +1,116 @@ +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode, KeyModifiers}; +use ratatui::{backend::CrosstermBackend, Terminal}; +use std::io::Stdout; +use std::time::Duration; + +use crate::tui::app::{App, LoginField, View}; + +/// The main event loop. Runs until `app.should_quit` is set. +/// +/// Every ~50 ms we: +/// 1. Drain any pending server messages from `server_rx`. +/// 2. Poll for a crossterm keyboard event. +/// 3. Redraw the terminal. +pub async fn run( + app: &mut App, + terminal: &mut Terminal>, + server_rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> Result<()> { + loop { + // Drain server messages (non-blocking). + while let Ok(msg) = server_rx.try_recv() { + app.handle_server_message(msg); + } + + // Draw. + terminal.draw(|f| crate::tui::views::draw(f, app))?; + + // Poll keyboard events with a short timeout so server messages stay responsive. + if event::poll(Duration::from_millis(50))? { + if let Event::Key(key) = event::read()? { + handle_key(app, key.code, key.modifiers); + } + } + + if app.should_quit { + break; + } + } + Ok(()) +} + +fn handle_key(app: &mut App, code: KeyCode, modifiers: KeyModifiers) { + // Ctrl-C or Ctrl-Q always quit. + if modifiers.contains(KeyModifiers::CONTROL) + && (code == KeyCode::Char('c') || code == KeyCode::Char('q')) + { + app.should_quit = true; + return; + } + + match app.view { + View::Login => handle_login_key(app, code), + View::Channel => handle_channel_key(app, code), + View::Dm => handle_dm_key(app, code), + } +} + +fn handle_login_key(app: &mut App, code: KeyCode) { + match code { + KeyCode::Tab => { + app.login_field = match app.login_field { + LoginField::Username => LoginField::Password, + LoginField::Password => LoginField::Username, + }; + } + KeyCode::Enter => app.submit_login(), + KeyCode::Char('t') if app.login_username.is_empty() && app.login_password.is_empty() => { + // Toggle register/login mode only when both fields are empty + app.login_register = !app.login_register; + } + KeyCode::Char(c) => match app.login_field { + LoginField::Username => app.login_username.push(c), + LoginField::Password => app.login_password.push(c), + }, + KeyCode::Backspace => match app.login_field { + LoginField::Username => { app.login_username.pop(); } + LoginField::Password => { app.login_password.pop(); } + }, + _ => {} + } +} + +fn handle_channel_key(app: &mut App, code: KeyCode) { + match code { + KeyCode::Enter => app.submit_channel_message(), + KeyCode::Char(c) => app.input.push(c), + KeyCode::Backspace => { app.input.pop(); } + // Channel navigation + KeyCode::Up => { + if app.selected_channel > 0 { + app.selected_channel -= 1; + } + } + KeyCode::Down => { + if app.selected_channel + 1 < app.channels.len() { + app.selected_channel += 1; + } + } + KeyCode::Esc => app.status_msg = None, + _ => {} + } +} + +fn handle_dm_key(app: &mut App, code: KeyCode) { + match code { + KeyCode::Enter => app.submit_dm_message(), + KeyCode::Char(c) => app.input.push(c), + KeyCode::Backspace => { app.input.pop(); } + KeyCode::Esc => { + app.view = View::Channel; + app.input.clear(); + } + _ => {} + } +} diff --git a/backchannel-client/src/tui/mod.rs b/backchannel-client/src/tui/mod.rs new file mode 100644 index 0000000..088a953 --- /dev/null +++ b/backchannel-client/src/tui/mod.rs @@ -0,0 +1,3 @@ +pub mod app; +pub mod events; +pub mod views; diff --git a/backchannel-client/src/tui/views/channel.rs b/backchannel-client/src/tui/views/channel.rs new file mode 100644 index 0000000..c77e6f0 --- /dev/null +++ b/backchannel-client/src/tui/views/channel.rs @@ -0,0 +1,147 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use crate::tui::app::App; + +pub fn draw(frame: &mut Frame, app: &App) { + let area = frame.size(); + + // Split into sidebar | main column + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(22), Constraint::Min(0)]) + .split(area); + + draw_sidebar(frame, app, columns[0]); + draw_main(frame, app, columns[1]); +} + +fn draw_sidebar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([Constraint::Min(0), Constraint::Length(6)]) + .split(area); + + // ── Channel list ──────────────────────────────────────────────────── + let items: Vec = app + .channels + .iter() + .enumerate() + .map(|(i, (_, name))| { + let style = if i == app.selected_channel { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(format!("# {}", name)).style(style) + }) + .collect(); + + let mut state = ListState::default(); + state.select(Some(app.selected_channel)); + + let channels = List::new(items) + .block( + Block::default() + .title(" Channels ") + .borders(Borders::ALL) + .style(Style::default().fg(Color::Cyan)), + ) + .highlight_style(Style::default().add_modifier(Modifier::BOLD)); + + frame.render_stateful_widget(channels, rows[0], &mut state); + + // ── Online users ───────────────────────────────────────────────────── + let user_items: Vec = app + .online_users + .iter() + .map(|(_, name)| ListItem::new(format!("● {}", name))) + .collect(); + + let online = List::new(user_items).block( + Block::default() + .title(" Online ") + .borders(Borders::ALL) + .style(Style::default().fg(Color::Green)), + ); + frame.render_widget(online, rows[1]); +} + +fn draw_main(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) { + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // channel title bar + Constraint::Min(0), // messages + Constraint::Length(3), // input box + Constraint::Length(1), // status bar + ]) + .split(area); + + // ── Title bar ──────────────────────────────────────────────────────── + let title = app + .current_channel() + .map(|(_, name)| format!(" # {} ", name)) + .unwrap_or_else(|| " No channel selected ".into()); + + let title_bar = Paragraph::new(title) + .style(Style::default().fg(Color::White).bg(Color::DarkGray).add_modifier(Modifier::BOLD)); + frame.render_widget(title_bar, rows[0]); + + // ── Messages ───────────────────────────────────────────────────────── + let msgs: Vec = app + .current_channel_messages() + .map(|deque| { + deque + .iter() + .map(|m| { + ListItem::new(Line::from(vec![ + Span::styled( + format!("{} ", m.timestamp), + Style::default().fg(Color::DarkGray), + ), + Span::styled( + format!("{}: ", m.author), + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), + ), + Span::raw(m.content.clone()), + ])) + }) + .collect() + }) + .unwrap_or_default(); + + let message_count = msgs.len(); + let mut list_state = ListState::default(); + if message_count > 0 { + list_state.select(Some(message_count - 1)); + } + + let messages = List::new(msgs) + .block(Block::default().borders(Borders::LEFT)) + .highlight_style(Style::default()); + frame.render_stateful_widget(messages, rows[1], &mut list_state); + + // ── Input box ───────────────────────────────────────────────────────── + let input = Paragraph::new(app.input.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Message (Enter to send) "), + ); + frame.render_widget(input, rows[2]); + + // ── Status bar ──────────────────────────────────────────────────────── + let status_text = app + .status_msg + .as_deref() + .unwrap_or("Ctrl-Q: quit | ↑↓: channels | Esc: dismiss"); + let status = Paragraph::new(status_text) + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(status, rows[3]); +} diff --git a/backchannel-client/src/tui/views/dm.rs b/backchannel-client/src/tui/views/dm.rs new file mode 100644 index 0000000..9af6f16 --- /dev/null +++ b/backchannel-client/src/tui/views/dm.rs @@ -0,0 +1,86 @@ +use ratatui::{ + layout::{Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState, Paragraph}, + Frame, +}; + +use crate::tui::app::App; + +pub fn draw(frame: &mut Frame, app: &App) { + let area = frame.size(); + + let rows = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // title bar + Constraint::Min(0), // messages + Constraint::Length(3), // input + Constraint::Length(1), // status + ]) + .split(area); + + // ── Title bar ───────────────────────────────────────────────────────── + let peer_name = app + .dm_peer_name + .as_deref() + .unwrap_or("Unknown"); + + let title = Paragraph::new(format!(" DM: {} (E2EE) ", peer_name)) + .style(Style::default().fg(Color::White).bg(Color::Blue).add_modifier(Modifier::BOLD)); + frame.render_widget(title, rows[0]); + + // ── Messages ────────────────────────────────────────────────────────── + let msgs: Vec = app + .current_dm_messages() + .map(|deque| { + deque + .iter() + .map(|m| { + let is_self = app.my_username.as_deref() == Some(m.author.as_str()); + let author_style = if is_self { + Style::default().fg(Color::Green).add_modifier(Modifier::BOLD) + } else { + Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + }; + ListItem::new(Line::from(vec![ + Span::styled( + format!("{} ", m.timestamp), + Style::default().fg(Color::DarkGray), + ), + Span::styled(format!("{}: ", m.author), author_style), + Span::raw(m.content.clone()), + ])) + }) + .collect() + }) + .unwrap_or_default(); + + let msg_count = msgs.len(); + let mut state = ListState::default(); + if msg_count > 0 { + state.select(Some(msg_count - 1)); + } + + let messages = List::new(msgs).block(Block::default().borders(Borders::LEFT)); + frame.render_stateful_widget(messages, rows[1], &mut state); + + // ── Input box ───────────────────────────────────────────────────────── + let input = Paragraph::new(app.input.as_str()) + .block( + Block::default() + .borders(Borders::ALL) + .title(" Encrypted message (Enter: send Esc: back) "), + ); + frame.render_widget(input, rows[2]); + + // ── Status bar ──────────────────────────────────────────────────────── + let status_text = app + .status_msg + .as_deref() + .unwrap_or("🔒 End-to-end encrypted"); + let status = Paragraph::new(status_text) + .style(Style::default().fg(Color::DarkGray)); + frame.render_widget(status, rows[3]); +} diff --git a/backchannel-client/src/tui/views/login.rs b/backchannel-client/src/tui/views/login.rs new file mode 100644 index 0000000..e3a2626 --- /dev/null +++ b/backchannel-client/src/tui/views/login.rs @@ -0,0 +1,98 @@ +use ratatui::{ + layout::{Alignment, Constraint, Direction, Layout}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, + Frame, +}; + +use crate::tui::app::{App, LoginField}; + +pub fn draw(frame: &mut Frame, app: &App) { + let area = frame.size(); + + // Centre a 50×12 box on screen. + let v = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage(30), + Constraint::Length(14), + Constraint::Min(0), + ]) + .split(area); + + let h = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(25), + Constraint::Percentage(50), + Constraint::Percentage(25), + ]) + .split(v[1]); + + let box_area = h[1]; + + let mode_label = if app.login_register { "Register" } else { "Login" }; + + let outer = Block::default() + .title(format!(" BackChannel — {} ", mode_label)) + .borders(Borders::ALL) + .style(Style::default().fg(Color::Cyan)); + + frame.render_widget(outer, box_area); + + let inner = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(1), // padding + Constraint::Length(3), // username + Constraint::Length(3), // password + Constraint::Length(1), // padding + Constraint::Length(1), // hint + Constraint::Length(1), // status + ]) + .margin(1) + .split(box_area); + + // Username field + let username_style = if app.login_field == LoginField::Username { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let username_widget = Paragraph::new(app.login_username.as_str()) + .block(Block::default().borders(Borders::ALL).title(" Username ")) + .style(username_style); + frame.render_widget(username_widget, inner[1]); + + // Password field (mask with asterisks) + let password_style = if app.login_field == LoginField::Password { + Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + let masked: String = "*".repeat(app.login_password.len()); + let password_widget = Paragraph::new(masked.as_str()) + .block(Block::default().borders(Borders::ALL).title(" Password ")) + .style(password_style); + frame.render_widget(password_widget, inner[2]); + + // Hints + let hint = Paragraph::new(Line::from(vec![ + Span::raw("Tab: switch field | Enter: submit | "), + Span::styled( + if app.login_register { "T: switch to Login" } else { "T: switch to Register" }, + Style::default().fg(Color::DarkGray), + ), + ])) + .alignment(Alignment::Center); + frame.render_widget(hint, inner[4]); + + // Status / error message + if let Some(msg) = &app.status_msg { + let status = Paragraph::new(msg.as_str()) + .style(Style::default().fg(Color::Red)) + .alignment(Alignment::Center); + frame.render_widget(status, inner[5]); + } +} diff --git a/backchannel-client/src/tui/views/mod.rs b/backchannel-client/src/tui/views/mod.rs new file mode 100644 index 0000000..26dc36d --- /dev/null +++ b/backchannel-client/src/tui/views/mod.rs @@ -0,0 +1,16 @@ +pub mod channel; +pub mod dm; +pub mod login; + +use ratatui::Frame; + +use crate::tui::app::{App, View}; + +/// Top-level draw dispatcher — renders the correct view based on `app.view`. +pub fn draw(frame: &mut Frame, app: &App) { + match app.view { + View::Login => login::draw(frame, app), + View::Channel => channel::draw(frame, app), + View::Dm => dm::draw(frame, app), + } +} diff --git a/backchannel-common/Cargo.toml b/backchannel-common/Cargo.toml new file mode 100644 index 0000000..64630f8 --- /dev/null +++ b/backchannel-common/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "backchannel-common" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +base64 = { workspace = true } +rand = { workspace = true } +ed25519-dalek = { workspace = true } +x25519-dalek = { workspace = true } +chacha20poly1305 = { workspace = true } +hkdf = { workspace = true } +sha2 = { workspace = true } diff --git a/backchannel-common/src/crypto/aead.rs b/backchannel-common/src/crypto/aead.rs new file mode 100644 index 0000000..853a753 --- /dev/null +++ b/backchannel-common/src/crypto/aead.rs @@ -0,0 +1,59 @@ +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use chacha20poly1305::{ + aead::{Aead, KeyInit}, + ChaCha20Poly1305, Key, Nonce, +}; +use hkdf::Hkdf; +use rand::RngCore; +use rand::rngs::OsRng; +use sha2::Sha256; + +use crate::error::{BackchannelError, Result}; + +const HKDF_INFO: &[u8] = b"backchannel-dm-v1"; +const HKDF_SALT: &[u8] = b"backchannel-salt-v1"; + +/// Derive a 32-byte ChaCha20-Poly1305 key from a raw X25519 shared secret +/// using HKDF-SHA256. The raw DH output should never be used directly. +pub fn derive_dm_key(raw_secret: &[u8]) -> [u8; 32] { + let hk = Hkdf::::new(Some(HKDF_SALT), raw_secret); + let mut okm = [0u8; 32]; + hk.expand(HKDF_INFO, &mut okm) + .expect("32-byte output always fits HKDF-SHA256"); + okm +} + +/// Encrypt `plaintext` with a 32-byte key. +/// +/// Returns `(ciphertext_b64, nonce_b64)`. A fresh random nonce is generated +/// for every call — callers must transmit both values to the recipient. +pub fn encrypt(key_bytes: &[u8; 32], plaintext: &[u8]) -> Result<(String, String)> { + let key = Key::from_slice(key_bytes); + let cipher = ChaCha20Poly1305::new(key); + + let mut nonce_bytes = [0u8; 12]; + OsRng.fill_bytes(&mut nonce_bytes); + let nonce = Nonce::from(nonce_bytes); + + let ciphertext = cipher + .encrypt(&nonce, plaintext) + .map_err(|_| BackchannelError::Crypto("encryption failed".into()))?; + + Ok((B64.encode(&ciphertext), B64.encode(nonce_bytes))) +} + +/// Decrypt `ciphertext_b64` using `key_bytes` and `nonce_b64`. +pub fn decrypt(key_bytes: &[u8; 32], nonce_b64: &str, ciphertext_b64: &str) -> Result> { + let key = Key::from_slice(key_bytes); + let cipher = ChaCha20Poly1305::new(key); + + let nonce_bytes = B64.decode(nonce_b64)?; + let nonce_arr: [u8; 12] = nonce_bytes + .try_into() + .map_err(|_| BackchannelError::InvalidKeyLength)?; + let nonce = Nonce::from(nonce_arr); + + let ciphertext = B64.decode(ciphertext_b64)?; + let plaintext = cipher.decrypt(&nonce, ciphertext.as_ref())?; + Ok(plaintext) +} diff --git a/backchannel-common/src/crypto/ecdh.rs b/backchannel-common/src/crypto/ecdh.rs new file mode 100644 index 0000000..9bb96e4 --- /dev/null +++ b/backchannel-common/src/crypto/ecdh.rs @@ -0,0 +1,37 @@ +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use rand::rngs::OsRng; +use x25519_dalek::{EphemeralSecret, PublicKey}; + +use crate::error::{BackchannelError, Result}; + +/// Generate an X25519 ephemeral keypair for one DM key exchange. +/// +/// The `EphemeralSecret` is consumed by `diffie_hellman`, ensuring it cannot +/// be reused. Call `derive_dm_key` (in `aead`) on the resulting shared secret. +pub fn generate_ephemeral() -> (EphemeralSecret, PublicKey) { + let secret = EphemeralSecret::random_from_rng(OsRng); + let public = PublicKey::from(&secret); + (secret, public) +} + +/// Encode an X25519 public key to base64. +pub fn pubkey_to_b64(key: &PublicKey) -> String { + B64.encode(key.as_bytes()) +} + +/// Decode a base64 string to an X25519 public key. +pub fn b64_to_pubkey(s: &str) -> Result { + let bytes = B64.decode(s)?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| BackchannelError::InvalidKeyLength)?; + Ok(PublicKey::from(arr)) +} + +/// Perform X25519 Diffie-Hellman and return the 32-byte shared secret. +/// +/// `secret` is consumed (ephemeral), preventing reuse. +pub fn diffie_hellman(secret: EphemeralSecret, their_pubkey: &PublicKey) -> [u8; 32] { + let shared = secret.diffie_hellman(their_pubkey); + *shared.as_bytes() +} diff --git a/backchannel-common/src/crypto/identity.rs b/backchannel-common/src/crypto/identity.rs new file mode 100644 index 0000000..447bc8f --- /dev/null +++ b/backchannel-common/src/crypto/identity.rs @@ -0,0 +1,67 @@ +use base64::{engine::general_purpose::STANDARD as B64, Engine}; +use ed25519_dalek::{Signature, SigningKey, VerifyingKey}; +use rand::rngs::OsRng; + +use crate::error::{BackchannelError, Result}; + +/// Generate a new Ed25519 identity keypair. +pub fn generate_keypair() -> (SigningKey, VerifyingKey) { + let signing_key = SigningKey::generate(&mut OsRng); + let verifying_key = signing_key.verifying_key(); + (signing_key, verifying_key) +} + +/// Encode an Ed25519 public key to a base64 string (32 bytes → ~44 chars). +pub fn pubkey_to_b64(key: &VerifyingKey) -> String { + B64.encode(key.as_bytes()) +} + +/// Decode a base64 string back to an Ed25519 public key. +pub fn b64_to_pubkey(s: &str) -> Result { + let bytes = B64.decode(s)?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| BackchannelError::InvalidKeyLength)?; + VerifyingKey::from_bytes(&arr).map_err(|e| BackchannelError::Crypto(e.to_string())) +} + +/// Encode a signing key (private key bytes) to base64 for keystore persistence. +pub fn signing_key_to_b64(key: &SigningKey) -> String { + B64.encode(key.to_bytes()) +} + +/// Decode a base64 string back to a signing key. +pub fn b64_to_signing_key(s: &str) -> Result { + let bytes = B64.decode(s)?; + let arr: [u8; 32] = bytes + .try_into() + .map_err(|_| BackchannelError::InvalidKeyLength)?; + Ok(SigningKey::from_bytes(&arr)) +} + +/// Sign a message with an Ed25519 signing key. +pub fn sign(key: &SigningKey, message: &[u8]) -> Signature { + use ed25519_dalek::Signer; + key.sign(message) +} + +/// Verify an Ed25519 signature. +pub fn verify(key: &VerifyingKey, message: &[u8], sig: &Signature) -> Result<()> { + use ed25519_dalek::Verifier; + key.verify(message, sig)?; + Ok(()) +} + +/// Encode a `Signature` to base64. +pub fn sig_to_b64(sig: &Signature) -> String { + B64.encode(sig.to_bytes()) +} + +/// Decode a base64 string back to a `Signature`. +pub fn b64_to_sig(s: &str) -> Result { + let bytes = B64.decode(s)?; + let arr: [u8; 64] = bytes + .try_into() + .map_err(|_| BackchannelError::InvalidKeyLength)?; + Ok(Signature::from_bytes(&arr)) +} diff --git a/backchannel-common/src/crypto/mod.rs b/backchannel-common/src/crypto/mod.rs new file mode 100644 index 0000000..c0fab9c --- /dev/null +++ b/backchannel-common/src/crypto/mod.rs @@ -0,0 +1,3 @@ +pub mod aead; +pub mod ecdh; +pub mod identity; diff --git a/backchannel-common/src/error.rs b/backchannel-common/src/error.rs new file mode 100644 index 0000000..0e415c9 --- /dev/null +++ b/backchannel-common/src/error.rs @@ -0,0 +1,39 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum BackchannelError { + #[error("cryptographic error: {0}")] + Crypto(String), + + #[error("base64 decode error: {0}")] + Base64(#[from] base64::DecodeError), + + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("invalid key length")] + InvalidKeyLength, + + #[error("decryption failed: authentication tag mismatch")] + DecryptionFailed, + + #[error("signature verification failed")] + InvalidSignature, + + #[error("{0}")] + Other(String), +} + +impl From for BackchannelError { + fn from(_: chacha20poly1305::Error) -> Self { + BackchannelError::DecryptionFailed + } +} + +impl From for BackchannelError { + fn from(_: ed25519_dalek::SignatureError) -> Self { + BackchannelError::InvalidSignature + } +} diff --git a/backchannel-common/src/lib.rs b/backchannel-common/src/lib.rs new file mode 100644 index 0000000..a580d8f --- /dev/null +++ b/backchannel-common/src/lib.rs @@ -0,0 +1,6 @@ +pub mod crypto; +pub mod error; +pub mod protocol; +pub mod types; + +pub use error::{BackchannelError, Result}; diff --git a/backchannel-common/src/protocol/client.rs b/backchannel-common/src/protocol/client.rs new file mode 100644 index 0000000..6d81aad --- /dev/null +++ b/backchannel-common/src/protocol/client.rs @@ -0,0 +1,121 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Every message the client can send to the server over the WebSocket. +/// +/// Serialised as JSON with an inline `"type"` discriminant, e.g.: +/// `{"type":"login","username":"alice","password":"...","identity_pubkey":"..."}` +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ClientMessage { + // ── Authentication ──────────────────────────────────────────────────── + /// First-time authentication with credentials. + Login { + username: String, + /// Sent in plaintext; the TLS layer protects this in transit. + password: String, + /// Base64-encoded Ed25519 public key (32 bytes). + /// Stored/updated server-side as the user's cryptographic identity. + identity_pubkey: String, + }, + + /// Register a new account. + Register { + username: String, + password: String, + identity_pubkey: String, + }, + + /// Re-establish an authenticated session using a previously issued JWT. + /// Avoids re-entering the password on reconnect. + ResumeSession { + token: String, + }, + + /// Gracefully end the session; server invalidates the JWT. + Logout, + + // ── Channel messaging ───────────────────────────────────────────────── + /// Post a plaintext message to a channel. + SendChannelMessage { + channel_id: Uuid, + content: String, + }, + + /// Request recent message history for a channel. + FetchChannelHistory { + channel_id: Uuid, + /// Pagination: fetch messages older than this ID. + before_message_id: Option, + limit: u32, + }, + + // ── Direct messages (E2EE) ──────────────────────────────────────────── + /// Phase 1 of DM key exchange: initiator sends their X25519 ephemeral + /// public key to the server, addressed to the target recipient. + InitDmKeyExchange { + recipient_id: Uuid, + /// Base64-encoded X25519 ephemeral public key (32 bytes). + sender_ephemeral_pubkey: String, + }, + + /// Phase 2 of DM key exchange: recipient responds with their ephemeral + /// public key. Both sides can now derive the same shared secret. + AcceptDmKeyExchange { + initiator_id: Uuid, + /// Base64-encoded X25519 ephemeral public key (32 bytes). + recipient_ephemeral_pubkey: String, + }, + + /// Send an E2EE DM. The server relays the ciphertext without decrypting. + SendDm { + recipient_id: Uuid, + /// Base64-encoded ChaCha20-Poly1305 ciphertext. + ciphertext: String, + /// Base64-encoded nonce (12 bytes). + nonce: String, + }, + + /// Request DM history with a specific peer. + FetchDmHistory { + peer_id: Uuid, + before_message_id: Option, + limit: u32, + }, + + // ── Channel management ──────────────────────────────────────────────── + CreateChannel { + name: String, + topic: Option, + }, + + DeleteChannel { + channel_id: Uuid, + }, + + // ── Role & permission management ────────────────────────────────────── + CreateRole { + name: String, + /// Permission bitmask; see `PermissionFlags` in `types.rs`. + permissions: u64, + }, + + AssignRole { + user_id: Uuid, + role_id: Uuid, + }, + + RevokeRole { + user_id: Uuid, + role_id: Uuid, + }, + + // ── Key directory ───────────────────────────────────────────────────── + /// Look up another user's registered Ed25519 identity public key. + QueryIdentityKey { + user_id: Uuid, + }, + + // ── Keepalive ───────────────────────────────────────────────────────── + Ping, +} diff --git a/backchannel-common/src/protocol/mod.rs b/backchannel-common/src/protocol/mod.rs new file mode 100644 index 0000000..5688bb2 --- /dev/null +++ b/backchannel-common/src/protocol/mod.rs @@ -0,0 +1,5 @@ +pub mod client; +pub mod server; + +pub use client::ClientMessage; +pub use server::{HistoricChannelMessage, HistoricDm, ServerMessage}; diff --git a/backchannel-common/src/protocol/server.rs b/backchannel-common/src/protocol/server.rs new file mode 100644 index 0000000..49c4260 --- /dev/null +++ b/backchannel-common/src/protocol/server.rs @@ -0,0 +1,141 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Every message the server can push to a client over the WebSocket. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerMessage { + // ── Auth responses ──────────────────────────────────────────────────── + AuthSuccess { + user_id: Uuid, + username: String, + /// Signed JWT. Client stores this for reconnect via `ResumeSession`. + session_token: String, + }, + + AuthError { + reason: String, + }, + + // ── Channel events ──────────────────────────────────────────────────── + /// Broadcast to all connected clients when a channel message is posted. + ChannelMessage { + message_id: Uuid, + channel_id: Uuid, + author_id: Uuid, + author_username: String, + content: String, + timestamp: DateTime, + }, + + /// Response to `FetchChannelHistory`. + ChannelHistory { + channel_id: Uuid, + messages: Vec, + }, + + ChannelCreated { + channel_id: Uuid, + name: String, + topic: Option, + }, + + ChannelDeleted { + channel_id: Uuid, + }, + + // ── DM key exchange relay ───────────────────────────────────────────── + /// Server relays the initiator's ephemeral pubkey to the recipient. + DmKeyExchangeRequest { + initiator_id: Uuid, + initiator_username: String, + sender_ephemeral_pubkey: String, + }, + + /// Server relays the recipient's ephemeral pubkey back to the initiator. + DmKeyExchangeResponse { + recipient_id: Uuid, + recipient_username: String, + recipient_ephemeral_pubkey: String, + }, + + // ── DM messages ─────────────────────────────────────────────────────── + /// Relayed E2EE DM. Server cannot read `ciphertext`. + DirectMessage { + message_id: Uuid, + sender_id: Uuid, + sender_username: String, + ciphertext: String, + nonce: String, + timestamp: DateTime, + }, + + DmHistory { + peer_id: Uuid, + messages: Vec, + }, + + // ── Presence ────────────────────────────────────────────────────────── + Pong, + + UserOnline { + user_id: Uuid, + username: String, + }, + + UserOffline { + user_id: Uuid, + username: String, + }, + + // ── Role events ─────────────────────────────────────────────────────── + RoleCreated { + role_id: Uuid, + name: String, + permissions: u64, + }, + + RoleAssigned { + user_id: Uuid, + role_id: Uuid, + }, + + RoleRevoked { + user_id: Uuid, + role_id: Uuid, + }, + + // ── Key directory ───────────────────────────────────────────────────── + IdentityKeyResponse { + user_id: Uuid, + /// Base64-encoded Ed25519 public key, or null if unknown. + identity_pubkey: Option, + }, + + // ── Generic error ───────────────────────────────────────────────────── + Error { + code: u16, + message: String, + }, +} + +/// A channel message returned in history responses. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoricChannelMessage { + pub message_id: Uuid, + pub author_id: Uuid, + pub author_username: String, + pub content: String, + pub timestamp: DateTime, +} + +/// A DM returned in history responses. Ciphertext is opaque to the server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoricDm { + pub message_id: Uuid, + pub sender_id: Uuid, + pub ciphertext: String, + pub nonce: String, + pub timestamp: DateTime, +} diff --git a/backchannel-common/src/types.rs b/backchannel-common/src/types.rs new file mode 100644 index 0000000..3a66b09 --- /dev/null +++ b/backchannel-common/src/types.rs @@ -0,0 +1,72 @@ +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// Typed UUID wrappers to prevent accidental mixing of IDs. + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct UserId(pub Uuid); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct ChannelId(pub Uuid); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct MessageId(pub Uuid); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct RoleId(pub Uuid); + +impl UserId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + pub fn inner(self) -> Uuid { + self.0 + } +} + +impl ChannelId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + pub fn inner(self) -> Uuid { + self.0 + } +} + +impl MessageId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + pub fn inner(self) -> Uuid { + self.0 + } +} + +impl RoleId { + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + pub fn inner(self) -> Uuid { + self.0 + } +} + +/// Permission bit-flags for server roles. +/// The `ADMINISTRATOR` flag bypasses all individual permission checks. +pub struct PermissionFlags; + +impl PermissionFlags { + pub const READ_MESSAGES: u64 = 1 << 0; + pub const SEND_MESSAGES: u64 = 1 << 1; + pub const MANAGE_CHANNELS: u64 = 1 << 2; + pub const MANAGE_ROLES: u64 = 1 << 3; + pub const KICK_MEMBERS: u64 = 1 << 4; + pub const BAN_MEMBERS: u64 = 1 << 5; + /// Bypasses all permission checks. + pub const ADMINISTRATOR: u64 = 1 << 63; +} + +/// Check whether `perms` satisfies a required flag, honoring ADMINISTRATOR. +pub fn has_permission(perms: u64, required: u64) -> bool { + (perms & PermissionFlags::ADMINISTRATOR) != 0 || (perms & required) == required +} diff --git a/backchannel-server/Cargo.toml b/backchannel-server/Cargo.toml new file mode 100644 index 0000000..a5296a3 --- /dev/null +++ b/backchannel-server/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "backchannel-server" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "backchannel-server" +path = "src/main.rs" + +[dependencies] +backchannel-common = { path = "../backchannel-common" } + +serde = { workspace = true } +serde_json = { workspace = true } +uuid = { workspace = true } +chrono = { workspace = true } +thiserror = { workspace = true } +anyhow = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +base64 = { workspace = true } +rand = { workspace = true } +argon2 = { workspace = true } +ed25519-dalek = { workspace = true } +tokio = { workspace = true } +tokio-tungstenite = { workspace = true } + +sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls", "macros", "migrate", "chrono", "uuid"] } +jsonwebtoken = "9" +rustls = { version = "0.23", features = ["ring"] } +rustls-pemfile = "2" +tokio-rustls = "0.26" +futures-util = "0.3" +axum = "0.7" +rust-embed = "8" +mime_guess = "2" diff --git a/backchannel-server/src/auth/mod.rs b/backchannel-server/src/auth/mod.rs new file mode 100644 index 0000000..f87b187 --- /dev/null +++ b/backchannel-server/src/auth/mod.rs @@ -0,0 +1,2 @@ +pub mod password; +pub mod token; diff --git a/backchannel-server/src/auth/password.rs b/backchannel-server/src/auth/password.rs new file mode 100644 index 0000000..3af6635 --- /dev/null +++ b/backchannel-server/src/auth/password.rs @@ -0,0 +1,31 @@ +use argon2::{ + password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, + Argon2, +}; + +use crate::error::{Result, ServerError}; + +/// Hash a plaintext password using Argon2id. +/// +/// The returned string is the PHC-format encoded hash, suitable for storage in +/// the `users.password_hash` column. Includes the salt, so no separate salt +/// storage is needed. +pub fn hash(password: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + let hash = Argon2::default() + .hash_password(password.as_bytes(), &salt)? + .to_string(); + Ok(hash) +} + +/// Verify a plaintext password against a stored PHC-format hash. +/// +/// Returns `Ok(())` on success, `Err(ServerError::Unauthorized)` on mismatch. +pub fn verify(password: &str, stored_hash: &str) -> Result<()> { + let parsed = PasswordHash::new(stored_hash) + .map_err(|e| ServerError::PasswordHash(e.to_string()))?; + + Argon2::default() + .verify_password(password.as_bytes(), &parsed) + .map_err(|_| ServerError::Unauthorized) +} diff --git a/backchannel-server/src/auth/token.rs b/backchannel-server/src/auth/token.rs new file mode 100644 index 0000000..99a68f8 --- /dev/null +++ b/backchannel-server/src/auth/token.rs @@ -0,0 +1,110 @@ +use chrono::Utc; +use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use sqlx::SqlitePool; +use uuid::Uuid; + +use crate::error::{Result, ServerError}; + +/// Claims embedded in every BackChannel JWT. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Claims { + /// Subject: user UUID as a string. + pub sub: String, + pub username: String, + /// JWT ID — stored in the `sessions` table for revocation support. + pub jti: String, + /// Issued-at (Unix seconds). + pub iat: i64, + /// Expiry (Unix seconds). + pub exp: i64, +} + +impl Claims { + pub fn user_id(&self) -> Result { + Uuid::parse_str(&self.sub).map_err(|e| ServerError::Internal(e.to_string())) + } +} + +const TOKEN_TTL_SECS: i64 = 7 * 24 * 3600; // 7 days + +/// Issue a signed JWT and persist its JTI in the `sessions` table. +pub async fn issue( + pool: &SqlitePool, + user_id: Uuid, + username: &str, + secret: &[u8], +) -> Result { + let now = Utc::now().timestamp(); + let jti = Uuid::new_v4().to_string(); + + let claims = Claims { + sub: user_id.to_string(), + username: username.to_string(), + jti: jti.clone(), + iat: now, + exp: now + TOKEN_TTL_SECS, + }; + + let token = encode( + &Header::default(), // HS256 + &claims, + &EncodingKey::from_secret(secret), + )?; + + // Persist JTI for revocation checks. + let expires_at = Utc::now() + .checked_add_signed(chrono::Duration::seconds(TOKEN_TTL_SECS)) + .unwrap_or_else(Utc::now) + .to_rfc3339(); + + sqlx::query( + "INSERT INTO sessions (jti, user_id, expires_at) VALUES (?, ?, ?)", + ) + .bind(&jti) + .bind(user_id.to_string()) + .bind(expires_at) + .execute(pool) + .await?; + + Ok(token) +} + +/// Decode and validate a JWT, then check the `sessions` table for revocation. +pub async fn validate(pool: &SqlitePool, token: &str, secret: &[u8]) -> Result { + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_exp = true; + + let token_data = decode::( + token, + &DecodingKey::from_secret(secret), + &validation, + ) + .map_err(|_| ServerError::Unauthorized)?; + + let claims = token_data.claims; + + // Check revocation. + let row: Option<(i64,)> = + sqlx::query_as("SELECT revoked FROM sessions WHERE jti = ?") + .bind(&claims.jti) + .fetch_optional(pool) + .await?; + + match row { + None => return Err(ServerError::Unauthorized), // JTI unknown + Some((revoked,)) if revoked != 0 => return Err(ServerError::Unauthorized), + _ => {} + } + + Ok(claims) +} + +/// Mark a JWT as revoked (logout / forced expiry). +pub async fn revoke(pool: &SqlitePool, jti: &str) -> Result<()> { + sqlx::query("UPDATE sessions SET revoked = 1 WHERE jti = ?") + .bind(jti) + .execute(pool) + .await?; + Ok(()) +} diff --git a/backchannel-server/src/config.rs b/backchannel-server/src/config.rs new file mode 100644 index 0000000..d2b3bcf --- /dev/null +++ b/backchannel-server/src/config.rs @@ -0,0 +1,84 @@ +use anyhow::Result; + +/// Runtime configuration for the BackChannel server. +/// Loaded from environment variables with sensible development defaults. +pub struct ServerConfig { + /// TCP address to bind, e.g. `0.0.0.0:7777` + pub bind_addr: String, + + /// HTTP address for the embedded web UI, e.g. `0.0.0.0:8080` + pub http_bind_addr: String, + + /// SQLite connection string, e.g. `sqlite:backchannel.db` + pub db_url: String, + + /// Raw bytes for HMAC-SHA256 JWT signing. In production, set + /// `BC_JWT_SECRET` to a long, random hex string. + pub jwt_secret: Vec, + + /// Enable TLS on the WebSocket listener. Requires `tls_cert_path` + /// and `tls_key_path` when true. + pub tls_enabled: bool, + + /// Path to the PEM-encoded TLS certificate chain. + pub tls_cert_path: Option, + + /// Path to the PEM-encoded private key. + pub tls_key_path: Option, +} + +impl ServerConfig { + /// Load configuration from environment variables. + /// + /// | Variable | Default | + /// |------------------|-----------------------------| + /// | `BC_BIND_ADDR` | `0.0.0.0:7777` | + /// | `BC_HTTP_ADDR` | `0.0.0.0:8080` | + /// | `BC_DB_URL` | `sqlite:backchannel.db` | + /// | `BC_JWT_SECRET` | *insecure dev placeholder* | + /// | `BC_TLS` | `false` | + /// | `BC_TLS_CERT` | *(none)* | + /// | `BC_TLS_KEY` | *(none)* | + pub fn from_env() -> Result { + let bind_addr = std::env::var("BC_BIND_ADDR") + .unwrap_or_else(|_| "0.0.0.0:7777".into()); + let http_bind_addr = std::env::var("BC_HTTP_ADDR") + .unwrap_or_else(|_| "0.0.0.0:8080".into()); + + let db_url = std::env::var("BC_DB_URL") + .unwrap_or_else(|_| "sqlite:backchannel.db".into()); + + let jwt_secret = match std::env::var("BC_JWT_SECRET") { + Ok(s) => s.into_bytes(), + Err(_) => { + tracing::warn!( + "BC_JWT_SECRET not set — using insecure dev placeholder. \ + Set this env var before deploying." + ); + b"CHANGE-ME-dev-only-jwt-secret-32b".to_vec() + } + }; + + let tls_enabled = std::env::var("BC_TLS") + .map(|v| v.eq_ignore_ascii_case("true") || v == "1") + .unwrap_or(false); + + let tls_cert_path = std::env::var("BC_TLS_CERT").ok(); + let tls_key_path = std::env::var("BC_TLS_KEY").ok(); + + if tls_enabled { + anyhow::ensure!(tls_cert_path.is_some(), "BC_TLS_CERT must be set when BC_TLS=true"); + anyhow::ensure!(tls_key_path.is_some(), "BC_TLS_KEY must be set when BC_TLS=true"); + } + + Ok(Self { + bind_addr, + http_bind_addr, + db_url, + jwt_secret, + tls_enabled, + tls_cert_path, + tls_key_path, + }) + } +} diff --git a/backchannel-server/src/db/channels.rs b/backchannel-server/src/db/channels.rs new file mode 100644 index 0000000..d87f112 --- /dev/null +++ b/backchannel-server/src/db/channels.rs @@ -0,0 +1,78 @@ +use chrono::Utc; +use sqlx::SqlitePool; +use uuid::Uuid; + +use crate::error::{Result, ServerError}; + +#[derive(Debug, sqlx::FromRow)] +pub struct ChannelRow { + pub id: String, + pub name: String, + pub topic: Option, + pub created_by: String, + pub created_at: String, +} + +impl ChannelRow { + pub fn uuid(&self) -> Result { + Uuid::parse_str(&self.id).map_err(|e| ServerError::Internal(e.to_string())) + } +} + +pub async fn create( + pool: &SqlitePool, + name: &str, + topic: Option<&str>, + created_by: Uuid, +) -> Result { + let id = Uuid::new_v4(); + let now = Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO channels (id, name, topic, created_by, created_at) VALUES (?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(name) + .bind(topic) + .bind(created_by.to_string()) + .bind(now) + .execute(pool) + .await?; + + Ok(id) +} + +pub async fn list(pool: &SqlitePool) -> Result> { + let rows = sqlx::query_as::<_, ChannelRow>( + "SELECT id, name, topic, created_by, created_at FROM channels ORDER BY created_at ASC", + ) + .fetch_all(pool) + .await?; + Ok(rows) +} + +pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result> { + let row = sqlx::query_as::<_, ChannelRow>( + "SELECT id, name, topic, created_by, created_at FROM channels WHERE id = ?", + ) + .bind(id.to_string()) + .fetch_optional(pool) + .await?; + Ok(row) +} + +pub async fn delete(pool: &SqlitePool, id: Uuid) -> Result { + let result = sqlx::query("DELETE FROM channels WHERE id = ?") + .bind(id.to_string()) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +pub async fn name_exists(pool: &SqlitePool, name: &str) -> Result { + let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM channels WHERE name = ?") + .bind(name) + .fetch_one(pool) + .await?; + Ok(count.0 > 0) +} diff --git a/backchannel-server/src/db/keys.rs b/backchannel-server/src/db/keys.rs new file mode 100644 index 0000000..d7dbc73 --- /dev/null +++ b/backchannel-server/src/db/keys.rs @@ -0,0 +1,17 @@ +use sqlx::SqlitePool; +use uuid::Uuid; + +use crate::error::Result; + +/// Look up the registered Ed25519 identity public key for a user. +/// +/// Returns `None` if the user does not exist or has no key on record +/// (the latter shouldn't happen after registration, but is handled defensively). +pub async fn get_identity_key(pool: &SqlitePool, user_id: Uuid) -> Result> { + let row: Option<(String,)> = + sqlx::query_as("SELECT identity_pubkey FROM users WHERE id = ?") + .bind(user_id.to_string()) + .fetch_optional(pool) + .await?; + Ok(row.map(|(k,)| k)) +} diff --git a/backchannel-server/src/db/messages.rs b/backchannel-server/src/db/messages.rs new file mode 100644 index 0000000..a0955ea --- /dev/null +++ b/backchannel-server/src/db/messages.rs @@ -0,0 +1,210 @@ +use backchannel_common::protocol::server::{HistoricChannelMessage, HistoricDm}; +use chrono::{DateTime, Utc}; +use sqlx::SqlitePool; +use uuid::Uuid; + +use crate::error::{Result, ServerError}; + +#[derive(Debug, sqlx::FromRow)] +struct ChannelMessageRow { + pub id: String, + pub channel_id: String, + pub author_id: String, + pub author_username: String, + pub content: String, + pub created_at: String, +} + +#[derive(Debug, sqlx::FromRow)] +struct DmRow { + pub id: String, + pub sender_id: String, + pub ciphertext: String, + pub nonce: String, + pub created_at: String, +} + +pub async fn insert_channel_message( + pool: &SqlitePool, + channel_id: Uuid, + author_id: Uuid, + content: &str, +) -> Result<(Uuid, DateTime)> { + let id = Uuid::new_v4(); + let now = Utc::now(); + let now_str = now.to_rfc3339(); + + sqlx::query( + "INSERT INTO channel_messages (id, channel_id, author_id, content, created_at) + VALUES (?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(channel_id.to_string()) + .bind(author_id.to_string()) + .bind(content) + .bind(&now_str) + .execute(pool) + .await?; + + Ok((id, now)) +} + +pub async fn fetch_channel_history( + pool: &SqlitePool, + channel_id: Uuid, + before_message_id: Option, + limit: u32, +) -> Result> { + let limit = limit.min(100) as i64; + + let rows: Vec = if let Some(before_id) = before_message_id { + // Fetch messages older than `before_id` by joining to get its created_at + sqlx::query_as::<_, ChannelMessageRow>( + "SELECT m.id, m.channel_id, m.author_id, u.username AS author_username, + m.content, m.created_at + FROM channel_messages m + JOIN users u ON u.id = m.author_id + WHERE m.channel_id = ? + AND m.created_at < (SELECT created_at FROM channel_messages WHERE id = ?) + ORDER BY m.created_at DESC + LIMIT ?", + ) + .bind(channel_id.to_string()) + .bind(before_id.to_string()) + .bind(limit) + .fetch_all(pool) + .await? + } else { + sqlx::query_as::<_, ChannelMessageRow>( + "SELECT m.id, m.channel_id, m.author_id, u.username AS author_username, + m.content, m.created_at + FROM channel_messages m + JOIN users u ON u.id = m.author_id + WHERE m.channel_id = ? + ORDER BY m.created_at DESC + LIMIT ?", + ) + .bind(channel_id.to_string()) + .bind(limit) + .fetch_all(pool) + .await? + }; + + let mut messages: Vec = rows + .into_iter() + .map(|r| { + let ts = r + .created_at + .parse::>() + .unwrap_or_else(|_| Utc::now()); + Ok(HistoricChannelMessage { + message_id: Uuid::parse_str(&r.id) + .map_err(|e| ServerError::Internal(e.to_string()))?, + author_id: Uuid::parse_str(&r.author_id) + .map_err(|e| ServerError::Internal(e.to_string()))?, + author_username: r.author_username, + content: r.content, + timestamp: ts, + }) + }) + .collect::>>()?; + + // Return in chronological order (oldest first). + messages.reverse(); + Ok(messages) +} + +pub async fn insert_dm( + pool: &SqlitePool, + sender_id: Uuid, + recipient_id: Uuid, + ciphertext: &str, + nonce: &str, +) -> Result<(Uuid, DateTime)> { + let id = Uuid::new_v4(); + let now = Utc::now(); + let now_str = now.to_rfc3339(); + + sqlx::query( + "INSERT INTO direct_messages (id, sender_id, recipient_id, ciphertext, nonce, created_at) + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(sender_id.to_string()) + .bind(recipient_id.to_string()) + .bind(ciphertext) + .bind(nonce) + .bind(&now_str) + .execute(pool) + .await?; + + Ok((id, now)) +} + +pub async fn fetch_dm_history( + pool: &SqlitePool, + user_a: Uuid, + user_b: Uuid, + before_message_id: Option, + limit: u32, +) -> Result> { + let limit = limit.min(100) as i64; + + let rows: Vec = if let Some(before_id) = before_message_id { + sqlx::query_as::<_, DmRow>( + "SELECT id, sender_id, ciphertext, nonce, created_at + FROM direct_messages + WHERE ((sender_id = ? AND recipient_id = ?) + OR (sender_id = ? AND recipient_id = ?)) + AND created_at < (SELECT created_at FROM direct_messages WHERE id = ?) + ORDER BY created_at DESC + LIMIT ?", + ) + .bind(user_a.to_string()) + .bind(user_b.to_string()) + .bind(user_b.to_string()) + .bind(user_a.to_string()) + .bind(before_id.to_string()) + .bind(limit) + .fetch_all(pool) + .await? + } else { + sqlx::query_as::<_, DmRow>( + "SELECT id, sender_id, ciphertext, nonce, created_at + FROM direct_messages + WHERE (sender_id = ? AND recipient_id = ?) + OR (sender_id = ? AND recipient_id = ?) + ORDER BY created_at DESC + LIMIT ?", + ) + .bind(user_a.to_string()) + .bind(user_b.to_string()) + .bind(user_b.to_string()) + .bind(user_a.to_string()) + .bind(limit) + .fetch_all(pool) + .await? + }; + + let mut messages: Vec = rows + .into_iter() + .map(|r| { + let ts = r + .created_at + .parse::>() + .unwrap_or_else(|_| Utc::now()); + Ok(HistoricDm { + message_id: Uuid::parse_str(&r.id) + .map_err(|e| ServerError::Internal(e.to_string()))?, + sender_id: Uuid::parse_str(&r.sender_id) + .map_err(|e| ServerError::Internal(e.to_string()))?, + ciphertext: r.ciphertext, + nonce: r.nonce, + timestamp: ts, + }) + }) + .collect::>>()?; + + messages.reverse(); + Ok(messages) +} diff --git a/backchannel-server/src/db/migrations/001_initial.sql b/backchannel-server/src/db/migrations/001_initial.sql new file mode 100644 index 0000000..dfc1ec8 --- /dev/null +++ b/backchannel-server/src/db/migrations/001_initial.sql @@ -0,0 +1,85 @@ +-- ── Users ───────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS users ( + id TEXT NOT NULL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + identity_pubkey TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- ── Roles ───────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS roles ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + permissions INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- Seed built-in roles. +-- admin: all bits set (i64::MAX in SQLite integer storage). +-- member: READ_MESSAGES (bit 0) | SEND_MESSAGES (bit 1) = 3 +INSERT OR IGNORE INTO roles (id, name, permissions) VALUES + ('00000000-0000-0000-0000-000000000001', 'admin', 9223372036854775807), + ('00000000-0000-0000-0000-000000000002', 'member', 3); + +-- ── User ↔ Role membership ───────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS user_roles ( + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id TEXT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + assigned_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + PRIMARY KEY (user_id, role_id) +); + +-- ── Channels ─────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS channels ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + topic TEXT, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +-- Seed a default channel owned by a system placeholder user. +-- The created_by value references no real user; foreign key is intentionally +-- left loose here since the system user is never inserted. +INSERT OR IGNORE INTO channels (id, name, created_by) VALUES + ('00000000-0000-0000-0000-000000000010', 'general', '00000000-0000-0000-0000-000000000000'); + +-- ── Channel messages ─────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS channel_messages ( + id TEXT NOT NULL PRIMARY KEY, + channel_id TEXT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + author_id TEXT NOT NULL REFERENCES users(id), + content TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_channel_messages_channel_created + ON channel_messages (channel_id, created_at DESC); + +-- ── Direct messages ──────────────────────────────────────────────────────────── +-- Ciphertext is stored opaquely. The server cannot decrypt DM content. +CREATE TABLE IF NOT EXISTS direct_messages ( + id TEXT NOT NULL PRIMARY KEY, + sender_id TEXT NOT NULL REFERENCES users(id), + recipient_id TEXT NOT NULL REFERENCES users(id), + ciphertext TEXT NOT NULL, + nonce TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE INDEX IF NOT EXISTS idx_dm_pair_created + ON direct_messages (sender_id, recipient_id, created_at DESC); + +-- ── Sessions ─────────────────────────────────────────────────────────────────── +-- JWT IDs stored here enable soft revocation (logout / forced expiry). +CREATE TABLE IF NOT EXISTS sessions ( + jti TEXT NOT NULL PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + expires_at TEXT NOT NULL, + revoked INTEGER NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions (user_id); diff --git a/backchannel-server/src/db/mod.rs b/backchannel-server/src/db/mod.rs new file mode 100644 index 0000000..cbcc599 --- /dev/null +++ b/backchannel-server/src/db/mod.rs @@ -0,0 +1,5 @@ +pub mod channels; +pub mod keys; +pub mod messages; +pub mod roles; +pub mod users; diff --git a/backchannel-server/src/db/roles.rs b/backchannel-server/src/db/roles.rs new file mode 100644 index 0000000..da89f94 --- /dev/null +++ b/backchannel-server/src/db/roles.rs @@ -0,0 +1,86 @@ +use chrono::Utc; +use sqlx::SqlitePool; +use uuid::Uuid; + +use crate::error::{Result, ServerError}; + +#[derive(Debug, sqlx::FromRow)] +pub struct RoleRow { + pub id: String, + pub name: String, + pub permissions: i64, + pub created_at: String, +} + +impl RoleRow { + pub fn uuid(&self) -> Result { + Uuid::parse_str(&self.id).map_err(|e| ServerError::Internal(e.to_string())) + } + + /// Returns the permissions bitmask as `u64` (SQLite stores it as `i64`). + pub fn permissions_u64(&self) -> u64 { + self.permissions as u64 + } +} + +pub async fn create(pool: &SqlitePool, name: &str, permissions: u64) -> Result { + let id = Uuid::new_v4(); + let now = Utc::now().to_rfc3339(); + + sqlx::query("INSERT INTO roles (id, name, permissions, created_at) VALUES (?, ?, ?, ?)") + .bind(id.to_string()) + .bind(name) + .bind(permissions as i64) + .bind(now) + .execute(pool) + .await?; + + Ok(id) +} + +pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result> { + let row = sqlx::query_as::<_, RoleRow>( + "SELECT id, name, permissions, created_at FROM roles WHERE id = ?", + ) + .bind(id.to_string()) + .fetch_optional(pool) + .await?; + Ok(row) +} + +pub async fn assign(pool: &SqlitePool, user_id: Uuid, role_id: Uuid) -> Result<()> { + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)", + ) + .bind(user_id.to_string()) + .bind(role_id.to_string()) + .bind(now) + .execute(pool) + .await?; + Ok(()) +} + +pub async fn revoke(pool: &SqlitePool, user_id: Uuid, role_id: Uuid) -> Result<()> { + sqlx::query("DELETE FROM user_roles WHERE user_id = ? AND role_id = ?") + .bind(user_id.to_string()) + .bind(role_id.to_string()) + .execute(pool) + .await?; + Ok(()) +} + +/// Compute the effective permission bitmask for a user by OR-ing all their role permissions. +pub async fn get_user_permissions(pool: &SqlitePool, user_id: Uuid) -> Result { + let rows: Vec<(i64,)> = sqlx::query_as( + "SELECT r.permissions FROM roles r + JOIN user_roles ur ON ur.role_id = r.id + WHERE ur.user_id = ?", + ) + .bind(user_id.to_string()) + .fetch_all(pool) + .await?; + + let perms = rows.into_iter().fold(0u64, |acc, (p,)| acc | p as u64); + Ok(perms) +} diff --git a/backchannel-server/src/db/users.rs b/backchannel-server/src/db/users.rs new file mode 100644 index 0000000..70041e4 --- /dev/null +++ b/backchannel-server/src/db/users.rs @@ -0,0 +1,115 @@ +use chrono::Utc; +use sqlx::SqlitePool; +use uuid::Uuid; + +use crate::error::{Result, ServerError}; + +/// A row from the `users` table. +#[derive(Debug, sqlx::FromRow)] +pub struct UserRow { + pub id: String, + pub username: String, + pub password_hash: String, + pub identity_pubkey: String, + pub created_at: String, + pub updated_at: String, +} + +impl UserRow { + pub fn uuid(&self) -> Result { + Uuid::parse_str(&self.id).map_err(|e| ServerError::Internal(e.to_string())) + } +} + +/// Insert a new user. Returns the new row's UUID. +pub async fn create( + pool: &SqlitePool, + username: &str, + password_hash: &str, + identity_pubkey: &str, +) -> Result { + let id = Uuid::new_v4(); + let now = Utc::now().to_rfc3339(); + + sqlx::query( + "INSERT INTO users (id, username, password_hash, identity_pubkey, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?)", + ) + .bind(id.to_string()) + .bind(username) + .bind(password_hash) + .bind(identity_pubkey) + .bind(&now) + .bind(&now) + .execute(pool) + .await?; + + Ok(id) +} + +/// Find a user by username. +pub async fn find_by_username(pool: &SqlitePool, username: &str) -> Result> { + let row = sqlx::query_as::<_, UserRow>( + "SELECT id, username, password_hash, identity_pubkey, created_at, updated_at + FROM users WHERE username = ?", + ) + .bind(username) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +/// Find a user by UUID. +pub async fn find_by_id(pool: &SqlitePool, id: Uuid) -> Result> { + let row = sqlx::query_as::<_, UserRow>( + "SELECT id, username, password_hash, identity_pubkey, created_at, updated_at + FROM users WHERE id = ?", + ) + .bind(id.to_string()) + .fetch_optional(pool) + .await?; + + Ok(row) +} + +/// Update (or set for the first time) a user's Ed25519 identity public key. +/// Called on every login to allow transparent key rotation. +pub async fn update_identity_key( + pool: &SqlitePool, + user_id: Uuid, + identity_pubkey: &str, +) -> Result<()> { + let now = Utc::now().to_rfc3339(); + sqlx::query("UPDATE users SET identity_pubkey = ?, updated_at = ? WHERE id = ?") + .bind(identity_pubkey) + .bind(now) + .bind(user_id.to_string()) + .execute(pool) + .await?; + Ok(()) +} + +/// Check whether a username is already taken. +pub async fn username_exists(pool: &SqlitePool, username: &str) -> Result { + let count: (i64,) = + sqlx::query_as("SELECT COUNT(*) FROM users WHERE username = ?") + .bind(username) + .fetch_one(pool) + .await?; + Ok(count.0 > 0) +} + +/// Assign the built-in `member` role to a newly registered user. +pub async fn assign_member_role(pool: &SqlitePool, user_id: Uuid) -> Result<()> { + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT OR IGNORE INTO user_roles (user_id, role_id, assigned_at) VALUES (?, ?, ?)", + ) + .bind(user_id.to_string()) + .bind("00000000-0000-0000-0000-000000000002") // member role UUID + .bind(now) + .execute(pool) + .await?; + Ok(()) +} diff --git a/backchannel-server/src/error.rs b/backchannel-server/src/error.rs new file mode 100644 index 0000000..ff958cc --- /dev/null +++ b/backchannel-server/src/error.rs @@ -0,0 +1,59 @@ +use thiserror::Error; + +pub type Result = std::result::Result; + +#[derive(Debug, Error)] +pub enum ServerError { + #[error("unauthorized")] + Unauthorized, + + #[error("forbidden")] + Forbidden, + + #[error("not found: {0}")] + NotFound(String), + + #[error("bad request: {0}")] + BadRequest(String), + + #[error("connection closed")] + ConnectionClosed, + + #[error("database error: {0}")] + Database(#[from] sqlx::Error), + + #[error("serialization error: {0}")] + Serialization(#[from] serde_json::Error), + + #[error("JWT error: {0}")] + Jwt(#[from] jsonwebtoken::errors::Error), + + #[error("password hash error: {0}")] + PasswordHash(String), + + #[error("common error: {0}")] + Common(#[from] backchannel_common::BackchannelError), + + #[error("internal error: {0}")] + Internal(String), +} + +impl From for ServerError { + fn from(e: argon2::password_hash::Error) -> Self { + ServerError::PasswordHash(e.to_string()) + } +} + +/// HTTP-style error code for `ServerMessage::Error`. +impl ServerError { + pub fn code(&self) -> u16 { + match self { + ServerError::Unauthorized => 401, + ServerError::Forbidden => 403, + ServerError::NotFound(_) => 404, + ServerError::BadRequest(_) => 400, + ServerError::ConnectionClosed => 503, + _ => 500, + } + } +} diff --git a/backchannel-server/src/handlers/auth.rs b/backchannel-server/src/handlers/auth.rs new file mode 100644 index 0000000..37b7542 --- /dev/null +++ b/backchannel-server/src/handlers/auth.rs @@ -0,0 +1,124 @@ +use std::sync::Arc; + +use backchannel_common::protocol::ServerMessage; +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; + +use crate::auth::{password, token}; +use crate::db::users; +use crate::error::{Result, ServerError}; +use crate::state::{AppState, ChannelBroadcast}; +use crate::ws::session::Session; + +pub async fn handle_login( + session: &mut Session, + state: &Arc, + username: String, + password_plain: String, + identity_pubkey: String, +) -> Result<()> { + let user = users::find_by_username(&state.db, &username) + .await? + .ok_or(ServerError::Unauthorized)?; + + password::verify(&password_plain, &user.password_hash)?; + let user_id = user.uuid()?; + + // Update identity key on every login to allow transparent key rotation. + users::update_identity_key(&state.db, user_id, &identity_pubkey).await?; + + let jwt = token::issue(&state.db, user_id, &username, &state.jwt_secret).await?; + let jti = extract_jti(&jwt, &state.jwt_secret)?; + + session.user_id = Some(user_id); + session.username = Some(username.clone()); + session.jti = Some(jti); + + state.register_session(session.conn_id, user_id, username.clone(), session.tx.clone()).await; + + let _ = state.channel_broadcast.send(ChannelBroadcast { + message: ServerMessage::UserOnline { user_id, username: username.clone() }, + }); + + session.send(ServerMessage::AuthSuccess { user_id, username, session_token: jwt }) +} + +pub async fn handle_register( + session: &mut Session, + state: &Arc, + username: String, + password_plain: String, + identity_pubkey: String, +) -> Result<()> { + if username.len() < 2 || username.len() > 32 { + return Err(ServerError::BadRequest("Username must be 2–32 characters".into())); + } + if password_plain.len() < 8 { + return Err(ServerError::BadRequest("Password must be at least 8 characters".into())); + } + if users::username_exists(&state.db, &username).await? { + return Err(ServerError::BadRequest("Username already taken".into())); + } + + let hash = password::hash(&password_plain)?; + let user_id = users::create(&state.db, &username, &hash, &identity_pubkey).await?; + users::assign_member_role(&state.db, user_id).await?; + + let jwt = token::issue(&state.db, user_id, &username, &state.jwt_secret).await?; + let jti = extract_jti(&jwt, &state.jwt_secret)?; + + session.user_id = Some(user_id); + session.username = Some(username.clone()); + session.jti = Some(jti); + + state.register_session(session.conn_id, user_id, username.clone(), session.tx.clone()).await; + + let _ = state.channel_broadcast.send(ChannelBroadcast { + message: ServerMessage::UserOnline { user_id, username: username.clone() }, + }); + + session.send(ServerMessage::AuthSuccess { user_id, username, session_token: jwt }) +} + +pub async fn handle_resume( + session: &mut Session, + state: &Arc, + token_str: String, +) -> Result<()> { + let claims = token::validate(&state.db, &token_str, &state.jwt_secret).await?; + let user_id = claims.user_id()?; + + session.user_id = Some(user_id); + session.username = Some(claims.username.clone()); + session.jti = Some(claims.jti.clone()); + + state.register_session(session.conn_id, user_id, claims.username.clone(), session.tx.clone()).await; + + let _ = state.channel_broadcast.send(ChannelBroadcast { + message: ServerMessage::UserOnline { user_id, username: claims.username.clone() }, + }); + + session.send(ServerMessage::AuthSuccess { + user_id, + username: claims.username, + session_token: token_str, + }) +} + +pub async fn handle_logout(session: &mut Session, state: &Arc) -> Result<()> { + session.require_auth()?; + if let Some(jti) = session.jti.take() { + token::revoke(&state.db, &jti).await?; + } + session.user_id = None; + session.username = None; + Ok(()) +} + +/// Extract the JTI claim from a JWT without re-running expiry validation. +/// Used immediately after `token::issue` to store the JTI in the session. +fn extract_jti(jwt: &str, secret: &[u8]) -> Result { + let mut v = Validation::new(Algorithm::HS256); + v.validate_exp = false; + let data = decode::(jwt, &DecodingKey::from_secret(secret), &v)?; + Ok(data.claims.jti) +} diff --git a/backchannel-server/src/handlers/channels.rs b/backchannel-server/src/handlers/channels.rs new file mode 100644 index 0000000..91cbdf1 --- /dev/null +++ b/backchannel-server/src/handlers/channels.rs @@ -0,0 +1,107 @@ +use std::sync::Arc; + +use backchannel_common::protocol::ServerMessage; +use backchannel_common::types::{has_permission, PermissionFlags}; +use uuid::Uuid; + +use crate::db::{channels, messages, roles}; +use crate::error::{Result, ServerError}; +use crate::state::{AppState, ChannelBroadcast}; +use crate::ws::session::Session; + +pub async fn handle_send( + session: &Session, + state: &Arc, + channel_id: Uuid, + content: String, +) -> Result<()> { + let user_id = session.require_auth()?; + let username = session.require_username()?.to_string(); + + channels::find_by_id(&state.db, channel_id) + .await? + .ok_or_else(|| ServerError::NotFound("Channel not found".into()))?; + + let perms = roles::get_user_permissions(&state.db, user_id).await?; + if !has_permission(perms, PermissionFlags::SEND_MESSAGES) { + return Err(ServerError::Forbidden); + } + + let (message_id, timestamp) = + messages::insert_channel_message(&state.db, channel_id, user_id, &content).await?; + + let _ = state.channel_broadcast.send(ChannelBroadcast { + message: ServerMessage::ChannelMessage { + message_id, + channel_id, + author_id: user_id, + author_username: username, + content, + timestamp, + }, + }); + + Ok(()) +} + +pub async fn handle_history( + session: &Session, + state: &Arc, + channel_id: Uuid, + before_message_id: Option, + limit: u32, +) -> Result<()> { + let user_id = session.require_auth()?; + let perms = roles::get_user_permissions(&state.db, user_id).await?; + if !has_permission(perms, PermissionFlags::READ_MESSAGES) { + return Err(ServerError::Forbidden); + } + + let msgs = messages::fetch_channel_history(&state.db, channel_id, before_message_id, limit).await?; + session.send(ServerMessage::ChannelHistory { channel_id, messages: msgs }) +} + +pub async fn handle_create( + session: &Session, + state: &Arc, + name: String, + topic: Option, +) -> Result<()> { + let user_id = session.require_auth()?; + let perms = roles::get_user_permissions(&state.db, user_id).await?; + if !has_permission(perms, PermissionFlags::MANAGE_CHANNELS) { + return Err(ServerError::Forbidden); + } + if channels::name_exists(&state.db, &name).await? { + return Err(ServerError::BadRequest("Channel name already exists".into())); + } + + let channel_id = channels::create(&state.db, &name, topic.as_deref(), user_id).await?; + + let _ = state.channel_broadcast.send(ChannelBroadcast { + message: ServerMessage::ChannelCreated { channel_id, name, topic }, + }); + + Ok(()) +} + +pub async fn handle_delete( + session: &Session, + state: &Arc, + channel_id: Uuid, +) -> Result<()> { + let user_id = session.require_auth()?; + let perms = roles::get_user_permissions(&state.db, user_id).await?; + if !has_permission(perms, PermissionFlags::MANAGE_CHANNELS) { + return Err(ServerError::Forbidden); + } + if !channels::delete(&state.db, channel_id).await? { + return Err(ServerError::NotFound("Channel not found".into())); + } + + let _ = state.channel_broadcast.send(ChannelBroadcast { + message: ServerMessage::ChannelDeleted { channel_id }, + }); + + Ok(()) +} diff --git a/backchannel-server/src/handlers/dms.rs b/backchannel-server/src/handlers/dms.rs new file mode 100644 index 0000000..e30218e --- /dev/null +++ b/backchannel-server/src/handlers/dms.rs @@ -0,0 +1,115 @@ +use std::sync::Arc; + +use backchannel_common::protocol::ServerMessage; +use uuid::Uuid; + +use crate::db::messages; +use crate::error::Result; +use crate::state::AppState; +use crate::ws::session::Session; + +pub async fn handle_init_key_exchange( + session: &Session, + state: &Arc, + recipient_id: Uuid, + sender_ephemeral_pubkey: String, +) -> Result<()> { + let initiator_id = session.require_auth()?; + let initiator_username = session.require_username()?.to_string(); + + // Store pending exchange in AppState (cleared once recipient accepts). + state + .pending_key_exchanges + .lock() + .await + .insert((initiator_id, recipient_id), sender_ephemeral_pubkey.clone()); + + // Relay to recipient if they're currently connected. + state + .send_to_user( + recipient_id, + &ServerMessage::DmKeyExchangeRequest { + initiator_id, + initiator_username, + sender_ephemeral_pubkey, + }, + ) + .await; + + Ok(()) +} + +pub async fn handle_accept_key_exchange( + session: &Session, + state: &Arc, + initiator_id: Uuid, + recipient_ephemeral_pubkey: String, +) -> Result<()> { + let recipient_id = session.require_auth()?; + let recipient_username = session.require_username()?.to_string(); + + // Clear the pending exchange. + state + .pending_key_exchanges + .lock() + .await + .remove(&(initiator_id, recipient_id)); + + // Relay response back to initiator. + state + .send_to_user( + initiator_id, + &ServerMessage::DmKeyExchangeResponse { + recipient_id, + recipient_username, + recipient_ephemeral_pubkey, + }, + ) + .await; + + Ok(()) +} + +pub async fn handle_send_dm( + session: &Session, + state: &Arc, + recipient_id: Uuid, + ciphertext: String, + nonce: String, +) -> Result<()> { + let sender_id = session.require_auth()?; + let sender_username = session.require_username()?.to_string(); + + let (message_id, timestamp) = + messages::insert_dm(&state.db, sender_id, recipient_id, &ciphertext, &nonce).await?; + + let msg = ServerMessage::DirectMessage { + message_id, + sender_id, + sender_username, + ciphertext, + nonce, + timestamp, + }; + + // Deliver to recipient and to sender's other sessions (multi-client sync). + state.send_to_user(recipient_id, &msg).await; + state.send_to_user(sender_id, &msg).await; + + Ok(()) +} + +pub async fn handle_dm_history( + session: &Session, + state: &Arc, + peer_id: Uuid, + before_message_id: Option, + limit: u32, +) -> Result<()> { + let user_id = session.require_auth()?; + + let history = + messages::fetch_dm_history(&state.db, user_id, peer_id, before_message_id, limit).await?; + + session.send(ServerMessage::DmHistory { peer_id, messages: history }) +} diff --git a/backchannel-server/src/handlers/mod.rs b/backchannel-server/src/handlers/mod.rs new file mode 100644 index 0000000..4774b2b --- /dev/null +++ b/backchannel-server/src/handlers/mod.rs @@ -0,0 +1,4 @@ +pub mod auth; +pub mod channels; +pub mod dms; +pub mod roles; diff --git a/backchannel-server/src/handlers/roles.rs b/backchannel-server/src/handlers/roles.rs new file mode 100644 index 0000000..9137097 --- /dev/null +++ b/backchannel-server/src/handlers/roles.rs @@ -0,0 +1,87 @@ +use std::sync::Arc; + +use backchannel_common::protocol::ServerMessage; +use backchannel_common::types::{has_permission, PermissionFlags}; +use uuid::Uuid; + +use crate::db::{keys, roles}; +use crate::error::{Result, ServerError}; +use crate::state::{AppState, ChannelBroadcast}; +use crate::ws::session::Session; + +pub async fn handle_create( + session: &Session, + state: &Arc, + name: String, + permissions: u64, +) -> Result<()> { + let user_id = session.require_auth()?; + let perms = roles::get_user_permissions(&state.db, user_id).await?; + if !has_permission(perms, PermissionFlags::MANAGE_ROLES) { + return Err(ServerError::Forbidden); + } + + let role_id = roles::create(&state.db, &name, permissions).await?; + + let _ = state.channel_broadcast.send(ChannelBroadcast { + message: ServerMessage::RoleCreated { role_id, name, permissions }, + }); + + Ok(()) +} + +pub async fn handle_assign( + session: &Session, + state: &Arc, + user_id: Uuid, + role_id: Uuid, +) -> Result<()> { + let caller_id = session.require_auth()?; + let perms = roles::get_user_permissions(&state.db, caller_id).await?; + if !has_permission(perms, PermissionFlags::MANAGE_ROLES) { + return Err(ServerError::Forbidden); + } + + roles::find_by_id(&state.db, role_id) + .await? + .ok_or_else(|| ServerError::NotFound("Role not found".into()))?; + + roles::assign(&state.db, user_id, role_id).await?; + + let _ = state.channel_broadcast.send(ChannelBroadcast { + message: ServerMessage::RoleAssigned { user_id, role_id }, + }); + + Ok(()) +} + +pub async fn handle_revoke( + session: &Session, + state: &Arc, + user_id: Uuid, + role_id: Uuid, +) -> Result<()> { + let caller_id = session.require_auth()?; + let perms = roles::get_user_permissions(&state.db, caller_id).await?; + if !has_permission(perms, PermissionFlags::MANAGE_ROLES) { + return Err(ServerError::Forbidden); + } + + roles::revoke(&state.db, user_id, role_id).await?; + + let _ = state.channel_broadcast.send(ChannelBroadcast { + message: ServerMessage::RoleRevoked { user_id, role_id }, + }); + + Ok(()) +} + +pub async fn handle_query_key( + session: &Session, + state: &Arc, + user_id: Uuid, +) -> Result<()> { + session.require_auth()?; + let pubkey = keys::get_identity_key(&state.db, user_id).await?; + session.send(ServerMessage::IdentityKeyResponse { user_id, identity_pubkey: pubkey }) +} diff --git a/backchannel-server/src/http/mod.rs b/backchannel-server/src/http/mod.rs new file mode 100644 index 0000000..06234c7 --- /dev/null +++ b/backchannel-server/src/http/mod.rs @@ -0,0 +1,3 @@ +mod server; + +pub use server::serve; diff --git a/backchannel-server/src/http/server.rs b/backchannel-server/src/http/server.rs new file mode 100644 index 0000000..50947a9 --- /dev/null +++ b/backchannel-server/src/http/server.rs @@ -0,0 +1,65 @@ +use anyhow::{Context, Result}; +use axum::body::Body; +use axum::extract::Path; +use axum::http::{header, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::Router; +use mime_guess::from_path; +use rust_embed::RustEmbed; + +#[derive(RustEmbed)] +#[folder = "../backchannel-web/dist"] +struct WebAssets; + +pub async fn serve(bind_addr: &str) -> Result<()> { + let app = Router::new() + .route("/", get(index)) + .route("/*path", get(asset_or_index)); + + let listener = tokio::net::TcpListener::bind(bind_addr) + .await + .with_context(|| format!("Failed to bind HTTP server to {bind_addr}"))?; + + tracing::info!("BackChannel web UI listening on http://{bind_addr}"); + axum::serve(listener, app).await.context("HTTP server failed") +} + +async fn index() -> Response { + render_asset("index.html") +} + +async fn asset_or_index(Path(path): Path) -> Response { + if path.is_empty() { + return render_asset("index.html"); + } + + render_asset(&path) +} + +fn render_asset(path: &str) -> Response { + let canonical = path.trim_start_matches('/'); + + if let Some(content) = WebAssets::get(canonical) { + return build_response(canonical, content.data.as_ref(), StatusCode::OK); + } + + if let Some(index) = WebAssets::get("index.html") { + return build_response("index.html", index.data.as_ref(), StatusCode::OK); + } + + (StatusCode::NOT_FOUND, "Web UI asset not found").into_response() +} + +fn build_response(path: &str, body: &[u8], status: StatusCode) -> Response { + let mime = from_path(path).first_or_octet_stream(); + + let mut response = Response::new(Body::from(body.to_vec())); + *response.status_mut() = status; + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_str(mime.as_ref()).unwrap_or(HeaderValue::from_static("application/octet-stream")), + ); + + response +} diff --git a/backchannel-server/src/main.rs b/backchannel-server/src/main.rs new file mode 100644 index 0000000..34ae2b5 --- /dev/null +++ b/backchannel-server/src/main.rs @@ -0,0 +1,62 @@ +mod auth; +mod config; +mod db; +mod error; +mod handlers; +mod http; +mod state; +mod ws; + +use std::sync::Arc; + +use anyhow::Result; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; +use std::str::FromStr; +use tokio::sync::{broadcast, RwLock, Mutex}; + +use state::AppState; + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "backchannel_server=debug,info".into()), + ) + .init(); + + let config = config::ServerConfig::from_env()?; + + let opts = SqliteConnectOptions::from_str(&config.db_url)?.create_if_missing(true); + let pool = SqlitePoolOptions::new() + .max_connections(5) + .connect_with(opts) + .await?; + + sqlx::migrate!("./src/db/migrations").run(&pool).await?; + tracing::info!("Database migrations applied"); + + let (bc_tx, _) = broadcast::channel(1024); + + let state = Arc::new(AppState { + sessions: RwLock::new(std::collections::HashMap::new()), + user_sessions: RwLock::new(std::collections::HashMap::new()), + pending_key_exchanges: Mutex::new(std::collections::HashMap::new()), + channel_broadcast: bc_tx, + db: pool, + jwt_secret: config.jwt_secret, + }); + + tokio::try_join!( + ws::listen( + Arc::clone(&state), + &config.bind_addr, + config.tls_enabled, + config.tls_cert_path, + config.tls_key_path, + ), + http::serve(&config.http_bind_addr) + )?; + + Ok(()) +} diff --git a/backchannel-server/src/state.rs b/backchannel-server/src/state.rs new file mode 100644 index 0000000..a4aa3d2 --- /dev/null +++ b/backchannel-server/src/state.rs @@ -0,0 +1,123 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use backchannel_common::protocol::ServerMessage; +use tokio::sync::{broadcast, mpsc, Mutex, RwLock}; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use uuid::Uuid; + +/// A handle to send WebSocket frames directly to one connection. +pub type WsSender = mpsc::UnboundedSender; + +/// State for a single authenticated WebSocket connection. +pub struct ConnectedSession { + pub user_id: Uuid, + pub username: String, + /// Sending on this channel pushes a frame to the client's write task. + pub tx: WsSender, +} + +/// A channel event broadcast to all connected clients. +/// +/// Channel messages are fanned out via a `tokio::sync::broadcast` channel +/// so that the handler never has to hold a lock while iterating sessions. +#[derive(Clone, Debug)] +pub struct ChannelBroadcast { + pub message: ServerMessage, +} + +/// The shared application state. Wrapped in `Arc` and passed to every task. +pub struct AppState { + /// All live WebSocket sessions, keyed by a per-connection UUID. + /// One user may hold multiple connections. + pub sessions: RwLock>, + + /// Reverse index: user_id → set of connection UUIDs. + /// Allows sending to all clients of a specific user (e.g. for DMs). + pub user_sessions: RwLock>>, + + /// In-flight DM key-exchange requests. + /// Key: (initiator_user_id, recipient_user_id) + /// Value: initiator's base64 X25519 ephemeral public key. + /// Cleared once the recipient accepts the exchange. + pub pending_key_exchanges: Mutex>, + + /// Server-wide broadcast for channel events. Each connection handler + /// calls `.subscribe()` to get its own receiver. + pub channel_broadcast: broadcast::Sender, + + /// SQLite connection pool. + pub db: sqlx::SqlitePool, + + /// Bytes used as the HMAC secret for JWT signing. + pub jwt_secret: Vec, +} + +impl AppState { + /// Look up all active sender handles for a given user ID. + /// Used when routing DMs or presence events to a specific user. + pub async fn senders_for_user(&self, user_id: Uuid) -> Vec { + let sessions = self.sessions.read().await; + let user_sessions = self.user_sessions.read().await; + + user_sessions + .get(&user_id) + .map(|conn_ids| { + conn_ids + .iter() + .filter_map(|id| sessions.get(id).map(|s| s.tx.clone())) + .collect() + }) + .unwrap_or_default() + } + + /// Send a `ServerMessage` to every connection belonging to `user_id`. + pub async fn send_to_user(&self, user_id: Uuid, msg: &ServerMessage) { + if let Ok(json) = serde_json::to_string(msg) { + for tx in self.senders_for_user(user_id).await { + let _ = tx.send(WsMessage::Text(json.clone())); + } + } + } + + /// Register a new connection in the session maps. + pub async fn register_session( + self: &Arc, + conn_id: Uuid, + user_id: Uuid, + username: String, + tx: WsSender, + ) { + let session = ConnectedSession { user_id, username, tx }; + self.sessions.write().await.insert(conn_id, session); + self.user_sessions + .write() + .await + .entry(user_id) + .or_default() + .push(conn_id); + } + + /// Remove a connection from the session maps. + pub async fn remove_session(self: &Arc, conn_id: Uuid) -> Option { + let session = self.sessions.write().await.remove(&conn_id)?; + let mut user_sessions = self.user_sessions.write().await; + if let Some(ids) = user_sessions.get_mut(&session.user_id) { + ids.retain(|id| id != &conn_id); + if ids.is_empty() { + user_sessions.remove(&session.user_id); + } + } + Some(session) + } + + /// Returns true if the given user has at least one active connection. + pub async fn is_user_online(&self, user_id: Uuid) -> bool { + self.user_sessions + .read() + .await + .get(&user_id) + .map(|v| !v.is_empty()) + .unwrap_or(false) + } +} diff --git a/backchannel-server/src/ws/handler.rs b/backchannel-server/src/ws/handler.rs new file mode 100644 index 0000000..ce6df78 --- /dev/null +++ b/backchannel-server/src/ws/handler.rs @@ -0,0 +1,179 @@ +use std::io::BufReader; +use std::net::SocketAddr; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use futures_util::{SinkExt, StreamExt}; +use tokio::net::TcpListener; +use tokio::sync::{broadcast, mpsc}; +use tokio_tungstenite::{accept_async, tungstenite::Message as WsMessage, WebSocketStream}; + +use backchannel_common::protocol::{ClientMessage, ServerMessage}; + +use crate::state::{AppState, ChannelBroadcast}; +use crate::ws::{router, session::Session}; + +/// Bind the TCP listener and accept connections in a loop. +pub async fn run( + state: Arc, + bind_addr: &str, + tls_enabled: bool, + tls_cert_path: Option, + tls_key_path: Option, +) -> Result<()> { + let listener = TcpListener::bind(bind_addr) + .await + .with_context(|| format!("Failed to bind to {}", bind_addr))?; + + tracing::info!("BackChannel server listening on ws{}://{}", if tls_enabled { "s" } else { "" }, bind_addr); + + if tls_enabled { + let cert_path = tls_cert_path.expect("tls_cert_path required when TLS enabled"); + let key_path = tls_key_path.expect("tls_key_path required when TLS enabled"); + let acceptor = build_tls_acceptor(&cert_path, &key_path)?; + + loop { + let (stream, addr) = listener.accept().await?; + let state = Arc::clone(&state); + let acceptor = acceptor.clone(); + + tokio::spawn(async move { + match acceptor.accept(stream).await { + Ok(tls_stream) => match accept_async(tls_stream).await { + Ok(ws) => handle_ws(ws, state, addr).await, + Err(e) => tracing::debug!("WS handshake failed from {}: {}", addr, e), + }, + Err(e) => tracing::debug!("TLS handshake failed from {}: {}", addr, e), + } + }); + } + } else { + loop { + let (stream, addr) = listener.accept().await?; + let state = Arc::clone(&state); + + tokio::spawn(async move { + match accept_async(stream).await { + Ok(ws) => handle_ws(ws, state, addr).await, + Err(e) => tracing::debug!("WS handshake failed from {}: {}", addr, e), + } + }); + } + } +} + +/// Per-connection WebSocket handler — generic over the underlying stream type +/// so it works with both plain TCP and TLS. +async fn handle_ws(ws_stream: WebSocketStream, state: Arc, addr: SocketAddr) +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, +{ + tracing::debug!("Connection established from {}", addr); + let (mut ws_sink, mut ws_source) = ws_stream.split(); + + // Per-connection mpsc: any task can push to this sender; the write task + // drains it to the WebSocket sink. + let (tx, mut rx) = mpsc::unbounded_channel::(); + + let mut session = Session::new(tx); + let conn_id = session.conn_id; + + // Subscribe to the server-wide channel broadcast before the loop starts. + let mut bc_rx = state.channel_broadcast.subscribe(); + + // Write task: merges direct messages (mpsc) + broadcast events → WS sink. + let write_task = tokio::spawn(async move { + loop { + tokio::select! { + msg = rx.recv() => { + match msg { + Some(m) => { if ws_sink.send(m).await.is_err() { break; } } + None => break, + } + } + event = bc_rx.recv() => { + match event { + Ok(ChannelBroadcast { message }) => { + if let Ok(json) = serde_json::to_string(&message) { + if ws_sink.send(WsMessage::Text(json)).await.is_err() { break; } + } + } + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("conn {} lagged by {} broadcast messages", conn_id, n); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + } + } + }); + + // Read loop: receive, deserialize, dispatch. + while let Some(msg_result) = ws_source.next().await { + match msg_result { + Ok(WsMessage::Text(text)) => { + match serde_json::from_str::(&text) { + Ok(client_msg) => { + if let Err(e) = router::route(client_msg, &mut session, &state).await { + let _ = session.send(ServerMessage::Error { + code: e.code(), + message: e.to_string(), + }); + } + } + Err(_) => { + let _ = session.send(ServerMessage::Error { + code: 400, + message: "Invalid message format".into(), + }); + } + } + } + Ok(WsMessage::Close(_)) | Err(_) => break, + Ok(_) => {} // Ignore Ping/Pong/Binary frames. + } + } + + tracing::debug!("Connection closed from {}", addr); + + // Clean up session maps and broadcast offline status. + if let Some(removed) = state.remove_session(conn_id).await { + if !state.is_user_online(removed.user_id).await { + let _ = state.channel_broadcast.send(ChannelBroadcast { + message: ServerMessage::UserOffline { + user_id: removed.user_id, + username: removed.username, + }, + }); + } + } + + write_task.abort(); +} + +/// Build a `tokio_rustls::TlsAcceptor` from PEM cert and key files. +fn build_tls_acceptor(cert_path: &str, key_path: &str) -> Result { + use rustls::ServerConfig; + use rustls_pemfile::{certs, private_key}; + use std::fs::File; + + let cert_file = File::open(cert_path) + .with_context(|| format!("Cannot open TLS cert: {}", cert_path))?; + let key_file = File::open(key_path) + .with_context(|| format!("Cannot open TLS key: {}", key_path))?; + + let certs: Vec<_> = certs(&mut BufReader::new(cert_file)) + .collect::>() + .context("Failed to parse TLS certificates")?; + + let key = private_key(&mut BufReader::new(key_file)) + .context("Failed to parse TLS private key")? + .context("No private key found in key file")?; + + let config = ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(certs, key) + .context("Invalid TLS certificate/key combination")?; + + Ok(tokio_rustls::TlsAcceptor::from(Arc::new(config))) +} diff --git a/backchannel-server/src/ws/mod.rs b/backchannel-server/src/ws/mod.rs new file mode 100644 index 0000000..23a08f6 --- /dev/null +++ b/backchannel-server/src/ws/mod.rs @@ -0,0 +1,18 @@ +pub mod handler; +pub mod router; +pub mod session; + +use std::sync::Arc; +use anyhow::Result; +use crate::state::AppState; + +/// Bind the TCP listener and accept WebSocket connections. +pub async fn listen( + state: Arc, + bind_addr: &str, + tls_enabled: bool, + tls_cert_path: Option, + tls_key_path: Option, +) -> Result<()> { + handler::run(state, bind_addr, tls_enabled, tls_cert_path, tls_key_path).await +} diff --git a/backchannel-server/src/ws/router.rs b/backchannel-server/src/ws/router.rs new file mode 100644 index 0000000..a6fcf61 --- /dev/null +++ b/backchannel-server/src/ws/router.rs @@ -0,0 +1,64 @@ +use std::sync::Arc; + +use backchannel_common::protocol::{ClientMessage, ServerMessage}; + +use crate::error::Result; +use crate::handlers; +use crate::state::AppState; +use crate::ws::session::Session; + +/// Dispatch a `ClientMessage` to the appropriate handler. +pub async fn route(msg: ClientMessage, session: &mut Session, state: &Arc) -> Result<()> { + use ClientMessage::*; + match msg { + Login { username, password, identity_pubkey } => { + handlers::auth::handle_login(session, state, username, password, identity_pubkey).await + } + Register { username, password, identity_pubkey } => { + handlers::auth::handle_register(session, state, username, password, identity_pubkey).await + } + ResumeSession { token } => { + handlers::auth::handle_resume(session, state, token).await + } + Logout => { + handlers::auth::handle_logout(session, state).await + } + SendChannelMessage { channel_id, content } => { + handlers::channels::handle_send(session, state, channel_id, content).await + } + FetchChannelHistory { channel_id, before_message_id, limit } => { + handlers::channels::handle_history(session, state, channel_id, before_message_id, limit).await + } + CreateChannel { name, topic } => { + handlers::channels::handle_create(session, state, name, topic).await + } + DeleteChannel { channel_id } => { + handlers::channels::handle_delete(session, state, channel_id).await + } + InitDmKeyExchange { recipient_id, sender_ephemeral_pubkey } => { + handlers::dms::handle_init_key_exchange(session, state, recipient_id, sender_ephemeral_pubkey).await + } + AcceptDmKeyExchange { initiator_id, recipient_ephemeral_pubkey } => { + handlers::dms::handle_accept_key_exchange(session, state, initiator_id, recipient_ephemeral_pubkey).await + } + SendDm { recipient_id, ciphertext, nonce } => { + handlers::dms::handle_send_dm(session, state, recipient_id, ciphertext, nonce).await + } + FetchDmHistory { peer_id, before_message_id, limit } => { + handlers::dms::handle_dm_history(session, state, peer_id, before_message_id, limit).await + } + CreateRole { name, permissions } => { + handlers::roles::handle_create(session, state, name, permissions).await + } + AssignRole { user_id, role_id } => { + handlers::roles::handle_assign(session, state, user_id, role_id).await + } + RevokeRole { user_id, role_id } => { + handlers::roles::handle_revoke(session, state, user_id, role_id).await + } + QueryIdentityKey { user_id } => { + handlers::roles::handle_query_key(session, state, user_id).await + } + Ping => session.send(ServerMessage::Pong), + } +} diff --git a/backchannel-server/src/ws/session.rs b/backchannel-server/src/ws/session.rs new file mode 100644 index 0000000..6ea7e6c --- /dev/null +++ b/backchannel-server/src/ws/session.rs @@ -0,0 +1,45 @@ +use backchannel_common::protocol::ServerMessage; +use tokio_tungstenite::tungstenite::Message as WsMessage; +use uuid::Uuid; + +use crate::error::{Result, ServerError}; +use crate::state::WsSender; + +/// Per-connection mutable state, owned exclusively by the connection's read task. +pub struct Session { + pub conn_id: Uuid, + pub user_id: Option, + pub username: Option, + /// JWT ID stored for revocation on logout. + pub jti: Option, + /// Sender to the write task for this connection. + pub tx: WsSender, +} + +impl Session { + pub fn new(tx: WsSender) -> Self { + Self { + conn_id: Uuid::new_v4(), + user_id: None, + username: None, + jti: None, + tx, + } + } + + /// Serialize and send a `ServerMessage` to this connection's write task. + pub fn send(&self, msg: ServerMessage) -> Result<()> { + let json = serde_json::to_string(&msg)?; + self.tx + .send(WsMessage::Text(json)) + .map_err(|_| ServerError::ConnectionClosed) + } + + pub fn require_auth(&self) -> Result { + self.user_id.ok_or(ServerError::Unauthorized) + } + + pub fn require_username(&self) -> Result<&str> { + self.username.as_deref().ok_or(ServerError::Unauthorized) + } +} diff --git a/backchannel-web/README.md b/backchannel-web/README.md new file mode 100644 index 0000000..7102159 --- /dev/null +++ b/backchannel-web/README.md @@ -0,0 +1,24 @@ +# BackChannel Web + +React + TypeScript browser client for BackChannel. + +## Development + +```powershell +cd backchannel-web +npm install +npm run dev +``` + +Default WebSocket target in the app is `ws://:7777` and can be changed from the UI. + +## Build for Embedded Server + +```powershell +cd backchannel-web +npm install +npm run build +``` + +The Rust server embeds files from `backchannel-web/dist` at compile time. +Rebuild the Rust server after producing a new web build. diff --git a/backchannel-web/dist/assets/index-C3Y0u3Sh.css b/backchannel-web/dist/assets/index-C3Y0u3Sh.css new file mode 100644 index 0000000..49ca07e --- /dev/null +++ b/backchannel-web/dist/assets/index-C3Y0u3Sh.css @@ -0,0 +1 @@ +:root{color-scheme:light;--bg-0: #0d1b2a;--panel: #fffaf0;--text: #1d2d44;--accent: #e76f51;--accent-2: #2a9d8f;--line: #f4a261}*{box-sizing:border-box}body{margin:0;min-height:100vh;background:radial-gradient(circle at top left,#415a77,var(--bg-0) 55%);font-family:Space Grotesk,Trebuchet MS,sans-serif;color:var(--text)}.layout{width:min(1400px,100vw);margin:0 auto;padding:20px;display:grid;gap:14px}.card{background:linear-gradient(145deg,#fffaf0,#fdf0d5);border:2px solid #e9c46a;border-radius:16px;box-shadow:0 8px 24px #0003}.topbar{padding:16px;display:flex;flex-wrap:wrap;justify-content:space-between;align-items:end;gap:12px}h1{margin:0;font-family:Bebas Neue,Impact,sans-serif;letter-spacing:.06em;font-size:clamp(2rem,4vw,2.8rem)}h2{margin:0;font-family:Bebas Neue,Impact,sans-serif;letter-spacing:.05em}p{margin:6px 0 0}.connection{display:flex;gap:8px;min-width:min(540px,100%)}.status{padding:10px 14px;font-weight:700;border-color:var(--accent-2)}.chat-shell{position:relative;padding-left:350px}.panel{padding:14px;display:grid;gap:10px}.chat-main{min-height:calc(100vh - 220px)}.auth-sidebar{position:fixed;left:20px;bottom:20px;width:320px;z-index:10}.switch{display:flex;gap:8px}.switch .active{background:var(--accent-2);color:#fff}input{width:100%;border:2px solid #264653;border-radius:10px;padding:8px 10px;font:inherit;background:#fff}form{display:grid;gap:8px}button{border:0;background:var(--accent);color:#fff;border-radius:10px;padding:8px 10px;cursor:pointer;font-weight:700}.chat-main form{grid-template-columns:1fr auto;align-items:end}.send-button{padding:6px 10px;min-width:68px}button:disabled{opacity:.5;cursor:not-allowed}.meta{display:grid;gap:6px;font-size:.9rem}.log{background:#fff;border:1px solid var(--line);border-radius:10px;min-height:200px;overflow-y:auto;padding:8px;display:flex;flex-direction:column;justify-content:flex-end;gap:6px}.chat-log{max-height:calc(100vh - 360px)}.line{display:grid;gap:2px;border-bottom:1px dashed #c8d4dd;padding-bottom:6px}time{font-size:.75rem;color:#526072}@media (max-width: 900px){.chat-shell{padding-left:0}.auth-sidebar{position:static;width:100%;margin-top:14px}.chat-main{min-height:auto}.chat-log{max-height:50vh}.connection{min-width:0;width:100%;flex-direction:column}} diff --git a/backchannel-web/dist/assets/index-DEokFOhy.js b/backchannel-web/dist/assets/index-DEokFOhy.js new file mode 100644 index 0000000..5006d28 --- /dev/null +++ b/backchannel-web/dist/assets/index-DEokFOhy.js @@ -0,0 +1,40 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const u of l)if(u.type==="childList")for(const o of u.addedNodes)o.tagName==="LINK"&&o.rel==="modulepreload"&&r(o)}).observe(document,{childList:!0,subtree:!0});function n(l){const u={};return l.integrity&&(u.integrity=l.integrity),l.referrerPolicy&&(u.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?u.credentials="include":l.crossOrigin==="anonymous"?u.credentials="omit":u.credentials="same-origin",u}function r(l){if(l.ep)return;l.ep=!0;const u=n(l);fetch(l.href,u)}})();function lc(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Qi={exports:{}},nl={},Ki={exports:{}},R={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Zn=Symbol.for("react.element"),uc=Symbol.for("react.portal"),oc=Symbol.for("react.fragment"),ic=Symbol.for("react.strict_mode"),sc=Symbol.for("react.profiler"),ac=Symbol.for("react.provider"),cc=Symbol.for("react.context"),fc=Symbol.for("react.forward_ref"),dc=Symbol.for("react.suspense"),pc=Symbol.for("react.memo"),mc=Symbol.for("react.lazy"),Do=Symbol.iterator;function hc(e){return e===null||typeof e!="object"?null:(e=Do&&e[Do]||e["@@iterator"],typeof e=="function"?e:null)}var Yi={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},Xi=Object.assign,Gi={};function sn(e,t,n){this.props=e,this.context=t,this.refs=Gi,this.updater=n||Yi}sn.prototype.isReactComponent={};sn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};sn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function Zi(){}Zi.prototype=sn.prototype;function $u(e,t,n){this.props=e,this.context=t,this.refs=Gi,this.updater=n||Yi}var Bu=$u.prototype=new Zi;Bu.constructor=$u;Xi(Bu,sn.prototype);Bu.isPureReactComponent=!0;var Fo=Array.isArray,Ji=Object.prototype.hasOwnProperty,Wu={current:null},qi={key:!0,ref:!0,__self:!0,__source:!0};function bi(e,t,n){var r,l={},u=null,o=null;if(t!=null)for(r in t.ref!==void 0&&(o=t.ref),t.key!==void 0&&(u=""+t.key),t)Ji.call(t,r)&&!qi.hasOwnProperty(r)&&(l[r]=t[r]);var i=arguments.length-2;if(i===1)l.children=n;else if(1>>1,J=x[K];if(0>>1;Kl(Sl,T))wtl(nr,Sl)?(x[K]=nr,x[wt]=T,K=wt):(x[K]=Sl,x[gt]=T,K=gt);else if(wtl(nr,T))x[K]=nr,x[wt]=T,K=wt;else break e}}return z}function l(x,z){var T=x.sortIndex-z.sortIndex;return T!==0?T:x.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var u=performance;e.unstable_now=function(){return u.now()}}else{var o=Date,i=o.now();e.unstable_now=function(){return o.now()-i}}var s=[],f=[],h=1,m=null,p=3,g=!1,w=!1,S=!1,I=typeof setTimeout=="function"?setTimeout:null,c=typeof clearTimeout=="function"?clearTimeout:null,a=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function d(x){for(var z=n(f);z!==null;){if(z.callback===null)r(f);else if(z.startTime<=x)r(f),z.sortIndex=z.expirationTime,t(s,z);else break;z=n(f)}}function v(x){if(S=!1,d(x),!w)if(n(s)!==null)w=!0,gl(k);else{var z=n(f);z!==null&&wl(v,z.startTime-x)}}function k(x,z){w=!1,S&&(S=!1,c(N),N=-1),g=!0;var T=p;try{for(d(z),m=n(s);m!==null&&(!(m.expirationTime>z)||x&&!ye());){var K=m.callback;if(typeof K=="function"){m.callback=null,p=m.priorityLevel;var J=K(m.expirationTime<=z);z=e.unstable_now(),typeof J=="function"?m.callback=J:m===n(s)&&r(s),d(z)}else r(s);m=n(s)}if(m!==null)var tr=!0;else{var gt=n(f);gt!==null&&wl(v,gt.startTime-z),tr=!1}return tr}finally{m=null,p=T,g=!1}}var _=!1,C=null,N=-1,$=5,L=-1;function ye(){return!(e.unstable_now()-L<$)}function P(){if(C!==null){var x=e.unstable_now();L=x;var z=!0;try{z=C(!0,x)}finally{z?A():(_=!1,C=null)}}else _=!1}var A;if(typeof a=="function")A=function(){a(P)};else if(typeof MessageChannel<"u"){var fn=new MessageChannel,rc=fn.port2;fn.port1.onmessage=P,A=function(){rc.postMessage(null)}}else A=function(){I(P,0)};function gl(x){C=x,_||(_=!0,A())}function wl(x,z){N=I(function(){x(e.unstable_now())},z)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(x){x.callback=null},e.unstable_continueExecution=function(){w||g||(w=!0,gl(k))},e.unstable_forceFrameRate=function(x){0>x||125K?(x.sortIndex=T,t(f,x),n(s)===null&&x===n(f)&&(S?(c(N),N=-1):S=!0,wl(v,T-K))):(x.sortIndex=J,t(s,x),w||g||(w=!0,gl(k))),x},e.unstable_shouldYield=ye,e.unstable_wrapCallback=function(x){var z=p;return function(){var T=p;p=z;try{return x.apply(this,arguments)}finally{p=T}}}})(ls);rs.exports=ls;var Pc=rs.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var zc=ue,ke=Pc;function y(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),Gl=Object.prototype.hasOwnProperty,Tc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,Ao={},Vo={};function Lc(e){return Gl.call(Vo,e)?!0:Gl.call(Ao,e)?!1:Tc.test(e)?Vo[e]=!0:(Ao[e]=!0,!1)}function Rc(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function Oc(e,t,n,r){if(t===null||typeof t>"u"||Rc(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function fe(e,t,n,r,l,u,o){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=u,this.removeEmptyString=o}var ne={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){ne[e]=new fe(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];ne[t]=new fe(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){ne[e]=new fe(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){ne[e]=new fe(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){ne[e]=new fe(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){ne[e]=new fe(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){ne[e]=new fe(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){ne[e]=new fe(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){ne[e]=new fe(e,5,!1,e.toLowerCase(),null,!1,!1)});var Qu=/[\-:]([a-z])/g;function Ku(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(Qu,Ku);ne[t]=new fe(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(Qu,Ku);ne[t]=new fe(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(Qu,Ku);ne[t]=new fe(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){ne[e]=new fe(e,1,!1,e.toLowerCase(),null,!1,!1)});ne.xlinkHref=new fe("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){ne[e]=new fe(e,1,!1,e.toLowerCase(),null,!0,!0)});function Yu(e,t,n,r){var l=ne.hasOwnProperty(t)?ne[t]:null;(l!==null?l.type!==0:r||!(2i||l[o]!==u[i]){var s=` +`+l[o].replace(" at new "," at ");return e.displayName&&s.includes("")&&(s=s.replace("",e.displayName)),s}while(1<=o&&0<=i);break}}}finally{_l=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Sn(e):""}function jc(e){switch(e.tag){case 5:return Sn(e.type);case 16:return Sn("Lazy");case 13:return Sn("Suspense");case 19:return Sn("SuspenseList");case 0:case 2:case 15:return e=xl(e.type,!1),e;case 11:return e=xl(e.type.render,!1),e;case 1:return e=xl(e.type,!0),e;default:return""}}function bl(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Ft:return"Fragment";case Dt:return"Portal";case Zl:return"Profiler";case Xu:return"StrictMode";case Jl:return"Suspense";case ql:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case is:return(e.displayName||"Context")+".Consumer";case os:return(e._context.displayName||"Context")+".Provider";case Gu:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case Zu:return t=e.displayName||null,t!==null?t:bl(e.type)||"Memo";case be:t=e._payload,e=e._init;try{return bl(e(t))}catch{}}return null}function Mc(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return bl(t);case 8:return t===Xu?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function pt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function as(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function Ic(e){var t=as(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,u=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(o){r=""+o,u.call(this,o)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(o){r=""+o},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function ur(e){e._valueTracker||(e._valueTracker=Ic(e))}function cs(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=as(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Or(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function eu(e,t){var n=t.checked;return H({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function Bo(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=pt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function fs(e,t){t=t.checked,t!=null&&Yu(e,"checked",t,!1)}function tu(e,t){fs(e,t);var n=pt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?nu(e,t.type,n):t.hasOwnProperty("defaultValue")&&nu(e,t.type,pt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function Wo(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function nu(e,t,n){(t!=="number"||Or(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var kn=Array.isArray;function Xt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=or.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Mn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var xn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},Dc=["Webkit","ms","Moz","O"];Object.keys(xn).forEach(function(e){Dc.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),xn[t]=xn[e]})});function hs(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||xn.hasOwnProperty(e)&&xn[e]?(""+t).trim():t+"px"}function vs(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=hs(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var Fc=H({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function uu(e,t){if(t){if(Fc[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(y(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(y(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(y(61))}if(t.style!=null&&typeof t.style!="object")throw Error(y(62))}}function ou(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var iu=null;function Ju(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var su=null,Gt=null,Zt=null;function Ko(e){if(e=bn(e)){if(typeof su!="function")throw Error(y(280));var t=e.stateNode;t&&(t=il(t),su(e.stateNode,e.type,t))}}function ys(e){Gt?Zt?Zt.push(e):Zt=[e]:Gt=e}function gs(){if(Gt){var e=Gt,t=Zt;if(Zt=Gt=null,Ko(e),t)for(e=0;e>>=0,e===0?32:31-(Xc(e)/Gc|0)|0}var ir=64,sr=4194304;function En(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Dr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,u=e.pingedLanes,o=n&268435455;if(o!==0){var i=o&~l;i!==0?r=En(i):(u&=o,u!==0&&(r=En(u)))}else o=n&~l,o!==0?r=En(o):u!==0&&(r=En(u));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,u=t&-t,l>=u||l===16&&(u&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function Jn(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Me(t),e[t]=n}function bc(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Nn),ti=" ",ni=!1;function Us(e,t){switch(e){case"keyup":return zf.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function As(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Ut=!1;function Lf(e,t){switch(e){case"compositionend":return As(t);case"keypress":return t.which!==32?null:(ni=!0,ti);case"textInput":return e=t.data,e===ti&&ni?null:e;default:return null}}function Rf(e,t){if(Ut)return e==="compositionend"||!uo&&Us(e,t)?(e=Ds(),_r=no=rt=null,Ut=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=oi(n)}}function Ws(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Ws(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Hs(){for(var e=window,t=Or();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Or(e.document)}return t}function oo(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function Vf(e){var t=Hs(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&Ws(n.ownerDocument.documentElement,n)){if(r!==null&&oo(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,u=Math.min(r.start,l);r=r.end===void 0?u:Math.min(r.end,l),!e.extend&&u>r&&(l=r,r=u,u=l),l=ii(n,u);var o=ii(n,r);l&&o&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==o.node||e.focusOffset!==o.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),u>r?(e.addRange(t),e.extend(o.node,o.offset)):(t.setEnd(o.node,o.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,At=null,mu=null,zn=null,hu=!1;function si(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;hu||At==null||At!==Or(r)||(r=At,"selectionStart"in r&&oo(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),zn&&Vn(zn,r)||(zn=r,r=Ar(mu,"onSelect"),0Bt||(e.current=ku[Bt],ku[Bt]=null,Bt--)}function D(e,t){Bt++,ku[Bt]=e.current,e.current=t}var mt={},ie=vt(mt),me=vt(!1),Pt=mt;function tn(e,t){var n=e.type.contextTypes;if(!n)return mt;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},u;for(u in n)l[u]=t[u];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function he(e){return e=e.childContextTypes,e!=null}function $r(){U(me),U(ie)}function hi(e,t,n){if(ie.current!==mt)throw Error(y(168));D(ie,t),D(me,n)}function bs(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(y(108,Mc(e)||"Unknown",l));return H({},n,r)}function Br(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||mt,Pt=ie.current,D(ie,e),D(me,me.current),!0}function vi(e,t,n){var r=e.stateNode;if(!r)throw Error(y(169));n?(e=bs(e,t,Pt),r.__reactInternalMemoizedMergedChildContext=e,U(me),U(ie),D(ie,e)):U(me),D(me,n)}var We=null,sl=!1,Ul=!1;function ea(e){We===null?We=[e]:We.push(e)}function qf(e){sl=!0,ea(e)}function yt(){if(!Ul&&We!==null){Ul=!0;var e=0,t=M;try{var n=We;for(M=1;e>=o,l-=o,He=1<<32-Me(t)+l|n<N?($=C,C=null):$=C.sibling;var L=p(c,C,d[N],v);if(L===null){C===null&&(C=$);break}e&&C&&L.alternate===null&&t(c,C),a=u(L,a,N),_===null?k=L:_.sibling=L,_=L,C=$}if(N===d.length)return n(c,C),V&&St(c,N),k;if(C===null){for(;NN?($=C,C=null):$=C.sibling;var ye=p(c,C,L.value,v);if(ye===null){C===null&&(C=$);break}e&&C&&ye.alternate===null&&t(c,C),a=u(ye,a,N),_===null?k=ye:_.sibling=ye,_=ye,C=$}if(L.done)return n(c,C),V&&St(c,N),k;if(C===null){for(;!L.done;N++,L=d.next())L=m(c,L.value,v),L!==null&&(a=u(L,a,N),_===null?k=L:_.sibling=L,_=L);return V&&St(c,N),k}for(C=r(c,C);!L.done;N++,L=d.next())L=g(C,c,N,L.value,v),L!==null&&(e&&L.alternate!==null&&C.delete(L.key===null?N:L.key),a=u(L,a,N),_===null?k=L:_.sibling=L,_=L);return e&&C.forEach(function(P){return t(c,P)}),V&&St(c,N),k}function I(c,a,d,v){if(typeof d=="object"&&d!==null&&d.type===Ft&&d.key===null&&(d=d.props.children),typeof d=="object"&&d!==null){switch(d.$$typeof){case lr:e:{for(var k=d.key,_=a;_!==null;){if(_.key===k){if(k=d.type,k===Ft){if(_.tag===7){n(c,_.sibling),a=l(_,d.props.children),a.return=c,c=a;break e}}else if(_.elementType===k||typeof k=="object"&&k!==null&&k.$$typeof===be&&wi(k)===_.type){n(c,_.sibling),a=l(_,d.props),a.ref=yn(c,_,d),a.return=c,c=a;break e}n(c,_);break}else t(c,_);_=_.sibling}d.type===Ft?(a=Nt(d.props.children,c.mode,v,d.key),a.return=c,c=a):(v=Rr(d.type,d.key,d.props,null,c.mode,v),v.ref=yn(c,a,d),v.return=c,c=v)}return o(c);case Dt:e:{for(_=d.key;a!==null;){if(a.key===_)if(a.tag===4&&a.stateNode.containerInfo===d.containerInfo&&a.stateNode.implementation===d.implementation){n(c,a.sibling),a=l(a,d.children||[]),a.return=c,c=a;break e}else{n(c,a);break}else t(c,a);a=a.sibling}a=Kl(d,c.mode,v),a.return=c,c=a}return o(c);case be:return _=d._init,I(c,a,_(d._payload),v)}if(kn(d))return w(c,a,d,v);if(dn(d))return S(c,a,d,v);hr(c,d)}return typeof d=="string"&&d!==""||typeof d=="number"?(d=""+d,a!==null&&a.tag===6?(n(c,a.sibling),a=l(a,d),a.return=c,c=a):(n(c,a),a=Ql(d,c.mode,v),a.return=c,c=a),o(c)):n(c,a)}return I}var rn=la(!0),ua=la(!1),Qr=vt(null),Kr=null,Qt=null,co=null;function fo(){co=Qt=Kr=null}function po(e){var t=Qr.current;U(Qr),e._currentValue=t}function xu(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function qt(e,t){Kr=e,co=Qt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(pe=!0),e.firstContext=null)}function ze(e){var t=e._currentValue;if(co!==e)if(e={context:e,memoizedValue:t,next:null},Qt===null){if(Kr===null)throw Error(y(308));Qt=e,Kr.dependencies={lanes:0,firstContext:e}}else Qt=Qt.next=e;return t}var _t=null;function mo(e){_t===null?_t=[e]:_t.push(e)}function oa(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,mo(t)):(n.next=l.next,l.next=n),t.interleaved=n,Ge(e,r)}function Ge(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var et=!1;function ho(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function ia(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Ke(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function at(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,j&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Ge(e,n)}return l=r.interleaved,l===null?(t.next=t,mo(r)):(t.next=l.next,l.next=t),r.interleaved=t,Ge(e,n)}function Cr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,bu(e,n)}}function Si(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,u=null;if(n=n.firstBaseUpdate,n!==null){do{var o={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};u===null?l=u=o:u=u.next=o,n=n.next}while(n!==null);u===null?l=u=t:u=u.next=t}else l=u=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:u,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function Yr(e,t,n,r){var l=e.updateQueue;et=!1;var u=l.firstBaseUpdate,o=l.lastBaseUpdate,i=l.shared.pending;if(i!==null){l.shared.pending=null;var s=i,f=s.next;s.next=null,o===null?u=f:o.next=f,o=s;var h=e.alternate;h!==null&&(h=h.updateQueue,i=h.lastBaseUpdate,i!==o&&(i===null?h.firstBaseUpdate=f:i.next=f,h.lastBaseUpdate=s))}if(u!==null){var m=l.baseState;o=0,h=f=s=null,i=u;do{var p=i.lane,g=i.eventTime;if((r&p)===p){h!==null&&(h=h.next={eventTime:g,lane:0,tag:i.tag,payload:i.payload,callback:i.callback,next:null});e:{var w=e,S=i;switch(p=t,g=n,S.tag){case 1:if(w=S.payload,typeof w=="function"){m=w.call(g,m,p);break e}m=w;break e;case 3:w.flags=w.flags&-65537|128;case 0:if(w=S.payload,p=typeof w=="function"?w.call(g,m,p):w,p==null)break e;m=H({},m,p);break e;case 2:et=!0}}i.callback!==null&&i.lane!==0&&(e.flags|=64,p=l.effects,p===null?l.effects=[i]:p.push(i))}else g={eventTime:g,lane:p,tag:i.tag,payload:i.payload,callback:i.callback,next:null},h===null?(f=h=g,s=m):h=h.next=g,o|=p;if(i=i.next,i===null){if(i=l.shared.pending,i===null)break;p=i,i=p.next,p.next=null,l.lastBaseUpdate=p,l.shared.pending=null}}while(!0);if(h===null&&(s=m),l.baseState=s,l.firstBaseUpdate=f,l.lastBaseUpdate=h,t=l.shared.interleaved,t!==null){l=t;do o|=l.lane,l=l.next;while(l!==t)}else u===null&&(l.shared.lanes=0);Lt|=o,e.lanes=o,e.memoizedState=m}}function ki(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Vl.transition;Vl.transition={};try{e(!1),t()}finally{M=n,Vl.transition=r}}function xa(){return Te().memoizedState}function nd(e,t,n){var r=ft(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Ca(e))Na(t,n);else if(n=oa(e,t,n,r),n!==null){var l=ae();Ie(n,e,r,l),Pa(n,t,r)}}function rd(e,t,n){var r=ft(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Ca(e))Na(t,l);else{var u=e.alternate;if(e.lanes===0&&(u===null||u.lanes===0)&&(u=t.lastRenderedReducer,u!==null))try{var o=t.lastRenderedState,i=u(o,n);if(l.hasEagerState=!0,l.eagerState=i,De(i,o)){var s=t.interleaved;s===null?(l.next=l,mo(t)):(l.next=s.next,s.next=l),t.interleaved=l;return}}catch{}finally{}n=oa(e,t,l,r),n!==null&&(l=ae(),Ie(n,e,r,l),Pa(n,t,r))}}function Ca(e){var t=e.alternate;return e===W||t!==null&&t===W}function Na(e,t){Tn=Gr=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Pa(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,bu(e,n)}}var Zr={readContext:ze,useCallback:re,useContext:re,useEffect:re,useImperativeHandle:re,useInsertionEffect:re,useLayoutEffect:re,useMemo:re,useReducer:re,useRef:re,useState:re,useDebugValue:re,useDeferredValue:re,useTransition:re,useMutableSource:re,useSyncExternalStore:re,useId:re,unstable_isNewReconciler:!1},ld={readContext:ze,useCallback:function(e,t){return Ue().memoizedState=[e,t===void 0?null:t],e},useContext:ze,useEffect:_i,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Pr(4194308,4,wa.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Pr(4194308,4,e,t)},useInsertionEffect:function(e,t){return Pr(4,2,e,t)},useMemo:function(e,t){var n=Ue();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=Ue();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=nd.bind(null,W,e),[r.memoizedState,e]},useRef:function(e){var t=Ue();return e={current:e},t.memoizedState=e},useState:Ei,useDebugValue:_o,useDeferredValue:function(e){return Ue().memoizedState=e},useTransition:function(){var e=Ei(!1),t=e[0];return e=td.bind(null,e[1]),Ue().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=W,l=Ue();if(V){if(n===void 0)throw Error(y(407));n=n()}else{if(n=t(),b===null)throw Error(y(349));Tt&30||fa(r,t,n)}l.memoizedState=n;var u={value:n,getSnapshot:t};return l.queue=u,_i(pa.bind(null,r,u,e),[e]),r.flags|=2048,Xn(9,da.bind(null,r,u,n,t),void 0,null),n},useId:function(){var e=Ue(),t=b.identifierPrefix;if(V){var n=Qe,r=He;n=(r&~(1<<32-Me(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Kn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=o.createElement(n,{is:r.is}):(e=o.createElement(n),n==="select"&&(o=e,r.multiple?o.multiple=!0:r.size&&(o.size=r.size))):e=o.createElementNS(e,n),e[Ae]=t,e[Wn]=r,Fa(e,t,!1,!1),t.stateNode=e;e:{switch(o=ou(n,r),n){case"dialog":F("cancel",e),F("close",e),l=r;break;case"iframe":case"object":case"embed":F("load",e),l=r;break;case"video":case"audio":for(l=0;l<_n.length;l++)F(_n[l],e);l=r;break;case"source":F("error",e),l=r;break;case"img":case"image":case"link":F("error",e),F("load",e),l=r;break;case"details":F("toggle",e),l=r;break;case"input":Bo(e,r),l=eu(e,r),F("invalid",e);break;case"option":l=r;break;case"select":e._wrapperState={wasMultiple:!!r.multiple},l=H({},r,{value:void 0}),F("invalid",e);break;case"textarea":Ho(e,r),l=ru(e,r),F("invalid",e);break;default:l=r}uu(n,l),i=l;for(u in i)if(i.hasOwnProperty(u)){var s=i[u];u==="style"?vs(e,s):u==="dangerouslySetInnerHTML"?(s=s?s.__html:void 0,s!=null&&ms(e,s)):u==="children"?typeof s=="string"?(n!=="textarea"||s!=="")&&Mn(e,s):typeof s=="number"&&Mn(e,""+s):u!=="suppressContentEditableWarning"&&u!=="suppressHydrationWarning"&&u!=="autoFocus"&&(jn.hasOwnProperty(u)?s!=null&&u==="onScroll"&&F("scroll",e):s!=null&&Yu(e,u,s,o))}switch(n){case"input":ur(e),Wo(e,r,!1);break;case"textarea":ur(e),Qo(e);break;case"option":r.value!=null&&e.setAttribute("value",""+pt(r.value));break;case"select":e.multiple=!!r.multiple,u=r.value,u!=null?Xt(e,!!r.multiple,u,!1):r.defaultValue!=null&&Xt(e,!!r.multiple,r.defaultValue,!0);break;default:typeof l.onClick=="function"&&(e.onclick=Vr)}switch(n){case"button":case"input":case"select":case"textarea":r=!!r.autoFocus;break e;case"img":r=!0;break e;default:r=!1}}r&&(t.flags|=4)}t.ref!==null&&(t.flags|=512,t.flags|=2097152)}return le(t),null;case 6:if(e&&t.stateNode!=null)Aa(e,t,e.memoizedProps,r);else{if(typeof r!="string"&&t.stateNode===null)throw Error(y(166));if(n=xt(Qn.current),xt($e.current),mr(t)){if(r=t.stateNode,n=t.memoizedProps,r[Ae]=t,(u=r.nodeValue!==n)&&(e=Se,e!==null))switch(e.tag){case 3:pr(r.nodeValue,n,(e.mode&1)!==0);break;case 5:e.memoizedProps.suppressHydrationWarning!==!0&&pr(r.nodeValue,n,(e.mode&1)!==0)}u&&(t.flags|=4)}else r=(n.nodeType===9?n:n.ownerDocument).createTextNode(r),r[Ae]=t,t.stateNode=r}return le(t),null;case 13:if(U(B),r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(V&&we!==null&&t.mode&1&&!(t.flags&128))ra(),nn(),t.flags|=98560,u=!1;else if(u=mr(t),r!==null&&r.dehydrated!==null){if(e===null){if(!u)throw Error(y(318));if(u=t.memoizedState,u=u!==null?u.dehydrated:null,!u)throw Error(y(317));u[Ae]=t}else nn(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;le(t),u=!1}else je!==null&&(Vu(je),je=null),u=!0;if(!u)return t.flags&65536?t:null}return t.flags&128?(t.lanes=n,t):(r=r!==null,r!==(e!==null&&e.memoizedState!==null)&&r&&(t.child.flags|=8192,t.mode&1&&(e===null||B.current&1?Z===0&&(Z=3):Lo())),t.updateQueue!==null&&(t.flags|=4),le(t),null);case 4:return ln(),Ou(e,t),e===null&&$n(t.stateNode.containerInfo),le(t),null;case 10:return po(t.type._context),le(t),null;case 17:return he(t.type)&&$r(),le(t),null;case 19:if(U(B),u=t.memoizedState,u===null)return le(t),null;if(r=(t.flags&128)!==0,o=u.rendering,o===null)if(r)gn(u,!1);else{if(Z!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=Xr(e),o!==null){for(t.flags|=128,gn(u,!1),r=o.updateQueue,r!==null&&(t.updateQueue=r,t.flags|=4),t.subtreeFlags=0,r=n,n=t.child;n!==null;)u=n,e=r,u.flags&=14680066,o=u.alternate,o===null?(u.childLanes=0,u.lanes=e,u.child=null,u.subtreeFlags=0,u.memoizedProps=null,u.memoizedState=null,u.updateQueue=null,u.dependencies=null,u.stateNode=null):(u.childLanes=o.childLanes,u.lanes=o.lanes,u.child=o.child,u.subtreeFlags=0,u.deletions=null,u.memoizedProps=o.memoizedProps,u.memoizedState=o.memoizedState,u.updateQueue=o.updateQueue,u.type=o.type,e=o.dependencies,u.dependencies=e===null?null:{lanes:e.lanes,firstContext:e.firstContext}),n=n.sibling;return D(B,B.current&1|2),t.child}e=e.sibling}u.tail!==null&&Y()>on&&(t.flags|=128,r=!0,gn(u,!1),t.lanes=4194304)}else{if(!r)if(e=Xr(o),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),gn(u,!0),u.tail===null&&u.tailMode==="hidden"&&!o.alternate&&!V)return le(t),null}else 2*Y()-u.renderingStartTime>on&&n!==1073741824&&(t.flags|=128,r=!0,gn(u,!1),t.lanes=4194304);u.isBackwards?(o.sibling=t.child,t.child=o):(n=u.last,n!==null?n.sibling=o:t.child=o,u.last=o)}return u.tail!==null?(t=u.tail,u.rendering=t,u.tail=t.sibling,u.renderingStartTime=Y(),t.sibling=null,n=B.current,D(B,r?n&1|2:n&1),t):(le(t),null);case 22:case 23:return To(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?ge&1073741824&&(le(t),t.subtreeFlags&6&&(t.flags|=8192)):le(t),null;case 24:return null;case 25:return null}throw Error(y(156,t.tag))}function dd(e,t){switch(so(t),t.tag){case 1:return he(t.type)&&$r(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return ln(),U(me),U(ie),go(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return yo(t),null;case 13:if(U(B),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(y(340));nn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return U(B),null;case 4:return ln(),null;case 10:return po(t.type._context),null;case 22:case 23:return To(),null;case 24:return null;default:return null}}var yr=!1,oe=!1,pd=typeof WeakSet=="function"?WeakSet:Set,E=null;function Kt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){Q(e,t,r)}else n.current=null}function ju(e,t,n){try{n()}catch(r){Q(e,t,r)}}var Mi=!1;function md(e,t){if(vu=Fr,e=Hs(),oo(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,u=r.focusNode;r=r.focusOffset;try{n.nodeType,u.nodeType}catch{n=null;break e}var o=0,i=-1,s=-1,f=0,h=0,m=e,p=null;t:for(;;){for(var g;m!==n||l!==0&&m.nodeType!==3||(i=o+l),m!==u||r!==0&&m.nodeType!==3||(s=o+r),m.nodeType===3&&(o+=m.nodeValue.length),(g=m.firstChild)!==null;)p=m,m=g;for(;;){if(m===e)break t;if(p===n&&++f===l&&(i=o),p===u&&++h===r&&(s=o),(g=m.nextSibling)!==null)break;m=p,p=m.parentNode}m=g}n=i===-1||s===-1?null:{start:i,end:s}}else n=null}n=n||{start:0,end:0}}else n=null;for(yu={focusedElem:e,selectionRange:n},Fr=!1,E=t;E!==null;)if(t=E,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,E=e;else for(;E!==null;){t=E;try{var w=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(w!==null){var S=w.memoizedProps,I=w.memoizedState,c=t.stateNode,a=c.getSnapshotBeforeUpdate(t.elementType===t.type?S:Re(t.type,S),I);c.__reactInternalSnapshotBeforeUpdate=a}break;case 3:var d=t.stateNode.containerInfo;d.nodeType===1?d.textContent="":d.nodeType===9&&d.documentElement&&d.removeChild(d.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(y(163))}}catch(v){Q(t,t.return,v)}if(e=t.sibling,e!==null){e.return=t.return,E=e;break}E=t.return}return w=Mi,Mi=!1,w}function Ln(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var u=l.destroy;l.destroy=void 0,u!==void 0&&ju(t,n,u)}l=l.next}while(l!==r)}}function fl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Mu(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function Va(e){var t=e.alternate;t!==null&&(e.alternate=null,Va(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ae],delete t[Wn],delete t[Su],delete t[Zf],delete t[Jf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function $a(e){return e.tag===5||e.tag===3||e.tag===4}function Ii(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||$a(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Iu(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Vr));else if(r!==4&&(e=e.child,e!==null))for(Iu(e,t,n),e=e.sibling;e!==null;)Iu(e,t,n),e=e.sibling}function Du(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Du(e,t,n),e=e.sibling;e!==null;)Du(e,t,n),e=e.sibling}var ee=null,Oe=!1;function qe(e,t,n){for(n=n.child;n!==null;)Ba(e,t,n),n=n.sibling}function Ba(e,t,n){if(Ve&&typeof Ve.onCommitFiberUnmount=="function")try{Ve.onCommitFiberUnmount(rl,n)}catch{}switch(n.tag){case 5:oe||Kt(n,t);case 6:var r=ee,l=Oe;ee=null,qe(e,t,n),ee=r,Oe=l,ee!==null&&(Oe?(e=ee,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):ee.removeChild(n.stateNode));break;case 18:ee!==null&&(Oe?(e=ee,n=n.stateNode,e.nodeType===8?Fl(e.parentNode,n):e.nodeType===1&&Fl(e,n),Un(e)):Fl(ee,n.stateNode));break;case 4:r=ee,l=Oe,ee=n.stateNode.containerInfo,Oe=!0,qe(e,t,n),ee=r,Oe=l;break;case 0:case 11:case 14:case 15:if(!oe&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var u=l,o=u.destroy;u=u.tag,o!==void 0&&(u&2||u&4)&&ju(n,t,o),l=l.next}while(l!==r)}qe(e,t,n);break;case 1:if(!oe&&(Kt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(i){Q(n,t,i)}qe(e,t,n);break;case 21:qe(e,t,n);break;case 22:n.mode&1?(oe=(r=oe)||n.memoizedState!==null,qe(e,t,n),oe=r):qe(e,t,n);break;default:qe(e,t,n)}}function Di(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new pd),t.forEach(function(r){var l=_d.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function Le(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=o),r&=~u}if(r=l,r=Y()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*vd(r/1960))-r,10e?16:e,lt===null)var r=!1;else{if(e=lt,lt=null,br=0,j&6)throw Error(y(331));var l=j;for(j|=4,E=e.current;E!==null;){var u=E,o=u.child;if(E.flags&16){var i=u.deletions;if(i!==null){for(var s=0;sY()-Po?Ct(e,0):No|=n),ve(e,t)}function Za(e,t){t===0&&(e.mode&1?(t=sr,sr<<=1,!(sr&130023424)&&(sr=4194304)):t=1);var n=ae();e=Ge(e,t),e!==null&&(Jn(e,t,n),ve(e,n))}function Ed(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Za(e,n)}function _d(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(y(314))}r!==null&&r.delete(t),Za(e,n)}var Ja;Ja=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||me.current)pe=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return pe=!1,cd(e,t,n);pe=!!(e.flags&131072)}else pe=!1,V&&t.flags&1048576&&ta(t,Hr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;zr(e,t),e=t.pendingProps;var l=tn(t,ie.current);qt(t,n),l=So(null,t,r,e,l,n);var u=ko();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,he(r)?(u=!0,Br(t)):u=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,ho(t),l.updater=cl,t.stateNode=l,l._reactInternals=t,Nu(t,r,e,n),t=Tu(null,t,r,!0,u,n)):(t.tag=0,V&&u&&io(t),se(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(zr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Cd(r),e=Re(r,e),l){case 0:t=zu(null,t,r,e,n);break e;case 1:t=Ri(null,t,r,e,n);break e;case 11:t=Ti(null,t,r,e,n);break e;case 14:t=Li(null,t,r,Re(r.type,e),n);break e}throw Error(y(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Re(r,l),zu(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Re(r,l),Ri(e,t,r,l,n);case 3:e:{if(Ma(t),e===null)throw Error(y(387));r=t.pendingProps,u=t.memoizedState,l=u.element,ia(e,t),Yr(t,r,null,n);var o=t.memoizedState;if(r=o.element,u.isDehydrated)if(u={element:r,isDehydrated:!1,cache:o.cache,pendingSuspenseBoundaries:o.pendingSuspenseBoundaries,transitions:o.transitions},t.updateQueue.baseState=u,t.memoizedState=u,t.flags&256){l=un(Error(y(423)),t),t=Oi(e,t,r,n,l);break e}else if(r!==l){l=un(Error(y(424)),t),t=Oi(e,t,r,n,l);break e}else for(we=st(t.stateNode.containerInfo.firstChild),Se=t,V=!0,je=null,n=ua(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(nn(),r===l){t=Ze(e,t,n);break e}se(e,t,r,n)}t=t.child}return t;case 5:return sa(t),e===null&&_u(t),r=t.type,l=t.pendingProps,u=e!==null?e.memoizedProps:null,o=l.children,gu(r,l)?o=null:u!==null&&gu(r,u)&&(t.flags|=32),ja(e,t),se(e,t,o,n),t.child;case 6:return e===null&&_u(t),null;case 13:return Ia(e,t,n);case 4:return vo(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=rn(t,null,r,n):se(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Re(r,l),Ti(e,t,r,l,n);case 7:return se(e,t,t.pendingProps,n),t.child;case 8:return se(e,t,t.pendingProps.children,n),t.child;case 12:return se(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,u=t.memoizedProps,o=l.value,D(Qr,r._currentValue),r._currentValue=o,u!==null)if(De(u.value,o)){if(u.children===l.children&&!me.current){t=Ze(e,t,n);break e}}else for(u=t.child,u!==null&&(u.return=t);u!==null;){var i=u.dependencies;if(i!==null){o=u.child;for(var s=i.firstContext;s!==null;){if(s.context===r){if(u.tag===1){s=Ke(-1,n&-n),s.tag=2;var f=u.updateQueue;if(f!==null){f=f.shared;var h=f.pending;h===null?s.next=s:(s.next=h.next,h.next=s),f.pending=s}}u.lanes|=n,s=u.alternate,s!==null&&(s.lanes|=n),xu(u.return,n,t),i.lanes|=n;break}s=s.next}}else if(u.tag===10)o=u.type===t.type?null:u.child;else if(u.tag===18){if(o=u.return,o===null)throw Error(y(341));o.lanes|=n,i=o.alternate,i!==null&&(i.lanes|=n),xu(o,n,t),o=u.sibling}else o=u.child;if(o!==null)o.return=u;else for(o=u;o!==null;){if(o===t){o=null;break}if(u=o.sibling,u!==null){u.return=o.return,o=u;break}o=o.return}u=o}se(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,qt(t,n),l=ze(l),r=r(l),t.flags|=1,se(e,t,r,n),t.child;case 14:return r=t.type,l=Re(r,t.pendingProps),l=Re(r.type,l),Li(e,t,r,l,n);case 15:return Ra(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Re(r,l),zr(e,t),t.tag=1,he(r)?(e=!0,Br(t)):e=!1,qt(t,n),za(t,r,l),Nu(t,r,l,n),Tu(null,t,r,!0,e,n);case 19:return Da(e,t,n);case 22:return Oa(e,t,n)}throw Error(y(156,t.tag))};function qa(e,t){return Cs(e,t)}function xd(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Ne(e,t,n,r){return new xd(e,t,n,r)}function Ro(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Cd(e){if(typeof e=="function")return Ro(e)?1:0;if(e!=null){if(e=e.$$typeof,e===Gu)return 11;if(e===Zu)return 14}return 2}function dt(e,t){var n=e.alternate;return n===null?(n=Ne(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Rr(e,t,n,r,l,u){var o=2;if(r=e,typeof e=="function")Ro(e)&&(o=1);else if(typeof e=="string")o=5;else e:switch(e){case Ft:return Nt(n.children,l,u,t);case Xu:o=8,l|=8;break;case Zl:return e=Ne(12,n,t,l|2),e.elementType=Zl,e.lanes=u,e;case Jl:return e=Ne(13,n,t,l),e.elementType=Jl,e.lanes=u,e;case ql:return e=Ne(19,n,t,l),e.elementType=ql,e.lanes=u,e;case ss:return pl(n,l,u,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case os:o=10;break e;case is:o=9;break e;case Gu:o=11;break e;case Zu:o=14;break e;case be:o=16,r=null;break e}throw Error(y(130,e==null?e:typeof e,""))}return t=Ne(o,n,t,l),t.elementType=e,t.type=r,t.lanes=u,t}function Nt(e,t,n,r){return e=Ne(7,e,r,t),e.lanes=n,e}function pl(e,t,n,r){return e=Ne(22,e,r,t),e.elementType=ss,e.lanes=n,e.stateNode={isHidden:!1},e}function Ql(e,t,n){return e=Ne(6,e,null,t),e.lanes=n,e}function Kl(e,t,n){return t=Ne(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Nd(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Nl(0),this.expirationTimes=Nl(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Nl(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Oo(e,t,n,r,l,u,o,i,s){return e=new Nd(e,t,n,i,s),t===1?(t=1,u===!0&&(t|=8)):t=0,u=Ne(3,null,null,t),e.current=u,u.stateNode=e,u.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},ho(u),e}function Pd(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(nc)}catch(e){console.error(e)}}nc(),ns.exports=Ee;var Od=ns.exports,Hi=Od;Xl.createRoot=Hi.createRoot,Xl.hydrateRoot=Hi.hydrateRoot;const It=typeof globalThis=="object"&&"crypto"in globalThis?globalThis.crypto:void 0;/*! noble-hashes - MIT License (c) 2022 Paul Miller (paulmillr.com) */function jd(e=32){if(It&&typeof It.getRandomValues=="function")return It.getRandomValues(new Uint8Array(e));if(It&&typeof It.randomBytes=="function")return Uint8Array.from(It.randomBytes(e));throw new Error("crypto.getRandomValues must be defined")}new TextEncoder().encode("backchannel-dm-salt-v1");new TextEncoder().encode("backchannel-dm-key-v1");new TextEncoder;new TextDecoder;function Md(e){let t="";for(const n of e)t+=String.fromCharCode(n);return btoa(t)}function Id(){const e=localStorage.getItem("bc.identity_key");if(e)return e;const t=Md(jd(32));return localStorage.setItem("bc.identity_key",t),t}const Yl="00000000-0000-0000-0000-000000000010";function Dd(e){return!e||typeof e!="object"||typeof e.type!="string"?null:e}function Fd(){return`ws://${window.location.hostname||"127.0.0.1"}:7777`}function Ud(){const[e,t]=ue.useState(()=>localStorage.getItem("bc.ws_url")??Fd()),[n,r]=ue.useState("offline"),[l,u]=ue.useState("login"),[o,i]=ue.useState(""),[s,f]=ue.useState(""),[h,m]=ue.useState(null),[p,g]=ue.useState(""),[w,S]=ue.useState([]),[I,c]=ue.useState("Not connected"),a=ue.useRef(null),d=ue.useRef(null),v=ue.useMemo(()=>n==="online",[n]);ue.useEffect(()=>{d.current&&(d.current.scrollTop=d.current.scrollHeight)},[w]);function k(P){return new Date(P).toLocaleTimeString([],{hour:"numeric",minute:"2-digit"})}function _(P){const A=a.current;if(!A||A.readyState!==WebSocket.OPEN){c("WebSocket is not connected");return}A.send(JSON.stringify(P))}function C(){if(a.current&&a.current.readyState<=WebSocket.OPEN)return;r("connecting"),c("Connecting..."),localStorage.setItem("bc.ws_url",e);const P=new WebSocket(e);a.current=P,P.onopen=()=>{r("online"),c("Connected");const A=localStorage.getItem("bc.session_token");A?_({type:"resume_session",token:A}):c("Connected. Please login or register.")},P.onclose=()=>{r("offline"),c("Disconnected"),a.current=null},P.onerror=()=>{c("WebSocket error")},P.onmessage=A=>{const fn=Dd(JSON.parse(A.data));if(!fn){c("Invalid server message");return}N(fn)}}function N(P){switch(P.type){case"auth_success":{localStorage.setItem("bc.session_token",P.session_token),m({id:P.user_id,username:P.username}),c(`Authenticated as ${P.username}`),_({type:"fetch_channel_history",channel_id:Yl,before_message_id:null,limit:100});return}case"auth_error":{c(`Auth error: ${P.reason}`);return}case"channel_history":{S([...P.messages].reverse().map(A=>({id:A.message_id,by:A.author_username,text:A.content,at:A.timestamp})));return}case"channel_message":{P.channel_id===Yl&&S(A=>[...A,{id:P.message_id,by:P.author_username,text:P.content,at:P.timestamp}]);return}case"error":{c(`Error ${P.code}: ${P.message}`);return}default:return}}function $(P){P.preventDefault();const A=Id();if(l==="login"){_({type:"login",username:o,password:s,identity_pubkey:A});return}_({type:"register",username:o,password:s,identity_pubkey:A})}function L(P){P.preventDefault(),p.trim()&&(_({type:"send_channel_message",channel_id:Yl,content:p.trim()}),g(""))}function ye(){_({type:"logout"}),localStorage.removeItem("bc.session_token"),m(null),c("Logged out")}return O.jsxs("main",{className:"layout",children:[O.jsxs("section",{className:"card topbar",children:[O.jsxs("div",{children:[O.jsx("h1",{children:"BackChannel"}),O.jsx("p",{children:"General chat room."})]}),O.jsxs("div",{className:"connection",children:[O.jsx("input",{value:e,onChange:P=>t(P.target.value),placeholder:"ws://127.0.0.1:7777"}),O.jsx("button",{onClick:C,disabled:v,children:"Connect"})]})]}),O.jsx("section",{className:"card status",children:I}),O.jsxs("section",{className:"chat-shell",children:[O.jsxs("article",{className:"card panel chat-main",children:[O.jsx("h2",{children:"#general"}),O.jsxs("form",{onSubmit:L,children:[O.jsx("input",{value:p,onChange:P=>g(P.target.value),placeholder:"message #general"}),O.jsx("button",{className:"send-button",type:"submit",disabled:!v,children:"Send"})]}),O.jsx("div",{ref:d,className:"log chat-log",children:w.map(P=>O.jsxs("div",{className:"line",children:[O.jsx("b",{children:P.by}),O.jsx("span",{children:P.text}),O.jsx("time",{children:k(P.at)})]},P.id))})]}),O.jsxs("aside",{className:"card panel auth-sidebar",children:[O.jsx("h2",{children:"Auth"}),O.jsxs("div",{className:"switch",children:[O.jsx("button",{className:l==="login"?"active":"",onClick:()=>u("login"),children:"Login"}),O.jsx("button",{className:l==="register"?"active":"",onClick:()=>u("register"),children:"Register"})]}),O.jsxs("form",{onSubmit:$,children:[O.jsx("input",{value:o,onChange:P=>i(P.target.value),placeholder:"username"}),O.jsx("input",{value:s,onChange:P=>f(P.target.value),placeholder:"password",type:"password"}),O.jsx("button",{type:"submit",disabled:!v,children:l})]}),O.jsxs("div",{className:"meta",children:[O.jsxs("div",{children:["User: ",(h==null?void 0:h.username)??"(not authenticated)"]}),O.jsxs("div",{children:["ID: ",(h==null?void 0:h.id)??"-"]}),O.jsx("button",{onClick:ye,disabled:!v,children:"Logout"})]})]})]})]})}Xl.createRoot(document.getElementById("root")).render(O.jsx(Sc.StrictMode,{children:O.jsx(Ud,{})})); diff --git a/backchannel-web/dist/index.html b/backchannel-web/dist/index.html new file mode 100644 index 0000000..fecf2e3 --- /dev/null +++ b/backchannel-web/dist/index.html @@ -0,0 +1,13 @@ + + + + + + BackChannel + + + + +
+ + diff --git a/backchannel-web/index.html b/backchannel-web/index.html new file mode 100644 index 0000000..cc0f645 --- /dev/null +++ b/backchannel-web/index.html @@ -0,0 +1,12 @@ + + + + + + BackChannel + + +
+ + + diff --git a/backchannel-web/package-lock.json b/backchannel-web/package-lock.json new file mode 100644 index 0000000..01b9843 --- /dev/null +++ b/backchannel-web/package-lock.json @@ -0,0 +1,1768 @@ +{ + "name": "backchannel-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "backchannel-web", + "version": "0.1.0", + "dependencies": { + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.8.0", + "@noble/hashes": "^1.8.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "typescript": "^5.6.3", + "vite": "^5.4.8" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@noble/ciphers": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-1.3.0.tgz", + "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/curves": { + "version": "1.9.7", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz", + "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "1.8.0" + }, + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/backchannel-web/package.json b/backchannel-web/package.json new file mode 100644 index 0000000..2028322 --- /dev/null +++ b/backchannel-web/package.json @@ -0,0 +1,25 @@ +{ + "name": "backchannel-web", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.8.0", + "@noble/hashes": "^1.8.0", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@vitejs/plugin-react": "^4.3.2", + "typescript": "^5.6.3", + "vite": "^5.4.8" + } +} diff --git a/backchannel-web/src/App.tsx b/backchannel-web/src/App.tsx new file mode 100644 index 0000000..ae894e6 --- /dev/null +++ b/backchannel-web/src/App.tsx @@ -0,0 +1,236 @@ +import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; + +import { getOrCreateIdentityKey } from "./crypto"; +import { ClientMessage, GENERAL_CHANNEL_ID, ServerMessage } from "./protocol"; + +type ChatLine = { id: string; by: string; text: string; at: string }; + +function parseServerMessage(raw: unknown): ServerMessage | null { + if (!raw || typeof raw !== "object") { + return null; + } + + const candidate = raw as { type?: unknown }; + if (typeof candidate.type !== "string") { + return null; + } + + return raw as ServerMessage; +} + +function defaultWsUrl(): string { + const host = window.location.hostname || "127.0.0.1"; + return `ws://${host}:7777`; +} + +export function App() { + const [wsUrl, setWsUrl] = useState(() => localStorage.getItem("bc.ws_url") ?? defaultWsUrl()); + const [socketState, setSocketState] = useState<"offline" | "connecting" | "online">("offline"); + const [authMode, setAuthMode] = useState<"login" | "register">("login"); + + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + + const [me, setMe] = useState<{ id: string; username: string } | null>(null); + const [channelInput, setChannelInput] = useState(""); + const [channelLines, setChannelLines] = useState([]); + + const [status, setStatus] = useState("Not connected"); + + const wsRef = useRef(null); + const chatLogRef = useRef(null); + + const ready = useMemo(() => socketState === "online", [socketState]); + + useEffect(() => { + if (chatLogRef.current) { + chatLogRef.current.scrollTop = chatLogRef.current.scrollHeight; + } + }, [channelLines]); + + function formatTime(timestamp: string): string { + return new Date(timestamp).toLocaleTimeString([], { + hour: "numeric", + minute: "2-digit", + }); + } + + function send(message: ClientMessage) { + const ws = wsRef.current; + if (!ws || ws.readyState !== WebSocket.OPEN) { + setStatus("WebSocket is not connected"); + return; + } + ws.send(JSON.stringify(message)); + } + + function connect() { + if (wsRef.current && wsRef.current.readyState <= WebSocket.OPEN) { + return; + } + + setSocketState("connecting"); + setStatus("Connecting..."); + localStorage.setItem("bc.ws_url", wsUrl); + + const ws = new WebSocket(wsUrl); + wsRef.current = ws; + + ws.onopen = () => { + setSocketState("online"); + setStatus("Connected"); + + const token = localStorage.getItem("bc.session_token"); + if (token) { + send({ type: "resume_session", token }); + } else { + setStatus("Connected. Please login or register."); + } + }; + + ws.onclose = () => { + setSocketState("offline"); + setStatus("Disconnected"); + wsRef.current = null; + }; + + ws.onerror = () => { + setStatus("WebSocket error"); + }; + + ws.onmessage = (event) => { + const msg = parseServerMessage(JSON.parse(event.data)); + if (!msg) { + setStatus("Invalid server message"); + return; + } + handleServer(msg); + }; + } + + function handleServer(msg: ServerMessage) { + switch (msg.type) { + case "auth_success": { + localStorage.setItem("bc.session_token", msg.session_token); + setMe({ id: msg.user_id, username: msg.username }); + setStatus(`Authenticated as ${msg.username}`); + send({ type: "fetch_channel_history", channel_id: GENERAL_CHANNEL_ID, before_message_id: null, limit: 100 }); + return; + } + case "auth_error": { + setStatus(`Auth error: ${msg.reason}`); + return; + } + case "channel_history": { + setChannelLines( + [...msg.messages] + .reverse() + .map((m) => ({ id: m.message_id, by: m.author_username, text: m.content, at: m.timestamp })), + ); + return; + } + case "channel_message": { + if (msg.channel_id === GENERAL_CHANNEL_ID) { + setChannelLines((prev) => [ + ...prev, + { id: msg.message_id, by: msg.author_username, text: msg.content, at: msg.timestamp }, + ]); + } + return; + } + case "error": { + setStatus(`Error ${msg.code}: ${msg.message}`); + return; + } + default: + return; + } + } + + function submitAuth(event: FormEvent) { + event.preventDefault(); + const identity = getOrCreateIdentityKey(); + + if (authMode === "login") { + send({ type: "login", username, password, identity_pubkey: identity }); + return; + } + + send({ type: "register", username, password, identity_pubkey: identity }); + } + + function submitChannel(event: FormEvent) { + event.preventDefault(); + if (!channelInput.trim()) { + return; + } + + send({ type: "send_channel_message", channel_id: GENERAL_CHANNEL_ID, content: channelInput.trim() }); + setChannelInput(""); + } + + function logout() { + send({ type: "logout" }); + localStorage.removeItem("bc.session_token"); + setMe(null); + setStatus("Logged out"); + } + + return ( +
+
+
+

BackChannel

+

General chat room.

+
+ +
+ setWsUrl(e.target.value)} placeholder="ws://127.0.0.1:7777" /> + +
+
+ +
{status}
+ +
+
+

#general

+
+ setChannelInput(e.target.value)} placeholder="message #general" /> + +
+ +
+ {channelLines.map((line) => ( +
+ {line.by} + {line.text} + +
+ ))} +
+
+ + +
+
+ ); +} diff --git a/backchannel-web/src/crypto.ts b/backchannel-web/src/crypto.ts new file mode 100644 index 0000000..2084dfb --- /dev/null +++ b/backchannel-web/src/crypto.ts @@ -0,0 +1,69 @@ +import { chacha20poly1305 } from "@noble/ciphers/chacha"; +import { x25519 } from "@noble/curves/ed25519"; +import { hkdf } from "@noble/hashes/hkdf"; +import { sha256 } from "@noble/hashes/sha256"; +import { randomBytes } from "@noble/hashes/utils"; + +const HKDF_SALT = new TextEncoder().encode("backchannel-dm-salt-v1"); +const HKDF_INFO = new TextEncoder().encode("backchannel-dm-key-v1"); + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +export function bytesToBase64(input: Uint8Array): string { + let bin = ""; + for (const b of input) { + bin += String.fromCharCode(b); + } + return btoa(bin); +} + +export function base64ToBytes(input: string): Uint8Array { + const bin = atob(input); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i += 1) { + bytes[i] = bin.charCodeAt(i); + } + return bytes; +} + +export function getOrCreateIdentityKey(): string { + const current = localStorage.getItem("bc.identity_key"); + if (current) { + return current; + } + + const key = bytesToBase64(randomBytes(32)); + localStorage.setItem("bc.identity_key", key); + return key; +} + +export function createEphemeralKeypair(): { privateKey: Uint8Array; publicKeyB64: string } { + const privateKey = x25519.utils.randomPrivateKey(); + const publicKey = x25519.getPublicKey(privateKey); + return { privateKey, publicKeyB64: bytesToBase64(publicKey) }; +} + +export function deriveDmKey(privateKey: Uint8Array, theirPublicKeyB64: string): Uint8Array { + const theirPublicKey = base64ToBytes(theirPublicKeyB64); + const sharedSecret = x25519.getSharedSecret(privateKey, theirPublicKey); + return hkdf(sha256, sharedSecret, HKDF_SALT, HKDF_INFO, 32); +} + +export function encryptDm(key: Uint8Array, plaintext: string): { nonce: string; ciphertext: string } { + const nonceBytes = randomBytes(12); + const cipher = chacha20poly1305(key, nonceBytes); + const ciphertext = cipher.encrypt(encoder.encode(plaintext)); + return { + nonce: bytesToBase64(nonceBytes), + ciphertext: bytesToBase64(ciphertext), + }; +} + +export function decryptDm(key: Uint8Array, nonceB64: string, ciphertextB64: string): string { + const nonceBytes = base64ToBytes(nonceB64); + const ciphertext = base64ToBytes(ciphertextB64); + const cipher = chacha20poly1305(key, nonceBytes); + const plaintext = cipher.decrypt(ciphertext); + return decoder.decode(plaintext); +} diff --git a/backchannel-web/src/main.tsx b/backchannel-web/src/main.tsx new file mode 100644 index 0000000..181f392 --- /dev/null +++ b/backchannel-web/src/main.tsx @@ -0,0 +1,11 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; + +import { App } from "./App"; +import "./styles.css"; + +ReactDOM.createRoot(document.getElementById("root")!).render( + + + , +); diff --git a/backchannel-web/src/protocol.ts b/backchannel-web/src/protocol.ts new file mode 100644 index 0000000..c1ba103 --- /dev/null +++ b/backchannel-web/src/protocol.ts @@ -0,0 +1,72 @@ +export const GENERAL_CHANNEL_ID = "00000000-0000-0000-0000-000000000010"; + +export type ClientMessage = + | { type: "login"; username: string; password: string; identity_pubkey: string } + | { type: "register"; username: string; password: string; identity_pubkey: string } + | { type: "resume_session"; token: string } + | { type: "logout" } + | { type: "send_channel_message"; channel_id: string; content: string } + | { type: "fetch_channel_history"; channel_id: string; before_message_id: string | null; limit: number } + | { type: "init_dm_key_exchange"; recipient_id: string; sender_ephemeral_pubkey: string } + | { type: "accept_dm_key_exchange"; initiator_id: string; recipient_ephemeral_pubkey: string } + | { type: "send_dm"; recipient_id: string; ciphertext: string; nonce: string } + | { type: "fetch_dm_history"; peer_id: string; before_message_id: string | null; limit: number } + | { type: "ping" }; + +export type ServerMessage = + | { type: "auth_success"; user_id: string; username: string; session_token: string } + | { type: "auth_error"; reason: string } + | { + type: "channel_message"; + message_id: string; + channel_id: string; + author_id: string; + author_username: string; + content: string; + timestamp: string; + } + | { + type: "channel_history"; + channel_id: string; + messages: Array<{ + message_id: string; + author_id: string; + author_username: string; + content: string; + timestamp: string; + }>; + } + | { + type: "dm_key_exchange_request"; + initiator_id: string; + initiator_username: string; + sender_ephemeral_pubkey: string; + } + | { + type: "dm_key_exchange_response"; + recipient_id: string; + recipient_username: string; + recipient_ephemeral_pubkey: string; + } + | { + type: "direct_message"; + message_id: string; + sender_id: string; + sender_username: string; + ciphertext: string; + nonce: string; + timestamp: string; + } + | { + type: "dm_history"; + peer_id: string; + messages: Array<{ + message_id: string; + sender_id: string; + ciphertext: string; + nonce: string; + timestamp: string; + }>; + } + | { type: "error"; code: number; message: string } + | { type: "pong" }; diff --git a/backchannel-web/src/styles.css b/backchannel-web/src/styles.css new file mode 100644 index 0000000..1757999 --- /dev/null +++ b/backchannel-web/src/styles.css @@ -0,0 +1,207 @@ +:root { + color-scheme: light; + --bg-0: #0d1b2a; + --panel: #fffaf0; + --text: #1d2d44; + --accent: #e76f51; + --accent-2: #2a9d8f; + --line: #f4a261; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; + background: radial-gradient(circle at top left, #415a77, var(--bg-0) 55%); + font-family: "Space Grotesk", "Trebuchet MS", sans-serif; + color: var(--text); +} + +.layout { + width: min(1400px, 100vw); + margin: 0 auto; + padding: 20px; + display: grid; + gap: 14px; +} + +.card { + background: linear-gradient(145deg, #fffaf0, #fdf0d5); + border: 2px solid #e9c46a; + border-radius: 16px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} + +.topbar { + padding: 16px; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: end; + gap: 12px; +} + +h1 { + margin: 0; + font-family: "Bebas Neue", "Impact", sans-serif; + letter-spacing: 0.06em; + font-size: clamp(2rem, 4vw, 2.8rem); +} + +h2 { + margin: 0; + font-family: "Bebas Neue", "Impact", sans-serif; + letter-spacing: 0.05em; +} + +p { + margin: 6px 0 0; +} + +.connection { + display: flex; + gap: 8px; + min-width: min(540px, 100%); +} + +.status { + padding: 10px 14px; + font-weight: 700; + border-color: var(--accent-2); +} + +.chat-shell { + position: relative; + padding-left: 350px; +} + +.panel { + padding: 14px; + display: grid; + gap: 10px; +} + +.chat-main { + min-height: calc(100vh - 220px); +} + +.auth-sidebar { + position: fixed; + left: 20px; + bottom: 20px; + width: 320px; + z-index: 10; +} + +.switch { + display: flex; + gap: 8px; +} + +.switch .active { + background: var(--accent-2); + color: white; +} + +input { + width: 100%; + border: 2px solid #264653; + border-radius: 10px; + padding: 8px 10px; + font: inherit; + background: #fff; +} + +form { + display: grid; + gap: 8px; +} + +button { + border: 0; + background: var(--accent); + color: #fff; + border-radius: 10px; + padding: 8px 10px; + cursor: pointer; + font-weight: 700; +} + +.chat-main form { + grid-template-columns: 1fr auto; + align-items: end; +} + +.send-button { + padding: 6px 10px; + min-width: 68px; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.meta { + display: grid; + gap: 6px; + font-size: 0.9rem; +} + +.log { + background: #fff; + border: 1px solid var(--line); + border-radius: 10px; + min-height: 200px; + overflow-y: auto; + padding: 8px; + display: flex; + flex-direction: column; + justify-content: flex-end; + gap: 6px; +} + +.chat-log { + max-height: calc(100vh - 360px); +} + +.line { + display: grid; + gap: 2px; + border-bottom: 1px dashed #c8d4dd; + padding-bottom: 6px; +} + +time { + font-size: 0.75rem; + color: #526072; +} + +@media (max-width: 900px) { + .chat-shell { + padding-left: 0; + } + + .auth-sidebar { + position: static; + width: 100%; + margin-top: 14px; + } + + .chat-main { + min-height: auto; + } + + .chat-log { + max-height: 50vh; + } + + .connection { + min-width: 0; + width: 100%; + flex-direction: column; + } +} diff --git a/backchannel-web/src/vite-env.d.ts b/backchannel-web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/backchannel-web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/backchannel-web/tsconfig.json b/backchannel-web/tsconfig.json new file mode 100644 index 0000000..f68352b --- /dev/null +++ b/backchannel-web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "allowImportingTsExtensions": false, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/backchannel-web/tsconfig.tsbuildinfo b/backchannel-web/tsconfig.tsbuildinfo new file mode 100644 index 0000000..a1f5158 --- /dev/null +++ b/backchannel-web/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/crypto.ts","./src/main.tsx","./src/protocol.ts","./src/vite-env.d.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/backchannel-web/vite.config.ts b/backchannel-web/vite.config.ts new file mode 100644 index 0000000..081c8d9 --- /dev/null +++ b/backchannel-web/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], +});