🔥 Okibi

yuki / okibi

焚き火の仲間だけの自前Gitホスティング(このサイト自身)

git clone https://git.takibi.wtf/yuki/okibi.git

← コミット一覧

10650e1cb6335f476111d88e6e3b6f73ad047713
yuki
1781032995
Okibi v0.1 — 焚き火(atsm)会員だけの自前 Git ホスティング

---
 .github/workflows/deploy.yml |   15 +
 .gitignore                   |    6 +
 Cargo.lock                   | 2199 ++++++++++++++++++++++++++++++++++++++++++
 Cargo.toml                   |   26 +
 Dockerfile                   |   19 +
 README.md                    |   51 +
 fly.toml                     |   36 +
 scripts/e2e.sh               |   70 ++
 src/auth.rs                  |  286 ++++++
 src/ci.rs                    |  150 +++
 src/config.rs                |   62 ++
 src/db.rs                    |  105 ++
 src/email.rs                 |   38 +
 src/git_http.rs              |  294 ++++++
 src/main.rs                  |   68 ++
 src/repos.rs                 |  191 ++++
 src/style.css                |   64 ++
 src/util.rs                  |   59 ++
 src/web.rs                   |  583 +++++++++++
 tasks/todo.md                |   36 +
 20 files changed, 4358 insertions(+)

diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
new file mode 100644
index 0000000..dd2f223
--- /dev/null
+++ b/.github/workflows/deploy.yml
@@ -0,0 +1,15 @@
+name: deploy
+on:
+  push:
+    branches: [main]
+
+jobs:
+  deploy:
+    runs-on: ubuntu-latest
+    concurrency: deploy-okibi
+    steps:
+      - uses: actions/checkout@v4
+      - uses: superfly/flyctl-actions/setup-flyctl@master
+      - run: flyctl deploy --remote-only
+        env:
+          FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2f2d4bb
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+/target
+/data
+*.db
+*.db-*
+/tmp
+.DS_Store
diff --git a/Cargo.lock b/Cargo.lock
new file mode 100644
index 0000000..294cd4b
--- /dev/null
+++ b/Cargo.lock
@@ -0,0 +1,2199 @@
+# 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 = "anyhow"
+version = "1.0.102"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
+
+[[package]]
+name = "atoi"
+version = "2.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "atomic-waker"
+version = "1.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
+
+[[package]]
+name = "autocfg"
+version = "1.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
+
+[[package]]
+name = "axum"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
+dependencies = [
+ "axum-core",
+ "axum-macros",
+ "bytes",
+ "form_urlencoded",
+ "futures-util",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-util",
+ "itoa",
+ "matchit",
+ "memchr",
+ "mime",
+ "percent-encoding",
+ "pin-project-lite",
+ "serde_core",
+ "serde_json",
+ "serde_path_to_error",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "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",
+ "http-body",
+ "http-body-util",
+ "mime",
+ "pin-project-lite",
+ "sync_wrapper",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+]
+
+[[package]]
+name = "axum-macros"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7aa268c23bfbbd2c4363b9cd302a4f504fb2a9dfe7e3451d66f35dd392e20aca"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "base64"
+version = "0.22.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
+
+[[package]]
+name = "bitflags"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
+
+[[package]]
+name = "block-buffer"
+version = "0.10.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
+dependencies = [
+ "generic-array",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.20.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
+
+[[package]]
+name = "bytes"
+version = "1.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
+
+[[package]]
+name = "cc"
+version = "1.2.63"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
+dependencies = [
+ "find-msvc-tools",
+ "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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "concurrent-queue"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "cpufeatures"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "crc"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d"
+dependencies = [
+ "crc-catalog",
+]
+
+[[package]]
+name = "crc-catalog"
+version = "2.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853"
+
+[[package]]
+name = "crossbeam-queue"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[package]]
+name = "crypto-common"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
+dependencies = [
+ "generic-array",
+ "typenum",
+]
+
+[[package]]
+name = "digest"
+version = "0.10.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
+dependencies = [
+ "block-buffer",
+ "crypto-common",
+]
+
+[[package]]
+name = "displaydoc"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "dotenvy"
+version = "0.15.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
+
+[[package]]
+name = "either"
+version = "1.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
+dependencies = [
+ "serde",
+]
+
+[[package]]
+name = "equivalent"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+
+[[package]]
+name = "errno"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "event-listener"
+version = "5.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
+dependencies = [
+ "concurrent-queue",
+ "parking",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
+
+[[package]]
+name = "flume"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+ "spin",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[package]]
+name = "form_urlencoded"
+version = "1.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
+dependencies = [
+ "percent-encoding",
+]
+
+[[package]]
+name = "futures-channel"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
+dependencies = [
+ "futures-core",
+ "futures-sink",
+]
+
+[[package]]
+name = "futures-core"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
+
+[[package]]
+name = "futures-executor"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "futures-util",
+]
+
+[[package]]
+name = "futures-intrusive"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f"
+dependencies = [
+ "futures-core",
+ "lock_api",
+ "parking_lot",
+]
+
+[[package]]
+name = "futures-io"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718"
+
+[[package]]
+name = "futures-sink"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893"
+
+[[package]]
+name = "futures-task"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393"
+
+[[package]]
+name = "futures-util"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6"
+dependencies = [
+ "futures-core",
+ "futures-io",
+ "futures-sink",
+ "futures-task",
+ "memchr",
+ "pin-project-lite",
+ "slab",
+]
+
+[[package]]
+name = "generic-array"
+version = "0.14.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
+dependencies = [
+ "typenum",
+ "version_check",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "wasi",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "getrandom"
+version = "0.3.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
+dependencies = [
+ "cfg-if",
+ "js-sys",
+ "libc",
+ "r-efi",
+ "wasip2",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.17.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+
+[[package]]
+name = "hashlink"
+version = "0.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+dependencies = [
+ "hashbrown 0.15.5",
+]
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[package]]
+name = "hex"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
+
+[[package]]
+name = "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 = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
+dependencies = [
+ "bytes",
+ "http",
+]
+
+[[package]]
+name = "http-body-util"
+version = "0.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "pin-project-lite",
+]
+
+[[package]]
+name = "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 = "hyper"
+version = "1.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
+dependencies = [
+ "atomic-waker",
+ "bytes",
+ "futures-channel",
+ "futures-core",
+ "http",
+ "http-body",
+ "httparse",
+ "httpdate",
+ "itoa",
+ "pin-project-lite",
+ "smallvec",
+ "tokio",
+ "want",
+]
+
+[[package]]
+name = "hyper-rustls"
+version = "0.27.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
+dependencies = [
+ "http",
+ "hyper",
+ "hyper-util",
+ "rustls",
+ "tokio",
+ "tokio-rustls",
+ "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",
+ "bytes",
+ "futures-channel",
+ "futures-util",
+ "http",
+ "http-body",
+ "hyper",
+ "ipnet",
+ "libc",
+ "percent-encoding",
+ "pin-project-lite",
+ "socket2",
+ "tokio",
+ "tower-service",
+ "tracing",
+]
+
+[[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 = "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 = "2.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.17.1",
+]
+
+[[package]]
+name = "ipnet"
+version = "2.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2"
+
+[[package]]
+name = "itoa"
+version = "1.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
+
+[[package]]
+name = "js-sys"
+version = "0.3.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2025f20d7a4fa7785846e7b63d10a76d3f1cee98ee5cb79ea59703f95e42162"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.186"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
+
+[[package]]
+name = "libsqlite3-sys"
+version = "0.30.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149"
+dependencies = [
+ "cc",
+ "pkg-config",
+ "vcpkg",
+]
+
+[[package]]
+name = "litemap"
+version = "0.8.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",
+]
+
+[[package]]
+name = "log"
+version = "0.4.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
+
+[[package]]
+name = "lru-slab"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
+
+[[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 = "maud"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8156733e27020ea5c684db5beac5d1d611e1272ab17901a49466294b84fc217e"
+dependencies = [
+ "itoa",
+ "maud_macros",
+]
+
+[[package]]
+name = "maud_macros"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7261b00f3952f617899bc012e3dbd56e4f0110a038175929fa5d18e5a19913ca"
+dependencies = [
+ "proc-macro2",
+ "proc-macro2-diagnostics",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "memchr"
+version = "2.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
+
+[[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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
+dependencies = [
+ "libc",
+ "wasi",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "nu-ansi-term"
+version = "0.50.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
+dependencies = [
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "okibi"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "axum",
+ "bytes",
+ "hex",
+ "maud",
+ "rand 0.8.6",
+ "reqwest",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx",
+ "tokio",
+ "tower",
+ "tower-http",
+ "tracing",
+ "tracing-subscriber",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
+
+[[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 = "percent-encoding"
+version = "2.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
+
+[[package]]
+name = "potential_utf"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564"
+dependencies = [
+ "zerovec",
+]
+
+[[package]]
+name = "ppv-lite86"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
+dependencies = [
+ "zerocopy",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.106"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "proc-macro2-diagnostics"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+ "version_check",
+]
+
+[[package]]
+name = "quinn"
+version = "0.11.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
+dependencies = [
+ "bytes",
+ "cfg_aliases",
+ "pin-project-lite",
+ "quinn-proto",
+ "quinn-udp",
+ "rustc-hash",
+ "rustls",
+ "socket2",
+ "thiserror",
+ "tokio",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-proto"
+version = "0.11.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
+dependencies = [
+ "bytes",
+ "getrandom 0.3.4",
+ "lru-slab",
+ "rand 0.9.4",
+ "ring",
+ "rustc-hash",
+ "rustls",
+ "rustls-pki-types",
+ "slab",
+ "thiserror",
+ "tinyvec",
+ "tracing",
+ "web-time",
+]
+
+[[package]]
+name = "quinn-udp"
+version = "0.5.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
+dependencies = [
+ "cfg_aliases",
+ "libc",
+ "once_cell",
+ "socket2",
+ "tracing",
+ "windows-sys 0.60.2",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.45"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924"
+dependencies = [
+ "proc-macro2",
+]
+
+[[package]]
+name = "r-efi"
+version = "5.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
+
+[[package]]
+name = "rand"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
+dependencies = [
+ "libc",
+ "rand_chacha 0.3.1",
+ "rand_core 0.6.4",
+]
+
+[[package]]
+name = "rand"
+version = "0.9.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
+dependencies = [
+ "rand_chacha 0.9.0",
+ "rand_core 0.9.5",
+]
+
+[[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 = "redox_syscall"
+version = "0.5.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
+dependencies = [
+ "bitflags",
+]
+
+[[package]]
+name = "regex-automata"
+version = "0.4.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
+dependencies = [
+ "aho-corasick",
+ "memchr",
+ "regex-syntax",
+]
+
+[[package]]
+name = "regex-syntax"
+version = "0.8.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",
+ "bytes",
+ "futures-core",
+ "http",
+ "http-body",
+ "http-body-util",
+ "hyper",
+ "hyper-rustls",
+ "hyper-util",
+ "js-sys",
+ "log",
+ "percent-encoding",
+ "pin-project-lite",
+ "quinn",
+ "rustls",
+ "rustls-pki-types",
+ "serde",
+ "serde_json",
+ "serde_urlencoded",
+ "sync_wrapper",
+ "tokio",
+ "tokio-rustls",
+ "tower",
+ "tower-http",
+ "tower-service",
+ "url",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+ "webpki-roots",
+]
+
+[[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 = "rustc-hash"
+version = "2.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe"
+
+[[package]]
+name = "rustls"
+version = "0.23.40"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
+dependencies = [
+ "once_cell",
+ "ring",
+ "rustls-pki-types",
+ "rustls-webpki",
+ "subtle",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-pki-types"
+version = "1.14.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
+dependencies = [
+ "web-time",
+ "zeroize",
+]
+
+[[package]]
+name = "rustls-webpki"
+version = "0.103.13"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
+dependencies = [
+ "ring",
+ "rustls-pki-types",
+ "untrusted",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[package]]
+name = "ryu"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
+
+[[package]]
+name = "scopeguard"
+version = "1.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
+
+[[package]]
+name = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_core"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
+dependencies = [
+ "serde_derive",
+]
+
+[[package]]
+name = "serde_derive"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.150"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "serde_path_to_error"
+version = "0.1.20"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
+dependencies = [
+ "itoa",
+ "serde",
+ "serde_core",
+]
+
+[[package]]
+name = "serde_urlencoded"
+version = "0.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
+dependencies = [
+ "form_urlencoded",
+ "itoa",
+ "ryu",
+ "serde",
+]
+
+[[package]]
+name = "sha2"
+version = "0.10.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
+dependencies = [
+ "cfg-if",
+ "cpufeatures",
+ "digest",
+]
+
+[[package]]
+name = "sharded-slab"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
+dependencies = [
+ "lazy_static",
+]
+
+[[package]]
+name = "shlex"
+version = "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 = "slab"
+version = "0.4.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "socket2"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
+dependencies = [
+ "libc",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "spin"
+version = "0.9.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
+dependencies = [
+ "lock_api",
+]
+
+[[package]]
+name = "sqlx"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc"
+dependencies = [
+ "sqlx-core",
+ "sqlx-macros",
+ "sqlx-sqlite",
+]
+
+[[package]]
+name = "sqlx-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6"
+dependencies = [
+ "base64",
+ "bytes",
+ "crc",
+ "crossbeam-queue",
+ "either",
+ "event-listener",
+ "futures-core",
+ "futures-intrusive",
+ "futures-io",
+ "futures-util",
+ "hashbrown 0.15.5",
+ "hashlink",
+ "indexmap",
+ "log",
+ "memchr",
+ "once_cell",
+ "percent-encoding",
+ "serde",
+ "sha2",
+ "smallvec",
+ "thiserror",
+ "tokio",
+ "tokio-stream",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "sqlx-macros"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "sqlx-core",
+ "sqlx-macros-core",
+ "syn",
+]
+
+[[package]]
+name = "sqlx-macros-core"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b"
+dependencies = [
+ "dotenvy",
+ "either",
+ "heck",
+ "hex",
+ "once_cell",
+ "proc-macro2",
+ "quote",
+ "serde",
+ "serde_json",
+ "sha2",
+ "sqlx-core",
+ "sqlx-sqlite",
+ "syn",
+ "tokio",
+ "url",
+]
+
+[[package]]
+name = "sqlx-sqlite"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea"
+dependencies = [
+ "atoi",
+ "flume",
+ "futures-channel",
+ "futures-core",
+ "futures-executor",
+ "futures-intrusive",
+ "futures-util",
+ "libsqlite3-sys",
+ "log",
+ "percent-encoding",
+ "serde",
+ "serde_urlencoded",
+ "sqlx-core",
+ "thiserror",
+ "tracing",
+ "url",
+]
+
+[[package]]
+name = "stable_deref_trait"
+version = "1.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
+
+[[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.117"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "sync_wrapper"
+version = "1.0.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
+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",
+]
+
+[[package]]
+name = "thiserror"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
+dependencies = [
+ "thiserror-impl",
+]
+
+[[package]]
+name = "thiserror-impl"
+version = "2.0.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "thread_local"
+version = "1.1.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "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.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3"
+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.52.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
+dependencies = [
+ "bytes",
+ "libc",
+ "mio",
+ "parking_lot",
+ "pin-project-lite",
+ "signal-hook-registry",
+ "socket2",
+ "tokio-macros",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "tokio-macros"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tokio-rustls"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
+dependencies = [
+ "rustls",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-stream"
+version = "0.1.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
+dependencies = [
+ "futures-core",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[package]]
+name = "tokio-util"
+version = "0.7.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
+dependencies = [
+ "bytes",
+ "futures-core",
+ "futures-sink",
+ "pin-project-lite",
+ "tokio",
+]
+
+[[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-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",
+ "http-body",
+ "http-body-util",
+ "http-range-header",
+ "httpdate",
+ "mime",
+ "mime_guess",
+ "percent-encoding",
+ "pin-project-lite",
+ "tokio",
+ "tokio-util",
+ "tower",
+ "tower-layer",
+ "tower-service",
+ "tracing",
+ "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 = "tracing"
+version = "0.1.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
+dependencies = [
+ "log",
+ "pin-project-lite",
+ "tracing-attributes",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-attributes"
+version = "0.1.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "tracing-core"
+version = "0.1.36"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
+dependencies = [
+ "once_cell",
+ "valuable",
+]
+
+[[package]]
+name = "tracing-log"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
+dependencies = [
+ "log",
+ "once_cell",
+ "tracing-core",
+]
+
+[[package]]
+name = "tracing-subscriber"
+version = "0.3.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 = "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 = "untrusted"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
+
+[[package]]
+name = "url"
+version = "2.5.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
+dependencies = [
+ "form_urlencoded",
+ "idna",
+ "percent-encoding",
+ "serde",
+]
+
+[[package]]
+name = "utf8_iter"
+version = "1.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+
+[[package]]
+name = "valuable"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
+
+[[package]]
+name = "vcpkg"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
+
+[[package]]
+name = "version_check"
+version = "0.9.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
+
+[[package]]
+name = "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.3+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a254a4b10c19a76f09a27640e7ffbf9bc30bf67e16a3bf28aaefa4920fe81563"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.73"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "54568702fabf5d4849ce2b90fadfa64168a097eaf4b351ce9df8b687a0086aaf"
+dependencies = [
+ "js-sys",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24a40fc75b0ec6f3746ceb10d36f53a93dcd68a93b11b6445983945d79eba0dc"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "908f34bd9b9ce3d4caf07b72dfab63d61504d156856c6bd3cd87fa350cf3985b"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.123"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7acbf7616c27b194bbb550bf77ed0c2c3e5b7fd1260a93082b95fb7f47959b92"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.100"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e0871acf327f283dc6da28a1696cdc64fb355ba9f935d052021fa77f35cce69"
+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.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d"
+dependencies = [
+ "rustls-pki-types",
+]
+
+[[package]]
+name = "windows-link"
+version = "0.2.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.60.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
+dependencies = [
+ "windows-targets 0.53.5",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.61.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
+dependencies = [
+ "windows-link",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm 0.52.6",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.53.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
+dependencies = [
+ "windows-link",
+ "windows_aarch64_gnullvm 0.53.1",
+ "windows_aarch64_msvc 0.53.1",
+ "windows_i686_gnu 0.53.1",
+ "windows_i686_gnullvm 0.53.1",
+ "windows_i686_msvc 0.53.1",
+ "windows_x86_64_gnu 0.53.1",
+ "windows_x86_64_gnullvm 0.53.1",
+ "windows_x86_64_msvc 0.53.1",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
+
+[[package]]
+name = "windows_aarch64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
+
+[[package]]
+name = "windows_i686_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
+
+[[package]]
+name = "windows_i686_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
+
+[[package]]
+name = "windows_i686_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
+
+[[package]]
+name = "windows_x86_64_gnu"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
+
+[[package]]
+name = "windows_x86_64_gnullvm"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
+
+[[package]]
+name = "windows_x86_64_msvc"
+version = "0.53.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
+
+[[package]]
+name = "wit-bindgen"
+version = "0.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 = "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",
+ "synstructure",
+]
+
+[[package]]
+name = "zerocopy"
+version = "0.8.51"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2e5361301a1d9e5dd94c524eb99365fbaed5b237e831d7f45e2ddea11ffe8627"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.51"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "422033a2245cb4b6ff8def11b2dfaf184a2ab2573f5af28082a163a68889af0e"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[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",
+ "synstructure",
+]
+
+[[package]]
+name = "zeroize"
+version = "1.8.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
+
+[[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",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..d5b849d
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,26 @@
+[package]
+name = "okibi"
+version = "0.1.0"
+edition = "2021"
+description = "Okibi — 焚き火(atsm)会員だけの自前 Git ホスティング"
+
+[dependencies]
+axum = { version = "0.8", features = ["macros"] }
+tokio = { version = "1", features = ["full"] }
+tower = "0.5"
+tower-http = { version = "0.6", features = ["trace", "fs"] }
+sqlx = { version = "0.8", default-features = false, features = ["sqlite", "runtime-tokio", "macros"] }
+maud = "0.27"
+serde = { version = "1", features = ["derive"] }
+serde_json = "1"
+reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
+rand = "0.8"
+sha2 = "0.10"
+hex = "0.4"
+anyhow = "1"
+tracing = "0.1"
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
+bytes = "1"
+
+[profile.release]
+opt-level = 2
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..8062413
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,19 @@
+# ---- build ----
+FROM rust:1.95-slim-bookworm AS build
+WORKDIR /app
+COPY Cargo.toml ./
+COPY Cargo.lock ./
+COPY src ./src
+RUN cargo build --release
+
+# ---- runtime ----
+FROM debian:bookworm-slim
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends git ca-certificates bash \
+ && rm -rf /var/lib/apt/lists/*
+COPY --from=build /app/target/release/okibi /usr/local/bin/okibi
+ENV OKIBI_DATA=/data \
+    OKIBI_BIND=0.0.0.0:8787 \
+    OKIBI_GIT_HTTP_BACKEND=/usr/lib/git-core/git-http-backend
+EXPOSE 8787
+CMD ["okibi"]
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e0c792e
--- /dev/null
+++ b/README.md
@@ -0,0 +1,51 @@
+# 🔥 Okibi — 焚き火(atsm)の仲間だけの Git
+
+> おき火=燃え続ける熾火。コードが、残る場所。
+
+atsm の焚き火コミュニティのメンバーだけが安全に push できる、自前のモダンな Git ホスティング。
+GitHub のような体験を、招待制で。
+
+## なにができる
+- メンバーだけがログイン(自前 magic-link / 管理者の招待リンク)
+- リポジトリ作成・公開/非公開
+- `git push` / `git pull`(HTTPS + Personal Access Token)
+- Web でコードを閲覧(ファイルツリー / blob)
+- push 時に `.okibi/ci.sh`(または `.okibi/ci.yml` の `script:`)を実行する簡易 CI
+
+## 技術
+- Rust + axum + SQLite(sqlx)+ maud
+- git smart-HTTP は実 `git-http-backend`(CGI) に委譲(pack は自前実装しない=安全)
+- 認証: セッション(Cookie) / git は handle + PAT の Basic 認証
+- シークレットはハッシュのみ保存(セッション・PAT・magic-link)
+
+## ローカル起動
+```bash
+cargo run            # http://localhost:8787
+# 開発時、メール未設定なら magic-link は標準出力に出ます
+```
+
+主な環境変数:
+| var | 説明 | 既定 |
+|---|---|---|
+| `OKIBI_BASE_URL` | 公開URL(リンク生成に使用) | `http://localhost:8787` |
+| `OKIBI_DATA` | データ/リポジトリ置き場 | `./data` |
+| `OKIBI_SEED_MEMBERS` | 初期メンバー(=admin)メール | yuki の2件 |
+| `OKIBI_ADMIN_BOOTSTRAP` | メール無しで admin ログインする秘密トークン | 無効 |
+| `RESEND_API_KEY` | あれば magic-link をメール送信 | 無ければ stdout |
+| `OKIBI_CI_ENABLE` | `1` で簡易CIを有効化(pushコード実行注意) | 無効 |
+
+## CI の使い方
+リポジトリに `.okibi/ci.sh` を置くと push 時に実行されます(`OKIBI_CI_ENABLE=1` のとき)。
+```sh
+# .okibi/ci.sh
+cargo test
+```
+
+## セキュリティ note(v1)
+- 簡易CIは pushコードをホスト上でそのまま実行します。既定で無効。
+  本番有効化時は ephemeral なサンドボックス(Fly machine/コンテナ)に隔離するのが TODO。
+- 認証は atsm allowlist で会員ゲート。atsm.wtf に SSO/OIDC が無いため、当面は
+  自前 magic-link + 招待リンクで運用(atsm が SSO を出したら差し替え)。
+
+## デプロイ
+`git push` → GitHub Actions → Fly.io(`fly deploy` 直叩きは禁止)。
diff --git a/fly.toml b/fly.toml
new file mode 100644
index 0000000..fa8cbf4
--- /dev/null
+++ b/fly.toml
@@ -0,0 +1,36 @@
+app = "okibi"
+primary_region = "nrt"
+
+[build]
+
+[env]
+  OKIBI_DATA = "/data"
+  OKIBI_BIND = "0.0.0.0:8787"
+  OKIBI_BASE_URL = "https://okibi.fly.dev"
+  OKIBI_GIT_HTTP_BACKEND = "/usr/lib/git-core/git-http-backend"
+
+[[mounts]]
+  source = "okibi_data"
+  destination = "/data"
+
+[http_service]
+  internal_port = 8787
+  force_https = true
+  auto_stop_machines = false
+  auto_start_machines = true
+  min_machines_running = 1
+
+[checks]
+  [checks.health]
+    port = 8787
+    type = "http"
+    interval = "15s"
+    timeout = "2s"
+    grace_period = "5s"
+    method = "get"
+    path = "/healthz"
+
+[[vm]]
+  cpu_kind = "shared"
+  cpus = 1
+  memory_mb = 512
diff --git a/scripts/e2e.sh b/scripts/e2e.sh
new file mode 100644
index 0000000..65ec586
--- /dev/null
+++ b/scripts/e2e.sh
@@ -0,0 +1,70 @@
+#!/usr/bin/env bash
+# End-to-end: magic-link login -> create repo -> issue PAT -> git push/pull.
+set -uo pipefail
+BASE="http://localhost:8799"
+JAR=$(mktemp)
+WORK=$(mktemp -d)
+LOG="${OKIBI_LOG:-/tmp/okibi.log}"
+EMAIL="yuki@hamada.tokyo"
+HANDLE="yuki"
+REPO="hello"
+ok(){ echo "✅ $*"; }
+die(){ echo "❌ $*"; exit 1; }
+
+echo "== 1. health =="
+curl -fsS "$BASE/healthz" >/dev/null && ok "health ok" || die "no server"
+
+echo "== 2. non-member is rejected =="
+curl -fsS -d "email=stranger@example.com" "$BASE/login" | grep -q "登録されていません" \
+  && ok "stranger rejected" || die "stranger NOT rejected (membership gate broken!)"
+
+echo "== 3. request magic link (member) =="
+: > "$LOG.marker" 2>/dev/null || true
+curl -fsS -c "$JAR" -d "email=$EMAIL" "$BASE/login" | grep -q "ログインリンク" || die "login submit failed"
+sleep 0.5
+LINK=$(grep -oE "$BASE/auth/verify\?token=[a-f0-9]+" "$LOG" | tail -1)
+[ -n "$LINK" ] && ok "magic link issued" || die "no magic link in log"
+
+echo "== 4. verify -> session =="
+curl -fsS -c "$JAR" -b "$JAR" "$LINK" -o /dev/null && ok "verified, session set" || die "verify failed"
+grep -q okibi_session "$JAR" || die "no session cookie"
+
+echo "== 5. create repo =="
+curl -fsS -c "$JAR" -b "$JAR" -d "name=$REPO&public=1" "$BASE/new" -o /dev/null && ok "repo created" || die "create failed"
+
+echo "== 6. issue PAT =="
+PAT=$(curl -fsS -c "$JAR" -b "$JAR" -d "name=e2e" "$BASE/settings/tokens" \
+      | grep -oE "okibi_[a-f0-9]+" | head -1)
+[ -n "$PAT" ] && ok "PAT issued (${PAT:0:12}...)" || die "no PAT"
+
+echo "== 7. git push =="
+cd "$WORK"
+git init -q -b main proj && cd proj
+git config user.email t@t.io; git config user.name t
+mkdir -p .okibi
+printf 'echo "hello from CI"\nls -la\n' > .okibi/ci.sh
+echo "# Hello Okibi" > README.md
+git add -A && git commit -qm "first fire"
+REMOTE="http://$HANDLE:$PAT@localhost:8799/$HANDLE/$REPO.git"
+git push -q "$REMOTE" main 2>&1 && ok "push ok" || die "push failed"
+
+echo "== 8. git clone back =="
+cd "$WORK"
+git clone -q "$REMOTE" cloned 2>&1 && [ -f cloned/README.md ] \
+  && ok "clone ok, README present" || die "clone failed"
+grep -q "Hello Okibi" cloned/README.md && ok "content matches" || die "content mismatch"
+
+echo "== 9. unauthorized push is blocked =="
+BADREMOTE="http://$HANDLE:okibi_deadbeef@localhost:8799/$HANDLE/$REPO.git"
+if git -C "$WORK/proj" push -q "$BADREMOTE" main 2>/dev/null; then
+  die "bad PAT push SUCCEEDED (auth broken!)"
+else
+  ok "bad PAT push rejected"
+fi
+
+echo "== 10. web tree shows file =="
+curl -fsS "$BASE/$HANDLE/$REPO" | grep -q "README.md" && ok "README visible in web tree" || die "web tree missing file"
+
+echo
+echo "🔥 ALL E2E CHECKS PASSED"
+rm -rf "$WORK" "$JAR"
diff --git a/src/auth.rs b/src/auth.rs
new file mode 100644
index 0000000..e056410
--- /dev/null
+++ b/src/auth.rs
@@ -0,0 +1,286 @@
+use crate::db::Db;
+use crate::util::{handle_from_email, norm_email, now, random_token, sha256_hex};
+use axum::http::HeaderMap;
+
+pub const SESSION_COOKIE: &str = "okibi_session";
+const SESSION_TTL: i64 = 60 * 60 * 24 * 30; // 30 days
+const MAGIC_TTL: i64 = 60 * 15; // 15 min
+
+#[derive(Clone, Debug, sqlx::FromRow)]
+pub struct User {
+    pub id: i64,
+    pub handle: String,
+    pub email: String,
+    pub display_name: String,
+    pub is_admin: i64,
+}
+
+/// Is this email on the atsm allowlist? Returns (display_name, is_admin).
+pub async fn member(db: &Db, email: &str) -> Option<(String, bool)> {
+    let email = norm_email(email);
+    sqlx::query_as::<_, (String, i64)>(
+        "SELECT display_name, is_admin FROM members WHERE email = ?",
+    )
+    .bind(&email)
+    .fetch_optional(db)
+    .await
+    .ok()
+    .flatten()
+    .map(|(n, a)| (n, a != 0))
+}
+
+/// Add (or keep) an email on the allowlist. Used by admin invites.
+pub async fn add_member(db: &Db, email: &str, is_admin: bool) -> anyhow::Result<()> {
+    sqlx::query(
+        "INSERT INTO members (email, display_name, is_admin, added_at)
+         VALUES (?, '', ?, ?)
+         ON CONFLICT(email) DO UPDATE SET is_admin = MAX(is_admin, excluded.is_admin)",
+    )
+    .bind(norm_email(email))
+    .bind(is_admin as i64)
+    .bind(now())
+    .execute(db)
+    .await?;
+    Ok(())
+}
+
+/// All allowlisted emails (for the admin members page).
+pub async fn list_members(db: &Db) -> Vec<(String, bool)> {
+    sqlx::query_as::<_, (String, i64)>(
+        "SELECT email, is_admin FROM members ORDER BY added_at",
+    )
+    .fetch_all(db)
+    .await
+    .unwrap_or_default()
+    .into_iter()
+    .map(|(e, a)| (e, a != 0))
+    .collect()
+}
+
+/// Create a single-use magic link token for an allowlisted email.
+pub async fn create_magic_link(db: &Db, email: &str) -> anyhow::Result<String> {
+    let token = random_token();
+    sqlx::query(
+        "INSERT INTO magic_links (token_hash, email, expires_at, used) VALUES (?, ?, ?, 0)",
+    )
+    .bind(sha256_hex(&token))
+    .bind(norm_email(email))
+    .bind(now() + MAGIC_TTL)
+    .execute(db)
+    .await?;
+    Ok(token)
+}
+
+/// Consume a magic link, returning the email if valid & unused & unexpired.
+pub async fn consume_magic_link(db: &Db, token: &str) -> Option<String> {
+    let h = sha256_hex(token);
+    let row = sqlx::query_as::<_, (String, i64, i64)>(
+        "SELECT email, expires_at, used FROM magic_links WHERE token_hash = ?",
+    )
+    .bind(&h)
+    .fetch_optional(db)
+    .await
+    .ok()
+    .flatten()?;
+    let (email, expires_at, used) = row;
+    if used != 0 || expires_at < now() {
+        return None;
+    }
+    sqlx::query("UPDATE magic_links SET used = 1 WHERE token_hash = ?")
+        .bind(&h)
+        .execute(db)
+        .await
+        .ok()?;
+    Some(email)
+}
+
+/// Find-or-create the user for an allowlisted email.
+pub async fn upsert_user(db: &Db, email: &str) -> anyhow::Result<User> {
+    let email = norm_email(email);
+    if let Some(u) = user_by_email(db, &email).await {
+        return Ok(u);
+    }
+    let (display_name, is_admin) = member(db, &email)
+        .await
+        .unwrap_or_else(|| (String::new(), false));
+    // Pick a unique handle.
+    let base = handle_from_email(&email);
+    let mut handle = base.clone();
+    let mut n = 1;
+    while sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE handle = ?")
+        .bind(&handle)
+        .fetch_one(db)
+        .await?
+        > 0
+    {
+        n += 1;
+        handle = format!("{base}{n}");
+    }
+    sqlx::query(
+        "INSERT INTO users (handle, email, display_name, is_admin, created_at)
+         VALUES (?, ?, ?, ?, ?)",
+    )
+    .bind(&handle)
+    .bind(&email)
+    .bind(&display_name)
+    .bind(is_admin as i64)
+    .bind(now())
+    .execute(db)
+    .await?;
+    user_by_email(db, &email)
+        .await
+        .ok_or_else(|| anyhow::anyhow!("user vanished after insert"))
+}
+
+pub async fn user_by_email(db: &Db, email: &str) -> Option<User> {
+    sqlx::query_as::<_, User>(
+        "SELECT id, handle, email, display_name, is_admin FROM users WHERE email = ?",
+    )
+    .bind(norm_email(email))
+    .fetch_optional(db)
+    .await
+    .ok()
+    .flatten()
+}
+
+pub async fn user_by_handle(db: &Db, handle: &str) -> Option<User> {
+    sqlx::query_as::<_, User>(
+        "SELECT id, handle, email, display_name, is_admin FROM users WHERE handle = ?",
+    )
+    .bind(handle)
+    .fetch_optional(db)
+    .await
+    .ok()
+    .flatten()
+}
+
+/// Create a web session, return the raw cookie value.
+pub async fn create_session(db: &Db, user_id: i64) -> anyhow::Result<String> {
+    let token = random_token();
+    sqlx::query("INSERT INTO sessions (token_hash, user_id, expires_at) VALUES (?, ?, ?)")
+        .bind(sha256_hex(&token))
+        .bind(user_id)
+        .bind(now() + SESSION_TTL)
+        .execute(db)
+        .await?;
+    Ok(token)
+}
+
+pub async fn destroy_session(db: &Db, token: &str) {
+    let _ = sqlx::query("DELETE FROM sessions WHERE token_hash = ?")
+        .bind(sha256_hex(token))
+        .execute(db)
+        .await;
+}
+
+/// Resolve the logged-in user from request cookies.
+pub async fn current_user(db: &Db, headers: &HeaderMap) -> Option<User> {
+    let token = cookie(headers, SESSION_COOKIE)?;
+    let row = sqlx::query_as::<_, (i64, i64)>(
+        "SELECT user_id, expires_at FROM sessions WHERE token_hash = ?",
+    )
+    .bind(sha256_hex(&token))
+    .fetch_optional(db)
+    .await
+    .ok()
+    .flatten()?;
+    if row.1 < now() {
+        return None;
+    }
+    sqlx::query_as::<_, User>(
+        "SELECT id, handle, email, display_name, is_admin FROM users WHERE id = ?",
+    )
+    .bind(row.0)
+    .fetch_optional(db)
+    .await
+    .ok()
+    .flatten()
+}
+
+/// Create a personal access token (for git over HTTPS). Returns raw token.
+pub async fn create_pat(db: &Db, user_id: i64, name: &str) -> anyhow::Result<String> {
+    let token = format!("okibi_{}", random_token());
+    sqlx::query(
+        "INSERT INTO pats (user_id, name, token_hash, created_at) VALUES (?, ?, ?, ?)",
+    )
+    .bind(user_id)
+    .bind(name)
+    .bind(sha256_hex(&token))
+    .bind(now())
+    .execute(db)
+    .await?;
+    Ok(token)
+}
+
+/// Verify HTTP basic auth (handle + PAT) for git operations.
+pub async fn verify_pat(db: &Db, handle: &str, token: &str) -> Option<User> {
+    let user = user_by_handle(db, handle).await?;
+    let h = sha256_hex(token);
+    let ok = sqlx::query_scalar::<_, i64>(
+        "SELECT COUNT(*) FROM pats WHERE user_id = ? AND token_hash = ?",
+    )
+    .bind(user.id)
+    .bind(&h)
+    .fetch_one(db)
+    .await
+    .ok()?
+        > 0;
+    if ok {
+        let _ = sqlx::query("UPDATE pats SET last_used = ? WHERE token_hash = ?")
+            .bind(now())
+            .bind(&h)
+            .execute(db)
+            .await;
+        Some(user)
+    } else {
+        None
+    }
+}
+
+/// Extract a cookie value from request headers.
+pub fn cookie(headers: &HeaderMap, name: &str) -> Option<String> {
+    let raw = headers.get(axum::http::header::COOKIE)?.to_str().ok()?;
+    for part in raw.split(';') {
+        let part = part.trim();
+        if let Some(rest) = part.strip_prefix(&format!("{name}=")) {
+            return Some(rest.to_string());
+        }
+    }
+    None
+}
+
+/// Parse HTTP Basic auth header into (user, pass).
+pub fn basic_auth(headers: &HeaderMap) -> Option<(String, String)> {
+    let raw = headers.get(axum::http::header::AUTHORIZATION)?.to_str().ok()?;
+    let b64 = raw.strip_prefix("Basic ")?;
+    let decoded = base64_decode(b64)?;
+    let s = String::from_utf8(decoded).ok()?;
+    let (u, p) = s.split_once(':')?;
+    Some((u.to_string(), p.to_string()))
+}
+
+/// Minimal base64 decoder (avoids pulling a crate just for basic auth).
+fn base64_decode(input: &str) -> Option<Vec<u8>> {
+    const TABLE: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+    let mut lut = [255u8; 256];
+    for (i, &c) in TABLE.iter().enumerate() {
+        lut[c as usize] = i as u8;
+    }
+    let clean: Vec<u8> = input.bytes().filter(|&b| b != b'=' && !b.is_ascii_whitespace()).collect();
+    let mut out = Vec::with_capacity(clean.len() * 3 / 4);
+    let mut buf = 0u32;
+    let mut bits = 0u32;
+    for &c in &clean {
+        let v = lut[c as usize];
+        if v == 255 {
+            return None;
+        }
+        buf = (buf << 6) | v as u32;
+        bits += 6;
+        if bits >= 8 {
+            bits -= 8;
+            out.push((buf >> bits) as u8);
+        }
+    }
+    Some(out)
+}
diff --git a/src/ci.rs b/src/ci.rs
new file mode 100644
index 0000000..0313d6b
--- /dev/null
+++ b/src/ci.rs
@@ -0,0 +1,150 @@
+use crate::repos::{self, Repo};
+use crate::util::now;
+use crate::AppState;
+use std::path::PathBuf;
+
+/// Fire CI for a repo after a push. Non-blocking: spawns a background task.
+///
+/// SAFETY: the runner executes code from the pushed commit on the host with no
+/// sandbox. It is OFF unless `OKIBI_CI_ENABLE=1`. v1 trusts atsm members only;
+/// real isolation (ephemeral Fly machine / container per run) is a follow-up.
+pub fn on_push(state: AppState, owner_handle: String, repo: Repo) {
+    if std::env::var("OKIBI_CI_ENABLE").ok().as_deref() != Some("1") {
+        tracing::info!("CI skipped (OKIBI_CI_ENABLE != 1) for {owner_handle}/{}", repo.name);
+        return;
+    }
+    tokio::spawn(async move {
+        if let Err(e) = run(state, owner_handle, repo).await {
+            tracing::error!("CI run failed: {e}");
+        }
+    });
+}
+
+async fn run(state: AppState, owner_handle: String, repo: Repo) -> anyhow::Result<()> {
+    let cfg = state.cfg.clone();
+    let (sha, _subject, _t) = match repos::head_commit(&cfg, &owner_handle, &repo.name, &repo.default_branch) {
+        Some(c) => c,
+        None => return Ok(()), // empty push / branch delete
+    };
+
+    let run_id: i64 = sqlx::query_scalar(
+        "INSERT INTO ci_runs (repo_id, sha, ref_name, status, created_at)
+         VALUES (?, ?, ?, 'running', ?) RETURNING id",
+    )
+    .bind(repo.id)
+    .bind(&sha)
+    .bind(&repo.default_branch)
+    .bind(now())
+    .fetch_one(&state.db)
+    .await?;
+
+    let bare = repos::repo_path(&cfg, &owner_handle, &repo.name);
+    let workdir = std::env::temp_dir().join(format!("okibi-ci-{run_id}"));
+    let _ = std::fs::remove_dir_all(&workdir);
+
+    let (status, log) = execute(&cfg.git_bin, &bare, &workdir, &sha).await;
+    let _ = std::fs::remove_dir_all(&workdir);
+
+    sqlx::query("UPDATE ci_runs SET status = ?, log = ?, finished_at = ? WHERE id = ?")
+        .bind(&status)
+        .bind(&log)
+        .bind(now())
+        .bind(run_id)
+        .execute(&state.db)
+        .await?;
+    tracing::info!("CI {owner_handle}/{} #{run_id} -> {status}", repo.name);
+    Ok(())
+}
+
+/// Checkout `sha` into a workdir and run the project's CI script.
+async fn execute(git_bin: &str, bare: &PathBuf, workdir: &PathBuf, sha: &str) -> (String, String) {
+    let mut log = String::new();
+
+    // Clone the bare repo at the pushed sha.
+    let clone = tokio::process::Command::new(git_bin)
+        .args(["clone", "--quiet"])
+        .arg(bare)
+        .arg(workdir)
+        .output()
+        .await;
+    if let Err(e) = clone {
+        return ("error".into(), format!("clone failed: {e}"));
+    }
+    let _ = tokio::process::Command::new(git_bin)
+        .current_dir(workdir)
+        .args(["checkout", "--quiet", sha])
+        .output()
+        .await;
+
+    let script = ci_script(workdir);
+    let script = match script {
+        Some(s) => s,
+        None => return ("skipped".into(), "no .okibi/ci.sh or script: in .okibi/ci.yml\n".into()),
+    };
+    log.push_str(&format!("$ running .okibi CI ({} bytes)\n", script.len()));
+
+    let fut = tokio::process::Command::new("bash")
+        .current_dir(workdir)
+        .arg("-eo")
+        .arg("pipefail")
+        .arg("-c")
+        .arg(&script)
+        .env("CI", "true")
+        .env("OKIBI_SHA", sha)
+        .output();
+
+    match tokio::time::timeout(std::time::Duration::from_secs(600), fut).await {
+        Ok(Ok(out)) => {
+            log.push_str(&String::from_utf8_lossy(&out.stdout));
+            log.push_str(&String::from_utf8_lossy(&out.stderr));
+            let status = if out.status.success() { "success" } else { "failed" };
+            (status.into(), log)
+        }
+        Ok(Err(e)) => ("error".into(), format!("{log}\nspawn error: {e}")),
+        Err(_) => ("timeout".into(), format!("{log}\nexceeded 600s")),
+    }
+}
+
+/// Resolve the CI script: prefer `.okibi/ci.sh`, else extract a `script:` list
+/// from `.okibi/ci.yml` (tiny line-based parser, no YAML dep).
+fn ci_script(workdir: &PathBuf) -> Option<String> {
+    let sh = workdir.join(".okibi/ci.sh");
+    if let Ok(s) = std::fs::read_to_string(&sh) {
+        if !s.trim().is_empty() {
+            return Some(s);
+        }
+    }
+    let yml = workdir.join(".okibi/ci.yml");
+    let text = std::fs::read_to_string(&yml).ok()?;
+    let mut lines = Vec::new();
+    let mut in_script = false;
+    for raw in text.lines() {
+        let trimmed = raw.trim_start();
+        if trimmed.starts_with("script:") {
+            in_script = true;
+            // inline form: `script: echo hi`
+            let rest = trimmed.trim_start_matches("script:").trim();
+            if !rest.is_empty() && rest != "|" {
+                lines.push(rest.trim_matches(|c| c == '"' || c == '\'').to_string());
+                in_script = false;
+            }
+            continue;
+        }
+        if in_script {
+            if let Some(item) = trimmed.strip_prefix("- ") {
+                lines.push(item.trim_matches(|c| c == '"' || c == '\'').to_string());
+            } else if trimmed.is_empty() {
+                continue;
+            } else if !raw.starts_with(' ') {
+                in_script = false; // dedent → end of block
+            } else {
+                lines.push(trimmed.to_string());
+            }
+        }
+    }
+    if lines.is_empty() {
+        None
+    } else {
+        Some(lines.join("\n"))
+    }
+}
diff --git a/src/config.rs b/src/config.rs
new file mode 100644
index 0000000..bc2952b
--- /dev/null
+++ b/src/config.rs
@@ -0,0 +1,62 @@
+use std::path::PathBuf;
+
+/// Runtime configuration, sourced from env with sane local defaults.
+#[derive(Clone)]
+pub struct Config {
+    /// Public base URL (used in magic links). e.g. https://okibi.fly.dev
+    pub base_url: String,
+    /// Directory holding bare git repos: `<data>/repos/<owner>/<name>.git`
+    pub repos_dir: PathBuf,
+    /// SQLite file path.
+    pub db_path: PathBuf,
+    /// Path to the `git` binary.
+    pub git_bin: String,
+    /// Path to `git-http-backend` CGI.
+    pub git_http_backend: String,
+    /// Resend API key (optional; if absent, magic links are logged to stdout).
+    pub resend_key: Option<String>,
+    /// From address for outgoing mail.
+    pub mail_from: String,
+    /// Comma-separated seed member emails (always allowed + admin).
+    pub seed_members: Vec<String>,
+    /// Secret token enabling no-email admin bootstrap login (optional).
+    pub admin_bootstrap: Option<String>,
+    pub bind: String,
+}
+
+fn env(key: &str) -> Option<String> {
+    std::env::var(key).ok().filter(|v| !v.is_empty())
+}
+
+impl Config {
+    pub fn from_env() -> Self {
+        let data = env("OKIBI_DATA").unwrap_or_else(|| "./data".into());
+        let data = PathBuf::from(data);
+        let default_backend = if std::path::Path::new(
+            "/opt/homebrew/opt/git/libexec/git-core/git-http-backend",
+        )
+        .exists()
+        {
+            "/opt/homebrew/opt/git/libexec/git-core/git-http-backend".to_string()
+        } else {
+            "/usr/lib/git-core/git-http-backend".to_string()
+        };
+        Config {
+            base_url: env("OKIBI_BASE_URL").unwrap_or_else(|| "http://localhost:8787".into()),
+            repos_dir: data.join("repos"),
+            db_path: data.join("okibi.db"),
+            git_bin: env("OKIBI_GIT_BIN").unwrap_or_else(|| "git".into()),
+            git_http_backend: env("OKIBI_GIT_HTTP_BACKEND").unwrap_or(default_backend),
+            resend_key: env("RESEND_API_KEY"),
+            mail_from: env("OKIBI_MAIL_FROM").unwrap_or_else(|| "Okibi <info@enablerdao.com>".into()),
+            seed_members: env("OKIBI_SEED_MEMBERS")
+                .unwrap_or_else(|| "yuki@hamada.tokyo,mail@yukihamada.jp".into())
+                .split(',')
+                .map(|s| s.trim().to_lowercase())
+                .filter(|s| !s.is_empty())
+                .collect(),
+            admin_bootstrap: env("OKIBI_ADMIN_BOOTSTRAP"),
+            bind: env("OKIBI_BIND").unwrap_or_else(|| "0.0.0.0:8787".into()),
+        }
+    }
+}
diff --git a/src/db.rs b/src/db.rs
new file mode 100644
index 0000000..490ce50
--- /dev/null
+++ b/src/db.rs
@@ -0,0 +1,105 @@
+use crate::util::now;
+use sqlx::sqlite::{SqlitePool, SqlitePoolOptions};
+use sqlx::ConnectOptions;
+use std::str::FromStr;
+
+pub type Db = SqlitePool;
+
+const SCHEMA: &str = r#"
+CREATE TABLE IF NOT EXISTS users (
+  id           INTEGER PRIMARY KEY AUTOINCREMENT,
+  handle       TEXT UNIQUE NOT NULL,
+  email        TEXT UNIQUE NOT NULL,
+  display_name TEXT NOT NULL DEFAULT '',
+  is_admin     INTEGER NOT NULL DEFAULT 0,
+  created_at   INTEGER NOT NULL
+);
+
+-- atsm member allowlist: only these emails may sign in.
+CREATE TABLE IF NOT EXISTS members (
+  email        TEXT PRIMARY KEY,
+  display_name TEXT NOT NULL DEFAULT '',
+  is_admin     INTEGER NOT NULL DEFAULT 0,
+  added_at     INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS sessions (
+  token_hash TEXT PRIMARY KEY,
+  user_id    INTEGER NOT NULL,
+  expires_at INTEGER NOT NULL
+);
+
+CREATE TABLE IF NOT EXISTS magic_links (
+  token_hash TEXT PRIMARY KEY,
+  email      TEXT NOT NULL,
+  expires_at INTEGER NOT NULL,
+  used       INTEGER NOT NULL DEFAULT 0
+);
+
+CREATE TABLE IF NOT EXISTS pats (
+  id         INTEGER PRIMARY KEY AUTOINCREMENT,
+  user_id    INTEGER NOT NULL,
+  name       TEXT NOT NULL DEFAULT '',
+  token_hash TEXT UNIQUE NOT NULL,
+  created_at INTEGER NOT NULL,
+  last_used  INTEGER
+);
+
+CREATE TABLE IF NOT EXISTS repos (
+  id             INTEGER PRIMARY KEY AUTOINCREMENT,
+  owner_id       INTEGER NOT NULL,
+  name           TEXT NOT NULL,
+  visibility     TEXT NOT NULL DEFAULT 'private',
+  default_branch TEXT NOT NULL DEFAULT 'main',
+  description    TEXT NOT NULL DEFAULT '',
+  created_at     INTEGER NOT NULL,
+  UNIQUE(owner_id, name)
+);
+
+CREATE TABLE IF NOT EXISTS ci_runs (
+  id          INTEGER PRIMARY KEY AUTOINCREMENT,
+  repo_id     INTEGER NOT NULL,
+  sha         TEXT NOT NULL,
+  ref_name    TEXT NOT NULL DEFAULT '',
+  status      TEXT NOT NULL DEFAULT 'queued',
+  log         TEXT NOT NULL DEFAULT '',
+  created_at  INTEGER NOT NULL,
+  finished_at INTEGER
+);
+"#;
+
+pub async fn init(db_path: &std::path::Path, seed_members: &[String]) -> anyhow::Result<Db> {
+    if let Some(parent) = db_path.parent() {
+        std::fs::create_dir_all(parent).ok();
+    }
+    let url = format!("sqlite://{}", db_path.display());
+    let opts = sqlx::sqlite::SqliteConnectOptions::from_str(&url)?
+        .create_if_missing(true)
+        .log_statements(tracing::log::LevelFilter::Debug);
+    let pool = SqlitePoolOptions::new()
+        .max_connections(5)
+        .connect_with(opts)
+        .await?;
+
+    for stmt in SCHEMA.split(';') {
+        let s = stmt.trim();
+        if !s.is_empty() {
+            sqlx::query(s).execute(&pool).await?;
+        }
+    }
+
+    // Seed allowlist members (admins).
+    for email in seed_members {
+        sqlx::query(
+            "INSERT INTO members (email, display_name, is_admin, added_at)
+             VALUES (?, '', 1, ?)
+             ON CONFLICT(email) DO UPDATE SET is_admin = 1",
+        )
+        .bind(email)
+        .bind(now())
+        .execute(&pool)
+        .await?;
+    }
+
+    Ok(pool)
+}
diff --git a/src/email.rs b/src/email.rs
new file mode 100644
index 0000000..6c51189
--- /dev/null
+++ b/src/email.rs
@@ -0,0 +1,38 @@
+use crate::config::Config;
+
+/// Send a magic-link email via Resend. If no key is configured, log the link
+/// to stdout (dev mode) so local flows still work.
+pub async fn send_magic_link(cfg: &Config, to: &str, link: &str) {
+    if cfg.resend_key.is_none() {
+        tracing::warn!("[dev] magic link for {to}: {link}");
+        println!("\n🔥 [dev] Okibi magic link for {to}:\n   {link}\n");
+        return;
+    }
+    let key = cfg.resend_key.as_ref().unwrap();
+    let html = format!(
+        r#"<div style="font-family:system-ui,sans-serif;max-width:480px;margin:0 auto">
+        <h2>🔥 Okibi にログイン</h2>
+        <p>焚き火の仲間だけの Git。下のボタンでログインします(15分有効)。</p>
+        <p><a href="{link}" style="display:inline-block;background:#e25822;color:#fff;
+        padding:12px 20px;border-radius:8px;text-decoration:none">ログイン</a></p>
+        <p style="color:#888;font-size:12px">心当たりがなければ無視してください。</p></div>"#
+    );
+    let body = serde_json::json!({
+        "from": cfg.mail_from,
+        "to": [to],
+        "subject": "🔥 Okibi ログインリンク",
+        "html": html,
+    });
+    let client = reqwest::Client::new();
+    match client
+        .post("https://api.resend.com/emails")
+        .bearer_auth(key)
+        .json(&body)
+        .send()
+        .await
+    {
+        Ok(r) if r.status().is_success() => tracing::info!("magic link emailed to {to}"),
+        Ok(r) => tracing::error!("resend failed {}: {}", r.status(), r.text().await.unwrap_or_default()),
+        Err(e) => tracing::error!("resend error: {e}"),
+    }
+}
diff --git a/src/git_http.rs b/src/git_http.rs
new file mode 100644
index 0000000..7f246dc
--- /dev/null
+++ b/src/git_http.rs
@@ -0,0 +1,294 @@
+use crate::auth::{self, User};
+use crate::repos::{self, Repo};
+use crate::{ci, AppState};
+use axum::body::Bytes;
+use axum::extract::{Path, RawQuery, State};
+use axum::http::{header, HeaderMap, StatusCode};
+use axum::response::{IntoResponse, Response};
+use std::io::Write;
+use std::process::{Command, Stdio};
+
+const REALM: &str = "Okibi — atsm members only";
+
+fn need_auth() -> Response {
+    (
+        StatusCode::UNAUTHORIZED,
+        [(header::WWW_AUTHENTICATE, format!("Basic realm=\"{REALM}\""))],
+        "authentication required\n",
+    )
+        .into_response()
+}
+
+/// Resolve (owner_handle, repo_name) → repo + owner, or an error response.
+async fn resolve(
+    state: &AppState,
+    owner: &str,
+    repo_seg: &str,
+) -> Result<(User, Repo), Response> {
+    let name = repo_seg.strip_suffix(".git").unwrap_or(repo_seg);
+    let owner_user = auth::user_by_handle(&state.db, owner)
+        .await
+        .ok_or_else(|| (StatusCode::NOT_FOUND, "no such owner\n").into_response())?;
+    let repo = repos::get_repo(&state.db, owner_user.id, name)
+        .await
+        .ok_or_else(|| (StatusCode::NOT_FOUND, "no such repo\n").into_response())?;
+    Ok((owner_user, repo))
+}
+
+/// Authorize a git op. `write` = push (receive-pack). Returns the actor on success.
+async fn authorize(
+    state: &AppState,
+    headers: &HeaderMap,
+    owner: &User,
+    repo: &Repo,
+    write: bool,
+) -> Result<Option<User>, Response> {
+    // Try PAT basic auth if present.
+    let actor = if let Some((u, p)) = auth::basic_auth(headers) {
+        auth::verify_pat(&state.db, &u, &p).await
+    } else {
+        None
+    };
+
+    if write {
+        // Push: must be authenticated and own the repo.
+        match &actor {
+            Some(a) if a.id == owner.id => Ok(Some(actor.unwrap())),
+            Some(_) => Err((StatusCode::FORBIDDEN, "not your repo\n").into_response()),
+            None => Err(need_auth()),
+        }
+    } else {
+        // Pull: public repos are open; private requires the owner.
+        if repo.is_public() {
+            Ok(actor)
+        } else {
+            match &actor {
+                Some(a) if a.id == owner.id => Ok(Some(actor.unwrap())),
+                Some(_) => Err((StatusCode::FORBIDDEN, "private repo\n").into_response()),
+                None => Err(need_auth()),
+            }
+        }
+    }
+}
+
+fn service_is_write(service: &str) -> bool {
+    service == "git-receive-pack"
+}
+
+// GET /{owner}/{repo}/info/refs?service=git-(upload|receive)-pack
+pub async fn info_refs(
+    State(state): State<AppState>,
+    Path((owner, repo)): Path<(String, String)>,
+    RawQuery(query): RawQuery,
+    headers: HeaderMap,
+) -> Response {
+    let query = query.unwrap_or_default();
+    let service = query
+        .split('&')
+        .find_map(|kv| kv.strip_prefix("service="))
+        .unwrap_or("")
+        .to_string();
+    if !service.starts_with("git-") {
+        return (StatusCode::BAD_REQUEST, "dumb http not supported\n").into_response();
+    }
+    let (owner_user, repo_meta) = match resolve(&state, &owner, &repo).await {
+        Ok(v) => v,
+        Err(r) => return r,
+    };
+    if let Err(r) = authorize(&state, &headers, &owner_user, &repo_meta, service_is_write(&service)).await {
+        return r;
+    }
+    cgi(
+        &state,
+        &owner_user.handle,
+        &repo,
+        "info/refs",
+        "GET",
+        &query,
+        None,
+        &headers,
+        Bytes::new(),
+        None,
+    )
+    .await
+}
+
+// POST /{owner}/{repo}/git-upload-pack
+pub async fn upload_pack(
+    State(state): State<AppState>,
+    Path((owner, repo)): Path<(String, String)>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    service(&state, owner, repo, "git-upload-pack", false, headers, body).await
+}
+
+// POST /{owner}/{repo}/git-receive-pack
+pub async fn receive_pack(
+    State(state): State<AppState>,
+    Path((owner, repo)): Path<(String, String)>,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    service(&state, owner, repo, "git-receive-pack", true, headers, body).await
+}
+
+async fn service(
+    state: &AppState,
+    owner: String,
+    repo: String,
+    svc: &str,
+    write: bool,
+    headers: HeaderMap,
+    body: Bytes,
+) -> Response {
+    let (owner_user, repo_meta) = match resolve(state, &owner, &repo).await {
+        Ok(v) => v,
+        Err(r) => return r,
+    };
+    let actor = match authorize(state, &headers, &owner_user, &repo_meta, write).await {
+        Ok(a) => a,
+        Err(r) => return r,
+    };
+    let content_type = headers
+        .get(header::CONTENT_TYPE)
+        .and_then(|v| v.to_str().ok())
+        .map(|s| s.to_string());
+    let resp = cgi(
+        state,
+        &owner_user.handle,
+        &repo,
+        svc,
+        "POST",
+        "",
+        content_type.as_deref(),
+        &headers,
+        body,
+        actor.as_ref().map(|u| u.handle.clone()),
+    )
+    .await;
+
+    // After a successful push, kick off CI for the repo's default branch.
+    if write && resp.status().is_success() {
+        ci::on_push(state.clone(), owner_user.handle.clone(), repo_meta.clone());
+    }
+    resp
+}
+
+/// Bridge a request into the `git-http-backend` CGI.
+#[allow(clippy::too_many_arguments)]
+async fn cgi(
+    state: &AppState,
+    owner_handle: &str,
+    repo_seg: &str,
+    subpath: &str,
+    method: &str,
+    query: &str,
+    content_type: Option<&str>,
+    headers: &HeaderMap,
+    body: Bytes,
+    remote_user: Option<String>,
+) -> Response {
+    let cfg = state.cfg.clone();
+    let project_root = cfg.repos_dir.join(owner_handle);
+    let path_info = format!("/{repo_seg}/{subpath}");
+    let git_protocol = headers
+        .get("git-protocol")
+        .and_then(|v| v.to_str().ok())
+        .map(|s| s.to_string());
+    let backend = cfg.git_http_backend.clone();
+    let method = method.to_string();
+    let query = query.to_string();
+    let content_type = content_type.map(|s| s.to_string());
+
+    let out = tokio::task::spawn_blocking(move || {
+        let mut cmd = Command::new(&backend);
+        cmd.env("GIT_PROJECT_ROOT", &project_root)
+            .env("GIT_HTTP_EXPORT_ALL", "1")
+            .env("PATH_INFO", &path_info)
+            .env("REQUEST_METHOD", &method)
+            .env("QUERY_STRING", &query)
+            .env("CONTENT_LENGTH", body.len().to_string())
+            .env("REMOTE_ADDR", "127.0.0.1")
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::piped());
+        if let Some(ct) = &content_type {
+            cmd.env("CONTENT_TYPE", ct);
+        }
+        if let Some(gp) = &git_protocol {
+            cmd.env("GIT_PROTOCOL", gp);
+        }
+        if let Some(ru) = &remote_user {
+            cmd.env("REMOTE_USER", ru);
+        }
+        let mut child = cmd.spawn()?;
+        if let Some(mut stdin) = child.stdin.take() {
+            stdin.write_all(&body)?;
+        }
+        let output = child.wait_with_output()?;
+        Ok::<_, std::io::Error>(output)
+    })
+    .await;
+
+    let output = match out {
+        Ok(Ok(o)) => o,
+        Ok(Err(e)) => {
+            tracing::error!("git-http-backend io error: {e}");
+            return (StatusCode::INTERNAL_SERVER_ERROR, "git backend error\n").into_response();
+        }
+        Err(e) => {
+            tracing::error!("git-http-backend join error: {e}");
+            return (StatusCode::INTERNAL_SERVER_ERROR, "git backend panicked\n").into_response();
+        }
+    };
+    if !output.status.success() && output.stdout.is_empty() {
+        tracing::error!(
+            "git-http-backend failed: {}",
+            String::from_utf8_lossy(&output.stderr)
+        );
+        return (StatusCode::INTERNAL_SERVER_ERROR, "git backend failed\n").into_response();
+    }
+
+    parse_cgi(output.stdout)
+}
+
+/// Split CGI output into headers + body and build an axum Response.
+fn parse_cgi(raw: Vec<u8>) -> Response {
+    let sep_crlf = find(&raw, b"\r\n\r\n");
+    let sep_lf = find(&raw, b"\n\n");
+    let (head_end, body_start) = match (sep_crlf, sep_lf) {
+        (Some(a), Some(b)) if a <= b => (a, a + 4),
+        (_, Some(b)) => (b, b + 2),
+        (Some(a), None) => (a, a + 4),
+        (None, None) => (0, 0),
+    };
+    let header_block = String::from_utf8_lossy(&raw[..head_end]).to_string();
+    let body = raw[body_start..].to_vec();
+
+    let mut status = StatusCode::OK;
+    let mut builder = Response::builder();
+    for line in header_block.lines() {
+        if let Some((k, v)) = line.split_once(':') {
+            let k = k.trim();
+            let v = v.trim();
+            if k.eq_ignore_ascii_case("Status") {
+                if let Some(code) = v.split_whitespace().next() {
+                    if let Ok(c) = code.parse::<u16>() {
+                        status = StatusCode::from_u16(c).unwrap_or(StatusCode::OK);
+                    }
+                }
+            } else {
+                builder = builder.header(k, v);
+            }
+        }
+    }
+    builder
+        .status(status)
+        .body(axum::body::Body::from(body))
+        .unwrap_or_else(|_| (StatusCode::INTERNAL_SERVER_ERROR, "response build error\n").into_response())
+}
+
+fn find(haystack: &[u8], needle: &[u8]) -> Option<usize> {
+    haystack.windows(needle.len()).position(|w| w == needle)
+}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..a2e2d29
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,68 @@
+mod auth;
+mod ci;
+mod config;
+mod db;
+mod email;
+mod git_http;
+mod repos;
+mod util;
+mod web;
+
+use axum::routing::{get, post};
+use axum::Router;
+use config::Config;
+use db::Db;
+
+/// Shared application state.
+#[derive(Clone)]
+pub struct AppState {
+    pub db: Db,
+    pub cfg: Config,
+}
+
+async fn healthz() -> &'static str {
+    "ok\n"
+}
+
+#[tokio::main]
+async fn main() -> anyhow::Result<()> {
+    tracing_subscriber::fmt()
+        .with_env_filter(
+            tracing_subscriber::EnvFilter::try_from_default_env()
+                .unwrap_or_else(|_| "okibi=info,tower_http=warn".into()),
+        )
+        .init();
+
+    let cfg = Config::from_env();
+    std::fs::create_dir_all(&cfg.repos_dir).ok();
+    let db = db::init(&cfg.db_path, &cfg.seed_members).await?;
+    tracing::info!("members seeded: {:?}", cfg.seed_members);
+
+    let state = AppState { db, cfg: cfg.clone() };
+
+    let app = Router::new()
+        .route("/", get(web::index))
+        .route("/healthz", get(healthz))
+        .route("/login", get(web::login_form).post(web::login_submit))
+        .route("/auth/verify", get(web::verify))
+        .route("/auth/bootstrap", get(web::bootstrap))
+        .route("/members", get(web::members_page))
+        .route("/members/invite", post(web::invite_submit))
+        .route("/logout", post(web::logout))
+        .route("/new", get(web::new_repo_form).post(web::new_repo_submit))
+        .route("/settings", get(web::settings))
+        .route("/settings/tokens", post(web::new_token))
+        // git smart-HTTP (must come before the generic repo view)
+        .route("/{owner}/{repo}/info/refs", get(git_http::info_refs))
+        .route("/{owner}/{repo}/git-upload-pack", post(git_http::upload_pack))
+        .route("/{owner}/{repo}/git-receive-pack", post(git_http::receive_pack))
+        // web views
+        .route("/{owner}", get(web::user_view))
+        .route("/{owner}/{repo}", get(web::repo_view))
+        .with_state(state);
+
+    let listener = tokio::net::TcpListener::bind(&cfg.bind).await?;
+    tracing::info!("🔥 Okibi listening on {} (base {})", cfg.bind, cfg.base_url);
+    axum::serve(listener, app).await?;
+    Ok(())
+}
diff --git a/src/repos.rs b/src/repos.rs
new file mode 100644
index 0000000..4cea6a3
--- /dev/null
+++ b/src/repos.rs
@@ -0,0 +1,191 @@
+use crate::auth::User;
+use crate::config::Config;
+use crate::db::Db;
+use crate::util::now;
+use std::path::PathBuf;
+use std::process::Command;
+
+#[derive(Clone, Debug, sqlx::FromRow)]
+pub struct Repo {
+    pub id: i64,
+    pub owner_id: i64,
+    pub name: String,
+    pub visibility: String,
+    pub default_branch: String,
+    pub description: String,
+}
+
+impl Repo {
+    pub fn is_public(&self) -> bool {
+        self.visibility == "public"
+    }
+}
+
+/// On-disk path of a repo's bare git dir.
+pub fn repo_path(cfg: &Config, owner_handle: &str, name: &str) -> PathBuf {
+    cfg.repos_dir.join(owner_handle).join(format!("{name}.git"))
+}
+
+/// Create repo metadata + initialise a bare git repo on disk.
+pub async fn create_repo(
+    db: &Db,
+    cfg: &Config,
+    owner: &User,
+    name: &str,
+    visibility: &str,
+    description: &str,
+) -> anyhow::Result<Repo> {
+    let visibility = if visibility == "public" { "public" } else { "private" };
+    sqlx::query(
+        "INSERT INTO repos (owner_id, name, visibility, default_branch, description, created_at)
+         VALUES (?, ?, ?, 'main', ?, ?)",
+    )
+    .bind(owner.id)
+    .bind(name)
+    .bind(visibility)
+    .bind(description)
+    .bind(now())
+    .execute(db)
+    .await?;
+
+    let path = repo_path(cfg, &owner.handle, name);
+    std::fs::create_dir_all(&path)?;
+    let path_str = path.to_string_lossy().to_string();
+    run_git(cfg, &["init", "--bare", "--initial-branch=main", &path_str])?;
+    // Allow smart-HTTP push/pull for this repo.
+    run_git_in(cfg, &path, &["config", "http.receivepack", "true"])?;
+    run_git_in(cfg, &path, &["config", "http.uploadpack", "true"])?;
+
+    get_repo(db, owner.id, name)
+        .await
+        .ok_or_else(|| anyhow::anyhow!("repo vanished after insert"))
+}
+
+pub async fn get_repo(db: &Db, owner_id: i64, name: &str) -> Option<Repo> {
+    sqlx::query_as::<_, Repo>(
+        "SELECT id, owner_id, name, visibility, default_branch, description
+         FROM repos WHERE owner_id = ? AND name = ?",
+    )
+    .bind(owner_id)
+    .bind(name)
+    .fetch_optional(db)
+    .await
+    .ok()
+    .flatten()
+}
+
+pub async fn list_repos_for_owner(db: &Db, owner_id: i64) -> Vec<Repo> {
+    sqlx::query_as::<_, Repo>(
+        "SELECT id, owner_id, name, visibility, default_branch, description
+         FROM repos WHERE owner_id = ? ORDER BY name",
+    )
+    .bind(owner_id)
+    .fetch_all(db)
+    .await
+    .unwrap_or_default()
+}
+
+/// Repos visible to `viewer` (public ones + viewer's own). Newest first.
+pub async fn list_visible(db: &Db, viewer: Option<&User>) -> Vec<(Repo, String)> {
+    let rows = sqlx::query_as::<_, (i64, i64, String, String, String, String, String)>(
+        "SELECT r.id, r.owner_id, r.name, r.visibility, r.default_branch, r.description, u.handle
+         FROM repos r JOIN users u ON u.id = r.owner_id
+         ORDER BY r.created_at DESC",
+    )
+    .fetch_all(db)
+    .await
+    .unwrap_or_default();
+    rows.into_iter()
+        .map(|(id, owner_id, name, visibility, default_branch, description, handle)| {
+            (
+                Repo { id, owner_id, name, visibility, default_branch, description },
+                handle,
+            )
+        })
+        .filter(|(r, _)| {
+            r.is_public() || viewer.map(|v| v.id == r.owner_id).unwrap_or(false)
+        })
+        .collect()
+}
+
+/// Does the repo have any commits yet?
+pub fn is_empty(cfg: &Config, owner_handle: &str, name: &str) -> bool {
+    let path = repo_path(cfg, owner_handle, name);
+    run_git_in(cfg, &path, &["rev-parse", "--verify", "HEAD"]).is_err()
+}
+
+/// `git ls-tree` a path at HEAD (or a ref). Returns (mode, type, name) rows.
+pub fn ls_tree(cfg: &Config, owner_handle: &str, name: &str, rev: &str, subpath: &str) -> Vec<(String, String, String)> {
+    let path = repo_path(cfg, owner_handle, name);
+    let spec = if subpath.is_empty() {
+        rev.to_string()
+    } else {
+        format!("{rev}:{subpath}")
+    };
+    let out = match run_git_in(cfg, &path, &["ls-tree", "--long", &spec]) {
+        Ok(o) => o,
+        Err(_) => return vec![],
+    };
+    let mut entries = vec![];
+    for line in out.lines() {
+        // <mode> <type> <object> <size>\t<name>
+        if let Some((meta, fname)) = line.split_once('\t') {
+            let cols: Vec<&str> = meta.split_whitespace().collect();
+            if cols.len() >= 2 {
+                entries.push((cols[0].to_string(), cols[1].to_string(), fname.to_string()));
+            }
+        }
+    }
+    // dirs first, then files, alpha
+    entries.sort_by(|a, b| {
+        let ad = a.1 == "tree";
+        let bd = b.1 == "tree";
+        bd.cmp(&ad).then(a.2.cmp(&b.2))
+    });
+    entries
+}
+
+/// Read a blob's bytes at `rev:path`.
+pub fn read_blob(cfg: &Config, owner_handle: &str, name: &str, rev: &str, file: &str) -> Option<Vec<u8>> {
+    let path = repo_path(cfg, owner_handle, name);
+    let spec = format!("{rev}:{file}");
+    let output = Command::new(&cfg.git_bin)
+        .arg("-C")
+        .arg(&path)
+        .args(["cat-file", "blob", &spec])
+        .output()
+        .ok()?;
+    if output.status.success() {
+        Some(output.stdout)
+    } else {
+        None
+    }
+}
+
+/// Latest commit (sha, subject, unix time) for a ref.
+pub fn head_commit(cfg: &Config, owner_handle: &str, name: &str, rev: &str) -> Option<(String, String, i64)> {
+    let path = repo_path(cfg, owner_handle, name);
+    let out = run_git_in(cfg, &path, &["log", "-1", "--format=%H%x00%s%x00%ct", rev]).ok()?;
+    let parts: Vec<&str> = out.trim().split('\u{0}').collect();
+    if parts.len() == 3 {
+        Some((parts[0].to_string(), parts[1].to_string(), parts[2].parse().unwrap_or(0)))
+    } else {
+        None
+    }
+}
+
+fn run_git(cfg: &Config, args: &[&str]) -> anyhow::Result<String> {
+    let out = Command::new(&cfg.git_bin).args(args).output()?;
+    if !out.status.success() {
+        anyhow::bail!("git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
+    }
+    Ok(String::from_utf8_lossy(&out.stdout).to_string())
+}
+
+fn run_git_in(cfg: &Config, dir: &std::path::Path, args: &[&str]) -> anyhow::Result<String> {
+    let out = Command::new(&cfg.git_bin).arg("-C").arg(dir).args(args).output()?;
+    if !out.status.success() {
+        anyhow::bail!("git {:?} failed: {}", args, String::from_utf8_lossy(&out.stderr));
+    }
+    Ok(String::from_utf8_lossy(&out.stdout).to_string())
+}
diff --git a/src/style.css b/src/style.css
new file mode 100644
index 0000000..791d56f
--- /dev/null
+++ b/src/style.css
@@ -0,0 +1,64 @@
+:root {
+  --bg: #0f0d0c; --panel: #1a1614; --ink: #f3ece6; --muted: #9a8f86;
+  --fire: #e25822; --fire2: #f59e0b; --line: #2a2320; --link: #f0a868;
+}
+* { box-sizing: border-box; }
+body {
+  margin: 0; background: var(--bg); color: var(--ink);
+  font: 15px/1.6 system-ui, -apple-system, "Hiragino Kaku Gothic ProN", sans-serif;
+}
+a { color: var(--link); text-decoration: none; }
+a:hover { text-decoration: underline; }
+.top {
+  display: flex; align-items: center; justify-content: space-between;
+  padding: 12px 20px; border-bottom: 1px solid var(--line);
+  background: linear-gradient(180deg, #1a1411, #0f0d0c);
+}
+.brand { font-size: 18px; font-weight: 700; color: var(--ink); }
+.top nav { display: flex; gap: 16px; align-items: center; }
+main { max-width: 880px; margin: 0 auto; padding: 28px 20px 80px; }
+h1 { font-size: 22px; margin: 0 0 16px; }
+h2 { font-size: 17px; }
+.muted { color: var(--muted); }
+.badge {
+  font-size: 11px; background: var(--line); color: var(--muted);
+  padding: 2px 8px; border-radius: 999px; margin-left: 8px; vertical-align: middle;
+}
+.card {
+  background: var(--panel); border: 1px solid var(--line);
+  border-radius: 12px; padding: 18px; margin: 16px 0;
+}
+input[type=text], input[type=email] {
+  width: 100%; padding: 10px 12px; margin: 6px 0 14px;
+  background: #0c0a09; border: 1px solid var(--line); border-radius: 8px; color: var(--ink);
+}
+label { display: block; font-size: 13px; color: var(--muted); }
+label.row, .row { display: flex; gap: 10px; align-items: center; }
+button {
+  background: var(--fire); color: #fff; border: 0; border-radius: 8px;
+  padding: 10px 18px; font-size: 14px; font-weight: 600; cursor: pointer;
+}
+button:hover { background: var(--fire2); }
+button.link { background: none; color: var(--link); padding: 0; font-weight: 400; }
+button.link:hover { text-decoration: underline; }
+.repolist { list-style: none; padding: 0; }
+.repolist li { padding: 14px 0; border-bottom: 1px solid var(--line); }
+.repolist .repo { font-size: 16px; }
+code, pre { font-family: ui-monospace, "SF Mono", Menlo, monospace; }
+pre {
+  background: #0c0a09; border: 1px solid var(--line); border-radius: 10px;
+  padding: 14px; overflow: auto; font-size: 13px;
+}
+pre.code { white-space: pre; }
+pre.token { color: var(--fire2); font-size: 15px; }
+.clonebar code {
+  display: inline-block; background: #0c0a09; border: 1px solid var(--line);
+  border-radius: 8px; padding: 8px 12px; margin: 8px 0; font-size: 13px;
+}
+table.tree { width: 100%; border-collapse: collapse; }
+table.tree td { padding: 8px 4px; border-bottom: 1px solid var(--line); }
+.crumb { font-size: 14px; }
+footer {
+  border-top: 1px solid var(--line); color: var(--muted); font-size: 12px;
+  text-align: center; padding: 24px;
+}
diff --git a/src/util.rs b/src/util.rs
new file mode 100644
index 0000000..cce0eca
--- /dev/null
+++ b/src/util.rs
@@ -0,0 +1,59 @@
+use rand::RngCore;
+use sha2::{Digest, Sha256};
+use std::time::{SystemTime, UNIX_EPOCH};
+
+/// Current unix time in seconds.
+pub fn now() -> i64 {
+    SystemTime::now()
+        .duration_since(UNIX_EPOCH)
+        .unwrap()
+        .as_secs() as i64
+}
+
+/// 32 random bytes as lowercase hex (64 chars).
+pub fn random_token() -> String {
+    let mut buf = [0u8; 32];
+    rand::thread_rng().fill_bytes(&mut buf);
+    hex::encode(buf)
+}
+
+/// SHA-256 hex digest — we store only hashes of secrets (sessions, PATs, magic links).
+pub fn sha256_hex(input: &str) -> String {
+    let mut h = Sha256::new();
+    h.update(input.as_bytes());
+    hex::encode(h.finalize())
+}
+
+/// Normalize an email for comparison (trim + lowercase).
+pub fn norm_email(s: &str) -> String {
+    s.trim().to_lowercase()
+}
+
+/// Derive a default handle from an email local-part, kept to [a-z0-9-].
+pub fn handle_from_email(email: &str) -> String {
+    let local = email.split('@').next().unwrap_or("user");
+    let mut out = String::new();
+    for c in local.chars() {
+        if c.is_ascii_alphanumeric() {
+            out.push(c.to_ascii_lowercase());
+        } else if c == '.' || c == '_' || c == '-' || c == '+' {
+            out.push('-');
+        }
+    }
+    let trimmed = out.trim_matches('-').to_string();
+    if trimmed.is_empty() {
+        "user".into()
+    } else {
+        trimmed
+    }
+}
+
+/// Validate a repo/handle name: lowercase alnum, dash, underscore, dot. 1..=64 chars.
+pub fn valid_name(s: &str) -> bool {
+    !s.is_empty()
+        && s.len() <= 64
+        && s.chars()
+            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
+        && s != "."
+        && s != ".."
+}
diff --git a/src/web.rs b/src/web.rs
new file mode 100644
index 0000000..f7a5929
--- /dev/null
+++ b/src/web.rs
@@ -0,0 +1,583 @@
+use crate::auth::{self, User, SESSION_COOKIE};
+use crate::repos::{self, Repo};
+use crate::util::valid_name;
+use crate::AppState;
+use axum::extract::{Form, Path, Query, State};
+use axum::http::{header, HeaderMap, StatusCode};
+use axum::response::{Html, IntoResponse, Redirect, Response};
+use maud::{html, Markup, PreEscaped, DOCTYPE};
+use serde::Deserialize;
+
+fn layout(title: &str, user: &Option<User>, body: Markup) -> Markup {
+    html! {
+        (DOCTYPE)
+        html lang="ja" {
+            head {
+                meta charset="utf-8";
+                meta name="viewport" content="width=device-width, initial-scale=1";
+                title { (title) " · Okibi" }
+                style { (PreEscaped(CSS)) }
+            }
+            body {
+                header.top {
+                    a.brand href="/" { "🔥 Okibi" }
+                    nav {
+                        @if let Some(u) = user {
+                            a href="/new" { "+ 新規リポジトリ" }
+                            a href={"/" (u.handle)} { "@" (u.handle) }
+                            @if u.is_admin != 0 { a href="/members" { "メンバー" } }
+                            a href="/settings" { "設定" }
+                            form method="post" action="/logout" style="display:inline" {
+                                button.link type="submit" { "ログアウト" }
+                            }
+                        } @else {
+                            a href="/login" { "ログイン" }
+                        }
+                    }
+                }
+                main { (body) }
+                footer { "焚き火(atsm)の仲間だけの Git · " a href="https://atsm.wtf" { "atsm.wtf" } }
+            }
+        }
+    }
+}
+
+async fn current(state: &AppState, headers: &HeaderMap) -> Option<User> {
+    auth::current_user(&state.db, headers).await
+}
+
+// GET /
+pub async fn index(State(state): State<AppState>, headers: HeaderMap) -> Response {
+    let user = current(&state, &headers).await;
+    let repos = repos::list_visible(&state.db, user.as_ref()).await;
+    let body = html! {
+        h1 { "リポジトリ" }
+        @if repos.is_empty() {
+            p.muted { "まだ何もない。" a href="/new" { "最初の一本を建てる" } "。" }
+        }
+        ul.repolist {
+            @for (r, owner) in &repos {
+                li {
+                    a.repo href={"/" (owner) "/" (r.name)} { (owner) " / " strong { (r.name) } }
+                    @if !r.is_public() { span.badge { "private" } }
+                    @if !r.description.is_empty() { p.muted { (r.description) } }
+                }
+            }
+        }
+    };
+    Html(layout("home", &user, body).into_string()).into_response()
+}
+
+// GET /{owner}
+pub async fn user_view(
+    State(state): State<AppState>,
+    Path(owner): Path<String>,
+    headers: HeaderMap,
+) -> Response {
+    let viewer = current(&state, &headers).await;
+    let owner_user = match auth::user_by_handle(&state.db, &owner).await {
+        Some(u) => u,
+        None => return error_page(&viewer, "ユーザーが見つかりません。"),
+    };
+    let is_self = viewer.as_ref().map(|v| v.id == owner_user.id).unwrap_or(false);
+    let repos = repos::list_repos_for_owner(&state.db, owner_user.id).await;
+    let body = html! {
+        h1 { "@" (owner_user.handle)
+            @if !owner_user.display_name.is_empty() { " " span.muted { (owner_user.display_name) } } }
+        ul.repolist {
+            @for r in &repos {
+                @if r.is_public() || is_self {
+                    li {
+                        a.repo href={"/" (owner) "/" (r.name)} { strong { (r.name) } }
+                        @if !r.is_public() { span.badge { "private" } }
+                        @if !r.description.is_empty() { p.muted { (r.description) } }
+                    }
+                }
+            }
+        }
+    };
+    Html(layout(&format!("@{owner}"), &viewer, body).into_string()).into_response()
+}
+
+// GET /login
+pub async fn login_form(State(state): State<AppState>, headers: HeaderMap) -> Response {
+    let user = current(&state, &headers).await;
+    if user.is_some() {
+        return Redirect::to("/").into_response();
+    }
+    let body = html! {
+        h1 { "ログイン" }
+        p.muted { "atsm の焚き火に参加しているメールアドレスへ、ログインリンクを送ります。" }
+        form method="post" action="/login" class="card" {
+            label { "メールアドレス" }
+            input type="email" name="email" required placeholder="you@example.com";
+            button type="submit" { "ログインリンクを送る" }
+        }
+    };
+    Html(layout("ログイン", &None, body).into_string()).into_response()
+}
+
+#[derive(Deserialize)]
+pub struct LoginInput {
+    email: String,
+}
+
+// POST /login
+pub async fn login_submit(
+    State(state): State<AppState>,
+    Form(input): Form<LoginInput>,
+) -> Response {
+    let email = input.email.trim().to_lowercase();
+    let is_member = auth::member(&state.db, &email).await.is_some();
+    let msg = if is_member {
+        match auth::create_magic_link(&state.db, &email).await {
+            Ok(token) => {
+                let link = format!("{}/auth/verify?token={}", state.cfg.base_url, token);
+                crate::email::send_magic_link(&state.cfg, &email, &link).await;
+                "メールを確認してください。ログインリンクを送りました(15分有効)。"
+            }
+            Err(_) => "エラーが発生しました。もう一度お試しください。",
+        }
+    } else {
+        "このメールアドレスは焚き火のメンバーに登録されていません。"
+    };
+    let body = html! {
+        h1 { "ログイン" }
+        p.card { (msg) }
+        p { a href="/login" { "戻る" } }
+    };
+    Html(layout("ログイン", &None, body).into_string()).into_response()
+}
+
+#[derive(Deserialize)]
+pub struct VerifyQuery {
+    token: String,
+}
+
+// GET /auth/verify?token=...
+pub async fn verify(
+    State(state): State<AppState>,
+    Query(q): Query<VerifyQuery>,
+) -> Response {
+    let email = match auth::consume_magic_link(&state.db, &q.token).await {
+        Some(e) => e,
+        None => {
+            let body = html! { h1 { "リンク無効" } p { "期限切れか使用済みです。" a href="/login" { "再送" } } };
+            return Html(layout("無効", &None, body).into_string()).into_response();
+        }
+    };
+    let user = match auth::upsert_user(&state.db, &email).await {
+        Ok(u) => u,
+        Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "user error").into_response(),
+    };
+    let token = match auth::create_session(&state.db, user.id).await {
+        Ok(t) => t,
+        Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "session error").into_response(),
+    };
+    redirect_with_cookie("/", &set_cookie(&state, &token, false))
+}
+
+#[derive(Deserialize)]
+pub struct BootstrapQuery {
+    token: String,
+}
+
+// GET /auth/bootstrap?token=...  — no-email admin login (env-gated).
+pub async fn bootstrap(
+    State(state): State<AppState>,
+    Query(q): Query<BootstrapQuery>,
+) -> Response {
+    match &state.cfg.admin_bootstrap {
+        Some(t) if !t.is_empty() && t == &q.token => {}
+        _ => return (StatusCode::NOT_FOUND, "not found\n").into_response(),
+    }
+    let email = match state.cfg.seed_members.first() {
+        Some(e) => e.clone(),
+        None => return (StatusCode::INTERNAL_SERVER_ERROR, "no seed admin\n").into_response(),
+    };
+    let _ = auth::add_member(&state.db, &email, true).await;
+    let user = match auth::upsert_user(&state.db, &email).await {
+        Ok(u) => u,
+        Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "user error\n").into_response(),
+    };
+    let token = match auth::create_session(&state.db, user.id).await {
+        Ok(t) => t,
+        Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "session error\n").into_response(),
+    };
+    redirect_with_cookie("/", &set_cookie(&state, &token, false))
+}
+
+// GET /members — admin: allowlist + invite link generator.
+pub async fn members_page(State(state): State<AppState>, headers: HeaderMap) -> Response {
+    let user = match current(&state, &headers).await {
+        Some(u) if u.is_admin != 0 => u,
+        Some(u) => return error_page(&Some(u), "管理者のみ。"),
+        None => return Redirect::to("/login").into_response(),
+    };
+    let members = auth::list_members(&state.db).await;
+    let body = html! {
+        h1 { "メンバー(焚き火の allowlist)" }
+        section.card {
+            h2 { "招待リンクを発行" }
+            p.muted { "メールアドレスを allowlist に追加し、ログイン用リンクを発行します。焚き火やDMで本人に渡してください(15分有効)。" }
+            form method="post" action="/members/invite" class="row" {
+                input type="email" name="email" placeholder="member@example.com" required;
+                label.row { input type="checkbox" name="admin" value="1"; " admin" }
+                button type="submit" { "発行" }
+            }
+        }
+        section.card {
+            h2 { "現在のメンバー" }
+            ul {
+                @for (email, is_admin) in &members {
+                    li { code { (email) } @if *is_admin { span.badge { "admin" } } }
+                }
+            }
+        }
+    };
+    Html(layout("メンバー", &Some(user), body).into_string()).into_response()
+}
+
+#[derive(Deserialize)]
+pub struct InviteInput {
+    email: String,
+    #[serde(default)]
+    admin: Option<String>,
+}
+
+// POST /members/invite — admin only.
+pub async fn invite_submit(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(input): Form<InviteInput>,
+) -> Response {
+    let user = match current(&state, &headers).await {
+        Some(u) if u.is_admin != 0 => u,
+        Some(u) => return error_page(&Some(u), "管理者のみ。"),
+        None => return Redirect::to("/login").into_response(),
+    };
+    let email = input.email.trim().to_lowercase();
+    if email.is_empty() || !email.contains('@') {
+        return error_page(&Some(user), "メールアドレスが不正です。");
+    }
+    if auth::add_member(&state.db, &email, input.admin.is_some())
+        .await
+        .is_err()
+    {
+        return error_page(&Some(user), "追加に失敗しました。");
+    }
+    let token = match auth::create_magic_link(&state.db, &email).await {
+        Ok(t) => t,
+        Err(_) => return error_page(&Some(user), "リンク発行に失敗しました。"),
+    };
+    let link = format!("{}/auth/verify?token={}", state.cfg.base_url, token);
+    let body = html! {
+        h1 { "招待リンクを発行しました" }
+        div.card {
+            p { code { (email) } " を allowlist に追加しました。下のリンクを本人に渡してください(15分有効・1回限り)。" }
+            pre.token { (link) }
+        }
+        p { a href="/members" { "メンバーへ戻る" } }
+    };
+    Html(layout("招待", &Some(user), body).into_string()).into_response()
+}
+
+// POST /logout
+pub async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Response {
+    if let Some(tok) = auth::cookie(&headers, SESSION_COOKIE) {
+        auth::destroy_session(&state.db, &tok).await;
+    }
+    redirect_with_cookie("/", &set_cookie(&state, "", true))
+}
+
+// GET /new
+pub async fn new_repo_form(State(state): State<AppState>, headers: HeaderMap) -> Response {
+    let user = match current(&state, &headers).await {
+        Some(u) => u,
+        None => return Redirect::to("/login").into_response(),
+    };
+    let body = html! {
+        h1 { "新規リポジトリ" }
+        form method="post" action="/new" class="card" {
+            label { "名前" }
+            input type="text" name="name" required placeholder="my-project"
+                pattern="[A-Za-z0-9_.-]+";
+            label { "説明(任意)" }
+            input type="text" name="description" placeholder="一言で";
+            label.row { input type="checkbox" name="public" value="1"; " 公開(チェックなし=private)" }
+            button type="submit" { "作成" }
+        }
+        p.muted { "作成後、" code { "@" (user.handle) } " 直下に置かれます。" }
+    };
+    Html(layout("新規", &Some(user), body).into_string()).into_response()
+}
+
+#[derive(Deserialize)]
+pub struct NewRepoInput {
+    name: String,
+    #[serde(default)]
+    description: String,
+    #[serde(default)]
+    public: Option<String>,
+}
+
+// POST /new
+pub async fn new_repo_submit(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(input): Form<NewRepoInput>,
+) -> Response {
+    let user = match current(&state, &headers).await {
+        Some(u) => u,
+        None => return Redirect::to("/login").into_response(),
+    };
+    let name = input.name.trim();
+    if !valid_name(name) {
+        return error_page(&Some(user), "名前が不正です(英数字 . _ - のみ)。");
+    }
+    if repos::get_repo(&state.db, user.id, name).await.is_some() {
+        return error_page(&Some(user), "同名のリポジトリが既にあります。");
+    }
+    let vis = if input.public.is_some() { "public" } else { "private" };
+    match repos::create_repo(&state.db, &state.cfg, &user, name, vis, input.description.trim()).await {
+        Ok(_) => Redirect::to(&format!("/{}/{}", user.handle, name)).into_response(),
+        Err(e) => error_page(&Some(user), &format!("作成に失敗: {e}")),
+    }
+}
+
+// GET /settings
+pub async fn settings(State(state): State<AppState>, headers: HeaderMap) -> Response {
+    let user = match current(&state, &headers).await {
+        Some(u) => u,
+        None => return Redirect::to("/login").into_response(),
+    };
+    let pats = sqlx::query_as::<_, (i64, String, i64)>(
+        "SELECT id, name, created_at FROM pats WHERE user_id = ? ORDER BY created_at DESC",
+    )
+    .bind(user.id)
+    .fetch_all(&state.db)
+    .await
+    .unwrap_or_default();
+    let body = html! {
+        h1 { "設定" }
+        section.card {
+            h2 { "アクセストークン (PAT)" }
+            p.muted { "git の push/pull で、パスワード代わりに使います。ユーザー名は " code { (user.handle) } "。" }
+            form method="post" action="/settings/tokens" class="row" {
+                input type="text" name="name" placeholder="トークン名(例: laptop)" required;
+                button type="submit" { "発行" }
+            }
+            @if !pats.is_empty() {
+                ul {
+                    @for (_, name, _) in &pats {
+                        li { code { (name) } }
+                    }
+                }
+            }
+        }
+    };
+    Html(layout("設定", &Some(user), body).into_string()).into_response()
+}
+
+#[derive(Deserialize)]
+pub struct NewTokenInput {
+    name: String,
+}
+
+// POST /settings/tokens
+pub async fn new_token(
+    State(state): State<AppState>,
+    headers: HeaderMap,
+    Form(input): Form<NewTokenInput>,
+) -> Response {
+    let user = match current(&state, &headers).await {
+        Some(u) => u,
+        None => return Redirect::to("/login").into_response(),
+    };
+    let token = match auth::create_pat(&state.db, user.id, input.name.trim()).await {
+        Ok(t) => t,
+        Err(_) => return error_page(&Some(user), "発行に失敗しました。"),
+    };
+    let body = html! {
+        h1 { "トークンを発行しました" }
+        div.card {
+            p { "この値は二度と表示されません。今すぐコピーしてください。" }
+            pre.token { (token) }
+            p.muted { "使い方:" }
+            pre { (format!("git clone {}/{}/REPO.git", state.cfg.base_url, user.handle)) "\n"
+                  "Username: " (user.handle) "\n"
+                  "Password: <このトークン>" }
+        }
+        p { a href="/settings" { "設定へ戻る" } }
+    };
+    Html(layout("トークン", &Some(user), body).into_string()).into_response()
+}
+
+#[derive(Deserialize)]
+pub struct BrowseQuery {
+    #[serde(default)]
+    p: String,
+}
+
+// GET /{owner}/{repo}
+pub async fn repo_view(
+    State(state): State<AppState>,
+    Path((owner, repo)): Path<(String, String)>,
+    Query(q): Query<BrowseQuery>,
+    headers: HeaderMap,
+) -> Response {
+    let viewer = current(&state, &headers).await;
+    let owner_user = match auth::user_by_handle(&state.db, &owner).await {
+        Some(u) => u,
+        None => return error_page(&viewer, "ユーザーが見つかりません。"),
+    };
+    let r = match repos::get_repo(&state.db, owner_user.id, &repo).await {
+        Some(r) => r,
+        None => return error_page(&viewer, "リポジトリが見つかりません。"),
+    };
+    // visibility gate
+    if !r.is_public() && viewer.as_ref().map(|v| v.id != owner_user.id).unwrap_or(true) {
+        return error_page(&viewer, "このリポジトリは非公開です。");
+    }
+
+    let clone_url = format!("{}/{}/{}.git", state.cfg.base_url, owner, repo);
+    let empty = repos::is_empty(&state.cfg, &owner, &repo);
+
+    let body = if empty {
+        empty_repo_view(&owner, &r, &clone_url, viewer.as_ref().map(|u| u.handle.as_str()).unwrap_or(&owner))
+    } else {
+        browse_view(&state, &owner, &r, &q.p, &clone_url).await
+    };
+    Html(layout(&format!("{owner}/{}", r.name), &viewer, body).into_string()).into_response()
+}
+
+fn repo_header(owner: &str, r: &Repo, clone_url: &str) -> Markup {
+    html! {
+        h1 { a href={"/" (owner)} { (owner) } " / " (r.name)
+             @if !r.is_public() { span.badge { "private" } } }
+        @if !r.description.is_empty() { p.muted { (r.description) } }
+        div.clonebar { code { (format!("git clone {clone_url}")) } }
+    }
+}
+
+fn empty_repo_view(owner: &str, r: &Repo, clone_url: &str, handle: &str) -> Markup {
+    html! {
+        (repo_header(owner, r, clone_url))
+        div.card {
+            h2 { "最初の push" }
+            pre {
+(format!(
+"git init
+git add .
+git commit -m \"first fire\"
+git branch -M main
+git remote add origin {clone_url}
+git push -u origin main
+
+# Username: {handle}
+# Password: <設定で発行した PAT>"))
+            }
+        }
+    }
+}
+
+async fn browse_view(state: &AppState, owner: &str, r: &Repo, path: &str, clone_url: &str) -> Markup {
+    let rev = &r.default_branch;
+    // file?
+    if !path.is_empty() {
+        if let Some(bytes) = repos::read_blob(&state.cfg, owner, &r.name, rev, path) {
+            let is_text = std::str::from_utf8(&bytes).is_ok() && bytes.len() < 512 * 1024;
+            return html! {
+                (repo_header(owner, r, clone_url))
+                (breadcrumb(owner, &r.name, path))
+                @if is_text {
+                    pre.code { (String::from_utf8_lossy(&bytes)) }
+                } @else {
+                    p.muted { "バイナリまたは大きすぎるファイル(" (bytes.len()) " bytes)。" }
+                }
+            };
+        }
+    }
+    let entries = repos::ls_tree(&state.cfg, owner, &r.name, rev, path);
+    let head = repos::head_commit(&state.cfg, owner, &r.name, rev);
+    html! {
+        (repo_header(owner, r, clone_url))
+        @if let Some((sha, subject, _)) = &head {
+            p.muted { "最新: " code { (&sha[..sha.len().min(8)]) } " " (subject) }
+        }
+        (breadcrumb(owner, &r.name, path))
+        table.tree {
+            @for (_mode, ty, name) in &entries {
+                tr {
+                    td {
+                        @let child = if path.is_empty() { name.clone() } else { format!("{path}/{name}") };
+                        @if ty == "tree" {
+                            a href={"/" (owner) "/" (r.name) "?p=" (urlencode(&child))} { "📁 " (name) }
+                        } @else {
+                            a href={"/" (owner) "/" (r.name) "?p=" (urlencode(&child))} { "📄 " (name) }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+fn breadcrumb(owner: &str, repo: &str, path: &str) -> Markup {
+    let segs: Vec<&str> = if path.is_empty() {
+        vec![]
+    } else {
+        path.split('/').collect()
+    };
+    html! {
+        p.crumb {
+            a href={"/" (owner) "/" (repo)} { (repo) }
+            @for (i, seg) in segs.iter().enumerate() {
+                " / "
+                @let acc = segs[..=i].join("/");
+                a href={"/" (owner) "/" (repo) "?p=" (urlencode(&acc))} { (seg) }
+            }
+        }
+    }
+}
+
+fn error_page(user: &Option<User>, msg: &str) -> Response {
+    let body = html! { h1 { "エラー" } p.card { (msg) } p { a href="/" { "ホームへ" } } };
+    (StatusCode::NOT_FOUND, Html(layout("エラー", user, body).into_string())).into_response()
+}
+
+fn urlencode(s: &str) -> String {
+    let mut out = String::new();
+    for b in s.bytes() {
+        match b {
+            b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'/' => out.push(b as char),
+            _ => out.push_str(&format!("%{b:02X}")),
+        }
+    }
+    out
+}
+
+// ---- cookie helpers ----
+
+fn set_cookie(state: &AppState, value: &str, clear: bool) -> String {
+    let secure = state.cfg.base_url.starts_with("https");
+    let max_age = if clear { 0 } else { 60 * 60 * 24 * 30 };
+    let mut c = format!(
+        "{SESSION_COOKIE}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}"
+    );
+    if secure {
+        c.push_str("; Secure");
+    }
+    c
+}
+
+fn redirect_with_cookie(to: &str, cookie: &str) -> Response {
+    (
+        StatusCode::SEE_OTHER,
+        [(header::LOCATION, to.to_string()), (header::SET_COOKIE, cookie.to_string())],
+        "",
+    )
+        .into_response()
+}
+
+const CSS: &str = include_str!("style.css");
diff --git a/tasks/todo.md b/tasks/todo.md
new file mode 100644
index 0000000..36a2f46
--- /dev/null
+++ b/tasks/todo.md
@@ -0,0 +1,36 @@
+# Okibi — 焚き火(atsm)会員だけの自前 Git ホスティング
+
+> 言葉が、残る。焚き火の仲間だけが安全に push できる、モダンな自作 GitHub。
+> Stack: Rust + axum + sqlx(SQLite) + maud。git smart-HTTP は git-http-backend(CGI) に委譲。
+
+## 決定事項(2026-06-10)
+- 本体: 完全自作(Rust/axum)
+- 認証: 自前 magic-link(メール)+ **atsm 会員 allowlist** ゲート
+  - 理由: atsm.wtf に OIDC 無し / members API は表示名のみ → 純正 SSO は kenny 側依存。allowlist で疎結合に。
+  - upgrade path: atsm.wtf が /api/me or OIDC を出したら差し替え
+- git: smart-HTTP を `git-http-backend` に委譲(pack 自前実装しない)
+- CI: v1 は `.okibi/ci.yml` を push 時に実行(最小ランナー)。隔離は後追い。
+- ホスト: Fly.io + Volume(`/data`)
+
+## フェーズ
+- [x] P0 scaffold: Cargo + axum + health + SQLite 初期化
+- [ ] P1 認証: magic-link 発行/検証 + セッション + atsm allowlist + PAT 発行
+- [ ] P2 リポ: 作成/一覧 + ディスクに bare repo init
+- [ ] P3 **git smart-HTTP push/pull**(CGI bridge + 認証ゲート)★コア
+- [ ] P4 Web UI: リポ一覧 / ファイルツリー / blob 表示(モダン最小)
+- [ ] P5 CI: post-receive → ci.yml 実行 → ログ保存/表示
+- [ ] P6 Fly デプロイ(git push → Actions、Volume、Resend secret)
+
+## v1 完了の定義(検証)
+1. allowlist のメールで magic-link ログインできる / 非会員は弾かれる
+2. ブラウザでリポ作成 → PAT 発行
+3. `git clone https://<handle>:<PAT>@okibi/<handle>/<repo>.git` → commit → push → pull が通る
+4. push した内容がブラウザのファイルツリーで見える
+5. `.okibi/ci.yml` の script が走りログが残る
+
+## ローカル検証コマンド
+```
+cargo build
+cargo run                       # :8787
+# 別シェルで git push/pull E2E(scripts/e2e.sh)
+```