From 271cfda84cf5d0805c97ff0cad2322022620e120 Mon Sep 17 00:00:00 2001 From: Matthew L McPeak Date: Wed, 17 Jun 2026 21:46:25 -0400 Subject: [PATCH] Initial Commit --- .env.example | 8 + .forgejo/workflows/ci.yml | 96 + .gitignore | 19 + Cargo.lock | 2956 ++++++++++++++ Cargo.toml | 32 + Dockerfile | 33 + README.md | 174 + dev.sh | 15 + docker-compose.dev.yml | 4 + docker-compose.yml | 34 + .../2026-06-16-mercury-implementation.md | 3438 +++++++++++++++++ .../plans/2026-06-17-security-fixes.md | 1072 +++++ .../specs/2026-06-16-mercury-design.md | 323 ++ entrypoint.sh | 20 + pics/Mercury-Login.png | Bin 0 -> 127837 bytes pics/Mercury.png | Bin 0 -> 73056 bytes src/auth/middleware.rs | 117 + src/auth/mod.rs | 123 + src/cache/mod.rs | 2 + src/cache/sweep.rs | 15 + src/config.rs | 57 + src/db/migrations/001_initial.sql | 48 + .../002_blacklist_system_tables.sql | 9 + .../003_blacklist_bypass_permission.sql | 32 + .../migrations/004_blacklist_method_text.sql | 2 + src/db/migrations/005_api_keys.sql | 12 + .../006_seed_api_keys_blacklist.sql | 5 + src/db/mod.rs | 2 + src/db/pool.rs | 11 + src/main.rs | 186 + src/models/api_key.rs | 27 + src/models/blacklist.rs | 30 + src/models/mod.rs | 5 + src/models/permission.rs | 24 + src/models/query.rs | 28 + src/models/user.rs | 34 + src/routes/admin/api_keys.rs | 103 + src/routes/admin/cache.rs | 24 + src/routes/admin/mod.rs | 59 + src/routes/admin/queries.rs | 85 + src/routes/admin/tables.rs | 242 ++ src/routes/auth.rs | 36 + src/routes/crud.rs | 428 ++ src/routes/mod.rs | 7 + src/state.rs | 255 ++ ui/.gitignore | 11 + ui/bun.lock | 230 ++ ui/index.html | 13 + ui/package.json | 23 + ui/public/favicon.svg | 13 + ui/src/App.vue | 3 + ui/src/assets/main.css | 252 ++ ui/src/env.d.ts | 7 + ui/src/main.ts | 16 + ui/src/router/index.ts | 36 + ui/src/stores/auth.ts | 70 + ui/src/stores/theme.ts | 24 + ui/src/views/Login.vue | 173 + ui/src/views/admin/ApiKeys.vue | 245 ++ ui/src/views/admin/Blacklist.vue | 232 ++ ui/src/views/admin/Cache.vue | 154 + ui/src/views/admin/Layout.vue | 265 ++ ui/src/views/admin/MethodSelect.vue | 122 + ui/src/views/admin/Permissions.vue | 147 + ui/src/views/admin/Queries.vue | 137 + ui/src/views/admin/Tables.vue | 565 +++ ui/src/views/admin/Users.vue | 198 + ui/tsconfig.json | 16 + ui/vite.config.d.ts | 3 + ui/vite.config.d.ts.map | 1 + ui/vite.config.js | 16 + ui/vite.config.js.map | 1 + ui/vite.config.ts | 29 + 73 files changed, 13234 insertions(+) create mode 100644 .env.example create mode 100644 .forgejo/workflows/ci.yml create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 Dockerfile create mode 100644 README.md create mode 100755 dev.sh create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml create mode 100644 docs/superpowers/plans/2026-06-16-mercury-implementation.md create mode 100644 docs/superpowers/plans/2026-06-17-security-fixes.md create mode 100644 docs/superpowers/specs/2026-06-16-mercury-design.md create mode 100644 entrypoint.sh create mode 100644 pics/Mercury-Login.png create mode 100644 pics/Mercury.png create mode 100644 src/auth/middleware.rs create mode 100644 src/auth/mod.rs create mode 100644 src/cache/mod.rs create mode 100644 src/cache/sweep.rs create mode 100644 src/config.rs create mode 100644 src/db/migrations/001_initial.sql create mode 100644 src/db/migrations/002_blacklist_system_tables.sql create mode 100644 src/db/migrations/003_blacklist_bypass_permission.sql create mode 100644 src/db/migrations/004_blacklist_method_text.sql create mode 100644 src/db/migrations/005_api_keys.sql create mode 100644 src/db/migrations/006_seed_api_keys_blacklist.sql create mode 100644 src/db/mod.rs create mode 100644 src/db/pool.rs create mode 100644 src/main.rs create mode 100644 src/models/api_key.rs create mode 100644 src/models/blacklist.rs create mode 100644 src/models/mod.rs create mode 100644 src/models/permission.rs create mode 100644 src/models/query.rs create mode 100644 src/models/user.rs create mode 100644 src/routes/admin/api_keys.rs create mode 100644 src/routes/admin/cache.rs create mode 100644 src/routes/admin/mod.rs create mode 100644 src/routes/admin/queries.rs create mode 100644 src/routes/admin/tables.rs create mode 100644 src/routes/auth.rs create mode 100644 src/routes/crud.rs create mode 100644 src/routes/mod.rs create mode 100644 src/state.rs create mode 100644 ui/.gitignore create mode 100644 ui/bun.lock create mode 100644 ui/index.html create mode 100644 ui/package.json create mode 100644 ui/public/favicon.svg create mode 100644 ui/src/App.vue create mode 100644 ui/src/assets/main.css create mode 100644 ui/src/env.d.ts create mode 100644 ui/src/main.ts create mode 100644 ui/src/router/index.ts create mode 100644 ui/src/stores/auth.ts create mode 100644 ui/src/stores/theme.ts create mode 100644 ui/src/views/Login.vue create mode 100644 ui/src/views/admin/ApiKeys.vue create mode 100644 ui/src/views/admin/Blacklist.vue create mode 100644 ui/src/views/admin/Cache.vue create mode 100644 ui/src/views/admin/Layout.vue create mode 100644 ui/src/views/admin/MethodSelect.vue create mode 100644 ui/src/views/admin/Permissions.vue create mode 100644 ui/src/views/admin/Queries.vue create mode 100644 ui/src/views/admin/Tables.vue create mode 100644 ui/src/views/admin/Users.vue create mode 100644 ui/tsconfig.json create mode 100644 ui/vite.config.d.ts create mode 100644 ui/vite.config.d.ts.map create mode 100644 ui/vite.config.js create mode 100644 ui/vite.config.js.map create mode 100644 ui/vite.config.ts diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6fa6df3 --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +DATABASE_URL=postgres://mercury:mercury@db:5432/mercury +JWT_SECRET=change_me_in_production +JWT_EXPIRY_SECS=3600 +CACHE_MAX_CAPACITY=10000 +CACHE_IDLE_TIMEOUT_SECS=300 +CACHE_SWEEP_INTERVAL_SECS=60 +# Comma-separated allowed CORS origins, or * for permissive. Empty = no CORS headers. +CORS_ORIGINS= diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..59e96c3 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,96 @@ +name: ci + +on: + push: + tags: + - "v*" + pull_request: + +jobs: + test: + runs-on: self-hosted + container: git.mcpeakdev.com/mcpeakdev/rust-ci:latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Cache cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Check formatting + run: cargo fmt --check + + - name: Clippy + run: cargo clippy -- -D warnings + + - name: Run tests + run: cargo test + + build-ui: + runs-on: self-hosted + container: git.mcpeakdev.com/mcpeakdev/bun-ci:latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install dependencies + run: | + printf '@nychthemeron:registry=https://git.mcpeakdev.com/api/packages/McPeakDev/npm/\n//git.mcpeakdev.com/api/packages/McPeakDev/npm/:_authToken=%s\n' "$BUN_AUTH_TOKEN" > .npmrc + bun install --frozen-lockfile + rm .npmrc + working-directory: ui + env: + BUN_AUTH_TOKEN: ${{ secrets.BUN_AUTH_TOKEN }} + + - name: Audit dependencies + run: bun audit || true + working-directory: ui + + - name: Type check + run: bun run build + working-directory: ui + + publish: + needs: [test, build-ui] + if: startsWith(github.ref, 'refs/tags/v') + runs-on: self-hosted + container: git.mcpeakdev.com/mcpeakdev/docker-pub:latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to registry + uses: docker/login-action@v3 + with: + registry: git.mcpeakdev.com + username: ${{ github.actor }} + password: ${{ secrets.BUN_AUTH_TOKEN }} + + - name: Extract image tag + id: meta + run: echo "tag=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + file: Dockerfile + push: true + tags: | + git.mcpeakdev.com/mcpeakdev/mercury:${{ steps.meta.outputs.tag }} + git.mcpeakdev.com/mcpeakdev/mercury:latest + cache-from: type=registry,ref=git.mcpeakdev.com/mcpeakdev/mercury:latest + cache-to: type=inline + secrets: | + bun_auth_token=${{ secrets.BUN_AUTH_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b00613b --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# ---> Rust +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..1d0d68c --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2956 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower 0.5.3", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bcrypt" +version = "0.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e65938ed058ef47d92cf8b346cc76ef48984572ade631927e9937b5ffc7662c7" +dependencies = [ + "base64 0.22.1", + "blowfish", + "getrandom 0.2.17", + "subtle", + "zeroize", +] + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +dependencies = [ + "serde_core", +] + +[[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 = "blowfish" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" +dependencies = [ + "byteorder", + "cipher", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +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 = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.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 = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid", + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", + "subtle", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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 = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "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.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hkdf" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" +dependencies = [ + "hmac", +] + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.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", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.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 = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.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", + "serde", + "serde_core", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +dependencies = [ + "bitflags", + "libc", + "plain", + "redox_syscall 0.8.1", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf4e226dcd58b4be396f7bd3c20da8fdee2911400705297ba7d2d7cc2c30f716" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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 = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mercury" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "bcrypt", + "chrono", + "dashmap", + "dotenvy", + "glob", + "http-body-util", + "jsonwebtoken", + "serde", + "serde_json", + "sha2", + "sqlx", + "tokio", + "tower 0.4.13", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[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 = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[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 = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pem" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] + +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.118", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.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 = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_syscall" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b44b894f2a6e36457d665d1e08c3866add6ed5e70050c1b4ba8a8ddedb02ce7" +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 = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core", + "signature", + "spki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "ring", + "rustls-webpki", + "sct", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "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 = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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 2.0.118", +] + +[[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 = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "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 = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "digest", + "rand_core", +] + +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror 2.0.18", + "time", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[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 = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der", +] + +[[package]] +name = "sqlformat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" +dependencies = [ + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9a2ccff1a000a5a59cd33da541d9f2fdcd9e6e8229cc200565942bff36d0aaa" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24ba59a9342a3d9bab6c56c118be528b27c9b60e490080e9711a04dccac83ef6" +dependencies = [ + "ahash", + "atoi", + "byteorder", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashlink", + "hex", + "indexmap", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls", + "rustls-pemfile", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots", +] + +[[package]] +name = "sqlx-macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea40e2345eb2faa9e1e5e326db8c34711317d2b5e08d0d5741619048a803127" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 1.0.109", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5833ef53aaa16d860e92123292f1f6a3d53c34ba8b1969f152ef1a7bb803f3c8" +dependencies = [ + "dotenvy", + "either", + "heck 0.4.1", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 1.0.109", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ed31390216d20e538e447a7a9b959e06ed9fc51c37b514b46eb758016ecd418" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags", + "byteorder", + "bytes", + "chrono", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c824eb80b894f926f89a0b9da0c7f435d27cdd35b8c655b114e58223918577e" +dependencies = [ + "atoi", + "base64 0.21.7", + "bitflags", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b244ef0a8414da0bed4bb1910426e890b19e5e9bccc27ada6b797d05c55ae0aa" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "sqlx-core", + "tracing", + "url", + "urlencoding", + "uuid", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stringprep" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4df3d392d81bd458a8a621b8bffbd2302a12ffe288a9d931670948749463b1" +dependencies = [ + "unicode-bidi", + "unicode-normalization", + "unicode-properties", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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 2.0.118", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711a53c2d47bbd818258c498c8dbfe186a2526c631495cfe7e078567f86b8469" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.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 2.0.118", +] + +[[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.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8fa9be0de6cf49e536ce1851f987bd21a43b771b09473c3549a6c853db37c1c" +dependencies = [ + "futures-core", + "futures-util", + "pin-project", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[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.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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 = "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-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[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", +] + +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.118", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm", + "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_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.118", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.118", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.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 2.0.118", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[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..8656b47 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "mercury" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "mercury" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.7", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +jsonwebtoken = "9" +bcrypt = "0.15" +dashmap = "5" +glob = "0.3" +tower-http = { version = "0.5", features = ["fs", "cors"] } +tower = { version = "0.4", features = ["util"] } +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +dotenvy = "0.15" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +sha2 = "0.10" + +[dev-dependencies] +tower = { version = "0.4", features = ["util"] } +http-body-util = "0.1" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2bca97c --- /dev/null +++ b/Dockerfile @@ -0,0 +1,33 @@ +# Stage 1: Build Vue UI +FROM oven/bun:1-alpine AS ui-builder +WORKDIR /ui +COPY ui/ . +RUN --mount=type=secret,id=bun_auth_token \ + printf '//git.mcpeakdev.com/api/packages/McPeakDev/npm/:_authToken=%s\n@nychthemeron:registry=https://git.mcpeakdev.com/api/packages/McPeakDev/npm/\n' \ + "$(cat /run/secrets/bun_auth_token)" > .npmrc && \ + bun install --frozen-lockfile +RUN bun run build + +# Stage 2: Build Rust API +FROM rust:1.96-slim AS api-builder +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY src/ src/ +RUN cargo build --release + +# Stage 3: Final image +FROM postgres:16 +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* + +ENV PGDATA=/var/lib/postgresql/data +ENV DATABASE_URL=postgres://mercury:mercury@127.0.0.1/mercury + +WORKDIR /app +COPY --from=api-builder /app/target/release/mercury . +COPY --from=ui-builder /ui/dist ./ui/dist +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +EXPOSE 3000 +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..2781a6a --- /dev/null +++ b/README.md @@ -0,0 +1,174 @@ +# Mercury + +A high-performance, monolithic Rust API with a dynamic CRUD engine, query registry cache, JWT bitmask permissions, and a Vue 3 admin frontend. + +![Login](pics/Mercury-Login.png) +![Permissions](pics/Mercury.png) + +## Quick Start + +```bash +cp .env.example .env # set JWT_SECRET +docker compose up --build +``` + +API: http://localhost:3000/api +Admin UI: http://localhost:3000 +Default credentials: `admin` / `admin` + +--- + +## API Contract + +### Authentication + +``` +POST /auth/login +Body: { "username": "...", "password": "..." } +Returns: { "token": "" } +``` + +All admin routes require `Authorization: Bearer `. + +--- + +### CRUD — Dynamic Table Access + +Requests are mapped to the named PostgreSQL table. The SQL is generated, cached, and executed automatically. + +``` +GET /api/{table} List all rows (supports ?col=val filters) +GET /api/{table}/{id} Get row by id +POST /api/{table} Insert row (JSON body) +PUT /api/{table}/{id} Update row by id (JSON body) +DELETE /api/{table}/{id} Delete row by id +``` + +**Notes:** +- `users` and `permissions` tables are blacklisted from public CRUD — use the admin suite. +- Filters are ANDed together: `GET /api/orders?status=open&priority=high` + +--- + +### Admin — Query Registry + +Requires JWT with `ADMIN_QUERY` permission (bit 8). + +``` +GET /admin/queries List all registered queries +POST /admin/queries Register a raw SQL template +GET /admin/queries/{identifier} Get query by slug +PUT /admin/queries/{identifier} Update SQL template or description +DELETE /admin/queries/{identifier} Remove query (evicts from cache) +GET /admin/queries/{identifier}/execute Execute query with ?param=val bindings +``` + +SQL templates use `:param_name` placeholders: +```sql +SELECT * FROM orders WHERE user_id = :user_id AND status = :status +``` + +--- + +### Admin — Cache + +Requires JWT with `ADMIN_CACHE` permission (bit 16). + +``` +GET /admin/cache/stats Cache size, hit count, miss count +DELETE /admin/cache Flush entire cache +``` + +--- + +### Admin — Users + +Requires JWT with `SUPER_ADMIN` permission (bit 32). + +``` +GET /admin/users List users +POST /admin/users Create user +GET /admin/users/{id} Get user +PUT /admin/users/{id} Update user +DELETE /admin/users/{id} Delete user +POST /admin/users/{id}/permissions/grant/{bit} OR bit into permissions mask +DELETE /admin/users/{id}/permissions/revoke/{bit} AND NOT bit from permissions mask +``` + +--- + +### Admin — Permissions + +Requires JWT with `SUPER_ADMIN` permission (bit 32). + +``` +GET /admin/permissions List permission definitions +POST /admin/permissions Create custom permission (auto-assigns next bit) +PUT /admin/permissions/{id} Update name/description +DELETE /admin/permissions/{id} Remove permission +``` + +--- + +### Admin — Route Blacklist + +Requires JWT with `SUPER_ADMIN` permission (bit 32). Changes take effect immediately in memory. + +``` +GET /admin/blacklist List all entries +POST /admin/blacklist Add glob pattern +PUT /admin/blacklist/{id} Update entry (set active: false to disable) +DELETE /admin/blacklist/{id} Remove entry +``` + +Pattern syntax: `*` matches one path segment, `**` matches many. +Example: `/api/sensitive/**` blocks all methods under that path. + +--- + +## Permission Bitmask + +| Name | Bit | Value | +|-------------|-----|-------| +| READ | 0 | 1 | +| WRITE | 1 | 2 | +| DELETE | 2 | 4 | +| ADMIN_QUERY | 3 | 8 | +| ADMIN_CACHE | 4 | 16 | +| SUPER_ADMIN | 5 | 32 | + +Custom permissions are added via the admin suite and assigned the next available power-of-2 bit. Masks support up to 128 bits (u128). + +--- + +## Configuration + +| Variable | Default | Description | +|---|---|---| +| `DATABASE_URL` | required | PostgreSQL connection string | +| `JWT_SECRET` | required | HMAC-HS256 signing secret | +| `JWT_EXPIRY_SECS` | 3600 | Token lifetime in seconds | +| `CACHE_MAX_CAPACITY` | 10000 | Max query templates in memory | +| `CACHE_IDLE_TIMEOUT_SECS` | 300 | Evict after N seconds idle | +| `CACHE_SWEEP_INTERVAL_SECS` | 60 | Sweep interval for eviction task | + +--- + +## Development + +```bash +# API only (requires local Postgres) +cargo run + +# UI dev server (proxies to local API) +cd ui && npm install && npm run dev + +# Full stack +docker compose up --build +``` + +## Stack + +- **API:** Rust, Axum, SQLx, PostgreSQL, DashMap, jsonwebtoken, bcrypt +- **Frontend:** Vue 3, Vite, @nychthemeron/library (dark mode default) +- **Infra:** Docker multi-stage build, docker compose diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..b959777 --- /dev/null +++ b/dev.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd $SCRIPT_DIR/ui/ && bun i + +cd "$SCRIPT_DIR" + +cleanup() { + docker compose down +} +trap cleanup INT TERM + +docker compose up --build diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..96aa935 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,4 @@ +# Reserved for future dev overrides. +# Port mapping omitted — dev.sh connects to the container's internal IP directly +# to avoid Docker Desktop WSL2 port-forwarding limitations. +services: {} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d80bc76 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,34 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: mercury + POSTGRES_PASSWORD: mercury + POSTGRES_DB: mercury + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mercury"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: . + ports: + - "3000:3000" + environment: + DATABASE_URL: postgres://mercury:mercury@db:5432/mercury + JWT_SECRET: ${JWT_SECRET:-change_me_in_production} + JWT_EXPIRY_SECS: ${JWT_EXPIRY_SECS:-3600} + CACHE_MAX_CAPACITY: ${CACHE_MAX_CAPACITY:-10000} + CACHE_IDLE_TIMEOUT_SECS: ${CACHE_IDLE_TIMEOUT_SECS:-300} + CACHE_SWEEP_INTERVAL_SECS: ${CACHE_SWEEP_INTERVAL_SECS:-60} + depends_on: + db: + condition: service_healthy + mem_limit: 512m + mem_reservation: 256m + +volumes: + postgres_data: diff --git a/docs/superpowers/plans/2026-06-16-mercury-implementation.md b/docs/superpowers/plans/2026-06-16-mercury-implementation.md new file mode 100644 index 0000000..98a116f --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-mercury-implementation.md @@ -0,0 +1,3438 @@ +# Mercury Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build Mercury — a monolithic Rust API with dynamic CRUD, a JWT bitmask permission system, a query registry cache, an admin suite, and a Vue 3 frontend — runnable via a single `docker compose up`. + +**Architecture:** Axum handles HTTP routing behind two middleware layers: a glob-based blacklist check and a JWT permission-bit validator. The CRUD engine auto-generates parameterized SQL from route + HTTP method, caches templates in a DashMap with TTI eviction, and persists named queries in PostgreSQL. Vue 3 is built by Vite and served as static files by `tower-http::ServeDir`. + +**Tech Stack:** Rust 1.75+, Axum 0.7, SQLx 0.7, PostgreSQL 16, DashMap 5, glob 0.3, jsonwebtoken 9, bcrypt 0.15, Vue 3, Vite, @nychthemeron/library, tower-http 0.5, Docker multi-stage build. + +--- + +## File Map + +``` +Mercury/ + Cargo.toml + .env.example + Dockerfile + docker-compose.yml + README.md + src/ + main.rs startup: config, pool, caches, router, static files + config.rs Config struct from env vars + state.rs AppState, QueryCache, BlacklistCache types + auth/ + mod.rs JWT Claims, encode/decode helpers + middleware.rs RequirePermission extractor + cache/ + mod.rs pub use re-exports + query_cache.rs DashMap + sweep task + hit/miss counters + blacklist_cache.rs Arc>> + reload fn + db/ + mod.rs pub use re-exports + pool.rs PgPool init + run migrations + migrations/ + 001_initial.sql schema, seed permissions, seed blacklist + models/ + mod.rs pub use re-exports + user.rs User, CreateUser, UpdateUser + permission.rs Permission, CreatePermission, UpdatePermission + query.rs StoredQuery, CreateQuery, UpdateQuery + blacklist.rs BlacklistEntry, CreateBlacklistEntry, UpdateBlacklistEntry + routes/ + mod.rs pub use re-exports + auth.rs POST /auth/login + crud.rs generic /api/{table} + query builder + row→JSON + admin/ + mod.rs admin Router assembly + queries.rs query registry CRUD + cache.rs cache stats + flush + users.rs user management + grant/revoke + permissions.rs permission definitions CRUD + blacklist.rs blacklist CRUD + trigger reload + ui/ + .npmrc + package.json + vite.config.ts + src/ + main.ts + App.vue + router/index.ts + stores/auth.ts + views/ + Login.vue + admin/ + Layout.vue + Queries.vue + Users.vue + Permissions.vue + Blacklist.vue + Cache.vue + tests/ + integration/ + auth_test.rs + crud_test.rs + admin_test.rs +``` + +--- + +## Task 1: Cargo.toml + project skeleton + +**Files:** +- Create: `Cargo.toml` +- Create: `src/main.rs` (stub) + +- [ ] **Step 1: Write Cargo.toml** + +```toml +[package] +name = "mercury" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "mercury" +path = "src/main.rs" + +[dependencies] +axum = { version = "0.7", features = ["macros"] } +tokio = { version = "1", features = ["full"] } +sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono", "migrate"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +jsonwebtoken = "9" +bcrypt = "0.15" +dashmap = "5" +glob = "0.3" +tower-http = { version = "0.5", features = ["fs", "cors"] } +tower = { version = "0.4", features = ["util"] } +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +dotenvy = "0.15" +anyhow = "1" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +tower = { version = "0.4", features = ["util"] } +http-body-util = "0.1" +``` + +- [ ] **Step 2: Write src/main.rs stub** + +```rust +#[tokio::main] +async fn main() { + println!("Mercury starting..."); +} +``` + +- [ ] **Step 3: Verify it compiles** + +```bash +cargo build +``` +Expected: `Compiling mercury v0.1.0` then `Finished`. + +- [ ] **Step 4: Commit** + +```bash +git add Cargo.toml src/main.rs +git commit -m "chore: init Mercury project scaffold" +``` + +--- + +## Task 2: Config + +**Files:** +- Create: `src/config.rs` +- Create: `.env.example` + +- [ ] **Step 1: Write failing test** + +In `src/config.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_defaults() { + std::env::set_var("DATABASE_URL", "postgres://test"); + std::env::set_var("JWT_SECRET", "secret"); + let cfg = Config::from_env().unwrap(); + assert_eq!(cfg.jwt_expiry_secs, 3600); + assert_eq!(cfg.cache_max_capacity, 10_000); + assert_eq!(cfg.cache_idle_timeout_secs, 300); + assert_eq!(cfg.cache_sweep_interval_secs, 60); + } +} +``` + +- [ ] **Step 2: Run test — expect compile failure** + +```bash +cargo test config +``` +Expected: `error[E0433]: failed to resolve: use of undeclared crate or module` + +- [ ] **Step 3: Implement Config** + +```rust +use anyhow::Result; + +#[derive(Clone, Debug)] +pub struct Config { + pub database_url: String, + pub jwt_secret: String, + pub jwt_expiry_secs: u64, + pub cache_max_capacity: usize, + pub cache_idle_timeout_secs: u64, + pub cache_sweep_interval_secs: u64, +} + +impl Config { + pub fn from_env() -> Result { + Ok(Self { + database_url: std::env::var("DATABASE_URL")?, + jwt_secret: std::env::var("JWT_SECRET")?, + jwt_expiry_secs: std::env::var("JWT_EXPIRY_SECS") + .unwrap_or_else(|_| "3600".into()) + .parse()?, + cache_max_capacity: std::env::var("CACHE_MAX_CAPACITY") + .unwrap_or_else(|_| "10000".into()) + .parse()?, + cache_idle_timeout_secs: std::env::var("CACHE_IDLE_TIMEOUT_SECS") + .unwrap_or_else(|_| "300".into()) + .parse()?, + cache_sweep_interval_secs: std::env::var("CACHE_SWEEP_INTERVAL_SECS") + .unwrap_or_else(|_| "60".into()) + .parse()?, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_defaults() { + std::env::set_var("DATABASE_URL", "postgres://test"); + std::env::set_var("JWT_SECRET", "secret"); + let cfg = Config::from_env().unwrap(); + assert_eq!(cfg.jwt_expiry_secs, 3600); + assert_eq!(cfg.cache_max_capacity, 10_000); + assert_eq!(cfg.cache_idle_timeout_secs, 300); + assert_eq!(cfg.cache_sweep_interval_secs, 60); + } +} +``` + +- [ ] **Step 4: Add to main.rs and run test** + +Add `mod config;` to `src/main.rs`. Then: + +```bash +cargo test config +``` +Expected: `test config::tests::test_config_defaults ... ok` + +- [ ] **Step 5: Write .env.example** + +```env +DATABASE_URL=postgres://mercury:mercury@db:5432/mercury +JWT_SECRET=change_me_in_production +JWT_EXPIRY_SECS=3600 +CACHE_MAX_CAPACITY=10000 +CACHE_IDLE_TIMEOUT_SECS=300 +CACHE_SWEEP_INTERVAL_SECS=60 +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/config.rs src/main.rs .env.example +git commit -m "feat: add Config struct loaded from env vars" +``` + +--- + +## Task 3: Models + +**Files:** +- Create: `src/models/mod.rs` +- Create: `src/models/user.rs` +- Create: `src/models/permission.rs` +- Create: `src/models/query.rs` +- Create: `src/models/blacklist.rs` + +- [ ] **Step 1: Write src/models/user.rs** + +```rust +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct User { + pub id: i32, + pub username: String, + #[serde(skip_serializing)] + pub password_hash: String, + pub permissions_mask: String, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateUser { + pub username: String, + pub password: String, + pub permissions_mask: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateUser { + pub username: Option, + pub password: Option, + pub permissions_mask: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} +``` + +- [ ] **Step 2: Write src/models/permission.rs** + +```rust +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Permission { + pub id: i32, + pub name: String, + pub bit_value: String, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreatePermission { + pub name: String, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdatePermission { + pub name: Option, + pub description: Option, +} +``` + +- [ ] **Step 3: Write src/models/query.rs** + +```rust +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct StoredQuery { + pub id: Uuid, + pub identifier: String, + pub sql_template: String, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateQuery { + pub identifier: String, + pub sql_template: String, + pub description: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateQuery { + pub sql_template: Option, + pub description: Option, +} +``` + +- [ ] **Step 4: Write src/models/blacklist.rs** + +```rust +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct BlacklistEntry { + pub id: i32, + pub pattern: String, + pub method: Option, + pub reason: Option, + pub active: bool, + pub created_at: DateTime, +} + +#[derive(Debug, Deserialize)] +pub struct CreateBlacklistEntry { + pub pattern: String, + pub method: Option, + pub reason: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateBlacklistEntry { + pub pattern: Option, + pub method: Option, + pub reason: Option, + pub active: Option, +} +``` + +- [ ] **Step 5: Write src/models/mod.rs** + +```rust +pub mod blacklist; +pub mod permission; +pub mod query; +pub mod user; +``` + +- [ ] **Step 6: Add mod to main.rs and compile** + +Add `mod models;` to `src/main.rs`. + +```bash +cargo build +``` +Expected: `Finished` with no errors. + +- [ ] **Step 7: Commit** + +```bash +git add src/models/ +git commit -m "feat: add domain models for user, permission, query, blacklist" +``` + +--- + +## Task 4: Database pool + migration + +**Files:** +- Create: `src/db/mod.rs` +- Create: `src/db/pool.rs` +- Create: `src/db/migrations/001_initial.sql` + +- [ ] **Step 1: Write src/db/migrations/001_initial.sql** + +```sql +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + permissions_mask TEXT NOT NULL DEFAULT '0', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE permissions ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) UNIQUE NOT NULL, + bit_value TEXT UNIQUE NOT NULL, + description TEXT +); + +CREATE TABLE queries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + identifier VARCHAR(255) UNIQUE NOT NULL, + sql_template TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE blacklist ( + id SERIAL PRIMARY KEY, + pattern VARCHAR(500) NOT NULL, + method VARCHAR(10), + reason TEXT, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Seed permissions +INSERT INTO permissions (name, bit_value, description) VALUES + ('READ', '1', 'Can read via CRUD endpoints'), + ('WRITE', '2', 'Can insert/update via CRUD'), + ('DELETE', '4', 'Can delete via CRUD'), + ('ADMIN_QUERY', '8', 'Can manage query registry'), + ('ADMIN_CACHE', '16', 'Can manage cache'), + ('SUPER_ADMIN', '32', 'Full access'); + +-- Seed blacklist (users and permissions tables are admin-only) +INSERT INTO blacklist (pattern, method, reason, active) VALUES + ('/api/users/**', NULL, 'admin-only table', true), + ('/api/permissions/**', NULL, 'admin-only table', true); +``` + +- [ ] **Step 2: Write src/db/pool.rs** + +```rust +use anyhow::Result; +use sqlx::{postgres::PgPoolOptions, PgPool}; + +pub async fn create_pool(database_url: &str) -> Result { + let pool = PgPoolOptions::new() + .max_connections(10) + .connect(database_url) + .await?; + sqlx::migrate!("src/db/migrations").run(&pool).await?; + Ok(pool) +} +``` + +- [ ] **Step 3: Write src/db/mod.rs** + +```rust +pub mod pool; +pub use pool::create_pool; +``` + +- [ ] **Step 4: Add mod to main.rs and compile** + +Add `mod db;` to `src/main.rs`. + +```bash +cargo build +``` +Expected: `Finished` with no errors. + +- [ ] **Step 5: Commit** + +```bash +git add src/db/ +git commit -m "feat: add database pool and initial migration" +``` + +--- + +## Task 5: AppState + +**Files:** +- Create: `src/state.rs` + +- [ ] **Step 1: Write src/state.rs** + +```rust +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dashmap::DashMap; +use glob::Pattern; +use sqlx::PgPool; +use tokio::sync::RwLock; + +use crate::config::Config; +use crate::models::blacklist::BlacklistEntry; + +pub fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs() +} + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, + pub query_cache: QueryCache, + pub blacklist_cache: BlacklistCache, + pub config: Arc, +} + +#[derive(Clone)] +pub struct QueryCache { + pub map: Arc>, + pub hits: Arc, + pub misses: Arc, +} + +#[derive(Clone, Debug)] +pub struct CacheEntry { + pub sql: String, + pub param_order: Vec, + pub last_accessed_secs: Arc, +} + +impl CacheEntry { + pub fn new(sql: String, param_order: Vec) -> Self { + Self { + sql, + param_order, + last_accessed_secs: Arc::new(AtomicU64::new(unix_now())), + } + } + + pub fn touch(&self) { + self.last_accessed_secs.store(unix_now(), Ordering::Relaxed); + } + + pub fn last_accessed(&self) -> u64 { + self.last_accessed_secs.load(Ordering::Relaxed) + } +} + +impl QueryCache { + pub fn new() -> Self { + Self { + map: Arc::new(DashMap::new()), + hits: Arc::new(AtomicU64::new(0)), + misses: Arc::new(AtomicU64::new(0)), + } + } + + pub fn get(&self, key: &str) -> Option { + if let Some(entry) = self.map.get(key) { + entry.touch(); + self.hits.fetch_add(1, Ordering::Relaxed); + Some(entry.clone()) + } else { + self.misses.fetch_add(1, Ordering::Relaxed); + None + } + } + + pub fn insert(&self, key: String, entry: CacheEntry, max_capacity: usize) { + if self.map.len() >= max_capacity { + let oldest_key = self + .map + .iter() + .min_by_key(|e| e.last_accessed()) + .map(|e| e.key().clone()); + if let Some(k) = oldest_key { + self.map.remove(&k); + } + } + self.map.insert(key, entry); + } + + pub fn remove(&self, key: &str) { + self.map.remove(key); + } + + pub fn hits(&self) -> u64 { + self.hits.load(Ordering::Relaxed) + } + + pub fn misses(&self) -> u64 { + self.misses.load(Ordering::Relaxed) + } + + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn flush(&self) { + self.map.clear(); + } +} + +#[derive(Clone)] +pub struct BlacklistCache { + pub inner: Arc>>, +} + +#[derive(Clone)] +pub struct CompiledEntry { + pub entry: BlacklistEntry, + pub pattern: Pattern, +} + +impl BlacklistCache { + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(Vec::new())), + } + } + + pub async fn load(&self, entries: Vec) { + let compiled: Vec = entries + .into_iter() + .filter_map(|e| { + Pattern::new(&e.pattern) + .ok() + .map(|pattern| CompiledEntry { entry: e, pattern }) + }) + .collect(); + let mut guard = self.inner.write().await; + *guard = compiled; + } + + pub async fn is_blocked(&self, method: &str, path: &str) -> bool { + let guard = self.inner.read().await; + guard.iter().any(|compiled| { + if !compiled.entry.active { + return false; + } + let method_matches = compiled + .entry + .method + .as_deref() + .map(|m| m.eq_ignore_ascii_case(method)) + .unwrap_or(true); + method_matches && compiled.pattern.matches(path) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cache_insert_and_get() { + let cache = QueryCache::new(); + let entry = CacheEntry::new("SELECT 1".into(), vec![]); + cache.insert("key1".into(), entry, 100); + let got = cache.get("key1"); + assert!(got.is_some()); + assert_eq!(got.unwrap().sql, "SELECT 1"); + assert_eq!(cache.hits(), 1); + assert_eq!(cache.misses(), 0); + } + + #[test] + fn test_cache_miss() { + let cache = QueryCache::new(); + let got = cache.get("missing"); + assert!(got.is_none()); + assert_eq!(cache.misses(), 1); + } + + #[test] + fn test_cache_capacity_evicts_oldest() { + let cache = QueryCache::new(); + let e1 = CacheEntry::new("SELECT 1".into(), vec![]); + // force e1 to be older + e1.last_accessed_secs.store(1, Ordering::Relaxed); + cache.map.insert("old".into(), e1); + let e2 = CacheEntry::new("SELECT 2".into(), vec![]); + cache.insert("new".into(), e2, 1); // capacity=1, should evict "old" + assert!(cache.map.get("old").is_none()); + assert!(cache.map.get("new").is_some()); + } + + #[tokio::test] + async fn test_blacklist_blocks_pattern() { + use chrono::Utc; + let cache = BlacklistCache::new(); + let entry = BlacklistEntry { + id: 1, + pattern: "/api/users/**".into(), + method: None, + reason: None, + active: true, + created_at: Utc::now(), + }; + cache.load(vec![entry]).await; + assert!(cache.is_blocked("GET", "/api/users/42").await); + assert!(!cache.is_blocked("GET", "/api/orders/1").await); + } + + #[tokio::test] + async fn test_blacklist_method_specific() { + use chrono::Utc; + let cache = BlacklistCache::new(); + let entry = BlacklistEntry { + id: 2, + pattern: "/api/secrets".into(), + method: Some("GET".into()), + reason: None, + active: true, + created_at: Utc::now(), + }; + cache.load(vec![entry]).await; + assert!(cache.is_blocked("GET", "/api/secrets").await); + assert!(!cache.is_blocked("POST", "/api/secrets").await); + } +} +``` + +- [ ] **Step 2: Add mod to main.rs** + +Add `mod state;` to `src/main.rs`. + +- [ ] **Step 3: Run tests** + +```bash +cargo test state +``` +Expected: 4 tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/state.rs src/main.rs +git commit -m "feat: add AppState with QueryCache and BlacklistCache" +``` + +--- + +## Task 6: Cache sweep background task + +**Files:** +- Create: `src/cache/mod.rs` +- Create: `src/cache/sweep.rs` + +- [ ] **Step 1: Write src/cache/sweep.rs** + +```rust +use std::time::Duration; +use crate::state::QueryCache; + +pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interval_secs: u64) { + tokio::spawn(async move { + let interval = Duration::from_secs(sweep_interval_secs); + loop { + tokio::time::sleep(interval).await; + let now = crate::state::unix_now(); + cache.map.retain(|_, entry| { + now.saturating_sub(entry.last_accessed()) < idle_timeout_secs + }); + } + }); +} +``` + +- [ ] **Step 2: Write src/cache/mod.rs** + +```rust +pub mod sweep; +pub use sweep::spawn_sweep_task; +``` + +- [ ] **Step 3: Add mod to main.rs and compile** + +Add `mod cache;` to `src/main.rs`. + +```bash +cargo build +``` +Expected: `Finished` with no errors. + +- [ ] **Step 4: Commit** + +```bash +git add src/cache/ +git commit -m "feat: add cache sweep background task for TTI eviction" +``` + +--- + +## Task 7: JWT auth module + +**Files:** +- Create: `src/auth/mod.rs` + +- [ ] **Step 1: Write failing tests** + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_decode_roundtrip() { + let secret = "test_secret"; + let token = encode_jwt("alice", 63u128, secret, 3600).unwrap(); + let claims = decode_jwt(&token, secret).unwrap(); + assert_eq!(claims.sub, "alice"); + assert_eq!(claims.permissions_mask(), 63u128); + } + + #[test] + fn test_expired_token_rejected() { + let secret = "test_secret"; + let token = encode_jwt("alice", 1u128, secret, 0).unwrap(); + // exp=0 means already expired + let result = decode_jwt(&token, secret); + assert!(result.is_err()); + } +} +``` + +- [ ] **Step 2: Run test — expect compile failure** + +```bash +cargo test auth +``` + +- [ ] **Step 3: Implement src/auth/mod.rs** + +```rust +pub mod middleware; + +use anyhow::Result; +use chrono::Utc; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; + +pub mod permissions { + pub const READ: u128 = 1; + pub const WRITE: u128 = 2; + pub const DELETE: u128 = 4; + pub const ADMIN_QUERY: u128 = 8; + pub const ADMIN_CACHE: u128 = 16; + pub const SUPER_ADMIN: u128 = 32; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, + pub permissions: String, // u128 stored as decimal string + pub exp: usize, +} + +impl Claims { + pub fn permissions_mask(&self) -> u128 { + self.permissions.parse().unwrap_or(0) + } + + pub fn has_permission(&self, bit: u128) -> bool { + self.permissions_mask() & bit != 0 + } +} + +pub fn encode_jwt(username: &str, mask: u128, secret: &str, expiry_secs: u64) -> Result { + let exp = (Utc::now().timestamp() as u64 + expiry_secs) as usize; + let claims = Claims { + sub: username.to_string(), + permissions: mask.to_string(), + exp, + }; + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + )?; + Ok(token) +} + +pub fn decode_jwt(token: &str, secret: &str) -> Result { + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::default(), + )?; + Ok(data.claims) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_decode_roundtrip() { + let secret = "test_secret"; + let token = encode_jwt("alice", 63u128, secret, 3600).unwrap(); + let claims = decode_jwt(&token, secret).unwrap(); + assert_eq!(claims.sub, "alice"); + assert_eq!(claims.permissions_mask(), 63u128); + } + + #[test] + fn test_has_permission() { + let claims = Claims { + sub: "bob".into(), + permissions: "9".into(), // READ (1) + ADMIN_QUERY (8) + exp: 9999999999, + }; + assert!(claims.has_permission(permissions::READ)); + assert!(claims.has_permission(permissions::ADMIN_QUERY)); + assert!(!claims.has_permission(permissions::SUPER_ADMIN)); + } +} +``` + +- [ ] **Step 4: Create stub for middleware (needed for mod declaration)** + +Create `src/auth/middleware.rs` as an empty stub: + +```rust +// filled in Task 8 +``` + +- [ ] **Step 5: Add mod to main.rs and run tests** + +Add `mod auth;` to `src/main.rs`. + +```bash +cargo test auth::tests +``` +Expected: 2 tests pass (`test_encode_decode_roundtrip`, `test_has_permission`). + +- [ ] **Step 6: Commit** + +```bash +git add src/auth/ +git commit -m "feat: add JWT encode/decode with u128 bitmask permissions" +``` + +--- + +## Task 8: Auth middleware + blacklist middleware + +**Files:** +- Modify: `src/auth/middleware.rs` + +- [ ] **Step 1: Write src/auth/middleware.rs** + +```rust +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, +}; + +use crate::{auth::decode_jwt, state::AppState}; + +pub async fn blacklist_layer( + State(state): State, + req: Request, + next: Next, +) -> Result { + let method = req.method().as_str().to_uppercase(); + let path = req.uri().path().to_string(); + if state.blacklist_cache.is_blocked(&method, &path).await { + return Err(StatusCode::FORBIDDEN); + } + Ok(next.run(req).await) +} + +pub fn extract_bearer(req: &Request) -> Option { + req.headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|s| s.to_string()) +} + +pub async fn require_auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + let claims = decode_jwt(&token, &state.config.jwt_secret) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + req.extensions_mut().insert(claims); + Ok(next.run(req).await) +} + +pub async fn require_super_admin( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + let claims = decode_jwt(&token, &state.config.jwt_secret) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + if !claims.has_permission(crate::auth::permissions::SUPER_ADMIN) { + return Err(StatusCode::FORBIDDEN); + } + req.extensions_mut().insert(claims); + Ok(next.run(req).await) +} + +pub async fn require_admin_query( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + let claims = decode_jwt(&token, &state.config.jwt_secret) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + if !claims.has_permission(crate::auth::permissions::ADMIN_QUERY) { + return Err(StatusCode::FORBIDDEN); + } + req.extensions_mut().insert(claims); + Ok(next.run(req).await) +} + +pub async fn require_admin_cache( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + let claims = decode_jwt(&token, &state.config.jwt_secret) + .map_err(|_| StatusCode::UNAUTHORIZED)?; + if !claims.has_permission(crate::auth::permissions::ADMIN_CACHE) { + return Err(StatusCode::FORBIDDEN); + } + req.extensions_mut().insert(claims); + Ok(next.run(req).await) +} +``` + +- [ ] **Step 2: Compile** + +```bash +cargo build +``` +Expected: `Finished` with no errors. + +- [ ] **Step 3: Commit** + +```bash +git add src/auth/middleware.rs +git commit -m "feat: add blacklist and JWT permission middlewares" +``` + +--- + +## Task 9: Auth login route + +**Files:** +- Create: `src/routes/mod.rs` +- Create: `src/routes/auth.rs` + +- [ ] **Step 1: Write src/routes/auth.rs** + +```rust +use axum::{extract::State, http::StatusCode, Json}; +use serde_json::{json, Value}; + +use crate::{ + auth::encode_jwt, + models::user::LoginRequest, + state::AppState, +}; + +pub async fn login( + State(state): State, + Json(body): Json, +) -> Result, StatusCode> { + let user = sqlx::query_as!( + crate::models::user::User, + "SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE username = $1", + body.username + ) + .fetch_optional(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + let valid = bcrypt::verify(&body.password, &user.password_hash) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if !valid { + return Err(StatusCode::UNAUTHORIZED); + } + + let mask: u128 = user.permissions_mask.parse().unwrap_or(0); + let token = encode_jwt(&user.username, mask, &state.config.jwt_secret, state.config.jwt_expiry_secs) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(json!({ "token": token }))) +} +``` + +- [ ] **Step 2: Write src/routes/mod.rs** + +```rust +pub mod admin; +pub mod auth; +pub mod crud; +``` + +- [ ] **Step 3: Create stub files to satisfy mod declarations** + +Create `src/routes/crud.rs`: +```rust +// filled in Task 10 +``` + +Create `src/routes/admin/mod.rs`: +```rust +// filled in Task 15 +``` + +- [ ] **Step 4: Add mod to main.rs and compile** + +Add `mod routes;` to `src/main.rs`. + +```bash +cargo build +``` +Expected: `Finished`. + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/ +git commit -m "feat: add /auth/login route with bcrypt verification" +``` + +--- + +## Task 10: CRUD route + query builder + +**Files:** +- Modify: `src/routes/crud.rs` + +- [ ] **Step 1: Write failing test** + +In `src/routes/crud.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_select_all() { + let (sql, params, key) = build_query("GET", "orders", None, &[], &[]).unwrap(); + assert_eq!(sql, "SELECT * FROM orders"); + assert!(params.is_empty()); + assert_eq!(key, "GET:orders:"); + } + + #[test] + fn test_build_select_by_id() { + let (sql, params, key) = build_query("GET", "orders", Some("42"), &[], &[]).unwrap(); + assert_eq!(sql, "SELECT * FROM orders WHERE id = $1"); + assert_eq!(params, vec!["42".to_string()]); + assert_eq!(key, "GET:orders:by_id"); + } + + #[test] + fn test_build_insert() { + let cols = vec![("email".into(), "a@b.com".into()), ("name".into(), "Alice".into())]; + let (sql, params, key) = build_query("POST", "users", None, &cols, &[]).unwrap(); + assert_eq!(sql, "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *"); + assert_eq!(params, vec!["a@b.com", "Alice"]); + assert_eq!(key, "POST:users:email,name"); + } + + #[test] + fn test_build_update() { + let cols = vec![("name".into(), "Bob".into())]; + let (sql, params, key) = build_query("PUT", "users", Some("7"), &cols, &[]).unwrap(); + assert_eq!(sql, "UPDATE users SET name = $1 WHERE id = $2 RETURNING *"); + assert_eq!(params, vec!["Bob", "7"]); + assert_eq!(key, "PUT:users:name:by_id"); + } + + #[test] + fn test_build_delete() { + let (sql, params, key) = build_query("DELETE", "users", Some("3"), &[], &[]).unwrap(); + assert_eq!(sql, "DELETE FROM users WHERE id = $1"); + assert_eq!(params, vec!["3"]); + assert_eq!(key, "DELETE:users:by_id"); + } + + #[test] + fn test_build_select_with_filters() { + let filters = vec![("status".into(), "active".into()), ("role".into(), "admin".into())]; + let (sql, params, key) = build_query("GET", "users", None, &[], &filters).unwrap(); + assert_eq!(sql, "SELECT * FROM users WHERE role = $1 AND status = $2"); + assert_eq!(params, vec!["admin", "active"]); + assert_eq!(key, "GET:users:role,status"); + } + + #[test] + fn test_rejects_invalid_table_name() { + let result = build_query("GET", "users; DROP TABLE users--", None, &[], &[]); + assert!(result.is_err()); + } +} +``` + +- [ ] **Step 2: Run test — expect compile failure** + +```bash +cargo test crud +``` + +- [ ] **Step 3: Implement src/routes/crud.rs** + +```rust +use anyhow::{anyhow, Result}; +use axum::{ + extract::{Path, Query, State}, + http::{Method, StatusCode}, + Json, +}; +use serde_json::Value; +use sqlx::postgres::PgRow; +use sqlx::Row; +use std::collections::HashMap; + +use crate::state::{AppState, CacheEntry}; + +fn validate_identifier(name: &str) -> Result<()> { + if name.chars().all(|c| c.is_alphanumeric() || c == '_') { + Ok(()) + } else { + Err(anyhow!("invalid identifier: {}", name)) + } +} + +/// Returns (sql, ordered_param_values, cache_key) +/// body_cols: sorted (col, val) pairs from request body +/// filter_cols: sorted (col, val) pairs from query params +pub fn build_query( + method: &str, + table: &str, + id: Option<&str>, + body_cols: &[(String, String)], + filter_cols: &[(String, String)], +) -> Result<(String, Vec, String)> { + validate_identifier(table)?; + for (col, _) in body_cols.iter().chain(filter_cols.iter()) { + validate_identifier(col)?; + } + + let mut sorted_body = body_cols.to_vec(); + sorted_body.sort_by(|a, b| a.0.cmp(&b.0)); + let mut sorted_filters = filter_cols.to_vec(); + sorted_filters.sort_by(|a, b| a.0.cmp(&b.0)); + + match method.to_uppercase().as_str() { + "GET" => { + if let Some(id_val) = id { + let sql = format!("SELECT * FROM {} WHERE id = $1", table); + let key = format!("GET:{}:by_id", table); + Ok((sql, vec![id_val.to_string()], key)) + } else if sorted_filters.is_empty() { + let sql = format!("SELECT * FROM {}", table); + let key = format!("GET:{}:", table); + Ok((sql, vec![], key)) + } else { + let col_names: Vec = sorted_filters.iter().map(|(c, _)| c.clone()).collect(); + let where_clause: Vec = col_names + .iter() + .enumerate() + .map(|(i, c)| format!("{} = ${}", c, i + 1)) + .collect(); + let sql = format!("SELECT * FROM {} WHERE {}", table, where_clause.join(" AND ")); + let params: Vec = sorted_filters.iter().map(|(_, v)| v.clone()).collect(); + let key = format!("GET:{}:{}", table, col_names.join(",")); + Ok((sql, params, key)) + } + } + "POST" => { + if sorted_body.is_empty() { + return Err(anyhow!("POST requires a body with at least one field")); + } + let cols: Vec = sorted_body.iter().map(|(c, _)| c.clone()).collect(); + let placeholders: Vec = (1..=cols.len()).map(|i| format!("${}", i)).collect(); + let sql = format!( + "INSERT INTO {} ({}) VALUES ({}) RETURNING *", + table, + cols.join(", "), + placeholders.join(", ") + ); + let params: Vec = sorted_body.iter().map(|(_, v)| v.clone()).collect(); + let key = format!("POST:{}:{}", table, cols.join(",")); + Ok((sql, params, key)) + } + "PUT" => { + let id_val = id.ok_or_else(|| anyhow!("PUT requires an id"))?; + if sorted_body.is_empty() { + return Err(anyhow!("PUT requires a body with at least one field")); + } + let cols: Vec = sorted_body.iter().map(|(c, _)| c.clone()).collect(); + let set_clause: Vec = cols + .iter() + .enumerate() + .map(|(i, c)| format!("{} = ${}", c, i + 1)) + .collect(); + let id_placeholder = cols.len() + 1; + let sql = format!( + "UPDATE {} SET {} WHERE id = ${} RETURNING *", + table, + set_clause.join(", "), + id_placeholder + ); + let mut params: Vec = sorted_body.iter().map(|(_, v)| v.clone()).collect(); + params.push(id_val.to_string()); + let key = format!("PUT:{}:{}:by_id", table, cols.join(",")); + Ok((sql, params, key)) + } + "DELETE" => { + let id_val = id.ok_or_else(|| anyhow!("DELETE requires an id"))?; + let sql = format!("DELETE FROM {} WHERE id = $1", table); + let key = format!("DELETE:{}:by_id", table); + Ok((sql, vec![id_val.to_string()], key)) + } + m => Err(anyhow!("unsupported method: {}", m)), + } +} + +pub fn pg_row_to_json(row: PgRow) -> Value { + let columns = row.columns(); + let mut map = serde_json::Map::new(); + for col in columns { + let name = col.name().to_string(); + let type_name = col.type_info().name(); + let val = match type_name { + "INT2" | "INT4" | "INT8" | "SERIAL" => row + .try_get::(col.ordinal()) + .map(|v| Value::Number(v.into())) + .unwrap_or(Value::Null), + "FLOAT4" | "FLOAT8" => row + .try_get::(col.ordinal()) + .ok() + .and_then(|v| serde_json::Number::from_f64(v)) + .map(Value::Number) + .unwrap_or(Value::Null), + "BOOL" => row + .try_get::(col.ordinal()) + .map(Value::Bool) + .unwrap_or(Value::Null), + "UUID" => row + .try_get::(col.ordinal()) + .map(|v| Value::String(v.to_string())) + .unwrap_or(Value::Null), + "TIMESTAMPTZ" | "TIMESTAMP" => row + .try_get::, _>(col.ordinal()) + .map(|v| Value::String(v.to_rfc3339())) + .unwrap_or(Value::Null), + _ => row + .try_get::(col.ordinal()) + .map(Value::String) + .unwrap_or(Value::Null), + }; + map.insert(name, val); + } + Value::Object(map) +} + +pub async fn handle_crud( + State(state): State, + method: Method, + Path(params): Path>, + Query(query_params): Query>, + body: Option>>, +) -> Result, StatusCode> { + let table = params.get("table").ok_or(StatusCode::BAD_REQUEST)?; + let id = params.get("id").map(|s| s.as_str()); + let method_str = method.as_str(); + + let body_cols: Vec<(String, String)> = body + .map(|Json(b)| { + b.into_iter() + .filter_map(|(k, v)| match v { + Value::String(s) => Some((k, s)), + Value::Number(n) => Some((k, n.to_string())), + Value::Bool(b) => Some((k, b.to_string())), + _ => None, + }) + .collect() + }) + .unwrap_or_default(); + + let filter_cols: Vec<(String, String)> = query_params.into_iter().collect(); + + let (sql, params_vals, cache_key) = + build_query(method_str, table, id, &body_cols, &filter_cols) + .map_err(|_| StatusCode::BAD_REQUEST)?; + + let cached = state.query_cache.get(&cache_key); + let _entry = if cached.is_none() { + let entry = CacheEntry::new(sql.clone(), vec![]); + state + .query_cache + .insert(cache_key, entry.clone(), state.config.cache_max_capacity); + entry + } else { + cached.unwrap() + }; + + let mut q = sqlx::query(&sql); + for val in ¶ms_vals { + q = q.bind(val.as_str()); + } + + match method_str.to_uppercase().as_str() { + "GET" => { + let rows = q + .fetch_all(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let json_rows: Vec = rows.into_iter().map(pg_row_to_json).collect(); + Ok(Json(Value::Array(json_rows))) + } + "POST" | "PUT" => { + let row = q + .fetch_one(&state.pool) + .await + .map_err(|e| { + if e.to_string().contains("no rows") { + StatusCode::NOT_FOUND + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + })?; + Ok(Json(pg_row_to_json(row))) + } + "DELETE" => { + q.execute(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(serde_json::json!({ "deleted": true }))) + } + _ => Err(StatusCode::METHOD_NOT_ALLOWED), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_select_all() { + let (sql, params, key) = build_query("GET", "orders", None, &[], &[]).unwrap(); + assert_eq!(sql, "SELECT * FROM orders"); + assert!(params.is_empty()); + assert_eq!(key, "GET:orders:"); + } + + #[test] + fn test_build_select_by_id() { + let (sql, params, key) = build_query("GET", "orders", Some("42"), &[], &[]).unwrap(); + assert_eq!(sql, "SELECT * FROM orders WHERE id = $1"); + assert_eq!(params, vec!["42".to_string()]); + assert_eq!(key, "GET:orders:by_id"); + } + + #[test] + fn test_build_insert() { + let cols = vec![("email".into(), "a@b.com".into()), ("name".into(), "Alice".into())]; + let (sql, params, key) = build_query("POST", "users", None, &cols, &[]).unwrap(); + assert_eq!(sql, "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *"); + assert_eq!(params, vec!["a@b.com", "Alice"]); + assert_eq!(key, "POST:users:email,name"); + } + + #[test] + fn test_build_update() { + let cols = vec![("name".into(), "Bob".into())]; + let (sql, params, key) = build_query("PUT", "users", Some("7"), &cols, &[]).unwrap(); + assert_eq!(sql, "UPDATE users SET name = $1 WHERE id = $2 RETURNING *"); + assert_eq!(params, vec!["Bob", "7"]); + assert_eq!(key, "PUT:users:name:by_id"); + } + + #[test] + fn test_build_delete() { + let (sql, params, key) = build_query("DELETE", "users", Some("3"), &[], &[]).unwrap(); + assert_eq!(sql, "DELETE FROM users WHERE id = $1"); + assert_eq!(params, vec!["3"]); + assert_eq!(key, "DELETE:users:by_id"); + } + + #[test] + fn test_build_select_with_filters() { + let filters = vec![("status".into(), "active".into()), ("role".into(), "admin".into())]; + let (sql, params, key) = build_query("GET", "users", None, &[], &filters).unwrap(); + assert_eq!(sql, "SELECT * FROM users WHERE role = $1 AND status = $2"); + assert_eq!(params, vec!["admin", "active"]); + assert_eq!(key, "GET:users:role,status"); + } + + #[test] + fn test_rejects_invalid_table_name() { + let result = build_query("GET", "users; DROP TABLE users--", None, &[], &[]); + assert!(result.is_err()); + } +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cargo test crud::tests +``` +Expected: 7 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/crud.rs +git commit -m "feat: add dynamic CRUD query builder with cache integration" +``` + +--- + +## Task 11: Admin routes — queries + +**Files:** +- Create: `src/routes/admin/queries.rs` + +- [ ] **Step 1: Write src/routes/admin/queries.rs** + +```rust +use axum::{ + extract::{Extension, Path, Query, State}, + http::StatusCode, + Json, +}; +use serde_json::{json, Value}; +use std::collections::HashMap; + +use crate::{ + auth::Claims, + models::query::{CreateQuery, StoredQuery, UpdateQuery}, + state::AppState, +}; + +pub async fn list_queries( + State(state): State, + Extension(_claims): Extension, +) -> Result>, StatusCode> { + let queries = sqlx::query_as!( + StoredQuery, + "SELECT id, identifier, sql_template, description, created_at, updated_at FROM queries ORDER BY created_at DESC" + ) + .fetch_all(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(queries)) +} + +pub async fn get_query( + State(state): State, + Extension(_claims): Extension, + Path(identifier): Path, +) -> Result, StatusCode> { + let q = sqlx::query_as!( + StoredQuery, + "SELECT id, identifier, sql_template, description, created_at, updated_at FROM queries WHERE identifier = $1", + identifier + ) + .fetch_optional(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + Ok(Json(q)) +} + +pub async fn create_query( + State(state): State, + Extension(_claims): Extension, + Json(body): Json, +) -> Result, StatusCode> { + let q = sqlx::query_as!( + StoredQuery, + "INSERT INTO queries (identifier, sql_template, description) VALUES ($1, $2, $3) RETURNING id, identifier, sql_template, description, created_at, updated_at", + body.identifier, + body.sql_template, + body.description + ) + .fetch_one(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(q)) +} + +pub async fn update_query( + State(state): State, + Extension(_claims): Extension, + Path(identifier): Path, + Json(body): Json, +) -> Result, StatusCode> { + let existing = sqlx::query_as!( + StoredQuery, + "SELECT id, identifier, sql_template, description, created_at, updated_at FROM queries WHERE identifier = $1", + identifier + ) + .fetch_optional(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let new_sql = body.sql_template.as_deref().unwrap_or(&existing.sql_template); + let new_desc = body.description.as_deref().or(existing.description.as_deref()); + + let q = sqlx::query_as!( + StoredQuery, + "UPDATE queries SET sql_template = $1, description = $2, updated_at = now() WHERE identifier = $3 RETURNING id, identifier, sql_template, description, created_at, updated_at", + new_sql, + new_desc, + identifier + ) + .fetch_one(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + state.query_cache.remove(&identifier); + Ok(Json(q)) +} + +pub async fn delete_query( + State(state): State, + Extension(_claims): Extension, + Path(identifier): Path, +) -> Result, StatusCode> { + sqlx::query!("DELETE FROM queries WHERE identifier = $1", identifier) + .execute(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + state.query_cache.remove(&identifier); + Ok(Json(json!({ "deleted": true }))) +} + +pub async fn execute_query( + State(state): State, + Extension(_claims): Extension, + Path(identifier): Path, + Query(params): Query>, +) -> Result, StatusCode> { + let stored = sqlx::query_as!( + StoredQuery, + "SELECT id, identifier, sql_template, description, created_at, updated_at FROM queries WHERE identifier = $1", + identifier + ) + .fetch_optional(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let mut sql = stored.sql_template.clone(); + let mut bound_vals: Vec = Vec::new(); + let mut idx = 1usize; + let mut sorted_params: Vec<(String, String)> = params.into_iter().collect(); + sorted_params.sort_by(|a, b| a.0.cmp(&b.0)); + for (name, val) in &sorted_params { + let placeholder = format!(":{}", name); + if sql.contains(&placeholder) { + sql = sql.replace(&placeholder, &format!("${}", idx)); + bound_vals.push(val.clone()); + idx += 1; + } + } + + let mut q = sqlx::query(&sql); + for val in &bound_vals { + q = q.bind(val.as_str()); + } + + let rows = q + .fetch_all(&state.pool) + .await + .map_err(|e| { + tracing::error!("query execution error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let json_rows: Vec = rows + .into_iter() + .map(crate::routes::crud::pg_row_to_json) + .collect(); + + Ok(Json(Value::Array(json_rows))) +} +``` + +- [ ] **Step 2: Compile** + +```bash +cargo build +``` +Expected: `Finished`. + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/admin/queries.rs +git commit -m "feat: add admin query registry CRUD routes" +``` + +--- + +## Task 12: Admin routes — cache, users, permissions, blacklist + +**Files:** +- Create: `src/routes/admin/cache.rs` +- Create: `src/routes/admin/users.rs` +- Create: `src/routes/admin/permissions.rs` +- Create: `src/routes/admin/blacklist.rs` +- Modify: `src/routes/admin/mod.rs` + +- [ ] **Step 1: Write src/routes/admin/cache.rs** + +```rust +use axum::{extract::State, http::StatusCode, Json}; +use serde_json::{json, Value}; + +use crate::{auth::Claims, state::AppState}; +use axum::extract::Extension; + +pub async fn cache_stats( + State(state): State, + Extension(_claims): Extension, +) -> Json { + Json(json!({ + "size": state.query_cache.len(), + "hits": state.query_cache.hits(), + "misses": state.query_cache.misses(), + })) +} + +pub async fn flush_cache( + State(state): State, + Extension(_claims): Extension, +) -> Result, StatusCode> { + state.query_cache.flush(); + Ok(Json(json!({ "flushed": true }))) +} +``` + +- [ ] **Step 2: Write src/routes/admin/users.rs** + +```rust +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + Json, +}; +use serde_json::{json, Value}; + +use crate::{ + auth::{encode_jwt, Claims}, + models::user::{CreateUser, UpdateUser, User}, + state::AppState, +}; + +pub async fn list_users( + State(state): State, + Extension(_claims): Extension, +) -> Result>, StatusCode> { + let users = sqlx::query_as!( + User, + "SELECT id, username, password_hash, permissions_mask, created_at FROM users ORDER BY id" + ) + .fetch_all(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(users)) +} + +pub async fn get_user( + State(state): State, + Extension(_claims): Extension, + Path(id): Path, +) -> Result, StatusCode> { + let user = sqlx::query_as!( + User, + "SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE id = $1", + id + ) + .fetch_optional(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + Ok(Json(user)) +} + +pub async fn create_user( + State(state): State, + Extension(_claims): Extension, + Json(body): Json, +) -> Result, StatusCode> { + let hash = bcrypt::hash(&body.password, bcrypt::DEFAULT_COST) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + let mask = body.permissions_mask.unwrap_or_else(|| "0".into()); + let user = sqlx::query_as!( + User, + "INSERT INTO users (username, password_hash, permissions_mask) VALUES ($1, $2, $3) RETURNING id, username, password_hash, permissions_mask, created_at", + body.username, + hash, + mask + ) + .fetch_one(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(user)) +} + +pub async fn update_user( + State(state): State, + Extension(_claims): Extension, + Path(id): Path, + Json(body): Json, +) -> Result, StatusCode> { + let existing = sqlx::query_as!( + User, + "SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE id = $1", + id + ) + .fetch_optional(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let new_username = body.username.as_deref().unwrap_or(&existing.username); + let new_hash = if let Some(pw) = &body.password { + bcrypt::hash(pw, bcrypt::DEFAULT_COST).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + } else { + existing.password_hash.clone() + }; + let new_mask = body.permissions_mask.as_deref().unwrap_or(&existing.permissions_mask); + + let user = sqlx::query_as!( + User, + "UPDATE users SET username = $1, password_hash = $2, permissions_mask = $3 WHERE id = $4 RETURNING id, username, password_hash, permissions_mask, created_at", + new_username, + new_hash, + new_mask, + id + ) + .fetch_one(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(user)) +} + +pub async fn delete_user( + State(state): State, + Extension(_claims): Extension, + Path(id): Path, +) -> Result, StatusCode> { + sqlx::query!("DELETE FROM users WHERE id = $1", id) + .execute(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(json!({ "deleted": true }))) +} + +pub async fn grant_permission( + State(state): State, + Extension(_claims): Extension, + Path((id, bit_value)): Path<(i32, String)>, +) -> Result, StatusCode> { + let user = sqlx::query_as!( + User, + "SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE id = $1", + id + ) + .fetch_optional(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let current: u128 = user.permissions_mask.parse().unwrap_or(0); + let bit: u128 = bit_value.parse().map_err(|_| StatusCode::BAD_REQUEST)?; + let new_mask = (current | bit).to_string(); + + let updated = sqlx::query_as!( + User, + "UPDATE users SET permissions_mask = $1 WHERE id = $2 RETURNING id, username, password_hash, permissions_mask, created_at", + new_mask, + id + ) + .fetch_one(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(updated)) +} + +pub async fn revoke_permission( + State(state): State, + Extension(_claims): Extension, + Path((id, bit_value)): Path<(i32, String)>, +) -> Result, StatusCode> { + let user = sqlx::query_as!( + User, + "SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE id = $1", + id + ) + .fetch_optional(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let current: u128 = user.permissions_mask.parse().unwrap_or(0); + let bit: u128 = bit_value.parse().map_err(|_| StatusCode::BAD_REQUEST)?; + let new_mask = (current & !bit).to_string(); + + let updated = sqlx::query_as!( + User, + "UPDATE users SET permissions_mask = $1 WHERE id = $2 RETURNING id, username, password_hash, permissions_mask, created_at", + new_mask, + id + ) + .fetch_one(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(updated)) +} +``` + +- [ ] **Step 3: Write src/routes/admin/permissions.rs** + +```rust +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + Json, +}; +use serde_json::{json, Value}; + +use crate::{ + auth::Claims, + models::permission::{CreatePermission, Permission, UpdatePermission}, + state::AppState, +}; + +pub async fn list_permissions( + State(state): State, + Extension(_claims): Extension, +) -> Result>, StatusCode> { + let perms = sqlx::query_as!( + Permission, + "SELECT id, name, bit_value, description FROM permissions ORDER BY id" + ) + .fetch_all(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(perms)) +} + +pub async fn create_permission( + State(state): State, + Extension(_claims): Extension, + Json(body): Json, +) -> Result, StatusCode> { + let existing: Vec = sqlx::query_scalar!("SELECT bit_value FROM permissions") + .fetch_all(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .into_iter() + .flatten() + .collect(); + + let next_bit = (0u128..) + .map(|i| 1u128 << i) + .find(|bit| !existing.contains(&bit.to_string())) + .ok_or(StatusCode::INSUFFICIENT_STORAGE)?; + + let perm = sqlx::query_as!( + Permission, + "INSERT INTO permissions (name, bit_value, description) VALUES ($1, $2, $3) RETURNING id, name, bit_value, description", + body.name, + next_bit.to_string(), + body.description + ) + .fetch_one(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(perm)) +} + +pub async fn update_permission( + State(state): State, + Extension(_claims): Extension, + Path(id): Path, + Json(body): Json, +) -> Result, StatusCode> { + let existing = sqlx::query_as!( + Permission, + "SELECT id, name, bit_value, description FROM permissions WHERE id = $1", + id + ) + .fetch_optional(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let new_name = body.name.as_deref().unwrap_or(&existing.name); + let new_desc = body.description.as_deref().or(existing.description.as_deref()); + + let perm = sqlx::query_as!( + Permission, + "UPDATE permissions SET name = $1, description = $2 WHERE id = $3 RETURNING id, name, bit_value, description", + new_name, + new_desc, + id + ) + .fetch_one(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(perm)) +} + +pub async fn delete_permission( + State(state): State, + Extension(_claims): Extension, + Path(id): Path, +) -> Result, StatusCode> { + sqlx::query!("DELETE FROM permissions WHERE id = $1", id) + .execute(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(json!({ "deleted": true }))) +} +``` + +- [ ] **Step 4: Write src/routes/admin/blacklist.rs** + +```rust +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + Json, +}; +use serde_json::{json, Value}; + +use crate::{ + auth::Claims, + models::blacklist::{BlacklistEntry, CreateBlacklistEntry, UpdateBlacklistEntry}, + state::AppState, +}; + +async fn reload_blacklist(state: &AppState) -> Result<(), StatusCode> { + let entries = sqlx::query_as!( + BlacklistEntry, + "SELECT id, pattern, method, reason, active, created_at FROM blacklist ORDER BY id" + ) + .fetch_all(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + state.blacklist_cache.load(entries).await; + Ok(()) +} + +pub async fn list_blacklist( + State(state): State, + Extension(_claims): Extension, +) -> Result>, StatusCode> { + let entries = sqlx::query_as!( + BlacklistEntry, + "SELECT id, pattern, method, reason, active, created_at FROM blacklist ORDER BY id" + ) + .fetch_all(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(entries)) +} + +pub async fn create_blacklist_entry( + State(state): State, + Extension(_claims): Extension, + Json(body): Json, +) -> Result, StatusCode> { + let entry = sqlx::query_as!( + BlacklistEntry, + "INSERT INTO blacklist (pattern, method, reason) VALUES ($1, $2, $3) RETURNING id, pattern, method, reason, active, created_at", + body.pattern, + body.method, + body.reason + ) + .fetch_one(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + reload_blacklist(&state).await?; + Ok(Json(entry)) +} + +pub async fn update_blacklist_entry( + State(state): State, + Extension(_claims): Extension, + Path(id): Path, + Json(body): Json, +) -> Result, StatusCode> { + let existing = sqlx::query_as!( + BlacklistEntry, + "SELECT id, pattern, method, reason, active, created_at FROM blacklist WHERE id = $1", + id + ) + .fetch_optional(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let new_pattern = body.pattern.as_deref().unwrap_or(&existing.pattern); + let new_method = body.method.as_deref().or(existing.method.as_deref()); + let new_reason = body.reason.as_deref().or(existing.reason.as_deref()); + let new_active = body.active.unwrap_or(existing.active); + + let entry = sqlx::query_as!( + BlacklistEntry, + "UPDATE blacklist SET pattern = $1, method = $2, reason = $3, active = $4 WHERE id = $5 RETURNING id, pattern, method, reason, active, created_at", + new_pattern, + new_method, + new_reason, + new_active, + id + ) + .fetch_one(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + reload_blacklist(&state).await?; + Ok(Json(entry)) +} + +pub async fn delete_blacklist_entry( + State(state): State, + Extension(_claims): Extension, + Path(id): Path, +) -> Result, StatusCode> { + sqlx::query!("DELETE FROM blacklist WHERE id = $1", id) + .execute(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + reload_blacklist(&state).await?; + Ok(Json(json!({ "deleted": true }))) +} +``` + +- [ ] **Step 5: Write src/routes/admin/mod.rs** + +```rust +pub mod blacklist; +pub mod cache; +pub mod permissions; +pub mod queries; +pub mod users; + +use axum::{ + middleware, + routing::{delete, get, post, put}, + Router, +}; + +use crate::{ + auth::middleware::{require_admin_cache, require_admin_query, require_super_admin}, + state::AppState, +}; + +pub fn admin_router(state: AppState) -> Router { + let query_routes = Router::new() + .route("/", get(queries::list_queries).post(queries::create_query)) + .route( + "/:identifier", + get(queries::get_query) + .put(queries::update_query) + .delete(queries::delete_query), + ) + .route("/:identifier/execute", get(queries::execute_query)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_query, + )); + + let cache_routes = Router::new() + .route("/stats", get(cache::cache_stats)) + .route("/", delete(cache::flush_cache)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_cache, + )); + + let super_routes = Router::new() + .route("/users", get(users::list_users).post(users::create_user)) + .route( + "/users/:id", + get(users::get_user) + .put(users::update_user) + .delete(users::delete_user), + ) + .route( + "/users/:id/permissions/grant/:bit_value", + post(users::grant_permission), + ) + .route( + "/users/:id/permissions/revoke/:bit_value", + delete(users::revoke_permission), + ) + .route( + "/permissions", + get(permissions::list_permissions).post(permissions::create_permission), + ) + .route( + "/permissions/:id", + put(permissions::update_permission).delete(permissions::delete_permission), + ) + .route( + "/blacklist", + get(blacklist::list_blacklist).post(blacklist::create_blacklist_entry), + ) + .route( + "/blacklist/:id", + put(blacklist::update_blacklist_entry).delete(blacklist::delete_blacklist_entry), + ) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_super_admin, + )); + + Router::new() + .nest("/queries", query_routes) + .nest("/cache", cache_routes) + .merge(super_routes) +} +``` + +- [ ] **Step 6: Compile** + +```bash +cargo build +``` +Expected: `Finished`. + +- [ ] **Step 7: Commit** + +```bash +git add src/routes/admin/ +git commit -m "feat: add admin routes for cache, users, permissions, and blacklist" +``` + +--- + +## Task 13: main.rs — full wiring + +**Files:** +- Modify: `src/main.rs` + +- [ ] **Step 1: Write full src/main.rs** + +```rust +mod auth; +mod cache; +mod config; +mod db; +mod models; +mod routes; +mod state; + +use std::sync::Arc; + +use axum::{middleware, routing::{delete, get, post, put}, Router}; +use tower_http::{cors::CorsLayer, services::ServeDir}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use crate::{ + auth::middleware::blacklist_layer, + cache::spawn_sweep_task, + config::Config, + db::create_pool, + models::blacklist::BlacklistEntry, + routes::{ + admin::admin_router, + auth::login, + crud::handle_crud, + }, + state::{AppState, BlacklistCache, QueryCache}, +}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenvy::dotenv().ok(); + + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "mercury=info".into())) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let config = Arc::new(Config::from_env()?); + let pool = create_pool(&config.database_url).await?; + + // Seed admin user if not present + let count: i64 = sqlx::query_scalar!("SELECT COUNT(*) FROM users WHERE username = 'admin'") + .fetch_one(&pool) + .await? + .unwrap_or(0); + if count == 0 { + let hash = bcrypt::hash("admin", bcrypt::DEFAULT_COST)?; + sqlx::query!( + "INSERT INTO users (username, password_hash, permissions_mask) VALUES ('admin', $1, '63')", + hash + ) + .execute(&pool) + .await?; + tracing::info!("seeded admin user"); + } + + let query_cache = QueryCache::new(); + let blacklist_cache = BlacklistCache::new(); + + // Load blacklist from DB into memory + let entries = sqlx::query_as!( + BlacklistEntry, + "SELECT id, pattern, method, reason, active, created_at FROM blacklist ORDER BY id" + ) + .fetch_all(&pool) + .await?; + blacklist_cache.load(entries).await; + + // Start cache sweep + spawn_sweep_task( + query_cache.clone(), + config.cache_idle_timeout_secs, + config.cache_sweep_interval_secs, + ); + + let state = AppState { + pool, + query_cache, + blacklist_cache, + config: config.clone(), + }; + + let crud_routes = Router::new() + .route("/api/:table", get(handle_crud).post(handle_crud)) + .route("/api/:table/:id", get(handle_crud).put(handle_crud).delete(handle_crud)) + .route_layer(middleware::from_fn_with_state(state.clone(), blacklist_layer)); + + let app = Router::new() + .route("/auth/login", post(login)) + .merge(crud_routes) + .nest("/admin", admin_router(state.clone())) + .nest_service("/", ServeDir::new("ui/dist")) + .layer(CorsLayer::permissive()) + .with_state(state); + + let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000)); + tracing::info!("listening on {}", addr); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} +``` + +- [ ] **Step 2: Compile** + +```bash +cargo build +``` +Expected: `Finished`. + +- [ ] **Step 3: Commit** + +```bash +git add src/main.rs +git commit -m "feat: wire full Axum router with middleware, CRUD, admin, and static file serving" +``` + +--- + +## Task 14: Docker setup + +**Files:** +- Create: `Dockerfile` +- Create: `docker-compose.yml` + +- [ ] **Step 1: Write Dockerfile** + +```dockerfile +# Stage 1: Build Vue UI +FROM node:20-alpine AS ui-builder +WORKDIR /ui +COPY ui/.npmrc . +COPY ui/package*.json . +RUN npm install +COPY ui/ . +RUN npm run build + +# Stage 2: Build Rust API +FROM rust:1.75-slim AS api-builder +RUN apt-get update && apt-get install -y pkg-config libssl-dev && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY Cargo.toml Cargo.lock ./ +COPY src/ src/ +RUN cargo build --release + +# Stage 3: Final image +FROM debian:bookworm-slim +RUN apt-get update && apt-get install -y ca-certificates libssl3 && rm -rf /var/lib/apt/lists/* +WORKDIR /app +COPY --from=api-builder /app/target/release/mercury . +COPY --from=ui-builder /ui/dist ./ui/dist +EXPOSE 3000 +CMD ["./mercury"] +``` + +- [ ] **Step 2: Write docker-compose.yml** + +```yaml +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_USER: mercury + POSTGRES_PASSWORD: mercury + POSTGRES_DB: mercury + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U mercury"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: . + ports: + - "3000:3000" + environment: + DATABASE_URL: postgres://mercury:mercury@db:5432/mercury + JWT_SECRET: ${JWT_SECRET:-change_me_in_production} + JWT_EXPIRY_SECS: ${JWT_EXPIRY_SECS:-3600} + CACHE_MAX_CAPACITY: ${CACHE_MAX_CAPACITY:-10000} + CACHE_IDLE_TIMEOUT_SECS: ${CACHE_IDLE_TIMEOUT_SECS:-300} + CACHE_SWEEP_INTERVAL_SECS: ${CACHE_SWEEP_INTERVAL_SECS:-60} + depends_on: + db: + condition: service_healthy + mem_limit: 512m + mem_reservation: 256m + +volumes: + postgres_data: +``` + +- [ ] **Step 3: Commit** + +```bash +git add Dockerfile docker-compose.yml +git commit -m "feat: add multi-stage Dockerfile and docker-compose" +``` + +--- + +## Task 15: Vue UI scaffold + +**Files:** +- Create: `ui/.npmrc` +- Create: `ui/package.json` +- Create: `ui/vite.config.ts` +- Create: `ui/src/main.ts` +- Create: `ui/src/App.vue` +- Create: `ui/src/router/index.ts` +- Create: `ui/src/stores/auth.ts` + +- [ ] **Step 1: Write ui/.npmrc** + +``` +@nychthemeron:registry=https://git.mcpeakdev.com/api/packages/McPeakDev/npm/ +``` + +- [ ] **Step 2: Write ui/package.json** + +```json +{ + "name": "mercury-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@nychthemeron/library": "latest", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^2.0.0" + } +} +``` + +- [ ] **Step 3: Write ui/vite.config.ts** + +```typescript +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/api': 'http://localhost:3000', + '/auth': 'http://localhost:3000', + '/admin': 'http://localhost:3000', + }, + }, + build: { + outDir: 'dist', + }, +}) +``` + +- [ ] **Step 4: Write ui/src/stores/auth.ts** + +```typescript +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' + +interface Claims { + sub: string + permissions: string + exp: number +} + +function parseJwt(token: string): Claims | null { + try { + const payload = token.split('.')[1] + return JSON.parse(atob(payload)) as Claims + } catch { + return null + } +} + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('mercury_token')) + const claims = computed(() => + token.value ? parseJwt(token.value) : null + ) + const isAuthenticated = computed(() => { + if (!claims.value) return false + return claims.value.exp * 1000 > Date.now() + }) + const username = computed(() => claims.value?.sub ?? '') + + function hasPermission(bit: bigint): boolean { + if (!claims.value) return false + const mask = BigInt(claims.value.permissions) + return (mask & bit) !== 0n + } + + const isSuperAdmin = computed(() => hasPermission(32n)) + + async function login(username: string, password: string): Promise { + const res = await fetch('/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }) + if (!res.ok) throw new Error('Invalid credentials') + const data = await res.json() + token.value = data.token + localStorage.setItem('mercury_token', data.token) + } + + function logout() { + token.value = null + localStorage.removeItem('mercury_token') + } + + function authHeaders(): Record { + return token.value ? { Authorization: `Bearer ${token.value}` } : {} + } + + return { token, claims, isAuthenticated, username, isSuperAdmin, hasPermission, login, logout, authHeaders } +}) +``` + +- [ ] **Step 5: Write ui/src/router/index.ts** + +```typescript +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', component: () => import('../views/Login.vue') }, + { + path: '/admin', + component: () => import('../views/admin/Layout.vue'), + children: [ + { path: 'queries', component: () => import('../views/admin/Queries.vue') }, + { path: 'users', component: () => import('../views/admin/Users.vue') }, + { path: 'permissions', component: () => import('../views/admin/Permissions.vue') }, + { path: 'blacklist', component: () => import('../views/admin/Blacklist.vue') }, + { path: 'cache', component: () => import('../views/admin/Cache.vue') }, + ], + meta: { requiresAuth: true }, + }, + { path: '/', redirect: '/admin/queries' }, + ], +}) + +router.beforeEach((to) => { + const auth = useAuthStore() + if (to.meta.requiresAuth && !auth.isAuthenticated) { + return '/login' + } + if (to.path === '/login' && auth.isAuthenticated) { + return '/admin/queries' + } +}) + +export default router +``` + +- [ ] **Step 6: Write ui/src/main.ts** + +```typescript +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import NychthemeronLibrary from '@nychthemeron/library' +import App from './App.vue' +import router from './router' + +const app = createApp(App) +app.use(createPinia()) +app.use(router) +app.use(NychthemeronLibrary, { theme: 'dark' }) +app.mount('#app') +``` + +- [ ] **Step 7: Write ui/src/App.vue** + +```vue + +``` + +- [ ] **Step 8: Create ui/src/views/Login.vue** + +```vue + + + + + +``` + +- [ ] **Step 9: Commit** + +```bash +git add ui/ +git commit -m "feat: scaffold Vue 3 UI with auth store, router, and login view" +``` + +--- + +## Task 16: Admin views + +**Files:** +- Create: `ui/src/views/admin/Layout.vue` +- Create: `ui/src/views/admin/Queries.vue` +- Create: `ui/src/views/admin/Users.vue` +- Create: `ui/src/views/admin/Permissions.vue` +- Create: `ui/src/views/admin/Blacklist.vue` +- Create: `ui/src/views/admin/Cache.vue` + +- [ ] **Step 1: Write ui/src/views/admin/Layout.vue** + +```vue + + + + + +``` + +- [ ] **Step 2: Write ui/src/views/admin/Queries.vue** + +```vue + + + +``` + +- [ ] **Step 3: Write ui/src/views/admin/Users.vue** + +```vue + + + +``` + +- [ ] **Step 4: Write ui/src/views/admin/Permissions.vue** + +```vue + + + +``` + +- [ ] **Step 5: Write ui/src/views/admin/Blacklist.vue** + +```vue + + + +``` + +- [ ] **Step 6: Write ui/src/views/admin/Cache.vue** + +```vue + + + + + +``` + +- [ ] **Step 7: Commit** + +```bash +git add ui/src/views/ +git commit -m "feat: add admin views for queries, users, permissions, blacklist, cache" +``` + +--- + +## Task 17: README update + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Write README.md** + +```markdown +# Mercury + +A high-performance, monolithic Rust API with a dynamic CRUD engine, query registry cache, JWT bitmask permissions, and a Vue 3 admin frontend. + +## Quick Start + +```bash +cp .env.example .env # set JWT_SECRET +docker compose up --build +``` + +API: http://localhost:3000/api +Admin UI: http://localhost:3000 +Default credentials: `admin` / `admin` + +--- + +## API Contract + +### Authentication + +``` +POST /auth/login +Body: { "username": "...", "password": "..." } +Returns: { "token": "" } +``` + +All admin routes require `Authorization: Bearer `. + +--- + +### CRUD — Dynamic Table Access + +Requests are mapped to the named PostgreSQL table. The SQL is generated, cached, and executed automatically. + +``` +GET /api/{table} List all rows (supports ?col=val filters) +GET /api/{table}/{id} Get row by id +POST /api/{table} Insert row (JSON body) +PUT /api/{table}/{id} Update row by id (JSON body) +DELETE /api/{table}/{id} Delete row by id +``` + +**Notes:** +- `users` and `permissions` tables are blacklisted from public CRUD — use the admin suite. +- Filters are ANDed together: `GET /api/orders?status=open&priority=high` + +--- + +### Admin — Query Registry + +Requires JWT with `ADMIN_QUERY` permission (bit 8). + +``` +GET /admin/queries List all registered queries +POST /admin/queries Register a raw SQL template +GET /admin/queries/{identifier} Get query by slug +PUT /admin/queries/{identifier} Update SQL template or description +DELETE /admin/queries/{identifier} Remove query (evicts from cache) +GET /admin/queries/{identifier}/execute Execute query with ?param=val bindings +``` + +SQL templates use `:param_name` placeholders: +```sql +SELECT * FROM orders WHERE user_id = :user_id AND status = :status +``` + +--- + +### Admin — Cache + +Requires JWT with `ADMIN_CACHE` permission (bit 16). + +``` +GET /admin/cache/stats Cache size, hit count, miss count +DELETE /admin/cache Flush entire cache +``` + +--- + +### Admin — Users + +Requires JWT with `SUPER_ADMIN` permission (bit 32). + +``` +GET /admin/users List users +POST /admin/users Create user +GET /admin/users/{id} Get user +PUT /admin/users/{id} Update user +DELETE /admin/users/{id} Delete user +POST /admin/users/{id}/permissions/grant/{bit} OR bit into permissions mask +DELETE /admin/users/{id}/permissions/revoke/{bit} AND NOT bit from permissions mask +``` + +--- + +### Admin — Permissions + +Requires JWT with `SUPER_ADMIN` permission (bit 32). + +``` +GET /admin/permissions List permission definitions +POST /admin/permissions Create custom permission (auto-assigns next bit) +PUT /admin/permissions/{id} Update name/description +DELETE /admin/permissions/{id} Remove permission +``` + +--- + +### Admin — Route Blacklist + +Requires JWT with `SUPER_ADMIN` permission (bit 32). Changes take effect immediately in memory. + +``` +GET /admin/blacklist List all entries +POST /admin/blacklist Add glob pattern +PUT /admin/blacklist/{id} Update entry (set active: false to disable) +DELETE /admin/blacklist/{id} Remove entry +``` + +Pattern syntax: `*` matches one path segment, `**` matches many. +Example: `/api/sensitive/**` blocks all methods under that path. + +--- + +## Permission Bitmask + +| Name | Bit | Value | +|-------------|-----|-------| +| READ | 0 | 1 | +| WRITE | 1 | 2 | +| DELETE | 2 | 4 | +| ADMIN_QUERY | 3 | 8 | +| ADMIN_CACHE | 4 | 16 | +| SUPER_ADMIN | 5 | 32 | + +Custom permissions are added via the admin suite and assigned the next available power-of-2 bit. Masks support up to 128 bits (u128). + +--- + +## Configuration + +| Variable | Default | Description | +|---|---|---| +| `DATABASE_URL` | required | PostgreSQL connection string | +| `JWT_SECRET` | required | HMAC-HS256 signing secret | +| `JWT_EXPIRY_SECS` | 3600 | Token lifetime in seconds | +| `CACHE_MAX_CAPACITY` | 10000 | Max query templates in memory | +| `CACHE_IDLE_TIMEOUT_SECS` | 300 | Evict after N seconds idle | +| `CACHE_SWEEP_INTERVAL_SECS` | 60 | Sweep interval for eviction task | + +--- + +## Development + +```bash +# API only (requires local Postgres) +cargo run + +# UI dev server (proxies to local API) +cd ui && npm install && npm run dev + +# Full stack +docker compose up --build +``` + +## Stack + +- **API:** Rust, Axum, SQLx, PostgreSQL, DashMap, jsonwebtoken, bcrypt +- **Frontend:** Vue 3, Vite, @nychthemeron/library (dark mode default) +- **Infra:** Docker multi-stage build, docker compose +``` + +- [ ] **Step 2: Commit** + +```bash +git add README.md +git commit -m "docs: update README with full API contract and configuration" +``` + +--- + +## Self-Review Checklist + +### Spec Coverage + +| Spec requirement | Task | +|---|---| +| Axum + SQLx + PostgreSQL | Task 1, 4 | +| Config from env vars | Task 2 | +| DashMap TTI cache with sweep | Task 5, 6 | +| Blacklist in-memory with glob | Task 5 | +| JWT with u128 bitmask | Task 7 | +| Auth + blacklist middleware | Task 8 | +| CRUD query builder | Task 10 | +| /auth/login | Task 9 | +| Admin query registry CRUD | Task 11 | +| Admin cache stats + flush | Task 12 | +| Admin user management | Task 12 | +| Admin permission definitions | Task 12 | +| Admin blacklist CRUD + reload | Task 12 | +| Router assembly + main.rs | Task 13 | +| Dockerfile multi-stage | Task 14 | +| docker-compose with mem_limit | Task 14 | +| Admin seeded in migration | Task 4 (schema) + Task 13 (seed on startup) | +| Vue 3 + @nychthemeron/library | Task 15 | +| Dark mode default | Task 15 (main.ts) | +| Login view | Task 15 | +| Admin views (5 pages) | Task 16 | +| README with API contract | Task 17 | +| `users`/`permissions` blacklisted | Task 4 (migration seed) | +| Blacklist reloads on write | Task 12 (blacklist.rs) | +| Permission grant/revoke endpoints | Task 12 (users.rs) | +| Custom permissions auto-assign bit | Task 12 (permissions.rs) | + +All spec requirements covered. No gaps found. diff --git a/docs/superpowers/plans/2026-06-17-security-fixes.md b/docs/superpowers/plans/2026-06-17-security-fixes.md new file mode 100644 index 0000000..0c7736d --- /dev/null +++ b/docs/superpowers/plans/2026-06-17-security-fixes.md @@ -0,0 +1,1072 @@ +# Security Fixes Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix all Critical, Important, and Minor security/robustness issues identified in the June 2026 code review. + +**Architecture:** Fixes are applied in dependency order — shared utilities first, then middleware, then handlers, then startup. No new crates required except `rpassword` is intentionally avoided (plain stdin for first-user prompt is acceptable). + +**Tech Stack:** Rust, Axum 0.7, SQLx 0.7, jsonwebtoken 9, bcrypt 0.15, DashMap 5, tower-http 0.5 + +--- + +## File Map + +| File | Changes | +|------|---------| +| `src/state.rs` | Fix `unix_now()` panic | +| `src/routes/mod.rs` | Add shared `is_valid_identifier()` | +| `src/routes/admin/tables.rs` | Use shared validator, add `PROTECTED_TABLES` denylist | +| `src/routes/admin/queries.rs` | Sort params longest-first before substitution | +| `src/routes/crud.rs` | Fix cache key, drop NULL_SENTINEL, enforce R/W/D bits, use shared validator | +| `src/auth/middleware.rs` | Stash Claims in extensions in `blacklist_layer`; add `authenticate()` helper | +| `src/config.rs` | Add `cors_origins: Vec` field | +| `src/main.rs` | First-user interactive prompt, body size limit, configured CORS | + +--- + +## Task 1: Fix `unix_now()` panic on sub-epoch clock + +**Files:** +- Modify: `src/state.rs:16-19` + +- [ ] **Step 1: Apply the fix** + +Change `src/state.rs`: +```rust +pub fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} +``` + +- [ ] **Step 2: Run tests** + +```bash +cargo test +``` +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/state.rs +git commit -m "fix: don't panic in unix_now() when clock is before epoch" +``` + +--- + +## Task 2: Fix stored query prefix-substitution bug + +**Files:** +- Modify: `src/routes/admin/queries.rs:29-30` + +**The bug:** When sorted alphabetically, `:user` is replaced before `:username`, turning `:username` into `$1name` — invalid SQL. + +- [ ] **Step 1: Write a failing test** + +Add to `src/routes/admin/queries.rs` (at the bottom, inside a `#[cfg(test)]` block — create the block if it doesn't exist): + +```rust +#[cfg(test)] +mod tests { + #[test] + fn test_prefix_param_substitution_order() { + // Simulate the substitution logic with a prefix-conflicting pair. + let template = "SELECT * FROM t WHERE user_id = :user_id AND username = :username"; + let mut params: Vec<(String, String)> = vec![ + ("user_id".into(), "42".into()), + ("username".into(), "alice".into()), + ]; + // Sort longest-first (the fix) + params.sort_by(|a, b| b.0.len().cmp(&a.0.len())); + let mut sql = template.to_string(); + for (i, (name, _)) in params.iter().enumerate() { + sql = sql.replace(&format!(":{}", name), &format!("${}", i + 1)); + } + assert!(sql.contains("$1") && sql.contains("$2"), "sql: {}", sql); + assert!(!sql.contains(":username"), "placeholder not replaced: {}", sql); + assert!(!sql.contains(":user_id"), "placeholder not replaced: {}", sql); + // username (len 8) should be $1, user_id (len 7) should be $2 + assert_eq!(sql, "SELECT * FROM t WHERE user_id = $2 AND username = $1"); + } +} +``` + +- [ ] **Step 2: Run test to confirm it fails before the fix** + +```bash +cargo test test_prefix_param_substitution_order +``` +Expected: FAIL (currently sorts alphabetically, not by length). + +- [ ] **Step 3: Apply the fix in `execute_query`** + +In `src/routes/admin/queries.rs`, change line 30 from: +```rust +sorted_params.sort_by(|a, b| a.0.cmp(&b.0)); +``` +to: +```rust +sorted_params.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(a.0.cmp(&b.0))); +``` + +- [ ] **Step 4: Run test to confirm it passes** + +```bash +cargo test test_prefix_param_substitution_order +``` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/admin/queries.rs +git commit -m "fix: sort stored query params longest-first to prevent prefix substitution corruption" +``` + +--- + +## Task 3: Fix cache key collision between by-id lookup and column filter + +**Files:** +- Modify: `src/routes/crud.rs:48` + +**The bug:** `GET /api/users/by_id` and `GET /api/users?by_id=foo` both produce key `"GET:users:by_id"`. Tilde (`~`) is not a valid identifier character so `"~id"` can never collide with a column name. + +- [ ] **Step 1: Update the existing test to expect the new key** + +In `src/routes/crud.rs` tests, change `test_build_select_by_id`: +```rust +#[test] +fn test_build_select_by_id() { + let (sql, params, key) = build_query("GET", "orders", Some("42"), &[], &[]).unwrap(); + assert_eq!(sql, "SELECT * FROM orders WHERE id = $1"); + assert_eq!(params, vec![Value::String("42".into())]); + assert_eq!(key, "GET:orders:~id"); +} +``` + +> Note: `params` type changes to `Vec` in Task 6. For now, keep as `Vec` and update `"by_id"` → `"~id"` only. +> +> If doing this task before Task 6, the test assertion for params stays `vec!["42".to_string()]`. Just change the key assertion to `"GET:orders:~id"`. + +- [ ] **Step 2: Apply the fix** + +In `src/routes/crud.rs:48`, change: +```rust +let key = format!("GET:{}:by_id", table); +``` +to: +```rust +let key = format!("GET:{}:~id", table); +``` + +- [ ] **Step 3: Run tests** + +```bash +cargo test +``` +Expected: all tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/routes/crud.rs +git commit -m "fix: change by-id cache key to ~id to prevent column name collision" +``` + +--- + +## Task 4: Extract shared `is_valid_identifier` to eliminate duplication + +**Files:** +- Modify: `src/routes/mod.rs` +- Modify: `src/routes/crud.rs:16-22` +- Modify: `src/routes/admin/tables.rs:17-23` + +- [ ] **Step 1: Add the shared function to `src/routes/mod.rs`** + +Replace the current content of `src/routes/mod.rs`: +```rust +pub mod admin; +pub mod auth; +pub mod crud; + +/// Returns true if `name` is a safe SQL identifier (non-empty, alphanumeric + underscore only). +pub fn is_valid_identifier(name: &str) -> bool { + !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') +} +``` + +- [ ] **Step 2: Update `src/routes/crud.rs` to use the shared function** + +Remove the local `validate_identifier` (lines 16-22): +```rust +// DELETE this function entirely: +fn validate_identifier(name: &str) -> Result<()> { + if name.chars().all(|c| c.is_alphanumeric() || c == '_') { + Ok(()) + } else { + Err(anyhow!("invalid identifier: {}", name)) + } +} +``` + +Replace the two call sites in `build_query`: +```rust +// Line 34 — was: validate_identifier(table)?; +if !crate::routes::is_valid_identifier(table) { + return Err(anyhow!("invalid identifier: {}", table)); +} +// Line 35-37 — was: for (col, _) in ... { validate_identifier(col)?; } +for (col, _) in body_cols.iter().chain(filter_cols.iter()) { + if !crate::routes::is_valid_identifier(col) { + return Err(anyhow!("invalid identifier: {}", col)); + } +} +``` + +- [ ] **Step 3: Update `src/routes/admin/tables.rs` to use the shared function** + +Remove the local `validate_identifier` (lines 17-23): +```rust +// DELETE this function entirely: +fn validate_identifier(name: &str) -> Result<(), StatusCode> { + if !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') { + Ok(()) + } else { + Err(StatusCode::BAD_REQUEST) + } +} +``` + +Replace all three call sites: +```rust +// In get_table_preview, create_table, drop_table — replace validate_identifier(&name)? with: +if !crate::routes::is_valid_identifier(&name) { + return Err(StatusCode::BAD_REQUEST); +} +// In create_table column loop — replace validate_identifier(&col.name)? with: +if !crate::routes::is_valid_identifier(&col.name) { + return Err(StatusCode::BAD_REQUEST); +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cargo test +``` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/routes/mod.rs src/routes/crud.rs src/routes/admin/tables.rs +git commit -m "refactor: extract shared is_valid_identifier to eliminate duplication" +``` + +--- + +## Task 5: Add protected-table denylist to DDL operations + +**Files:** +- Modify: `src/routes/admin/tables.rs` + +- [ ] **Step 1: Write failing tests** + +Add to `src/routes/admin/tables.rs` at the bottom: + +```rust +#[cfg(test)] +mod tests { + use super::PROTECTED_TABLES; + + #[test] + fn test_protected_tables_list() { + assert!(PROTECTED_TABLES.contains(&"users")); + assert!(PROTECTED_TABLES.contains(&"blacklist")); + assert!(PROTECTED_TABLES.contains(&"api_keys")); + assert!(PROTECTED_TABLES.contains(&"queries")); + assert!(PROTECTED_TABLES.contains(&"permissions")); + } + + #[test] + fn test_is_protected() { + assert!(is_protected("users")); + assert!(is_protected("USERS")); // case-insensitive + assert!(!is_protected("orders")); + } +} +``` + +- [ ] **Step 2: Run test to confirm it fails** + +```bash +cargo test test_protected_tables_list test_is_protected +``` +Expected: FAIL — `PROTECTED_TABLES` and `is_protected` not defined yet. + +- [ ] **Step 3: Add the denylist and helper** + +At the top of `src/routes/admin/tables.rs`, after the `use` imports, add: + +```rust +const PROTECTED_TABLES: &[&str] = &["users", "blacklist", "api_keys", "queries", "permissions"]; + +fn is_protected(name: &str) -> bool { + let lower = name.to_lowercase(); + PROTECTED_TABLES.iter().any(|&t| t == lower) +} +``` + +- [ ] **Step 4: Guard `drop_table`** + +In `drop_table`, after `validate_identifier(&name)?;`, add: +```rust +if is_protected(&name) { + return Err(StatusCode::FORBIDDEN); +} +``` + +- [ ] **Step 5: Guard `create_table`** + +In `create_table`, after `validate_identifier(&body.name)?;`, add: +```rust +if is_protected(&body.name) { + return Err(StatusCode::FORBIDDEN); +} +``` + +- [ ] **Step 6: Run tests** + +```bash +cargo test +``` +Expected: all tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add src/routes/admin/tables.rs +git commit -m "fix: block DDL operations on protected system tables" +``` + +--- + +## Task 6: Eliminate NULL_SENTINEL — thread `serde_json::Value` through `build_query` + +**Files:** +- Modify: `src/routes/crud.rs` (signature of `build_query`, binding loop, password hashing) + +**The bug:** `"\x00NULL"` is an in-band sentinel — a real string field containing those bytes would be silently written as SQL NULL. + +- [ ] **Step 1: Update `build_query` signature and return type** + +Change the function signature and all `Vec` params returns to `Vec`. Full new function (replace lines 27-114): + +```rust +/// Returns (sql, ordered_param_values, cache_key) +pub fn build_query( + method: &str, + table: &str, + id: Option<&str>, + body_cols: &[(String, Value)], + filter_cols: &[(String, String)], +) -> Result<(String, Vec, String)> { + if !crate::routes::is_valid_identifier(table) { + return Err(anyhow!("invalid identifier: {}", table)); + } + for (col, _) in body_cols.iter() { + if !crate::routes::is_valid_identifier(col) { + return Err(anyhow!("invalid identifier: {}", col)); + } + } + for (col, _) in filter_cols.iter() { + if !crate::routes::is_valid_identifier(col) { + return Err(anyhow!("invalid identifier: {}", col)); + } + } + + let mut sorted_body = body_cols.to_vec(); + sorted_body.sort_by(|a, b| a.0.cmp(&b.0)); + let mut sorted_filters = filter_cols.to_vec(); + sorted_filters.sort_by(|a, b| a.0.cmp(&b.0)); + + match method.to_uppercase().as_str() { + "GET" => { + if let Some(id_val) = id { + let sql = format!("SELECT * FROM {} WHERE id = $1", table); + let key = format!("GET:{}:~id", table); + Ok((sql, vec![Value::String(id_val.to_string())], key)) + } else if sorted_filters.is_empty() { + let sql = format!("SELECT * FROM {}", table); + let key = format!("GET:{}:", table); + Ok((sql, vec![], key)) + } else { + let col_names: Vec = sorted_filters.iter().map(|(c, _)| c.clone()).collect(); + let where_clause: Vec = col_names + .iter() + .enumerate() + .map(|(i, c)| format!("{} = ${}", c, i + 1)) + .collect(); + let sql = format!("SELECT * FROM {} WHERE {}", table, where_clause.join(" AND ")); + let params: Vec = sorted_filters.iter().map(|(_, v)| Value::String(v.clone())).collect(); + let key = format!("GET:{}:{}", table, col_names.join(",")); + Ok((sql, params, key)) + } + } + "POST" => { + if sorted_body.is_empty() { + return Err(anyhow!("POST requires a body with at least one field")); + } + let cols: Vec = sorted_body.iter().map(|(c, _)| c.clone()).collect(); + let placeholders: Vec = (1..=cols.len()).map(|i| format!("${}", i)).collect(); + let sql = format!( + "INSERT INTO {} ({}) VALUES ({}) RETURNING *", + table, + cols.join(", "), + placeholders.join(", ") + ); + let params: Vec = sorted_body.iter().map(|(_, v)| v.clone()).collect(); + let key = format!("POST:{}:{}", table, cols.join(",")); + Ok((sql, params, key)) + } + "PUT" => { + let id_val = id.ok_or_else(|| anyhow!("PUT requires an id"))?; + if sorted_body.is_empty() { + return Err(anyhow!("PUT requires a body with at least one field")); + } + let cols: Vec = sorted_body.iter().map(|(c, _)| c.clone()).collect(); + let set_clause: Vec = cols + .iter() + .enumerate() + .map(|(i, c)| format!("{} = ${}", c, i + 1)) + .collect(); + let id_placeholder = cols.len() + 1; + let sql = format!( + "UPDATE {} SET {} WHERE id = ${} RETURNING *", + table, + set_clause.join(", "), + id_placeholder + ); + let mut params: Vec = sorted_body.iter().map(|(_, v)| v.clone()).collect(); + params.push(Value::String(id_val.to_string())); + let key = format!("PUT:{}:{}:by_id", table, cols.join(",")); + Ok((sql, params, key)) + } + "DELETE" => { + let id_val = id.ok_or_else(|| anyhow!("DELETE requires an id"))?; + let sql = format!("DELETE FROM {} WHERE id = $1", table); + let key = format!("DELETE:{}:by_id", table); + Ok((sql, vec![Value::String(id_val.to_string())], key)) + } + m => Err(anyhow!("unsupported method: {}", m)), + } +} +``` + +- [ ] **Step 2: Update `handle_crud` — simplify body collection and remove sentinel** + +Replace the body collection block (lines 206-234) in `handle_crud`: + +```rust +// Body params: collect as typed Values directly +let mut body_cols: Vec<(String, Value)> = body + .map(|Json(b)| b.into_iter().collect()) + .unwrap_or_default(); + +// Hash the password field for the users table before building the query. +if table == "users" && matches!(method_str.to_uppercase().as_str(), "POST" | "PUT") { + if let Some(pos) = body_cols.iter().position(|(k, _)| k == "password") { + let (_, val) = body_cols.remove(pos); + if let Value::String(plaintext) = val { + let hash = bcrypt::hash(&plaintext, bcrypt::DEFAULT_COST) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + body_cols.push(("password_hash".to_string(), Value::String(hash))); + } + } +} +``` + +- [ ] **Step 3: Update the binding loop in `handle_crud`** + +Replace the binding loop (after `let mut q = sqlx::query(&sql);`): + +```rust +let mut q = sqlx::query(&sql); +for val in ¶ms_vals { + match val { + Value::Null => q = q.bind(Option::::None), + Value::Bool(b) => q = q.bind(*b), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + q = q.bind(i); + } else if let Some(f) = n.as_f64() { + q = q.bind(f); + } else { + q = q.bind(n.to_string()); + } + } + Value::String(s) => q = q.bind(s.as_str()), + other => q = q.bind(other.to_string()), + } +} +``` + +- [ ] **Step 4: Update unit tests for `build_query` to use `Value`** + +In the `#[cfg(test)]` block at the bottom of `src/routes/crud.rs`, update all `build_query` calls (the signature now takes `&[(String, Value)]` for body_cols and returns `Vec`). + +Update `test_build_insert`: +```rust +#[test] +fn test_build_insert() { + let cols = vec![ + ("email".into(), Value::String("a@b.com".into())), + ("name".into(), Value::String("Alice".into())), + ]; + let (sql, params, key) = build_query("POST", "users", None, &cols, &[]).unwrap(); + assert_eq!(sql, "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *"); + assert_eq!(params, vec![Value::String("a@b.com".into()), Value::String("Alice".into())]); + assert_eq!(key, "POST:users:email,name"); +} +``` + +Update `test_build_update`: +```rust +#[test] +fn test_build_update() { + let cols = vec![("name".into(), Value::String("Bob".into()))]; + let (sql, params, key) = build_query("PUT", "users", Some("7"), &cols, &[]).unwrap(); + assert_eq!(sql, "UPDATE users SET name = $1 WHERE id = $2 RETURNING *"); + assert_eq!(params, vec![Value::String("Bob".into()), Value::String("7".into())]); + assert_eq!(key, "PUT:users:name:by_id"); +} +``` + +Update `test_build_select_by_id` (already changed key in Task 3): +```rust +#[test] +fn test_build_select_by_id() { + let (sql, params, key) = build_query("GET", "orders", Some("42"), &[], &[]).unwrap(); + assert_eq!(sql, "SELECT * FROM orders WHERE id = $1"); + assert_eq!(params, vec![Value::String("42".into())]); + assert_eq!(key, "GET:orders:~id"); +} +``` + +Update `test_build_delete`: +```rust +#[test] +fn test_build_delete() { + let (sql, params, key) = build_query("DELETE", "users", Some("3"), &[], &[]).unwrap(); + assert_eq!(sql, "DELETE FROM users WHERE id = $1"); + assert_eq!(params, vec![Value::String("3".into())]); + assert_eq!(key, "DELETE:users:by_id"); +} +``` + +Update `test_build_select_all` (no body/filter params, just check it compiles): +```rust +#[test] +fn test_build_select_all() { + let (sql, params, key) = build_query("GET", "orders", None, &[], &[]).unwrap(); + assert_eq!(sql, "SELECT * FROM orders"); + assert!(params.is_empty()); + assert_eq!(key, "GET:orders:"); +} +``` + +Update `test_build_select_with_filters`: +```rust +#[test] +fn test_build_select_with_filters() { + let filters = vec![("status".into(), "active".into()), ("role".into(), "admin".into())]; + let (sql, params, key) = build_query("GET", "users", None, &[], &filters).unwrap(); + assert_eq!(sql, "SELECT * FROM users WHERE role = $1 AND status = $2"); + assert_eq!(params, vec![Value::String("admin".into()), Value::String("active".into())]); + assert_eq!(key, "GET:users:role,status"); +} +``` + +`test_rejects_invalid_table_name` needs no change. + +- [ ] **Step 5: Run tests** + +```bash +cargo test +``` +Expected: all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add src/routes/crud.rs +git commit -m "fix: eliminate NULL_SENTINEL by threading serde_json::Value through build_query" +``` + +--- + +## Task 7: Stash Claims in `blacklist_layer` to eliminate double DB hit + +**Files:** +- Modify: `src/auth/middleware.rs` + +**The fix:** Add an `authenticate()` helper. `blacklist_layer` stashes resolved `Claims` in request extensions. All `require_*` functions check extensions first, skipping the DB call if already authenticated. + +- [ ] **Step 1: Rewrite `src/auth/middleware.rs`** + +Replace the entire file: + +```rust +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, +}; + +use crate::{auth::{decode_jwt, resolve_api_key, Claims}, state::AppState}; + +/// Resolves a Bearer token to Claims, trying JWT then API key. +/// On success, inserts Claims into request extensions so downstream middleware +/// can reuse them without hitting the database again. +async fn authenticate( + token: &str, + state: &AppState, + req: &mut Request, +) -> Option { + if let Some(existing) = req.extensions().get::().cloned() { + return Some(existing); + } + let claims = if let Ok(c) = decode_jwt(token, &state.config.jwt_secret) { + c + } else { + resolve_api_key(token, &state.pool).await? + }; + req.extensions_mut().insert(claims.clone()); + Some(claims) +} + +pub async fn blacklist_layer( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let method = req.method().as_str().to_uppercase(); + let path = req.uri().path().to_string(); + let caller_mask = if let Some(token) = extract_bearer(&req) { + authenticate(&token, &state, &mut req) + .await + .map(|c| c.permissions_mask()) + .unwrap_or(0) + } else { + 0 + }; + if state.blacklist_cache.is_blocked(&method, &path, caller_mask).await { + return Err(StatusCode::FORBIDDEN); + } + Ok(next.run(req).await) +} + +pub fn extract_bearer(req: &Request) -> Option { + req.headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|s| s.to_string()) +} + +pub async fn require_auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?; + Ok(next.run(req).await) +} + +pub async fn require_super_admin( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + let claims = authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?; + if !claims.has_permission(crate::auth::permissions::SUPER_ADMIN) { + return Err(StatusCode::FORBIDDEN); + } + Ok(next.run(req).await) +} + +pub async fn require_admin_query( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + let claims = authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?; + if !claims.has_permission(crate::auth::permissions::ADMIN_QUERY) { + return Err(StatusCode::FORBIDDEN); + } + Ok(next.run(req).await) +} + +pub async fn require_admin_cache( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + let claims = authenticate(&token, &state, &mut req).await.ok_or(StatusCode::UNAUTHORIZED)?; + if !claims.has_permission(crate::auth::permissions::ADMIN_CACHE) { + return Err(StatusCode::FORBIDDEN); + } + Ok(next.run(req).await) +} +``` + +- [ ] **Step 2: Run tests** + +```bash +cargo test +``` +Expected: all tests pass. + +- [ ] **Step 3: Commit** + +```bash +git add src/auth/middleware.rs +git commit -m "fix: stash Claims in request extensions to eliminate double DB hit per API key request" +``` + +--- + +## Task 8: Enforce READ/WRITE/DELETE permission bits in CRUD handler + +**Files:** +- Modify: `src/routes/crud.rs` + +- [ ] **Step 1: Add `Extension(claims)` to `handle_crud` and check permissions** + +Change the `handle_crud` signature to extract `Claims`: + +```rust +pub async fn handle_crud( + State(state): State, + method: Method, + Extension(claims): Extension, + Path(params): Path>, + Query(query_params): Query>, + body: Option>>, +) -> Result, StatusCode> { +``` + +Then add permission check immediately after extracting `method_str`: + +```rust +let method_str = method.as_str(); + +// Enforce permission bits before doing any work. +let required_bit = match method_str.to_uppercase().as_str() { + "GET" => crate::auth::permissions::READ, + "POST" | "PUT" => crate::auth::permissions::WRITE, + "DELETE" => crate::auth::permissions::DELETE, + _ => return Err(StatusCode::METHOD_NOT_ALLOWED), +}; +if !claims.has_permission(required_bit) { + return Err(StatusCode::FORBIDDEN); +} +``` + +- [ ] **Step 2: Run tests** + +```bash +cargo test +``` +Expected: all tests pass. (`build_query` unit tests don't call `handle_crud`, so they're unaffected.) + +- [ ] **Step 3: Commit** + +```bash +git add src/routes/crud.rs +git commit -m "fix: enforce READ/WRITE/DELETE permission bits in CRUD handler" +``` + +--- + +## Task 9: Add configurable CORS and body size limit + +**Files:** +- Modify: `src/config.rs` +- Modify: `src/main.rs` + +- [ ] **Step 1: Add `cors_origins` to `Config`** + +In `src/config.rs`, add the field and parsing: + +```rust +use anyhow::Result; + +#[derive(Clone, Debug)] +pub struct Config { + pub database_url: String, + pub jwt_secret: String, + pub jwt_expiry_secs: u64, + pub cache_max_capacity: usize, + pub cache_idle_timeout_secs: u64, + pub cache_sweep_interval_secs: u64, + /// Comma-separated list of allowed CORS origins, or "*" for permissive. + /// If empty, no CORS headers are added. + pub cors_origins: Vec, +} + +impl Config { + pub fn from_env() -> Result { + Ok(Self { + database_url: std::env::var("DATABASE_URL")?, + jwt_secret: std::env::var("JWT_SECRET")?, + jwt_expiry_secs: std::env::var("JWT_EXPIRY_SECS") + .unwrap_or_else(|_| "3600".into()) + .parse()?, + cache_max_capacity: std::env::var("CACHE_MAX_CAPACITY") + .unwrap_or_else(|_| "10000".into()) + .parse()?, + cache_idle_timeout_secs: std::env::var("CACHE_IDLE_TIMEOUT_SECS") + .unwrap_or_else(|_| "300".into()) + .parse()?, + cache_sweep_interval_secs: std::env::var("CACHE_SWEEP_INTERVAL_SECS") + .unwrap_or_else(|_| "60".into()) + .parse()?, + cors_origins: std::env::var("CORS_ORIGINS") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + }) + } +} +``` + +- [ ] **Step 2: Update `.env.example`** + +Add `CORS_ORIGINS=` (empty, no CORS by default) to `.env.example`. + +- [ ] **Step 3: Update `src/main.rs` imports and app builder** + +Add imports at the top of `src/main.rs`: +```rust +use axum::extract::DefaultBodyLimit; +use axum::http::{header, HeaderValue, Method}; +use tower_http::cors::{Any, CorsLayer}; +``` + +Replace `CorsLayer::permissive()` in the app builder with a configured layer. The CRUD routes also get a body size limit. Replace the `crud_routes` and `app` blocks: + +```rust +let crud_routes = Router::new() + .route("/api/:table", get(handle_crud).post(handle_crud)) + .route("/api/:table/", get(handle_crud).post(handle_crud)) + .route("/api/:table/:id", get(handle_crud).put(handle_crud).delete(handle_crud)) + .route("/api/:table/:id/", get(handle_crud).put(handle_crud).delete(handle_crud)) + .layer(DefaultBodyLimit::max(1 * 1024 * 1024)) // 1 MB + .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)) + .route_layer(middleware::from_fn_with_state(state.clone(), blacklist_layer)); + +let cors_layer = build_cors(&config.cors_origins); + +let app = Router::new() + .route("/auth/login", post(login)) + .merge(crud_routes) + .nest("/api/admin", admin_router(state.clone())) + .nest_service("/", ServeDir::new("ui/dist").fallback(ServeFile::new("ui/dist/index.html"))) + .layer(cors_layer) + .with_state(state); +``` + +Add the `build_cors` helper function (before `main`): + +```rust +fn build_cors(origins: &[String]) -> CorsLayer { + if origins.is_empty() { + return CorsLayer::new(); + } + if origins.iter().any(|o| o == "*") { + return CorsLayer::permissive(); + } + let parsed: Vec = origins + .iter() + .filter_map(|o| o.parse().ok()) + .collect(); + CorsLayer::new() + .allow_origin(parsed) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE, Method::OPTIONS]) + .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]) +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cargo test +``` +Expected: all tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/config.rs src/main.rs .env.example +git commit -m "fix: replace permissive CORS with configured origins; add 1MB body size limit" +``` + +--- + +## Task 10: First-user interactive setup (replace hardcoded admin/admin) + +**Files:** +- Modify: `src/main.rs` + +**Behavior:** On startup, if no users exist in the database, the server prompts on stdin for a username and password, creates that user with full permissions (mask=63), then starts serving. Routes are locked down by auth from the first request — there is no magic credential. + +- [ ] **Step 1: Add `use std::io::Write;` import to `src/main.rs`** + +In `src/main.rs` imports, add: +```rust +use std::io::{self, Write}; +``` + +- [ ] **Step 2: Replace the admin seed block** + +Remove lines 42-56 in `src/main.rs` (the current admin seed): +```rust +// DELETE all of this: +let count: i64 = sqlx::query_scalar::<_, Option>("SELECT COUNT(*) FROM users WHERE username = 'admin'") + .fetch_one(&pool) + .await? + .unwrap_or(0); +if count == 0 { + let hash = bcrypt::hash("admin", bcrypt::DEFAULT_COST)?; + sqlx::query( + "INSERT INTO users (username, password_hash, permissions_mask) VALUES ('admin', $1, '63')" + ) + .bind(hash) + .execute(&pool) + .await?; + tracing::info!("seeded admin user"); +} +``` + +Replace with: +```rust +// If no users exist, prompt to create the first admin. +let user_count: i64 = sqlx::query_scalar::<_, Option>("SELECT COUNT(*) FROM users") + .fetch_one(&pool) + .await? + .unwrap_or(0); +if user_count == 0 { + println!("\nNo users found. Create the first admin account."); + print!("Username: "); + io::stdout().flush()?; + let mut username = String::new(); + io::stdin().read_line(&mut username)?; + let username = username.trim().to_string(); + if username.is_empty() { + anyhow::bail!("username cannot be empty"); + } + + print!("Password: "); + io::stdout().flush()?; + let mut password = String::new(); + io::stdin().read_line(&mut password)?; + let password = password.trim().to_string(); + if password.is_empty() { + anyhow::bail!("password cannot be empty"); + } + + let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)?; + sqlx::query( + "INSERT INTO users (username, password_hash, permissions_mask) VALUES ($1, $2, '63')", + ) + .bind(&username) + .bind(hash) + .execute(&pool) + .await?; + tracing::info!("created first admin user: {}", username); + println!("Admin user '{}' created. Starting server...\n", username); +} +``` + +- [ ] **Step 3: Run tests** + +```bash +cargo test +``` +Expected: all tests pass (this is startup logic, not unit-testable without a DB). + +- [ ] **Step 4: Commit** + +```bash +git add src/main.rs +git commit -m "fix: replace hardcoded admin/admin seed with interactive first-user setup on empty DB" +``` + +--- + +## Task 11: Remove spurious `mut` on `body_cols_typed` + +> **Note:** After Task 6, `body_cols_typed` no longer exists — this was eliminated when the body collection was simplified. Verify the `mut` warning is gone. + +- [ ] **Step 1: Confirm no `mut` warning** + +```bash +cargo build 2>&1 | grep "unused_mut\|body_cols_typed" +``` +Expected: no output (variable was removed in Task 6). + +If the warning still appears for another variable, find and remove the `mut`: +```bash +cargo build 2>&1 | grep "warning.*mut" +``` +Fix any remaining spurious `mut` annotations. + +- [ ] **Step 2: Commit if any change was needed** + +```bash +git add src/routes/crud.rs +git commit -m "fix: remove spurious mut annotations" +``` + +--- + +## Self-Review + +**Spec coverage check:** +- C1 (CRUD permissions) → Task 8 ✓ +- C2 (admin/admin seed) → Task 10 ✓ +- C3 (protected tables) → Task 5 ✓ +- C4 (prefix substitution) → Task 2 ✓ +- I1 (cache key collision) → Task 3 ✓ +- I2 (double DB hit) → Task 7 ✓ +- I3 (NULL_SENTINEL) → Task 6 ✓ +- I4 (unix_now panic) → Task 1 ✓ +- M1 (duplicate validator) → Task 4 ✓ +- M3 (permissive CORS) → Task 9 ✓ +- M5 (body size limit) → Task 9 ✓ +- M6 (spurious mut) → Task 11 ✓ + +**Dependency order:** +- Task 4 (shared validator) must run before Task 5 (it uses `is_valid_identifier`) +- Task 6 (Value params) must run before Task 8 (handle_crud signature stabilizes) +- Task 7 (stash Claims) must run before Task 8 (handle_crud reads claims from extensions) +- All other tasks are independent + +**Type consistency check:** +- `build_query` returns `Vec` after Task 6; all callers updated in the same task ✓ +- `is_valid_identifier` returns `bool` (added Task 4); callers in Tasks 4 and 5 check `!is_valid_identifier(...)` ✓ +- `authenticate()` returns `Option` (Task 7); all require_* callers use `.ok_or(UNAUTHORIZED)?` ✓ diff --git a/docs/superpowers/specs/2026-06-16-mercury-design.md b/docs/superpowers/specs/2026-06-16-mercury-design.md new file mode 100644 index 0000000..b5a1549 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-mercury-design.md @@ -0,0 +1,323 @@ +# Mercury — Design Spec +**Date:** 2026-06-16 + +## Overview + +Mercury is a monolithic, high-performance universal CRUD API written in Rust. It accepts HTTP requests, maps them to PostgreSQL tables, generates and caches SQL dynamically, and exposes an admin suite (with a Vue 3 frontend) for managing queries, users, permissions, and route security. A JWT-based bitmask permission system gates all admin operations. + +--- + +## Architecture + +``` +HTTP Request + ↓ + Blacklist Middleware (in-memory glob match) + ↓ + Auth Middleware (JWT decode + permission bit check, admin routes only) + ↓ + Axum Router + ↓ + Query Cache (DashMap) + ↓ cache miss + Query Registry (PostgreSQL: queries table) + ↓ query not yet registered + Query Builder (auto-generates SQL from route + HTTP method) + ↓ + PostgreSQL (sqlx PgPool) +``` + +**Stack:** +- `axum` — async HTTP framework (tokio-native) +- `sqlx` — async PostgreSQL driver, raw query support, compile-time checked migrations +- `DashMap` — concurrent hashmap for the query cache +- `glob` — compiled pattern matching for the route blacklist +- `jsonwebtoken` — JWT encode/decode +- Vue 3 + Vite + `@nychthemeron/library` — frontend, served as static files by Rust +- `tower-http::ServeDir` — serves `ui/dist/` at `/` + +--- + +## Endpoints + +### Public + +``` +POST /auth/login → returns JWT +``` + +### CRUD (no auth, blacklist-checked) + +``` +GET /api/{table} → SELECT * FROM {table} [?col=val filters] +GET /api/{table}/{id} → SELECT * FROM {table} WHERE id = {id} +POST /api/{table} → INSERT INTO {table} +PUT /api/{table}/{id} → UPDATE {table} WHERE id = {id} +DELETE /api/{table}/{id} → DELETE FROM {table} WHERE id = {id} +``` + +`users` and `permissions` are blacklisted by default — only accessible via the admin suite. + +### Admin — Query Registry (JWT + `ADMIN_QUERY` bit) + +``` +GET /admin/queries → list all registered queries +POST /admin/queries → register raw SQL template +GET /admin/queries/{identifier} → get query by slug identifier +PUT /admin/queries/{identifier} → update SQL template or metadata +DELETE /admin/queries/{identifier} → remove from registry + evict from cache +GET /admin/queries/{identifier}/execute → execute query with ?param=val bindings +``` + +`{identifier}` is the human-readable slug (e.g. `get-user-orders`), not the internal UUID. The UUID is used internally only. + +### Admin — Cache (JWT + `ADMIN_CACHE` bit) + +``` +GET /admin/cache/stats → current size, hit count, miss count +DELETE /admin/cache → flush entire cache +``` + +### Admin — Users (JWT + `SUPER_ADMIN` bit) + +``` +GET /admin/users → list users +POST /admin/users → create user +GET /admin/users/{id} → get user +PUT /admin/users/{id} → update user +DELETE /admin/users/{id} → delete user +POST /admin/users/{id}/permissions/grant/{bit_value} → OR bit into mask +DELETE /admin/users/{id}/permissions/revoke/{bit_value} → AND NOT bit from mask +``` + +### Admin — Permissions (JWT + `SUPER_ADMIN` bit) + +``` +GET /admin/permissions → list all permission definitions +POST /admin/permissions → create custom permission (auto-assigns next available bit_value) +PUT /admin/permissions/{id} → update name/description +DELETE /admin/permissions/{id} → remove permission definition +``` + +### Admin — Blacklist (JWT + `SUPER_ADMIN` bit) + +``` +GET /admin/blacklist → list all entries +POST /admin/blacklist → add route pattern +PUT /admin/blacklist/{id} → update entry +DELETE /admin/blacklist/{id} → remove entry +``` + +Any write to the blacklist immediately reloads the in-memory blacklist from the DB. + +### Frontend (served by Rust) + +``` +GET / → serves ui/dist/index.html (SPA entry) +GET /assets/* → static assets +``` + +--- + +## Database Schema + +### `users` +| column | type | notes | +|---|---|---| +| id | SERIAL PRIMARY KEY | | +| username | VARCHAR UNIQUE NOT NULL | | +| password_hash | TEXT NOT NULL | bcrypt | +| permissions_mask | NUMERIC NOT NULL DEFAULT 0 | u128 bitmask | +| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | | + +### `permissions` +| column | type | notes | +|---|---|---| +| id | SERIAL PRIMARY KEY | | +| name | VARCHAR UNIQUE NOT NULL | e.g. `READ` | +| bit_value | NUMERIC UNIQUE NOT NULL | power of 2, up to 2^127 | +| description | TEXT | | + +### `queries` +| column | type | notes | +|---|---|---| +| id | UUID PRIMARY KEY DEFAULT gen_random_uuid() | | +| identifier | VARCHAR UNIQUE NOT NULL | human-readable slug | +| sql_template | TEXT NOT NULL | raw SQL with :param placeholders | +| description | TEXT | | +| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | | +| updated_at | TIMESTAMPTZ NOT NULL DEFAULT now() | | + +### `blacklist` +| column | type | notes | +|---|---|---| +| id | SERIAL PRIMARY KEY | | +| pattern | VARCHAR NOT NULL | glob-style, e.g. `/api/users/**` | +| method | VARCHAR | NULL = all methods | +| reason | TEXT | | +| active | BOOLEAN NOT NULL DEFAULT true | | +| created_at | TIMESTAMPTZ NOT NULL DEFAULT now() | | + +### Seed Data (migration) +Default permission definitions: + +| name | bit_value | +|---|---| +| READ | 1 | +| WRITE | 2 | +| DELETE | 4 | +| ADMIN_QUERY | 8 | +| ADMIN_CACHE | 16 | +| SUPER_ADMIN | 32 | + +Default user: `admin` / `admin`, `permissions_mask = 63` (all bits set). + +Default blacklist entries: `/api/users/**` and `/api/permissions/**` (method: NULL). + +--- + +## Query Cache + +**Structure:** `DashMap` + +- Key: deterministic string derived from `{METHOD}:{table}:{params}` for auto-generated queries, or query `identifier` for named queries +- Value: the compiled query + timestamp of last access +- Hit/miss counters: two `AtomicU64` values on the shared state struct + +**Eviction:** a tokio background task runs every `CACHE_SWEEP_INTERVAL_SECS`. It iterates the map and removes entries where `last_accessed.elapsed() > CACHE_IDLE_TIMEOUT_SECS`. + +**Capacity:** before inserting a new entry, if `cache.len() >= CACHE_MAX_CAPACITY`, the entry with the oldest `last_accessed` is evicted first. + +**Cache invalidation:** deleting or updating a query via the admin suite immediately removes it from the DashMap. + +--- + +## Blacklist Cache + +**Structure:** `Arc>>` + +- Loaded from DB at startup +- Reloaded (write lock, full rebuild from DB) on any admin write to the `blacklist` table +- Checked in middleware on every request before routing: if any active entry matches `(method, path)`, return `403 Forbidden` + +--- + +## Permission System + +Permissions are u128 bitmasks. The JWT claims embed the user's current `permissions_mask`. Middleware extracts the JWT and performs a bitwise AND check — no DB lookup per request. + +``` +READ = 1 +WRITE = 2 +DELETE = 4 +ADMIN_QUERY = 8 +ADMIN_CACHE = 16 +SUPER_ADMIN = 32 +``` + +Custom permissions auto-assign the next unused power-of-2 bit_value. Up to 128 distinct permission bits supported. + +`permissions_mask` stored as PostgreSQL `NUMERIC` (arbitrary precision), converted to/from `u128` in the Rust data layer. + +--- + +## JWT + +- Algorithm: HS256 +- Claims: `sub` (username), `permissions` (u128 as string), `exp` +- Secret: `JWT_SECRET` env var +- Passed as `Authorization: Bearer ` header +- Token expiry: configurable via `JWT_EXPIRY_SECS` + +--- + +## Configuration (env vars) + +```env +DATABASE_URL=postgres://mercury:mercury@db:5432/mercury +JWT_SECRET=changeme +JWT_EXPIRY_SECS=3600 +CACHE_MAX_CAPACITY=10000 +CACHE_IDLE_TIMEOUT_SECS=300 +CACHE_SWEEP_INTERVAL_SECS=60 +``` + +--- + +## Project Structure + +``` +Mercury/ + src/ + main.rs -- startup: config, DB pool, cache init, router mount + config.rs -- Config struct loaded from env + auth/ + mod.rs -- JWT encode/decode, Claims struct + middleware.rs -- axum middleware: validate JWT, check permission bit + cache/ + query_cache.rs -- DashMap cache + sweep task + hit/miss counters + blacklist_cache.rs -- Arc> + glob::Pattern, reload fn + db/ + pool.rs -- sqlx PgPool init + migrations/ -- .sql files, run via sqlx::migrate!() + 001_initial.sql -- all tables + seed data + routes/ + crud.rs -- generic /api/{table} handler + query builder + auth.rs -- /auth/login + admin/ + queries.rs + cache.rs + users.rs + permissions.rs + blacklist.rs + models/ + query.rs + user.rs + permission.rs + blacklist.rs + ui/ + .npmrc -- @nychthemeron:registry=https://git.mcpeakdev.com/api/packages/McPeakDev/npm/ + package.json + vite.config.ts + src/ + main.ts -- dark mode default, router, pinia init + App.vue + router/index.ts -- route guards: redirect /login if no JWT + stores/auth.ts -- JWT storage, permissions bitmask helpers + views/ + Login.vue + admin/ + Queries.vue + Users.vue + Permissions.vue + Blacklist.vue + Cache.vue + Cargo.toml + docker-compose.yml + Dockerfile -- multi-stage: Node build UI, Rust build API + .env.example +``` + +--- + +## Docker + +**Multi-stage Dockerfile:** +1. Stage 1 (node): install `@nychthemeron/library`, run `vite build` → `ui/dist/` +2. Stage 2 (rust): compile API, copy `ui/dist/` into final image +3. Final image runs the Rust binary; `tower-http::ServeDir` serves `ui/dist/` at `/` + +**docker-compose.yml:** +- `db` service: `postgres:16-alpine`, persistent volume, health check +- `api` service: built from Dockerfile, depends on `db`, `mem_limit` set, env vars from `.env` +- Migrations run at API startup via `sqlx::migrate!()` + +--- + +## Frontend Contract + +- Dark mode is the default; set via `@nychthemeron/library` theme config in `main.ts` +- Login page at `/login` calls `POST /auth/login`, stores JWT in `localStorage` +- All admin views require `SUPER_ADMIN` bit in JWT; route guard redirects to `/login` if absent +- Permission bitmask helpers in `stores/auth.ts`: `hasPermission(bit: number): boolean` +- Admin views wire directly to their respective admin endpoints diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 0000000..5c9a391 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +set -e + +PGDATA="${PGDATA:-/var/lib/postgresql/data}" +PGLOG="/var/log/postgresql/postgresql.log" + +mkdir -p "$PGDATA" /var/log/postgresql +chown postgres:postgres "$PGDATA" /var/log/postgresql + +if [ ! -f "$PGDATA/PG_VERSION" ]; then + su -s /bin/sh postgres -c "initdb -D $PGDATA" + su -s /bin/sh postgres -c "pg_ctl start -D $PGDATA -w -l $PGLOG" + su -s /bin/sh postgres -c "psql postgres -c \"CREATE USER mercury WITH PASSWORD 'mercury';\"" + su -s /bin/sh postgres -c "psql postgres -c \"CREATE DATABASE mercury OWNER mercury;\"" + su -s /bin/sh postgres -c "pg_ctl stop -D $PGDATA -w" +fi + +su -s /bin/sh postgres -c "pg_ctl start -D $PGDATA -w -l $PGLOG" + +exec /app/mercury diff --git a/pics/Mercury-Login.png b/pics/Mercury-Login.png new file mode 100644 index 0000000000000000000000000000000000000000..7cbda99a487ddaee6a675e62a3611e9aa8ae8104 GIT binary patch literal 127837 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

OTBzoZQN= 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 h`w8v!TeNi7Hzsf+muP0(%T&|Jq*<6D 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 literal 0 HcmV?d00001 diff --git a/src/auth/middleware.rs b/src/auth/middleware.rs new file mode 100644 index 0000000..2817420 --- /dev/null +++ b/src/auth/middleware.rs @@ -0,0 +1,117 @@ +use axum::{ + extract::{Request, State}, + http::StatusCode, + middleware::Next, + response::Response, +}; + +use crate::{ + auth::{decode_jwt, resolve_api_key, Claims}, + state::AppState, +}; + +/// Resolves a Bearer token to Claims, trying JWT then API key. +/// Inserts Claims into request extensions on success so downstream +/// middleware can reuse them without an additional DB round-trip. +async fn authenticate(token: &str, state: &AppState, req: &mut Request) -> Option { + if let Some(existing) = req.extensions().get::().cloned() { + return Some(existing); + } + let claims = if let Ok(c) = decode_jwt(token, &state.config.jwt_secret) { + c + } else { + resolve_api_key(token, &state.pool).await? + }; + req.extensions_mut().insert(claims.clone()); + Some(claims) +} + +pub async fn blacklist_layer( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let method = req.method().as_str().to_uppercase(); + let path = req.uri().path().to_string(); + let caller_mask = if let Some(token) = extract_bearer(&req) { + authenticate(&token, &state, &mut req) + .await + .map(|c| c.permissions_mask()) + .unwrap_or(0) + } else { + 0 + }; + if state + .blacklist_cache + .is_blocked(&method, &path, caller_mask) + .await + { + return Err(StatusCode::FORBIDDEN); + } + Ok(next.run(req).await) +} + +pub fn extract_bearer(req: &Request) -> Option { + req.headers() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.strip_prefix("Bearer ")) + .map(|s| s.to_string()) +} + +pub async fn require_auth( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + authenticate(&token, &state, &mut req) + .await + .ok_or(StatusCode::UNAUTHORIZED)?; + Ok(next.run(req).await) +} + +pub async fn require_super_admin( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + let claims = authenticate(&token, &state, &mut req) + .await + .ok_or(StatusCode::UNAUTHORIZED)?; + if !claims.has_permission(crate::auth::permissions::SUPER_ADMIN) { + return Err(StatusCode::FORBIDDEN); + } + Ok(next.run(req).await) +} + +pub async fn require_admin_query( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + let claims = authenticate(&token, &state, &mut req) + .await + .ok_or(StatusCode::UNAUTHORIZED)?; + if !claims.has_permission(crate::auth::permissions::ADMIN_QUERY) { + return Err(StatusCode::FORBIDDEN); + } + Ok(next.run(req).await) +} + +pub async fn require_admin_cache( + State(state): State, + mut req: Request, + next: Next, +) -> Result { + let token = extract_bearer(&req).ok_or(StatusCode::UNAUTHORIZED)?; + let claims = authenticate(&token, &state, &mut req) + .await + .ok_or(StatusCode::UNAUTHORIZED)?; + if !claims.has_permission(crate::auth::permissions::ADMIN_CACHE) { + return Err(StatusCode::FORBIDDEN); + } + Ok(next.run(req).await) +} diff --git a/src/auth/mod.rs b/src/auth/mod.rs new file mode 100644 index 0000000..fb6a7df --- /dev/null +++ b/src/auth/mod.rs @@ -0,0 +1,123 @@ +pub mod middleware; + +use anyhow::Result; +use chrono::Utc; +use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +#[allow(dead_code)] +pub mod permissions { + pub const READ: u128 = 1; + pub const WRITE: u128 = 2; + pub const DELETE: u128 = 4; + pub const ADMIN_QUERY: u128 = 8; + pub const ADMIN_CACHE: u128 = 16; + pub const SUPER_ADMIN: u128 = 32; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Claims { + pub sub: String, + pub permissions: String, // u128 stored as decimal string + pub exp: usize, +} + +impl Claims { + pub fn permissions_mask(&self) -> u128 { + self.permissions.parse().unwrap_or(0) + } + + pub fn has_permission(&self, bit: u128) -> bool { + self.permissions_mask() & bit != 0 + } +} + +pub fn encode_jwt(username: &str, mask: u128, secret: &str, expiry_secs: u64) -> Result { + let exp = (Utc::now().timestamp() as u64 + expiry_secs) as usize; + let claims = Claims { + sub: username.to_string(), + permissions: mask.to_string(), + exp, + }; + let token = encode( + &Header::default(), + &claims, + &EncodingKey::from_secret(secret.as_bytes()), + )?; + Ok(token) +} + +/// Generates a new API key: `mrc_<32 random hex chars>`. +/// Returns `(plain_key, key_prefix, key_hash)`. +pub fn generate_api_key() -> (String, String, String) { + let raw = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let key = format!("mrc_{}", raw); + let prefix = raw[..8].to_string(); + let hash = hash_api_key(&key); + (key, prefix, hash) +} + +pub fn hash_api_key(key: &str) -> String { + format!("{:x}", Sha256::digest(key.as_bytes())) +} + +/// Resolves an API key token against the database. +/// Updates `last_used_at` on success. +pub async fn resolve_api_key(token: &str, pool: &sqlx::PgPool) -> Option { + if !token.starts_with("mrc_") { + return None; + } + let hash = hash_api_key(token); + let row: (String, String) = sqlx::query_as( + "UPDATE api_keys SET last_used_at = now() + WHERE key_hash = $1 AND (expires_at IS NULL OR expires_at > now()) + RETURNING name, permissions_mask", + ) + .bind(&hash) + .fetch_optional(pool) + .await + .ok()??; + + Some(Claims { + sub: row.0, + permissions: row.1, + exp: usize::MAX, + }) +} + +pub fn decode_jwt(token: &str, secret: &str) -> Result { + let data = decode::( + token, + &DecodingKey::from_secret(secret.as_bytes()), + &Validation::default(), + )?; + Ok(data.claims) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encode_decode_roundtrip() { + let secret = "test_secret"; + let token = encode_jwt("alice", 63u128, secret, 3600).unwrap(); + let claims = decode_jwt(&token, secret).unwrap(); + assert_eq!(claims.sub, "alice"); + assert_eq!(claims.permissions_mask(), 63u128); + } + + #[test] + fn test_has_permission() { + let claims = Claims { + sub: "bob".into(), + permissions: "9".into(), // READ (1) + ADMIN_QUERY (8) + exp: 9999999999, + }; + assert!(claims.has_permission(permissions::READ)); + assert!(claims.has_permission(permissions::ADMIN_QUERY)); + assert!(!claims.has_permission(permissions::SUPER_ADMIN)); + } +} diff --git a/src/cache/mod.rs b/src/cache/mod.rs new file mode 100644 index 0000000..a1ca063 --- /dev/null +++ b/src/cache/mod.rs @@ -0,0 +1,2 @@ +pub mod sweep; +pub use sweep::spawn_sweep_task; diff --git a/src/cache/sweep.rs b/src/cache/sweep.rs new file mode 100644 index 0000000..bee1933 --- /dev/null +++ b/src/cache/sweep.rs @@ -0,0 +1,15 @@ +use crate::state::QueryCache; +use std::time::Duration; + +pub fn spawn_sweep_task(cache: QueryCache, idle_timeout_secs: u64, sweep_interval_secs: u64) { + tokio::spawn(async move { + let interval = Duration::from_secs(sweep_interval_secs); + loop { + tokio::time::sleep(interval).await; + let now = crate::state::unix_now(); + cache + .map + .retain(|_, entry| now.saturating_sub(entry.last_accessed()) < idle_timeout_secs); + } + }); +} diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..8970d6a --- /dev/null +++ b/src/config.rs @@ -0,0 +1,57 @@ +use anyhow::Result; + +#[derive(Clone, Debug)] +pub struct Config { + pub database_url: String, + pub jwt_secret: String, + pub jwt_expiry_secs: u64, + pub cache_max_capacity: usize, + pub cache_idle_timeout_secs: u64, + pub cache_sweep_interval_secs: u64, + /// Comma-separated allowed CORS origins, or "*" for permissive. Empty = no CORS headers. + pub cors_origins: Vec, +} + +impl Config { + pub fn from_env() -> Result { + Ok(Self { + database_url: std::env::var("DATABASE_URL")?, + jwt_secret: std::env::var("JWT_SECRET")?, + jwt_expiry_secs: std::env::var("JWT_EXPIRY_SECS") + .unwrap_or_else(|_| "3600".into()) + .parse()?, + cache_max_capacity: std::env::var("CACHE_MAX_CAPACITY") + .unwrap_or_else(|_| "10000".into()) + .parse()?, + cache_idle_timeout_secs: std::env::var("CACHE_IDLE_TIMEOUT_SECS") + .unwrap_or_else(|_| "300".into()) + .parse()?, + cache_sweep_interval_secs: std::env::var("CACHE_SWEEP_INTERVAL_SECS") + .unwrap_or_else(|_| "60".into()) + .parse()?, + cors_origins: std::env::var("CORS_ORIGINS") + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_defaults() { + std::env::set_var("DATABASE_URL", "postgres://test"); + std::env::set_var("JWT_SECRET", "secret"); + let cfg = Config::from_env().unwrap(); + assert_eq!(cfg.jwt_expiry_secs, 3600); + assert_eq!(cfg.cache_max_capacity, 10_000); + assert_eq!(cfg.cache_idle_timeout_secs, 300); + assert_eq!(cfg.cache_sweep_interval_secs, 60); + assert!(cfg.cors_origins.is_empty()); + } +} diff --git a/src/db/migrations/001_initial.sql b/src/db/migrations/001_initial.sql new file mode 100644 index 0000000..e2b3711 --- /dev/null +++ b/src/db/migrations/001_initial.sql @@ -0,0 +1,48 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +CREATE TABLE users ( + id SERIAL PRIMARY KEY, + username VARCHAR(255) UNIQUE NOT NULL, + password_hash TEXT NOT NULL, + permissions_mask TEXT NOT NULL DEFAULT '0', + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE permissions ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) UNIQUE NOT NULL, + bit_value TEXT UNIQUE NOT NULL, + description TEXT +); + +CREATE TABLE queries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + identifier VARCHAR(255) UNIQUE NOT NULL, + sql_template TEXT NOT NULL, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE TABLE blacklist ( + id SERIAL PRIMARY KEY, + pattern VARCHAR(500) NOT NULL, + method VARCHAR(10), + reason TEXT, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Seed permissions +INSERT INTO permissions (name, bit_value, description) VALUES + ('READ', '1', 'Can read via CRUD endpoints'), + ('WRITE', '2', 'Can insert/update via CRUD'), + ('DELETE', '4', 'Can delete via CRUD'), + ('ADMIN_QUERY', '8', 'Can manage query registry'), + ('ADMIN_CACHE', '16', 'Can manage cache'), + ('SUPER_ADMIN', '32', 'Full access'); + +-- Seed blacklist (users and permissions tables are admin-only) +INSERT INTO blacklist (pattern, method, reason, active) VALUES + ('/api/users/**', NULL, 'admin-only table', true), + ('/api/permissions/**', NULL, 'admin-only table', true); diff --git a/src/db/migrations/002_blacklist_system_tables.sql b/src/db/migrations/002_blacklist_system_tables.sql new file mode 100644 index 0000000..8823f83 --- /dev/null +++ b/src/db/migrations/002_blacklist_system_tables.sql @@ -0,0 +1,9 @@ +-- Block PostgreSQL system catalog tables from the public CRUD API. +-- The pg_ prefix covers pg_tables, pg_class, pg_user, pg_shadow, pg_authid, etc. +-- information_schema columns contain dots so they're already rejected by the +-- identifier validator, but we block them here for defense in depth. +INSERT INTO blacklist (pattern, method, reason, active) VALUES + ('/api/pg_*', NULL, 'postgresql system catalog', true), + ('/api/pg_*/**', NULL, 'postgresql system catalog', true), + ('/api/information_schema', NULL, 'postgresql information schema', true), + ('/api/information_schema/**', NULL, 'postgresql information schema', true); diff --git a/src/db/migrations/003_blacklist_bypass_permission.sql b/src/db/migrations/003_blacklist_bypass_permission.sql new file mode 100644 index 0000000..2efe878 --- /dev/null +++ b/src/db/migrations/003_blacklist_bypass_permission.sql @@ -0,0 +1,32 @@ +-- Allow blacklist entries to be bypassed by callers who hold a specific permission bit. +-- bypass_mask NULL means the rule blocks everyone unconditionally. +ALTER TABLE blacklist ADD COLUMN bypass_mask TEXT; + +-- Existing user/permission rules: SUPER_ADMIN (bit 32) can bypass them so the +-- admin UI can reach these tables via the generic /api/:table CRUD endpoint. +UPDATE blacklist +SET bypass_mask = '32' +WHERE pattern IN ('/api/users/**', '/api/permissions/**'); + +-- Add rules for queries and blacklist tables, which also need admin-only CRUD access. +INSERT INTO blacklist (pattern, method, reason, active, bypass_mask) VALUES + ('/api/users', NULL, 'admin-only table', true, '32'), + ('/api/permissions', NULL, 'admin-only table', true, '32'), + ('/api/queries', NULL, 'admin-only table', true, '32'), + ('/api/queries/**', NULL, 'admin-only table', true, '32'), + ('/api/blacklist', NULL, 'admin-only table', true, '32'), + ('/api/blacklist/**', NULL, 'admin-only table', true, '32'); + +-- Automatically update updated_at on queries mutations so the generic CRUD +-- endpoint doesn't need to know about that column. +CREATE OR REPLACE FUNCTION update_updated_at() +RETURNS TRIGGER AS $$ +BEGIN + NEW.updated_at = NOW(); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER queries_updated_at + BEFORE UPDATE ON queries + FOR EACH ROW EXECUTE FUNCTION update_updated_at(); diff --git a/src/db/migrations/004_blacklist_method_text.sql b/src/db/migrations/004_blacklist_method_text.sql new file mode 100644 index 0000000..b9582b5 --- /dev/null +++ b/src/db/migrations/004_blacklist_method_text.sql @@ -0,0 +1,2 @@ +-- Widen method column to support comma-separated multi-method values e.g. "GET,POST" +ALTER TABLE blacklist ALTER COLUMN method TYPE TEXT; diff --git a/src/db/migrations/005_api_keys.sql b/src/db/migrations/005_api_keys.sql new file mode 100644 index 0000000..75ef167 --- /dev/null +++ b/src/db/migrations/005_api_keys.sql @@ -0,0 +1,12 @@ +CREATE TABLE api_keys ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + key_prefix VARCHAR(16) NOT NULL, + key_hash TEXT NOT NULL UNIQUE, + permissions_mask TEXT NOT NULL DEFAULT '0', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ, + last_used_at TIMESTAMPTZ +); + +CREATE INDEX idx_api_keys_hash ON api_keys(key_hash); diff --git a/src/db/migrations/006_seed_api_keys_blacklist.sql b/src/db/migrations/006_seed_api_keys_blacklist.sql new file mode 100644 index 0000000..1ddf16c --- /dev/null +++ b/src/db/migrations/006_seed_api_keys_blacklist.sql @@ -0,0 +1,5 @@ +-- Prevent raw CRUD access to the api_keys table (would expose key_hash). +-- The proper endpoints live at /api/admin/api-keys and are guarded by require_super_admin. +INSERT INTO blacklist (pattern, method, reason, active) VALUES + ('/api/api_keys', NULL, 'api key hashes must not be exposed via generic CRUD', true), + ('/api/api_keys/**', NULL, 'api key hashes must not be exposed via generic CRUD', true); diff --git a/src/db/mod.rs b/src/db/mod.rs new file mode 100644 index 0000000..620c68e --- /dev/null +++ b/src/db/mod.rs @@ -0,0 +1,2 @@ +pub mod pool; +pub use pool::create_pool; diff --git a/src/db/pool.rs b/src/db/pool.rs new file mode 100644 index 0000000..d2c4790 --- /dev/null +++ b/src/db/pool.rs @@ -0,0 +1,11 @@ +use anyhow::Result; +use sqlx::{postgres::PgPoolOptions, PgPool}; + +pub async fn create_pool(database_url: &str) -> Result { + let pool = PgPoolOptions::new() + .max_connections(10) + .connect(database_url) + .await?; + sqlx::migrate!("src/db/migrations").run(&pool).await?; + Ok(pool) +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..2db7dac --- /dev/null +++ b/src/main.rs @@ -0,0 +1,186 @@ +mod auth; +mod cache; +mod config; +mod db; +mod models; +mod routes; +mod state; + +use std::io::{self, Write}; +use std::sync::Arc; + +use axum::{ + extract::DefaultBodyLimit, + http::{header, HeaderValue, Method}, + middleware, + routing::{get, post}, + Router, +}; +use tower_http::{ + cors::CorsLayer, + services::{ServeDir, ServeFile}, +}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +use crate::{ + auth::middleware::{blacklist_layer, require_auth}, + cache::spawn_sweep_task, + config::Config, + db::create_pool, + models::blacklist::BlacklistEntry, + routes::{admin::admin_router, auth::login, crud::handle_crud}, + state::{AppState, BlacklistCache, QueryCache}, +}; + +fn build_cors(origins: &[String]) -> CorsLayer { + if origins.is_empty() { + return CorsLayer::new(); + } + if origins.iter().any(|o| o == "*") { + return CorsLayer::permissive(); + } + let parsed: Vec = origins.iter().filter_map(|o| o.parse().ok()).collect(); + CorsLayer::new() + .allow_origin(parsed) + .allow_methods([ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::OPTIONS, + ]) + .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION]) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + dotenvy::dotenv().ok(); + + tracing_subscriber::registry() + .with( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "mercury=info".into()), + ) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let config = Arc::new(Config::from_env()?); + let pool = create_pool(&config.database_url).await?; + + // If no users exist, prompt to create the first admin interactively. + let user_count: i64 = sqlx::query_scalar::<_, Option>("SELECT COUNT(*) FROM users") + .fetch_one(&pool) + .await? + .unwrap_or(0); + if user_count == 0 { + let (username, password) = match ( + std::env::var("MERCURY_ADMIN_USER") + .ok() + .filter(|s| !s.is_empty()), + std::env::var("MERCURY_ADMIN_PASSWORD") + .ok() + .filter(|s| !s.is_empty()), + ) { + (Some(u), Some(p)) => { + tracing::info!("creating first admin user from environment variables"); + (u, p) + } + _ => { + println!("\nNo users found. Create the first admin account."); + print!("Username: "); + io::stdout().flush()?; + let mut username = String::new(); + io::stdin().read_line(&mut username)?; + let username = username.trim().to_string(); + if username.is_empty() { + anyhow::bail!("username cannot be empty"); + } + + print!("Password: "); + io::stdout().flush()?; + let mut password = String::new(); + io::stdin().read_line(&mut password)?; + let password = password.trim().to_string(); + if password.is_empty() { + anyhow::bail!("password cannot be empty"); + } + (username, password) + } + }; + + let hash = bcrypt::hash(&password, bcrypt::DEFAULT_COST)?; + sqlx::query( + "INSERT INTO users (username, password_hash, permissions_mask) VALUES ($1, $2, '63')", + ) + .bind(&username) + .bind(hash) + .execute(&pool) + .await?; + tracing::info!("created first admin user: {}", username); + println!("Admin user '{}' created. Starting server...\n", username); + } + + let query_cache = QueryCache::new(); + let blacklist_cache = BlacklistCache::new(); + + // Load blacklist from DB into memory + let entries = sqlx::query_as::<_, BlacklistEntry>( + "SELECT id, pattern, method, reason, active, bypass_mask, created_at FROM blacklist ORDER BY id" + ) + .fetch_all(&pool) + .await?; + blacklist_cache.load(entries).await; + + // Start cache sweep + spawn_sweep_task( + query_cache.clone(), + config.cache_idle_timeout_secs, + config.cache_sweep_interval_secs, + ); + + let state = AppState { + pool, + query_cache, + blacklist_cache, + config: config.clone(), + }; + + let crud_routes = Router::new() + .route("/api/:table", get(handle_crud).post(handle_crud)) + .route("/api/:table/", get(handle_crud).post(handle_crud)) + .route( + "/api/:table/:id", + get(handle_crud).put(handle_crud).delete(handle_crud), + ) + .route( + "/api/:table/:id/", + get(handle_crud).put(handle_crud).delete(handle_crud), + ) + .layer(DefaultBodyLimit::max(1024 * 1024)) + // require_auth is inner (added first); blacklist_layer is outer (added last, runs first). + // Order: blacklist check → auth check → handler. + .route_layer(middleware::from_fn_with_state(state.clone(), require_auth)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + blacklist_layer, + )); + + let cors_layer = build_cors(&config.cors_origins); + + let app = Router::new() + .route("/auth/login", post(login)) + .merge(crud_routes) + .nest("/api/admin", admin_router(state.clone())) + .nest_service( + "/", + ServeDir::new("ui/dist").fallback(ServeFile::new("ui/dist/index.html")), + ) + .layer(cors_layer) + .with_state(state); + + let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 3000)); + tracing::info!("listening on {}", addr); + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + Ok(()) +} diff --git a/src/models/api_key.rs b/src/models/api_key.rs new file mode 100644 index 0000000..7437c6b --- /dev/null +++ b/src/models/api_key.rs @@ -0,0 +1,27 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct ApiKey { + pub id: i32, + pub name: String, + pub key_prefix: String, + pub permissions_mask: String, + pub created_at: DateTime, + pub expires_at: Option>, + pub last_used_at: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct CreateApiKey { + pub name: String, + pub permissions_mask: Option, + pub expires_at: Option>, +} + +#[derive(Debug, Deserialize)] +pub struct UpdateApiKey { + pub name: Option, + pub permissions_mask: Option, + pub expires_at: Option>, +} diff --git a/src/models/blacklist.rs b/src/models/blacklist.rs new file mode 100644 index 0000000..7ed6b2b --- /dev/null +++ b/src/models/blacklist.rs @@ -0,0 +1,30 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct BlacklistEntry { + pub id: i32, + pub pattern: String, + pub method: Option, + pub reason: Option, + pub active: bool, + pub bypass_mask: Option, + pub created_at: DateTime, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct CreateBlacklistEntry { + pub pattern: String, + pub method: Option, + pub reason: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct UpdateBlacklistEntry { + pub pattern: Option, + pub method: Option, + pub reason: Option, + pub active: Option, +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..a83bc1d --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,5 @@ +pub mod api_key; +pub mod blacklist; +pub mod permission; +pub mod query; +pub mod user; diff --git a/src/models/permission.rs b/src/models/permission.rs new file mode 100644 index 0000000..cf702fc --- /dev/null +++ b/src/models/permission.rs @@ -0,0 +1,24 @@ +use serde::{Deserialize, Serialize}; + +#[allow(dead_code)] +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct Permission { + pub id: i32, + pub name: String, + pub bit_value: String, + pub description: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct CreatePermission { + pub name: String, + pub description: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct UpdatePermission { + pub name: Option, + pub description: Option, +} diff --git a/src/models/query.rs b/src/models/query.rs new file mode 100644 index 0000000..b0b8082 --- /dev/null +++ b/src/models/query.rs @@ -0,0 +1,28 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct StoredQuery { + pub id: Uuid, + pub identifier: String, + pub sql_template: String, + pub description: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct CreateQuery { + pub identifier: String, + pub sql_template: String, + pub description: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct UpdateQuery { + pub sql_template: Option, + pub description: Option, +} diff --git a/src/models/user.rs b/src/models/user.rs new file mode 100644 index 0000000..ec9ff38 --- /dev/null +++ b/src/models/user.rs @@ -0,0 +1,34 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)] +pub struct User { + pub id: i32, + pub username: String, + #[serde(skip_serializing)] + pub password_hash: String, + pub permissions_mask: String, + pub created_at: DateTime, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct CreateUser { + pub username: String, + pub password: String, + pub permissions_mask: Option, +} + +#[allow(dead_code)] +#[derive(Debug, Deserialize)] +pub struct UpdateUser { + pub username: Option, + pub password: Option, + pub permissions_mask: Option, +} + +#[derive(Debug, Deserialize)] +pub struct LoginRequest { + pub username: String, + pub password: String, +} diff --git a/src/routes/admin/api_keys.rs b/src/routes/admin/api_keys.rs new file mode 100644 index 0000000..58ab6da --- /dev/null +++ b/src/routes/admin/api_keys.rs @@ -0,0 +1,103 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + Json, +}; +use serde_json::{json, Value}; + +use crate::{ + auth::generate_api_key, + models::api_key::{ApiKey, CreateApiKey, UpdateApiKey}, + state::AppState, +}; + +pub async fn list_api_keys(State(state): State) -> Result>, StatusCode> { + let keys = sqlx::query_as::<_, ApiKey>( + "SELECT id, name, key_prefix, permissions_mask, created_at, expires_at, last_used_at + FROM api_keys ORDER BY created_at DESC", + ) + .fetch_all(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + Ok(Json(keys)) +} + +pub async fn create_api_key( + State(state): State, + Json(body): Json, +) -> Result, StatusCode> { + let (plain_key, key_prefix, key_hash) = generate_api_key(); + let mask = body.permissions_mask.unwrap_or_else(|| "0".into()); + + sqlx::query( + "INSERT INTO api_keys (name, key_prefix, key_hash, permissions_mask, expires_at) + VALUES ($1, $2, $3, $4, $5)", + ) + .bind(&body.name) + .bind(&key_prefix) + .bind(&key_hash) + .bind(&mask) + .bind(body.expires_at) + .execute(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(json!({ + "key": plain_key, + "prefix": key_prefix, + "name": body.name, + "permissions_mask": mask, + "note": "Store this key securely — it will not be shown again." + }))) +} + +pub async fn update_api_key( + State(state): State, + Path(id): Path, + Json(body): Json, +) -> Result, StatusCode> { + if body.name.is_none() && body.permissions_mask.is_none() && body.expires_at.is_none() { + return Err(StatusCode::BAD_REQUEST); + } + + let key = sqlx::query_as::<_, ApiKey>( + "UPDATE api_keys + SET name = COALESCE($1, name), + permissions_mask = COALESCE($2, permissions_mask), + expires_at = CASE WHEN $3::boolean THEN $4 ELSE expires_at END + WHERE id = $5 + RETURNING id, name, key_prefix, permissions_mask, created_at, expires_at, last_used_at", + ) + .bind(body.name.as_deref()) + .bind(body.permissions_mask.as_deref()) + .bind(body.expires_at.is_some()) + .bind(body.expires_at) + .bind(id) + .fetch_optional(&state.pool) + .await + .map_err(|e| { + tracing::error!("update api_key {}: {}", id, e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .ok_or(StatusCode::NOT_FOUND)?; + + Ok(Json(key)) +} + +pub async fn revoke_api_key( + State(state): State, + Path(id): Path, +) -> Result { + let rows = sqlx::query("DELETE FROM api_keys WHERE id = $1") + .bind(id) + .execute(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .rows_affected(); + + if rows == 0 { + Err(StatusCode::NOT_FOUND) + } else { + Ok(StatusCode::NO_CONTENT) + } +} diff --git a/src/routes/admin/cache.rs b/src/routes/admin/cache.rs new file mode 100644 index 0000000..b159eae --- /dev/null +++ b/src/routes/admin/cache.rs @@ -0,0 +1,24 @@ +use axum::extract::Extension; +use axum::{extract::State, http::StatusCode, Json}; +use serde_json::{json, Value}; + +use crate::{auth::Claims, state::AppState}; + +pub async fn cache_stats( + State(state): State, + Extension(_claims): Extension, +) -> Json { + Json(json!({ + "size": state.query_cache.len(), + "hits": state.query_cache.hits(), + "misses": state.query_cache.misses(), + })) +} + +pub async fn flush_cache( + State(state): State, + Extension(_claims): Extension, +) -> Result, StatusCode> { + state.query_cache.flush(); + Ok(Json(json!({ "flushed": true }))) +} diff --git a/src/routes/admin/mod.rs b/src/routes/admin/mod.rs new file mode 100644 index 0000000..d1709b1 --- /dev/null +++ b/src/routes/admin/mod.rs @@ -0,0 +1,59 @@ +pub mod api_keys; +pub mod cache; +pub mod queries; +pub mod tables; + +use axum::{ + middleware, + routing::{delete, get, put}, + Router, +}; + +use crate::{ + auth::middleware::{require_admin_cache, require_admin_query, require_super_admin}, + state::AppState, +}; + +pub fn admin_router(state: AppState) -> Router { + let query_routes = Router::new() + .route("/:identifier/execute", get(queries::execute_query)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_query, + )); + + let cache_routes = Router::new() + .route("/stats", get(cache::cache_stats)) + .route("/", delete(cache::flush_cache)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_admin_cache, + )); + + let super_routes = Router::new() + .route( + "/tables", + get(tables::list_tables).post(tables::create_table), + ) + .route( + "/tables/:name", + get(tables::get_table_preview).delete(tables::drop_table), + ) + .route( + "/api-keys", + get(api_keys::list_api_keys).post(api_keys::create_api_key), + ) + .route( + "/api-keys/:id", + put(api_keys::update_api_key).delete(api_keys::revoke_api_key), + ) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_super_admin, + )); + + Router::new() + .nest("/queries", query_routes) + .nest("/cache", cache_routes) + .merge(super_routes) +} diff --git a/src/routes/admin/queries.rs b/src/routes/admin/queries.rs new file mode 100644 index 0000000..ce4dd15 --- /dev/null +++ b/src/routes/admin/queries.rs @@ -0,0 +1,85 @@ +use axum::{ + extract::{Extension, Path, Query, State}, + http::StatusCode, + Json, +}; +use serde_json::Value; +use std::collections::HashMap; + +use crate::{auth::Claims, models::query::StoredQuery, state::AppState}; + +pub async fn execute_query( + State(state): State, + Extension(_claims): Extension, + Path(identifier): Path, + Query(params): Query>, +) -> Result, StatusCode> { + let stored = sqlx::query_as::<_, StoredQuery>( + "SELECT id, identifier, sql_template, description, created_at, updated_at FROM queries WHERE identifier = $1" + ) + .bind(&identifier) + .fetch_optional(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::NOT_FOUND)?; + + let mut sql = stored.sql_template.clone(); + let mut bound_vals: Vec = Vec::new(); + let mut idx = 1usize; + let mut sorted_params: Vec<(String, String)> = params.into_iter().collect(); + sorted_params.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(a.0.cmp(&b.0))); + for (name, val) in &sorted_params { + let placeholder = format!(":{}", name); + if sql.contains(&placeholder) { + sql = sql.replace(&placeholder, &format!("${}", idx)); + bound_vals.push(val.clone()); + idx += 1; + } + } + + let mut q = sqlx::query(&sql); + for val in &bound_vals { + q = q.bind(val.as_str()); + } + + let rows = q.fetch_all(&state.pool).await.map_err(|e| { + tracing::error!("query execution error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + let json_rows: Vec = rows + .into_iter() + .map(crate::routes::crud::pg_row_to_json) + .collect(); + + Ok(Json(Value::Array(json_rows))) +} + +#[cfg(test)] +mod tests { + #[test] + fn test_prefix_param_substitution_order() { + let template = "SELECT * FROM t WHERE user_id = :user_id AND username = :username"; + let mut params: Vec<(String, String)> = vec![ + ("user_id".into(), "42".into()), + ("username".into(), "alice".into()), + ]; + params.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(a.0.cmp(&b.0))); + let mut sql = template.to_string(); + for (i, (name, _)) in params.iter().enumerate() { + sql = sql.replace(&format!(":{}", name), &format!("${}", i + 1)); + } + assert!( + !sql.contains(":username"), + "placeholder not replaced: {}", + sql + ); + assert!( + !sql.contains(":user_id"), + "placeholder not replaced: {}", + sql + ); + // username (len 8) comes first → $1; user_id (len 7) → $2 + assert_eq!(sql, "SELECT * FROM t WHERE user_id = $2 AND username = $1"); + } +} diff --git a/src/routes/admin/tables.rs b/src/routes/admin/tables.rs new file mode 100644 index 0000000..03eafcb --- /dev/null +++ b/src/routes/admin/tables.rs @@ -0,0 +1,242 @@ +use axum::{ + extract::{Extension, Path, State}, + http::StatusCode, + Json, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::Row; + +use crate::{auth::Claims, routes::crud::pg_row_to_json, state::AppState}; + +const ALLOWED_TYPES: &[&str] = &[ + "TEXT", + "INTEGER", + "BIGINT", + "SMALLINT", + "BOOLEAN", + "NUMERIC", + "FLOAT4", + "FLOAT8", + "UUID", + "TIMESTAMPTZ", + "DATE", + "JSONB", +]; + +const PROTECTED_TABLES: &[&str] = &["users", "blacklist", "api_keys", "queries", "permissions"]; + +fn is_protected(name: &str) -> bool { + let lower = name.to_lowercase(); + PROTECTED_TABLES.iter().any(|&t| t == lower) +} + +#[derive(Debug, Deserialize)] +pub struct ColumnDef { + pub name: String, + pub col_type: String, + pub nullable: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreateTableRequest { + pub name: String, + pub columns: Vec, +} + +#[derive(Debug, Serialize)] +pub struct TableInfo { + pub table_name: String, + pub column_count: i64, +} + +#[derive(Debug, Serialize)] +pub struct ColumnInfo { + pub column_name: String, + pub data_type: String, + pub is_nullable: String, +} + +#[derive(Debug, Serialize)] +pub struct TablePreview { + pub table_name: String, + pub row_count: i64, + pub columns: Vec, + pub sample_rows: Vec, +} + +pub async fn list_tables( + State(state): State, + Extension(_claims): Extension, +) -> Result>, StatusCode> { + let rows = sqlx::query( + r#" + SELECT t.table_name, + COUNT(c.column_name)::bigint AS column_count + FROM information_schema.tables t + LEFT JOIN information_schema.columns c + ON c.table_schema = t.table_schema AND c.table_name = t.table_name + WHERE t.table_schema = 'public' AND t.table_type = 'BASE TABLE' + GROUP BY t.table_name + ORDER BY t.table_name + "#, + ) + .fetch_all(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let tables = rows + .into_iter() + .map(|r| TableInfo { + table_name: r.try_get::("table_name").unwrap_or_default(), + column_count: r.try_get::("column_count").unwrap_or(0), + }) + .collect(); + + Ok(Json(tables)) +} + +pub async fn get_table_preview( + State(state): State, + Extension(_claims): Extension, + Path(name): Path, +) -> Result, StatusCode> { + if !crate::routes::is_valid_identifier(&name) { + return Err(StatusCode::BAD_REQUEST); + } + + let col_rows = sqlx::query( + "SELECT column_name, data_type, is_nullable \ + FROM information_schema.columns \ + WHERE table_schema = 'public' AND table_name = $1 \ + ORDER BY ordinal_position", + ) + .bind(&name) + .fetch_all(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if col_rows.is_empty() { + return Err(StatusCode::NOT_FOUND); + } + + let columns: Vec = col_rows + .into_iter() + .map(|r| ColumnInfo { + column_name: r.try_get::("column_name").unwrap_or_default(), + data_type: r.try_get::("data_type").unwrap_or_default(), + is_nullable: r.try_get::("is_nullable").unwrap_or_default(), + }) + .collect(); + + let count_sql = format!("SELECT COUNT(*)::bigint FROM {}", name); + let row_count: i64 = sqlx::query(&count_sql) + .fetch_one(&state.pool) + .await + .map(|r| r.try_get::(0).unwrap_or(0)) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + let sample_sql = format!("SELECT * FROM {} LIMIT 10", name); + let sample_rows: Vec = sqlx::query(&sample_sql) + .fetch_all(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .into_iter() + .map(pg_row_to_json) + .collect(); + + Ok(Json(TablePreview { + table_name: name, + row_count, + columns, + sample_rows, + })) +} + +pub async fn create_table( + State(state): State, + Extension(_claims): Extension, + Json(body): Json, +) -> Result, StatusCode> { + if !crate::routes::is_valid_identifier(&body.name) { + return Err(StatusCode::BAD_REQUEST); + } + if is_protected(&body.name) { + return Err(StatusCode::FORBIDDEN); + } + + if body.columns.is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + + let mut col_defs = vec!["id SERIAL PRIMARY KEY".to_string()]; + + for col in &body.columns { + if !crate::routes::is_valid_identifier(&col.name) { + return Err(StatusCode::BAD_REQUEST); + } + + let upper_type = col.col_type.to_uppercase(); + if !ALLOWED_TYPES.contains(&upper_type.as_str()) { + return Err(StatusCode::UNPROCESSABLE_ENTITY); + } + + let nullable = col.nullable.unwrap_or(true); + let null_clause = if nullable { "" } else { " NOT NULL" }; + col_defs.push(format!("{} {}{}", col.name, upper_type, null_clause)); + } + + let sql = format!("CREATE TABLE {} ({})", body.name, col_defs.join(", ")); + + sqlx::query(&sql).execute(&state.pool).await.map_err(|e| { + tracing::error!("create table error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(TableInfo { + table_name: body.name, + column_count: body.columns.len() as i64 + 1, + })) +} + +pub async fn drop_table( + State(state): State, + Extension(_claims): Extension, + Path(name): Path, +) -> Result, StatusCode> { + if !crate::routes::is_valid_identifier(&name) { + return Err(StatusCode::BAD_REQUEST); + } + if is_protected(&name) { + return Err(StatusCode::FORBIDDEN); + } + + let sql = format!("DROP TABLE IF EXISTS {}", name); + sqlx::query(&sql).execute(&state.pool).await.map_err(|e| { + tracing::error!("drop table error: {}", e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Json(serde_json::json!({ "dropped": true }))) +} + +#[cfg(test)] +mod tests { + use super::{is_protected, PROTECTED_TABLES}; + + #[test] + fn test_protected_tables_list() { + assert!(PROTECTED_TABLES.contains(&"users")); + assert!(PROTECTED_TABLES.contains(&"blacklist")); + assert!(PROTECTED_TABLES.contains(&"api_keys")); + assert!(PROTECTED_TABLES.contains(&"queries")); + assert!(PROTECTED_TABLES.contains(&"permissions")); + } + + #[test] + fn test_is_protected() { + assert!(is_protected("users")); + assert!(is_protected("USERS")); + assert!(!is_protected("orders")); + } +} diff --git a/src/routes/auth.rs b/src/routes/auth.rs new file mode 100644 index 0000000..e4cdf40 --- /dev/null +++ b/src/routes/auth.rs @@ -0,0 +1,36 @@ +use axum::{extract::State, http::StatusCode, Json}; +use serde_json::{json, Value}; + +use crate::{auth::encode_jwt, models::user::LoginRequest, state::AppState}; + +pub async fn login( + State(state): State, + Json(body): Json, +) -> Result, StatusCode> { + let user = sqlx::query_as::<_, crate::models::user::User>( + "SELECT id, username, password_hash, permissions_mask, created_at FROM users WHERE username = $1", + ) + .bind(&body.username) + .fetch_optional(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? + .ok_or(StatusCode::UNAUTHORIZED)?; + + let valid = bcrypt::verify(&body.password, &user.password_hash) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + if !valid { + return Err(StatusCode::UNAUTHORIZED); + } + + let mask: u128 = user.permissions_mask.parse().unwrap_or(0); + let token = encode_jwt( + &user.username, + mask, + &state.config.jwt_secret, + state.config.jwt_expiry_secs, + ) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + + Ok(Json(json!({ "token": token }))) +} diff --git a/src/routes/crud.rs b/src/routes/crud.rs new file mode 100644 index 0000000..e2b1355 --- /dev/null +++ b/src/routes/crud.rs @@ -0,0 +1,428 @@ +use anyhow::{anyhow, Result}; +use axum::{ + extract::{Extension, Path, Query, State}, + http::{Method, StatusCode}, + Json, +}; +use serde_json::Value; +use sqlx::postgres::PgRow; +use sqlx::Column; +use sqlx::Row; +use sqlx::TypeInfo; +use std::collections::HashMap; + +use crate::{ + auth::Claims, + models::blacklist::BlacklistEntry, + state::{AppState, CacheEntry}, +}; + +/// Returns (sql, ordered_param_values, cache_key). +/// body_cols: (col_name, typed_value) pairs from request body. +/// filter_cols: (col_name, string_value) pairs from query params. +pub fn build_query( + method: &str, + table: &str, + id: Option<&str>, + body_cols: &[(String, Value)], + filter_cols: &[(String, String)], +) -> Result<(String, Vec, String)> { + if !crate::routes::is_valid_identifier(table) { + return Err(anyhow!("invalid identifier: {}", table)); + } + for (col, _) in body_cols.iter() { + if !crate::routes::is_valid_identifier(col) { + return Err(anyhow!("invalid identifier: {}", col)); + } + } + for (col, _) in filter_cols.iter() { + if !crate::routes::is_valid_identifier(col) { + return Err(anyhow!("invalid identifier: {}", col)); + } + } + + let mut sorted_body = body_cols.to_vec(); + sorted_body.sort_by(|a, b| a.0.cmp(&b.0)); + let mut sorted_filters = filter_cols.to_vec(); + sorted_filters.sort_by(|a, b| a.0.cmp(&b.0)); + + match method.to_uppercase().as_str() { + "GET" => { + if let Some(id_val) = id { + let sql = format!("SELECT * FROM {} WHERE id = $1", table); + let key = format!("GET:{}:~id", table); + Ok((sql, vec![Value::String(id_val.to_string())], key)) + } else if sorted_filters.is_empty() { + let sql = format!("SELECT * FROM {}", table); + let key = format!("GET:{}:", table); + Ok((sql, vec![], key)) + } else { + let col_names: Vec = + sorted_filters.iter().map(|(c, _)| c.clone()).collect(); + let where_clause: Vec = col_names + .iter() + .enumerate() + .map(|(i, c)| format!("{} = ${}", c, i + 1)) + .collect(); + let sql = format!( + "SELECT * FROM {} WHERE {}", + table, + where_clause.join(" AND ") + ); + let params: Vec = sorted_filters + .iter() + .map(|(_, v)| Value::String(v.clone())) + .collect(); + let key = format!("GET:{}:{}", table, col_names.join(",")); + Ok((sql, params, key)) + } + } + "POST" => { + if sorted_body.is_empty() { + return Err(anyhow!("POST requires a body with at least one field")); + } + let cols: Vec = sorted_body.iter().map(|(c, _)| c.clone()).collect(); + let placeholders: Vec = (1..=cols.len()).map(|i| format!("${}", i)).collect(); + let sql = format!( + "INSERT INTO {} ({}) VALUES ({}) RETURNING *", + table, + cols.join(", "), + placeholders.join(", ") + ); + let params: Vec = sorted_body.iter().map(|(_, v)| v.clone()).collect(); + let key = format!("POST:{}:{}", table, cols.join(",")); + Ok((sql, params, key)) + } + "PUT" => { + let id_val = id.ok_or_else(|| anyhow!("PUT requires an id"))?; + if sorted_body.is_empty() { + return Err(anyhow!("PUT requires a body with at least one field")); + } + let cols: Vec = sorted_body.iter().map(|(c, _)| c.clone()).collect(); + let set_clause: Vec = cols + .iter() + .enumerate() + .map(|(i, c)| format!("{} = ${}", c, i + 1)) + .collect(); + let id_placeholder = cols.len() + 1; + let sql = format!( + "UPDATE {} SET {} WHERE id = ${} RETURNING *", + table, + set_clause.join(", "), + id_placeholder + ); + let mut params: Vec = sorted_body.iter().map(|(_, v)| v.clone()).collect(); + params.push(Value::String(id_val.to_string())); + let key = format!("PUT:{}:{}:by_id", table, cols.join(",")); + Ok((sql, params, key)) + } + "DELETE" => { + let id_val = id.ok_or_else(|| anyhow!("DELETE requires an id"))?; + let sql = format!("DELETE FROM {} WHERE id = $1", table); + let key = format!("DELETE:{}:by_id", table); + Ok((sql, vec![Value::String(id_val.to_string())], key)) + } + m => Err(anyhow!("unsupported method: {}", m)), + } +} + +pub fn pg_row_to_json(row: PgRow) -> Value { + let columns = row.columns(); + let mut map = serde_json::Map::new(); + for col in columns { + let name = col.name().to_string(); + let type_name = col.type_info().name(); + let val = match type_name { + "INT2" => row + .try_get::(col.ordinal()) + .map(|v| Value::Number(i64::from(v).into())) + .unwrap_or(Value::Null), + "INT4" | "SERIAL" => row + .try_get::(col.ordinal()) + .map(|v| Value::Number(i64::from(v).into())) + .unwrap_or(Value::Null), + "INT8" => row + .try_get::(col.ordinal()) + .map(|v| Value::Number(v.into())) + .unwrap_or(Value::Null), + "FLOAT4" | "FLOAT8" => row + .try_get::(col.ordinal()) + .ok() + .and_then(serde_json::Number::from_f64) + .map(Value::Number) + .unwrap_or(Value::Null), + "BOOL" => row + .try_get::(col.ordinal()) + .map(Value::Bool) + .unwrap_or(Value::Null), + "UUID" => row + .try_get::(col.ordinal()) + .map(|v| Value::String(v.to_string())) + .unwrap_or(Value::Null), + "TIMESTAMPTZ" | "TIMESTAMP" => row + .try_get::, _>(col.ordinal()) + .map(|v| Value::String(v.to_rfc3339())) + .unwrap_or(Value::Null), + _ => row + .try_get::(col.ordinal()) + .map(Value::String) + .unwrap_or(Value::Null), + }; + map.insert(name, val); + } + Value::Object(map) +} + +async fn reload_blacklist(state: &AppState) -> Result<(), StatusCode> { + let entries = sqlx::query_as::<_, BlacklistEntry>( + "SELECT id, pattern, method, reason, active, bypass_mask, created_at FROM blacklist ORDER BY id", + ) + .fetch_all(&state.pool) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + state.blacklist_cache.load(entries).await; + Ok(()) +} + +fn strip_password_hash(v: Value) -> Value { + match v { + Value::Object(mut m) => { + m.remove("password_hash"); + Value::Object(m) + } + Value::Array(arr) => Value::Array( + arr.into_iter() + .map(|item| match item { + Value::Object(mut m) => { + m.remove("password_hash"); + Value::Object(m) + } + other => other, + }) + .collect(), + ), + other => other, + } +} + +pub async fn handle_crud( + State(state): State, + method: Method, + Extension(claims): Extension, + Path(params): Path>, + Query(query_params): Query>, + body: Option>>, +) -> Result, StatusCode> { + let table = params.get("table").ok_or(StatusCode::BAD_REQUEST)?.clone(); + let id = params.get("id").map(|s| s.as_str()); + let method_str = method.as_str(); + + // Enforce permission bits before doing any work. + let required_bit = match method_str.to_uppercase().as_str() { + "GET" => crate::auth::permissions::READ, + "POST" | "PUT" => crate::auth::permissions::WRITE, + "DELETE" => crate::auth::permissions::DELETE, + _ => return Err(StatusCode::METHOD_NOT_ALLOWED), + }; + if !claims.has_permission(required_bit) { + return Err(StatusCode::FORBIDDEN); + } + + // Collect body as typed Values directly — no sentinel needed. + let mut body_cols: Vec<(String, Value)> = body + .map(|Json(b)| b.into_iter().collect()) + .unwrap_or_default(); + + // Hash the password field for the users table before building the query. + if table == "users" && matches!(method_str.to_uppercase().as_str(), "POST" | "PUT") { + if let Some(pos) = body_cols.iter().position(|(k, _)| k == "password") { + let (_, val) = body_cols.remove(pos); + if let Value::String(plaintext) = val { + let hash = bcrypt::hash(&plaintext, bcrypt::DEFAULT_COST) + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; + body_cols.push(("password_hash".to_string(), Value::String(hash))); + } + } + } + + let filter_cols: Vec<(String, String)> = query_params.into_iter().collect(); + + let (sql, params_vals, cache_key) = + build_query(method_str, &table, id, &body_cols, &filter_cols) + .map_err(|_| StatusCode::BAD_REQUEST)?; + + let sql = if let Some(entry) = state.query_cache.get(&cache_key) { + entry.sql.clone() + } else { + let entry = CacheEntry::new(sql.clone()); + state + .query_cache + .insert(cache_key, entry.clone(), state.config.cache_max_capacity); + entry.sql + }; + + let mut q = sqlx::query(&sql); + for val in ¶ms_vals { + match val { + Value::Null => q = q.bind(Option::::None), + Value::Bool(b) => q = q.bind(*b), + Value::Number(n) => { + if let Some(i) = n.as_i64() { + q = q.bind(i); + } else if let Some(f) = n.as_f64() { + q = q.bind(f); + } else { + q = q.bind(n.to_string()); + } + } + Value::String(s) => q = q.bind(s.as_str()), + other => q = q.bind(other.to_string()), + } + } + + let response = match method_str.to_uppercase().as_str() { + "GET" => { + let rows = q.fetch_all(&state.pool).await.map_err(|e| { + tracing::error!("GET {}: {}", table, e); + StatusCode::INTERNAL_SERVER_ERROR + })?; + let mut v = Value::Array(rows.into_iter().map(pg_row_to_json).collect()); + if table == "users" { + v = strip_password_hash(v); + } + v + } + "POST" | "PUT" => { + let row = q.fetch_one(&state.pool).await.map_err(|e| { + if matches!(e, sqlx::Error::RowNotFound) { + StatusCode::NOT_FOUND + } else { + tracing::error!("{} {}: {}", method_str, table, e); + StatusCode::INTERNAL_SERVER_ERROR + } + })?; + let mut v = pg_row_to_json(row); + if table == "users" { + v = strip_password_hash(v); + } + v + } + "DELETE" => { + let rows_affected = q + .execute(&state.pool) + .await + .map_err(|e| { + tracing::error!("DELETE {}: {}", table, e); + StatusCode::INTERNAL_SERVER_ERROR + })? + .rows_affected(); + if rows_affected == 0 { + return Err(StatusCode::NOT_FOUND); + } + serde_json::json!({ "deleted": true }) + } + _ => return Err(StatusCode::METHOD_NOT_ALLOWED), + }; + + // Reload the in-memory blacklist cache after any mutation to the blacklist table. + if table == "blacklist" && method_str != "GET" { + reload_blacklist(&state).await?; + } + + Ok(Json(response)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_select_all() { + let (sql, params, key) = build_query("GET", "orders", None, &[], &[]).unwrap(); + assert_eq!(sql, "SELECT * FROM orders"); + assert!(params.is_empty()); + assert_eq!(key, "GET:orders:"); + } + + #[test] + fn test_build_select_by_id() { + let (sql, params, key) = build_query("GET", "orders", Some("42"), &[], &[]).unwrap(); + assert_eq!(sql, "SELECT * FROM orders WHERE id = $1"); + assert_eq!(params, vec![Value::String("42".into())]); + assert_eq!(key, "GET:orders:~id"); + } + + #[test] + fn test_build_insert() { + let cols = vec![ + ("email".into(), Value::String("a@b.com".into())), + ("name".into(), Value::String("Alice".into())), + ]; + let (sql, params, key) = build_query("POST", "users", None, &cols, &[]).unwrap(); + assert_eq!( + sql, + "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *" + ); + assert_eq!( + params, + vec![ + Value::String("a@b.com".into()), + Value::String("Alice".into()) + ] + ); + assert_eq!(key, "POST:users:email,name"); + } + + #[test] + fn test_build_update() { + let cols = vec![("name".into(), Value::String("Bob".into()))]; + let (sql, params, key) = build_query("PUT", "users", Some("7"), &cols, &[]).unwrap(); + assert_eq!(sql, "UPDATE users SET name = $1 WHERE id = $2 RETURNING *"); + assert_eq!( + params, + vec![Value::String("Bob".into()), Value::String("7".into())] + ); + assert_eq!(key, "PUT:users:name:by_id"); + } + + #[test] + fn test_build_delete() { + let (sql, params, key) = build_query("DELETE", "users", Some("3"), &[], &[]).unwrap(); + assert_eq!(sql, "DELETE FROM users WHERE id = $1"); + assert_eq!(params, vec![Value::String("3".into())]); + assert_eq!(key, "DELETE:users:by_id"); + } + + #[test] + fn test_build_select_with_filters() { + let filters = vec![ + ("status".into(), "active".into()), + ("role".into(), "admin".into()), + ]; + let (sql, params, key) = build_query("GET", "users", None, &[], &filters).unwrap(); + assert_eq!(sql, "SELECT * FROM users WHERE role = $1 AND status = $2"); + assert_eq!( + params, + vec![ + Value::String("admin".into()), + Value::String("active".into()) + ] + ); + assert_eq!(key, "GET:users:role,status"); + } + + #[test] + fn test_build_null_body_value() { + let cols = vec![("note".into(), Value::Null)]; + let (sql, params, key) = build_query("POST", "items", None, &cols, &[]).unwrap(); + assert_eq!(sql, "INSERT INTO items (note) VALUES ($1) RETURNING *"); + assert_eq!(params, vec![Value::Null]); + assert_eq!(key, "POST:items:note"); + } + + #[test] + fn test_rejects_invalid_table_name() { + let result = build_query("GET", "users; DROP TABLE users--", None, &[], &[]); + assert!(result.is_err()); + } +} diff --git a/src/routes/mod.rs b/src/routes/mod.rs new file mode 100644 index 0000000..72d18ab --- /dev/null +++ b/src/routes/mod.rs @@ -0,0 +1,7 @@ +pub mod admin; +pub mod auth; +pub mod crud; + +pub fn is_valid_identifier(name: &str) -> bool { + !name.is_empty() && name.chars().all(|c| c.is_alphanumeric() || c == '_') +} diff --git a/src/state.rs b/src/state.rs new file mode 100644 index 0000000..b4d8482 --- /dev/null +++ b/src/state.rs @@ -0,0 +1,255 @@ +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use dashmap::DashMap; +use glob::Pattern; +use sqlx::PgPool; +use tokio::sync::RwLock; + +use crate::config::Config; +use crate::models::blacklist::BlacklistEntry; + +pub fn unix_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +#[derive(Clone)] +pub struct AppState { + pub pool: PgPool, + pub query_cache: QueryCache, + pub blacklist_cache: BlacklistCache, + pub config: Arc, +} + +#[derive(Clone)] +pub struct QueryCache { + pub map: Arc>, + pub hits: Arc, + pub misses: Arc, +} + +#[derive(Clone, Debug)] +pub struct CacheEntry { + pub sql: String, + pub last_accessed_secs: Arc, +} + +impl CacheEntry { + pub fn new(sql: String) -> Self { + Self { + sql, + last_accessed_secs: Arc::new(AtomicU64::new(unix_now())), + } + } + + pub fn touch(&self) { + self.last_accessed_secs.store(unix_now(), Ordering::Relaxed); + } + + pub fn last_accessed(&self) -> u64 { + self.last_accessed_secs.load(Ordering::Relaxed) + } +} + +impl QueryCache { + pub fn new() -> Self { + Self { + map: Arc::new(DashMap::new()), + hits: Arc::new(AtomicU64::new(0)), + misses: Arc::new(AtomicU64::new(0)), + } + } + + pub fn get(&self, key: &str) -> Option { + if let Some(entry) = self.map.get(key) { + entry.touch(); + self.hits.fetch_add(1, Ordering::Relaxed); + Some(entry.clone()) + } else { + self.misses.fetch_add(1, Ordering::Relaxed); + None + } + } + + pub fn insert(&self, key: String, entry: CacheEntry, max_capacity: usize) { + if self.map.len() >= max_capacity { + let oldest_key = self + .map + .iter() + .min_by_key(|e| e.last_accessed()) + .map(|e| e.key().clone()); + if let Some(k) = oldest_key { + self.map.remove(&k); + } + } + self.map.insert(key, entry); + } + + #[allow(dead_code)] + pub fn remove(&self, key: &str) { + self.map.remove(key); + } + + pub fn hits(&self) -> u64 { + self.hits.load(Ordering::Relaxed) + } + + pub fn misses(&self) -> u64 { + self.misses.load(Ordering::Relaxed) + } + + pub fn len(&self) -> usize { + self.map.len() + } + + pub fn flush(&self) { + self.map.clear(); + } +} + +#[derive(Clone)] +pub struct BlacklistCache { + pub inner: Arc>>, +} + +#[derive(Clone)] +pub struct CompiledEntry { + pub entry: BlacklistEntry, + pub pattern: Pattern, + pub bypass_mask: Option, +} + +impl BlacklistCache { + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(Vec::new())), + } + } + + pub async fn load(&self, entries: Vec) { + let compiled: Vec = entries + .into_iter() + .filter_map(|e| { + let bypass_mask = e + .bypass_mask + .as_deref() + .and_then(|s| s.parse::().ok()); + Pattern::new(&e.pattern).ok().map(|pattern| CompiledEntry { + entry: e, + pattern, + bypass_mask, + }) + }) + .collect(); + let mut guard = self.inner.write().await; + *guard = compiled; + } + + /// Returns true if the request should be blocked. + /// `caller_mask` is 0 for unauthenticated requests; bypass only applies + /// when the caller holds the permission bit stored in bypass_mask. + pub async fn is_blocked(&self, method: &str, path: &str, caller_mask: u128) -> bool { + let path = path.trim_end_matches('/'); + let path = if path.is_empty() { "/" } else { path }; + let guard = self.inner.read().await; + guard.iter().any(|compiled| { + if !compiled.entry.active { + return false; + } + let method_matches = compiled + .entry + .method + .as_deref() + .map(|m| { + m.split(',') + .any(|part| part.trim().eq_ignore_ascii_case(method)) + }) + .unwrap_or(true); + if !method_matches || !compiled.pattern.matches(path) { + return false; + } + // If caller holds the bypass permission, they are not blocked. + !matches!(compiled.bypass_mask, Some(mask) if caller_mask & mask != 0) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_cache_insert_and_get() { + let cache = QueryCache::new(); + let entry = CacheEntry::new("SELECT 1".into()); + cache.insert("key1".into(), entry, 100); + let got = cache.get("key1"); + assert!(got.is_some()); + assert_eq!(got.unwrap().sql, "SELECT 1"); + assert_eq!(cache.hits(), 1); + assert_eq!(cache.misses(), 0); + } + + #[test] + fn test_cache_miss() { + let cache = QueryCache::new(); + let got = cache.get("missing"); + assert!(got.is_none()); + assert_eq!(cache.misses(), 1); + } + + #[test] + fn test_cache_capacity_evicts_oldest() { + let cache = QueryCache::new(); + let e1 = CacheEntry::new("SELECT 1".into()); + // force e1 to be older + e1.last_accessed_secs.store(1, Ordering::Relaxed); + cache.map.insert("old".into(), e1); + let e2 = CacheEntry::new("SELECT 2".into()); + cache.insert("new".into(), e2, 1); // capacity=1, should evict "old" + assert!(cache.map.get("old").is_none()); + assert!(cache.map.get("new").is_some()); + } + + #[tokio::test] + async fn test_blacklist_blocks_pattern() { + use chrono::Utc; + let cache = BlacklistCache::new(); + let entry = BlacklistEntry { + id: 1, + pattern: "/api/users/**".into(), + method: None, + reason: None, + active: true, + bypass_mask: None, + created_at: Utc::now(), + }; + cache.load(vec![entry]).await; + assert!(cache.is_blocked("GET", "/api/users/42", 0).await); + assert!(!cache.is_blocked("GET", "/api/orders/1", 0).await); + } + + #[tokio::test] + async fn test_blacklist_method_specific() { + use chrono::Utc; + let cache = BlacklistCache::new(); + let entry = BlacklistEntry { + id: 2, + pattern: "/api/secrets".into(), + method: Some("GET".into()), + reason: None, + active: true, + bypass_mask: None, + created_at: Utc::now(), + }; + cache.load(vec![entry]).await; + assert!(cache.is_blocked("GET", "/api/secrets", 0).await); + assert!(!cache.is_blocked("POST", "/api/secrets", 0).await); + } +} diff --git a/ui/.gitignore b/ui/.gitignore new file mode 100644 index 0000000..27420d2 --- /dev/null +++ b/ui/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +dist/ +.npmrc + +# vue-tsc emit artifacts (should never appear in src/ — noEmit is set) +src/**/*.vue.js +src/**/*.vue.js.map +src/**/*.vue.d.ts +src/**/*.vue.d.ts.map +src/**/*.ts.js +src/**/*.ts.js.map diff --git a/ui/bun.lock b/ui/bun.lock new file mode 100644 index 0000000..caff4cc --- /dev/null +++ b/ui/bun.lock @@ -0,0 +1,230 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "mercury-ui", + "dependencies": { + "@nychthemeron/library": "latest", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-router": "^4.3.0", + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^2.0.0", + }, + }, + }, + "packages": { + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], + + "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + + "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.21.5", "", { "os": "android", "cpu": "arm64" }, "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.21.5", "", { "os": "android", "cpu": "x64" }, "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.21.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.21.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.21.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.21.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.21.5", "", { "os": "linux", "cpu": "arm" }, "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.21.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.21.5", "", { "os": "linux", "cpu": "ia32" }, "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.21.5", "", { "os": "linux", "cpu": "ppc64" }, "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.21.5", "", { "os": "linux", "cpu": "none" }, "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.21.5", "", { "os": "linux", "cpu": "s390x" }, "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.21.5", "", { "os": "linux", "cpu": "x64" }, "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.21.5", "", { "os": "none", "cpu": "x64" }, "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.21.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.21.5", "", { "os": "sunos", "cpu": "x64" }, "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.21.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.21.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@nychthemeron/library": ["@nychthemeron/library@0.0.1", "https://git.mcpeakdev.com/api/packages/McPeakDev/npm/%40nychthemeron%2Flibrary/-/0.0.1/library-0.0.1.tgz", { "peerDependencies": { "primevue": "^4.5.0", "vue": "^3.5.0" } }, "sha512-84pcTnF8Ead8D7TSLhKTS8ACpaOCTS0mylzkWqgGrNs4fnrH9tEJg874nFHegiFy5P1fuKCk4B7Yt/Pjc4NK/Q=="], + + "@primeuix/styled": ["@primeuix/styled@0.7.4", "", { "dependencies": { "@primeuix/utils": "^0.6.1" } }, "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ=="], + + "@primeuix/styles": ["@primeuix/styles@2.0.3", "", { "dependencies": { "@primeuix/styled": "^0.7.4" } }, "sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw=="], + + "@primeuix/utils": ["@primeuix/utils@0.6.4", "", {}, "sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg=="], + + "@primevue/core": ["@primevue/core@4.5.5", "", { "dependencies": { "@primeuix/styled": "^0.7.4", "@primeuix/utils": "^0.6.2" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JpkXhq1ddc70JdsC3CC4dM+UbeeWuCW/8DpS9dNBfrOk824TLSlRlMEGFyVKqRMn5WPQvYLiy3xXfLQeNdSqhQ=="], + + "@primevue/icons": ["@primevue/icons@4.5.5", "", { "dependencies": { "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.5" } }, "sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.62.0", "", { "os": "android", "cpu": "arm" }, "sha512-IPIQ55ythEHkfEd9jMEi32OQ7SxURsGA43JI22lj01OLZNt2NUbJX8YUHxkVWyQ6daHPNn0truF5nSj3DQp6YQ=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.62.0", "", { "os": "android", "cpu": "arm64" }, "sha512-M6s9cr10MibETyo8JsOkq+Lo1+lU6hcvb1MApnUql5qte/5hMEgzlN8/ReIKNfRV8rrqX50W1BX9zoUhC192RA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BqCoMoIbn0keKys+dEAdBa70EtOwV1bEsQCUgU9FdiZmmMge/Zk7LlkYGqbrdHR+Frnt0E1FOanly+rlwvvQzw=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.62.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-SIMzST3VFNXDAbeIWDWiFCNM5qncUBDWaEV7NfE7oZbDt2mgfW4MvbKdbYiGOLoM32gbTv608UMd0XktEYSD7w=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.62.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ezjfSQMP7ArdUsbBwbQIfwAlhE84I2iVnzQNCFSveqV42q+BmKlzVpf7mxv5EchLcoWU4y6/heFzVg1F+hodUQ=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.62.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-9+qTWGW9AZRhnUgwtTwzNwcPlL87ngkeN0LA+q1bADvmY9aNvWaF2TFW8BZgnQPYxpDI7+rMVLivcd4V737TAQ=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.62.0", "", { "os": "linux", "cpu": "arm" }, "sha512-T1dMEQhXA/jkJ/jyMIw9IovK8bSUq7A8kLIlvZTb/6YIVsp2zLavr4F3oyllHWo7eIVJRyE5n3tUjQJEbE1IuQ=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.62.0", "", { "os": "linux", "cpu": "arm" }, "sha512-2as0LgT7qQpyceQq6VUJYnumUMUrgGQCWIiDIN9DE0/tglsk6o66uCB4f3djRawAltvfCNLyZZrsqbPA6inCsA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.62.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-bVURMg+6eNN9C/yc0aVjooZcwTTtYF4YW3xta5pP0//r3o1V8gXEHXWCndj47w/HhwsFroZrFhR+6uQP5T0n0g=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.62.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-Ful8pM/2yYI83PViWdFdpZhdI8HJ5qsXANe5atypbHDf+KIBBDsZsbyy8hbXnULVvW9NsTh5DHwbcBftyLTfiw=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-9Gp/DgrkzfUBmNPVTyPTvay+4xEP7M/clXpj3efXBcm6uTIVIgDg4rqUpqKXvLEuFRVuEpSAOkhgNeecvaZ4Cg=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-m9tsJz54LUXkSYM8+8PG81B9IKK5r+2T0clMq4QrS16xFosufU7firBDAZEsDheDs7wTlP7h3++S7lMsU955HA=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.62.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3UvJ5PNVU16aJf6M3tFI24pWzAl2/ynfbyRN3ICyQajK1lSkrnVYNnLz3v04J32qKa0FczJc22zeToc0lr2A3w=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.62.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-vRWUAbYLGHBZS6Q8Msb2sfnf1fvJf+47t8l/TwOerM2qArzy+IeNMTHrYLHXh95h8MoatPHI5hhSZNs+mGXKPg=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-c00T5SYENHAt86cfW47URaP3Us5vLC/4QO7GYud1G5VNRffCwwCuBspwqYrriuJB+5m0WFzClCn9wed0FBjKvg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-krrCDilhXOwFkSkO3Wm9I/f9H0L92XHHwy2fwxjukxIbh0dem8gZqOW5Y8BsHrpJv5qwlRBV+Wl4ZFyRWhUpwg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.62.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-7pfYFSTc4/rUC/FtAI0Qp6QthDBCIi6/AuP1xYqFk5vanI6KnL5dWKP60OM/05LOsbwTmIcvr6eXC4CJuJ75IA=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.62.0", "", { "os": "linux", "cpu": "x64" }, "sha512-7SDIalKeIpG0Ifogbbdn58HmSotYMlf23K3dCJEmiVd9Fg36Vmni82iPQec27N3wY4Bvbxftkxz6vSx9OcouTg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.62.0", "", { "os": "linux", "cpu": "x64" }, "sha512-eRZevouTH2i1HeAVLqJuLnt256krQkGY0TN6WsTmsIhuzbh457HuWDMakKwmi0Cjadux983CoSr8Lim2QhUIFw=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.62.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-3oVS7FLGa4U1qcvao9ylGxrjXZyUQqR8UwxEcnUEyPX53O/C/mKDZegNXTdHCP+h3e6ta/f1EN38Yif1mmZHYg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.62.0", "", { "os": "none", "cpu": "arm64" }, "sha512-yTB9TgfWj5wHe5QgktAgXTLLot1gvEjl1NiPPAUiCs4oPrIWFl5V4nC3GrkNdj9LaAU4s94nVrGbGOCqUpyWsg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.62.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-5LOhoaesY3doG1c+ac/2JtgREpKoJr5bUHH8tKY0V8di7+uSV6BwLs2PlR0/yzefGOkR+wE7ZolZphHCsyG5Rw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.62.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-yYkWHhmbhRTWTnWos5HC4GcPQfjlzzCNbM9e/+GXrLuaBXYA3qSDR9f0Vgufd5S8yX81U8jPKp7ZnAjZFMtRnw=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-SoTb6lPg25xZlA2ibwQ++ahCCnH+FP0qmEuafMJ4gznZKOlXioKEAeJLgCrqjM98ACziXM9V1amFjICVL4IFoA=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5L+T1fMX4RIEBoZzT0+sQ0PhTS36NULFmMXtl1TZo44TMAROIMHbZufSOjVWt/Y622BtxgxtaNOokbTDvfsrZA=="], + + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], + + "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="], + + "@volar/language-core": ["@volar/language-core@2.4.15", "", { "dependencies": { "@volar/source-map": "2.4.15" } }, "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA=="], + + "@volar/source-map": ["@volar/source-map@2.4.15", "", {}, "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg=="], + + "@volar/typescript": ["@volar/typescript@2.4.15", "", { "dependencies": { "@volar/language-core": "2.4.15", "path-browserify": "^1.0.1", "vscode-uri": "^3.0.8" } }, "sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg=="], + + "@vue/compiler-core": ["@vue/compiler-core@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/shared": "3.5.38", "entities": "^7.0.1", "estree-walker": "^2.0.2", "source-map-js": "^1.2.1" } }, "sha512-s99aGxWYig9ErHbct27KXEGhrBYlRI6c4MwAgXErOAbX9xiW37/uMa+XUDO69zLz83dng8UUZ70CTOJrLrYrEQ=="], + + "@vue/compiler-dom": ["@vue/compiler-dom@3.5.38", "", { "dependencies": { "@vue/compiler-core": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-JTqp25l8aFfJYF7/KmsXZjAxJz7T+SjmTJLoXVjHtc2BrSgSiW2n9Aem/cWq1OPe68A8JL06B3eVdhlP0H4TVw=="], + + "@vue/compiler-sfc": ["@vue/compiler-sfc@3.5.38", "", { "dependencies": { "@babel/parser": "^7.29.7", "@vue/compiler-core": "3.5.38", "@vue/compiler-dom": "3.5.38", "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38", "estree-walker": "^2.0.2", "magic-string": "^0.30.21", "postcss": "^8.5.15", "source-map-js": "^1.2.1" } }, "sha512-DuA2GiZawSEW442iw/9+Fkol8hTgb4Ke5KkhmSry65QA7YuyMbIdy8p0XZRMvNwJdgRz307W8g1CSzdvS4nuNg=="], + + "@vue/compiler-ssr": ["@vue/compiler-ssr@3.5.38", "", { "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-7s+W5Gc42FGxZMcuwl8H5B29T8BJPMdBT7KHFE+BbAuZ/iTEdTtv7z2XiMjiaUUw4w3ZcCEdHs36RuYJ2VA7bA=="], + + "@vue/compiler-vue2": ["@vue/compiler-vue2@2.7.16", "", { "dependencies": { "de-indent": "^1.0.2", "he": "^1.2.0" } }, "sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A=="], + + "@vue/devtools-api": ["@vue/devtools-api@6.6.4", "", {}, "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="], + + "@vue/language-core": ["@vue/language-core@2.2.12", "", { "dependencies": { "@volar/language-core": "2.4.15", "@vue/compiler-dom": "^3.5.0", "@vue/compiler-vue2": "^2.7.16", "@vue/shared": "^3.5.0", "alien-signals": "^1.0.3", "minimatch": "^9.0.3", "muggle-string": "^0.4.1", "path-browserify": "^1.0.1" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA=="], + + "@vue/reactivity": ["@vue/reactivity@3.5.38", "", { "dependencies": { "@vue/shared": "3.5.38" } }, "sha512-pG6LV/NDNRbKizcUjFFLAfjaL8mcv4DmR9avNcUw2gDHBzZneuS2TWCmp633ynzxz9YYKNeEPK2I8Wraqy2HUQ=="], + + "@vue/runtime-core": ["@vue/runtime-core@3.5.38", "", { "dependencies": { "@vue/reactivity": "3.5.38", "@vue/shared": "3.5.38" } }, "sha512-iyW8WVfF1CpCXxncZY5Ei6rSd6oZr5DgEom//fUjRBRl56AXPD+s9ATvukRt77ZFTuYlnVA1bxY+dJB94tWVYw=="], + + "@vue/runtime-dom": ["@vue/runtime-dom@3.5.38", "", { "dependencies": { "@vue/reactivity": "3.5.38", "@vue/runtime-core": "3.5.38", "@vue/shared": "3.5.38", "csstype": "^3.2.3" } }, "sha512-apX2wt9sdfDshS+a2xueFZLVpt0GkRJZSoPmrW/SA4yzXTznhfcMVW59gr7h4YQeY0vJhdJkk2rsIDwgfFgC5A=="], + + "@vue/server-renderer": ["@vue/server-renderer@3.5.38", "", { "dependencies": { "@vue/compiler-ssr": "3.5.38", "@vue/shared": "3.5.38" }, "peerDependencies": { "vue": "3.5.38" } }, "sha512-vue8vbf2QlV4quHqzwmJy6dWfmRhP1J8l4wtZg60CL6VoKqcPY2oe7may3+1d9qfpedjK5PRLFqd5k3Isj9mUw=="], + + "@vue/shared": ["@vue/shared@3.5.38", "", {}, "sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug=="], + + "alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], + + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], + + "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], + + "estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], + + "muggle-string": ["muggle-string@0.4.1", "", {}, "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ=="], + + "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], + + "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "pinia": ["pinia@2.3.1", "", { "dependencies": { "@vue/devtools-api": "^6.6.3", "vue-demi": "^0.14.10" }, "peerDependencies": { "typescript": ">=4.4.4", "vue": "^2.7.0 || ^3.5.11" }, "optionalPeers": ["typescript"] }, "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug=="], + + "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], + + "primevue": ["primevue@4.5.5", "", { "dependencies": { "@primeuix/styled": "^0.7.4", "@primeuix/styles": "^2.0.3", "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.5", "@primevue/icons": "4.5.5" } }, "sha512-Kv5REIewCdP806QaoU+4nBXfmpzOGFKkZ9qH4KsL6MjiAQVc4PUzypt8erl4r3Vzh3nr3aWZIxkxYRRsLGiX2A=="], + + "rollup": ["rollup@4.62.0", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.62.0", "@rollup/rollup-android-arm64": "4.62.0", "@rollup/rollup-darwin-arm64": "4.62.0", "@rollup/rollup-darwin-x64": "4.62.0", "@rollup/rollup-freebsd-arm64": "4.62.0", "@rollup/rollup-freebsd-x64": "4.62.0", "@rollup/rollup-linux-arm-gnueabihf": "4.62.0", "@rollup/rollup-linux-arm-musleabihf": "4.62.0", "@rollup/rollup-linux-arm64-gnu": "4.62.0", "@rollup/rollup-linux-arm64-musl": "4.62.0", "@rollup/rollup-linux-loong64-gnu": "4.62.0", "@rollup/rollup-linux-loong64-musl": "4.62.0", "@rollup/rollup-linux-ppc64-gnu": "4.62.0", "@rollup/rollup-linux-ppc64-musl": "4.62.0", "@rollup/rollup-linux-riscv64-gnu": "4.62.0", "@rollup/rollup-linux-riscv64-musl": "4.62.0", "@rollup/rollup-linux-s390x-gnu": "4.62.0", "@rollup/rollup-linux-x64-gnu": "4.62.0", "@rollup/rollup-linux-x64-musl": "4.62.0", "@rollup/rollup-openbsd-x64": "4.62.0", "@rollup/rollup-openharmony-arm64": "4.62.0", "@rollup/rollup-win32-arm64-msvc": "4.62.0", "@rollup/rollup-win32-ia32-msvc": "4.62.0", "@rollup/rollup-win32-x64-gnu": "4.62.0", "@rollup/rollup-win32-x64-msvc": "4.62.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-nc72Wgq62I7rtDV4izT5/aaS0zxy3kttkinf9586ApknY3jZO9NYsmtc24fUckA0X7Q2v+ML4a15pdUlV5V/jA=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], + + "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], + + "vue": ["vue@3.5.38", "", { "dependencies": { "@vue/compiler-dom": "3.5.38", "@vue/compiler-sfc": "3.5.38", "@vue/runtime-dom": "3.5.38", "@vue/server-renderer": "3.5.38", "@vue/shared": "3.5.38" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-vAMKHfImQlYSy0C+PBue4s3ERZ2xGKfgZg5GXAsLInq1dyh2H78ILVP5sK0KPFPVW4kv+OGCIvBEondcjpZp7A=="], + + "vue-demi": ["vue-demi@0.14.10", "", { "peerDependencies": { "@vue/composition-api": "^1.0.0-rc.1", "vue": "^3.0.0-0 || ^2.6.0" }, "optionalPeers": ["@vue/composition-api"], "bin": { "vue-demi-fix": "bin/vue-demi-fix.js", "vue-demi-switch": "bin/vue-demi-switch.js" } }, "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg=="], + + "vue-router": ["vue-router@4.6.4", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="], + + "vue-tsc": ["vue-tsc@2.2.12", "", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="], + } +} diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..e66c144 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,13 @@ + + + + + + Mercury + + + +

+ + + diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..4e2e8ae --- /dev/null +++ b/ui/package.json @@ -0,0 +1,23 @@ +{ + "name": "mercury-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@nychthemeron/library": "latest", + "pinia": "^2.1.0", + "vue": "^3.4.0", + "vue-router": "^4.3.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "typescript": "^5.3.0", + "vite": "^5.0.0", + "vue-tsc": "^2.0.0" + } +} diff --git a/ui/public/favicon.svg b/ui/public/favicon.svg new file mode 100644 index 0000000..12fae39 --- /dev/null +++ b/ui/public/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/ui/src/App.vue b/ui/src/App.vue new file mode 100644 index 0000000..7c2aa3f --- /dev/null +++ b/ui/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/ui/src/assets/main.css b/ui/src/assets/main.css new file mode 100644 index 0000000..8c3f4b6 --- /dev/null +++ b/ui/src/assets/main.css @@ -0,0 +1,252 @@ +*, +*::before, +*::after { + box-sizing: border-box; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + font-family: var(--font-serif); + background-color: var(--surface-0); + color: var(--text-body); + min-height: 100vh; +} + +h1, +h2, +h3 { + font-family: var(--font-serif); + font-weight: 600; /* Cinzel's actual heaviest weight — avoids synthetic bold */ + font-optical-sizing: auto; + color: var(--text-high); + margin: 0; + letter-spacing: 0.06em; +} + +a { + color: var(--primary); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +code { + font-family: var(--font-mono); + font-size: 0.85em; + color: var(--text-label); + background: var(--surface-2); + padding: 0.1em 0.4em; + border-radius: 3px; + border: 1px solid var(--border-lo); +} + +/* ── Page header ────────────────────────────────────────── */ +.page-header { + display: flex; + justify-content: space-between; + align-items: flex-end; + margin-bottom: 1.75rem; +} + +.page-title { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.page-title h2 { + font-size: 1.6rem; + line-height: 1; +} + +.page-title .subtitle { + font-family: var(--font-sans); + font-size: 0.8rem; + color: var(--text-muted); + letter-spacing: 0.02em; +} + +/* ── Table card ─────────────────────────────────────────── */ +.table-card { + background-color: var(--surface-1); + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.07), + 0 1px 4px rgba(0, 0, 0, 0.05); + overflow: hidden; +} + +/* Scroll wrapper sits inside the card so the header stays pinned */ +.table-scroll { + overflow-x: auto; +} + +.table-card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.875rem 1.25rem; + border-bottom: 1px solid var(--border); + background: var(--surface-2); +} + +.table-card-header .count { + font-size: 0.78rem; + color: var(--text-muted); + font-family: var(--font-mono); +} + +.data-table { + width: 100%; + min-width: max-content; + border-collapse: collapse; + font-family: var(--font-serif); + font-size: 0.875rem; +} + +.data-table th { + padding: 0.65rem 1.25rem; + text-align: left; + background-color: var(--surface-2); + border-bottom: 1px solid var(--border); + font-weight: 700; + font-size: 0.7rem; + color: var(--text-muted); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.data-table td { + padding: 0.8rem 1.25rem; + text-align: left; + border-bottom: 1px solid var(--border-lo); + color: var(--text-body); + vertical-align: middle; +} + +.data-table tbody tr:last-child td { + border-bottom: none; +} + +.data-table tbody tr { + transition: background-color 0.12s ease; +} + +.data-table tbody tr:hover td { + background: color-mix(in srgb, var(--primary) 4%, var(--surface-1)); +} + +.actions-cell { + display: flex; + gap: 0.4rem; +} + +/* ── Empty state ────────────────────────────────────────── */ +.empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 4rem 2rem; + color: var(--text-dim); +} + +.empty-state .empty-icon { + font-size: 2.5rem; + opacity: 0.35; + line-height: 1; +} + +.empty-state .empty-label { + font-family: var(--font-serif); + font-size: 1rem; + letter-spacing: 0.04em; + color: var(--text-muted); +} + +.empty-state .empty-hint { + font-size: 0.8rem; + color: var(--text-dim); +} + +/* ── Dialog form ────────────────────────────────────────── */ +.dialog-form { + display: flex; + flex-direction: column; + gap: 1.1rem; + padding-top: 0.25rem; +} + +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; +} + +.field label { + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.09em; + text-transform: uppercase; + color: var(--text-muted); +} + +.field .hint { + margin: 0; + font-size: 0.78rem; + color: var(--text-dim); +} + +.optional { + font-weight: 400; + font-size: 0.72rem; + color: var(--text-dim); + letter-spacing: 0; + text-transform: none; +} + +/* ── Gold divider ───────────────────────────────────────── */ +.gold-rule { + display: flex; + align-items: center; + gap: 0.6rem; + color: var(--primary); + font-size: 0.6rem; + letter-spacing: 0.2em; + opacity: 0.6; +} +.gold-rule::before, +.gold-rule::after { + content: ""; + flex: 1; + height: 1px; + background: linear-gradient( + 90deg, + transparent, + var(--primary), + transparent + ); +} +.nych-dialog { + min-width: 500px; +} + +[class^="nych-button"] { + box-shadow: unset !important; + justify-content: center !important; +} + +[class^="nych-button"]:hover { + box-shadow: 0 0 5px var(--text-high); +} + +.nych-loading-icon .wreath { + transform-box: fill-box; + transform-origin: center; +} diff --git a/ui/src/env.d.ts b/ui/src/env.d.ts new file mode 100644 index 0000000..6ac34fb --- /dev/null +++ b/ui/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/ui/src/main.ts b/ui/src/main.ts new file mode 100644 index 0000000..92a3949 --- /dev/null +++ b/ui/src/main.ts @@ -0,0 +1,16 @@ +import { createApp } from "vue"; +import '@nychthemeron/library/theme' +import './assets/main.css' +import './stores/theme' +import { createPinia } from "pinia"; +import PrimeVue from "@primevue/core/config"; +import { createNychthemeron } from "@nychthemeron/library"; +import App from "./App.vue"; +import router from "./router"; + +const app = createApp(App); +app.use(createPinia()); +app.use(router); +app.use(PrimeVue, { unstyled: true }); +app.use(createNychthemeron()); +app.mount("#app"); diff --git a/ui/src/router/index.ts b/ui/src/router/index.ts new file mode 100644 index 0000000..fa876b5 --- /dev/null +++ b/ui/src/router/index.ts @@ -0,0 +1,36 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '../stores/auth' + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/login', component: () => import('../views/Login.vue') }, + { + path: '/admin', + component: () => import('../views/admin/Layout.vue'), + children: [ + { path: 'queries', component: () => import('../views/admin/Queries.vue') }, + { path: 'tables', component: () => import('../views/admin/Tables.vue') }, + { path: 'users', component: () => import('../views/admin/Users.vue') }, + { path: 'permissions', component: () => import('../views/admin/Permissions.vue') }, + { path: 'blacklist', component: () => import('../views/admin/Blacklist.vue') }, + { path: 'api-keys', component: () => import('../views/admin/ApiKeys.vue') }, + { path: 'cache', component: () => import('../views/admin/Cache.vue') }, + ], + meta: { requiresAuth: true }, + }, + { path: '/', redirect: '/admin/queries' }, + ], +}) + +router.beforeEach((to) => { + const auth = useAuthStore() + if (to.meta.requiresAuth && !auth.isAuthenticated) { + return '/login' + } + if (to.path === '/login' && auth.isAuthenticated) { + return '/admin/queries' + } +}) + +export default router diff --git a/ui/src/stores/auth.ts b/ui/src/stores/auth.ts new file mode 100644 index 0000000..4fe8b5f --- /dev/null +++ b/ui/src/stores/auth.ts @@ -0,0 +1,70 @@ +import { defineStore } from "pinia"; +import { ref, computed } from "vue"; + +interface Claims { + sub: string; + permissions: string; + exp: number; +} + +function parseJwt(token: string): Claims | null { + try { + const payload = token.split(".")[1]; + return payload ? (JSON.parse(atob(payload)) as Claims) : null; + } catch { + return null; + } +} + +export const useAuthStore = defineStore("auth", () => { + const token = ref(localStorage.getItem("mercury_token")); + const claims = computed(() => + token.value ? parseJwt(token.value) : null, + ); + const isAuthenticated = computed(() => { + if (!claims.value) return false; + return claims.value.exp * 1000 > Date.now(); + }); + const username = computed(() => claims.value?.sub ?? ""); + + function hasPermission(bit: bigint): boolean { + if (!claims.value) return false; + const mask = BigInt(claims.value.permissions); + return (mask & bit) !== 0n; + } + + const isSuperAdmin = computed(() => hasPermission(32n)); + + async function login(username: string, password: string): Promise { + const res = await fetch("/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) throw new Error("Invalid credentials"); + const data = await res.json(); + token.value = data.token; + localStorage.setItem("mercury_token", data.token); + } + + function logout() { + token.value = null; + localStorage.removeItem("mercury_token"); + } + + function authHeaders(): Record { + return token.value ? { Authorization: `Bearer ${token.value}` } : {}; + } + + return { + token, + claims, + isAuthenticated, + username, + isSuperAdmin, + hasPermission, + login, + logout, + authHeaders, + }; +}); diff --git a/ui/src/stores/theme.ts b/ui/src/stores/theme.ts new file mode 100644 index 0000000..9cebd64 --- /dev/null +++ b/ui/src/stores/theme.ts @@ -0,0 +1,24 @@ +import { ref } from 'vue' + +export type Theme = 'apollo' | 'hades' + +const stored = localStorage.getItem('mercury-theme') as Theme | null +const theme = ref(stored ?? 'apollo') + +function applyTheme(t: Theme) { + theme.value = t + document.documentElement.setAttribute('data-theme', t) + localStorage.setItem('mercury-theme', t) +} + +applyTheme(theme.value) + +export function useTheme() { + return { + theme, + isDark: () => theme.value === 'hades', + toggle() { + applyTheme(theme.value === 'apollo' ? 'hades' : 'apollo') + }, + } +} diff --git a/ui/src/views/Login.vue b/ui/src/views/Login.vue new file mode 100644 index 0000000..c6b097b --- /dev/null +++ b/ui/src/views/Login.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/ui/src/views/admin/ApiKeys.vue b/ui/src/views/admin/ApiKeys.vue new file mode 100644 index 0000000..9c0c776 --- /dev/null +++ b/ui/src/views/admin/ApiKeys.vue @@ -0,0 +1,245 @@ + + + + + diff --git a/ui/src/views/admin/Blacklist.vue b/ui/src/views/admin/Blacklist.vue new file mode 100644 index 0000000..f9a19f8 --- /dev/null +++ b/ui/src/views/admin/Blacklist.vue @@ -0,0 +1,232 @@ + + + + + diff --git a/ui/src/views/admin/Cache.vue b/ui/src/views/admin/Cache.vue new file mode 100644 index 0000000..a61dcf4 --- /dev/null +++ b/ui/src/views/admin/Cache.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/ui/src/views/admin/Layout.vue b/ui/src/views/admin/Layout.vue new file mode 100644 index 0000000..1e2f38b --- /dev/null +++ b/ui/src/views/admin/Layout.vue @@ -0,0 +1,265 @@ + + + + + diff --git a/ui/src/views/admin/MethodSelect.vue b/ui/src/views/admin/MethodSelect.vue new file mode 100644 index 0000000..128dab8 --- /dev/null +++ b/ui/src/views/admin/MethodSelect.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/ui/src/views/admin/Permissions.vue b/ui/src/views/admin/Permissions.vue new file mode 100644 index 0000000..808f51b --- /dev/null +++ b/ui/src/views/admin/Permissions.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/ui/src/views/admin/Queries.vue b/ui/src/views/admin/Queries.vue new file mode 100644 index 0000000..6cb03d4 --- /dev/null +++ b/ui/src/views/admin/Queries.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/ui/src/views/admin/Tables.vue b/ui/src/views/admin/Tables.vue new file mode 100644 index 0000000..70699a4 --- /dev/null +++ b/ui/src/views/admin/Tables.vue @@ -0,0 +1,565 @@ + + + + + diff --git a/ui/src/views/admin/Users.vue b/ui/src/views/admin/Users.vue new file mode 100644 index 0000000..78df8fd --- /dev/null +++ b/ui/src/views/admin/Users.vue @@ -0,0 +1,198 @@ + + + + + diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..d5763ca --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "jsx": "preserve", + "noEmit": true, + "strict": true, + "skipLibCheck": true, + "isolatedModules": true, + "useDefineForClassFields": true + }, + "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": ["node_modules", "dist"] +} diff --git a/ui/vite.config.d.ts b/ui/vite.config.d.ts new file mode 100644 index 0000000..fd582a2 --- /dev/null +++ b/ui/vite.config.d.ts @@ -0,0 +1,3 @@ +declare const _default: any; +export default _default; +//# sourceMappingURL=vite.config.d.ts.map \ No newline at end of file diff --git a/ui/vite.config.d.ts.map b/ui/vite.config.d.ts.map new file mode 100644 index 0000000..0aae1e2 --- /dev/null +++ b/ui/vite.config.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"vite.config.d.ts","sourceRoot":"","sources":["vite.config.ts"],"names":[],"mappings":";AAGA,wBAYE"} \ No newline at end of file diff --git a/ui/vite.config.js b/ui/vite.config.js new file mode 100644 index 0000000..5127513 --- /dev/null +++ b/ui/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/api': 'http://localhost:3000', + '/auth': 'http://localhost:3000', + '/admin': 'http://localhost:3000', + }, + }, + build: { + outDir: 'dist', + }, +}); +//# sourceMappingURL=vite.config.js.map \ No newline at end of file diff --git a/ui/vite.config.js.map b/ui/vite.config.js.map new file mode 100644 index 0000000..4cb056d --- /dev/null +++ b/ui/vite.config.js.map @@ -0,0 +1 @@ +{"version":3,"file":"vite.config.js","sourceRoot":"","sources":["vite.config.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAA;AACnC,OAAO,GAAG,MAAM,oBAAoB,CAAA;AAEpC,eAAe,YAAY,CAAC;IAC1B,OAAO,EAAE,CAAC,GAAG,EAAE,CAAC;IAChB,MAAM,EAAE;QACN,KAAK,EAAE;YACL,MAAM,EAAE,uBAAuB;YAC/B,OAAO,EAAE,uBAAuB;YAChC,QAAQ,EAAE,uBAAuB;SAClC;KACF;IACD,KAAK,EAAE;QACL,MAAM,EAAE,MAAM;KACf;CACF,CAAC,CAAA"} \ No newline at end of file diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..e4ef3c2 --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + plugins: [vue()], + server: { + proxy: { + '/api': 'http://localhost:3000', + '/auth': 'http://localhost:3000', + '/admin': 'http://localhost:3000', + }, + }, + build: { + outDir: 'dist', + cleanOutDir: true, + rollupOptions: { + output: { + manualChunks: { + vue: ['vue', 'vue-router', 'pinia'], + primevue: ['@primevue/core'], + nychthemeron: ['@nychthemeron/library'], + }, + entryFileNames: 'js/[name]-[hash].js', + chunkFileNames: 'js/[name]-[hash].js', + assetFileNames: 'assets/[name]-[hash][extname]', + }, + }, + }, +})