diff --git a/.cspell.json b/.cspell.json index 39fdcb2..72cb459 100644 --- a/.cspell.json +++ b/.cspell.json @@ -95,6 +95,49 @@ "backgrounded", "reconstructable", "Württemberg", - "delegatecall" + "delegatecall", + "clippy", + "thiserror", + "healthz", + "serde", + "reqwest", + "chrono", + "println", + "eprintln", + "CBOR", + "cbor", + "HMAC", + "Deployers", + "SETNX", + "dtnh", + "bypassable", + "keypair", + "PUBSINK", + "SESS", + "PREG", + "sessioning", + "Memorystore", + "autonumber", + "rssi", + "TYPEHASH", + "Reattested", + "reattest", + "UPGRADER", + "uncompromised", + "Keymaster", + "jailbroken", + "keybox", + "Solana", + "DIMO", + "JFZERTQSIs", + "Magisk", + "keyboxes", + "decompiles", + "decompilation", + "sideloaded", + "unforgeable", + "slashable", + "Slashable", + "Frida" ] } diff --git a/.github/instructions/rust.instructions.md b/.github/instructions/rust.instructions.md new file mode 100644 index 0000000..3a9a078 --- /dev/null +++ b/.github/instructions/rust.instructions.md @@ -0,0 +1,26 @@ +--- +applyTo: "beacon-relay/**/*.rs,beacon-relay/Cargo.toml" +--- + +# Rust Development Instructions (beacon-relay) + +This project is a Rust API server using **axum 0.8**, **tokio**, and **serde**. +Full development standards are in `beacon-relay/AGENTS.md` — read that file +before making changes. + +## Quick Reference + +- **Build**: `cd beacon-relay && cargo build` +- **Test**: `cd beacon-relay && cargo test` +- **Lint**: `cd beacon-relay && cargo clippy -- -D warnings` +- **Format**: `cd beacon-relay && cargo fmt --check` +- **Run**: `cd beacon-relay && cargo run` + +## Key Conventions + +- Use `thiserror` for error types, implement `IntoResponse` for HTTP errors. +- Use `tracing` macros for logging, never `println!`. +- Handlers return `impl IntoResponse`; extract state via `axum::extract::State`. +- Config loaded via Figment: `config/default.toml` → env vars (`APP_` prefix). +- Integration tests use Tower `ServiceExt` to call the router directly. +- Always run `cargo clippy -- -D warnings` and `cargo fmt --check` before finishing. diff --git a/.github/workflows/beacon-relay-ci.yml b/.github/workflows/beacon-relay-ci.yml new file mode 100644 index 0000000..f9e9ba6 --- /dev/null +++ b/.github/workflows/beacon-relay-ci.yml @@ -0,0 +1,53 @@ +name: beacon-relay + +on: + push: + paths: + - "beacon-relay/**" + - ".github/workflows/beacon-relay-ci.yml" + pull_request: + branches: + - main + paths: + - "beacon-relay/**" + - ".github/workflows/beacon-relay-ci.yml" + workflow_dispatch: {} + +concurrency: + group: beacon-relay-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + name: Build & Test + runs-on: ubuntu-latest + + defaults: + run: + working-directory: beacon-relay + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + + - name: Cache Rust dependencies + uses: Swatinem/rust-cache@v2 + with: + workspaces: beacon-relay + + - name: Build + run: cargo build --locked + + - name: Test + run: cargo test --locked + + - name: Clippy + run: cargo clippy --locked -- -D warnings + + - name: Format check + run: cargo fmt --check diff --git a/beacon-relay/.gitignore b/beacon-relay/.gitignore new file mode 100644 index 0000000..91969a7 --- /dev/null +++ b/beacon-relay/.gitignore @@ -0,0 +1,2 @@ +/target/ +GATEWAY-SPEC.md diff --git a/beacon-relay/AGENTS.md b/beacon-relay/AGENTS.md new file mode 100644 index 0000000..af2d59d --- /dev/null +++ b/beacon-relay/AGENTS.md @@ -0,0 +1,230 @@ +# Beacon Relay — Rust Development Standards + +This file is the authoritative reference for AI agents working on the +`beacon-relay` Rust project. Read it before every task. Trust these +instructions and only search if information here is incomplete or wrong. + +## Project Overview + +**beacon-relay** is the v2 gateway for the Nodle DePIN network. It is the +first point of processing for BLE scan data, device heartbeats, and telemetry +collected by participating mobile devices. The service authenticates devices +via EIP-712 signatures, validates sessions backed by on-chain state (ZkSync +Era), and relays accepted data to downstream streaming sinks (default: Google +Pub/Sub). + +- **Design document**: `GATEWAY-SPEC.md` in this directory. +- **Crate name**: `beacon-relay` +- **Location**: `beacon-relay/` in the monorepo root. +- **Language**: Rust (edition 2021) +- **Async runtime**: Tokio +- **Web framework**: Axum + +--- + +## Build, Test, Run Commands + +```bash +# All commands from the beacon-relay/ directory +cd beacon-relay + +# Build (debug) +cargo build + +# Build (release) +cargo build --release + +# Run the server (default: 127.0.0.1:3000) +cargo run + +# Run with custom config via env vars +APP_HOST=0.0.0.0 APP_PORT=8080 cargo run + +# Run all tests +cargo test + +# Run a specific test +cargo test test_name + +# Lint (treat warnings as errors) +cargo clippy -- -D warnings + +# Format check +cargo fmt --check + +# Format (apply) +cargo fmt +``` + +Always run `cargo clippy -- -D warnings` and `cargo fmt --check` before +considering a task complete. + +--- + +## Project Structure + +``` +beacon-relay/ +├── Cargo.toml # Manifest — pin major versions, use ~ for minor +├── Cargo.lock # Committed to version control (binary crate) +├── AGENTS.md # This file +├── .gitignore # Rust-specific ignores +├── config/ +│ └── default.toml # Default configuration values +├── src/ +│ ├── main.rs # Entry point: config → tracing → router → serve +│ ├── config.rs # Figment-based config loading +│ ├── error.rs # AppError enum (thiserror), IntoResponse impl +│ ├── telemetry.rs # Tracing subscriber initialization +│ └── routes/ +│ ├── mod.rs # Router builder: create_router() +│ └── health.rs # GET /healthz +└── tests/ + └── health_test.rs # Integration test for health endpoint +``` + +As the project grows, new modules should follow this pattern: + +- `src/routes/.rs` for HTTP handlers +- `src/middleware/` for Tower middleware layers +- `src/models/` for shared data types +- `src/services/` for business logic (not HTTP-coupled) +- `src/db/` for database/Redis interaction + +--- + +## Approved Dependencies (Stable, Mature Crates) + +Only add dependencies from this list unless there is a clear, documented +reason. Prefer crates with >10M downloads, active maintenance, and no +known security advisories. + +### Core Stack + +| Purpose | Crate | Version | Notes | +| ------------------ | -------------------- | -------------------------------- | --------------------------------------- | +| Web framework | `axum` | ~0.8 | Tokio-team, macro-free, Tower ecosystem | +| Async runtime | `tokio` | 1 (features: full) | De facto standard | +| Serialization | `serde` | 1 (features: derive) | Universal | +| JSON | `serde_json` | 1 | Standard JSON | +| Structured logging | `tracing` | 0.1 | Async-aware, structured | +| Log subscriber | `tracing-subscriber` | 0.3 (features: env-filter, json) | Configurable output | +| Config loading | `figment` | 0.10 (features: toml, env) | Hierarchical, type-safe | +| Error types | `thiserror` | 2 | Derive Error for enums | +| HTTP middleware | `tower-http` | 0.6 (features: trace, cors) | Tower layers for axum | +| HTTP types | `tower` | 0.5 | Service trait, layers | + +### Future Iterations (Do Not Add Until Needed) + +| Purpose | Crate | Notes | +| ---------------- | ----------------------- | ----------------------------------------- | +| CBOR encoding | `ciborium` | BLE batch body parsing | +| Redis client | `fred` or `redis` | Session/nonce store | +| JWT | `jsonwebtoken` | Session token issuance/validation | +| Ethereum/EIP-712 | `alloy` | Signature verification, chain interaction | +| HTTP client | `reqwest` | Play Integrity API calls | +| UUID | `uuid` | Session IDs | +| Time | `chrono` or `time` | Prefer `time` (lighter, no C deps) | +| Pub/Sub | `google-cloud-pubsub` | Streaming sink | +| OpenTelemetry | `tracing-opentelemetry` | Distributed tracing export | + +### Dependency Selection Criteria + +When evaluating a new dependency: + +1. **Downloads**: >10M total on crates.io preferred; >1M acceptable if niche. +2. **Maintenance**: Last release within 6 months; active issue triage. +3. **Security**: No open advisories in `cargo audit`. +4. **Compatibility**: Must work with current Tokio and Axum versions. +5. **Size**: Prefer crates with minimal transitive dependencies. +6. **Alternatives**: Check if the functionality exists in `std` or an already-included crate first. + +--- + +## Coding Standards + +### Error Handling + +- Use `thiserror` for all error enums. Every variant must have a `#[error("...")]` message. +- Implement `IntoResponse` for error types that reach HTTP handlers. +- Map errors to appropriate HTTP status codes — do not leak internal details to clients. +- Use `?` propagation freely; avoid manual `match` on `Result` unless you need to transform the error. +- Reserve `unwrap()` and `expect()` for cases where the invariant is provably upheld (e.g., compile-time constants, test code). Add a comment explaining why. +- Never use `unwrap()` on user input, network responses, or file I/O. + +### Logging and Tracing + +- Use `tracing` macros (`info!`, `warn!`, `error!`, `debug!`, `trace!`), never `println!` or `eprintln!` in library/production code. +- Add structured fields: `tracing::info!(endpoint = %path, status = %code, "request handled")`. +- Use `#[tracing::instrument]` on async functions that benefit from span context. Skip large arguments with `skip(body)`. + +### Naming Conventions + +- **Files**: `snake_case.rs` +- **Types**: `PascalCase` — `AppError`, `HealthResponse` +- **Functions**: `snake_case` — `create_router`, `init_tracing` +- **Constants**: `SCREAMING_SNAKE_CASE` +- **Test functions**: `test_` (e.g., `test_healthz_returns_200`) + +### Code Organization + +- Keep handler functions short. Extract business logic into separate functions or service modules. +- One public type or function per conceptual responsibility. +- Group related `use` statements: std → external crates → crate modules. +- Prefer returning `impl IntoResponse` from handlers over concrete types. + +### Testing + +- **Integration tests** go in `tests/` directory — they test the HTTP interface via Tower `ServiceExt`. +- **Unit tests** go in `#[cfg(test)] mod tests` blocks within source files. +- Test names: `test_` (e.g., `test_healthz_returns_200`). +- Use `#[tokio::test]` for async tests. +- Assert both status codes and response bodies. +- Do not use `unwrap()` in tests without a comment — prefer `expect("reason")`. + +### Security + +- Never log secrets, tokens, private keys, or raw attestation blobs at INFO level or above. +- Validate and bound all external input at system boundaries. +- Use constant-time comparison for secrets and signatures. +- Set appropriate CORS and security headers via Tower middleware. +- Follow OWASP Top 10 guidance. + +### Performance + +- Handlers should be non-blocking. Use `tokio::task::spawn_blocking` for CPU-heavy work. +- Prefer streaming/zero-copy patterns for large request bodies (BLE batches). +- Use `Arc` for shared application state passed via Axum's `State` extractor. + +--- + +## Configuration + +Configuration is loaded via Figment in this order (later overrides earlier): + +1. `config/default.toml` — defaults committed to VCS +2. `config/{APP_ENV}.toml` — environment-specific (e.g., `production.toml`), optional +3. Environment variables prefixed with `APP_` — highest priority + +Nested config uses `__` separator in env vars: `APP_SERVER__PORT=8080`. + +All config fields must have sensible defaults in `default.toml` so the server +starts with zero configuration. + +--- + +## Design Document Reference + +The gateway specification is in `GATEWAY-SPEC.md` in this directory. Key +sections for implementation: + +- §3 Architecture Overview — module boundaries and data flow +- §4 API Endpoints — full endpoint map +- §5 Device Onboarding — `/v2/onboard` flow +- §6 Per-Request Authentication — `/v2/scan/ble` validation +- §7 EIP-712 Typed Data Signing — signature verification details +- §8 Session Management — Redis state, refresh flow, service classes +- §9 Smart Contract Interfaces — `PublisherRegistry` and `DeviceRegistry` + +When implementing a new endpoint or module, read the corresponding +specification section first. diff --git a/beacon-relay/Cargo.lock b/beacon-relay/Cargo.lock new file mode 100644 index 0000000..7ec57ef --- /dev/null +++ b/beacon-relay/Cargo.lock @@ -0,0 +1,933 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "atomic" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89cbf775b137e9b968e67227ef7f775587cde3fd31b0d8599dbd0f598a48340" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "beacon-relay" +version = "0.1.0" +dependencies = [ + "axum", + "figment", + "http-body-util", + "serde", + "serde_json", + "thiserror", + "tokio", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[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", +] + +[[package]] +name = "figment" +version = "0.10.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cb01cd46b0cf372153850f4c6c272d9cbea2da513e07538405148f95bd789f3" +dependencies = [ + "atomic", + "pear", + "serde", + "toml", + "uncased", + "version_check", +] + +[[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", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[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-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "inlinable_string" +version = "0.1.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8fae54786f62fb2918dcfae3d568594e50eb9b5c25bf04371af6fe7516452fb" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[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", + "smallvec", + "windows-link", +] + +[[package]] +name = "pear" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdeeaa00ce488657faba8ebf44ab9361f9365a97bd39ffb8a60663f57ff4b467" +dependencies = [ + "inlinable_string", + "pear_codegen", + "yansi", +] + +[[package]] +name = "pear_codegen" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bab5b985dc082b345f812b7df84e1bef27e7207b39e448439ba8bd69c93f147" +dependencies = [ + "proc-macro2", + "proc-macro2-diagnostics", + "quote", + "syn", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proc-macro2-diagnostics" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "version_check", + "yansi", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c55a2eff8b69ce66c84f85e1da1c233edc36ceb85a2058d11b0d6a3c7e7569c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "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", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", +] + +[[package]] +name = "uncased" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1b88fcfe09e89d3866a5c11019378088af2d24c3fbd4f0543f96b479ec90697" +dependencies = [ + "version_check", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "yansi" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/beacon-relay/Cargo.toml b/beacon-relay/Cargo.toml new file mode 100644 index 0000000..0f07276 --- /dev/null +++ b/beacon-relay/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "beacon-relay" +version = "0.1.0" +edition = "2021" +description = "V2 gateway for the Nodle DePIN network — BLE beacon data relay" +license = "MIT" + +[dependencies] +axum = "0.8" +figment = { version = "0.10", features = ["toml", "env"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tokio = { version = "1", features = ["full"] } +tower-http = { version = "0.6", features = ["trace", "cors"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] } + +[dev-dependencies] +http-body-util = "0.1" +tower = { version = "0.5", features = ["util"] } diff --git a/beacon-relay/config/default.toml b/beacon-relay/config/default.toml new file mode 100644 index 0000000..f67f77f --- /dev/null +++ b/beacon-relay/config/default.toml @@ -0,0 +1,7 @@ +[server] +host = "127.0.0.1" +port = 3000 + +[log] +level = "info" +format = "pretty" diff --git a/beacon-relay/src/config.rs b/beacon-relay/src/config.rs new file mode 100644 index 0000000..3aeb1ea --- /dev/null +++ b/beacon-relay/src/config.rs @@ -0,0 +1,44 @@ +use figment::{ + providers::{Env, Format, Toml}, + Figment, +}; +use serde::Deserialize; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +pub struct Config { + pub server: ServerConfig, + pub log: LogConfig, +} + +#[derive(Debug, Deserialize)] +pub struct ServerConfig { + pub host: String, + pub port: u16, +} + +#[derive(Debug, Deserialize)] +pub struct LogConfig { + pub level: String, + pub format: String, +} + +impl Config { + pub fn load() -> Result> { + let config_path = Self::default_config_path(); + Figment::new() + .merge(Toml::file(config_path)) + .merge(Env::prefixed("APP_").split("__")) + .extract() + .map_err(Box::new) + } + + pub fn listen_addr(&self) -> String { + format!("{}:{}", self.server.host, self.server.port) + } + + fn default_config_path() -> PathBuf { + let manifest_dir = env!("CARGO_MANIFEST_DIR"); + PathBuf::from(manifest_dir).join("config/default.toml") + } +} diff --git a/beacon-relay/src/error.rs b/beacon-relay/src/error.rs new file mode 100644 index 0000000..493227c --- /dev/null +++ b/beacon-relay/src/error.rs @@ -0,0 +1,18 @@ +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; + +#[derive(Debug, thiserror::Error)] +#[allow(dead_code)] +pub enum AppError { + #[error("internal server error: {0}")] + Internal(String), +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let status = match &self { + AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + }; + (status, self.to_string()).into_response() + } +} diff --git a/beacon-relay/src/lib.rs b/beacon-relay/src/lib.rs new file mode 100644 index 0000000..f6a47a6 --- /dev/null +++ b/beacon-relay/src/lib.rs @@ -0,0 +1,4 @@ +pub mod config; +pub mod error; +pub mod routes; +pub mod telemetry; diff --git a/beacon-relay/src/main.rs b/beacon-relay/src/main.rs new file mode 100644 index 0000000..15ba5bc --- /dev/null +++ b/beacon-relay/src/main.rs @@ -0,0 +1,54 @@ +mod config; +mod error; +mod routes; +mod telemetry; + +use tokio::net::TcpListener; +use tokio::signal; + +#[tokio::main] +async fn main() { + let cfg = config::Config::load().expect("failed to load configuration"); + + telemetry::init_tracing(&cfg.log.level, &cfg.log.format); + + let app = routes::create_router(); + let addr = cfg.listen_addr(); + + let listener = TcpListener::bind(&addr) + .await + .expect("failed to bind TCP listener"); + + tracing::info!(addr = %addr, "beacon-relay listening"); + + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .await + .expect("server error"); +} + +async fn shutdown_signal() { + let ctrl_c = async { + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); + }; + + #[cfg(unix)] + let terminate = async { + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install SIGTERM handler") + .recv() + .await; + }; + + #[cfg(not(unix))] + let terminate = std::future::pending::<()>(); + + tokio::select! { + () = ctrl_c => {}, + () = terminate => {}, + } + + tracing::info!("shutdown signal received, starting graceful shutdown"); +} diff --git a/beacon-relay/src/routes/health.rs b/beacon-relay/src/routes/health.rs new file mode 100644 index 0000000..5986d8d --- /dev/null +++ b/beacon-relay/src/routes/health.rs @@ -0,0 +1,11 @@ +use axum::Json; +use serde::Serialize; + +#[derive(Serialize)] +pub struct HealthResponse { + pub status: &'static str, +} + +pub async fn healthz() -> Json { + Json(HealthResponse { status: "ok" }) +} diff --git a/beacon-relay/src/routes/mod.rs b/beacon-relay/src/routes/mod.rs new file mode 100644 index 0000000..2348103 --- /dev/null +++ b/beacon-relay/src/routes/mod.rs @@ -0,0 +1,10 @@ +use axum::{routing::get, Router}; +use tower_http::trace::TraceLayer; + +mod health; + +pub fn create_router() -> Router { + Router::new() + .route("/healthz", get(health::healthz)) + .layer(TraceLayer::new_for_http()) +} diff --git a/beacon-relay/src/telemetry.rs b/beacon-relay/src/telemetry.rs new file mode 100644 index 0000000..a33b93e --- /dev/null +++ b/beacon-relay/src/telemetry.rs @@ -0,0 +1,14 @@ +use tracing_subscriber::{fmt, EnvFilter}; + +pub fn init_tracing(level: &str, format: &str) { + let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(level)); + + match format { + "json" => { + fmt().with_env_filter(filter).json().init(); + } + _ => { + fmt().with_env_filter(filter).init(); + } + } +} diff --git a/beacon-relay/tests/health_test.rs b/beacon-relay/tests/health_test.rs new file mode 100644 index 0000000..a03f7be --- /dev/null +++ b/beacon-relay/tests/health_test.rs @@ -0,0 +1,51 @@ +use axum::http::StatusCode; +use http_body_util::BodyExt; +use tower::ServiceExt; + +#[tokio::test] +async fn test_healthz_returns_200() { + let app = beacon_relay::routes::create_router(); + + let request = axum::http::Request::builder() + .method("GET") + .uri("/healthz") + .body(axum::body::Body::empty()) + .expect("failed to build request"); + + let response = app + .oneshot(request) + .await + .expect("failed to execute request"); + + assert_eq!(response.status(), StatusCode::OK); + + let body = response + .into_body() + .collect() + .await + .expect("failed to read body") + .to_bytes(); + + let json: serde_json::Value = + serde_json::from_slice(&body).expect("response is not valid JSON"); + + assert_eq!(json["status"], "ok"); +} + +#[tokio::test] +async fn test_unknown_route_returns_404() { + let app = beacon_relay::routes::create_router(); + + let request = axum::http::Request::builder() + .method("GET") + .uri("/nonexistent") + .body(axum::body::Body::empty()) + .expect("failed to build request"); + + let response = app + .oneshot(request) + .await + .expect("failed to execute request"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +}