commit aa1a7fa572e317925b08b97d54caee3d2c1d8835
Author: Bendik Aagaard Lynghaug
Date: Wed Jul 29 19:38:40 2026 +0200
Initial commit: content-driven onboarding portal
Leptos/Axum app that renders a Question/Alternative/Feature schema
loaded from a sibling content repo (portal-content). Kanidm OIDC login,
content-driven authorization (Question.qualifies), a generic NATS
KV-backed resource + state-transition mechanism (no bespoke "applicant"
concept baked into the runtime - it's all content), a SHA-256 DAG chain
tying submissions and decisions together, and the "YES - Rasterized
Lines" piece (ported from the live uhhm.no site) as the landing hero.
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ea8c4bf
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+/target
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..65aa92f
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,5042 @@
+# This file is automatically @generated by Cargo.
+# It is not intended for manual editing.
+version = 4
+
+[[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 = "any_spawner"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1384d3fe1eecb464229fcf6eebb72306591c56bf27b373561489458a7c73027d"
+dependencies = [
+ "futures",
+ "thiserror 2.0.19",
+ "tokio",
+ "wasm-bindgen-futures",
+]
+
+[[package]]
+name = "anyhow"
+version = "1.0.104"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+
+[[package]]
+name = "arc-swap"
+version = "1.9.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "async-lock"
+version = "3.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
+dependencies = [
+ "event-listener",
+ "event-listener-strategy",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "async-nats"
+version = "0.38.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76433c4de73442daedb3a59e991d94e85c14ebfc33db53dfcd347a21cd6ef4f8"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures",
+ "memchr",
+ "nkeys",
+ "nuid",
+ "once_cell",
+ "pin-project",
+ "portable-atomic",
+ "rand 0.8.7",
+ "regex",
+ "ring",
+ "rustls-native-certs 0.7.3",
+ "rustls-pemfile",
+ "rustls-webpki 0.102.8",
+ "serde",
+ "serde_json",
+ "serde_nanos",
+ "serde_repr",
+ "thiserror 1.0.69",
+ "time",
+ "tokio",
+ "tokio-rustls 0.26.4",
+ "tokio-util",
+ "tokio-websockets",
+ "tracing",
+ "tryhard",
+ "url",
+]
+
+[[package]]
+name = "async-once-cell"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288f83726785267c6f2ef073a3d83dc3f9b81464e9f99898240cced85fce35a"
+
+[[package]]
+name = "async-trait"
+version = "0.1.91"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "attribute-derive"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05832cdddc8f2650cc2cc187cc2e952b8c133a48eb055f35211f61ee81502d77"
+dependencies = [
+ "attribute-derive-macro",
+ "derive-where",
+ "manyhow",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "attribute-derive-macro"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a7cdbbd4bd005c5d3e2e9c885e6fa575db4f4a3572335b974d8db853b6beb61"
+dependencies = [
+ "collection_literals",
+ "interpolator",
+ "manyhow",
+ "proc-macro-utils",
+ "proc-macro2",
+ "quote",
+ "quote-use",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "aws-credential-types"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e93964ffdaf57857f544be3666a5f57570bb699e934700f11b49708f61bb556e"
+dependencies = [
+ "aws-smithy-async",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-rs"
+version = "1.17.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1"
+dependencies = [
+ "aws-lc-sys",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-lc-sys"
+version = "0.43.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c"
+dependencies = [
+ "cc",
+ "cmake",
+ "dunce",
+ "fs_extra",
+ "pkg-config",
+]
+
+[[package]]
+name = "aws-runtime"
+version = "1.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9007227e10b5fed2f3e0a2beff489211e2b5604c400b7a9d5d81ca9d64c24bb"
+dependencies = [
+ "aws-credential-types",
+ "aws-sigv4",
+ "aws-smithy-async",
+ "aws-smithy-eventstream",
+ "aws-smithy-http",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "aws-types",
+ "bytes",
+ "bytes-utils",
+ "fastrand",
+ "http 0.2.12",
+ "http 1.4.2",
+ "http-body 0.4.6",
+ "http-body 1.1.0",
+ "percent-encoding",
+ "pin-project-lite",
+ "tracing",
+ "uuid",
+]
+
+[[package]]
+name = "aws-sdk-s3"
+version = "1.140.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9660cf991e512fbe6094f1041ff3d3282bc5aeeeb6184e8de437ec2de024a10"
+dependencies = [
+ "arc-swap",
+ "aws-credential-types",
+ "aws-runtime",
+ "aws-sigv4",
+ "aws-smithy-async",
+ "aws-smithy-checksums",
+ "aws-smithy-eventstream",
+ "aws-smithy-http",
+ "aws-smithy-json",
+ "aws-smithy-observability",
+ "aws-smithy-runtime",
+ "aws-smithy-runtime-api",
+ "aws-smithy-schema",
+ "aws-smithy-types",
+ "aws-smithy-xml",
+ "aws-types",
+ "bytes",
+ "fastrand",
+ "hex",
+ "hmac 0.13.0",
+ "http 0.2.12",
+ "http 1.4.2",
+ "http-body 1.1.0",
+ "lru",
+ "percent-encoding",
+ "regex-lite",
+ "sha2 0.11.0",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "aws-sigv4"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "723c2234ad7511ceef63eab016b7ba6ff7c55590fefb96fa8467af014a07309f"
+dependencies = [
+ "aws-credential-types",
+ "aws-smithy-eventstream",
+ "aws-smithy-http",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "bytes",
+ "crypto-bigint",
+ "form_urlencoded",
+ "hex",
+ "hmac 0.13.0",
+ "http 0.2.12",
+ "http 1.4.2",
+ "p256",
+ "percent-encoding",
+ "sha2 0.11.0",
+ "subtle",
+ "time",
+ "tracing",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-smithy-async"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f02e407fb3b54891734224b9ffac8a71fdd35f542500fa1af95754a6b2beb316"
+dependencies = [
+ "futures-util",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "aws-smithy-checksums"
+version = "0.65.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67ecd999972b58e67cab052f5129906c08c25883bd0788ceefc55ef97d61307"
+dependencies = [
+ "aws-smithy-http",
+ "aws-smithy-types",
+ "bytes",
+ "crc-fast",
+ "hex",
+ "http 1.4.2",
+ "http-body 1.1.0",
+ "http-body-util",
+ "md-5",
+ "pin-project-lite",
+ "sha1 0.11.0",
+ "sha2 0.11.0",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-eventstream"
+version = "0.61.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a9381123ab62d20c13082b151f30f962a3b112b727345394536dfa39a482944"
+dependencies = [
+ "aws-smithy-types",
+ "bytes",
+ "crc32fast",
+]
+
+[[package]]
+name = "aws-smithy-http"
+version = "0.64.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "37843d9add67c3aff5856f409c6dc315d3cdff60f9c0cb5b670dab1e9920306d"
+dependencies = [
+ "aws-smithy-eventstream",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "bytes",
+ "bytes-utils",
+ "futures-core",
+ "futures-util",
+ "http 1.4.2",
+ "http-body 1.1.0",
+ "http-body-util",
+ "percent-encoding",
+ "pin-project-lite",
+ "pin-utils",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-http-client"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "635d23afda0a6ab48d666c4d447c4873e8d1e83518a2be2093122397e50b838e"
+dependencies = [
+ "aws-smithy-async",
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "h2 0.3.27",
+ "h2 0.4.15",
+ "http 0.2.12",
+ "http 1.4.2",
+ "http-body 0.4.6",
+ "hyper 0.14.32",
+ "hyper 1.11.0",
+ "hyper-rustls 0.24.2",
+ "hyper-rustls 0.27.9",
+ "hyper-util",
+ "pin-project-lite",
+ "rustls 0.21.12",
+ "rustls 0.23.42",
+ "rustls-native-certs 0.8.4",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls 0.26.4",
+ "tower",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-json"
+version = "0.63.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3dc65a121adb4b33729919fcfa14fa36fb33c1555a8f06bb0e2188dbfdc1d9ef"
+dependencies = [
+ "aws-smithy-runtime-api",
+ "aws-smithy-schema",
+ "aws-smithy-types",
+]
+
+[[package]]
+name = "aws-smithy-observability"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e86338c869539a581bf161247762a6e87f92c5c075060057b5ed6d06632ed0c"
+dependencies = [
+ "aws-smithy-runtime-api",
+]
+
+[[package]]
+name = "aws-smithy-runtime"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07505b34e8f4b3591a4fa69e9792b52289b95488dbbc68c3c0075b7bedb245e1"
+dependencies = [
+ "aws-smithy-async",
+ "aws-smithy-http",
+ "aws-smithy-http-client",
+ "aws-smithy-observability",
+ "aws-smithy-runtime-api",
+ "aws-smithy-schema",
+ "aws-smithy-types",
+ "bytes",
+ "fastrand",
+ "http 0.2.12",
+ "http 1.4.2",
+ "http-body 0.4.6",
+ "http-body 1.1.0",
+ "http-body-util",
+ "pin-project-lite",
+ "pin-utils",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "aws-smithy-runtime-api"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b98f2e1fd67ec06618f9c291e5e495a468e60519e44c9c1979cd0521f3affdb"
+dependencies = [
+ "aws-smithy-async",
+ "aws-smithy-runtime-api-macros",
+ "aws-smithy-types",
+ "bytes",
+ "http 0.2.12",
+ "http 1.4.2",
+ "pin-project-lite",
+ "tokio",
+ "tracing",
+ "zeroize",
+]
+
+[[package]]
+name = "aws-smithy-runtime-api-macros"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "221eaa237ddf1ca79b60d1372aad77e47f9c0ea5b3ce5099da8c61d027dc77b3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "aws-smithy-schema"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d56e0a4e53127a632224e43633b0fe045fa9e1e3cfc68b9830f1115e103f910"
+dependencies = [
+ "aws-smithy-runtime-api",
+ "aws-smithy-types",
+ "http 1.4.2",
+]
+
+[[package]]
+name = "aws-smithy-types"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6dc683efb34b9e755675b37fedbe0103141e5b6df7bdc9eb6967756a8c167d8"
+dependencies = [
+ "base64-simd",
+ "bytes",
+ "bytes-utils",
+ "futures-core",
+ "http 0.2.12",
+ "http 1.4.2",
+ "http-body 0.4.6",
+ "http-body 1.1.0",
+ "http-body-util",
+ "itoa",
+ "num-integer",
+ "pin-project-lite",
+ "pin-utils",
+ "ryu",
+ "serde",
+ "time",
+ "tokio",
+ "tokio-util",
+]
+
+[[package]]
+name = "aws-smithy-xml"
+version = "0.62.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce84f71c72fee2cbbadde6e7d082f5fb466e3a84733855295fa7aafd1b31b7d8"
+dependencies = [
+ "aws-smithy-runtime-api",
+ "aws-smithy-schema",
+ "aws-smithy-types",
+ "xmlparser",
+]
+
+[[package]]
+name = "aws-types"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eec1cd5469f328c782dc3e33d4153cf118a54e33cbb3356d60d16f89883e1f94"
+dependencies = [
+ "aws-credential-types",
+ "aws-smithy-async",
+ "aws-smithy-runtime-api",
+ "aws-smithy-schema",
+ "aws-smithy-types",
+ "rustc_version",
+ "tracing",
+]
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "base64 0.22.1",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http 1.4.2",
+ "http-body 1.1.0",
+ "http-body-util",
+ "hyper 1.11.0",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "multer",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sha1 0.10.7",
+ "sync_wrapper",
+ "tokio",
+ "tokio-tungstenite",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-core"
+version = "0.5.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http 1.4.2",
+ "http-body 1.1.0",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "base16"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d27c3610c36aee21ce8ac510e6224498de4228ad772a171ed65643a24693a5a8"
+
+[[package]]
+name = "base16ct"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
+
+[[package]]
+name = "base64"
+version = "0.21.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "base64-simd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "339abbe78e73178762e23bea9dfd08e697eb3f3301cd4be981c0f78ba5859195"
+dependencies = [
+ "outref",
+ "vsimd",
+]
+
+[[package]]
+name = "base64ct"
+version = "1.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
+
+[[package]]
+name = "bitflags"
+version = "2.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
+
+[[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 = "block-buffer"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "bs58"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4"
+dependencies = [
+ "tinyvec",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "bytes-utils"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35"
+dependencies = [
+ "bytes",
+ "either",
+]
+
+[[package]]
+name = "camino"
+version = "1.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d"
+
+[[package]]
+name = "cc"
+version = "1.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9"
+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 = "cfg_aliases"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
+
+[[package]]
+name = "chacha20"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "chrono"
+version = "0.4.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
+dependencies = [
+ "iana-time-zone",
+ "js-sys",
+ "num-traits",
+ "serde",
+ "wasm-bindgen",
+ "windows-link",
+]
+
+[[package]]
+name = "cmake"
+version = "0.1.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678"
+dependencies = [
+ "cc",
+]
+
+[[package]]
+name = "cmov"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
+
+[[package]]
+name = "codee"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9dbbdc4b4d349732bc6690de10a9de952bd39ba6a065c586e26600b6b0b91f5"
+dependencies = [
+ "serde",
+ "serde_json",
+ "thiserror 2.0.19",
+]
+
+[[package]]
+name = "collection_literals"
+version = "1.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2550f75b8cfac212855f6b1885455df8eaee8fe8e246b647d69146142e016084"
+
+[[package]]
+name = "config"
+version = "0.15.25"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b85f248a4de22d204ceabc6299d89d2c70fbd7f09fea53c06c852369652d8139"
+dependencies = [
+ "convert_case 0.6.0",
+ "pathdiff",
+ "serde_core",
+ "toml",
+ "winnow",
+]
+
+[[package]]
+name = "console_error_panic_hook"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
+dependencies = [
+ "cfg-if",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "const-oid"
+version = "0.9.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
+
+[[package]]
+name = "const-oid"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
+
+[[package]]
+name = "const-str"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f"
+
+[[package]]
+name = "const_format"
+version = "0.2.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4481a617ad9a412be3b97c5d403fef8ed023103368908b9c50af598ff467cc1e"
+dependencies = [
+ "const_format_proc_macros",
+ "konst",
+]
+
+[[package]]
+name = "const_format_proc_macros"
+version = "0.2.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d57c2eccfb16dbac1f4e61e206105db5820c9d26c3c472bc17c774259ef7744"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-xid",
+]
+
+[[package]]
+name = "const_str_slice_concat"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f67855af358fcb20fac58f9d714c94e2b228fe5694c1c9b4ead4a366343eda1b"
+
+[[package]]
+name = "convert_case"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "convert_case"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "affbf0190ed2caf063e3def54ff444b449371d55c58e513a95ab98eca50adb49"
+dependencies = [
+ "unicode-segmentation",
+]
+
+[[package]]
+name = "convert_case_extras"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589c70f0faf8aa9d17787557d5eae854d7755cac50f5c3d12c81d3d57661cebb"
+dependencies = [
+ "convert_case 0.11.0",
+]
+
+[[package]]
+name = "cookie"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
+dependencies = [
+ "percent-encoding",
+ "time",
+ "version_check",
+]
+
+[[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"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
+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 = "cpufeatures"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc-fast"
+version = "1.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5"
+dependencies = [
+ "digest 0.10.7",
+ "spin 0.10.1",
+]
+
+[[package]]
+name = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crypto-bigint"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
+dependencies = [
+ "generic-array",
+ "rand_core 0.6.4",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "crypto-common"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
+dependencies = [
+ "hybrid-array",
+]
+
+[[package]]
+name = "ctutils"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+dependencies = [
+ "cmov",
+]
+
+[[package]]
+name = "curve25519-dalek"
+version = "4.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "curve25519-dalek-derive",
+ "digest 0.10.7",
+ "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 2.0.119",
+]
+
+[[package]]
+name = "darling"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
+dependencies = [
+ "darling_core",
+ "darling_macro",
+]
+
+[[package]]
+name = "darling_core"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
+dependencies = [
+ "ident_case",
+ "proc-macro2",
+ "quote",
+ "strsim",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "darling_macro"
+version = "0.23.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
+dependencies = [
+ "darling_core",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "data-encoding"
+version = "2.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8"
+
+[[package]]
+name = "der"
+version = "0.7.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
+dependencies = [
+ "const-oid 0.9.6",
+ "pem-rfc7468",
+ "zeroize",
+]
+
+[[package]]
+name = "deranged"
+version = "0.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "derive-where"
+version = "1.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer 0.10.4",
+ "const-oid 0.9.6",
+ "crypto-common 0.1.7",
+ "subtle",
+]
+
+[[package]]
+name = "digest"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
+dependencies = [
+ "block-buffer 0.12.1",
+ "const-oid 0.10.2",
+ "crypto-common 0.2.2",
+ "ctutils",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "drain_filter_polyfill"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "669a445ee724c5c69b1b06fe0b63e70a1c84bc9bb7d9696cd4f4e3ec45050408"
+
+[[package]]
+name = "dunce"
+version = "1.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
+
+[[package]]
+name = "dyn-clone"
+version = "1.0.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
+
+[[package]]
+name = "ecdsa"
+version = "0.16.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
+dependencies = [
+ "der",
+ "digest 0.10.7",
+ "elliptic-curve",
+ "rfc6979",
+ "signature",
+ "spki",
+]
+
+[[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",
+ "serde",
+ "sha2 0.10.9",
+ "signature",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "either"
+version = "1.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d"
+
+[[package]]
+name = "either_of"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5060e0a4cbf26a87550792688ade88e6b8aec9208613631a7a363bda7bc2d4cd"
+dependencies = [
+ "paste",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "elliptic-curve"
+version = "0.13.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
+dependencies = [
+ "base16ct",
+ "crypto-bigint",
+ "digest 0.10.7",
+ "ff",
+ "generic-array",
+ "group",
+ "hkdf",
+ "pem-rfc7468",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "sec1",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "encoding_rs"
+version = "0.8.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "erased"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1731451909bde27714eacba19c2566362a7f35224f52b153d3f42cf60f72472"
+
+[[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 = "event-listener"
+version = "5.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
+dependencies = [
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "event-listener-strategy"
+version = "0.5.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
+dependencies = [
+ "event-listener",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "fastrand"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
+
+[[package]]
+name = "ff"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
+dependencies = [
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[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 = "fnv"
+version = "1.0.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
+
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
+[[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"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a"
+
+[[package]]
+name = "futures-macro"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "futures-sink"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307"
+
+[[package]]
+name = "futures-task"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109"
+
+[[package]]
+name = "futures-util"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa"
+dependencies = [
+ "futures-channel",
+ "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",
+ "zeroize",
+]
+
+[[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 5.3.0",
+ "wasip2",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi 6.0.0",
+ "rand_core 0.10.1",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "gloo-net"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c06f627b1a58ca3d42b45d6104bf1e1a03799df472df00988b6ba21accc10580"
+dependencies = [
+ "futures-channel",
+ "futures-core",
+ "futures-sink",
+ "gloo-utils",
+ "http 1.4.2",
+ "js-sys",
+ "pin-project",
+ "serde",
+ "serde_json",
+ "thiserror 1.0.69",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "gloo-utils"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b5555354113b18c547c1d3a98fbf7fb32a9ff4f6fa112ce823a21641a0ba3aa"
+dependencies = [
+ "js-sys",
+ "serde",
+ "serde_json",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "group"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
+dependencies = [
+ "ff",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
+[[package]]
+name = "guardian"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "17e2ac29387b1aa07a1e448f7bb4f35b500787971e965b02842b900afa5c8f6f"
+
+[[package]]
+name = "h2"
+version = "0.3.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d"
+dependencies = [
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "futures-util",
+ "http 0.2.12",
+ "indexmap 2.14.0",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "h2"
+version = "0.4.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "fnv",
+ "futures-core",
+ "futures-sink",
+ "http 1.4.2",
+ "indexmap 2.14.0",
+ "slab",
+ "tokio",
+ "tokio-util",
+ "tracing",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.12.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888"
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[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 0.12.1",
+]
+
+[[package]]
+name = "hmac"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
+dependencies = [
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "hmac"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
+dependencies = [
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "html-escape"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46c1ff2d1cbf39efe5af0900ced8a069b5e61557a17544eb0c4a50239937389e"
+
+[[package]]
+name = "http"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1"
+dependencies = [
+ "bytes",
+ "fnv",
+ "itoa",
+]
+
+[[package]]
+name = "http"
+version = "1.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
+dependencies = [
+ "bytes",
+ "itoa",
+]
+
+[[package]]
+name = "http-body"
+version = "0.4.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
+dependencies = [
+ "bytes",
+ "http 0.2.12",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http-body"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
+dependencies = [
+ "bytes",
+ "http 1.4.2",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http 1.4.2",
+ "http-body 1.1.0",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "http-range-header"
+version = "0.4.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
+
+[[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 = "hybrid-array"
+version = "0.4.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c"
+dependencies = [
+ "typenum",
+]
+
+[[package]]
+name = "hydration_context"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bbbeb23ee808258cef2c5585ff0dc8e41da21a8dde943f6b290da153a042a96"
+dependencies = [
+ "futures",
+ "js-sys",
+ "or_poisoned",
+ "pin-project-lite",
+ "serde",
+ "throw_error",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "hyper"
+version = "0.14.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
+dependencies = [
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "futures-util",
+ "h2 0.3.27",
+ "http 0.2.12",
+ "http-body 0.4.6",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "socket2 0.5.10",
+ "tokio",
+ "tower-service",
+ "tracing",
+ "want",
+]
+
+[[package]]
+name = "hyper"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "h2 0.4.15",
+ "http 1.4.2",
+ "http-body 1.1.0",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.24.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590"
+dependencies = [
+ "futures-util",
+ "http 0.2.12",
+ "hyper 0.14.32",
+ "log",
+ "rustls 0.21.12",
+ "tokio",
+ "tokio-rustls 0.24.1",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http 1.4.2",
+ "hyper 1.11.0",
+ "hyper-util",
+ "rustls 0.23.42",
+ "rustls-native-certs 0.8.4",
+ "tokio",
+ "tokio-rustls 0.26.4",
+ "tower-service",
+ "webpki-roots",
+]
+
+[[package]]
+name = "hyper-util"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http 1.4.2",
+ "http-body 1.1.0",
+ "hyper 1.11.0",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2 0.6.5",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[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.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c"
+dependencies = [
+ "displaydoc",
+ "potential_utf",
+ "utf8_iter",
+ "yoke",
+ "zerofrom",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_locale_core"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29"
+dependencies = [
+ "displaydoc",
+ "litemap",
+ "tinystr",
+ "writeable",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4"
+dependencies = [
+ "icu_collections",
+ "icu_normalizer_data",
+ "icu_properties",
+ "icu_provider",
+ "smallvec",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_normalizer_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38"
+
+[[package]]
+name = "icu_properties"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de"
+dependencies = [
+ "icu_collections",
+ "icu_locale_core",
+ "icu_properties_data",
+ "icu_provider",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "icu_properties_data"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14"
+
+[[package]]
+name = "icu_provider"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421"
+dependencies = [
+ "displaydoc",
+ "icu_locale_core",
+ "writeable",
+ "yoke",
+ "zerofrom",
+ "zerotrie",
+ "zerovec",
+]
+
+[[package]]
+name = "ident_case"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
+
+[[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.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714"
+dependencies = [
+ "icu_normalizer",
+ "icu_properties",
+]
+
+[[package]]
+name = "indexmap"
+version = "1.9.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99"
+dependencies = [
+ "autocfg",
+ "hashbrown 0.12.3",
+ "serde",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "interpolator"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "71dd52191aae121e8611f1e8dc3e324dd0dd1dee1e6dd91d10ee07a3cfb4d9d8"
+
+[[package]]
+name = "inventory"
+version = "0.3.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
+dependencies = [
+ "rustversion",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "itertools"
+version = "0.10.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "jobserver"
+version = "0.1.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3"
+dependencies = [
+ "getrandom 0.4.3",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "konst"
+version = "0.2.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "128133ed7824fcd73d6e7b17957c5eb7bacb885649bd8c69708b2331a10bcefb"
+dependencies = [
+ "konst_macro_rules",
+]
+
+[[package]]
+name = "konst_macro_rules"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a4933f3f57a8e9d9da04db23fb153356ecaf00cbd14aee46279c33dc80925c37"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+dependencies = [
+ "spin 0.9.9",
+]
+
+[[package]]
+name = "leptos"
+version = "0.8.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "705e2951f3688e0c4f66bbb7a2702282782dcee716971dbd6209c2619d272479"
+dependencies = [
+ "any_spawner",
+ "base64 0.22.1",
+ "cfg-if",
+ "either_of",
+ "futures",
+ "getrandom 0.4.3",
+ "hydration_context",
+ "leptos_config",
+ "leptos_dom",
+ "leptos_hot_reload",
+ "leptos_macro",
+ "leptos_server",
+ "oco_ref",
+ "or_poisoned",
+ "paste",
+ "rand 0.9.5",
+ "reactive_graph",
+ "rustc-hash",
+ "rustc_version",
+ "send_wrapper",
+ "serde",
+ "serde_json",
+ "serde_qs",
+ "server_fn",
+ "slotmap",
+ "tachys",
+ "thiserror 2.0.19",
+ "throw_error",
+ "typed-builder",
+ "typed-builder-macro",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm_split_helpers",
+ "web-sys",
+]
+
+[[package]]
+name = "leptos_axum"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "513e2dd5fe81512b53304429b9ca77cceb954d23af88921c0c33119380f07cbe"
+dependencies = [
+ "any_spawner",
+ "axum",
+ "futures",
+ "hydration_context",
+ "leptos",
+ "leptos_integration_utils",
+ "leptos_macro",
+ "leptos_meta",
+ "leptos_router",
+ "or_poisoned",
+ "server_fn",
+ "tachys",
+ "tokio",
+ "tower",
+ "tower-http",
+]
+
+[[package]]
+name = "leptos_config"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c06f751315bccc0d193fab302ac01d25bcfcd97474d4676440e7e3250dc3fc3"
+dependencies = [
+ "config",
+ "regex",
+ "serde",
+ "thiserror 2.0.19",
+ "typed-builder",
+]
+
+[[package]]
+name = "leptos_dom"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35742e9ed8f8aaf9e549b454c68a7ac0992536e06856365639b111f72ab07884"
+dependencies = [
+ "js-sys",
+ "or_poisoned",
+ "reactive_graph",
+ "send_wrapper",
+ "tachys",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "leptos_hot_reload"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d2a0f220c8a5ef3c51199dfb9cdd702bc0eb80d52fbe70c7890adfaaae8a4b1"
+dependencies = [
+ "anyhow",
+ "camino",
+ "indexmap 2.14.0",
+ "or_poisoned",
+ "proc-macro2",
+ "quote",
+ "rstml",
+ "serde",
+ "syn 2.0.119",
+ "walkdir",
+]
+
+[[package]]
+name = "leptos_integration_utils"
+version = "0.8.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c097f89cd9aa606297672f56fa5bdda09f01609a9f4eefaccdbb5ab5afea4279"
+dependencies = [
+ "futures",
+ "hydration_context",
+ "leptos",
+ "leptos_config",
+ "leptos_meta",
+ "leptos_router",
+ "reactive_graph",
+]
+
+[[package]]
+name = "leptos_macro"
+version = "0.8.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96de6e8da9d4f1a7b74b447b317d590ebabb38709f588f7ee20564b773ccbcce"
+dependencies = [
+ "attribute-derive",
+ "cfg-if",
+ "convert_case 0.11.0",
+ "convert_case_extras",
+ "html-escape",
+ "itertools 0.14.0",
+ "leptos_hot_reload",
+ "prettyplease",
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "rstml",
+ "rustc_version",
+ "server_fn_macro",
+ "syn 2.0.119",
+ "uuid",
+]
+
+[[package]]
+name = "leptos_meta"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c3efe657b4c55ed2e078922786ffe20acfb71767c3dd913767b09a35c75c890"
+dependencies = [
+ "futures",
+ "indexmap 2.14.0",
+ "leptos",
+ "or_poisoned",
+ "send_wrapper",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "leptos_router"
+version = "0.8.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "318c035b5995324c2f60900978ac67da3b21e6062a6e904f8b93b38eddc4e3a2"
+dependencies = [
+ "any_spawner",
+ "either_of",
+ "futures",
+ "gloo-net",
+ "js-sys",
+ "leptos",
+ "leptos_router_macro",
+ "or_poisoned",
+ "percent-encoding",
+ "reactive_graph",
+ "rustc_version",
+ "send_wrapper",
+ "tachys",
+ "thiserror 2.0.19",
+ "url",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "leptos_router_macro"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "409c0bd99f986c3cfa1a4db2443c835bc602ded1a12784e22ecb28c3ed5a2ae2"
+dependencies = [
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "leptos_server"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da974775c5ccbb6bd64be7f53f75e8321542e28f21563a416574dbe4d5447eae"
+dependencies = [
+ "any_spawner",
+ "base64 0.22.1",
+ "codee",
+ "futures",
+ "hydration_context",
+ "or_poisoned",
+ "reactive_graph",
+ "send_wrapper",
+ "serde",
+ "serde_json",
+ "server_fn",
+ "tachys",
+]
+
+[[package]]
+name = "libc"
+version = "0.2.189"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+
+[[package]]
+name = "libm"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
+
+[[package]]
+name = "litemap"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
+
+[[package]]
+name = "lock_api"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
+dependencies = [
+ "scopeguard",
+ "serde",
+]
+
+[[package]]
+name = "log"
+version = "0.4.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
+
+[[package]]
+name = "lru"
+version = "0.16.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39"
+dependencies = [
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[package]]
+name = "manyhow"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b33efb3ca6d3b07393750d4030418d594ab1139cee518f0dc88db70fec873587"
+dependencies = [
+ "manyhow-macros",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "manyhow-macros"
+version = "0.11.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46fce34d199b78b6e6073abf984c9cf5fd3e9330145a93ee0738a7443e371495"
+dependencies = [
+ "proc-macro-utils",
+ "proc-macro2",
+ "quote",
+]
+
+[[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.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
+
+[[package]]
+name = "md-5"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
+dependencies = [
+ "cfg-if",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
+
+[[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 = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "multer"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83e87776546dc87511aa5ee218730c92b666d7264ab6ed41f9d215af9cd5224b"
+dependencies = [
+ "bytes",
+ "encoding_rs",
+ "futures-util",
+ "http 1.4.2",
+ "httparse",
+ "memchr",
+ "mime",
+ "spin 0.9.9",
+ "version_check",
+]
+
+[[package]]
+name = "next_tuple"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "60993920e071b0c9b66f14e2b32740a4e27ffc82854dcd72035887f336a09a28"
+
+[[package]]
+name = "nkeys"
+version = "0.4.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "879011babc47a1c7fdf5a935ae3cfe94f34645ca0cac1c7f6424b36fc743d1bf"
+dependencies = [
+ "data-encoding",
+ "ed25519",
+ "ed25519-dalek",
+ "getrandom 0.2.17",
+ "log",
+ "rand 0.8.7",
+ "signatory",
+]
+
+[[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 = "nuid"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc895af95856f929163a0aa20c26a78d26bfdc839f51b9d5aa7a5b79e52b7e83"
+dependencies = [
+ "rand 0.8.7",
+]
+
+[[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 0.8.7",
+ "smallvec",
+ "zeroize",
+]
+
+[[package]]
+name = "num-conv"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
+
+[[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.46"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
+dependencies = [
+ "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 = "oauth2"
+version = "5.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
+dependencies = [
+ "base64 0.22.1",
+ "chrono",
+ "getrandom 0.2.17",
+ "http 1.4.2",
+ "rand 0.8.7",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "serde_path_to_error",
+ "sha2 0.10.9",
+ "thiserror 1.0.69",
+ "url",
+]
+
+[[package]]
+name = "oco_ref"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed0423ff9973dea4d6bd075934fdda86ebb8c05bdf9d6b0507067d4a1226371d"
+dependencies = [
+ "serde",
+ "thiserror 2.0.19",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[package]]
+name = "openidconnect"
+version = "4.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c6709ba2ea764bbed26bce1adf3c10517113ddea6f2d4196e4851757ef2b2"
+dependencies = [
+ "base64 0.21.7",
+ "chrono",
+ "dyn-clone",
+ "ed25519-dalek",
+ "hmac 0.12.1",
+ "http 1.4.2",
+ "itertools 0.10.5",
+ "log",
+ "oauth2",
+ "p256",
+ "p384",
+ "rand 0.8.7",
+ "rsa",
+ "serde",
+ "serde-value",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_plain",
+ "serde_with",
+ "sha2 0.10.9",
+ "subtle",
+ "thiserror 1.0.69",
+ "url",
+]
+
+[[package]]
+name = "openssl-probe"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
+
+[[package]]
+name = "openssl-probe"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
+
+[[package]]
+name = "or_poisoned"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c04f5d74368e4d0dfe06c45c8627c81bd7c317d52762d118fb9b3076f6420fd"
+
+[[package]]
+name = "ordered-float"
+version = "2.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "outref"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
+
+[[package]]
+name = "p256"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2 0.10.9",
+]
+
+[[package]]
+name = "p384"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
+dependencies = [
+ "ecdsa",
+ "elliptic-curve",
+ "primeorder",
+ "sha2 0.10.9",
+]
+
+[[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",
+ "smallvec",
+ "windows-link",
+]
+
+[[package]]
+name = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[package]]
+name = "pathdiff"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
+
+[[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"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[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.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "portable-atomic"
+version = "1.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3"
+
+[[package]]
+name = "portal"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-nats",
+ "aws-sdk-s3",
+ "axum",
+ "base64 0.22.1",
+ "chrono",
+ "console_error_panic_hook",
+ "dotenvy",
+ "futures",
+ "js-sys",
+ "leptos",
+ "leptos_axum",
+ "leptos_meta",
+ "leptos_router",
+ "openidconnect",
+ "serde",
+ "serde_json",
+ "serde_yaml",
+ "sha2 0.10.9",
+ "tokio",
+ "tower",
+ "tower-http",
+ "tower-sessions",
+ "tracing",
+ "tracing-subscriber",
+ "url",
+ "uuid",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+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 2.0.119",
+]
+
+[[package]]
+name = "primeorder"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
+dependencies = [
+ "elliptic-curve",
+]
+
+[[package]]
+name = "proc-macro-error-attr2"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+]
+
+[[package]]
+name = "proc-macro-error2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
+dependencies = [
+ "proc-macro-error-attr2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "proc-macro-utils"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eeaf08a13de400bc215877b5bdc088f241b12eb42f0a548d3390dc1c56bb7071"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "smallvec",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.107"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "version_check",
+ "yansi",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls 0.23.42",
+ "socket2 0.6.5",
+ "thiserror 2.0.19",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
+dependencies = [
+ "bytes",
+ "getrandom 0.4.3",
+ "lru-slab",
+ "rand 0.10.2",
+ "rand_pcg",
+ "ring",
+ "rustc-hash",
+ "rustls 0.23.42",
+ "rustls-pki-types",
+ "slab",
+ "thiserror 2.0.19",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2 0.6.5",
+ "tracing",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.47"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "quote-use"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9619db1197b497a36178cfc736dc96b271fe918875fbf1344c436a7e93d0321e"
+dependencies = [
+ "quote",
+ "quote-use-macros",
+]
+
+[[package]]
+name = "quote-use-macros"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "82ebfb7faafadc06a7ab141a6f67bcfb24cb8beb158c6fe933f2f035afa99f35"
+dependencies = [
+ "proc-macro-utils",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "r-efi"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
+
+[[package]]
+name = "rand"
+version = "0.8.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
+[[package]]
+name = "rand"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
+dependencies = [
+ "chacha20",
+ "getrandom 0.4.3",
+ "rand_core 0.10.1",
+]
+
+[[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 0.6.4",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
+dependencies = [
+ "ppv-lite86",
+ "rand_core 0.9.5",
+]
+
+[[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 = "rand_core"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
+dependencies = [
+ "getrandom 0.3.4",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
+
+[[package]]
+name = "rand_pcg"
+version = "0.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
+[[package]]
+name = "reactive_graph"
+version = "0.2.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "00c5a025366836190c7030e883cc2bcd9e384ff555336e3c7954741ca411b177"
+dependencies = [
+ "any_spawner",
+ "async-lock",
+ "futures",
+ "guardian",
+ "hydration_context",
+ "indexmap 2.14.0",
+ "or_poisoned",
+ "paste",
+ "pin-project-lite",
+ "rustc-hash",
+ "rustc_version",
+ "send_wrapper",
+ "serde",
+ "slotmap",
+ "thiserror 2.0.19",
+ "web-sys",
+]
+
+[[package]]
+name = "reactive_stores"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c30fd35b7d299c591293bb69fed47a703eb2703b1cff0493e78b16ed007e5382"
+dependencies = [
+ "guardian",
+ "indexmap 2.14.0",
+ "itertools 0.14.0",
+ "or_poisoned",
+ "paste",
+ "reactive_graph",
+ "reactive_stores_macro",
+ "rustc-hash",
+ "send_wrapper",
+]
+
+[[package]]
+name = "reactive_stores_macro"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68072edd607edd30b9ebf57d984ba45d8ab8809e598d0f6046278373fb76a5a0"
+dependencies = [
+ "convert_case 0.11.0",
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "ref-cast"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d"
+dependencies = [
+ "ref-cast-impl",
+]
+
+[[package]]
+name = "ref-cast-impl"
+version = "1.0.26"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "regex"
+version = "1.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-automata",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-lite"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
+
+[[package]]
+name = "reqwest"
+version = "0.12.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "http 1.4.2",
+ "http-body 1.1.0",
+ "http-body-util",
+ "hyper 1.11.0",
+ "hyper-rustls 0.27.9",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls 0.23.42",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls 0.26.4",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[package]]
+name = "rfc6979"
+version = "0.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
+dependencies = [
+ "hmac 0.12.1",
+ "subtle",
+]
+
+[[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 0.9.6",
+ "digest 0.10.7",
+ "num-bigint-dig",
+ "num-integer",
+ "num-traits",
+ "pkcs1",
+ "pkcs8",
+ "rand_core 0.6.4",
+ "signature",
+ "spki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rstml"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61cf4616de7499fc5164570d40ca4e1b24d231c6833a88bff0fe00725080fd56"
+dependencies = [
+ "derive-where",
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn 2.0.119",
+ "syn_derive",
+ "thiserror 2.0.19",
+]
+
+[[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[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.21.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e"
+dependencies = [
+ "log",
+ "ring",
+ "rustls-webpki 0.101.7",
+ "sct",
+]
+
+[[package]]
+name = "rustls"
+version = "0.23.42"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138"
+dependencies = [
+ "aws-lc-rs",
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki 0.103.13",
+ "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 0.1.6",
+ "rustls-pemfile",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework 2.11.1",
+]
+
+[[package]]
+name = "rustls-native-certs"
+version = "0.8.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d"
+dependencies = [
+ "openssl-probe 0.2.1",
+ "rustls-pki-types",
+ "schannel",
+ "security-framework 3.7.0",
+]
+
+[[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.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.101.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
+[[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.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "aws-lc-rs",
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
+
+[[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.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "schemars"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "schemars"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
+dependencies = [
+ "dyn-clone",
+ "ref-cast",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "sct"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414"
+dependencies = [
+ "ring",
+ "untrusted",
+]
+
+[[package]]
+name = "sec1"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
+dependencies = [
+ "base16ct",
+ "der",
+ "generic-array",
+ "pkcs8",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework"
+version = "3.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
+dependencies = [
+ "bitflags",
+ "core-foundation 0.10.1",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
+[[package]]
+name = "security-framework-sys"
+version = "2.17.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
+dependencies = [
+ "core-foundation-sys",
+ "libc",
+]
+
+[[package]]
+name = "semver"
+version = "1.0.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
+
+[[package]]
+name = "send_wrapper"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "serde"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde-value"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c"
+dependencies = [
+ "ordered-float",
+ "serde",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.229"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_nanos"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a93142f0367a4cc53ae0fead1bcda39e85beccfad3dcd717656cacab94b12985"
+dependencies = [
+ "serde",
+]
+
+[[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_plain"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "serde_qs"
+version = "0.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3faaf9e727533a19351a43cc5a8de957372163c7d35cc48c90b75cdda13c352"
+dependencies = [
+ "percent-encoding",
+ "serde",
+ "thiserror 2.0.19",
+]
+
+[[package]]
+name = "serde_repr"
+version = "0.1.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_spanned"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+dependencies = [
+ "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 = "serde_with"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c"
+dependencies = [
+ "base64 0.22.1",
+ "bs58",
+ "chrono",
+ "hex",
+ "indexmap 1.9.3",
+ "indexmap 2.14.0",
+ "schemars 0.9.0",
+ "schemars 1.2.2",
+ "serde_core",
+ "serde_json",
+ "serde_with_macros",
+ "time",
+]
+
+[[package]]
+name = "serde_with_macros"
+version = "3.21.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660"
+dependencies = [
+ "darling",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "serde_yaml"
+version = "0.9.34+deprecated"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
+dependencies = [
+ "indexmap 2.14.0",
+ "itoa",
+ "ryu",
+ "serde",
+ "unsafe-libyaml",
+]
+
+[[package]]
+name = "server_fn"
+version = "0.8.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "be8559dd05af1b5b7e363a150616589d5a88af5187273f7f331ba0dae8922812"
+dependencies = [
+ "axum",
+ "base64 0.22.1",
+ "bytes",
+ "const-str",
+ "const_format",
+ "futures",
+ "gloo-net",
+ "http 1.4.2",
+ "http-body-util",
+ "hyper 1.11.0",
+ "inventory",
+ "js-sys",
+ "or_poisoned",
+ "pin-project-lite",
+ "rustc_version",
+ "rustversion",
+ "send_wrapper",
+ "serde",
+ "serde_json",
+ "serde_qs",
+ "server_fn_macro_default",
+ "thiserror 2.0.19",
+ "throw_error",
+ "tokio",
+ "tower",
+ "tower-layer",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "wasm-streams",
+ "web-sys",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "server_fn_macro"
+version = "0.8.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1295b54815397d30d986b63f93cfd515fa86d5e528e0bb589ce9d530502f9e0f"
+dependencies = [
+ "const_format",
+ "convert_case 0.11.0",
+ "proc-macro2",
+ "quote",
+ "rustc_version",
+ "syn 2.0.119",
+ "xxhash-rust",
+]
+
+[[package]]
+name = "server_fn_macro_default"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63eb08f80db903d3c42f64e60ebb3875e0305be502bdc064ec0a0eab42207f00"
+dependencies = [
+ "server_fn_macro",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "sha1"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha1"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.2.17",
+ "digest 0.10.7",
+]
+
+[[package]]
+name = "sha2"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+dependencies = [
+ "cfg-if",
+ "cpufeatures 0.3.0",
+ "digest 0.11.3",
+]
+
+[[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 = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+
+[[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 = "signatory"
+version = "0.27.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1e303f8205714074f6068773f0e29527e0453937fe837c9717d066635b65f31"
+dependencies = [
+ "pkcs8",
+ "rand_core 0.6.4",
+ "signature",
+ "zeroize",
+]
+
+[[package]]
+name = "signature"
+version = "2.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
+dependencies = [
+ "digest 0.10.7",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
+
+[[package]]
+name = "socket2"
+version = "0.5.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
+dependencies = [
+ "libc",
+ "windows-sys 0.52.0",
+]
+
+[[package]]
+name = "socket2"
+version = "0.6.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e"
+
+[[package]]
+name = "spin"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3"
+
+[[package]]
+name = "spki"
+version = "0.7.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
+dependencies = [
+ "base64ct",
+ "der",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[package]]
+name = "strsim"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
+
+[[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.119"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn"
+version = "3.0.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "syn_derive"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb066a04799e45f5d582e8fc6ec8e6d6896040d00898eb4e6a835196815b219"
+dependencies = [
+ "proc-macro-error2",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+dependencies = [
+ "futures-core",
+]
+
+[[package]]
+name = "synstructure"
+version = "0.13.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tachys"
+version = "0.2.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a92ba81187437cc5df4281f2326a2e13cc81e8f96448292d1112388e2025ca66"
+dependencies = [
+ "any_spawner",
+ "async-trait",
+ "const_str_slice_concat",
+ "drain_filter_polyfill",
+ "either_of",
+ "erased",
+ "futures",
+ "html-escape",
+ "indexmap 2.14.0",
+ "itertools 0.14.0",
+ "js-sys",
+ "next_tuple",
+ "oco_ref",
+ "or_poisoned",
+ "paste",
+ "reactive_graph",
+ "reactive_stores",
+ "rustc-hash",
+ "rustc_version",
+ "send_wrapper",
+ "slotmap",
+ "throw_error",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[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.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9"
+dependencies = [
+ "thiserror-impl 2.0.19",
+]
+
+[[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 2.0.119",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 3.0.3",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "throw_error"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc0ed6038fcbc0795aca7c92963ddda636573b956679204e044492d2b13c8f64"
+dependencies = [
+ "pin-project-lite",
+]
+
+[[package]]
+name = "time"
+version = "0.3.54"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244"
+dependencies = [
+ "deranged",
+ "num-conv",
+ "powerfmt",
+ "serde_core",
+ "time-core",
+ "time-macros",
+]
+
+[[package]]
+name = "time-core"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109"
+
+[[package]]
+name = "time-macros"
+version = "0.2.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85"
+dependencies = [
+ "num-conv",
+ "time-core",
+]
+
+[[package]]
+name = "tinystr"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d"
+dependencies = [
+ "displaydoc",
+ "zerovec",
+]
+
+[[package]]
+name = "tinyvec"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f"
+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.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2 0.6.5",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.24.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081"
+dependencies = [
+ "rustls 0.21.12",
+ "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.42",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
+dependencies = [
+ "futures-util",
+ "log",
+ "tokio",
+ "tungstenite",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "libc",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-websockets"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f591660438b3038dd04d16c938271c79e7e06260ad2ea2885a4861bfb238605d"
+dependencies = [
+ "base64 0.22.1",
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "http 1.4.2",
+ "httparse",
+ "rand 0.8.7",
+ "ring",
+ "rustls-native-certs 0.8.4",
+ "rustls-pki-types",
+ "tokio",
+ "tokio-rustls 0.26.4",
+ "tokio-util",
+]
+
+[[package]]
+name = "toml"
+version = "1.1.4+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
+dependencies = [
+ "serde_core",
+ "serde_spanned",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_datetime"
+version = "1.1.1+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.1.3+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+dependencies = [
+ "winnow",
+]
+
+[[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-cookies"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "151b5a3e3c45df17466454bb74e9ecedecc955269bdedbf4d150dfa393b55a36"
+dependencies = [
+ "axum-core",
+ "cookie",
+ "futures-util",
+ "http 1.4.2",
+ "parking_lot",
+ "pin-project-lite",
+ "tower-layer",
+ "tower-service",
+]
+
+[[package]]
+name = "tower-http"
+version = "0.6.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840"
+dependencies = [
+ "bitflags",
+ "bytes",
+ "futures-core",
+ "futures-util",
+ "http 1.4.2",
+ "http-body 1.1.0",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "url",
+]
+
+[[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 = "tower-sessions"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "43a05911f23e8fae446005fe9b7b97e66d95b6db589dc1c4d59f6a2d4d4927d3"
+dependencies = [
+ "async-trait",
+ "http 1.4.2",
+ "time",
+ "tokio",
+ "tower-cookies",
+ "tower-layer",
+ "tower-service",
+ "tower-sessions-core",
+ "tower-sessions-memory-store",
+ "tracing",
+]
+
+[[package]]
+name = "tower-sessions-core"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ce8cce604865576b7751b7a6bc3058f754569a60d689328bb74c52b1d87e355b"
+dependencies = [
+ "async-trait",
+ "axum-core",
+ "base64 0.22.1",
+ "futures",
+ "http 1.4.2",
+ "parking_lot",
+ "rand 0.8.7",
+ "serde",
+ "serde_json",
+ "thiserror 2.0.19",
+ "time",
+ "tokio",
+ "tracing",
+]
+
+[[package]]
+name = "tower-sessions-memory-store"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fb05909f2e1420135a831dd5df9f5596d69196d0a64c3499ca474c4bd3d33242"
+dependencies = [
+ "async-trait",
+ "time",
+ "tokio",
+ "tower-sessions-core",
+]
+
+[[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 2.0.119",
+]
+
+[[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.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
+dependencies = [
+ "matchers",
+ "nu-ansi-term",
+ "once_cell",
+ "regex-automata",
+ "sharded-slab",
+ "smallvec",
+ "thread_local",
+ "tracing",
+ "tracing-core",
+ "tracing-log",
+]
+
+[[package]]
+name = "try-lock"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
+
+[[package]]
+name = "tryhard"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fe58ebd5edd976e0fe0f8a14d2a04b7c81ef153ea9a54eebc42e67c2c23b4e5"
+dependencies = [
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tungstenite"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
+dependencies = [
+ "bytes",
+ "data-encoding",
+ "http 1.4.2",
+ "httparse",
+ "log",
+ "rand 0.9.5",
+ "sha1 0.10.7",
+ "thiserror 2.0.19",
+]
+
+[[package]]
+name = "typed-builder"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31aa81521b70f94402501d848ccc0ecaa8f93c8eb6999eb9747e72287757ffda"
+dependencies = [
+ "typed-builder-macro",
+]
+
+[[package]]
+name = "typed-builder-macro"
+version = "0.23.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "076a02dc54dd46795c2e9c8282ed40bcfb1e22747e955de9389a1de28190fb26"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "typenum"
+version = "1.20.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
+
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.24"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.13.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "unsafe-libyaml"
+version = "0.2.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
+
+[[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",
+ "serde_derive",
+]
+
+[[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.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
+dependencies = [
+ "getrandom 0.4.3",
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "vsimd"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
+
+[[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 = "want"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
+dependencies = [
+ "try-lock",
+]
+
+[[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.4+wasi-0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.76"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.126"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm_split_helpers"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ab578aae2fe2916edaea06843187d50f87b0965622da0ceef648edca27b385ba"
+dependencies = [
+ "async-once-cell",
+ "wasm_split_macros",
+]
+
+[[package]]
+name = "wasm_split_macros"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e653af7ee4a9ef0fce481a9ec6f43cb78de20d0cdb4f4f5862e1dc6e407e6c8"
+dependencies = [
+ "base16",
+ "quote",
+ "sha2 0.10.9",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.103"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "web-time"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "webpki-roots"
+version = "1.0.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[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 = "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 2.0.119",
+]
+
+[[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 2.0.119",
+]
+
+[[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.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets",
+]
+
+[[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.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
+]
+
+[[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_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[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_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[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_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "winnow"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.57.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
+
+[[package]]
+name = "writeable"
+version = "0.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4"
+
+[[package]]
+name = "xmlparser"
+version = "0.13.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4"
+
+[[package]]
+name = "xxhash-rust"
+version = "0.8.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6"
+
+[[package]]
+name = "yansi"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
+
+[[package]]
+name = "yoke"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+dependencies = [
+ "stable_deref_trait",
+ "yoke-derive",
+ "zerofrom",
+]
+
+[[package]]
+name = "yoke-derive"
+version = "0.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.55"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zerofrom"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272"
+dependencies = [
+ "zerofrom-derive",
+]
+
+[[package]]
+name = "zerofrom-derive"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+
+[[package]]
+name = "zerotrie"
+version = "0.2.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf"
+dependencies = [
+ "displaydoc",
+ "yoke",
+ "zerofrom",
+]
+
+[[package]]
+name = "zerovec"
+version = "0.11.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239"
+dependencies = [
+ "yoke",
+ "zerofrom",
+ "zerovec-derive",
+]
+
+[[package]]
+name = "zerovec-derive"
+version = "0.11.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..64cd5e9
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,111 @@
+[package]
+name = "portal"
+version = "0.1.0"
+edition = "2021"
+
+[lib]
+crate-type = ["cdylib", "rlib"]
+
+[dependencies]
+leptos = { version = "0.8" }
+leptos_meta = { version = "0.8" }
+leptos_router = { version = "0.8" }
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+
+# --- server only ---
+leptos_axum = { version = "0.8", optional = true }
+axum = { version = "0.8", features = ["multipart"], optional = true }
+aws-sdk-s3 = { version = "1", optional = true }
+tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"], optional = true }
+tower = { version = "0.5", optional = true }
+tower-http = { version = "0.6", features = ["fs"], optional = true }
+tower-sessions = { version = "0.14", optional = true }
+async-nats = { version = "0.38", optional = true }
+url = { version = "2", optional = true }
+serde_yaml = { version = "0.9", optional = true }
+sha2 = { version = "0.10", optional = true }
+openidconnect = { version = "4", optional = true }
+# For pulling the `groups` custom claim out of the already-verified ID
+# token's raw JWT payload - see server/oidc.rs::extract_groups_claim.
+base64 = { version = "0.22", optional = true }
+chrono = { version = "0.4", features = ["serde"], optional = true }
+uuid = { version = "1", features = ["v4"], optional = true }
+dotenvy = { version = "0.15", optional = true }
+anyhow = { version = "1", optional = true }
+futures = { version = "0.3", optional = true }
+tracing = { version = "0.1", optional = true }
+tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
+
+# --- browser only ---
+wasm-bindgen = { version = "0.2", optional = true }
+wasm-bindgen-futures = { version = "0.4", optional = true }
+js-sys = { version = "0.3", optional = true }
+console_error_panic_hook = { version = "0.1", optional = true }
+web-sys = { version = "0.3", features = [
+ "Blob",
+ "File",
+ "FileList",
+ "FormData",
+ "HtmlInputElement",
+ "Request",
+ "RequestInit",
+ "Response",
+ "Window",
+], optional = true }
+
+[features]
+default = []
+hydrate = [
+ "leptos/hydrate",
+ "dep:wasm-bindgen",
+ "dep:wasm-bindgen-futures",
+ "dep:js-sys",
+ "dep:console_error_panic_hook",
+ "dep:web-sys",
+]
+ssr = [
+ "leptos/ssr",
+ "leptos_meta/ssr",
+ "leptos_router/ssr",
+ "dep:leptos_axum",
+ "dep:axum",
+ "dep:aws-sdk-s3",
+ "dep:tokio",
+ "dep:tower",
+ "dep:tower-http",
+ "dep:tower-sessions",
+ "dep:async-nats",
+ "dep:url",
+ "dep:serde_yaml",
+ "dep:sha2",
+ "dep:openidconnect",
+ "dep:base64",
+ "dep:chrono",
+ "dep:uuid",
+ "dep:dotenvy",
+ "dep:anyhow",
+ "dep:futures",
+ "dep:tracing",
+ "dep:tracing-subscriber",
+]
+
+[profile.wasm-release]
+inherits = "release"
+opt-level = 'z'
+lto = true
+codegen-units = 1
+
+[package.metadata.leptos]
+output-name = "portal"
+site-root = "target/site"
+site-pkg-dir = "pkg"
+style-file = "style/main.css"
+assets-dir = "public"
+site-addr = "127.0.0.1:3010"
+reload-port = 3011
+bin-features = ["ssr"]
+bin-default-features = false
+lib-features = ["hydrate"]
+lib-default-features = false
+lib-profile-release = "wasm-release"
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..a8bf722
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/public/fonts/quicksand-semibold-latin-ext.woff2 b/public/fonts/quicksand-semibold-latin-ext.woff2
new file mode 100644
index 0000000..c7abeeb
Binary files /dev/null and b/public/fonts/quicksand-semibold-latin-ext.woff2 differ
diff --git a/public/fonts/quicksand-semibold-latin.woff2 b/public/fonts/quicksand-semibold-latin.woff2
new file mode 100644
index 0000000..df7256d
Binary files /dev/null and b/public/fonts/quicksand-semibold-latin.woff2 differ
diff --git a/public/fonts/quicksand-semibold-vietnamese.woff2 b/public/fonts/quicksand-semibold-vietnamese.woff2
new file mode 100644
index 0000000..a96d672
Binary files /dev/null and b/public/fonts/quicksand-semibold-vietnamese.woff2 differ
diff --git a/src/answers.rs b/src/answers.rs
new file mode 100644
index 0000000..db39be6
--- /dev/null
+++ b/src/answers.rs
@@ -0,0 +1,179 @@
+//! Durable, queryable storage for submitted answers - the KV-backed
+//! counterpart to the ephemeral `events::AnswerSubmitted` every
+//! submission already publishes. The runtime here has no notion of
+//! "applicant" or any other specific workflow: an `Alternative.record_as`
+//! names a NATS KV bucket (nothing more); on submit, the response gets
+//! wrapped in the same envelope (`Answer`) regardless of what content
+//! produced it. `resource::get_resource` reads it back generically, and
+//! `transition_answer` below moves an answer between states named in
+//! content (`ResourceSpec.transitions`), never states hardcoded here.
+//! Whatever workflow this ends up powering - applicant review, order
+//! triage, comment moderation - is entirely a property of some
+//! `portal-content` YAML, not of this file.
+
+use leptos::prelude::*;
+use serde::{Deserialize, Serialize};
+
+/// An answer's starting state - content decides what it may transition
+/// to (`ResourceSpec.transitions`), never this file.
+pub const OPEN_STATE: &str = "open";
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Answer {
+ /// The submission's chain hash - stable, unique, and already what
+ /// ties this answer back into the DAG a transition later extends.
+ pub id: String,
+ pub question_id: String,
+ pub alternative: String,
+ /// The submitted form fields, verbatim - the runtime never inspects
+ /// these keys, only content (via its own copy/labels) gives them
+ /// meaning.
+ pub responses: serde_json::Value,
+ pub state: String,
+ pub submitted_ms: i64,
+ #[serde(default)]
+ pub decided_ms: Option,
+ #[serde(default)]
+ pub decided_by: Option,
+}
+
+/// Writes a submitted answer into `bucket`, creating it on first use -
+/// there's no fixed, pre-declared list of buckets, since content can
+/// name any.
+#[cfg(feature = "ssr")]
+pub async fn store_answer(
+ js: &async_nats::jetstream::Context,
+ bucket: &str,
+ id: String,
+ question_id: &str,
+ alternative: &str,
+ responses: &serde_json::Value,
+ submitted_ms: i64,
+) -> anyhow::Result<()> {
+ let store = match js.get_key_value(bucket).await {
+ Ok(store) => store,
+ Err(_) => {
+ js.create_key_value(async_nats::jetstream::kv::Config {
+ bucket: bucket.to_string(),
+ ..Default::default()
+ })
+ .await?
+ }
+ };
+ let answer = Answer {
+ id: id.clone(),
+ question_id: question_id.to_string(),
+ alternative: alternative.to_string(),
+ responses: responses.clone(),
+ state: OPEN_STATE.to_string(),
+ submitted_ms,
+ decided_ms: None,
+ decided_by: None,
+ };
+ let data = serde_json::to_vec(&answer)?;
+ store.put(id, data.into()).await?;
+ Ok(())
+}
+
+/// Moves a stored answer from `open` to one of the target states its
+/// own resource spec declares (`ResourceSpec.transitions`) - `to` is
+/// checked against that content-declared allow-list, never accepted as
+/// an arbitrary string. Requires a signed-in member of the resource's
+/// `requires_group`, regardless of whether the resource is otherwise
+/// `public` - reads can be public, mutations always need identity.
+#[server]
+pub async fn transition_answer(
+ question_id: String,
+ feature_name: String,
+ item_id: String,
+ to: String,
+) -> Result<(), ServerFnError> {
+ use crate::auth::{User, SESSION_USER_KEY};
+ use crate::chain::hash_node;
+ use crate::events::{emit_answer_submitted, AnswerSubmitted};
+ use crate::server::AppState;
+
+ let state = expect_context::();
+ let question = state
+ .questions
+ .get(&question_id)
+ .ok_or_else(|| ServerFnError::new("unknown question"))?;
+ let feature = question
+ .alternatives
+ .iter()
+ .flat_map(|a| &a.features)
+ .find(|f| f.name == feature_name)
+ .ok_or_else(|| ServerFnError::new("unknown feature"))?;
+ let resource = feature
+ .resource
+ .as_ref()
+ .ok_or_else(|| ServerFnError::new("feature has no resource"))?;
+ let transition = resource
+ .transitions
+ .iter()
+ .find(|t| t.to == to)
+ .ok_or_else(|| ServerFnError::new("no such transition"))?
+ .clone();
+ let group = resource
+ .requires_group
+ .as_deref()
+ .ok_or_else(|| ServerFnError::new("resource has no transitions available"))?;
+
+ let session: tower_sessions::Session = leptos_axum::extract().await?;
+ let user = session
+ .get::(SESSION_USER_KEY)
+ .await
+ .map_err(|e| ServerFnError::new(e.to_string()))?
+ .ok_or_else(|| ServerFnError::new("not signed in"))?;
+ if !user.groups.iter().any(|g| g == group) {
+ return Err(ServerFnError::new("not authorized"));
+ }
+
+ let store = state
+ .jetstream
+ .get_key_value(&resource.bucket)
+ .await
+ .map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
+ let bytes = store
+ .get(&item_id)
+ .await
+ .map_err(|e| ServerFnError::new(e.to_string()))?
+ .ok_or_else(|| ServerFnError::new("unknown answer"))?;
+ let mut answer: Answer =
+ serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))?;
+ if answer.state != OPEN_STATE {
+ return Err(ServerFnError::new("already decided"));
+ }
+
+ let decided_ms = chrono::Utc::now().timestamp_millis();
+ answer.state = to.clone();
+ answer.decided_ms = Some(decided_ms);
+ answer.decided_by = Some(user.username.clone());
+
+ let data = serde_json::to_vec(&answer).map_err(|e| ServerFnError::new(e.to_string()))?;
+ store
+ .put(&item_id, data.into())
+ .await
+ .map_err(|e| ServerFnError::new(e.to_string()))?;
+
+ // Extend the DAG: the decision is a child node of the answer's own
+ // submission hash, published the same way any other answer is -
+ // question_id/alternative come from this call's own arguments and
+ // the content-declared transition label, never a hardcoded value.
+ let decision_payload = serde_json::json!({ "to": to, "item": item_id, "by": user.username });
+ let parent_hashes = vec![item_id.clone()];
+ let chain_hash = hash_node(&question_id, &parent_hashes, &decision_payload, decided_ms);
+ let event = AnswerSubmitted {
+ chain_hash,
+ parent_hashes,
+ question_id,
+ alternative: transition.label,
+ responses: decision_payload,
+ timestamp_ms: decided_ms,
+ };
+ emit_answer_submitted(&state.nats, &event)
+ .await
+ .map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
+
+ Ok(())
+}
diff --git a/src/app.rs b/src/app.rs
new file mode 100644
index 0000000..78edd5e
--- /dev/null
+++ b/src/app.rs
@@ -0,0 +1,862 @@
+use leptos::prelude::*;
+use leptos_meta::{provide_meta_context, MetaTags, Stylesheet, Title};
+use leptos_router::{
+ components::{Route, Router, Routes},
+ hooks::{use_location, use_navigate, use_query_map},
+ path, NavigateOptions,
+};
+use serde::{Deserialize, Serialize};
+
+use crate::answers::{Answer, TransitionAnswer};
+use crate::auth::{current_user, User};
+use crate::content::{is_qualified, Alternative, Question, Transition};
+use crate::resource::get_resource;
+
+/// The visible site name/wordmark - "portal" is just this codebase's
+/// working title, not necessarily what any given deployment is called.
+/// Baked in at *build* time (`option_env!`, not `std::env::var`) so the
+/// ssr and hydrate targets - compiled separately - end up with the
+/// identical value without a runtime round-trip or context-passing
+/// between them; set `SITE_NAME` for both `cargo leptos` invocations
+/// (ssr and hydrate) to change it, e.g. `SITE_NAME=uhhm`. CSS uppercases
+/// the wordmark for display regardless of the source casing here.
+const SITE_NAME: &str = match option_env!("SITE_NAME") {
+ Some(name) => name,
+ None => "portal",
+};
+
+pub fn shell(options: LeptosOptions) -> impl IntoView {
+ view! {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+}
+
+#[component]
+pub fn App() -> impl IntoView {
+ provide_meta_context();
+
+ view! {
+
+
+
+ }>
+
+
+
+
+ }
+}
+
+#[component]
+fn QuestionPage() -> impl IntoView {
+ let location = use_location();
+ let query = use_query_map();
+ let path = Memo::new(move |_| {
+ let p = location.pathname.get();
+ if p.is_empty() {
+ "/".to_string()
+ } else {
+ p
+ }
+ });
+ let parent_hash = Memo::new(move |_| query.with(|q| q.get("chain")));
+
+ let question = Resource::new(move || path.get(), get_question);
+ let user = Resource::new(|| (), |_| current_user());
+
+ view! {
+
+ "loading…"
+
+ }
+ }>
+ {move || {
+ // Both read unconditionally (not nested inside the match
+ // below) so Suspense tracks both from the first pass,
+ // rather than only discovering `user` once `question`
+ // has already resolved.
+ let user_res = user.get();
+ let question_res = question.get();
+ question_res
+ .map(|res| match res {
+ Ok(Some(q)) => {
+ let current = user_res.and_then(|r| r.ok()).flatten();
+ view! {
+
+ }
+ .into_any()
+ }
+ _ => view! { }.into_any(),
+ })
+ }}
+
+ }
+}
+
+#[component]
+fn QuestionView(
+ question: Question,
+ parent_hash: Option,
+ user: Option,
+) -> impl IntoView {
+ let question_id = question.id.clone();
+
+ // Generic: any question with `qualifies` set renders this same gate
+ // instead of its alternatives - "/review" isn't a special case, it's
+ // just a question that happens to have `qualifies` set.
+ if !is_qualified(user.as_ref(), &question) {
+ let signed_in = user.is_some();
+ return view! {
+
+
+
+ {if signed_in {
+ view! { "This part of the site is for organizational owners — sign in with that account to take a look."
}
+ .into_any()
+ } else {
+ view! {
+
+ "Sign in"
+
+ }
+ .into_any()
+ }}
+
+
+ }
+ .into_any();
+ }
+
+ view! {
+
+
+
+ }
+ }
+ />
+
+ }
+ .into_any()
+}
+
+// Binds the actual "YES - Rasterized Lines" piece live at uhhm.no
+// (ported to `public/yes.js`, an ES module now instead of a page-owning
+// script) as a typed JS class - the idiomatic wasm-bindgen way to drive
+// an existing JS module from Rust, rather than re-implementing canvas
+// animation logic here.
+#[cfg(feature = "hydrate")]
+mod yes {
+ use wasm_bindgen::prelude::*;
+
+ #[wasm_bindgen(module = "/yes.js")]
+ extern "C" {
+ #[wasm_bindgen(js_name = RasterizedYES)]
+ pub type RasterizedYes;
+
+ #[wasm_bindgen(constructor, js_class = "RasterizedYES")]
+ pub fn new() -> RasterizedYes;
+
+ #[wasm_bindgen(method)]
+ pub fn stop(this: &RasterizedYes);
+ }
+}
+
+#[component]
+fn Hero(title: String, description: String, show_yes: bool) -> impl IntoView {
+ // Only the landing page gets the full interactive piece - it's the
+ // one page this is actually "the" hero for; other pages (thank-you
+ // pages, /review) get the plain dark header below. Starts the
+ // animation once the canvas elements exist (post-mount, via
+ // Effect), and explicitly stops the requestAnimationFrame loop on
+ // unmount - the original page-owning script never needed this since
+ // navigating away meant a full document unload, which doesn't
+ // happen in an SPA.
+ #[cfg(feature = "hydrate")]
+ if show_yes {
+ // `on_cleanup` requires Send + Sync (even single-threaded, wasm),
+ // which a JS-backed value never is - `StoredValue`'s `LocalStorage`
+ // variant is the established way around this (same pattern
+ // cnats' WebRTC call state uses for its own non-Send peer map).
+ let instance: StoredValue, LocalStorage> =
+ StoredValue::new_local(None);
+ Effect::new(move |_| {
+ instance.set_value(Some(yes::RasterizedYes::new()));
+ });
+ on_cleanup(move || {
+ instance.update_value(|opt| {
+ if let Some(inst) = opt.take() {
+ inst.stop();
+ }
+ });
+ });
+ }
+
+ view! {
+
+ {show_yes
+ .then(|| {
+ view! {
+
+
+
+
+ }
+ })}
+
+
+ }
+}
+
+#[component]
+fn AlternativeCard(
+ question_id: String,
+ alternative: Alternative,
+ parent_hash: Option,
+) -> impl IntoView {
+ let has_action = alternative.action.is_some();
+
+ // File fields don't fit a live-typed RwSignal - they get
+ // their own map of element refs, read (and uploaded) only at submit
+ // time, and are excluded from `field_map` below.
+ let mut field_map: std::collections::HashMap> =
+ std::collections::HashMap::new();
+ let mut file_refs: std::collections::HashMap> =
+ std::collections::HashMap::new();
+ for feature in &alternative.features {
+ for req in &feature.requirements {
+ if req.kind == "file" {
+ file_refs.entry(req.name.clone()).or_insert_with(NodeRef::new);
+ } else {
+ field_map
+ .entry(req.name.clone())
+ .or_insert_with(|| RwSignal::new(String::new()));
+ }
+ }
+ }
+
+ // Prefixes field DOM ids with the alternative too, not just the
+ // question - two alternatives on the same page (e.g. both asking for
+ // "email") would otherwise mint the same id twice, which is invalid
+ // HTML and makes pick the wrong input.
+ let field_prefix = format!(
+ "{question_id}-{}",
+ alternative.name.to_lowercase().replace(' ', "-")
+ );
+
+ let submit = ServerAction::::new();
+ let navigate = use_navigate();
+
+ let question_id_for_submit = question_id.clone();
+ let alt_name_for_submit = alternative.name.clone();
+ let parent_hash_for_submit = parent_hash.clone();
+ let field_map_for_submit = field_map.clone();
+ let file_refs_for_submit = file_refs.clone();
+ let on_submit = move |ev: leptos::ev::SubmitEvent| {
+ ev.prevent_default();
+ let field_map_for_submit = field_map_for_submit.clone();
+ let file_refs_for_submit = file_refs_for_submit.clone();
+ let question_id_for_submit = question_id_for_submit.clone();
+ let alt_name_for_submit = alt_name_for_submit.clone();
+ let parent_hash_for_submit = parent_hash_for_submit.clone();
+ leptos::task::spawn_local(async move {
+ let mut map = serde_json::Map::new();
+ for (name, sig) in field_map_for_submit.iter() {
+ map.insert(name.clone(), serde_json::Value::String(sig.get()));
+ }
+ // Only used under hydrate (file uploads are a browser-only
+ // concern); referenced unconditionally so an `ssr` build
+ // doesn't warn about it going unused.
+ let _ = &file_refs_for_submit;
+
+ #[cfg(feature = "hydrate")]
+ for (name, file_ref) in file_refs_for_submit.iter() {
+ let Some(input) = file_ref.get_untracked() else {
+ continue;
+ };
+ let Some(files) = input.files() else { continue };
+ let Some(file) = files.get(0) else { continue };
+ match upload_file(&file).await {
+ Ok(key) => {
+ map.insert(name.clone(), serde_json::Value::String(key));
+ }
+ Err(e) => {
+ leptos::logging::error!("upload of {name} failed: {e:?}");
+ }
+ }
+ }
+
+ let responses_json =
+ serde_json::to_string(&serde_json::Value::Object(map)).unwrap_or_default();
+ submit.dispatch(SubmitAnswer {
+ question_id: question_id_for_submit,
+ alternative: alt_name_for_submit,
+ parent_hash: parent_hash_for_submit,
+ responses_json,
+ });
+ });
+ };
+
+ Effect::new(move |_| {
+ if let Some(Ok(result)) = submit.value().get() {
+ if let Some(next) = result.next {
+ navigate(
+ &format!("{next}?chain={}", result.chain_hash),
+ NavigateOptions::default(),
+ );
+ }
+ }
+ });
+
+ let button_label = alternative
+ .consequence
+ .first()
+ .cloned()
+ .unwrap_or_else(|| "Send".to_string());
+
+ view! {
+
+ {alternative.name.clone()}
+ {alternative.description.clone()}
+ {e}
}
+ />
+
+ }
+ }
+ }
+ />
+
+ {has_action
+ .then(|| {
+ view! {
+
+ }
+ })}
+
+ }
+}
+
+/// Fetches and renders a `Feature`'s `resource` (`content::ResourceSpec`).
+/// Fully generic: it has no idea what "/review" or "applicants" are - it
+/// just fetches `question_id`/`feature_name`'s data and renders it
+/// according to its own shape (see `ResourceValue`), with one button
+/// per `transitions` entry when the data looks like a list of answers.
+#[component]
+fn ResourceFeature(
+ question_id: String,
+ feature_name: String,
+ transitions: Vec,
+) -> impl IntoView {
+ let data = Resource::new(
+ {
+ let question_id = question_id.clone();
+ let feature_name = feature_name.clone();
+ move || (question_id.clone(), feature_name.clone())
+ },
+ |(q, f)| get_resource(q, f),
+ );
+
+ view! {
+
+
"loading…" }>
+ {move || {
+ let question_id = question_id.clone();
+ let feature_name = feature_name.clone();
+ let transitions = transitions.clone();
+ data.get()
+ .map(|res| match res {
+ Ok(value) => {
+ view! {
+
+ }
+ .into_any()
+ }
+ Err(e) => {
+ view! { {e.to_string()}
}.into_any()
+ }
+ })
+ }}
+
+
+ }
+}
+
+/// Renders whatever `get_resource` returned, purely from its shape: an
+/// array that parses as `answers::Answer` renders as a list (with
+/// transition buttons, if any were declared); anything else renders as
+/// a plain, read-only dump. No `kind`/render-mode tag anywhere - the
+/// data decides.
+#[component]
+fn ResourceValue(
+ question_id: String,
+ feature_name: String,
+ transitions: Vec,
+ value: serde_json::Value,
+) -> impl IntoView {
+ if let serde_json::Value::Array(items) = &value {
+ if let Ok(answers) = serde_json::from_value::>(value.clone()) {
+ let mut answers = answers;
+ answers.sort_by(|a, b| {
+ let open = |s: &str| s != crate::answers::OPEN_STATE;
+ open(&a.state).cmp(&open(&b.state)).then(b.submitted_ms.cmp(&a.submitted_ms))
+ });
+ return view! {
+
+
+ }
+ }
+ }
+ />
+
+ }
+ .into_any();
+ }
+ if items.is_empty() {
+ return view! { "Nothing here yet."
}.into_any();
+ }
+ }
+
+ view! { {value.to_string()} }.into_any()
+}
+
+#[component]
+fn AnswerRow(
+ question_id: String,
+ feature_name: String,
+ transitions: Vec,
+ answer: Answer,
+) -> impl IntoView {
+ let is_open = answer.state == crate::answers::OPEN_STATE;
+ let responses = answer
+ .responses
+ .as_object()
+ .cloned()
+ .unwrap_or_default();
+ let submitted_ms = answer.submitted_ms;
+ let state = answer.state.clone();
+ let item_id = answer.id.clone();
+
+ let transition = ServerAction::::new();
+
+ view! {
+
+
+
>()
+ }
+ key=|(k, _)| k.clone()
+ children=move |(key, value)| {
+ let text = value.as_str().map(str::to_string).unwrap_or_else(|| value.to_string());
+ view! {
+
+ {key}
+ {text}
+
+ }
+ }
+ />
+
+
+ {state}
+ {format_ms(submitted_ms)}
+
+
+
+
+ {t.label.clone()}
+
+ }
+ }
+ }
+ />
+
+
+
+ }
+}
+
+fn format_ms(ms: i64) -> String {
+ #[cfg(feature = "ssr")]
+ {
+ use chrono::TimeZone;
+ chrono::Utc
+ .timestamp_millis_opt(ms)
+ .single()
+ .map(|t| t.format("%Y-%m-%d %H:%M UTC").to_string())
+ .unwrap_or_else(|| ms.to_string())
+ }
+ #[cfg(not(feature = "ssr"))]
+ {
+ ms.to_string()
+ }
+}
+
+#[component]
+fn NotFound() -> impl IntoView {
+ view! {
+
+ "Nothing here"
+ "back to the start"
+
+ }
+}
+
+#[server]
+pub async fn get_question(path: String) -> Result, ServerFnError> {
+ use crate::server::AppState;
+ let state = expect_context::();
+ Ok(state.questions.get(&path).cloned())
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct SubmitResult {
+ pub next: Option,
+ pub chain_hash: String,
+}
+
+#[server]
+pub async fn submit_answer(
+ question_id: String,
+ alternative: String,
+ parent_hash: Option,
+ responses_json: String,
+) -> Result {
+ use crate::answers::store_answer;
+ use crate::auth::{User, SESSION_USER_KEY};
+ use crate::chain::hash_node;
+ use crate::content::is_qualified;
+ use crate::events::{emit_answer_submitted, AnswerSubmitted};
+ use crate::server::AppState;
+
+ let state = expect_context::();
+ let question = state
+ .questions
+ .get(&question_id)
+ .ok_or_else(|| ServerFnError::new("unknown question"))?;
+
+ let session: tower_sessions::Session = leptos_axum::extract().await?;
+ let user = session
+ .get::(SESSION_USER_KEY)
+ .await
+ .map_err(|e| ServerFnError::new(e.to_string()))?;
+ if !is_qualified(user.as_ref(), question) {
+ return Err(ServerFnError::new("not authorized for this question"));
+ }
+
+ let alt = question
+ .alternatives
+ .iter()
+ .find(|a| a.name == alternative)
+ .ok_or_else(|| ServerFnError::new("unknown alternative"))?;
+ let next = alt.action.clone();
+ let record_as = alt.record_as.clone();
+
+ let responses: serde_json::Value = serde_json::from_str(&responses_json)
+ .map_err(|e| ServerFnError::new(format!("invalid responses: {e}")))?;
+
+ let parent_hashes: Vec = parent_hash.into_iter().collect();
+ let timestamp_ms = chrono::Utc::now().timestamp_millis();
+ let chain_hash = hash_node(&question_id, &parent_hashes, &responses, timestamp_ms);
+
+ emit_answer_submitted(
+ &state.nats,
+ &AnswerSubmitted {
+ chain_hash: chain_hash.clone(),
+ parent_hashes,
+ question_id: question_id.clone(),
+ alternative: alternative.clone(),
+ responses: responses.clone(),
+ timestamp_ms,
+ },
+ )
+ .await
+ .map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
+
+ // Best-effort: a bucket-write hiccup shouldn't fail a submission the
+ // NATS event has already recorded.
+ if let Some(bucket) = &record_as {
+ if let Err(e) = store_answer(
+ &state.jetstream,
+ bucket,
+ chain_hash.clone(),
+ &question_id,
+ &alternative,
+ &responses,
+ timestamp_ms,
+ )
+ .await
+ {
+ tracing::error!("failed to store answer in {bucket}: {e}");
+ }
+ }
+
+ Ok(SubmitResult { next, chain_hash })
+}
+
+/// Uploads one file to `POST /upload` and returns its stored object key
+/// - the value a `type: file` requirement contributes to
+/// `responses_json`, same as any other field. A plain `fetch`, not a
+/// Leptos server fn: those aren't built for binary bodies, and the
+/// server side is a raw Axum multipart handler (`src/upload.rs`) for
+/// the same reason.
+#[cfg(feature = "hydrate")]
+async fn upload_file(file: &web_sys::File) -> Result {
+ use wasm_bindgen::{JsCast, JsValue};
+ use wasm_bindgen_futures::JsFuture;
+ use web_sys::{FormData, Request, RequestInit, Response};
+
+ let form = FormData::new()?;
+ form.append_with_blob("file", file)?;
+
+ let opts = RequestInit::new();
+ opts.set_method("POST");
+ opts.set_body_opt_form_data(Some(&form));
+ let request = Request::new_with_str_and_init("/upload", &opts)?;
+
+ let window = web_sys::window().ok_or_else(|| JsValue::from_str("no window"))?;
+ let resp: Response = JsFuture::from(window.fetch_with_request(&request))
+ .await?
+ .dyn_into()?;
+ if !resp.ok() {
+ return Err(JsValue::from_str(&format!(
+ "upload failed with status {}",
+ resp.status()
+ )));
+ }
+ let json = JsFuture::from(resp.json()?).await?;
+ js_sys::Reflect::get(&json, &JsValue::from_str("key"))?
+ .as_string()
+ .ok_or_else(|| JsValue::from_str("upload response had no key"))
+}
diff --git a/src/auth.rs b/src/auth.rs
new file mode 100644
index 0000000..2811336
--- /dev/null
+++ b/src/auth.rs
@@ -0,0 +1,30 @@
+use leptos::prelude::*;
+use serde::{Deserialize, Serialize};
+
+/// The authenticated user, as established by the Kanidm OIDC flow and
+/// stored in the server-side session. Ported from cnats' `auth.rs` -
+/// same shape, same provider.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+pub struct User {
+ pub sub: String,
+ pub username: String,
+ pub display_name: String,
+ /// Kanidm group membership, from the `groups` OIDC claim (see
+ /// `oauth2 update-claim-map`). Fixed at login time - not re-checked
+ /// live, so a group change only takes effect on the next login.
+ #[serde(default)]
+ pub groups: Vec,
+}
+
+pub const SESSION_USER_KEY: &str = "user";
+
+/// Returns the currently signed-in user, if any.
+#[server]
+pub async fn current_user() -> Result, ServerFnError> {
+ let session: tower_sessions::Session = leptos_axum::extract().await?;
+ let user = session
+ .get::(SESSION_USER_KEY)
+ .await
+ .map_err(|e| ServerFnError::new(e.to_string()))?;
+ Ok(user)
+}
diff --git a/src/chain.rs b/src/chain.rs
new file mode 100644
index 0000000..264db66
--- /dev/null
+++ b/src/chain.rs
@@ -0,0 +1,35 @@
+//! SHA-256 hashed DAG chain, ported from dodrenett's lib/challenge.ts.
+//! Kept deliberately plain: every submission hashes in its parent(s), but
+//! nothing here produces more than one parent yet - that only starts
+//! mattering once something (an LLM, or a human) posts follow-up
+//! questions back into a visitor's chain instead of the fixed content in
+//! ../portal-content. The DAG-shaped `parent_hashes` field exists so that
+//! can slot in later without a schema change; the hashing itself doesn't
+//! need to.
+#![cfg(feature = "ssr")]
+
+use sha2::{Digest, Sha256};
+
+/// Hashes one chain node from its question, its parent hash(es), the
+/// submitted responses, and a timestamp. Parents are sorted first so the
+/// hash doesn't depend on the order multiple parents happened to arrive
+/// in.
+pub fn hash_node(
+ question_id: &str,
+ parent_hashes: &[String],
+ responses: &serde_json::Value,
+ timestamp_ms: i64,
+) -> String {
+ let mut parents = parent_hashes.to_vec();
+ parents.sort();
+ let payload = serde_json::json!({
+ "questionId": question_id,
+ "parentHashes": parents,
+ "responses": responses,
+ "timestamp": timestamp_ms,
+ });
+ let data = serde_json::to_vec(&payload).expect("json values always serialize");
+ let mut hasher = Sha256::new();
+ hasher.update(&data);
+ format!("{:x}", hasher.finalize())
+}
diff --git a/src/content.rs b/src/content.rs
new file mode 100644
index 0000000..212fa17
--- /dev/null
+++ b/src/content.rs
@@ -0,0 +1,168 @@
+use serde::{Deserialize, Serialize};
+
+/// One page: a prompt plus the paths on from it. `id` doubles as the URL
+/// path it's served at ("/" is the landing page). Loaded from a plain
+/// YAML file per question in a content directory kept in its own git
+/// repo (see ../portal-content) - editing content is a content-repo
+/// commit, not a Rust rebuild.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Question {
+ pub id: String,
+ #[serde(default)]
+ pub route: Option,
+ pub name: String,
+ #[serde(default)]
+ pub description: String,
+ /// Kanidm group required to view/submit this question - `None` means
+ /// open to anyone, matching every question today. Content-driven
+ /// on purpose: a gated page like "/review" is just a Question with
+ /// this set, not a bespoke Rust route.
+ #[serde(default)]
+ pub qualifies: Option,
+ #[serde(default)]
+ pub alternatives: Vec,
+}
+
+/// Whether `user` may view/submit `question`. `true` when the question
+/// has no `qualifies` requirement. Mirrors `chat::is_authorized_for_room`
+/// in cnats - same synchronous, I/O-free shape, same staleness tradeoff
+/// (group membership is fixed at login, not re-checked live).
+pub fn is_qualified(user: Option<&crate::auth::User>, question: &Question) -> bool {
+ match &question.qualifies {
+ None => true,
+ Some(group) => user.is_some_and(|u| u.groups.iter().any(|g| g == group)),
+ }
+}
+
+/// One path through a question: a short pitch, an optional next question
+/// to advance to on submit, and the form (via `features`) that collects
+/// what's needed to get there.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Alternative {
+ pub name: String,
+ #[serde(default)]
+ pub description: String,
+ #[serde(default)]
+ pub action: Option,
+ #[serde(default)]
+ pub consequence: Vec,
+ #[serde(default)]
+ pub encouragements: Vec,
+ #[serde(default)]
+ pub features: Vec,
+ /// Names a NATS KV bucket to also durably store this submission
+ /// into, beyond the ephemeral NATS event every submission already
+ /// gets published as - just a bucket name, not a keyword the
+ /// runtime special-cases (see `src/answers.rs`). Content-driven
+ /// rather than matching on the alternative's name/copy, which would
+ /// silently break if the wording changes later.
+ #[serde(default)]
+ pub record_as: Option,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Feature {
+ pub name: String,
+ #[serde(default)]
+ pub description: String,
+ #[serde(default)]
+ pub requirements: Vec,
+ /// Live data this feature pulls in. Read-only unless `transitions`
+ /// is non-empty, in which case listed answers get one action button
+ /// per transition (see `src/resource.rs`, `src/answers.rs`).
+ #[serde(default)]
+ pub resource: Option,
+}
+
+/// Declares a NATS KV-backed live-data read. The bucket/key here are
+/// only ever read server-side, from content the server itself loaded at
+/// startup - `get_resource` (`src/resource.rs`) takes a question id +
+/// feature name from the client, never a bucket/key directly, so a
+/// visitor can never probe an arbitrary bucket by naming it. There is
+/// no render-mode/"kind" tag here on purpose - what a resource displays
+/// as follows from its data's own shape (a plain value, or a list of
+/// `answers::Answer`s), not from a Rust-side special case per content
+/// item.
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct ResourceSpec {
+ pub bucket: String,
+ /// A single item; omit to list the whole bucket.
+ #[serde(default)]
+ pub key: Option,
+ /// Kanidm group required to read this resource.
+ #[serde(default)]
+ pub requires_group: Option,
+ /// Must be explicitly set for an anonymous-readable resource - a
+ /// spec with neither this nor `requires_group` is unreachable by
+ /// design (fail closed, not fail open). Reads only - mutations
+ /// (`transitions` below) always require `requires_group` regardless
+ /// of this flag.
+ #[serde(default)]
+ pub public: bool,
+ /// States a listed answer may move to, and the button label for
+ /// each - empty means the resource is read-only. The allow-list a
+ /// `transition_answer` call is checked against, so a client can
+ /// never move an answer to a state content didn't declare.
+ #[serde(default)]
+ pub transitions: Vec,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Transition {
+ pub to: String,
+ pub label: String,
+}
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct Requirement {
+ pub name: String,
+ #[serde(default)]
+ pub label: Option,
+ #[serde(default)]
+ pub placeholder: Option,
+ #[serde(default = "default_requirement_type", rename = "type")]
+ pub kind: String,
+ #[serde(default)]
+ pub optional: bool,
+ /// `type: file` only - accept multiple files.
+ #[serde(default)]
+ pub multiple: bool,
+ /// `type: file` only - HTML `accept` hint (UX only, not a security
+ /// boundary - the upload handler re-checks content-type itself).
+ #[serde(default)]
+ pub accept: Option,
+}
+
+fn default_requirement_type() -> String {
+ "text".to_string()
+}
+
+impl Requirement {
+ pub fn display_label(&self) -> String {
+ self.label.clone().unwrap_or_else(|| self.name.clone())
+ }
+}
+
+/// Reads every `*.yaml` file in `dir` as a `Question`, keyed by its own
+/// `id`. Runs once at startup; no hot-reload yet - restart the process
+/// (or add a watcher later) to pick up content changes.
+#[cfg(feature = "ssr")]
+pub fn load_questions(
+ dir: &std::path::Path,
+) -> anyhow::Result> {
+ let mut out = std::collections::HashMap::new();
+ for entry in std::fs::read_dir(dir)
+ .map_err(|e| anyhow::anyhow!("reading content dir {}: {e}", dir.display()))?
+ {
+ let entry = entry?;
+ let path = entry.path();
+ if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
+ continue;
+ }
+ let raw = std::fs::read_to_string(&path)?;
+ let question: Question = serde_yaml::from_str(&raw)
+ .map_err(|e| anyhow::anyhow!("parsing {}: {e}", path.display()))?;
+ out.insert(question.id.clone(), question);
+ }
+ Ok(out)
+}
diff --git a/src/events.rs b/src/events.rs
new file mode 100644
index 0000000..364d2c3
--- /dev/null
+++ b/src/events.rs
@@ -0,0 +1,30 @@
+//! Plain NATS event publishing - ported from dodrenett's lib/nats.ts
+//! (AnswerSubmitted). Deliberately just a publish, no stream/consumer
+//! bookkeeping here: whoever reads `portal.answers.submitted` today is a
+//! person watching the subject; a future LLM-driven follow-up poster
+//! reads the exact same subject and shape, so this doesn't need to
+//! change shape when that shows up, only gain a second subscriber.
+#![cfg(feature = "ssr")]
+
+use serde::{Deserialize, Serialize};
+
+#[derive(Clone, Debug, Serialize, Deserialize)]
+pub struct AnswerSubmitted {
+ pub chain_hash: String,
+ pub parent_hashes: Vec,
+ pub question_id: String,
+ pub alternative: String,
+ pub responses: serde_json::Value,
+ pub timestamp_ms: i64,
+}
+
+pub const ANSWERS_SUBJECT: &str = "portal.answers.submitted";
+
+pub async fn emit_answer_submitted(
+ nats: &async_nats::Client,
+ payload: &AnswerSubmitted,
+) -> anyhow::Result<()> {
+ let data = serde_json::to_vec(payload)?;
+ nats.publish(ANSWERS_SUBJECT, data.into()).await?;
+ Ok(())
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..d3e214f
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,20 @@
+pub mod answers;
+pub mod app;
+pub mod auth;
+pub mod chain;
+pub mod content;
+pub mod events;
+pub mod resource;
+
+#[cfg(feature = "ssr")]
+pub mod server;
+
+#[cfg(feature = "ssr")]
+pub mod upload;
+
+#[cfg(feature = "hydrate")]
+#[wasm_bindgen::prelude::wasm_bindgen]
+pub fn hydrate() {
+ console_error_panic_hook::set_once();
+ leptos::mount::hydrate_body(app::App);
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..8491bb0
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,144 @@
+#[cfg(feature = "ssr")]
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+ use axum::{body::Body, extract::State, http::Request, response::IntoResponse, routing::{any, get, post}, Router};
+ use leptos::prelude::*;
+ use leptos_axum::{generate_route_list, LeptosRoutes};
+ use portal::app::{shell, App};
+ use portal::content;
+ use portal::server::{oidc, AppState};
+ use portal::upload::{self, Garage};
+ use std::sync::Arc;
+ use tower_http::services::{ServeDir, ServeFile};
+ use tower_sessions::{MemoryStore, SessionManagerLayer};
+
+ dotenvy::dotenv().ok();
+ tracing_subscriber::fmt()
+ .with_env_filter(
+ tracing_subscriber::EnvFilter::try_from_default_env()
+ .unwrap_or_else(|_| "info,portal=debug".into()),
+ )
+ .init();
+
+ let conf = get_configuration(None)?;
+ let leptos_options = conf.leptos_options;
+ let addr = leptos_options.site_addr;
+ let routes = generate_route_list(App);
+
+ let content_dir = std::env::var("CONTENT_DIR")
+ .unwrap_or_else(|_| "../portal-content/questions".to_string());
+ let questions = content::load_questions(std::path::Path::new(&content_dir))?;
+ tracing::info!(count = questions.len(), dir = %content_dir, "loaded content");
+
+ let nats_url =
+ std::env::var("NATS_URL").unwrap_or_else(|_| "nats://127.0.0.1:4222".to_string());
+ tracing::info!(%nats_url, "connecting to NATS");
+ let parsed = url::Url::parse(&nats_url)?;
+ let mut nats_opts = async_nats::ConnectOptions::new();
+ if !parsed.username().is_empty() {
+ nats_opts = nats_opts.user_and_password(
+ parsed.username().to_string(),
+ parsed.password().unwrap_or_default().to_string(),
+ );
+ }
+ let nats = nats_opts.connect(&nats_url).await?;
+
+ // No fixed, pre-declared list of buckets to initialize here - content
+ // names whatever bucket an alternative's `record_as` should write
+ // into, and `answers::store_answer` creates it on first use.
+ let jetstream = async_nats::jetstream::new(nats.clone());
+
+ let oidc_state = Arc::new(oidc::Oidc::from_env().await?);
+
+ let garage = Garage::from_env();
+ if garage.is_none() {
+ tracing::warn!("GARAGE_* env vars not set - file upload fields will fail closed");
+ }
+
+ let state = AppState {
+ leptos_options: leptos_options.clone(),
+ nats,
+ jetstream,
+ questions: std::sync::Arc::new(questions),
+ oidc: oidc_state,
+ garage,
+ };
+
+ // Dev-friendly defaults: in-memory sessions, secure cookies only when
+ // COOKIE_SECURE=true (set it behind TLS in production) - same
+ // defaults cnats uses. SameSite=Lax (tower-sessions defaults to
+ // Strict) because the OIDC callback is, by definition, a top-level
+ // GET arriving via a redirect *from* the IdP's origin - Strict
+ // withholds the cookie on exactly that request, breaking login
+ // whenever the app and Kanidm aren't under the same registrable
+ // domain (e.g. this app on 127.0.0.1 during local dev vs. Kanidm's
+ // real domain). Lax is the standard, correct setting for this
+ // pattern, not a workaround.
+ let cookie_secure = std::env::var("COOKIE_SECURE")
+ .map(|v| v == "true" || v == "1")
+ .unwrap_or(false);
+ let session_layer = SessionManagerLayer::new(MemoryStore::default())
+ .with_secure(cookie_secure)
+ .with_same_site(tower_sessions::cookie::SameSite::Lax)
+ .with_name("portal_session");
+
+ async fn server_fn_handler(
+ State(state): State,
+ request: Request,
+ ) -> impl IntoResponse {
+ leptos_axum::handle_server_fns_with_context(
+ move || provide_context(state.clone()),
+ request,
+ )
+ .await
+ }
+
+ // The app's own Leptos route list includes a root-level wildcard
+ // (`/*any`, since a question's `id` doubles as its URL path), which
+ // would otherwise swallow every request - including these static
+ // assets - before `.fallback()` ever got a chance to serve them.
+ // Registering them explicitly here lets axum's route matching pick
+ // the more specific match over the wildcard.
+ let pkg_dir = format!(
+ "{}/{}",
+ leptos_options.site_root, leptos_options.site_pkg_dir
+ );
+ let favicon_path = format!("{}/favicon.svg", leptos_options.site_root);
+ let fonts_dir = format!("{}/fonts", leptos_options.site_root);
+
+ let app = Router::new()
+ .route("/auth/login", get(oidc::login))
+ .route("/auth/callback", get(oidc::callback))
+ .route("/auth/logout", get(oidc::logout))
+ .route("/api/{*fn_name}", any(server_fn_handler))
+ .route("/upload", post(upload::upload))
+ .nest_service("/pkg", ServeDir::new(pkg_dir))
+ .nest_service("/fonts", ServeDir::new(fonts_dir))
+ .route_service("/favicon.svg", ServeFile::new(favicon_path))
+ .leptos_routes_with_context(
+ &state,
+ routes,
+ {
+ let state = state.clone();
+ move || provide_context(state.clone())
+ },
+ {
+ let leptos_options = leptos_options.clone();
+ move || shell(leptos_options.clone())
+ },
+ )
+ .fallback(leptos_axum::file_and_error_handler::(shell))
+ .layer(session_layer)
+ .with_state(state);
+
+ tracing::info!("listening on http://{addr}");
+ let listener = tokio::net::TcpListener::bind(&addr).await?;
+ axum::serve(listener, app.into_make_service()).await?;
+ Ok(())
+}
+
+#[cfg(not(feature = "ssr"))]
+fn main() {
+ // The browser build is a cdylib; this stub only exists so `cargo check`
+ // without features still succeeds.
+}
diff --git a/src/resource.rs b/src/resource.rs
new file mode 100644
index 0000000..5ed242e
--- /dev/null
+++ b/src/resource.rs
@@ -0,0 +1,89 @@
+//! Generic, authorized reads of NATS KV data declared in content
+//! (`content::ResourceSpec`, on a `Feature`). The one property that
+//! matters: the bucket/key a resource reads is never a client-supplied
+//! parameter, only something the server resolves from its own
+//! trusted, YAML-loaded content - the client only ever names a
+//! question id + feature name, so it can't probe an arbitrary bucket
+//! by just naming it.
+
+use leptos::prelude::*;
+
+/// Fetches the live data for `question_id`'s `feature_name` feature.
+/// Fails closed: a resource with neither `public: true` nor
+/// `requires_group` set is unreachable, not "open" by omission.
+#[server]
+pub async fn get_resource(
+ question_id: String,
+ feature_name: String,
+) -> Result {
+ use crate::auth::{User, SESSION_USER_KEY};
+ use crate::server::AppState;
+
+ let state = expect_context::();
+ let question = state
+ .questions
+ .get(&question_id)
+ .ok_or_else(|| ServerFnError::new("unknown question"))?;
+ let feature = question
+ .alternatives
+ .iter()
+ .flat_map(|a| &a.features)
+ .find(|f| f.name == feature_name)
+ .ok_or_else(|| ServerFnError::new("unknown feature"))?;
+ let resource = feature
+ .resource
+ .as_ref()
+ .ok_or_else(|| ServerFnError::new("feature has no resource"))?;
+
+ if !resource.public {
+ let group = resource
+ .requires_group
+ .as_deref()
+ .ok_or_else(|| ServerFnError::new("resource is not accessible"))?;
+ let session: tower_sessions::Session = leptos_axum::extract().await?;
+ let user = session
+ .get::(SESSION_USER_KEY)
+ .await
+ .map_err(|e| ServerFnError::new(e.to_string()))?
+ .ok_or_else(|| ServerFnError::new("not signed in"))?;
+ if !user.groups.iter().any(|g| g == group) {
+ return Err(ServerFnError::new("not authorized"));
+ }
+ }
+
+ let store = state
+ .jetstream
+ .get_key_value(&resource.bucket)
+ .await
+ .map_err(|e| ServerFnError::new(format!("resource bucket unavailable: {e}")))?;
+
+ match &resource.key {
+ Some(key) => {
+ let bytes = store
+ .get(key)
+ .await
+ .map_err(|e| ServerFnError::new(e.to_string()))?
+ .ok_or_else(|| ServerFnError::new("resource key not found"))?;
+ serde_json::from_slice(&bytes).map_err(|e| ServerFnError::new(e.to_string()))
+ }
+ None => {
+ use futures::TryStreamExt;
+ let keys: Vec = store
+ .keys()
+ .await
+ .map_err(|e| ServerFnError::new(e.to_string()))?
+ .try_collect()
+ .await
+ .map_err(|e| ServerFnError::new(e.to_string()))?;
+ let mut items = Vec::new();
+ for key in keys {
+ if let Ok(Some(bytes)) = store.get(&key).await {
+ if let Ok(value) = serde_json::from_slice::(&bytes) {
+ items.push(value);
+ }
+ }
+ }
+ Ok(serde_json::Value::Array(items))
+ }
+ }
+}
diff --git a/src/server/mod.rs b/src/server/mod.rs
new file mode 100644
index 0000000..78eea50
--- /dev/null
+++ b/src/server/mod.rs
@@ -0,0 +1,30 @@
+#![cfg(feature = "ssr")]
+
+pub mod oidc;
+
+use axum::extract::FromRef;
+use leptos::prelude::LeptosOptions;
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use crate::content::Question;
+
+#[derive(Clone)]
+pub struct AppState {
+ pub leptos_options: LeptosOptions,
+ pub nats: async_nats::Client,
+ /// JetStream context - source of every NATS KV bucket this app
+ /// reads/writes (applicants, and whatever a `ResourceSpec` names).
+ pub jetstream: async_nats::jetstream::Context,
+ pub questions: Arc>,
+ pub oidc: Arc,
+ /// `None` when `GARAGE_*` env vars aren't set - uploads are the one
+ /// optional feature, everything else works without Garage.
+ pub garage: Option,
+}
+
+impl FromRef for LeptosOptions {
+ fn from_ref(state: &AppState) -> Self {
+ state.leptos_options.clone()
+ }
+}
diff --git a/src/server/oidc.rs b/src/server/oidc.rs
new file mode 100644
index 0000000..5ba4339
--- /dev/null
+++ b/src/server/oidc.rs
@@ -0,0 +1,251 @@
+//! OIDC authorization-code + PKCE flow against Kanidm. Ported from
+//! cnats' `server/oidc.rs` - same provider, same flow, unchanged.
+//!
+//! Kanidm serves per-client OIDC discovery documents at
+//! `/oauth2/openid//.well-known/openid-configuration`,
+//! and enforces PKCE, so this module always sends a S256 challenge.
+
+use axum::{
+ extract::{Query, State},
+ http::StatusCode,
+ response::Redirect,
+};
+use openidconnect::{
+ core::{CoreAuthenticationFlow, CoreClient, CoreProviderMetadata},
+ AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet, EndpointNotSet,
+ EndpointSet, IssuerUrl, Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
+ TokenResponse,
+};
+use serde::Deserialize;
+use tower_sessions::Session;
+
+use crate::auth::{User, SESSION_USER_KEY};
+
+use super::AppState;
+
+type OidcClient = CoreClient<
+ EndpointSet, // auth endpoint
+ EndpointNotSet, // device auth
+ EndpointNotSet, // introspection
+ EndpointNotSet, // revocation
+ EndpointMaybeSet, // token endpoint (from discovery)
+ EndpointMaybeSet, // userinfo endpoint (from discovery)
+>;
+
+pub struct Oidc {
+ client: OidcClient,
+ http: openidconnect::reqwest::Client,
+}
+
+const PKCE_KEY: &str = "oidc_pkce_verifier";
+const CSRF_KEY: &str = "oidc_csrf_state";
+const NONCE_KEY: &str = "oidc_nonce";
+
+impl Oidc {
+ /// Discovers the provider and builds the client from environment:
+ /// `KANIDM_URL`, `OAUTH2_CLIENT_ID`, `OAUTH2_CLIENT_SECRET`, `PUBLIC_URL`.
+ pub async fn from_env() -> anyhow::Result {
+ let kanidm_url = require_env("KANIDM_URL")?;
+ let client_id = require_env("OAUTH2_CLIENT_ID")?;
+ let client_secret = require_env("OAUTH2_CLIENT_SECRET")?;
+ let public_url = require_env("PUBLIC_URL")?;
+
+ let issuer = IssuerUrl::new(format!(
+ "{}/oauth2/openid/{}",
+ kanidm_url.trim_end_matches('/'),
+ client_id
+ ))?;
+ let redirect = RedirectUrl::new(format!(
+ "{}/auth/callback",
+ public_url.trim_end_matches('/')
+ ))?;
+
+ // Never follow redirects when talking to the IdP (SSRF hygiene).
+ let http = openidconnect::reqwest::ClientBuilder::new()
+ .redirect(openidconnect::reqwest::redirect::Policy::none())
+ .build()?;
+
+ tracing::info!(issuer = %issuer.as_str(), "discovering OIDC provider");
+ let metadata = CoreProviderMetadata::discover_async(issuer, &http).await?;
+ let client = CoreClient::from_provider_metadata(
+ metadata,
+ ClientId::new(client_id),
+ Some(ClientSecret::new(client_secret)),
+ )
+ .set_redirect_uri(redirect);
+
+ Ok(Self { client, http })
+ }
+}
+
+fn require_env(name: &str) -> anyhow::Result {
+ std::env::var(name).map_err(|_| anyhow::anyhow!("missing required env var {name}"))
+}
+
+type HandlerError = (StatusCode, String);
+
+fn internal(err: impl std::fmt::Display) -> HandlerError {
+ tracing::error!("oidc error: {err}");
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ "authentication failed; see server logs".to_string(),
+ )
+}
+
+/// GET /auth/login — stash PKCE/state/nonce in the session and bounce to Kanidm.
+pub async fn login(State(state): State, session: Session) -> Result {
+ let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
+
+ let (auth_url, csrf_state, nonce) = state
+ .oidc
+ .client
+ .authorize_url(
+ CoreAuthenticationFlow::AuthorizationCode,
+ CsrfToken::new_random,
+ Nonce::new_random,
+ )
+ .add_scope(Scope::new("openid".to_string()))
+ .add_scope(Scope::new("profile".to_string()))
+ .add_scope(Scope::new("email".to_string()))
+ .set_pkce_challenge(pkce_challenge)
+ .url();
+
+ session
+ .insert(PKCE_KEY, pkce_verifier.secret())
+ .await
+ .map_err(internal)?;
+ session
+ .insert(CSRF_KEY, csrf_state.secret())
+ .await
+ .map_err(internal)?;
+ session
+ .insert(NONCE_KEY, nonce.secret())
+ .await
+ .map_err(internal)?;
+
+ Ok(Redirect::to(auth_url.as_str()))
+}
+
+#[derive(Deserialize)]
+pub struct CallbackParams {
+ code: String,
+ state: String,
+}
+
+/// GET /auth/callback — verify state, exchange the code, verify the ID token,
+/// and store the user in the session.
+pub async fn callback(
+ State(state): State,
+ session: Session,
+ Query(params): Query,
+) -> Result {
+ let stored_csrf: Option = session.remove(CSRF_KEY).await.map_err(internal)?;
+ let pkce_verifier: Option = session.remove(PKCE_KEY).await.map_err(internal)?;
+ let nonce: Option = session.remove(NONCE_KEY).await.map_err(internal)?;
+
+ let (Some(stored_csrf), Some(pkce_verifier), Some(nonce)) =
+ (stored_csrf, pkce_verifier, nonce)
+ else {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ "no login in progress; start again at /auth/login".to_string(),
+ ));
+ };
+ if params.state != stored_csrf {
+ return Err((
+ StatusCode::BAD_REQUEST,
+ "state mismatch; start again at /auth/login".to_string(),
+ ));
+ }
+
+ let oidc = &state.oidc;
+ let token_response = oidc
+ .client
+ .exchange_code(AuthorizationCode::new(params.code))
+ .map_err(internal)?
+ .set_pkce_verifier(PkceCodeVerifier::new(pkce_verifier))
+ .request_async(&oidc.http)
+ .await
+ .map_err(internal)?;
+
+ let id_token = token_response
+ .id_token()
+ .ok_or_else(|| internal("provider returned no ID token"))?;
+ let claims = id_token
+ .claims(&oidc.client.id_token_verifier(), &Nonce::new(nonce))
+ .map_err(internal)?;
+
+ let username = claims
+ .preferred_username()
+ .map(|u| u.as_str().to_string())
+ .or_else(|| claims.email().map(|e| e.as_str().to_string()))
+ .unwrap_or_else(|| claims.subject().as_str().to_string());
+ let display_name = claims
+ .name()
+ .and_then(|n| n.get(None))
+ .map(|n| n.as_str().to_string())
+ .unwrap_or_else(|| username.clone());
+
+ // `groups` is a custom claim (Kanidm `oauth2 update-claim-map`), not
+ // something the Core* typed claims struct above knows about. The
+ // signature is already verified by `id_token.claims(...)` above, so
+ // re-reading the same payload's raw JSON for one more field is safe -
+ // just a plain field extraction, not a second verification step.
+ // IdToken's Serialize impl (not Display - it has none) produces the
+ // raw compact JWT string "header.payload.signature".
+ let groups = extract_groups_claim(&id_token);
+
+ let user = User {
+ sub: claims.subject().as_str().to_string(),
+ username,
+ display_name,
+ groups,
+ };
+
+ // Rotate the session id on privilege change, then store the user.
+ session.cycle_id().await.map_err(internal)?;
+ session
+ .insert(SESSION_USER_KEY, &user)
+ .await
+ .map_err(internal)?;
+
+ tracing::info!(user = %user.username, "signed in");
+ Ok(Redirect::to("/"))
+}
+
+/// GET /auth/logout — drop the session.
+pub async fn logout(session: Session) -> Result {
+ session.flush().await.map_err(internal)?;
+ Ok(Redirect::to("/"))
+}
+
+/// Pulls the `groups` custom claim (Kanidm `oauth2 update-claim-map`) out
+/// of an ID token's raw JWT payload. `IdToken`'s `Serialize` impl (it has
+/// no `Display`) produces the compact "header.payload.signature" string,
+/// which is where this reads from - the signature itself is never
+/// re-checked here, that already happened via `id_token.claims(...)`
+/// before this is called. Defensive by design: any parse failure (no
+/// claim, wrong shape) just yields no groups rather than failing login.
+fn extract_groups_claim(id_token: &T) -> Vec {
+ use base64::Engine;
+
+ let Ok(serde_json::Value::String(compact)) = serde_json::to_value(id_token) else {
+ return Vec::new();
+ };
+ let Some(payload_b64) = compact.split('.').nth(1) else {
+ return Vec::new();
+ };
+ let Ok(payload_bytes) = base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(payload_b64)
+ else {
+ return Vec::new();
+ };
+ let Ok(payload) = serde_json::from_slice::(&payload_bytes) else {
+ return Vec::new();
+ };
+
+ payload
+ .get("groups")
+ .and_then(|g| g.as_array())
+ .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
+ .unwrap_or_default()
+}
diff --git a/src/upload.rs b/src/upload.rs
new file mode 100644
index 0000000..05f468a
--- /dev/null
+++ b/src/upload.rs
@@ -0,0 +1,113 @@
+//! Plain multipart upload → Garage (S3-compatible), for `type: file`
+//! requirement fields. A raw Axum handler, not a Leptos server fn -
+//! those aren't built for binary bodies. The stored object key becomes
+//! that field's value in `responses_json` (`app.rs`'s file-input
+//! widget), so nothing about `submit_answer`'s generic
+//! `Map` flow needs to know a file was involved.
+#![cfg(feature = "ssr")]
+
+use aws_sdk_s3::config::{BehaviorVersion, Credentials, Region};
+use aws_sdk_s3::primitives::ByteStream;
+use axum::extract::{Multipart, State};
+use axum::http::StatusCode;
+use axum::Json;
+use serde::Serialize;
+
+use crate::server::AppState;
+
+const MAX_UPLOAD_BYTES: usize = 20 * 1024 * 1024;
+
+#[derive(Clone)]
+pub struct Garage {
+ client: aws_sdk_s3::Client,
+ bucket: String,
+}
+
+impl Garage {
+ /// Builds a client from `GARAGE_S3_ENDPOINT`/`GARAGE_ACCESS_KEY`/
+ /// `GARAGE_SECRET_KEY`/`GARAGE_UPLOADS_BUCKET` - same secret names
+ /// dodrenett's own Woodpecker pipeline already uses. `None` (not an
+ /// error) when unset, since uploads are optional: everything else
+ /// works without Garage configured.
+ pub fn from_env() -> Option {
+ let endpoint = std::env::var("GARAGE_S3_ENDPOINT")
+ .unwrap_or_else(|_| "http://garage:3900".to_string());
+ let access_key = std::env::var("GARAGE_ACCESS_KEY").ok()?;
+ let secret_key = std::env::var("GARAGE_SECRET_KEY").ok()?;
+ let bucket = std::env::var("GARAGE_UPLOADS_BUCKET")
+ .unwrap_or_else(|_| "portal-attachments".to_string());
+
+ let credentials = Credentials::new(access_key, secret_key, None, None, "portal");
+ let config = aws_sdk_s3::Config::builder()
+ .behavior_version(BehaviorVersion::latest())
+ .region(Region::new("garage"))
+ .endpoint_url(endpoint)
+ .credentials_provider(credentials)
+ // Garage expects path-style bucket addressing, not the
+ // virtual-hosted `bucket.host` style AWS defaults to.
+ .force_path_style(true)
+ .build();
+ Some(Self {
+ client: aws_sdk_s3::Client::from_conf(config),
+ bucket,
+ })
+ }
+}
+
+#[derive(Serialize)]
+pub struct UploadResponse {
+ key: String,
+}
+
+/// POST /upload - streams the first `file` field straight through to
+/// Garage and hands back its object key. Server-side hygiene beyond the
+/// HTML `accept` hint (a UX-only signal, not a boundary): a hard size
+/// cap, and the upload is rejected outright if Garage isn't configured
+/// rather than silently succeeding nowhere.
+pub async fn upload(
+ State(state): State,
+ mut multipart: Multipart,
+) -> Result, (StatusCode, String)> {
+ let garage = state.garage.as_ref().ok_or((
+ StatusCode::SERVICE_UNAVAILABLE,
+ "uploads are not configured".to_string(),
+ ))?;
+
+ while let Some(field) = multipart
+ .next_field()
+ .await
+ .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?
+ {
+ if field.name() != Some("file") {
+ continue;
+ }
+ let content_type = field
+ .content_type()
+ .unwrap_or("application/octet-stream")
+ .to_string();
+ let filename = field.file_name().unwrap_or("upload").to_string();
+ let bytes = field
+ .bytes()
+ .await
+ .map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
+ if bytes.len() > MAX_UPLOAD_BYTES {
+ return Err((StatusCode::PAYLOAD_TOO_LARGE, "file too large".to_string()));
+ }
+
+ let key = format!("{}-{filename}", uuid::Uuid::new_v4());
+ garage
+ .client
+ .put_object()
+ .bucket(&garage.bucket)
+ .key(&key)
+ .body(ByteStream::from(bytes))
+ .content_type(content_type)
+ .send()
+ .await
+ .map_err(|e| (StatusCode::BAD_GATEWAY, format!("upload failed: {e}")))?;
+
+ return Ok(Json(UploadResponse { key }));
+ }
+
+ Err((StatusCode::BAD_REQUEST, "no file field in request".to_string()))
+}
diff --git a/style/main.css b/style/main.css
new file mode 100644
index 0000000..83cd9b0
--- /dev/null
+++ b/style/main.css
@@ -0,0 +1,327 @@
+/* Same self-hosted Quicksand used for headings on Gitea/the homepage
+ dashboard elsewhere in this stack (see
+ ~/repos/infrastructure/gitea-custom-header.tmpl) - reused as-is
+ (same files, same unicode-range split) rather than pulled from a
+ font CDN. */
+@font-face {
+ font-family: "Quicksand";
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url("/fonts/quicksand-semibold-latin.woff2") format("woff2");
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+@font-face {
+ font-family: "Quicksand";
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url("/fonts/quicksand-semibold-latin-ext.woff2") format("woff2");
+ unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+@font-face {
+ font-family: "Quicksand";
+ font-style: normal;
+ font-weight: 600;
+ font-display: swap;
+ src: url("/fonts/quicksand-semibold-vietnamese.woff2") format("woff2");
+ unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB;
+}
+
+:root {
+ /* the one brand color - everything else is neutral. Tuned for
+ legibility against near-black, distinct from the YES canvas's own
+ cyan/magenta/yellow so UI chrome never fights with it. */
+ --accent: #f0923c;
+ --accent-soft: rgba(240, 146, 60, 0.16);
+
+ --paper: #0a0a0a;
+ --paper-raised: #161616;
+ --ink: #ededed;
+ --ink-dim: #9a9a9a;
+ --line: #2a2a2a;
+
+ --heading: "Quicksand", var(--sans);
+ --sans: -apple-system, "SF Pro Text", ui-sans-serif, "Segoe UI", system-ui, sans-serif;
+
+ --radius: 1.1rem;
+}
+
+* { box-sizing: border-box; }
+
+html, body {
+ margin: 0;
+ padding: 0;
+ background: var(--paper);
+ color: var(--ink);
+ font-family: var(--sans);
+ font-size: 17px;
+ line-height: 1.55;
+}
+
+h1, h2, h3 {
+ font-family: var(--heading);
+ font-weight: 600;
+ line-height: 1.2;
+ margin: 0 0 0.4em;
+}
+
+p { margin: 0 0 0.9em; }
+
+a { color: var(--accent); }
+
+::selection { background: var(--accent-soft); color: var(--ink); }
+
+main.loading, main.not-found {
+ min-height: 60vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ text-align: center;
+ color: var(--ink-dim);
+}
+
+/* hero */
+
+.hero {
+ max-width: 780px;
+ margin: 0 auto;
+ padding: 4rem 1.5rem 2.5rem;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ text-align: center;
+ gap: 1rem;
+}
+
+.wordmark {
+ font-family: var(--heading);
+ font-size: 0.85rem;
+ letter-spacing: 0.16em;
+ text-transform: uppercase;
+ color: var(--ink-dim);
+ text-decoration: none;
+}
+
+.hero-copy h1 { font-size: 2.1rem; }
+
+.hero-copy p {
+ color: var(--ink-dim);
+ font-size: 1.05rem;
+ max-width: 46ch;
+}
+
+/* the actual "YES - Rasterized Lines" piece live at uhhm.no (see
+ public/yes.js) - landing page only. Full-viewport so it's a real
+ moment, not a thumbnail; hero-copy overlays near the bottom rather
+ than interrupting the canvas. */
+
+.hero-yes {
+ position: relative;
+ max-width: none;
+ width: 100%;
+ height: 100svh;
+ padding: 0;
+ justify-content: flex-end;
+ overflow: hidden;
+}
+
+.hero-canvas {
+ position: absolute;
+ inset: 0;
+}
+
+.hero-canvas canvas {
+ position: absolute;
+ top: 0;
+ left: 0;
+ cursor: pointer;
+}
+
+.hero-canvas #rasterCanvas {
+ mix-blend-mode: overlay;
+ opacity: 0.5;
+}
+
+.hero-yes .hero-copy {
+ position: relative;
+ z-index: 1;
+ padding: 0 1.5rem 3.5rem;
+ gap: 0.6rem;
+ text-shadow: 0 2px 24px rgba(0, 0, 0, 0.8);
+}
+
+/* alternatives */
+
+.alternatives {
+ max-width: 780px;
+ margin: 0 auto 5rem;
+ padding: 0 1.5rem;
+ display: grid;
+ gap: 1.25rem;
+}
+
+.alt-card {
+ background: var(--paper-raised);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 1.75rem 1.9rem;
+}
+
+.alt-card h2 { font-size: 1.3rem; }
+
+.alt-description { color: var(--ink-dim); }
+
+.alt-encouragement {
+ font-size: 0.9rem;
+ color: var(--accent);
+ font-style: italic;
+}
+
+.features { display: grid; gap: 1.1rem; margin-top: 0.6rem; }
+
+.feature h3 { font-size: 1rem; margin-bottom: 0.15em; }
+.feature p { color: var(--ink-dim); font-size: 0.95rem; }
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+ margin-top: 0.6rem;
+}
+
+.field-label {
+ font-size: 0.85rem;
+ font-weight: 600;
+ color: var(--ink-dim);
+}
+
+.required { color: var(--accent); margin-left: 0.2em; }
+
+input, textarea {
+ font: inherit;
+ color: var(--ink);
+ background: var(--paper);
+ border: 1px solid var(--line);
+ border-radius: 0.6rem;
+ padding: 0.65rem 0.8rem;
+ width: 100%;
+}
+
+textarea { min-height: 5rem; resize: vertical; }
+
+input:focus, textarea:focus {
+ outline: none;
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px var(--accent-soft);
+}
+
+.alt-submit {
+ margin-top: 1.4rem;
+ font-family: var(--sans);
+ font-weight: 600;
+ font-size: 0.95rem;
+ color: #0a0a0a;
+ background: var(--accent);
+ border: none;
+ border-radius: 0.7rem;
+ padding: 0.75rem 1.4rem;
+ cursor: pointer;
+ transition: filter 120ms, transform 120ms;
+}
+
+.alt-submit:hover { filter: brightness(1.08); }
+.alt-submit:active { transform: translateY(1px); }
+.alt-submit:disabled { filter: grayscale(0.5) brightness(0.85); cursor: wait; }
+
+.gate-card { text-align: center; }
+
+/* resources */
+
+.resource { margin-top: 0.6rem; }
+
+.resource-loading, .resource-empty {
+ color: var(--ink-dim);
+ font-size: 0.9rem;
+}
+
+.resource-error { color: #ff6b6b; font-size: 0.9rem; }
+
+.resource-raw {
+ background: var(--paper);
+ border: 1px solid var(--line);
+ border-radius: 0.6rem;
+ padding: 0.8rem;
+ font-size: 0.82rem;
+ overflow-x: auto;
+ white-space: pre-wrap;
+}
+
+.answer-list { display: grid; gap: 0.8rem; }
+
+.answer-row {
+ border: 1px solid var(--line);
+ border-radius: 0.8rem;
+ padding: 1rem 1.1rem;
+ display: grid;
+ gap: 0.6rem;
+}
+
+.answer-row.decided { opacity: 0.55; }
+
+.answer-fields {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
+ gap: 0.5rem 1rem;
+}
+
+.answer-field { display: flex; flex-direction: column; gap: 0.1rem; }
+
+.answer-field-key {
+ font-size: 0.72rem;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--ink-dim);
+}
+
+.answer-field-value { font-size: 0.92rem; word-break: break-word; }
+
+.answer-meta {
+ display: flex;
+ gap: 0.8rem;
+ align-items: center;
+ font-size: 0.78rem;
+ color: var(--ink-dim);
+}
+
+.answer-state {
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 600;
+ color: var(--accent);
+}
+
+.answer-actions { display: flex; gap: 0.6rem; }
+
+.answer-action {
+ font-family: var(--sans);
+ font-weight: 600;
+ font-size: 0.82rem;
+ color: var(--ink);
+ background: var(--paper);
+ border: 1px solid var(--line);
+ border-radius: 0.5rem;
+ padding: 0.4rem 0.8rem;
+ cursor: pointer;
+ transition: border-color 120ms, color 120ms;
+}
+
+.answer-action:hover { border-color: var(--accent); color: var(--accent); }
+.answer-action:disabled { opacity: 0.5; cursor: wait; }
+
+@media (max-width: 640px) {
+ .hero { padding: 3rem 1.25rem 2rem; }
+ .alt-card { padding: 1.4rem 1.3rem; }
+}
diff --git a/yes.js b/yes.js
new file mode 100644
index 0000000..4a02419
--- /dev/null
+++ b/yes.js
@@ -0,0 +1,438 @@
+// Ported from ~/repos/webpage/content/visualize/ah.html ("YES - Rasterized
+// Lines"), the piece currently live at uhhm.no - kept as the actual asset,
+// not reinvented. Two changes from the original: exported as a class (no
+// auto-init on `window load`, since Leptos controls when this mounts) and
+// a `stop()` method that actually breaks the requestAnimationFrame loop -
+// the original ran forever once started, fine for a static page that's
+// the whole document, not fine in an SPA where this hero mounts/unmounts
+// as you navigate.
+
+export class RasterizedYES {
+ constructor() {
+ this.rasterCanvas = document.getElementById('rasterCanvas');
+ this.lineCanvas = document.getElementById('lineCanvas');
+ this.rasterCtx = this.rasterCanvas.getContext('2d');
+ this.lineCtx = this.lineCanvas.getContext('2d');
+
+ this.lines = [];
+ this.rasterData = null;
+ this.isActive = true;
+ this.destroyed = false;
+ this.time = 0;
+
+ this.mouseX = 0.5;
+ this.mouseY = 0.5;
+ this.containmentStrength = 0.5;
+ this.wiggleAmount = 0.5;
+
+ this.setupCanvas();
+ this.setupMouseTracking();
+ this.setupClickHandler();
+ this.rasterizeText();
+ this.initializeLines();
+ this.animate();
+ }
+
+ stop() {
+ this.destroyed = true;
+ if (this._resizeHandler) {
+ window.removeEventListener('resize', this._resizeHandler);
+ }
+ }
+
+ setupCanvas() {
+ const pixelRatio = window.devicePixelRatio || 1;
+ const width = window.innerWidth;
+ const height = window.innerHeight;
+
+ this.rasterCanvas.style.width = width + 'px';
+ this.rasterCanvas.style.height = height + 'px';
+ this.lineCanvas.style.width = width + 'px';
+ this.lineCanvas.style.height = height + 'px';
+
+ this.rasterCanvas.width = width * pixelRatio;
+ this.rasterCanvas.height = height * pixelRatio;
+ this.lineCanvas.width = width * pixelRatio;
+ this.lineCanvas.height = height * pixelRatio;
+
+ this.rasterCtx.scale(pixelRatio, pixelRatio);
+ this.lineCtx.scale(pixelRatio, pixelRatio);
+
+ this.displayWidth = width;
+ this.displayHeight = height;
+ this.pixelRatio = pixelRatio;
+
+ if (!this._resizeHandler) {
+ this._resizeHandler = () => {
+ this.setupCanvas();
+ this.rasterizeText();
+ };
+ window.addEventListener('resize', this._resizeHandler);
+ }
+ }
+
+ setupMouseTracking() {
+ const updatePosition = (clientX, clientY) => {
+ this.mouseX = clientX / window.innerWidth;
+ this.mouseY = clientY / window.innerHeight;
+ this.containmentStrength = 0.1 + (this.mouseX * 0.9);
+ this.wiggleAmount = 0.1 + (this.mouseY * 1.9);
+ };
+
+ window.addEventListener('mousemove', (e) => {
+ updatePosition(e.clientX, e.clientY);
+ });
+
+ // Scoped to the canvas itself, not `window` - the original
+ // standalone page was the whole document, so preventDefault()ing
+ // touchmove globally was harmless (nothing else to scroll to).
+ // Embedded as a hero above a longer page, that same global
+ // handler silently blocks scrolling everywhere, not just over
+ // the canvas - this only intercepts touches that start there.
+ this.lineCanvas.addEventListener('touchmove', (e) => {
+ e.preventDefault();
+ if (e.touches.length > 0) {
+ const touch = e.touches[0];
+ updatePosition(touch.clientX, touch.clientY);
+ }
+ }, { passive: false });
+
+ this.lineCanvas.addEventListener('touchstart', (e) => {
+ e.preventDefault();
+ if (e.touches.length > 0) {
+ const touch = e.touches[0];
+ updatePosition(touch.clientX, touch.clientY);
+ }
+ }, { passive: false });
+
+ this.mouseX = 0.5;
+ this.mouseY = 0.5;
+ this.containmentStrength = 0.55;
+ this.wiggleAmount = 1.05;
+ }
+
+ setupClickHandler() {
+ this.lineCanvas.addEventListener('click', () => {
+ this.restartAnimation();
+ });
+ }
+
+ restartAnimation() {
+ this.time = 0;
+ this.lineCtx.fillStyle = '#0a0a0a';
+ this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
+ this.initializeLines();
+ this.isActive = true;
+ }
+
+ rasterizeText() {
+ const width = this.displayWidth;
+ const height = this.displayHeight;
+
+ const aspectRatio = width / height;
+ let fontSize;
+ if (aspectRatio < 1) {
+ fontSize = width * 0.4;
+ } else {
+ fontSize = height * 0.5;
+ }
+
+ this.fontSize = fontSize;
+
+ this.rasterCtx.font = `bold ${fontSize}px Arial, sans-serif`;
+ this.rasterCtx.textAlign = 'left';
+ this.rasterCtx.textBaseline = 'middle';
+
+ const fullTextMetrics = this.rasterCtx.measureText('YES');
+ const textWidth = fullTextMetrics.width;
+ const textStartX = (width - textWidth) / 2;
+ const textY = height / 2;
+
+ const letters = ['Y', 'E', 'S'];
+ this.letterPositions = [];
+ let currentX = textStartX;
+
+ for (let i = 0; i < letters.length; i++) {
+ const letterMetrics = this.rasterCtx.measureText(letters[i]);
+ this.letterPositions[i] = {
+ x: currentX,
+ y: textY,
+ width: letterMetrics.width,
+ centerX: currentX + letterMetrics.width / 2
+ };
+ currentX += letterMetrics.width;
+ }
+
+ this.letterRasters = [];
+
+ for (let i = 0; i < 3; i++) {
+ this.rasterCtx.fillStyle = '#111';
+ this.rasterCtx.fillRect(0, 0, width, height);
+
+ this.rasterCtx.fillStyle = '#ffffff';
+ this.rasterCtx.fillText(letters[i], this.letterPositions[i].x, this.letterPositions[i].y);
+
+ this.letterRasters[i] = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height);
+ }
+
+ this.rasterCtx.fillStyle = '#111';
+ this.rasterCtx.fillRect(0, 0, width, height);
+ this.rasterCtx.fillStyle = '#ffffff';
+ this.rasterCtx.fillText('YES', textStartX, textY);
+
+ this.rasterData = this.rasterCtx.getImageData(0, 0, this.rasterCanvas.width, this.rasterCanvas.height);
+ }
+
+ isInSpecificLetter(x, y, letterIndex) {
+ const canvasX = x * this.pixelRatio;
+ const canvasY = y * this.pixelRatio;
+
+ if (!this.letterRasters || !this.letterRasters[letterIndex] ||
+ canvasX < 0 || canvasY < 0 ||
+ canvasX >= this.rasterCanvas.width || canvasY >= this.rasterCanvas.height) {
+ return false;
+ }
+
+ const index = (Math.floor(canvasY) * this.rasterCanvas.width + Math.floor(canvasX)) * 4;
+ const red = this.letterRasters[letterIndex].data[index];
+ return red > 128;
+ }
+
+ initializeLines() {
+ this.lines = [];
+ const numLines = 60;
+
+ const letterCentroids = this.calculateLetterCentroids();
+
+ for (let i = 0; i < numLines; i++) {
+ const letterIndex = Math.floor(i / (numLines / 3));
+ let startX, startY, centerX, centerY;
+
+ if (letterCentroids[letterIndex]) {
+ centerX = letterCentroids[letterIndex].x;
+ centerY = letterCentroids[letterIndex].y;
+
+ const startVariation = this.fontSize * 0.1;
+ startX = centerX + (Math.random() - 0.5) * startVariation;
+ startY = centerY + (Math.random() - 0.5) * startVariation;
+
+ if (!this.isInSpecificLetter(startX, startY, letterIndex)) {
+ const nearestPoint = this.findNearestSpecificLetterPixel(startX, startY, letterIndex);
+ if (nearestPoint) {
+ startX = nearestPoint.x;
+ startY = nearestPoint.y;
+ } else {
+ startX = centerX;
+ startY = centerY;
+ }
+ }
+ } else {
+ const letterPos = this.letterPositions[letterIndex];
+ startX = letterPos.centerX;
+ startY = letterPos.y;
+ centerX = startX;
+ centerY = startY;
+ }
+
+ const colors = [
+ 'hsl(180, 90%, 70%)',
+ 'hsl(300, 90%, 70%)',
+ 'hsl(60, 90%, 70%)'
+ ];
+
+ this.lines.push({
+ relativeX: (startX - centerX) / this.fontSize,
+ relativeY: (startY - centerY) / this.fontSize,
+ prevRelativeX: (startX - centerX) / this.fontSize,
+ prevRelativeY: (startY - centerY) / this.fontSize,
+ angle: Math.random() * Math.PI * 2,
+ color: colors[letterIndex],
+ letterIndex: letterIndex,
+ lastSeenInside: { x: (startX - centerX) / this.fontSize, y: (startY - centerY) / this.fontSize },
+ outsideDuration: 0
+ });
+ }
+ }
+
+ calculateLetterCentroids() {
+ const centroids = [];
+
+ for (let letterIndex = 0; letterIndex < 3; letterIndex++) {
+ let sumX = 0, sumY = 0, count = 0;
+
+ const letterPos = this.letterPositions[letterIndex];
+ const searchStartX = Math.max(0, letterPos.x - this.fontSize * 0.1);
+ const searchEndX = Math.min(this.displayWidth, letterPos.x + letterPos.width + this.fontSize * 0.1);
+ const searchStartY = Math.max(0, letterPos.y - this.fontSize * 0.6);
+ const searchEndY = Math.min(this.displayHeight, letterPos.y + this.fontSize * 0.6);
+
+ const step = Math.max(1, Math.floor(this.fontSize * 0.02));
+ for (let y = searchStartY; y <= searchEndY; y += step) {
+ for (let x = searchStartX; x <= searchEndX; x += step) {
+ if (this.isInSpecificLetter(x, y, letterIndex)) {
+ sumX += x;
+ sumY += y;
+ count++;
+ }
+ }
+ }
+
+ if (count > 0) {
+ centroids[letterIndex] = {
+ x: sumX / count,
+ y: sumY / count
+ };
+ } else {
+ centroids[letterIndex] = {
+ x: letterPos.centerX,
+ y: letterPos.y
+ };
+ }
+ }
+
+ return centroids;
+ }
+
+ updateLines() {
+ this.time += 0.016;
+ if (!this.isActive) return;
+
+ const letterCentroids = this.calculateLetterCentroids();
+
+ this.lines.forEach(line => {
+ line.prevRelativeX = line.relativeX;
+ line.prevRelativeY = line.relativeY;
+
+ const centroid = letterCentroids[line.letterIndex];
+ if (!centroid) return;
+
+ const currentX = centroid.x + line.relativeX * this.fontSize;
+ const currentY = centroid.y + line.relativeY * this.fontSize;
+
+ const currentlyInside = this.isInSpecificLetter(currentX, currentY, line.letterIndex);
+
+ if (currentlyInside) {
+ line.outsideDuration = 0;
+ line.lastSeenInside = { x: line.relativeX, y: line.relativeY };
+ } else {
+ line.outsideDuration++;
+ }
+
+ const visionDistance = 0.08 * this.fontSize;
+ const centerX = currentX + Math.cos(line.angle) * visionDistance;
+ const centerY = currentY + Math.sin(line.angle) * visionDistance;
+ const leftX = currentX + Math.cos(line.angle - 0.4) * visionDistance;
+ const leftY = currentY + Math.sin(line.angle - 0.4) * visionDistance;
+ const rightX = currentX + Math.cos(line.angle + 0.4) * visionDistance;
+ const rightY = currentY + Math.sin(line.angle + 0.4) * visionDistance;
+
+ const centerSees = this.isInSpecificLetter(centerX, centerY, line.letterIndex);
+ const leftSees = this.isInSpecificLetter(leftX, leftY, line.letterIndex);
+ const rightSees = this.isInSpecificLetter(rightX, rightY, line.letterIndex);
+
+ let speed = 0.02;
+
+ const attractionThreshold = Math.floor(15 + (1 - this.containmentStrength) * 45);
+
+ if (line.outsideDuration > attractionThreshold) {
+ const targetX = line.lastSeenInside.x;
+ const targetY = line.lastSeenInside.y;
+ const deltaX = targetX - line.relativeX;
+ const deltaY = targetY - line.relativeY;
+ const angleToTarget = Math.atan2(deltaY, deltaX);
+
+ let angleDiff = angleToTarget - line.angle;
+ while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
+ while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
+
+ const baseAttraction = Math.min(0.4, line.outsideDuration / 80);
+ const attractionStrength = baseAttraction * this.containmentStrength;
+ line.angle += angleDiff * attractionStrength;
+ }
+
+ if (centerSees) {
+ const baseWiggle = 0.15;
+ line.angle += (Math.random() - 0.5) * baseWiggle * this.wiggleAmount;
+ } else {
+ speed *= (0.3 + this.containmentStrength * 0.4);
+
+ const baseTurnStrength = 0.3 + (this.containmentStrength * 0.4);
+ const randomTurnAmount = 0.2 * this.wiggleAmount;
+
+ if (leftSees && !rightSees) {
+ line.angle -= baseTurnStrength + Math.random() * randomTurnAmount;
+ } else if (rightSees && !leftSees) {
+ line.angle += baseTurnStrength + Math.random() * randomTurnAmount;
+ } else {
+ const randomTurn = (Math.random() - 0.5) * (0.8 + this.wiggleAmount * 0.7);
+ line.angle += randomTurn;
+ }
+ }
+
+ line.relativeX += Math.cos(line.angle) * speed;
+ line.relativeY += Math.sin(line.angle) * speed;
+
+ line.relativeX = Math.max(-1.7, Math.min(1.7, line.relativeX));
+ line.relativeY = Math.max(-1.7, Math.min(1.7, line.relativeY));
+ });
+ }
+
+ findNearestSpecificLetterPixel(x, y, letterIndex) {
+ const searchRadius = this.fontSize * 0.08;
+ const step = Math.max(1, Math.floor(this.fontSize * 0.01));
+ let nearestPoint = null;
+ let nearestDistance = Infinity;
+
+ for (let dy = -searchRadius; dy <= searchRadius; dy += step) {
+ for (let dx = -searchRadius; dx <= searchRadius; dx += step) {
+ const testX = x + dx;
+ const testY = y + dy;
+
+ if (this.isInSpecificLetter(testX, testY, letterIndex)) {
+ const distance = Math.sqrt(dx * dx + dy * dy);
+ if (distance < nearestDistance) {
+ nearestDistance = distance;
+ nearestPoint = { x: testX, y: testY };
+ }
+ }
+ }
+ }
+
+ return nearestPoint;
+ }
+
+ draw() {
+ this.lineCtx.fillStyle = 'rgba(10, 10, 10, 0.03)';
+ this.lineCtx.fillRect(0, 0, this.displayWidth, this.displayHeight);
+
+ const letterCentroids = this.calculateLetterCentroids();
+
+ this.lines.forEach(line => {
+ const centroid = letterCentroids[line.letterIndex];
+ if (!centroid) return;
+
+ const currentX = centroid.x + line.relativeX * this.fontSize;
+ const currentY = centroid.y + line.relativeY * this.fontSize;
+ const prevX = centroid.x + line.prevRelativeX * this.fontSize;
+ const prevY = centroid.y + line.prevRelativeY * this.fontSize;
+
+ if (prevX === currentX && prevY === currentY) return;
+
+ this.lineCtx.strokeStyle = line.color.replace('70%)', '80%, 0.8)');
+ this.lineCtx.lineWidth = this.fontSize * 0.003;
+ this.lineCtx.lineCap = 'round';
+
+ this.lineCtx.beginPath();
+ this.lineCtx.moveTo(prevX, prevY);
+ this.lineCtx.lineTo(currentX, currentY);
+ this.lineCtx.stroke();
+ });
+ }
+
+ animate() {
+ if (this.destroyed) return;
+ this.updateLines();
+ this.draw();
+ requestAnimationFrame(() => this.animate());
+ }
+}