commit b439762877da133f6b773d1c89d25decb5b4d326 Author: kjuulh Date: Sat Mar 7 19:46:13 2026 +0100 feat: add basic website Signed-off-by: kjuulh diff --git a/.claude/projects/-home-kjuulh-git-git-kjuulh-io-forage-client/memory/MEMORY.md b/.claude/projects/-home-kjuulh-git-git-kjuulh-io-forage-client/memory/MEMORY.md new file mode 100644 index 0000000..e6fd9cd --- /dev/null +++ b/.claude/projects/-home-kjuulh-git-git-kjuulh-io-forage-client/memory/MEMORY.md @@ -0,0 +1,41 @@ +# Forage Client - Project Memory + +## Project Overview +- Forage is a server-side rendered frontend for forest-server +- All auth/user/org management via gRPC to forest-server's UsersService +- No local user database - forest-server owns all auth state +- Follows VSDD methodology + +## Architecture +- Rust workspace with 5 crates: forage-server, forage-core, forage-db, forage-grpc, ci +- forage-grpc: generated proto stubs from forest's users.proto (buf generate) +- forage-core: ForestAuth trait (async_trait, object-safe), validation, types +- forage-server: axum routes, gRPC client impl, cookie-based session +- MiniJinja templates, Tailwind CSS +- Forest + Mise for task running + +## Key Patterns +- `ForestAuth` trait uses `#[async_trait]` for object safety -> `Arc` +- `GrpcForestClient` in forage-server implements ForestAuth via tonic +- `MockForestClient` in tests implements ForestAuth for testing without gRPC +- Auth via HTTP-only cookies: `forage_access` + `forage_refresh` +- `RequireAuth` extractor redirects to /login, `MaybeAuth` is optional +- Templates at workspace root, resolved via `CARGO_MANIFEST_DIR` in tests + +## Dependencies +- tonic 0.14 + tonic-prost 0.14 + prost 0.14 (must match for generated code) +- axum-extra with cookie feature for cookie management +- async-trait for object-safe async traits +- buf for proto generation (users.proto from forest) + +## CI/CD +- Dagger-based CI in ci/ crate: `ci pr` and `ci main` +- `mise run ci:pr` / `mise run ci:main` +- Docker builds with distroless runtime + +## Current State +- 20 tests passing (6 validation + 14 integration) +- Spec 001 (landing page): complete +- Spec 002 (authentication): Phase 2 complete +- Routes: /, /pricing, /signup, /login, /logout, /dashboard, /settings/tokens +- FOREST_SERVER_URL env var configures gRPC endpoint (default localhost:4040) diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1b3085f --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +# Forest server gRPC endpoint +FOREST_SERVER_URL=http://localhost:4040 + +# HTTP port (default: 3000) +# PORT=3001 + +# PostgreSQL connection (optional - omit for in-memory sessions) +# DATABASE_URL=postgresql://forageuser:foragepassword@localhost:5432/forage diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..81c44ef --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +.env +*.swp +*.swo +*~ +.DS_Store +node_modules/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..9db3af7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,92 @@ +# Forage Client - AI Development Guide + +## Project Overview + +Forage is the managed platform and registry for [Forest](https://src.rawpotion.io/rawpotion/forest) - an infrastructure-as-code tool that lets organisations codify their development workflows, CI, deployments, and component sharing. Forage extends forest by providing: + +- **Component Registry**: Host and distribute forest components +- **Managed Deployments**: Push a `forest.cue` manifest and get automatic deployment (Heroku-like experience) +- **Container Runtimes**: Pay-as-you-go alternative to Kubernetes +- **Managed Services**: Databases, user management, observability, and more +- **Organisation Management**: Teams, billing, access control + +## Architecture + +- **Language**: Rust +- **Web Framework**: Axum +- **Templating**: MiniJinja (server-side rendered) +- **Styling**: Tailwind CSS (via standalone CLI) +- **Database**: PostgreSQL (via SQLx, compile-time checked queries) +- **Build System**: Forest + Mise for task running + +## Project Structure + +``` +/ +├── CLAUDE.md # This file +├── Cargo.toml # Workspace root +├── forest.cue # Forest project manifest +├── mise.toml # Task runner configuration +├── crates/ +│ ├── forage-server/ # Main axum web server +│ │ ├── src/ +│ │ │ ├── main.rs +│ │ │ ├── routes/ # Axum route handlers +│ │ │ ├── templates/ # MiniJinja templates +│ │ │ └── state.rs # Application state +│ │ └── Cargo.toml +│ ├── forage-core/ # Business logic, pure functions +│ │ ├── src/ +│ │ │ ├── lib.rs +│ │ │ ├── registry/ # Component registry logic +│ │ │ ├── deployments/ # Deployment orchestration +│ │ │ └── billing/ # Pricing and billing +│ │ └── Cargo.toml +│ └── forage-db/ # Database layer +│ ├── src/ +│ │ ├── lib.rs +│ │ └── migrations/ +│ └── Cargo.toml +├── templates/ # Shared MiniJinja templates +│ ├── base.html.jinja +│ ├── pages/ +│ └── components/ +├── static/ # Static assets (CSS, JS, images) +├── specs/ # VSDD specification documents +└── tests/ # Integration tests +``` + +## Development Methodology: VSDD + +This project follows **Verified Spec-Driven Development (VSDD)**. See `specs/VSDD.md` for the full methodology. + +### Key Rules for AI Development + +Follow the VSDD pipeline **religiously**. No shortcuts, no skipping phases. + +1. **Spec First**: Never implement without a spec in `specs/`. Read the spec before writing code. +2. **Test First**: Write failing tests before implementation. No code exists without a test that demanded it. Confirm tests fail (Red) before writing implementation (Green). +3. **Pure Core / Effectful Shell**: `forage-core` is the pure, testable core. `forage-server` is the effectful shell. Database access lives in `forage-db`. +4. **Minimal Implementation**: Write the minimum code to pass each test. Refactor only after green. +5. **Trace Everything**: Every spec requirement maps to tests which map to implementation. +6. **Adversarial Review**: After implementation, conduct a thorough adversarial review (Phase 3). Save reviews in `specs/reviews/`. +7. **Feedback Loop**: Review findings feed back into specs and tests (Phase 4). Iterate until convergence. +8. **Hardening**: Run clippy, cargo-audit, and static analysis (Phase 5). Property-based tests where applicable. + +## Commands + +- `mise run develop` - Start the dev server +- `mise run test` - Run all tests +- `mise run db:migrate` - Run database migrations +- `mise run build` - Build release binary +- `forest run ` - Run forest-defined commands + +## Conventions + +- Use `snake_case` for all Rust identifiers +- Prefer `thiserror` for error types in libraries, `anyhow` in binaries +- All database queries use SQLx compile-time checking +- Templates use MiniJinja with `.html.jinja` extension +- Routes are organized by feature in `routes/` modules +- All public API endpoints return proper HTTP status codes +- Configuration via environment variables with sensible defaults diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..145cd27 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3887 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[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 = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[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.117", +] + +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8" +dependencies = [ + "axum-core", + "axum-macros", + "bytes", + "form_urlencoded", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "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 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-extra" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9963ff19f40c6102c76756ef0a46004c0d58957d87259fc9208ff8441c12ab96" +dependencies = [ + "axum", + "axum-core", + "bytes", + "cookie", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "serde_core", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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 = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" +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 = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[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.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" +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.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "ci" +version = "0.1.0" +dependencies = [ + "chrono", + "clap", + "dagger-sdk", + "eyre", + "tokio", +] + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim 0.11.1", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[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 = "dagger-sdk" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82dde5a2985705a4d3118281dd119d2310cba07e9be08221bd470e7af3fa5502" +dependencies = [ + "async-trait", + "base64 0.21.7", + "derive_builder", + "dirs", + "eyre", + "flate2", + "futures", + "graphql_client", + "hex", + "hex-literal", + "platform-info", + "reqwest", + "serde", + "serde_graphql_input", + "serde_json", + "sha2", + "tar", + "tempfile", + "thiserror 1.0.69", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + +[[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" +dependencies = [ + "powerfmt", +] + +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + +[[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 = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[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 = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "forage-core" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "rand 0.9.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "uuid", +] + +[[package]] +name = "forage-db" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "forage-core", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.18", + "tracing", + "uuid", +] + +[[package]] +name = "forage-grpc" +version = "0.1.0" +dependencies = [ + "prost", + "prost-types", + "tonic", + "tonic-prost", +] + +[[package]] +name = "forage-server" +version = "0.1.0" +dependencies = [ + "anyhow", + "async-trait", + "axum", + "axum-extra", + "chrono", + "forage-core", + "forage-db", + "forage-grpc", + "minijinja", + "serde", + "serde_json", + "sqlx", + "tokio", + "tonic", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + +[[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" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[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 = "graphql-introspection-query" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f2a4732cf5140bd6c082434494f785a19cfb566ab07d1382c3671f5812fed6d" +dependencies = [ + "serde", +] + +[[package]] +name = "graphql-parser" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a818c0d883d7c0801df27be910917750932be279c7bc82dc541b8769425f409" +dependencies = [ + "combine", + "thiserror 1.0.69", +] + +[[package]] +name = "graphql_client" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cdf7b487d864c2939b23902291a5041bc4a84418268f25fda1c8d4e15ad8fa" +dependencies = [ + "graphql_query_derive", + "reqwest", + "serde", + "serde_json", +] + +[[package]] +name = "graphql_client_codegen" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a40f793251171991c4eb75bd84bc640afa8b68ff6907bc89d3b712a22f700506" +dependencies = [ + "graphql-introspection-query", + "graphql-parser", + "heck 0.4.1", + "lazy_static", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn 1.0.109", +] + +[[package]] +name = "graphql_query_derive" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bda454f3d313f909298f626115092d348bc231025699f557b27e248475f48c" +dependencies = [ + "graphql_client_codegen", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http 1.4.0", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hashlink" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[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 = "hex-literal" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fe2267d4ed49bc07b63801559be28c718ea06c4738b7a03c94df7386d2cde46" + +[[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 = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[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 1.4.0", + "http-body 1.0.1", + "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 = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "pin-utils", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.32", + "rustls 0.21.12", + "tokio", + "tokio-rustls", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper 1.8.1", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.8.1", + "libc", + "pin-project-lite", + "socket2 0.6.3", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.182" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a" +dependencies = [ + "bitflags 2.11.0", + "libc", + "plain", + "redox_syscall 0.7.3", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "md-5" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +dependencies = [ + "cfg-if", + "digest", +] + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memo-map" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d1115007560874e373613744c6fba374c17688327a71c1476d1a5954cc857b" + +[[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 = "minijinja" +version = "2.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ea5ea1e90055f200af6b8e52a4a34e05e77e7fee953a9fb40c631efdc43cab1" +dependencies = [ + "memo-map", + "self_cell", + "serde", +] + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + +[[package]] +name = "num-conv" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "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.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "platform-info" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7539aeb3fdd8cb4f6a331307cf71a1039cee75e94e8a71725b9484f4a0d9451a" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[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 = "prost" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "prost-types" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +dependencies = [ + "prost", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_syscall" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[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 = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.27", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-rustls", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls 0.21.12", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-rustls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "webpki-roots 0.25.4", + "winreg", +] + +[[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 0.6.4", + "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 2.11.0", + "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 = [ + "log", + "ring", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki 0.103.9", + "subtle", + "zeroize", +] + +[[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-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "self_cell" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89" + +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_graphql_input" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7b3ed302fb48549bd1b0df59d180655f0eb621d71a3924c68e1af9aed4f6a6a" +dependencies = [ + "anyhow", + "itoa", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-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 0.6.4", +] + +[[package]] +name = "simd-adler32" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +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 = "sqlx" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" +dependencies = [ + "base64 0.22.1", + "bytes", + "chrono", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.15.5", + "hashlink", + "indexmap", + "log", + "memchr", + "once_cell", + "percent-encoding", + "rustls 0.23.37", + "serde", + "serde_json", + "sha2", + "smallvec", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tracing", + "url", + "uuid", + "webpki-roots 0.26.11", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.117", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.117", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.11.0", + "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 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.11.0", + "byteorder", + "chrono", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 2.0.18", + "tracing", + "uuid", + "whoami", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" +dependencies = [ + "atoi", + "chrono", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "thiserror 2.0.18", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "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 = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "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.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[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.117", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +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.117", +] + +[[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.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.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 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[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 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + +[[package]] +name = "tokio-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 = "tonic" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" +dependencies = [ + "async-trait", + "axum", + "base64 0.22.1", + "bytes", + "h2 0.4.13", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.8.1", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2 0.6.3", + "sync_wrapper 1.0.2", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper 1.0.2", + "tokio", + "tokio-util", + "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 = [ + "async-compression", + "bitflags 2.11.0", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "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.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-bidi" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-properties" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +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 = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +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 = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.0", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "0.25.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f20c57d8d7db6d3b86154206ae5d8fba62dd39573114de97c2cb0578251f8e1" + +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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.117", +] + +[[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 = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 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.117", + "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.117", + "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 2.11.0", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[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..a1b8bf8 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[workspace] +resolver = "2" +members = [ + "crates/forage-server", + "crates/forage-core", + "crates/forage-db", + "crates/forage-grpc", + "ci", +] + +[workspace.dependencies] +anyhow = "1" +thiserror = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["full"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +axum = { version = "0.8", features = ["macros"] } +axum-extra = { version = "0.10", features = ["cookie"] } +minijinja = { version = "2", features = ["loader"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "migrate", "uuid", "chrono"] } +uuid = { version = "1", features = ["v4", "serde"] } +chrono = { version = "0.4", features = ["serde"] } +tower = "0.5" +tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-gzip"] } +tonic = "0.14" +prost = "0.14" +prost-types = "0.14" +tonic-prost = "0.14" +async-trait = "0.1" +rand = "0.9" diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..318c087 --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,9 @@ +version: v2 +plugins: + - remote: buf.build/community/neoeinstein-prost:v0.5.0 + out: crates/forage-grpc/src/grpc/ + - remote: buf.build/community/neoeinstein-tonic:v0.5.0 + out: crates/forage-grpc/src/grpc/ + opt: + - client_mod_attribute=*=cfg(feature = "client") + - server_mod_attribute=*=cfg(feature = "server") diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..de64a33 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,3 @@ +version: v2 +modules: + - path: interface/proto diff --git a/ci/Cargo.toml b/ci/Cargo.toml new file mode 100644 index 0000000..e05a601 --- /dev/null +++ b/ci/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ci" +version = "0.1.0" +edition = "2024" + +[[bin]] +name = "ci" +path = "src/main.rs" + +[dependencies] +dagger-sdk = "0.20" +eyre = "0.6" +tokio = { version = "1", features = ["full"] } +clap = { version = "4", features = ["derive"] } +chrono = "0.4" diff --git a/ci/src/main.rs b/ci/src/main.rs new file mode 100644 index 0000000..7e50ac7 --- /dev/null +++ b/ci/src/main.rs @@ -0,0 +1,325 @@ +use std::path::PathBuf; + +use clap::Parser; + +const BIN_NAME: &str = "forage-server"; +const MOLD_VERSION: &str = "2.40.4"; + +#[derive(Parser)] +#[command(name = "ci")] +enum Cli { + /// Run PR validation pipeline (check + test + build) + Pr, + /// Run main branch pipeline (check + test + build + publish) + Main, +} + +#[tokio::main] +async fn main() -> eyre::Result<()> { + let cli = Cli::parse(); + + dagger_sdk::connect(|client| async move { + match cli { + Cli::Pr => run_pr(&client).await?, + Cli::Main => run_main(&client).await?, + } + Ok(()) + }) + .await?; + + Ok(()) +} + +async fn run_pr(client: &dagger_sdk::Query) -> eyre::Result<()> { + eprintln!("==> PR pipeline: check + test + build"); + + let base = build_base(client).await?; + + eprintln!("--- cargo check --workspace"); + base.clone() + .with_exec(vec!["cargo", "check", "--workspace"]) + .sync() + .await?; + + eprintln!("--- cargo clippy"); + base.clone() + .with_exec(vec![ + "cargo", + "clippy", + "--workspace", + "--", + "-D", + "warnings", + ]) + .sync() + .await?; + + eprintln!("--- cargo fmt --check"); + base.clone() + .with_exec(vec!["cargo", "fmt", "--", "--check"]) + .sync() + .await?; + + eprintln!("--- running tests"); + run_tests(&base).await?; + + eprintln!("--- building release image"); + let _image = build_release_image(client, &base).await?; + + eprintln!("==> PR pipeline complete"); + Ok(()) +} + +async fn run_main(client: &dagger_sdk::Query) -> eyre::Result<()> { + eprintln!("==> Main pipeline: check + test + build + publish"); + + let base = build_base(client).await?; + + eprintln!("--- cargo check --workspace"); + base.clone() + .with_exec(vec!["cargo", "check", "--workspace"]) + .sync() + .await?; + + eprintln!("--- running tests"); + run_tests(&base).await?; + + eprintln!("--- building release image"); + let image = build_release_image(client, &base).await?; + + eprintln!("--- publishing image"); + publish_image(client, &image).await?; + + eprintln!("==> Main pipeline complete"); + Ok(()) +} + +/// Load only Rust-relevant source files from host. +/// Using include patterns prevents cache busting from unrelated file changes. +fn load_source(client: &dagger_sdk::Query) -> eyre::Result { + let src = client.host().directory_opts( + ".", + dagger_sdk::HostDirectoryOptsBuilder::default() + .include(vec![ + "**/*.rs", + "**/Cargo.toml", + "Cargo.lock", + ".sqlx/**", + "**/*.sql", + "**/*.toml", + "templates/**", + "static/**", + ]) + .build()?, + ); + Ok(src) +} + +/// Load dependency-only source (Cargo.toml + Cargo.lock + .sqlx, no .rs or tests). +fn load_dep_source(client: &dagger_sdk::Query) -> eyre::Result { + let src = client.host().directory_opts( + ".", + dagger_sdk::HostDirectoryOptsBuilder::default() + .include(vec!["**/Cargo.toml", "Cargo.lock", ".sqlx/**"]) + .build()?, + ); + Ok(src) +} + +/// Create skeleton source files so cargo can resolve deps without real source. +fn create_skeleton_files(client: &dagger_sdk::Query) -> eyre::Result { + let main_content = r#"fn main() { panic!("skeleton"); }"#; + let lib_content = r#"pub fn _skeleton() {}"#; + + let crate_paths = discover_crates()?; + let mut dir = client.directory(); + + for crate_path in &crate_paths { + let src_dir = crate_path.join("src"); + dir = dir.with_new_file( + src_dir.join("main.rs").to_string_lossy().to_string(), + main_content, + ); + dir = dir.with_new_file( + src_dir.join("lib.rs").to_string_lossy().to_string(), + lib_content, + ); + } + + // Also add skeleton for ci/ crate itself. + dir = dir.with_new_file("ci/src/main.rs".to_string(), main_content); + + Ok(dir) +} + +/// Discover workspace crate directories by finding Cargo.toml files. +fn discover_crates() -> eyre::Result> { + let mut crate_paths = Vec::new(); + let root = PathBuf::from("crates"); + if root.is_dir() { + find_crates_recursive(&root, &mut crate_paths)?; + } + Ok(crate_paths) +} + +fn find_crates_recursive(dir: &PathBuf, out: &mut Vec) -> eyre::Result<()> { + if dir.join("Cargo.toml").exists() { + out.push(dir.clone()); + } + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + if entry.file_type()?.is_dir() { + let name = entry.file_name(); + if name == "target" || name == "node_modules" { + continue; + } + find_crates_recursive(&entry.path(), out)?; + } + } + Ok(()) +} + +/// Build the base Rust container with all deps cached. +async fn build_base(client: &dagger_sdk::Query) -> eyre::Result { + let src = load_source(client)?; + let dep_src = load_dep_source(client)?; + let skeleton = create_skeleton_files(client)?; + + let dep_src_with_skeleton = dep_src.with_directory(".", skeleton); + + // Base rust image with build tools. + let rust_base = client + .container() + .from("rust:1.85-bookworm") + .with_exec(vec!["apt", "update"]) + .with_exec(vec!["apt", "install", "-y", "clang", "wget"]) + // Install mold linker. + .with_exec(vec![ + "wget", + "-q", + &format!( + "https://github.com/rui314/mold/releases/download/v{MOLD_VERSION}/mold-{MOLD_VERSION}-x86_64-linux.tar.gz" + ), + ]) + .with_exec(vec![ + "tar", + "-xf", + &format!("mold-{MOLD_VERSION}-x86_64-linux.tar.gz"), + ]) + .with_exec(vec![ + "mv", + &format!("mold-{MOLD_VERSION}-x86_64-linux/bin/mold"), + "/usr/bin/mold", + ]); + + // Step 1: build deps with skeleton source (cacheable layer). + let prebuild = rust_base + .clone() + .with_workdir("/mnt/src") + .with_env_variable("SQLX_OFFLINE", "true") + .with_directory("/mnt/src", dep_src_with_skeleton) + .with_exec(vec!["cargo", "build", "--release", "--bin", BIN_NAME]); + + // Step 2: copy cargo registry from prebuild (avoids re-downloading deps). + let build_container = rust_base + .with_workdir("/mnt/src") + .with_env_variable("SQLX_OFFLINE", "true") + .with_directory("/usr/local/cargo", prebuild.directory("/usr/local/cargo")) + .with_directory("/mnt/src/", src); + + Ok(build_container) +} + +/// Run tests. +async fn run_tests(base: &dagger_sdk::Container) -> eyre::Result<()> { + base.clone() + .with_exec(vec!["cargo", "test", "--workspace"]) + .sync() + .await?; + + Ok(()) +} + +/// Build release binary and package into a slim image. +async fn build_release_image( + client: &dagger_sdk::Query, + base: &dagger_sdk::Container, +) -> eyre::Result { + let built = base + .clone() + .with_exec(vec!["cargo", "build", "--release", "--bin", BIN_NAME]); + + let binary = built.file(format!("/mnt/src/target/release/{BIN_NAME}")); + + // Load templates and static assets from host for the runtime image. + let templates = client.host().directory("templates"); + let static_assets = client.host().directory("static"); + + // Distroless cc-debian12 matches the build image's glibc + // and includes libgcc + ca-certificates with no shell or package manager. + let final_image = client + .container() + .from("gcr.io/distroless/cc-debian12") + .with_file(format!("/usr/local/bin/{BIN_NAME}"), binary) + .with_directory("/templates", templates) + .with_directory("/static", static_assets) + .with_env_variable("FORAGE_TEMPLATES_PATH", "/templates"); + + final_image.sync().await?; + + // Set the final entrypoint for the published image. + let final_image = final_image.with_entrypoint(vec![BIN_NAME]); + + eprintln!("--- release image built successfully"); + Ok(final_image) +} + +/// Publish image to container registry with latest, commit, and timestamp tags. +async fn publish_image( + client: &dagger_sdk::Query, + image: &dagger_sdk::Container, +) -> eyre::Result<()> { + let registry = std::env::var("CI_REGISTRY").unwrap_or_else(|_| "registry.forage.sh".into()); + let user = std::env::var("CI_REGISTRY_USER").unwrap_or_else(|_| "forage".into()); + let image_name = std::env::var("CI_IMAGE_NAME") + .unwrap_or_else(|_| format!("{registry}/{user}/forage-server")); + + let password = std::env::var("CI_REGISTRY_PASSWORD") + .map_err(|_| eyre::eyre!("CI_REGISTRY_PASSWORD must be set for publishing"))?; + + let commit = git_short_hash()?; + let timestamp = chrono::Utc::now().format("%Y%m%d%H%M%S").to_string(); + + let tags = vec!["latest".to_string(), commit, timestamp]; + + let authed = image.clone().with_registry_auth( + ®istry, + &user, + client.set_secret("registry-password", &password), + ); + + for tag in &tags { + let image_ref = format!("{image_name}:{tag}"); + authed + .publish_opts( + &image_ref, + dagger_sdk::ContainerPublishOptsBuilder::default().build()?, + ) + .await?; + eprintln!("--- published {image_ref}"); + } + + Ok(()) +} + +/// Get the short git commit hash from the host. +fn git_short_hash() -> eyre::Result { + let output = std::process::Command::new("git") + .args(["rev-parse", "--short", "HEAD"]) + .output()?; + let hash = String::from_utf8(output.stdout)?.trim().to_string(); + if hash.is_empty() { + return Err(eyre::eyre!("could not determine git commit hash")); + } + Ok(hash) +} diff --git a/crates/forage-core/Cargo.toml b/crates/forage-core/Cargo.toml new file mode 100644 index 0000000..2e02430 --- /dev/null +++ b/crates/forage-core/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "forage-core" +version = "0.1.0" +edition = "2024" + +[dependencies] +async-trait.workspace = true +thiserror.workspace = true +serde.workspace = true +serde_json.workspace = true +uuid.workspace = true +chrono.workspace = true +rand.workspace = true + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt"] } diff --git a/crates/forage-core/src/auth/mod.rs b/crates/forage-core/src/auth/mod.rs new file mode 100644 index 0000000..b9c09ef --- /dev/null +++ b/crates/forage-core/src/auth/mod.rs @@ -0,0 +1,112 @@ +mod validation; + +pub use validation::{validate_email, validate_password, validate_username}; + +use serde::{Deserialize, Serialize}; + +/// Tokens returned by forest-server after login/register. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AuthTokens { + pub access_token: String, + pub refresh_token: String, + pub expires_in_seconds: i64, +} + +/// Minimal user info from forest-server. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct User { + pub user_id: String, + pub username: String, + pub emails: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserEmail { + pub email: String, + pub verified: bool, +} + +/// A personal access token (metadata only, no raw key). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PersonalAccessToken { + pub token_id: String, + pub name: String, + pub scopes: Vec, + pub created_at: Option, + pub last_used: Option, + pub expires_at: Option, +} + +/// Result of creating a PAT - includes the raw key shown once. +#[derive(Debug, Clone)] +pub struct CreatedToken { + pub token: PersonalAccessToken, + pub raw_token: String, +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum AuthError { + #[error("invalid credentials")] + InvalidCredentials, + + #[error("already exists: {0}")] + AlreadyExists(String), + + #[error("not authenticated")] + NotAuthenticated, + + #[error("token expired")] + TokenExpired, + + #[error("forest-server unavailable: {0}")] + Unavailable(String), + + #[error("{0}")] + Other(String), +} + +/// Trait for communicating with forest-server's UsersService. +/// Object-safe via async_trait so we can use `Arc`. +#[async_trait::async_trait] +pub trait ForestAuth: Send + Sync { + async fn register( + &self, + username: &str, + email: &str, + password: &str, + ) -> Result; + + async fn login( + &self, + identifier: &str, + password: &str, + ) -> Result; + + async fn refresh_token( + &self, + refresh_token: &str, + ) -> Result; + + async fn logout(&self, refresh_token: &str) -> Result<(), AuthError>; + + async fn get_user(&self, access_token: &str) -> Result; + + async fn list_tokens( + &self, + access_token: &str, + user_id: &str, + ) -> Result, AuthError>; + + async fn create_token( + &self, + access_token: &str, + user_id: &str, + name: &str, + ) -> Result; + + async fn delete_token( + &self, + access_token: &str, + token_id: &str, + ) -> Result<(), AuthError>; +} diff --git a/crates/forage-core/src/auth/validation.rs b/crates/forage-core/src/auth/validation.rs new file mode 100644 index 0000000..243d5ad --- /dev/null +++ b/crates/forage-core/src/auth/validation.rs @@ -0,0 +1,120 @@ +#[derive(Debug, PartialEq)] +pub struct ValidationError(pub String); + +pub fn validate_email(email: &str) -> Result<(), ValidationError> { + if email.is_empty() { + return Err(ValidationError("Email is required".into())); + } + if !email.contains('@') || !email.contains('.') { + return Err(ValidationError("Invalid email format".into())); + } + if email.len() > 254 { + return Err(ValidationError("Email too long".into())); + } + Ok(()) +} + +pub fn validate_password(password: &str) -> Result<(), ValidationError> { + if password.is_empty() { + return Err(ValidationError("Password is required".into())); + } + if password.len() < 12 { + return Err(ValidationError( + "Password must be at least 12 characters".into(), + )); + } + if password.len() > 1024 { + return Err(ValidationError("Password too long".into())); + } + if !password.chars().any(|c| c.is_uppercase()) { + return Err(ValidationError( + "Password must contain at least one uppercase letter".into(), + )); + } + if !password.chars().any(|c| c.is_lowercase()) { + return Err(ValidationError( + "Password must contain at least one lowercase letter".into(), + )); + } + if !password.chars().any(|c| c.is_ascii_digit()) { + return Err(ValidationError( + "Password must contain at least one digit".into(), + )); + } + Ok(()) +} + +pub fn validate_username(username: &str) -> Result<(), ValidationError> { + if username.is_empty() { + return Err(ValidationError("Username is required".into())); + } + if username.len() < 3 { + return Err(ValidationError( + "Username must be at least 3 characters".into(), + )); + } + if username.len() > 64 { + return Err(ValidationError("Username too long".into())); + } + if !username + .chars() + .all(|c| c.is_alphanumeric() || c == '-' || c == '_') + { + return Err(ValidationError( + "Username can only contain letters, numbers, hyphens, and underscores".into(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_email() { + assert!(validate_email("user@example.com").is_ok()); + assert!(validate_email("a@b.c").is_ok()); + } + + #[test] + fn invalid_email() { + assert!(validate_email("").is_err()); + assert!(validate_email("noat").is_err()); + assert!(validate_email("no@dot").is_err()); + assert!(validate_email(&format!("{}@b.c", "a".repeat(251))).is_err()); + } + + #[test] + fn valid_password() { + assert!(validate_password("SecurePass123").is_ok()); + assert!(validate_password("MyLongPassphrase1").is_ok()); + } + + #[test] + fn invalid_password() { + assert!(validate_password("").is_err()); + assert!(validate_password("short").is_err()); + assert!(validate_password("12345678901").is_err()); // 11 chars + assert!(validate_password(&"a".repeat(1025)).is_err()); + assert!(validate_password("alllowercase1").is_err()); // no uppercase + assert!(validate_password("ALLUPPERCASE1").is_err()); // no lowercase + assert!(validate_password("NoDigitsHere!").is_err()); // no digit + } + + #[test] + fn valid_username() { + assert!(validate_username("alice").is_ok()); + assert!(validate_username("bob-123").is_ok()); + assert!(validate_username("foo_bar").is_ok()); + } + + #[test] + fn invalid_username() { + assert!(validate_username("").is_err()); + assert!(validate_username("ab").is_err()); + assert!(validate_username("has spaces").is_err()); + assert!(validate_username("has@symbol").is_err()); + assert!(validate_username(&"a".repeat(65)).is_err()); + } +} diff --git a/crates/forage-core/src/billing/mod.rs b/crates/forage-core/src/billing/mod.rs new file mode 100644 index 0000000..dd8b08a --- /dev/null +++ b/crates/forage-core/src/billing/mod.rs @@ -0,0 +1 @@ +// Billing and pricing logic - usage tracking, plan management. diff --git a/crates/forage-core/src/deployments/mod.rs b/crates/forage-core/src/deployments/mod.rs new file mode 100644 index 0000000..f39bac1 --- /dev/null +++ b/crates/forage-core/src/deployments/mod.rs @@ -0,0 +1 @@ +// Deployment orchestration logic - managing deployment lifecycle. diff --git a/crates/forage-core/src/lib.rs b/crates/forage-core/src/lib.rs new file mode 100644 index 0000000..e8d0e2f --- /dev/null +++ b/crates/forage-core/src/lib.rs @@ -0,0 +1,6 @@ +pub mod auth; +pub mod session; +pub mod platform; +pub mod registry; +pub mod deployments; +pub mod billing; diff --git a/crates/forage-core/src/platform/mod.rs b/crates/forage-core/src/platform/mod.rs new file mode 100644 index 0000000..28d60ac --- /dev/null +++ b/crates/forage-core/src/platform/mod.rs @@ -0,0 +1,101 @@ +use serde::{Deserialize, Serialize}; + +/// Validate that a slug (org name, project name) is safe for use in URLs and templates. +/// Allows lowercase alphanumeric, hyphens, max 64 chars. Must not be empty. +pub fn validate_slug(s: &str) -> bool { + !s.is_empty() + && s.len() <= 64 + && s.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') + && !s.starts_with('-') + && !s.ends_with('-') +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Organisation { + pub organisation_id: String, + pub name: String, + pub role: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Artifact { + pub artifact_id: String, + pub slug: String, + pub context: ArtifactContext, + pub created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ArtifactContext { + pub title: String, + pub description: Option, +} + +#[derive(Debug, Clone, thiserror::Error)] +pub enum PlatformError { + #[error("not authenticated")] + NotAuthenticated, + + #[error("not found: {0}")] + NotFound(String), + + #[error("service unavailable: {0}")] + Unavailable(String), + + #[error("{0}")] + Other(String), +} + +/// Trait for platform data from forest-server (organisations, projects, artifacts). +/// Separate from `ForestAuth` which handles identity. +#[async_trait::async_trait] +pub trait ForestPlatform: Send + Sync { + async fn list_my_organisations( + &self, + access_token: &str, + ) -> Result, PlatformError>; + + async fn list_projects( + &self, + access_token: &str, + organisation: &str, + ) -> Result, PlatformError>; + + async fn list_artifacts( + &self, + access_token: &str, + organisation: &str, + project: &str, + ) -> Result, PlatformError>; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn valid_slugs() { + assert!(validate_slug("my-org")); + assert!(validate_slug("a")); + assert!(validate_slug("abc123")); + assert!(validate_slug("my-cool-project-2")); + } + + #[test] + fn invalid_slugs() { + assert!(!validate_slug("")); + assert!(!validate_slug("-starts-with-dash")); + assert!(!validate_slug("ends-with-dash-")); + assert!(!validate_slug("UPPERCASE")); + assert!(!validate_slug("has spaces")); + assert!(!validate_slug("has_underscores")); + assert!(!validate_slug("has.dots")); + assert!(!validate_slug(&"a".repeat(65))); + } + + #[test] + fn max_length_slug_is_valid() { + assert!(validate_slug(&"a".repeat(64))); + } +} diff --git a/crates/forage-core/src/registry/mod.rs b/crates/forage-core/src/registry/mod.rs new file mode 100644 index 0000000..24fb3a1 --- /dev/null +++ b/crates/forage-core/src/registry/mod.rs @@ -0,0 +1 @@ +// Component registry logic - discovering, resolving, and managing forest components. diff --git a/crates/forage-core/src/session/mod.rs b/crates/forage-core/src/session/mod.rs new file mode 100644 index 0000000..bc8b642 --- /dev/null +++ b/crates/forage-core/src/session/mod.rs @@ -0,0 +1,260 @@ +mod store; + +pub use store::InMemorySessionStore; + +use crate::auth::UserEmail; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// Opaque session identifier. 32 bytes of cryptographic randomness, base64url-encoded. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SessionId(String); + +impl SessionId { + pub fn generate() -> Self { + use rand::Rng; + let mut bytes = [0u8; 32]; + rand::rng().fill(&mut bytes); + Self(base64url_encode(&bytes)) + } + + /// Construct from a raw cookie value. No validation - it's just a lookup key. + pub fn from_raw(s: String) -> Self { + Self(s) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for SessionId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +fn base64url_encode(bytes: &[u8]) -> String { + use std::fmt::Write; + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity((bytes.len() * 4).div_ceil(3)); + for chunk in bytes.chunks(3) { + let n = match chunk.len() { + 3 => (chunk[0] as u32) << 16 | (chunk[1] as u32) << 8 | chunk[2] as u32, + 2 => (chunk[0] as u32) << 16 | (chunk[1] as u32) << 8, + 1 => (chunk[0] as u32) << 16, + _ => unreachable!(), + }; + let _ = out.write_char(CHARS[((n >> 18) & 0x3F) as usize] as char); + let _ = out.write_char(CHARS[((n >> 12) & 0x3F) as usize] as char); + if chunk.len() > 1 { + let _ = out.write_char(CHARS[((n >> 6) & 0x3F) as usize] as char); + } + if chunk.len() > 2 { + let _ = out.write_char(CHARS[(n & 0x3F) as usize] as char); + } + } + out +} + +/// Cached user info stored in the session to avoid repeated gRPC calls. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedUser { + pub user_id: String, + pub username: String, + pub emails: Vec, + #[serde(default)] + pub orgs: Vec, +} + +/// Cached organisation membership. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedOrg { + pub name: String, + pub role: String, +} + +/// Generate a CSRF token (16 bytes of randomness, base64url-encoded). +pub fn generate_csrf_token() -> String { + use rand::Rng; + let mut bytes = [0u8; 16]; + rand::rng().fill(&mut bytes); + base64url_encode(&bytes) +} + +/// Server-side session data. Never exposed to the browser. +#[derive(Debug, Clone)] +pub struct SessionData { + pub access_token: String, + pub refresh_token: String, + pub access_expires_at: DateTime, + pub user: Option, + pub csrf_token: String, + pub created_at: DateTime, + pub last_seen_at: DateTime, +} + +impl SessionData { + /// Whether the access token is expired or will expire within the given margin. + pub fn is_access_expired(&self, margin: chrono::Duration) -> bool { + Utc::now() + margin >= self.access_expires_at + } + + /// Whether the access token needs refreshing (expired or within 60s of expiry). + pub fn needs_refresh(&self) -> bool { + self.is_access_expired(chrono::Duration::seconds(60)) + } +} + +#[derive(Debug, thiserror::Error)] +pub enum SessionError { + #[error("session store error: {0}")] + Store(String), +} + +/// Trait for session persistence. Swappable between in-memory, Redis, Postgres. +#[async_trait::async_trait] +pub trait SessionStore: Send + Sync { + async fn create(&self, data: SessionData) -> Result; + async fn get(&self, id: &SessionId) -> Result, SessionError>; + async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError>; + async fn delete(&self, id: &SessionId) -> Result<(), SessionError>; +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + #[test] + fn session_id_generates_unique_ids() { + let ids: HashSet = (0..1000).map(|_| SessionId::generate().0).collect(); + assert_eq!(ids.len(), 1000); + } + + #[test] + fn session_id_is_base64url_safe() { + for _ in 0..100 { + let id = SessionId::generate(); + let s = id.as_str(); + assert!( + s.chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), + "invalid chars in session id: {s}" + ); + } + } + + #[test] + fn session_id_has_sufficient_length() { + // 32 bytes -> ~43 base64url chars + let id = SessionId::generate(); + assert!(id.as_str().len() >= 42, "session id too short: {}", id.as_str().len()); + } + + #[test] + fn session_data_not_expired() { + let data = SessionData { + access_token: "tok".into(), + refresh_token: "ref".into(), + csrf_token: "test-csrf".into(), + access_expires_at: Utc::now() + chrono::Duration::hours(1), + user: None, + created_at: Utc::now(), + last_seen_at: Utc::now(), + }; + assert!(!data.is_access_expired(chrono::Duration::zero())); + assert!(!data.needs_refresh()); + } + + #[test] + fn session_data_expired() { + let data = SessionData { + access_token: "tok".into(), + refresh_token: "ref".into(), + csrf_token: "test-csrf".into(), + access_expires_at: Utc::now() - chrono::Duration::seconds(1), + user: None, + created_at: Utc::now(), + last_seen_at: Utc::now(), + }; + assert!(data.is_access_expired(chrono::Duration::zero())); + assert!(data.needs_refresh()); + } + + #[test] + fn session_data_needs_refresh_within_margin() { + let data = SessionData { + access_token: "tok".into(), + refresh_token: "ref".into(), + csrf_token: "test-csrf".into(), + access_expires_at: Utc::now() + chrono::Duration::seconds(30), + user: None, + created_at: Utc::now(), + last_seen_at: Utc::now(), + }; + // Not expired yet, but within 60s margin + assert!(!data.is_access_expired(chrono::Duration::zero())); + assert!(data.needs_refresh()); + } + + #[tokio::test] + async fn in_memory_store_create_and_get() { + let store = InMemorySessionStore::new(); + let data = make_session_data(); + let id = store.create(data.clone()).await.unwrap(); + let retrieved = store.get(&id).await.unwrap().expect("session should exist"); + assert_eq!(retrieved.access_token, data.access_token); + assert_eq!(retrieved.refresh_token, data.refresh_token); + } + + #[tokio::test] + async fn in_memory_store_get_nonexistent_returns_none() { + let store = InMemorySessionStore::new(); + let id = SessionId::generate(); + assert!(store.get(&id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn in_memory_store_update() { + let store = InMemorySessionStore::new(); + let data = make_session_data(); + let id = store.create(data).await.unwrap(); + + let mut updated = make_session_data(); + updated.access_token = "new-access".into(); + store.update(&id, updated).await.unwrap(); + + let retrieved = store.get(&id).await.unwrap().unwrap(); + assert_eq!(retrieved.access_token, "new-access"); + } + + #[tokio::test] + async fn in_memory_store_delete() { + let store = InMemorySessionStore::new(); + let data = make_session_data(); + let id = store.create(data).await.unwrap(); + store.delete(&id).await.unwrap(); + assert!(store.get(&id).await.unwrap().is_none()); + } + + #[tokio::test] + async fn in_memory_store_delete_nonexistent_is_ok() { + let store = InMemorySessionStore::new(); + let id = SessionId::generate(); + // Should not error + store.delete(&id).await.unwrap(); + } + + fn make_session_data() -> SessionData { + SessionData { + access_token: "test-access".into(), + refresh_token: "test-refresh".into(), + csrf_token: "test-csrf".into(), + access_expires_at: Utc::now() + chrono::Duration::hours(1), + user: None, + created_at: Utc::now(), + last_seen_at: Utc::now(), + } + } +} diff --git a/crates/forage-core/src/session/store.rs b/crates/forage-core/src/session/store.rs new file mode 100644 index 0000000..47fd2a6 --- /dev/null +++ b/crates/forage-core/src/session/store.rs @@ -0,0 +1,66 @@ +use std::collections::HashMap; +use std::sync::RwLock; + +use chrono::{Duration, Utc}; + +use super::{SessionData, SessionError, SessionId, SessionStore}; + +/// In-memory session store. Suitable for single-instance deployments. +/// Sessions are lost on server restart. +pub struct InMemorySessionStore { + sessions: RwLock>, + max_inactive: Duration, +} + +impl Default for InMemorySessionStore { + fn default() -> Self { + Self::new() + } +} + +impl InMemorySessionStore { + pub fn new() -> Self { + Self { + sessions: RwLock::new(HashMap::new()), + max_inactive: Duration::days(30), + } + } + + /// Remove sessions inactive for longer than `max_inactive`. + pub fn reap_expired(&self) { + let cutoff = Utc::now() - self.max_inactive; + let mut sessions = self.sessions.write().unwrap(); + sessions.retain(|_, data| data.last_seen_at > cutoff); + } + + pub fn session_count(&self) -> usize { + self.sessions.read().unwrap().len() + } +} + +#[async_trait::async_trait] +impl SessionStore for InMemorySessionStore { + async fn create(&self, data: SessionData) -> Result { + let id = SessionId::generate(); + let mut sessions = self.sessions.write().unwrap(); + sessions.insert(id.clone(), data); + Ok(id) + } + + async fn get(&self, id: &SessionId) -> Result, SessionError> { + let sessions = self.sessions.read().unwrap(); + Ok(sessions.get(id).cloned()) + } + + async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError> { + let mut sessions = self.sessions.write().unwrap(); + sessions.insert(id.clone(), data); + Ok(()) + } + + async fn delete(&self, id: &SessionId) -> Result<(), SessionError> { + let mut sessions = self.sessions.write().unwrap(); + sessions.remove(id); + Ok(()) + } +} diff --git a/crates/forage-db/Cargo.toml b/crates/forage-db/Cargo.toml new file mode 100644 index 0000000..3bffb6f --- /dev/null +++ b/crates/forage-db/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "forage-db" +version = "0.1.0" +edition = "2024" + +[dependencies] +forage-core = { path = "../forage-core" } +sqlx.workspace = true +thiserror.workspace = true +uuid.workspace = true +chrono.workspace = true +tracing.workspace = true +serde.workspace = true +serde_json.workspace = true +async-trait.workspace = true diff --git a/crates/forage-db/src/lib.rs b/crates/forage-db/src/lib.rs new file mode 100644 index 0000000..dc18857 --- /dev/null +++ b/crates/forage-db/src/lib.rs @@ -0,0 +1,9 @@ +mod sessions; + +pub use sessions::PgSessionStore; +pub use sqlx::PgPool; + +/// Run all pending migrations. +pub async fn migrate(pool: &PgPool) -> Result<(), sqlx::migrate::MigrateError> { + sqlx::migrate!("src/migrations").run(pool).await +} diff --git a/crates/forage-db/src/migrations/20260307000001_create_sessions.sql b/crates/forage-db/src/migrations/20260307000001_create_sessions.sql new file mode 100644 index 0000000..3f0af2a --- /dev/null +++ b/crates/forage-db/src/migrations/20260307000001_create_sessions.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + access_token TEXT NOT NULL, + refresh_token TEXT NOT NULL, + access_expires_at TIMESTAMPTZ NOT NULL, + user_id TEXT, + username TEXT, + user_emails JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_sessions_last_seen ON sessions (last_seen_at); diff --git a/crates/forage-db/src/migrations/20260307000002_add_csrf_token.sql b/crates/forage-db/src/migrations/20260307000002_add_csrf_token.sql new file mode 100644 index 0000000..99b320f --- /dev/null +++ b/crates/forage-db/src/migrations/20260307000002_add_csrf_token.sql @@ -0,0 +1 @@ +ALTER TABLE sessions ADD COLUMN csrf_token TEXT NOT NULL DEFAULT ''; diff --git a/crates/forage-db/src/sessions.rs b/crates/forage-db/src/sessions.rs new file mode 100644 index 0000000..b844ab5 --- /dev/null +++ b/crates/forage-db/src/sessions.rs @@ -0,0 +1,163 @@ +use chrono::{DateTime, Utc}; +use forage_core::auth::UserEmail; +use forage_core::session::{CachedUser, SessionData, SessionError, SessionId, SessionStore}; +use sqlx::PgPool; + +/// PostgreSQL-backed session store for horizontal scaling. +pub struct PgSessionStore { + pool: PgPool, +} + +impl PgSessionStore { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + /// Remove sessions inactive for longer than `max_inactive_days`. + pub async fn reap_expired(&self, max_inactive_days: i64) -> Result { + let cutoff = Utc::now() - chrono::Duration::days(max_inactive_days); + let result = sqlx::query("DELETE FROM sessions WHERE last_seen_at < $1") + .bind(cutoff) + .execute(&self.pool) + .await + .map_err(|e| SessionError::Store(e.to_string()))?; + Ok(result.rows_affected()) + } +} + +#[async_trait::async_trait] +impl SessionStore for PgSessionStore { + async fn create(&self, data: SessionData) -> Result { + let id = SessionId::generate(); + let (user_id, username, emails_json) = match &data.user { + Some(u) => ( + Some(u.user_id.clone()), + Some(u.username.clone()), + Some( + serde_json::to_value(&u.emails) + .map_err(|e| SessionError::Store(e.to_string()))?, + ), + ), + None => (None, None, None), + }; + + sqlx::query( + "INSERT INTO sessions (session_id, access_token, refresh_token, access_expires_at, user_id, username, user_emails, csrf_token, created_at, last_seen_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + ) + .bind(id.as_str()) + .bind(&data.access_token) + .bind(&data.refresh_token) + .bind(data.access_expires_at) + .bind(&user_id) + .bind(&username) + .bind(&emails_json) + .bind(&data.csrf_token) + .bind(data.created_at) + .bind(data.last_seen_at) + .execute(&self.pool) + .await + .map_err(|e| SessionError::Store(e.to_string()))?; + + Ok(id) + } + + async fn get(&self, id: &SessionId) -> Result, SessionError> { + let row: Option = sqlx::query_as( + "SELECT access_token, refresh_token, access_expires_at, user_id, username, user_emails, csrf_token, created_at, last_seen_at + FROM sessions WHERE session_id = $1", + ) + .bind(id.as_str()) + .fetch_optional(&self.pool) + .await + .map_err(|e| SessionError::Store(e.to_string()))?; + + Ok(row.map(|r| r.into_session_data())) + } + + async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError> { + let (user_id, username, emails_json) = match &data.user { + Some(u) => ( + Some(u.user_id.clone()), + Some(u.username.clone()), + Some( + serde_json::to_value(&u.emails) + .map_err(|e| SessionError::Store(e.to_string()))?, + ), + ), + None => (None, None, None), + }; + + sqlx::query( + "UPDATE sessions SET access_token = $1, refresh_token = $2, access_expires_at = $3, user_id = $4, username = $5, user_emails = $6, csrf_token = $7, last_seen_at = $8 + WHERE session_id = $9", + ) + .bind(&data.access_token) + .bind(&data.refresh_token) + .bind(data.access_expires_at) + .bind(&user_id) + .bind(&username) + .bind(&emails_json) + .bind(&data.csrf_token) + .bind(data.last_seen_at) + .bind(id.as_str()) + .execute(&self.pool) + .await + .map_err(|e| SessionError::Store(e.to_string()))?; + + Ok(()) + } + + async fn delete(&self, id: &SessionId) -> Result<(), SessionError> { + sqlx::query("DELETE FROM sessions WHERE session_id = $1") + .bind(id.as_str()) + .execute(&self.pool) + .await + .map_err(|e| SessionError::Store(e.to_string()))?; + + Ok(()) + } +} + +#[derive(sqlx::FromRow)] +struct SessionRow { + access_token: String, + refresh_token: String, + access_expires_at: DateTime, + user_id: Option, + username: Option, + user_emails: Option, + csrf_token: String, + created_at: DateTime, + last_seen_at: DateTime, +} + +impl SessionRow { + fn into_session_data(self) -> SessionData { + let user = match (self.user_id, self.username) { + (Some(user_id), Some(username)) => { + let emails: Vec = self + .user_emails + .and_then(|v| serde_json::from_value(v).ok()) + .unwrap_or_default(); + Some(CachedUser { + user_id, + username, + emails, + orgs: vec![], + }) + } + _ => None, + }; + + SessionData { + access_token: self.access_token, + refresh_token: self.refresh_token, + access_expires_at: self.access_expires_at, + user, + csrf_token: self.csrf_token, + created_at: self.created_at, + last_seen_at: self.last_seen_at, + } + } +} diff --git a/crates/forage-grpc/Cargo.toml b/crates/forage-grpc/Cargo.toml new file mode 100644 index 0000000..4d71b53 --- /dev/null +++ b/crates/forage-grpc/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "forage-grpc" +version = "0.1.0" +edition = "2024" + +[features] +default = ["client"] +client = [] +server = [] + +[dependencies] +prost.workspace = true +prost-types.workspace = true +tonic.workspace = true +tonic-prost.workspace = true diff --git a/crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs b/crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs new file mode 100644 index 0000000..0b528fa --- /dev/null +++ b/crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs @@ -0,0 +1,821 @@ +// @generated +// This file is @generated by prost-build. +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Organisation { + #[prost(string, tag="1")] + pub organisation_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub name: ::prost::alloc::string::String, + #[prost(message, optional, tag="3")] + pub created_at: ::core::option::Option<::prost_types::Timestamp>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CreateOrganisationRequest { + #[prost(string, tag="1")] + pub name: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CreateOrganisationResponse { + #[prost(string, tag="1")] + pub organisation_id: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetOrganisationRequest { + #[prost(oneof="get_organisation_request::Identifier", tags="1, 2")] + pub identifier: ::core::option::Option, +} +/// Nested message and enum types in `GetOrganisationRequest`. +pub mod get_organisation_request { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Identifier { + #[prost(string, tag="1")] + OrganisationId(::prost::alloc::string::String), + #[prost(string, tag="2")] + Name(::prost::alloc::string::String), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetOrganisationResponse { + #[prost(message, optional, tag="1")] + pub organisation: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SearchOrganisationsRequest { + #[prost(string, tag="1")] + pub query: ::prost::alloc::string::String, + #[prost(int32, tag="2")] + pub page_size: i32, + #[prost(string, tag="3")] + pub page_token: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct SearchOrganisationsResponse { + #[prost(message, repeated, tag="1")] + pub organisations: ::prost::alloc::vec::Vec, + #[prost(string, tag="2")] + pub next_page_token: ::prost::alloc::string::String, + #[prost(int32, tag="3")] + pub total_count: i32, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ListMyOrganisationsRequest { + /// Optional role filter (e.g. "admin"); empty means all roles + #[prost(string, tag="1")] + pub role: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListMyOrganisationsResponse { + #[prost(message, repeated, tag="1")] + pub organisations: ::prost::alloc::vec::Vec, + /// The role the caller has in each organisation (parallel to organisations) + #[prost(string, repeated, tag="2")] + pub roles: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +// -- Members ------------------------------------------------------------------ + +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct OrganisationMember { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub username: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub role: ::prost::alloc::string::String, + #[prost(message, optional, tag="4")] + pub joined_at: ::core::option::Option<::prost_types::Timestamp>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AddMemberRequest { + #[prost(string, tag="1")] + pub organisation_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub user_id: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub role: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AddMemberResponse { + #[prost(message, optional, tag="1")] + pub member: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RemoveMemberRequest { + #[prost(string, tag="1")] + pub organisation_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub user_id: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RemoveMemberResponse { +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UpdateMemberRoleRequest { + #[prost(string, tag="1")] + pub organisation_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub user_id: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub role: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UpdateMemberRoleResponse { + #[prost(message, optional, tag="1")] + pub member: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ListMembersRequest { + #[prost(string, tag="1")] + pub organisation_id: ::prost::alloc::string::String, + #[prost(int32, tag="2")] + pub page_size: i32, + #[prost(string, tag="3")] + pub page_token: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListMembersResponse { + #[prost(message, repeated, tag="1")] + pub members: ::prost::alloc::vec::Vec, + #[prost(string, tag="2")] + pub next_page_token: ::prost::alloc::string::String, + #[prost(int32, tag="3")] + pub total_count: i32, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AnnotateReleaseRequest { + #[prost(string, tag="1")] + pub artifact_id: ::prost::alloc::string::String, + #[prost(map="string, string", tag="2")] + pub metadata: ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>, + #[prost(message, optional, tag="3")] + pub source: ::core::option::Option, + #[prost(message, optional, tag="4")] + pub context: ::core::option::Option, + #[prost(message, optional, tag="5")] + pub project: ::core::option::Option, + #[prost(message, optional, tag="6")] + pub r#ref: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct AnnotateReleaseResponse { + #[prost(message, optional, tag="1")] + pub artifact: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetArtifactBySlugRequest { + #[prost(string, tag="1")] + pub slug: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetArtifactBySlugResponse { + #[prost(message, optional, tag="1")] + pub artifact: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetArtifactsByProjectRequest { + #[prost(message, optional, tag="1")] + pub project: ::core::option::Option, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetArtifactsByProjectResponse { + #[prost(message, repeated, tag="1")] + pub artifact: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ReleaseRequest { + #[prost(string, tag="1")] + pub artifact_id: ::prost::alloc::string::String, + #[prost(string, repeated, tag="2")] + pub destinations: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(string, repeated, tag="3")] + pub environments: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ReleaseResponse { + /// List of release intents created (one per destination) + #[prost(message, repeated, tag="1")] + pub intents: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ReleaseIntent { + #[prost(string, tag="1")] + pub release_intent_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub destination: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub environment: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct WaitReleaseRequest { + #[prost(string, tag="1")] + pub release_intent_id: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct WaitReleaseEvent { + #[prost(oneof="wait_release_event::Event", tags="1, 2")] + pub event: ::core::option::Option, +} +/// Nested message and enum types in `WaitReleaseEvent`. +pub mod wait_release_event { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Event { + #[prost(message, tag="1")] + StatusUpdate(super::ReleaseStatusUpdate), + #[prost(message, tag="2")] + LogLine(super::ReleaseLogLine), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ReleaseStatusUpdate { + #[prost(string, tag="1")] + pub destination: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub status: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ReleaseLogLine { + #[prost(string, tag="1")] + pub destination: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub line: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub timestamp: ::prost::alloc::string::String, + #[prost(enumeration="LogChannel", tag="4")] + pub channel: i32, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetOrganisationsRequest { +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetOrganisationsResponse { + #[prost(message, repeated, tag="1")] + pub organisations: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetProjectsRequest { + #[prost(oneof="get_projects_request::Query", tags="1")] + pub query: ::core::option::Option, +} +/// Nested message and enum types in `GetProjectsRequest`. +pub mod get_projects_request { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Query { + #[prost(message, tag="1")] + Organisation(super::OrganisationRef), + } +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetProjectsResponse { + #[prost(string, repeated, tag="1")] + pub projects: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Source { + #[prost(string, optional, tag="1")] + pub user: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag="2")] + pub email: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag="3")] + pub source_type: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag="4")] + pub run_url: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ArtifactContext { + #[prost(string, tag="1")] + pub title: ::prost::alloc::string::String, + #[prost(string, optional, tag="2")] + pub description: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag="3")] + pub web: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag="4")] + pub pr: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct Artifact { + #[prost(string, tag="1")] + pub id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub artifact_id: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub slug: ::prost::alloc::string::String, + #[prost(map="string, string", tag="4")] + pub metadata: ::std::collections::HashMap<::prost::alloc::string::String, ::prost::alloc::string::String>, + #[prost(message, optional, tag="5")] + pub source: ::core::option::Option, + #[prost(message, optional, tag="6")] + pub context: ::core::option::Option, + #[prost(message, optional, tag="7")] + pub project: ::core::option::Option, + #[prost(message, repeated, tag="8")] + pub destinations: ::prost::alloc::vec::Vec, + #[prost(string, tag="9")] + pub created_at: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ArtifactDestination { + #[prost(string, tag="1")] + pub name: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub environment: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub type_organisation: ::prost::alloc::string::String, + #[prost(string, tag="4")] + pub type_name: ::prost::alloc::string::String, + #[prost(uint64, tag="5")] + pub type_version: u64, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Project { + #[prost(string, tag="1")] + pub organisation: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub project: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct Ref { + #[prost(string, tag="1")] + pub commit_sha: ::prost::alloc::string::String, + #[prost(string, optional, tag="2")] + pub branch: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag="3")] + pub commit_message: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag="4")] + pub version: ::core::option::Option<::prost::alloc::string::String>, + #[prost(string, optional, tag="5")] + pub repo_url: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct OrganisationRef { + #[prost(string, tag="1")] + pub organisation: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum LogChannel { + Unspecified = 0, + Stdout = 1, + Stderr = 2, +} +impl LogChannel { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "LOG_CHANNEL_UNSPECIFIED", + Self::Stdout => "LOG_CHANNEL_STDOUT", + Self::Stderr => "LOG_CHANNEL_STDERR", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "LOG_CHANNEL_UNSPECIFIED" => Some(Self::Unspecified), + "LOG_CHANNEL_STDOUT" => Some(Self::Stdout), + "LOG_CHANNEL_STDERR" => Some(Self::Stderr), + _ => None, + } + } +} +// ─── Core types ────────────────────────────────────────────────────── + +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct User { + /// UUID + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub username: ::prost::alloc::string::String, + #[prost(message, repeated, tag="3")] + pub emails: ::prost::alloc::vec::Vec, + #[prost(message, repeated, tag="4")] + pub oauth_connections: ::prost::alloc::vec::Vec, + #[prost(bool, tag="5")] + pub mfa_enabled: bool, + #[prost(message, optional, tag="6")] + pub created_at: ::core::option::Option<::prost_types::Timestamp>, + #[prost(message, optional, tag="7")] + pub updated_at: ::core::option::Option<::prost_types::Timestamp>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UserEmail { + #[prost(string, tag="1")] + pub email: ::prost::alloc::string::String, + #[prost(bool, tag="2")] + pub verified: bool, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct OAuthConnection { + #[prost(enumeration="OAuthProvider", tag="1")] + pub provider: i32, + #[prost(string, tag="2")] + pub provider_user_id: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub provider_email: ::prost::alloc::string::String, + #[prost(message, optional, tag="4")] + pub linked_at: ::core::option::Option<::prost_types::Timestamp>, +} +// ─── Authentication ────────────────────────────────────────────────── + +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RegisterRequest { + #[prost(string, tag="1")] + pub username: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub email: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub password: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct RegisterResponse { + #[prost(message, optional, tag="1")] + pub user: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub tokens: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LoginRequest { + #[prost(string, tag="3")] + pub password: ::prost::alloc::string::String, + /// Login with either username or email + #[prost(oneof="login_request::Identifier", tags="1, 2")] + pub identifier: ::core::option::Option, +} +/// Nested message and enum types in `LoginRequest`. +pub mod login_request { + /// Login with either username or email + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Identifier { + #[prost(string, tag="1")] + Username(::prost::alloc::string::String), + #[prost(string, tag="2")] + Email(::prost::alloc::string::String), + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct LoginResponse { + #[prost(message, optional, tag="1")] + pub user: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub tokens: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RefreshTokenRequest { + #[prost(string, tag="1")] + pub refresh_token: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RefreshTokenResponse { + #[prost(message, optional, tag="1")] + pub tokens: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LogoutRequest { + #[prost(string, tag="1")] + pub refresh_token: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LogoutResponse { +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AuthTokens { + #[prost(string, tag="1")] + pub access_token: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub refresh_token: ::prost::alloc::string::String, + #[prost(int64, tag="3")] + pub expires_in_seconds: i64, +} +// ─── Token introspection ───────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct TokenInfoRequest { +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct TokenInfoResponse { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + /// Unix timestamp (seconds) + #[prost(int64, tag="2")] + pub expires_at: i64, +} +// ─── User CRUD ─────────────────────────────────────────────────────── + +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct GetUserRequest { + #[prost(oneof="get_user_request::Identifier", tags="1, 2, 3")] + pub identifier: ::core::option::Option, +} +/// Nested message and enum types in `GetUserRequest`. +pub mod get_user_request { + #[derive(Clone, PartialEq, Eq, Hash, ::prost::Oneof)] + pub enum Identifier { + #[prost(string, tag="1")] + UserId(::prost::alloc::string::String), + #[prost(string, tag="2")] + Username(::prost::alloc::string::String), + #[prost(string, tag="3")] + Email(::prost::alloc::string::String), + } +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetUserResponse { + #[prost(message, optional, tag="1")] + pub user: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UpdateUserRequest { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + #[prost(string, optional, tag="2")] + pub username: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct UpdateUserResponse { + #[prost(message, optional, tag="1")] + pub user: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DeleteUserRequest { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DeleteUserResponse { +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ListUsersRequest { + #[prost(int32, tag="1")] + pub page_size: i32, + #[prost(string, tag="2")] + pub page_token: ::prost::alloc::string::String, + /// search across username, email + #[prost(string, optional, tag="3")] + pub search: ::core::option::Option<::prost::alloc::string::String>, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListUsersResponse { + #[prost(message, repeated, tag="1")] + pub users: ::prost::alloc::vec::Vec, + #[prost(string, tag="2")] + pub next_page_token: ::prost::alloc::string::String, + #[prost(int32, tag="3")] + pub total_count: i32, +} +// ─── Password management ───────────────────────────────────────────── + +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ChangePasswordRequest { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub current_password: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub new_password: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ChangePasswordResponse { +} +// ─── Email management ──────────────────────────────────────────────── + +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AddEmailRequest { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub email: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct AddEmailResponse { + #[prost(message, optional, tag="1")] + pub email: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct VerifyEmailRequest { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub email: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct VerifyEmailResponse { +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RemoveEmailRequest { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub email: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct RemoveEmailResponse { +} +// ─── OAuth / Social login ──────────────────────────────────────────── + +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct OAuthLoginRequest { + #[prost(enumeration="OAuthProvider", tag="1")] + pub provider: i32, + #[prost(string, tag="2")] + pub authorization_code: ::prost::alloc::string::String, + #[prost(string, tag="3")] + pub redirect_uri: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct OAuthLoginResponse { + #[prost(message, optional, tag="1")] + pub user: ::core::option::Option, + #[prost(message, optional, tag="2")] + pub tokens: ::core::option::Option, + #[prost(bool, tag="3")] + pub is_new_user: bool, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LinkOAuthProviderRequest { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + #[prost(enumeration="OAuthProvider", tag="2")] + pub provider: i32, + #[prost(string, tag="3")] + pub authorization_code: ::prost::alloc::string::String, + #[prost(string, tag="4")] + pub redirect_uri: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct LinkOAuthProviderResponse { + #[prost(message, optional, tag="1")] + pub connection: ::core::option::Option, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UnlinkOAuthProviderRequest { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + #[prost(enumeration="OAuthProvider", tag="2")] + pub provider: i32, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct UnlinkOAuthProviderResponse { +} +// ─── Personal access tokens ────────────────────────────────────────── + +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct PersonalAccessToken { + /// UUID + #[prost(string, tag="1")] + pub token_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub name: ::prost::alloc::string::String, + #[prost(string, repeated, tag="3")] + pub scopes: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + #[prost(message, optional, tag="4")] + pub expires_at: ::core::option::Option<::prost_types::Timestamp>, + #[prost(message, optional, tag="5")] + pub last_used: ::core::option::Option<::prost_types::Timestamp>, + #[prost(message, optional, tag="6")] + pub created_at: ::core::option::Option<::prost_types::Timestamp>, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CreatePersonalAccessTokenRequest { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + #[prost(string, tag="2")] + pub name: ::prost::alloc::string::String, + #[prost(string, repeated, tag="3")] + pub scopes: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, + /// Duration in seconds; 0 = no expiry + #[prost(int64, tag="4")] + pub expires_in_seconds: i64, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct CreatePersonalAccessTokenResponse { + #[prost(message, optional, tag="1")] + pub token: ::core::option::Option, + /// The raw token value, only returned on creation + #[prost(string, tag="2")] + pub raw_token: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct ListPersonalAccessTokensRequest { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct ListPersonalAccessTokensResponse { + #[prost(message, repeated, tag="1")] + pub tokens: ::prost::alloc::vec::Vec, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DeletePersonalAccessTokenRequest { + #[prost(string, tag="1")] + pub token_id: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DeletePersonalAccessTokenResponse { +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SetupMfaRequest { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + #[prost(enumeration="MfaType", tag="2")] + pub mfa_type: i32, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct SetupMfaResponse { + /// UUID + #[prost(string, tag="1")] + pub mfa_id: ::prost::alloc::string::String, + /// TOTP provisioning URI (otpauth://...) + #[prost(string, tag="2")] + pub provisioning_uri: ::prost::alloc::string::String, + /// Base32-encoded secret for manual entry + #[prost(string, tag="3")] + pub secret: ::prost::alloc::string::String, +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct VerifyMfaRequest { + #[prost(string, tag="1")] + pub mfa_id: ::prost::alloc::string::String, + /// The TOTP code to verify setup + #[prost(string, tag="2")] + pub code: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct VerifyMfaResponse { +} +#[derive(Clone, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DisableMfaRequest { + #[prost(string, tag="1")] + pub user_id: ::prost::alloc::string::String, + /// Current TOTP code to confirm disable + #[prost(string, tag="2")] + pub code: ::prost::alloc::string::String, +} +#[derive(Clone, Copy, PartialEq, Eq, Hash, ::prost::Message)] +pub struct DisableMfaResponse { +} +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum OAuthProvider { + OauthProviderUnspecified = 0, + OauthProviderGithub = 1, + OauthProviderGoogle = 2, + OauthProviderGitlab = 3, + OauthProviderMicrosoft = 4, +} +impl OAuthProvider { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::OauthProviderUnspecified => "OAUTH_PROVIDER_UNSPECIFIED", + Self::OauthProviderGithub => "OAUTH_PROVIDER_GITHUB", + Self::OauthProviderGoogle => "OAUTH_PROVIDER_GOOGLE", + Self::OauthProviderGitlab => "OAUTH_PROVIDER_GITLAB", + Self::OauthProviderMicrosoft => "OAUTH_PROVIDER_MICROSOFT", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "OAUTH_PROVIDER_UNSPECIFIED" => Some(Self::OauthProviderUnspecified), + "OAUTH_PROVIDER_GITHUB" => Some(Self::OauthProviderGithub), + "OAUTH_PROVIDER_GOOGLE" => Some(Self::OauthProviderGoogle), + "OAUTH_PROVIDER_GITLAB" => Some(Self::OauthProviderGitlab), + "OAUTH_PROVIDER_MICROSOFT" => Some(Self::OauthProviderMicrosoft), + _ => None, + } + } +} +// ─── MFA ───────────────────────────────────────────────────────────── + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration)] +#[repr(i32)] +pub enum MfaType { + Unspecified = 0, + Totp = 1, +} +impl MfaType { + /// String value of the enum field names used in the ProtoBuf definition. + /// + /// The values are not transformed in any way and thus are considered stable + /// (if the ProtoBuf definition does not change) and safe for programmatic use. + pub fn as_str_name(&self) -> &'static str { + match self { + Self::Unspecified => "MFA_TYPE_UNSPECIFIED", + Self::Totp => "MFA_TYPE_TOTP", + } + } + /// Creates an enum from field names used in the ProtoBuf definition. + pub fn from_str_name(value: &str) -> ::core::option::Option { + match value { + "MFA_TYPE_UNSPECIFIED" => Some(Self::Unspecified), + "MFA_TYPE_TOTP" => Some(Self::Totp), + _ => None, + } + } +} +include!("forest.v1.tonic.rs"); +// @@protoc_insertion_point(module) \ No newline at end of file diff --git a/crates/forage-grpc/src/grpc/forest/v1/forest.v1.tonic.rs b/crates/forage-grpc/src/grpc/forest/v1/forest.v1.tonic.rs new file mode 100644 index 0000000..88a398f --- /dev/null +++ b/crates/forage-grpc/src/grpc/forest/v1/forest.v1.tonic.rs @@ -0,0 +1,3579 @@ +// @generated +/// Generated client implementations. +pub mod organisation_service_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + /// + #[derive(Debug, Clone)] + pub struct OrganisationServiceClient { + inner: tonic::client::Grpc, + } + impl OrganisationServiceClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl OrganisationServiceClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> OrganisationServiceClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + OrganisationServiceClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + /// + pub async fn create_organisation( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.OrganisationService/CreateOrganisation", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "forest.v1.OrganisationService", + "CreateOrganisation", + ), + ); + self.inner.unary(req, path, codec).await + } + /// + pub async fn get_organisation( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.OrganisationService/GetOrganisation", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("forest.v1.OrganisationService", "GetOrganisation"), + ); + self.inner.unary(req, path, codec).await + } + /// + pub async fn search_organisations( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.OrganisationService/SearchOrganisations", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "forest.v1.OrganisationService", + "SearchOrganisations", + ), + ); + self.inner.unary(req, path, codec).await + } + /// + pub async fn list_my_organisations( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.OrganisationService/ListMyOrganisations", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "forest.v1.OrganisationService", + "ListMyOrganisations", + ), + ); + self.inner.unary(req, path, codec).await + } + /// + pub async fn add_member( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.OrganisationService/AddMember", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.OrganisationService", "AddMember")); + self.inner.unary(req, path, codec).await + } + /// + pub async fn remove_member( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.OrganisationService/RemoveMember", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("forest.v1.OrganisationService", "RemoveMember"), + ); + self.inner.unary(req, path, codec).await + } + /// + pub async fn update_member_role( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.OrganisationService/UpdateMemberRole", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("forest.v1.OrganisationService", "UpdateMemberRole"), + ); + self.inner.unary(req, path, codec).await + } + /// + pub async fn list_members( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.OrganisationService/ListMembers", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.OrganisationService", "ListMembers")); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod organisation_service_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with OrganisationServiceServer. + #[async_trait] + pub trait OrganisationService: std::marker::Send + std::marker::Sync + 'static { + /// + async fn create_organisation( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// + async fn get_organisation( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// + async fn search_organisations( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// + async fn list_my_organisations( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// + async fn add_member( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// + async fn remove_member( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// + async fn update_member_role( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + /// + async fn list_members( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + } + /// + #[derive(Debug)] + pub struct OrganisationServiceServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl OrganisationServiceServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for OrganisationServiceServer + where + T: OrganisationService, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/forest.v1.OrganisationService/CreateOrganisation" => { + #[allow(non_camel_case_types)] + struct CreateOrganisationSvc(pub Arc); + impl< + T: OrganisationService, + > tonic::server::UnaryService + for CreateOrganisationSvc { + type Response = super::CreateOrganisationResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::create_organisation( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = CreateOrganisationSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.OrganisationService/GetOrganisation" => { + #[allow(non_camel_case_types)] + struct GetOrganisationSvc(pub Arc); + impl< + T: OrganisationService, + > tonic::server::UnaryService + for GetOrganisationSvc { + type Response = super::GetOrganisationResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_organisation( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetOrganisationSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.OrganisationService/SearchOrganisations" => { + #[allow(non_camel_case_types)] + struct SearchOrganisationsSvc(pub Arc); + impl< + T: OrganisationService, + > tonic::server::UnaryService + for SearchOrganisationsSvc { + type Response = super::SearchOrganisationsResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::search_organisations( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = SearchOrganisationsSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.OrganisationService/ListMyOrganisations" => { + #[allow(non_camel_case_types)] + struct ListMyOrganisationsSvc(pub Arc); + impl< + T: OrganisationService, + > tonic::server::UnaryService + for ListMyOrganisationsSvc { + type Response = super::ListMyOrganisationsResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::list_my_organisations( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ListMyOrganisationsSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.OrganisationService/AddMember" => { + #[allow(non_camel_case_types)] + struct AddMemberSvc(pub Arc); + impl< + T: OrganisationService, + > tonic::server::UnaryService + for AddMemberSvc { + type Response = super::AddMemberResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::add_member(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = AddMemberSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.OrganisationService/RemoveMember" => { + #[allow(non_camel_case_types)] + struct RemoveMemberSvc(pub Arc); + impl< + T: OrganisationService, + > tonic::server::UnaryService + for RemoveMemberSvc { + type Response = super::RemoveMemberResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::remove_member(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = RemoveMemberSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.OrganisationService/UpdateMemberRole" => { + #[allow(non_camel_case_types)] + struct UpdateMemberRoleSvc(pub Arc); + impl< + T: OrganisationService, + > tonic::server::UnaryService + for UpdateMemberRoleSvc { + type Response = super::UpdateMemberRoleResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::update_member_role( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = UpdateMemberRoleSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.OrganisationService/ListMembers" => { + #[allow(non_camel_case_types)] + struct ListMembersSvc(pub Arc); + impl< + T: OrganisationService, + > tonic::server::UnaryService + for ListMembersSvc { + type Response = super::ListMembersResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::list_members(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ListMembersSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => { + Box::pin(async move { + let mut response = http::Response::new( + tonic::body::Body::default(), + ); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } + } + } + } + impl Clone for OrganisationServiceServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "forest.v1.OrganisationService"; + impl tonic::server::NamedService for OrganisationServiceServer { + const NAME: &'static str = SERVICE_NAME; + } +} +/// Generated client implementations. +pub mod release_service_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + #[derive(Debug, Clone)] + pub struct ReleaseServiceClient { + inner: tonic::client::Grpc, + } + impl ReleaseServiceClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl ReleaseServiceClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> ReleaseServiceClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + ReleaseServiceClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + pub async fn annotate_release( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.ReleaseService/AnnotateRelease", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.ReleaseService", "AnnotateRelease")); + self.inner.unary(req, path, codec).await + } + pub async fn release( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.ReleaseService/Release", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.ReleaseService", "Release")); + self.inner.unary(req, path, codec).await + } + pub async fn wait_release( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response>, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.ReleaseService/WaitRelease", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.ReleaseService", "WaitRelease")); + self.inner.server_streaming(req, path, codec).await + } + pub async fn get_artifact_by_slug( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.ReleaseService/GetArtifactBySlug", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("forest.v1.ReleaseService", "GetArtifactBySlug"), + ); + self.inner.unary(req, path, codec).await + } + pub async fn get_artifacts_by_project( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.ReleaseService/GetArtifactsByProject", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("forest.v1.ReleaseService", "GetArtifactsByProject"), + ); + self.inner.unary(req, path, codec).await + } + pub async fn get_organisations( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.ReleaseService/GetOrganisations", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.ReleaseService", "GetOrganisations")); + self.inner.unary(req, path, codec).await + } + pub async fn get_projects( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.ReleaseService/GetProjects", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.ReleaseService", "GetProjects")); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod release_service_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with ReleaseServiceServer. + #[async_trait] + pub trait ReleaseService: std::marker::Send + std::marker::Sync + 'static { + async fn annotate_release( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn release( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + /// Server streaming response type for the WaitRelease method. + type WaitReleaseStream: tonic::codegen::tokio_stream::Stream< + Item = std::result::Result, + > + + std::marker::Send + + 'static; + async fn wait_release( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn get_artifact_by_slug( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn get_artifacts_by_project( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn get_organisations( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn get_projects( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + } + #[derive(Debug)] + pub struct ReleaseServiceServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl ReleaseServiceServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for ReleaseServiceServer + where + T: ReleaseService, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/forest.v1.ReleaseService/AnnotateRelease" => { + #[allow(non_camel_case_types)] + struct AnnotateReleaseSvc(pub Arc); + impl< + T: ReleaseService, + > tonic::server::UnaryService + for AnnotateReleaseSvc { + type Response = super::AnnotateReleaseResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::annotate_release(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = AnnotateReleaseSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.ReleaseService/Release" => { + #[allow(non_camel_case_types)] + struct ReleaseSvc(pub Arc); + impl< + T: ReleaseService, + > tonic::server::UnaryService + for ReleaseSvc { + type Response = super::ReleaseResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::release(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ReleaseSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.ReleaseService/WaitRelease" => { + #[allow(non_camel_case_types)] + struct WaitReleaseSvc(pub Arc); + impl< + T: ReleaseService, + > tonic::server::ServerStreamingService + for WaitReleaseSvc { + type Response = super::WaitReleaseEvent; + type ResponseStream = T::WaitReleaseStream; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::wait_release(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = WaitReleaseSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.server_streaming(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.ReleaseService/GetArtifactBySlug" => { + #[allow(non_camel_case_types)] + struct GetArtifactBySlugSvc(pub Arc); + impl< + T: ReleaseService, + > tonic::server::UnaryService + for GetArtifactBySlugSvc { + type Response = super::GetArtifactBySlugResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_artifact_by_slug(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetArtifactBySlugSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.ReleaseService/GetArtifactsByProject" => { + #[allow(non_camel_case_types)] + struct GetArtifactsByProjectSvc(pub Arc); + impl< + T: ReleaseService, + > tonic::server::UnaryService + for GetArtifactsByProjectSvc { + type Response = super::GetArtifactsByProjectResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_artifacts_by_project( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetArtifactsByProjectSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.ReleaseService/GetOrganisations" => { + #[allow(non_camel_case_types)] + struct GetOrganisationsSvc(pub Arc); + impl< + T: ReleaseService, + > tonic::server::UnaryService + for GetOrganisationsSvc { + type Response = super::GetOrganisationsResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_organisations(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetOrganisationsSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.ReleaseService/GetProjects" => { + #[allow(non_camel_case_types)] + struct GetProjectsSvc(pub Arc); + impl< + T: ReleaseService, + > tonic::server::UnaryService + for GetProjectsSvc { + type Response = super::GetProjectsResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_projects(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetProjectsSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => { + Box::pin(async move { + let mut response = http::Response::new( + tonic::body::Body::default(), + ); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } + } + } + } + impl Clone for ReleaseServiceServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "forest.v1.ReleaseService"; + impl tonic::server::NamedService for ReleaseServiceServer { + const NAME: &'static str = SERVICE_NAME; + } +} +/// Generated client implementations. +pub mod users_service_client { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + use tonic::codegen::http::Uri; + #[derive(Debug, Clone)] + pub struct UsersServiceClient { + inner: tonic::client::Grpc, + } + impl UsersServiceClient { + /// Attempt to create a new client by connecting to a given endpoint. + pub async fn connect(dst: D) -> Result + where + D: TryInto, + D::Error: Into, + { + let conn = tonic::transport::Endpoint::new(dst)?.connect().await?; + Ok(Self::new(conn)) + } + } + impl UsersServiceClient + where + T: tonic::client::GrpcService, + T::Error: Into, + T::ResponseBody: Body + std::marker::Send + 'static, + ::Error: Into + std::marker::Send, + { + pub fn new(inner: T) -> Self { + let inner = tonic::client::Grpc::new(inner); + Self { inner } + } + pub fn with_origin(inner: T, origin: Uri) -> Self { + let inner = tonic::client::Grpc::with_origin(inner, origin); + Self { inner } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> UsersServiceClient> + where + F: tonic::service::Interceptor, + T::ResponseBody: Default, + T: tonic::codegen::Service< + http::Request, + Response = http::Response< + >::ResponseBody, + >, + >, + , + >>::Error: Into + std::marker::Send + std::marker::Sync, + { + UsersServiceClient::new(InterceptedService::new(inner, interceptor)) + } + /// Compress requests with the given encoding. + /// + /// This requires the server to support it otherwise it might respond with an + /// error. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.send_compressed(encoding); + self + } + /// Enable decompressing responses. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.inner = self.inner.accept_compressed(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_decoding_message_size(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.inner = self.inner.max_encoding_message_size(limit); + self + } + pub async fn register( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/Register", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "Register")); + self.inner.unary(req, path, codec).await + } + pub async fn login( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/Login", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "Login")); + self.inner.unary(req, path, codec).await + } + pub async fn refresh_token( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/RefreshToken", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "RefreshToken")); + self.inner.unary(req, path, codec).await + } + pub async fn logout( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result, tonic::Status> { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/Logout", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "Logout")); + self.inner.unary(req, path, codec).await + } + pub async fn get_user( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/GetUser", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "GetUser")); + self.inner.unary(req, path, codec).await + } + pub async fn update_user( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/UpdateUser", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "UpdateUser")); + self.inner.unary(req, path, codec).await + } + pub async fn delete_user( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/DeleteUser", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "DeleteUser")); + self.inner.unary(req, path, codec).await + } + pub async fn list_users( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/ListUsers", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "ListUsers")); + self.inner.unary(req, path, codec).await + } + pub async fn change_password( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/ChangePassword", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "ChangePassword")); + self.inner.unary(req, path, codec).await + } + pub async fn add_email( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/AddEmail", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "AddEmail")); + self.inner.unary(req, path, codec).await + } + pub async fn verify_email( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/VerifyEmail", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "VerifyEmail")); + self.inner.unary(req, path, codec).await + } + pub async fn remove_email( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/RemoveEmail", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "RemoveEmail")); + self.inner.unary(req, path, codec).await + } + pub async fn o_auth_login( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/OAuthLogin", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "OAuthLogin")); + self.inner.unary(req, path, codec).await + } + pub async fn link_o_auth_provider( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/LinkOAuthProvider", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "LinkOAuthProvider")); + self.inner.unary(req, path, codec).await + } + pub async fn unlink_o_auth_provider( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/UnlinkOAuthProvider", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("forest.v1.UsersService", "UnlinkOAuthProvider"), + ); + self.inner.unary(req, path, codec).await + } + pub async fn create_personal_access_token( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/CreatePersonalAccessToken", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "forest.v1.UsersService", + "CreatePersonalAccessToken", + ), + ); + self.inner.unary(req, path, codec).await + } + pub async fn list_personal_access_tokens( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/ListPersonalAccessTokens", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new("forest.v1.UsersService", "ListPersonalAccessTokens"), + ); + self.inner.unary(req, path, codec).await + } + pub async fn delete_personal_access_token( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/DeletePersonalAccessToken", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert( + GrpcMethod::new( + "forest.v1.UsersService", + "DeletePersonalAccessToken", + ), + ); + self.inner.unary(req, path, codec).await + } + pub async fn token_info( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/TokenInfo", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "TokenInfo")); + self.inner.unary(req, path, codec).await + } + pub async fn setup_mfa( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/SetupMfa", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "SetupMfa")); + self.inner.unary(req, path, codec).await + } + pub async fn verify_mfa( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/VerifyMfa", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "VerifyMfa")); + self.inner.unary(req, path, codec).await + } + pub async fn disable_mfa( + &mut self, + request: impl tonic::IntoRequest, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + > { + self.inner + .ready() + .await + .map_err(|e| { + tonic::Status::unknown( + format!("Service was not ready: {}", e.into()), + ) + })?; + let codec = tonic_prost::ProstCodec::default(); + let path = http::uri::PathAndQuery::from_static( + "/forest.v1.UsersService/DisableMfa", + ); + let mut req = request.into_request(); + req.extensions_mut() + .insert(GrpcMethod::new("forest.v1.UsersService", "DisableMfa")); + self.inner.unary(req, path, codec).await + } + } +} +/// Generated server implementations. +pub mod users_service_server { + #![allow( + unused_variables, + dead_code, + missing_docs, + clippy::wildcard_imports, + clippy::let_unit_value, + )] + use tonic::codegen::*; + /// Generated trait containing gRPC methods that should be implemented for use with UsersServiceServer. + #[async_trait] + pub trait UsersService: std::marker::Send + std::marker::Sync + 'static { + async fn register( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn login( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + async fn refresh_token( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn logout( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + async fn get_user( + &self, + request: tonic::Request, + ) -> std::result::Result, tonic::Status>; + async fn update_user( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn delete_user( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn list_users( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn change_password( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn add_email( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn verify_email( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn remove_email( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn o_auth_login( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn link_o_auth_provider( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn unlink_o_auth_provider( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn create_personal_access_token( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn list_personal_access_tokens( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn delete_personal_access_token( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn token_info( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn setup_mfa( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn verify_mfa( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + async fn disable_mfa( + &self, + request: tonic::Request, + ) -> std::result::Result< + tonic::Response, + tonic::Status, + >; + } + #[derive(Debug)] + pub struct UsersServiceServer { + inner: Arc, + accept_compression_encodings: EnabledCompressionEncodings, + send_compression_encodings: EnabledCompressionEncodings, + max_decoding_message_size: Option, + max_encoding_message_size: Option, + } + impl UsersServiceServer { + pub fn new(inner: T) -> Self { + Self::from_arc(Arc::new(inner)) + } + pub fn from_arc(inner: Arc) -> Self { + Self { + inner, + accept_compression_encodings: Default::default(), + send_compression_encodings: Default::default(), + max_decoding_message_size: None, + max_encoding_message_size: None, + } + } + pub fn with_interceptor( + inner: T, + interceptor: F, + ) -> InterceptedService + where + F: tonic::service::Interceptor, + { + InterceptedService::new(Self::new(inner), interceptor) + } + /// Enable decompressing requests with the given encoding. + #[must_use] + pub fn accept_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.accept_compression_encodings.enable(encoding); + self + } + /// Compress responses with the given encoding, if the client supports it. + #[must_use] + pub fn send_compressed(mut self, encoding: CompressionEncoding) -> Self { + self.send_compression_encodings.enable(encoding); + self + } + /// Limits the maximum size of a decoded message. + /// + /// Default: `4MB` + #[must_use] + pub fn max_decoding_message_size(mut self, limit: usize) -> Self { + self.max_decoding_message_size = Some(limit); + self + } + /// Limits the maximum size of an encoded message. + /// + /// Default: `usize::MAX` + #[must_use] + pub fn max_encoding_message_size(mut self, limit: usize) -> Self { + self.max_encoding_message_size = Some(limit); + self + } + } + impl tonic::codegen::Service> for UsersServiceServer + where + T: UsersService, + B: Body + std::marker::Send + 'static, + B::Error: Into + std::marker::Send + 'static, + { + type Response = http::Response; + type Error = std::convert::Infallible; + type Future = BoxFuture; + fn poll_ready( + &mut self, + _cx: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn call(&mut self, req: http::Request) -> Self::Future { + match req.uri().path() { + "/forest.v1.UsersService/Register" => { + #[allow(non_camel_case_types)] + struct RegisterSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for RegisterSvc { + type Response = super::RegisterResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::register(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = RegisterSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/Login" => { + #[allow(non_camel_case_types)] + struct LoginSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService for LoginSvc { + type Response = super::LoginResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::login(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = LoginSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/RefreshToken" => { + #[allow(non_camel_case_types)] + struct RefreshTokenSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for RefreshTokenSvc { + type Response = super::RefreshTokenResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::refresh_token(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = RefreshTokenSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/Logout" => { + #[allow(non_camel_case_types)] + struct LogoutSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for LogoutSvc { + type Response = super::LogoutResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::logout(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = LogoutSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/GetUser" => { + #[allow(non_camel_case_types)] + struct GetUserSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for GetUserSvc { + type Response = super::GetUserResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::get_user(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = GetUserSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/UpdateUser" => { + #[allow(non_camel_case_types)] + struct UpdateUserSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for UpdateUserSvc { + type Response = super::UpdateUserResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::update_user(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = UpdateUserSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/DeleteUser" => { + #[allow(non_camel_case_types)] + struct DeleteUserSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for DeleteUserSvc { + type Response = super::DeleteUserResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::delete_user(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = DeleteUserSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/ListUsers" => { + #[allow(non_camel_case_types)] + struct ListUsersSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for ListUsersSvc { + type Response = super::ListUsersResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::list_users(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ListUsersSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/ChangePassword" => { + #[allow(non_camel_case_types)] + struct ChangePasswordSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for ChangePasswordSvc { + type Response = super::ChangePasswordResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::change_password(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ChangePasswordSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/AddEmail" => { + #[allow(non_camel_case_types)] + struct AddEmailSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for AddEmailSvc { + type Response = super::AddEmailResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::add_email(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = AddEmailSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/VerifyEmail" => { + #[allow(non_camel_case_types)] + struct VerifyEmailSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for VerifyEmailSvc { + type Response = super::VerifyEmailResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::verify_email(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = VerifyEmailSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/RemoveEmail" => { + #[allow(non_camel_case_types)] + struct RemoveEmailSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for RemoveEmailSvc { + type Response = super::RemoveEmailResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::remove_email(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = RemoveEmailSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/OAuthLogin" => { + #[allow(non_camel_case_types)] + struct OAuthLoginSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for OAuthLoginSvc { + type Response = super::OAuthLoginResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::o_auth_login(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = OAuthLoginSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/LinkOAuthProvider" => { + #[allow(non_camel_case_types)] + struct LinkOAuthProviderSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for LinkOAuthProviderSvc { + type Response = super::LinkOAuthProviderResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::link_o_auth_provider(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = LinkOAuthProviderSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/UnlinkOAuthProvider" => { + #[allow(non_camel_case_types)] + struct UnlinkOAuthProviderSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for UnlinkOAuthProviderSvc { + type Response = super::UnlinkOAuthProviderResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::unlink_o_auth_provider(&inner, request) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = UnlinkOAuthProviderSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/CreatePersonalAccessToken" => { + #[allow(non_camel_case_types)] + struct CreatePersonalAccessTokenSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService< + super::CreatePersonalAccessTokenRequest, + > for CreatePersonalAccessTokenSvc { + type Response = super::CreatePersonalAccessTokenResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request< + super::CreatePersonalAccessTokenRequest, + >, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::create_personal_access_token( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = CreatePersonalAccessTokenSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/ListPersonalAccessTokens" => { + #[allow(non_camel_case_types)] + struct ListPersonalAccessTokensSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for ListPersonalAccessTokensSvc { + type Response = super::ListPersonalAccessTokensResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request< + super::ListPersonalAccessTokensRequest, + >, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::list_personal_access_tokens( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = ListPersonalAccessTokensSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/DeletePersonalAccessToken" => { + #[allow(non_camel_case_types)] + struct DeletePersonalAccessTokenSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService< + super::DeletePersonalAccessTokenRequest, + > for DeletePersonalAccessTokenSvc { + type Response = super::DeletePersonalAccessTokenResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request< + super::DeletePersonalAccessTokenRequest, + >, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::delete_personal_access_token( + &inner, + request, + ) + .await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = DeletePersonalAccessTokenSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/TokenInfo" => { + #[allow(non_camel_case_types)] + struct TokenInfoSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for TokenInfoSvc { + type Response = super::TokenInfoResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::token_info(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = TokenInfoSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/SetupMfa" => { + #[allow(non_camel_case_types)] + struct SetupMfaSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for SetupMfaSvc { + type Response = super::SetupMfaResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::setup_mfa(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = SetupMfaSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/VerifyMfa" => { + #[allow(non_camel_case_types)] + struct VerifyMfaSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for VerifyMfaSvc { + type Response = super::VerifyMfaResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::verify_mfa(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = VerifyMfaSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + "/forest.v1.UsersService/DisableMfa" => { + #[allow(non_camel_case_types)] + struct DisableMfaSvc(pub Arc); + impl< + T: UsersService, + > tonic::server::UnaryService + for DisableMfaSvc { + type Response = super::DisableMfaResponse; + type Future = BoxFuture< + tonic::Response, + tonic::Status, + >; + fn call( + &mut self, + request: tonic::Request, + ) -> Self::Future { + let inner = Arc::clone(&self.0); + let fut = async move { + ::disable_mfa(&inner, request).await + }; + Box::pin(fut) + } + } + let accept_compression_encodings = self.accept_compression_encodings; + let send_compression_encodings = self.send_compression_encodings; + let max_decoding_message_size = self.max_decoding_message_size; + let max_encoding_message_size = self.max_encoding_message_size; + let inner = self.inner.clone(); + let fut = async move { + let method = DisableMfaSvc(inner); + let codec = tonic_prost::ProstCodec::default(); + let mut grpc = tonic::server::Grpc::new(codec) + .apply_compression_config( + accept_compression_encodings, + send_compression_encodings, + ) + .apply_max_message_size_config( + max_decoding_message_size, + max_encoding_message_size, + ); + let res = grpc.unary(method, req).await; + Ok(res) + }; + Box::pin(fut) + } + _ => { + Box::pin(async move { + let mut response = http::Response::new( + tonic::body::Body::default(), + ); + let headers = response.headers_mut(); + headers + .insert( + tonic::Status::GRPC_STATUS, + (tonic::Code::Unimplemented as i32).into(), + ); + headers + .insert( + http::header::CONTENT_TYPE, + tonic::metadata::GRPC_CONTENT_TYPE, + ); + Ok(response) + }) + } + } + } + } + impl Clone for UsersServiceServer { + fn clone(&self) -> Self { + let inner = self.inner.clone(); + Self { + inner, + accept_compression_encodings: self.accept_compression_encodings, + send_compression_encodings: self.send_compression_encodings, + max_decoding_message_size: self.max_decoding_message_size, + max_encoding_message_size: self.max_encoding_message_size, + } + } + } + /// Generated gRPC service name + pub const SERVICE_NAME: &str = "forest.v1.UsersService"; + impl tonic::server::NamedService for UsersServiceServer { + const NAME: &'static str = SERVICE_NAME; + } +} diff --git a/crates/forage-grpc/src/lib.rs b/crates/forage-grpc/src/lib.rs new file mode 100644 index 0000000..ff45e4e --- /dev/null +++ b/crates/forage-grpc/src/lib.rs @@ -0,0 +1,6 @@ +#![allow(clippy::empty_docs)] + +#[path = "./grpc/forest/v1/forest.v1.rs"] +pub mod grpc; + +pub use grpc::*; diff --git a/crates/forage-server/Cargo.toml b/crates/forage-server/Cargo.toml new file mode 100644 index 0000000..8b75b5b --- /dev/null +++ b/crates/forage-server/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "forage-server" +version = "0.1.0" +edition = "2024" + +[dependencies] +forage-core = { path = "../forage-core" } +forage-db = { path = "../forage-db" } +forage-grpc = { path = "../forage-grpc" } +anyhow.workspace = true +chrono.workspace = true +async-trait.workspace = true +axum.workspace = true +axum-extra.workspace = true +minijinja.workspace = true +serde.workspace = true +sqlx.workspace = true +serde_json.workspace = true +tokio.workspace = true +tonic.workspace = true +tower.workspace = true +tower-http.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +uuid.workspace = true diff --git a/crates/forage-server/src/auth.rs b/crates/forage-server/src/auth.rs new file mode 100644 index 0000000..055fdb7 --- /dev/null +++ b/crates/forage-server/src/auth.rs @@ -0,0 +1,164 @@ +use axum::extract::FromRequestParts; +use axum::http::request::Parts; +use axum_extra::extract::CookieJar; +use axum_extra::extract::cookie::Cookie; + +use forage_core::session::{CachedOrg, CachedUser, SessionId}; + +use crate::state::AppState; + +pub const SESSION_COOKIE: &str = "forage_session"; + +/// Maximum access token lifetime: 24 hours. +/// Defends against forest-server returning absolute timestamps instead of durations. +const MAX_TOKEN_LIFETIME_SECS: i64 = 86400; + +/// Cap expires_in_seconds to a sane maximum. +pub fn cap_token_expiry(expires_in_seconds: i64) -> i64 { + expires_in_seconds.min(MAX_TOKEN_LIFETIME_SECS) +} + +/// Active session data available to route handlers. +pub struct Session { + pub session_id: SessionId, + pub access_token: String, + pub user: CachedUser, + pub csrf_token: String, +} + +/// Extractor that requires an active session. Redirects to /login if not authenticated. +/// Handles transparent token refresh when access token is near expiry. +impl FromRequestParts for Session { + type Rejection = axum::response::Redirect; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let jar = CookieJar::from_headers(&parts.headers); + let session_id = jar + .get(SESSION_COOKIE) + .map(|c| SessionId::from_raw(c.value().to_string())) + .ok_or(axum::response::Redirect::to("/login"))?; + + let mut session_data = state + .sessions + .get(&session_id) + .await + .ok() + .flatten() + .ok_or(axum::response::Redirect::to("/login"))?; + + // Transparent token refresh + if session_data.needs_refresh() { + match state + .forest_client + .refresh_token(&session_data.refresh_token) + .await + { + Ok(tokens) => { + session_data.access_token = tokens.access_token; + session_data.refresh_token = tokens.refresh_token; + session_data.access_expires_at = + chrono::Utc::now() + chrono::Duration::seconds(cap_token_expiry(tokens.expires_in_seconds)); + session_data.last_seen_at = chrono::Utc::now(); + + // Refresh the user cache too + if let Ok(user) = state + .forest_client + .get_user(&session_data.access_token) + .await + { + let orgs = state + .platform_client + .list_my_organisations(&session_data.access_token) + .await + .ok() + .unwrap_or_default() + .into_iter() + .map(|o| CachedOrg { + name: o.name, + role: o.role, + }) + .collect(); + session_data.user = Some(CachedUser { + user_id: user.user_id.clone(), + username: user.username.clone(), + emails: user.emails, + orgs, + }); + } + + let _ = state.sessions.update(&session_id, session_data.clone()).await; + } + Err(_) => { + // Refresh token rejected - session is dead + let _ = state.sessions.delete(&session_id).await; + return Err(axum::response::Redirect::to("/login")); + } + } + } else { + // Throttle last_seen_at writes: only update if older than 5 minutes + let now = chrono::Utc::now(); + if now - session_data.last_seen_at > chrono::Duration::minutes(5) { + session_data.last_seen_at = now; + let _ = state.sessions.update(&session_id, session_data.clone()).await; + } + } + + let user = session_data + .user + .ok_or(axum::response::Redirect::to("/login"))?; + + Ok(Session { + session_id, + access_token: session_data.access_token, + user, + csrf_token: session_data.csrf_token, + }) + } +} + +/// Extractor that optionally provides session info. Never rejects. +/// Used for pages that behave differently when authenticated (e.g., login/signup redirect). +pub struct MaybeSession { + pub session: Option, +} + +impl FromRequestParts for MaybeSession { + type Rejection = std::convert::Infallible; + + async fn from_request_parts( + parts: &mut Parts, + state: &AppState, + ) -> Result { + let session = Session::from_request_parts(parts, state).await.ok(); + Ok(MaybeSession { session }) + } +} + +/// Build a Set-Cookie header for the session. +pub fn session_cookie(session_id: &SessionId) -> CookieJar { + let cookie = Cookie::build((SESSION_COOKIE, session_id.to_string())) + .path("/") + .http_only(true) + .secure(true) + .same_site(axum_extra::extract::cookie::SameSite::Lax) + .build(); + + CookieJar::new().add(cookie) +} + +/// Validate that a submitted CSRF token matches the session's token. +pub fn validate_csrf(session: &Session, submitted: &str) -> bool { + !session.csrf_token.is_empty() && session.csrf_token == submitted +} + +/// Build a Set-Cookie header that clears the session cookie. +pub fn clear_session_cookie() -> CookieJar { + let mut cookie = Cookie::from(SESSION_COOKIE); + cookie.set_path("/"); + cookie.make_removal(); + + CookieJar::new().add(cookie) +} diff --git a/crates/forage-server/src/forest_client.rs b/crates/forage-server/src/forest_client.rs new file mode 100644 index 0000000..5fb7823 --- /dev/null +++ b/crates/forage-server/src/forest_client.rs @@ -0,0 +1,497 @@ +use forage_core::auth::{ + AuthError, AuthTokens, CreatedToken, ForestAuth, PersonalAccessToken, User, UserEmail, +}; +use forage_core::platform::{ + Artifact, ArtifactContext, ForestPlatform, Organisation, PlatformError, +}; +use forage_grpc::organisation_service_client::OrganisationServiceClient; +use forage_grpc::release_service_client::ReleaseServiceClient; +use forage_grpc::users_service_client::UsersServiceClient; +use tonic::metadata::MetadataValue; +use tonic::transport::Channel; +use tonic::Request; + +fn bearer_request(access_token: &str, msg: T) -> Result, String> { + let mut req = Request::new(msg); + let bearer: MetadataValue<_> = format!("Bearer {access_token}") + .parse() + .map_err(|_| "invalid token format".to_string())?; + req.metadata_mut().insert("authorization", bearer); + Ok(req) +} + +/// Real gRPC client to forest-server's UsersService. +#[derive(Clone)] +pub struct GrpcForestClient { + channel: Channel, +} + +impl GrpcForestClient { + /// Create a client that connects lazily (for when server may not be available at startup). + pub fn connect_lazy(endpoint: &str) -> anyhow::Result { + let channel = Channel::from_shared(endpoint.to_string())?.connect_lazy(); + Ok(Self { channel }) + } + + fn client(&self) -> UsersServiceClient { + UsersServiceClient::new(self.channel.clone()) + } + + fn org_client(&self) -> OrganisationServiceClient { + OrganisationServiceClient::new(self.channel.clone()) + } + + fn release_client(&self) -> ReleaseServiceClient { + ReleaseServiceClient::new(self.channel.clone()) + } + + fn authed_request(access_token: &str, msg: T) -> Result, AuthError> { + bearer_request(access_token, msg).map_err(AuthError::Other) + } +} + +fn map_status(status: tonic::Status) -> AuthError { + match status.code() { + tonic::Code::Unauthenticated => AuthError::InvalidCredentials, + tonic::Code::AlreadyExists => AuthError::AlreadyExists(status.message().into()), + tonic::Code::PermissionDenied => AuthError::NotAuthenticated, + tonic::Code::Unavailable => AuthError::Unavailable(status.message().into()), + _ => AuthError::Other(status.message().into()), + } +} + +fn convert_user(u: forage_grpc::User) -> User { + User { + user_id: u.user_id, + username: u.username, + emails: u + .emails + .into_iter() + .map(|e| UserEmail { + email: e.email, + verified: e.verified, + }) + .collect(), + } +} + +fn convert_token(t: forage_grpc::PersonalAccessToken) -> PersonalAccessToken { + PersonalAccessToken { + token_id: t.token_id, + name: t.name, + scopes: t.scopes, + created_at: t.created_at.map(|ts| ts.to_string()), + last_used: t.last_used.map(|ts| ts.to_string()), + expires_at: t.expires_at.map(|ts| ts.to_string()), + } +} + +#[async_trait::async_trait] +impl ForestAuth for GrpcForestClient { + async fn register( + &self, + username: &str, + email: &str, + password: &str, + ) -> Result { + let resp = self + .client() + .register(forage_grpc::RegisterRequest { + username: username.into(), + email: email.into(), + password: password.into(), + }) + .await + .map_err(map_status)? + .into_inner(); + + let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?; + Ok(AuthTokens { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_in_seconds: tokens.expires_in_seconds, + }) + } + + async fn login(&self, identifier: &str, password: &str) -> Result { + let login_identifier = if identifier.contains('@') { + forage_grpc::login_request::Identifier::Email(identifier.into()) + } else { + forage_grpc::login_request::Identifier::Username(identifier.into()) + }; + + let resp = self + .client() + .login(forage_grpc::LoginRequest { + identifier: Some(login_identifier), + password: password.into(), + }) + .await + .map_err(map_status)? + .into_inner(); + + let tokens = resp.tokens.ok_or(AuthError::Other("no tokens in response".into()))?; + Ok(AuthTokens { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_in_seconds: tokens.expires_in_seconds, + }) + } + + async fn refresh_token(&self, refresh_token: &str) -> Result { + let resp = self + .client() + .refresh_token(forage_grpc::RefreshTokenRequest { + refresh_token: refresh_token.into(), + }) + .await + .map_err(map_status)? + .into_inner(); + + let tokens = resp + .tokens + .ok_or(AuthError::Other("no tokens in response".into()))?; + Ok(AuthTokens { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + expires_in_seconds: tokens.expires_in_seconds, + }) + } + + async fn logout(&self, refresh_token: &str) -> Result<(), AuthError> { + self.client() + .logout(forage_grpc::LogoutRequest { + refresh_token: refresh_token.into(), + }) + .await + .map_err(map_status)?; + Ok(()) + } + + async fn get_user(&self, access_token: &str) -> Result { + let req = Self::authed_request( + access_token, + forage_grpc::TokenInfoRequest {}, + )?; + + let info = self + .client() + .token_info(req) + .await + .map_err(map_status)? + .into_inner(); + + let req = Self::authed_request( + access_token, + forage_grpc::GetUserRequest { + identifier: Some(forage_grpc::get_user_request::Identifier::UserId( + info.user_id, + )), + }, + )?; + + let resp = self + .client() + .get_user(req) + .await + .map_err(map_status)? + .into_inner(); + + let user = resp.user.ok_or(AuthError::Other("no user in response".into()))?; + Ok(convert_user(user)) + } + + async fn list_tokens( + &self, + access_token: &str, + user_id: &str, + ) -> Result, AuthError> { + let req = Self::authed_request( + access_token, + forage_grpc::ListPersonalAccessTokensRequest { + user_id: user_id.into(), + }, + )?; + + let resp = self + .client() + .list_personal_access_tokens(req) + .await + .map_err(map_status)? + .into_inner(); + + Ok(resp.tokens.into_iter().map(convert_token).collect()) + } + + async fn create_token( + &self, + access_token: &str, + user_id: &str, + name: &str, + ) -> Result { + let req = Self::authed_request( + access_token, + forage_grpc::CreatePersonalAccessTokenRequest { + user_id: user_id.into(), + name: name.into(), + scopes: vec![], + expires_in_seconds: 0, + }, + )?; + + let resp = self + .client() + .create_personal_access_token(req) + .await + .map_err(map_status)? + .into_inner(); + + let token = resp + .token + .ok_or(AuthError::Other("no token in response".into()))?; + Ok(CreatedToken { + token: convert_token(token), + raw_token: resp.raw_token, + }) + } + + async fn delete_token( + &self, + access_token: &str, + token_id: &str, + ) -> Result<(), AuthError> { + let req = Self::authed_request( + access_token, + forage_grpc::DeletePersonalAccessTokenRequest { + token_id: token_id.into(), + }, + )?; + + self.client() + .delete_personal_access_token(req) + .await + .map_err(map_status)?; + Ok(()) + } +} + +fn convert_organisations( + organisations: Vec, + roles: Vec, +) -> Vec { + organisations + .into_iter() + .zip(roles) + .map(|(org, role)| Organisation { + organisation_id: org.organisation_id, + name: org.name, + role, + }) + .collect() +} + +fn convert_artifact(a: forage_grpc::Artifact) -> Artifact { + let ctx = a.context.unwrap_or_default(); + Artifact { + artifact_id: a.artifact_id, + slug: a.slug, + context: ArtifactContext { + title: ctx.title, + description: if ctx.description.as_deref() == Some("") { + None + } else { + ctx.description + }, + }, + created_at: a.created_at, + } +} + +fn map_platform_status(status: tonic::Status) -> PlatformError { + match status.code() { + tonic::Code::Unauthenticated | tonic::Code::PermissionDenied => { + PlatformError::NotAuthenticated + } + tonic::Code::NotFound => PlatformError::NotFound(status.message().into()), + tonic::Code::Unavailable => PlatformError::Unavailable(status.message().into()), + _ => PlatformError::Other(status.message().into()), + } +} + +fn platform_authed_request(access_token: &str, msg: T) -> Result, PlatformError> { + bearer_request(access_token, msg).map_err(PlatformError::Other) +} + +#[async_trait::async_trait] +impl ForestPlatform for GrpcForestClient { + async fn list_my_organisations( + &self, + access_token: &str, + ) -> Result, PlatformError> { + let req = platform_authed_request( + access_token, + forage_grpc::ListMyOrganisationsRequest { role: String::new() }, + )?; + + let resp = self + .org_client() + .list_my_organisations(req) + .await + .map_err(map_platform_status)? + .into_inner(); + + Ok(convert_organisations(resp.organisations, resp.roles)) + } + + async fn list_projects( + &self, + access_token: &str, + organisation: &str, + ) -> Result, PlatformError> { + let req = platform_authed_request( + access_token, + forage_grpc::GetProjectsRequest { + query: Some(forage_grpc::get_projects_request::Query::Organisation( + forage_grpc::OrganisationRef { + organisation: organisation.into(), + }, + )), + }, + )?; + + let resp = self + .release_client() + .get_projects(req) + .await + .map_err(map_platform_status)? + .into_inner(); + + Ok(resp.projects) + } + + async fn list_artifacts( + &self, + access_token: &str, + organisation: &str, + project: &str, + ) -> Result, PlatformError> { + let req = platform_authed_request( + access_token, + forage_grpc::GetArtifactsByProjectRequest { + project: Some(forage_grpc::Project { + organisation: organisation.into(), + project: project.into(), + }), + }, + )?; + + let resp = self + .release_client() + .get_artifacts_by_project(req) + .await + .map_err(map_platform_status)? + .into_inner(); + + Ok(resp.artifact.into_iter().map(convert_artifact).collect()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_org(id: &str, name: &str) -> forage_grpc::Organisation { + forage_grpc::Organisation { + organisation_id: id.into(), + name: name.into(), + ..Default::default() + } + } + + fn make_artifact(slug: &str, ctx: Option) -> forage_grpc::Artifact { + forage_grpc::Artifact { + artifact_id: "a1".into(), + slug: slug.into(), + context: ctx, + created_at: "2026-01-01".into(), + ..Default::default() + } + } + + #[test] + fn convert_organisations_pairs_orgs_with_roles() { + let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")]; + let roles = vec!["owner".into(), "member".into()]; + + let result = convert_organisations(orgs, roles); + assert_eq!(result.len(), 2); + assert_eq!(result[0].name, "alpha"); + assert_eq!(result[0].role, "owner"); + assert_eq!(result[1].name, "beta"); + assert_eq!(result[1].role, "member"); + } + + #[test] + fn convert_organisations_truncates_when_roles_shorter() { + let orgs = vec![make_org("o1", "alpha"), make_org("o2", "beta")]; + let roles = vec!["owner".into()]; // only 1 role for 2 orgs + + let result = convert_organisations(orgs, roles); + // zip truncates to shorter iterator + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "alpha"); + } + + #[test] + fn convert_organisations_empty() { + let result = convert_organisations(vec![], vec![]); + assert!(result.is_empty()); + } + + #[test] + fn convert_artifact_with_full_context() { + let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext { + title: "My API".into(), + description: Some("A cool API".into()), + ..Default::default() + })); + + let result = convert_artifact(a); + assert_eq!(result.slug, "my-api"); + assert_eq!(result.context.title, "My API"); + assert_eq!(result.context.description.as_deref(), Some("A cool API")); + } + + #[test] + fn convert_artifact_empty_description_becomes_none() { + let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext { + title: "My API".into(), + description: Some(String::new()), + ..Default::default() + })); + + let result = convert_artifact(a); + assert!(result.context.description.is_none()); + } + + #[test] + fn convert_artifact_missing_context_uses_defaults() { + let a = make_artifact("my-api", None); + + let result = convert_artifact(a); + assert_eq!(result.context.title, ""); + assert!(result.context.description.is_none()); + } + + #[test] + fn convert_artifact_none_description_stays_none() { + let a = make_artifact("my-api", Some(forage_grpc::ArtifactContext { + title: "My API".into(), + description: None, + ..Default::default() + })); + + let result = convert_artifact(a); + assert!(result.context.description.is_none()); + } +} diff --git a/crates/forage-server/src/main.rs b/crates/forage-server/src/main.rs new file mode 100644 index 0000000..232962f --- /dev/null +++ b/crates/forage-server/src/main.rs @@ -0,0 +1,1339 @@ +mod auth; +mod forest_client; +mod routes; +mod state; +mod templates; + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::Router; +use forage_core::session::{InMemorySessionStore, SessionStore}; +use forage_db::PgSessionStore; +use tower_http::services::ServeDir; +use tower_http::trace::TraceLayer; +use tracing_subscriber::EnvFilter; + +use crate::forest_client::GrpcForestClient; +use crate::state::AppState; +use crate::templates::TemplateEngine; + +pub fn build_router(state: AppState) -> Router { + Router::new() + .merge(routes::router()) + .nest_service("/static", ServeDir::new("static")) + .layer(TraceLayer::new_for_http()) + .with_state(state) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into())) + .init(); + + let forest_endpoint = + std::env::var("FOREST_SERVER_URL").unwrap_or_else(|_| "http://localhost:4040".into()); + tracing::info!("connecting to forest-server at {forest_endpoint}"); + + let forest_client = GrpcForestClient::connect_lazy(&forest_endpoint)?; + let template_engine = TemplateEngine::new()?; + + // Session store: PostgreSQL if DATABASE_URL is set, otherwise in-memory + let sessions: Arc = if let Ok(database_url) = std::env::var("DATABASE_URL") { + tracing::info!("using PostgreSQL session store"); + let pool = sqlx::PgPool::connect(&database_url).await?; + forage_db::migrate(&pool).await?; + + let pg_store = Arc::new(PgSessionStore::new(pool)); + + // Session reaper for PostgreSQL + let reaper = pg_store.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(300)); + loop { + interval.tick().await; + match reaper.reap_expired(30).await { + Ok(n) if n > 0 => tracing::info!("session reaper: removed {n} expired sessions"), + Err(e) => tracing::warn!("session reaper error: {e}"), + _ => {} + } + } + }); + + pg_store + } else { + tracing::info!("using in-memory session store (set DATABASE_URL for persistence)"); + let mem_store = Arc::new(InMemorySessionStore::new()); + + let reaper = mem_store.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(300)); + loop { + interval.tick().await; + reaper.reap_expired(); + tracing::debug!("session reaper: {} active sessions", reaper.session_count()); + } + }); + + mem_store + }; + + let forest_client = Arc::new(forest_client); + let state = AppState::new(template_engine, forest_client.clone(), forest_client, sessions); + let app = build_router(state); + + let port: u16 = std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(3000); + let addr = SocketAddr::from(([0, 0, 0, 0], port)); + tracing::info!("listening on {}", addr); + + let listener = tokio::net::TcpListener::bind(addr).await?; + axum::serve(listener, app).await?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use chrono::Utc; + use forage_core::auth::*; + use forage_core::platform::{ + Artifact, ArtifactContext, ForestPlatform, Organisation, PlatformError, + }; + use forage_core::session::{CachedOrg, CachedUser, SessionData, SessionStore}; + use std::sync::Mutex; + use tower::ServiceExt; + + /// Configurable mock behavior for testing different scenarios. + #[derive(Default)] + struct MockBehavior { + register_result: Option>, + login_result: Option>, + refresh_result: Option>, + get_user_result: Option>, + list_tokens_result: Option, AuthError>>, + create_token_result: Option>, + delete_token_result: Option>, + } + + /// Configurable mock behavior for platform (orgs, projects, artifacts). + #[derive(Default)] + struct MockPlatformBehavior { + list_orgs_result: Option, PlatformError>>, + list_projects_result: Option, PlatformError>>, + list_artifacts_result: Option, PlatformError>>, + } + + fn ok_tokens() -> AuthTokens { + AuthTokens { + access_token: "mock-access".into(), + refresh_token: "mock-refresh".into(), + expires_in_seconds: 3600, + } + } + + fn ok_user() -> User { + User { + user_id: "user-123".into(), + username: "testuser".into(), + emails: vec![UserEmail { + email: "test@example.com".into(), + verified: true, + }], + } + } + + /// Mock forest client with per-test configurable behavior. + struct MockForestClient { + behavior: Mutex, + } + + impl MockForestClient { + fn new() -> Self { + Self { + behavior: Mutex::new(MockBehavior::default()), + } + } + + fn with_behavior(behavior: MockBehavior) -> Self { + Self { + behavior: Mutex::new(behavior), + } + } + } + + #[async_trait::async_trait] + impl ForestAuth for MockForestClient { + async fn register( + &self, + _username: &str, + _email: &str, + _password: &str, + ) -> Result { + let b = self.behavior.lock().unwrap(); + b.register_result.clone().unwrap_or(Ok(ok_tokens())) + } + + async fn login( + &self, + identifier: &str, + password: &str, + ) -> Result { + let b = self.behavior.lock().unwrap(); + if let Some(result) = b.login_result.clone() { + return result; + } + if identifier == "testuser" && password == "CorrectPass123" { + Ok(ok_tokens()) + } else { + Err(AuthError::InvalidCredentials) + } + } + + async fn refresh_token(&self, _refresh_token: &str) -> Result { + let b = self.behavior.lock().unwrap(); + b.refresh_result.clone().unwrap_or(Ok(AuthTokens { + access_token: "refreshed-access".into(), + refresh_token: "refreshed-refresh".into(), + expires_in_seconds: 3600, + })) + } + + async fn logout(&self, _refresh_token: &str) -> Result<(), AuthError> { + Ok(()) + } + + async fn get_user(&self, access_token: &str) -> Result { + let b = self.behavior.lock().unwrap(); + if let Some(result) = b.get_user_result.clone() { + return result; + } + if access_token == "mock-access" || access_token == "refreshed-access" { + Ok(ok_user()) + } else { + Err(AuthError::NotAuthenticated) + } + } + + async fn list_tokens( + &self, + _access_token: &str, + _user_id: &str, + ) -> Result, AuthError> { + let b = self.behavior.lock().unwrap(); + b.list_tokens_result.clone().unwrap_or(Ok(vec![])) + } + + async fn create_token( + &self, + _access_token: &str, + _user_id: &str, + name: &str, + ) -> Result { + let b = self.behavior.lock().unwrap(); + b.create_token_result.clone().unwrap_or(Ok(CreatedToken { + token: PersonalAccessToken { + token_id: "tok-1".into(), + name: name.into(), + scopes: vec![], + created_at: None, + last_used: None, + expires_at: None, + }, + raw_token: "forg_abcdef1234567890".into(), + })) + } + + async fn delete_token( + &self, + _access_token: &str, + _token_id: &str, + ) -> Result<(), AuthError> { + let b = self.behavior.lock().unwrap(); + b.delete_token_result.clone().unwrap_or(Ok(())) + } + } + + struct MockPlatformClient { + behavior: Mutex, + } + + impl MockPlatformClient { + fn new() -> Self { + Self { + behavior: Mutex::new(MockPlatformBehavior::default()), + } + } + + fn with_behavior(behavior: MockPlatformBehavior) -> Self { + Self { + behavior: Mutex::new(behavior), + } + } + } + + fn default_orgs() -> Vec { + vec![Organisation { + organisation_id: "org-1".into(), + name: "testorg".into(), + role: "admin".into(), + }] + } + + #[async_trait::async_trait] + impl ForestPlatform for MockPlatformClient { + async fn list_my_organisations( + &self, + _access_token: &str, + ) -> Result, PlatformError> { + let b = self.behavior.lock().unwrap(); + b.list_orgs_result.clone().unwrap_or(Ok(default_orgs())) + } + + async fn list_projects( + &self, + _access_token: &str, + _organisation: &str, + ) -> Result, PlatformError> { + let b = self.behavior.lock().unwrap(); + b.list_projects_result + .clone() + .unwrap_or(Ok(vec!["my-api".into()])) + } + + async fn list_artifacts( + &self, + _access_token: &str, + _organisation: &str, + _project: &str, + ) -> Result, PlatformError> { + let b = self.behavior.lock().unwrap(); + b.list_artifacts_result.clone().unwrap_or(Ok(vec![Artifact { + artifact_id: "art-1".into(), + slug: "my-api-abc123".into(), + context: ArtifactContext { + title: "Deploy v1.0".into(), + description: Some("Initial release".into()), + }, + created_at: "2026-03-07T12:00:00Z".into(), + }])) + } + } + + fn make_templates() -> TemplateEngine { + let workspace_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap(); + TemplateEngine::from_path(&workspace_root.join("templates")) + .expect("templates must load for tests") + } + + fn test_state() -> (AppState, Arc) { + test_state_with(MockForestClient::new(), MockPlatformClient::new()) + } + + fn test_state_with( + mock: MockForestClient, + platform: MockPlatformClient, + ) -> (AppState, Arc) { + let sessions = Arc::new(InMemorySessionStore::new()); + let state = AppState::new( + make_templates(), + Arc::new(mock), + Arc::new(platform), + sessions.clone(), + ); + (state, sessions) + } + + fn test_app() -> Router { + let (state, _) = test_state(); + build_router(state) + } + + fn test_app_with(mock: MockForestClient) -> Router { + let (state, _) = test_state_with(mock, MockPlatformClient::new()); + build_router(state) + } + + fn default_test_orgs() -> Vec { + vec![CachedOrg { + name: "testorg".into(), + role: "owner".into(), + }] + } + + /// Create a test session and return the cookie header value. + async fn create_test_session(sessions: &Arc) -> String { + let now = Utc::now(); + let data = SessionData { + access_token: "mock-access".into(), + refresh_token: "mock-refresh".into(), + csrf_token: "test-csrf".into(), + access_expires_at: now + chrono::Duration::hours(1), + user: Some(CachedUser { + user_id: "user-123".into(), + username: "testuser".into(), + emails: vec![UserEmail { + email: "test@example.com".into(), + verified: true, + }], + orgs: default_test_orgs(), + }), + created_at: now, + last_seen_at: now, + }; + let session_id = sessions.create(data).await.unwrap(); + format!("forage_session={}", session_id) + } + + /// Create a test session with an expired access token but valid refresh token. + async fn create_expired_session(sessions: &Arc) -> String { + let now = Utc::now(); + let data = SessionData { + access_token: "expired-access".into(), + refresh_token: "mock-refresh".into(), + csrf_token: "test-csrf".into(), + access_expires_at: now - chrono::Duration::seconds(10), + user: Some(CachedUser { + user_id: "user-123".into(), + username: "testuser".into(), + emails: vec![UserEmail { + email: "test@example.com".into(), + verified: true, + }], + orgs: default_test_orgs(), + }), + created_at: now, + last_seen_at: now, + }; + let session_id = sessions.create(data).await.unwrap(); + format!("forage_session={}", session_id) + } + + /// Create a test session with no cached orgs (for onboarding tests). + async fn create_test_session_no_orgs(sessions: &Arc) -> String { + let now = Utc::now(); + let data = SessionData { + access_token: "mock-access".into(), + refresh_token: "mock-refresh".into(), + csrf_token: "test-csrf".into(), + access_expires_at: now + chrono::Duration::hours(1), + user: Some(CachedUser { + user_id: "user-123".into(), + username: "testuser".into(), + emails: vec![UserEmail { + email: "test@example.com".into(), + verified: true, + }], + orgs: vec![], + }), + created_at: now, + last_seen_at: now, + }; + let session_id = sessions.create(data).await.unwrap(); + format!("forage_session={}", session_id) + } + + // ─── Landing / Pricing ──────────────────────────────────────────── + + #[tokio::test] + async fn landing_page_returns_200() { + let response = test_app() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn landing_page_contains_expected_content() { + let response = test_app() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("Forage - The Platform for Forest")); + assert!(html.contains("forest.cue")); + assert!(html.contains("Component Registry")); + assert!(html.contains("Managed Deployments")); + assert!(html.contains("Container Deployments")); + } + + #[tokio::test] + async fn pricing_page_returns_200() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/pricing") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn pricing_page_contains_all_tiers() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/pricing") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("Free")); + assert!(html.contains("Developer")); + assert!(html.contains("Team")); + assert!(html.contains("Enterprise")); + assert!(html.contains("$10")); + assert!(html.contains("$25")); + } + + #[tokio::test] + async fn unknown_route_returns_404() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/nonexistent") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + + // ─── Auth routes ──────────────────────────────────────────────── + + #[tokio::test] + async fn signup_page_returns_200() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/signup") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn signup_page_contains_form() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/signup") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("username")); + assert!(html.contains("email")); + assert!(html.contains("password")); + } + + #[tokio::test] + async fn login_page_returns_200() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/login") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + #[tokio::test] + async fn login_page_contains_form() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/login") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("identifier")); + assert!(html.contains("password")); + } + + #[tokio::test] + async fn dashboard_without_auth_redirects_to_login() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/dashboard") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/login"); + } + + #[tokio::test] + async fn dashboard_with_session_redirects_to_org() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/dashboard") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // Dashboard now redirects to first org's projects + assert_eq!(response.status(), StatusCode::SEE_OTHER); + } + + #[tokio::test] + async fn dashboard_with_expired_token_refreshes_transparently() { + let (state, sessions) = test_state(); + let cookie = create_expired_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/dashboard") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // Should succeed (redirect to org) because refresh_token works + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert!(response + .headers() + .get("location") + .unwrap() + .to_str() + .unwrap() + .starts_with("/orgs/")); + } + + #[tokio::test] + async fn dashboard_with_invalid_session_redirects() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/dashboard") + .header("cookie", "forage_session=nonexistent") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/login"); + } + + #[tokio::test] + async fn old_token_cookies_are_ignored() { + // Old-style cookies should not authenticate + let response = test_app() + .oneshot( + Request::builder() + .uri("/dashboard") + .header("cookie", "forage_access=mock-access; forage_refresh=mock-refresh") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/login"); + } + + #[tokio::test] + async fn login_submit_success_sets_session_cookie() { + let response = test_app() + .oneshot( + Request::builder() + .method("POST") + .uri("/login") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "identifier=testuser&password=CorrectPass123", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/dashboard"); + // Should have a single forage_session cookie + let cookies: Vec<_> = response.headers().get_all("set-cookie").iter().collect(); + assert!(!cookies.is_empty()); + let cookie_str = cookies[0].to_str().unwrap(); + assert!(cookie_str.contains("forage_session=")); + assert!(cookie_str.contains("HttpOnly")); + } + + #[tokio::test] + async fn login_submit_bad_credentials_shows_error() { + let response = test_app() + .oneshot( + Request::builder() + .method("POST") + .uri("/login") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("identifier=testuser&password=wrongpassword")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("Invalid")); + } + + #[tokio::test] + async fn logout_destroys_session_and_redirects() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + assert_eq!(sessions.session_count(), 1); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/logout") + .header("cookie", &cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("_csrf=test-csrf")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/"); + + // Session should be destroyed + assert_eq!(sessions.session_count(), 0); + } + + #[tokio::test] + async fn logout_with_invalid_csrf_returns_403() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/logout") + .header("cookie", &cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("_csrf=wrong-token")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // Session should NOT be destroyed + assert_eq!(sessions.session_count(), 1); + } + + // ─── Error path tests ───────────────────────────────────────────── + + #[tokio::test] + async fn login_when_forest_unavailable_shows_error() { + let mock = MockForestClient::with_behavior(MockBehavior { + login_result: Some(Err(AuthError::Unavailable("connection refused".into()))), + ..Default::default() + }); + let response = test_app_with(mock) + .oneshot( + Request::builder() + .method("POST") + .uri("/login") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("identifier=testuser&password=CorrectPass123")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("temporarily unavailable")); + } + + #[tokio::test] + async fn signup_duplicate_shows_error() { + let mock = MockForestClient::with_behavior(MockBehavior { + register_result: Some(Err(AuthError::AlreadyExists("username taken".into()))), + ..Default::default() + }); + let response = test_app_with(mock) + .oneshot( + Request::builder() + .method("POST") + .uri("/signup") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "username=testuser&email=test@example.com&password=SecurePass123&password_confirm=SecurePass123", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("already registered")); + } + + #[tokio::test] + async fn signup_when_forest_unavailable_shows_error() { + let mock = MockForestClient::with_behavior(MockBehavior { + register_result: Some(Err(AuthError::Unavailable("connection refused".into()))), + ..Default::default() + }); + let response = test_app_with(mock) + .oneshot( + Request::builder() + .method("POST") + .uri("/signup") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "username=testuser&email=test@example.com&password=SecurePass123&password_confirm=SecurePass123", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("temporarily unavailable")); + } + + #[tokio::test] + async fn expired_session_with_failed_refresh_redirects_to_login() { + let mock = MockForestClient::with_behavior(MockBehavior { + refresh_result: Some(Err(AuthError::NotAuthenticated)), + ..Default::default() + }); + let (state, sessions) = test_state_with(mock, MockPlatformClient::new()); + let cookie = create_expired_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/dashboard") + .header("cookie", cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/login"); + + // Session should be destroyed + assert_eq!(sessions.session_count(), 0); + } + + #[tokio::test] + async fn login_empty_fields_shows_validation_error() { + let response = test_app() + .oneshot( + Request::builder() + .method("POST") + .uri("/login") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("identifier=&password=")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("required")); + } + + #[tokio::test] + async fn signup_password_too_short_shows_validation_error() { + let response = test_app() + .oneshot( + Request::builder() + .method("POST") + .uri("/signup") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "username=testuser&email=test@example.com&password=short&password_confirm=short", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("at least 12")); + } + + #[tokio::test] + async fn signup_password_mismatch_shows_error() { + let response = test_app() + .oneshot( + Request::builder() + .method("POST") + .uri("/signup") + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from( + "username=testuser&email=test@example.com&password=SecurePass123&password_confirm=differentpassword", + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("do not match")); + } + + #[tokio::test] + async fn delete_token_error_returns_500() { + let mock = MockForestClient::with_behavior(MockBehavior { + delete_token_result: Some(Err(AuthError::Other("db error".into()))), + ..Default::default() + }); + let (state, sessions) = test_state_with(mock, MockPlatformClient::new()); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/settings/tokens/tok-1/delete") + .header("cookie", cookie) + .header("content-type", "application/x-www-form-urlencoded") + .body(Body::from("_csrf=test-csrf")) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + } + + // ─── Platform / Projects tests ─────────────────────────────────── + + #[tokio::test] + async fn dashboard_with_orgs_redirects_to_first_org() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/dashboard") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get("location").unwrap(), + "/orgs/testorg/projects" + ); + } + + #[tokio::test] + async fn dashboard_no_orgs_shows_onboarding() { + let (state, sessions) = test_state(); + let cookie = create_test_session_no_orgs(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/dashboard") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("forest orgs create")); + } + + #[tokio::test] + async fn dashboard_platform_unavailable_shows_onboarding() { + // With cached orgs empty, dashboard shows onboarding regardless of platform state + let (state, sessions) = test_state(); + let cookie = create_test_session_no_orgs(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/dashboard") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + // Graceful degradation: shows onboarding instead of error + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("forest orgs create")); + } + + #[tokio::test] + async fn projects_list_platform_unavailable_degrades_gracefully() { + // When list_projects fails, the route shows empty projects (graceful degradation) + let platform = MockPlatformClient::with_behavior(MockPlatformBehavior { + list_projects_result: Some(Err(PlatformError::Unavailable("connection refused".into()))), + ..Default::default() + }); + let (state, sessions) = test_state_with(MockForestClient::new(), platform); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/projects") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("No projects yet")); + } + + #[tokio::test] + async fn error_403_renders_html() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/unknown-org/projects") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("Access denied")); + } + + #[tokio::test] + async fn authenticated_pages_show_app_nav() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/projects") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + // Should show app nav, not marketing nav + assert!(html.contains("Sign out")); + assert!(html.contains("testorg")); + assert!(!html.contains("Sign in")); + } + + #[tokio::test] + async fn projects_list_returns_200_with_projects() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/projects") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("my-api")); + } + + #[tokio::test] + async fn projects_list_empty_shows_empty_state() { + let platform = MockPlatformClient::with_behavior(MockPlatformBehavior { + list_projects_result: Some(Ok(vec![])), + ..Default::default() + }); + let (state, sessions) = test_state_with(MockForestClient::new(), platform); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/projects") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("No projects yet")); + } + + #[tokio::test] + async fn projects_list_unauthenticated_redirects() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/orgs/testorg/projects") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/login"); + } + + #[tokio::test] + async fn projects_list_non_member_returns_403() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/unknown-org/projects") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } + + #[tokio::test] + async fn project_detail_returns_200_with_artifacts() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/projects/my-api") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("my-api")); + assert!(html.contains("Deploy v1.0")); + assert!(html.contains("my-api-abc123")); + } + + #[tokio::test] + async fn project_detail_empty_artifacts_shows_empty_state() { + let platform = MockPlatformClient::with_behavior(MockPlatformBehavior { + list_artifacts_result: Some(Ok(vec![])), + ..Default::default() + }); + let (state, sessions) = test_state_with(MockForestClient::new(), platform); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/projects/my-api") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("No releases yet")); + } + + #[tokio::test] + async fn usage_page_returns_200() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/testorg/usage") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = axum::body::to_bytes(response.into_body(), usize::MAX) + .await + .unwrap(); + let html = String::from_utf8(body.to_vec()).unwrap(); + assert!(html.contains("Early Access")); + assert!(html.contains("testorg")); + } + + #[tokio::test] + async fn usage_page_unauthenticated_redirects() { + let response = test_app() + .oneshot( + Request::builder() + .uri("/orgs/testorg/usage") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!(response.headers().get("location").unwrap(), "/login"); + } + + #[tokio::test] + async fn usage_page_non_member_returns_403() { + let (state, sessions) = test_state(); + let cookie = create_test_session(&sessions).await; + let app = build_router(state); + + let response = app + .oneshot( + Request::builder() + .uri("/orgs/unknown-org/usage") + .header("cookie", &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::FORBIDDEN); + } +} diff --git a/crates/forage-server/src/routes/auth.rs b/crates/forage-server/src/routes/auth.rs new file mode 100644 index 0000000..d0dd614 --- /dev/null +++ b/crates/forage-server/src/routes/auth.rs @@ -0,0 +1,500 @@ +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Redirect, Response}; +use axum::routing::{get, post}; +use axum::{Form, Router}; +use chrono::Utc; +use minijinja::context; +use serde::Deserialize; + +use super::error_page; +use crate::auth::{self, MaybeSession, Session}; +use crate::state::AppState; +use forage_core::auth::{validate_email, validate_password, validate_username}; +use forage_core::session::{CachedOrg, CachedUser, SessionData, generate_csrf_token}; + +pub fn router() -> Router { + Router::new() + .route("/signup", get(signup_page).post(signup_submit)) + .route("/login", get(login_page).post(login_submit)) + .route("/logout", post(logout_submit)) + .route("/dashboard", get(dashboard)) + .route( + "/settings/tokens", + get(tokens_page).post(create_token_submit), + ) + .route("/settings/tokens/{id}/delete", post(delete_token_submit)) +} + +// ─── Signup ───────────────────────────────────────────────────────── + +async fn signup_page( + State(state): State, + maybe: MaybeSession, +) -> Result { + if maybe.session.is_some() { + return Ok(Redirect::to("/dashboard").into_response()); + } + + render_signup(&state, "", "", "", None) +} + +#[derive(Deserialize)] +struct SignupForm { + username: String, + email: String, + password: String, + password_confirm: String, +} + +async fn signup_submit( + State(state): State, + maybe: MaybeSession, + Form(form): Form, +) -> Result { + if maybe.session.is_some() { + return Ok(Redirect::to("/dashboard").into_response()); + } + + // Validate + if let Err(e) = validate_username(&form.username) { + return render_signup(&state, &form.username, &form.email, "", Some(e.0)); + } + if let Err(e) = validate_email(&form.email) { + return render_signup(&state, &form.username, &form.email, "", Some(e.0)); + } + if let Err(e) = validate_password(&form.password) { + return render_signup(&state, &form.username, &form.email, "", Some(e.0)); + } + if form.password != form.password_confirm { + return render_signup( + &state, + &form.username, + &form.email, + "", + Some("Passwords do not match".into()), + ); + } + + // Register via forest-server + match state + .forest_client + .register(&form.username, &form.email, &form.password) + .await + { + Ok(tokens) => { + // Fetch user info for the session cache + let mut user_cache = state + .forest_client + .get_user(&tokens.access_token) + .await + .ok() + .map(|u| CachedUser { + user_id: u.user_id, + username: u.username, + emails: u.emails, + orgs: vec![], + }); + + // Cache org memberships in the session + if let Some(ref mut user) = user_cache + && let Ok(orgs) = state + .platform_client + .list_my_organisations(&tokens.access_token) + .await + { + user.orgs = orgs + .into_iter() + .map(|o| CachedOrg { + name: o.name, + role: o.role, + }) + .collect(); + } + + let now = Utc::now(); + let session_data = SessionData { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + access_expires_at: now + chrono::Duration::seconds(auth::cap_token_expiry(tokens.expires_in_seconds)), + user: user_cache, + csrf_token: generate_csrf_token(), + created_at: now, + last_seen_at: now, + }; + + match state.sessions.create(session_data).await { + Ok(session_id) => { + let cookie = auth::session_cookie(&session_id); + Ok((cookie, Redirect::to("/dashboard")).into_response()) + } + Err(_) => render_signup( + &state, + &form.username, + &form.email, + "", + Some("Internal error. Please try again.".into()), + ), + } + } + Err(forage_core::auth::AuthError::AlreadyExists(_)) => render_signup( + &state, + &form.username, + &form.email, + "", + Some("Username or email already registered".into()), + ), + Err(forage_core::auth::AuthError::Unavailable(msg)) => { + tracing::error!("forest-server unavailable: {msg}"); + render_signup( + &state, + &form.username, + &form.email, + "", + Some("Service temporarily unavailable. Please try again.".into()), + ) + } + Err(e) => render_signup( + &state, + &form.username, + &form.email, + "", + Some(e.to_string()), + ), + } +} + +fn render_signup( + state: &AppState, + username: &str, + email: &str, + _password: &str, + error: Option, +) -> Result { + let html = state + .templates + .render( + "pages/signup.html.jinja", + context! { + title => "Sign Up - Forage", + description => "Create your Forage account", + username => username, + email => email, + error => error, + }, + ) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + axum::http::StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Html(html).into_response()) +} + +// ─── Login ────────────────────────────────────────────────────────── + +async fn login_page( + State(state): State, + maybe: MaybeSession, +) -> Result { + if maybe.session.is_some() { + return Ok(Redirect::to("/dashboard").into_response()); + } + + render_login(&state, "", None) +} + +#[derive(Deserialize)] +struct LoginForm { + identifier: String, + password: String, +} + +async fn login_submit( + State(state): State, + maybe: MaybeSession, + Form(form): Form, +) -> Result { + if maybe.session.is_some() { + return Ok(Redirect::to("/dashboard").into_response()); + } + + if form.identifier.is_empty() || form.password.is_empty() { + return render_login( + &state, + &form.identifier, + Some("Email/username and password are required".into()), + ); + } + + match state + .forest_client + .login(&form.identifier, &form.password) + .await + { + Ok(tokens) => { + let mut user_cache = state + .forest_client + .get_user(&tokens.access_token) + .await + .ok() + .map(|u| CachedUser { + user_id: u.user_id, + username: u.username, + emails: u.emails, + orgs: vec![], + }); + + // Cache org memberships in the session + if let Some(ref mut user) = user_cache + && let Ok(orgs) = state + .platform_client + .list_my_organisations(&tokens.access_token) + .await + { + user.orgs = orgs + .into_iter() + .map(|o| CachedOrg { + name: o.name, + role: o.role, + }) + .collect(); + } + + let now = Utc::now(); + let session_data = SessionData { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + access_expires_at: now + chrono::Duration::seconds(auth::cap_token_expiry(tokens.expires_in_seconds)), + user: user_cache, + csrf_token: generate_csrf_token(), + created_at: now, + last_seen_at: now, + }; + + match state.sessions.create(session_data).await { + Ok(session_id) => { + let cookie = auth::session_cookie(&session_id); + Ok((cookie, Redirect::to("/dashboard")).into_response()) + } + Err(_) => render_login( + &state, + &form.identifier, + Some("Internal error. Please try again.".into()), + ), + } + } + Err(forage_core::auth::AuthError::InvalidCredentials) => render_login( + &state, + &form.identifier, + Some("Invalid email/username or password".into()), + ), + Err(forage_core::auth::AuthError::Unavailable(msg)) => { + tracing::error!("forest-server unavailable: {msg}"); + render_login( + &state, + &form.identifier, + Some("Service temporarily unavailable. Please try again.".into()), + ) + } + Err(e) => render_login(&state, &form.identifier, Some(e.to_string())), + } +} + +fn render_login( + state: &AppState, + identifier: &str, + error: Option, +) -> Result { + let html = state + .templates + .render( + "pages/login.html.jinja", + context! { + title => "Sign In - Forage", + description => "Sign in to your Forage account", + identifier => identifier, + error => error, + }, + ) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + axum::http::StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Html(html).into_response()) +} + +// ─── Logout ───────────────────────────────────────────────────────── + +async fn logout_submit( + State(state): State, + session: Session, + Form(form): Form, +) -> Result { + if !auth::validate_csrf(&session, &form._csrf) { + return Err(error_page(&state, StatusCode::FORBIDDEN, "Invalid request", "CSRF validation failed. Please try again.")); + } + // Best-effort logout on forest-server + if let Ok(Some(data)) = state.sessions.get(&session.session_id).await { + let _ = state.forest_client.logout(&data.refresh_token).await; + } + let _ = state.sessions.delete(&session.session_id).await; + Ok((auth::clear_session_cookie(), Redirect::to("/"))) +} + +// ─── Dashboard ────────────────────────────────────────────────────── + +async fn dashboard( + State(state): State, + session: Session, +) -> Result { + // Use cached org memberships from the session + let orgs = &session.user.orgs; + + if let Some(first_org) = orgs.first() { + return Ok(Redirect::to(&format!("/orgs/{}/projects", first_org.name)).into_response()); + } + + // No orgs: show onboarding + let html = state + .templates + .render( + "pages/onboarding.html.jinja", + context! { + title => "Get Started - Forage", + description => "Create your first organisation", + user => context! { username => session.user.username }, + csrf_token => &session.csrf_token, + }, + ) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.") + })?; + + Ok(Html(html).into_response()) +} + +// ─── Tokens ───────────────────────────────────────────────────────── + +async fn tokens_page( + State(state): State, + session: Session, +) -> Result { + let tokens = state + .forest_client + .list_tokens(&session.access_token, &session.user.user_id) + .await + .unwrap_or_default(); + + let html = state + .templates + .render( + "pages/tokens.html.jinja", + context! { + title => "API Tokens - Forage", + description => "Manage your personal access tokens", + user => context! { username => session.user.username }, + tokens => tokens.iter().map(|t| context! { + token_id => t.token_id, + name => t.name, + created_at => t.created_at, + last_used => t.last_used, + expires_at => t.expires_at, + }).collect::>(), + csrf_token => &session.csrf_token, + created_token => None::, + }, + ) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.") + })?; + + Ok(Html(html).into_response()) +} + +#[derive(Deserialize)] +struct CsrfForm { + _csrf: String, +} + +#[derive(Deserialize)] +struct CreateTokenForm { + name: String, + _csrf: String, +} + +async fn create_token_submit( + State(state): State, + session: Session, + Form(form): Form, +) -> Result { + if !auth::validate_csrf(&session, &form._csrf) { + return Err(error_page(&state, StatusCode::FORBIDDEN, "Invalid request", "CSRF validation failed. Please try again.")); + } + + let created = state + .forest_client + .create_token(&session.access_token, &session.user.user_id, &form.name) + .await + .map_err(|e| { + tracing::error!("failed to create token: {e}"); + error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.") + })?; + + let tokens = state + .forest_client + .list_tokens(&session.access_token, &session.user.user_id) + .await + .unwrap_or_default(); + + let html = state + .templates + .render( + "pages/tokens.html.jinja", + context! { + title => "API Tokens - Forage", + description => "Manage your personal access tokens", + user => context! { username => session.user.username }, + tokens => tokens.iter().map(|t| context! { + token_id => t.token_id, + name => t.name, + created_at => t.created_at, + last_used => t.last_used, + expires_at => t.expires_at, + }).collect::>(), + csrf_token => &session.csrf_token, + created_token => Some(created.raw_token), + }, + ) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.") + })?; + + Ok(Html(html).into_response()) +} + +async fn delete_token_submit( + State(state): State, + session: Session, + axum::extract::Path(token_id): axum::extract::Path, + Form(form): Form, +) -> Result { + if !auth::validate_csrf(&session, &form._csrf) { + return Err(error_page(&state, StatusCode::FORBIDDEN, "Invalid request", "CSRF validation failed. Please try again.")); + } + + state + .forest_client + .delete_token(&session.access_token, &token_id) + .await + .map_err(|e| { + tracing::error!("failed to delete token: {e}"); + error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.") + })?; + + Ok(Redirect::to("/settings/tokens").into_response()) +} diff --git a/crates/forage-server/src/routes/mod.rs b/crates/forage-server/src/routes/mod.rs new file mode 100644 index 0000000..2c5e626 --- /dev/null +++ b/crates/forage-server/src/routes/mod.rs @@ -0,0 +1,35 @@ +mod auth; +mod pages; +mod platform; + +use axum::Router; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Response}; +use minijinja::context; + +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .merge(pages::router()) + .merge(auth::router()) + .merge(platform::router()) +} + +/// Render an error page with the given status code, heading, and message. +fn error_page(state: &AppState, status: StatusCode, heading: &str, message: &str) -> Response { + let html = state.templates.render( + "pages/error.html.jinja", + context! { + title => format!("{} - Forage", heading), + description => message, + status => status.as_u16(), + heading => heading, + message => message, + }, + ); + match html { + Ok(body) => (status, Html(body)).into_response(), + Err(_) => status.into_response(), + } +} diff --git a/crates/forage-server/src/routes/pages.rs b/crates/forage-server/src/routes/pages.rs new file mode 100644 index 0000000..d1be9fa --- /dev/null +++ b/crates/forage-server/src/routes/pages.rs @@ -0,0 +1,59 @@ +use axum::extract::State; +use axum::response::Html; +use axum::routing::get; +use axum::Router; +use minijinja::context; + +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/", get(landing)) + .route("/pricing", get(pricing)) + .route("/components", get(components)) +} + +async fn landing(State(state): State) -> Result, axum::http::StatusCode> { + let html = state + .templates + .render("pages/landing.html.jinja", context! { + title => "Forage - The Platform for Forest", + description => "Push a forest.cue manifest, get production infrastructure.", + }) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + axum::http::StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Html(html)) +} + +async fn pricing(State(state): State) -> Result, axum::http::StatusCode> { + let html = state + .templates + .render("pages/pricing.html.jinja", context! { + title => "Pricing - Forage", + description => "Simple, transparent pricing. Pay only for what you use.", + }) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + axum::http::StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Html(html)) +} + +async fn components(State(state): State) -> Result, axum::http::StatusCode> { + let html = state + .templates + .render("pages/components.html.jinja", context! { + title => "Components - Forage", + description => "Discover and share reusable forest components.", + }) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + axum::http::StatusCode::INTERNAL_SERVER_ERROR + })?; + + Ok(Html(html)) +} diff --git a/crates/forage-server/src/routes/platform.rs b/crates/forage-server/src/routes/platform.rs new file mode 100644 index 0000000..1157177 --- /dev/null +++ b/crates/forage-server/src/routes/platform.rs @@ -0,0 +1,166 @@ +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::{Html, IntoResponse, Response}; +use axum::routing::get; +use axum::Router; +use forage_core::platform::validate_slug; +use forage_core::session::CachedOrg; +use minijinja::context; + +use super::error_page; +use crate::auth::Session; +use crate::state::AppState; + +pub fn router() -> Router { + Router::new() + .route("/orgs/{org}/projects", get(projects_list)) + .route("/orgs/{org}/projects/{project}", get(project_detail)) + .route("/orgs/{org}/usage", get(usage)) +} + +fn orgs_context(orgs: &[CachedOrg]) -> Vec { + orgs.iter() + .map(|o| context! { name => o.name, role => o.role }) + .collect() +} + +async fn projects_list( + State(state): State, + session: Session, + Path(org): Path, +) -> Result { + if !validate_slug(&org) { + return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation name.")); + } + + let orgs = &session.user.orgs; + + if !orgs.iter().any(|o| o.name == org) { + return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation.")); + } + + let projects = state + .platform_client + .list_projects(&session.access_token, &org) + .await + .unwrap_or_default(); + + let html = state + .templates + .render( + "pages/projects.html.jinja", + context! { + title => format!("{org} - Projects - Forage"), + description => format!("Projects in {org}"), + user => context! { username => session.user.username }, + csrf_token => &session.csrf_token, + current_org => &org, + orgs => orgs_context(orgs), + org_name => &org, + projects => projects, + }, + ) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.") + })?; + + Ok(Html(html).into_response()) +} + +async fn project_detail( + State(state): State, + session: Session, + Path((org, project)): Path<(String, String)>, +) -> Result { + if !validate_slug(&org) || !validate_slug(&project) { + return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation or project name.")); + } + + let orgs = &session.user.orgs; + + if !orgs.iter().any(|o| o.name == org) { + return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation.")); + } + + let artifacts = state + .platform_client + .list_artifacts(&session.access_token, &org, &project) + .await + .unwrap_or_default(); + + let html = state + .templates + .render( + "pages/project_detail.html.jinja", + context! { + title => format!("{project} - {org} - Forage"), + description => format!("Project {project} in {org}"), + user => context! { username => session.user.username }, + csrf_token => &session.csrf_token, + current_org => &org, + orgs => orgs_context(orgs), + org_name => &org, + project_name => &project, + artifacts => artifacts.iter().map(|a| context! { + slug => a.slug, + title => a.context.title, + description => a.context.description, + created_at => a.created_at, + }).collect::>(), + }, + ) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.") + })?; + + Ok(Html(html).into_response()) +} + +async fn usage( + State(state): State, + session: Session, + Path(org): Path, +) -> Result { + if !validate_slug(&org) { + return Err(error_page(&state, StatusCode::BAD_REQUEST, "Invalid request", "Invalid organisation name.")); + } + + let orgs = &session.user.orgs; + + let current_org_data = orgs.iter().find(|o| o.name == org); + let current_org_data = match current_org_data { + Some(o) => o, + None => return Err(error_page(&state, StatusCode::FORBIDDEN, "Access denied", "You don't have access to this organisation.")), + }; + + let projects = state + .platform_client + .list_projects(&session.access_token, &org) + .await + .unwrap_or_default(); + + let html = state + .templates + .render( + "pages/usage.html.jinja", + context! { + title => format!("Usage - {org} - Forage"), + description => format!("Usage and plan for {org}"), + user => context! { username => session.user.username }, + csrf_token => &session.csrf_token, + current_org => &org, + orgs => orgs_context(orgs), + org_name => &org, + role => ¤t_org_data.role, + project_count => projects.len(), + }, + ) + .map_err(|e| { + tracing::error!("template error: {e:#}"); + error_page(&state, StatusCode::INTERNAL_SERVER_ERROR, "Something went wrong", "Please try again.") + })?; + + Ok(Html(html).into_response()) +} diff --git a/crates/forage-server/src/state.rs b/crates/forage-server/src/state.rs new file mode 100644 index 0000000..4c6ccfd --- /dev/null +++ b/crates/forage-server/src/state.rs @@ -0,0 +1,30 @@ +use std::sync::Arc; + +use crate::templates::TemplateEngine; +use forage_core::auth::ForestAuth; +use forage_core::platform::ForestPlatform; +use forage_core::session::SessionStore; + +#[derive(Clone)] +pub struct AppState { + pub templates: TemplateEngine, + pub forest_client: Arc, + pub platform_client: Arc, + pub sessions: Arc, +} + +impl AppState { + pub fn new( + templates: TemplateEngine, + forest_client: Arc, + platform_client: Arc, + sessions: Arc, + ) -> Self { + Self { + templates, + forest_client, + platform_client, + sessions, + } + } +} diff --git a/crates/forage-server/src/templates.rs b/crates/forage-server/src/templates.rs new file mode 100644 index 0000000..7218de6 --- /dev/null +++ b/crates/forage-server/src/templates.rs @@ -0,0 +1,35 @@ +use std::path::Path; + +use anyhow::Context; +use minijinja::Environment; + +#[derive(Clone)] +pub struct TemplateEngine { + env: Environment<'static>, +} + +impl TemplateEngine { + pub fn from_path(path: &Path) -> anyhow::Result { + if !path.exists() { + anyhow::bail!("templates directory not found: {}", path.display()); + } + + let mut env = Environment::new(); + env.set_loader(minijinja::path_loader(path)); + + Ok(Self { env }) + } + + pub fn new() -> anyhow::Result { + Self::from_path(Path::new("templates")) + } + + pub fn render(&self, template: &str, ctx: minijinja::Value) -> anyhow::Result { + let tmpl = self + .env + .get_template(template) + .with_context(|| format!("template not found: {template}"))?; + tmpl.render(ctx) + .with_context(|| format!("failed to render template: {template}")) + } +} diff --git a/forest.cue b/forest.cue new file mode 100644 index 0000000..10c6342 --- /dev/null +++ b/forest.cue @@ -0,0 +1,18 @@ +project: { + name: "forage-client" + organisation: "forage" +} + +commands: { + dev: ["cargo run -p forage-server"] + build: ["cargo build --release -p forage-server"] + compile: ["cargo build --release"] + test: ["cargo test --workspace"] + check: ["cargo check --workspace", "cargo clippy --workspace -- -D warnings"] + fmt: ["cargo fmt"] + "fmt:check": ["cargo fmt -- --check"] + + "docker:build": [ + "docker build -f templates/forage-server.Dockerfile -t forage/forage-server:dev .", + ] +} diff --git a/interface/proto/forest/v1/organisations.proto b/interface/proto/forest/v1/organisations.proto new file mode 100644 index 0000000..5a9abd0 --- /dev/null +++ b/interface/proto/forest/v1/organisations.proto @@ -0,0 +1,104 @@ +syntax = "proto3"; + +package forest.v1; + +import "google/protobuf/timestamp.proto"; + +message Organisation { + string organisation_id = 1; + string name = 2; + google.protobuf.Timestamp created_at = 3; +} + +message CreateOrganisationRequest { + string name = 1; +} +message CreateOrganisationResponse { + string organisation_id = 1; +} + +message GetOrganisationRequest { + oneof identifier { + string organisation_id = 1; + string name = 2; + } +} +message GetOrganisationResponse { + Organisation organisation = 1; +} + +message SearchOrganisationsRequest { + string query = 1; + int32 page_size = 2; + string page_token = 3; +} +message SearchOrganisationsResponse { + repeated Organisation organisations = 1; + string next_page_token = 2; + int32 total_count = 3; +} + +message ListMyOrganisationsRequest { + // Optional role filter (e.g. "admin"); empty means all roles + string role = 1; +} +message ListMyOrganisationsResponse { + repeated Organisation organisations = 1; + // The role the caller has in each organisation (parallel to organisations) + repeated string roles = 2; +} + +// -- Members ------------------------------------------------------------------ + +message OrganisationMember { + string user_id = 1; + string username = 2; + string role = 3; + google.protobuf.Timestamp joined_at = 4; +} + +message AddMemberRequest { + string organisation_id = 1; + string user_id = 2; + string role = 3; +} +message AddMemberResponse { + OrganisationMember member = 1; +} + +message RemoveMemberRequest { + string organisation_id = 1; + string user_id = 2; +} +message RemoveMemberResponse {} + +message UpdateMemberRoleRequest { + string organisation_id = 1; + string user_id = 2; + string role = 3; +} +message UpdateMemberRoleResponse { + OrganisationMember member = 1; +} + +message ListMembersRequest { + string organisation_id = 1; + int32 page_size = 2; + string page_token = 3; +} +message ListMembersResponse { + repeated OrganisationMember members = 1; + string next_page_token = 2; + int32 total_count = 3; +} + +service OrganisationService { + rpc CreateOrganisation(CreateOrganisationRequest) returns (CreateOrganisationResponse); + rpc GetOrganisation(GetOrganisationRequest) returns (GetOrganisationResponse); + rpc SearchOrganisations(SearchOrganisationsRequest) returns (SearchOrganisationsResponse); + rpc ListMyOrganisations(ListMyOrganisationsRequest) returns (ListMyOrganisationsResponse); + rpc AddMember(AddMemberRequest) returns (AddMemberResponse); + rpc RemoveMember(RemoveMemberRequest) returns (RemoveMemberResponse); + rpc UpdateMemberRole(UpdateMemberRoleRequest) returns (UpdateMemberRoleResponse); + rpc ListMembers(ListMembersRequest) returns (ListMembersResponse); +} diff --git a/interface/proto/forest/v1/releases.proto b/interface/proto/forest/v1/releases.proto new file mode 100644 index 0000000..6595f82 --- /dev/null +++ b/interface/proto/forest/v1/releases.proto @@ -0,0 +1,151 @@ +syntax = "proto3"; + +package forest.v1; + +message AnnotateReleaseRequest { + string artifact_id = 1; + map metadata = 2; + Source source = 3; + ArtifactContext context = 4; + Project project = 5; + Ref ref = 6; +} +message AnnotateReleaseResponse { + Artifact artifact = 1; +} + +message GetArtifactBySlugRequest { + string slug = 1; +} +message GetArtifactBySlugResponse { + Artifact artifact = 1; +} +message GetArtifactsByProjectRequest { + Project project = 1; +} +message GetArtifactsByProjectResponse { + repeated Artifact artifact = 1; +} + +message ReleaseRequest { + string artifact_id = 1; + repeated string destinations = 2; + repeated string environments = 3; +} +message ReleaseResponse { + // List of release intents created (one per destination) + repeated ReleaseIntent intents = 1; +} + +message ReleaseIntent { + string release_intent_id = 1; + string destination = 2; + string environment = 3; +} + +message WaitReleaseRequest { + string release_intent_id = 1; +} + +message WaitReleaseEvent { + oneof event { + ReleaseStatusUpdate status_update = 1; + ReleaseLogLine log_line = 2; + } +} + +message ReleaseStatusUpdate { + string destination = 1; + string status = 2; +} + +message ReleaseLogLine { + string destination = 1; + string line = 2; + string timestamp = 3; + LogChannel channel = 4; +} + +enum LogChannel { + LOG_CHANNEL_UNSPECIFIED = 0; + LOG_CHANNEL_STDOUT = 1; + LOG_CHANNEL_STDERR = 2; +} + +message GetOrganisationsRequest {} +message GetOrganisationsResponse { + repeated OrganisationRef organisations = 1; +} + +message GetProjectsRequest { + oneof query { + OrganisationRef organisation = 1; + } +} +message GetProjectsResponse { + repeated string projects = 1; +} + + + +service ReleaseService { + rpc AnnotateRelease(AnnotateReleaseRequest) returns (AnnotateReleaseResponse); + rpc Release(ReleaseRequest) returns (ReleaseResponse); + rpc WaitRelease(WaitReleaseRequest) returns (stream WaitReleaseEvent); + + rpc GetArtifactBySlug(GetArtifactBySlugRequest) returns (GetArtifactBySlugResponse); + rpc GetArtifactsByProject(GetArtifactsByProjectRequest) returns (GetArtifactsByProjectResponse); + rpc GetOrganisations(GetOrganisationsRequest) returns (GetOrganisationsResponse); + rpc GetProjects(GetProjectsRequest) returns (GetProjectsResponse); +} + +message Source { + optional string user = 1; + optional string email = 2; + optional string source_type = 3; + optional string run_url = 4; +} + +message ArtifactContext { + string title = 1; + optional string description = 2; + optional string web = 3; + optional string pr = 4; +} + +message Artifact { + string id = 1; + string artifact_id = 2; + string slug = 3; + map metadata = 4; + Source source = 5; + ArtifactContext context = 6; + Project project = 7; + repeated ArtifactDestination destinations = 8; + string created_at = 9; +} + +message ArtifactDestination { + string name = 1; + string environment = 2; + string type_organisation = 3; + string type_name = 4; + uint64 type_version = 5; +} + +message Project { + string organisation = 1; + string project = 2; +} + +message Ref { + string commit_sha = 1; + optional string branch = 2; + optional string commit_message = 3; + optional string version = 4; + optional string repo_url = 5; +} + +message OrganisationRef { + string organisation = 1; +} diff --git a/interface/proto/forest/v1/users.proto b/interface/proto/forest/v1/users.proto new file mode 100644 index 0000000..527165d --- /dev/null +++ b/interface/proto/forest/v1/users.proto @@ -0,0 +1,317 @@ +syntax = "proto3"; + +package forest.v1; + +import "google/protobuf/timestamp.proto"; + +// UsersService handles user management, authentication, and profile operations. +service UsersService { + // Authentication + rpc Register(RegisterRequest) returns (RegisterResponse); + rpc Login(LoginRequest) returns (LoginResponse); + rpc RefreshToken(RefreshTokenRequest) returns (RefreshTokenResponse); + rpc Logout(LogoutRequest) returns (LogoutResponse); + + // User CRUD + rpc GetUser(GetUserRequest) returns (GetUserResponse); + rpc UpdateUser(UpdateUserRequest) returns (UpdateUserResponse); + rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse); + rpc ListUsers(ListUsersRequest) returns (ListUsersResponse); + + // Password management + rpc ChangePassword(ChangePasswordRequest) returns (ChangePasswordResponse); + + // Email management + rpc AddEmail(AddEmailRequest) returns (AddEmailResponse); + rpc VerifyEmail(VerifyEmailRequest) returns (VerifyEmailResponse); + rpc RemoveEmail(RemoveEmailRequest) returns (RemoveEmailResponse); + + // Social / OAuth login + rpc OAuthLogin(OAuthLoginRequest) returns (OAuthLoginResponse); + rpc LinkOAuthProvider(LinkOAuthProviderRequest) returns (LinkOAuthProviderResponse); + rpc UnlinkOAuthProvider(UnlinkOAuthProviderRequest) returns (UnlinkOAuthProviderResponse); + + // Personal access tokens + rpc CreatePersonalAccessToken(CreatePersonalAccessTokenRequest) returns (CreatePersonalAccessTokenResponse); + rpc ListPersonalAccessTokens(ListPersonalAccessTokensRequest) returns (ListPersonalAccessTokensResponse); + rpc DeletePersonalAccessToken(DeletePersonalAccessTokenRequest) returns (DeletePersonalAccessTokenResponse); + + // Token introspection (requires valid access token) + rpc TokenInfo(TokenInfoRequest) returns (TokenInfoResponse); + + // MFA + rpc SetupMfa(SetupMfaRequest) returns (SetupMfaResponse); + rpc VerifyMfa(VerifyMfaRequest) returns (VerifyMfaResponse); + rpc DisableMfa(DisableMfaRequest) returns (DisableMfaResponse); +} + +// ─── Core types ────────────────────────────────────────────────────── + +message User { + string user_id = 1; // UUID + string username = 2; + repeated UserEmail emails = 3; + repeated OAuthConnection oauth_connections = 4; + bool mfa_enabled = 5; + google.protobuf.Timestamp created_at = 6; + google.protobuf.Timestamp updated_at = 7; +} + +message UserEmail { + string email = 1; + bool verified = 2; +} + +enum OAuthProvider { + OAUTH_PROVIDER_UNSPECIFIED = 0; + OAUTH_PROVIDER_GITHUB = 1; + OAUTH_PROVIDER_GOOGLE = 2; + OAUTH_PROVIDER_GITLAB = 3; + OAUTH_PROVIDER_MICROSOFT = 4; +} + +message OAuthConnection { + OAuthProvider provider = 1; + string provider_user_id = 2; + string provider_email = 3; + google.protobuf.Timestamp linked_at = 4; +} + +// ─── Authentication ────────────────────────────────────────────────── + +message RegisterRequest { + string username = 1; + string email = 2; + string password = 3; +} + +message RegisterResponse { + User user = 1; + AuthTokens tokens = 2; +} + +message LoginRequest { + // Login with either username or email + oneof identifier { + string username = 1; + string email = 2; + } + string password = 3; +} + +message LoginResponse { + User user = 1; + AuthTokens tokens = 2; +} + +message RefreshTokenRequest { + string refresh_token = 1; +} + +message RefreshTokenResponse { + AuthTokens tokens = 1; +} + +message LogoutRequest { + string refresh_token = 1; +} + +message LogoutResponse {} + +message AuthTokens { + string access_token = 1; + string refresh_token = 2; + int64 expires_in_seconds = 3; +} + +// ─── Token introspection ───────────────────────────────────────────── + +message TokenInfoRequest {} + +message TokenInfoResponse { + string user_id = 1; + int64 expires_at = 2; // Unix timestamp (seconds) +} + +// ─── User CRUD ─────────────────────────────────────────────────────── + +message GetUserRequest { + oneof identifier { + string user_id = 1; + string username = 2; + string email = 3; + } +} + +message GetUserResponse { + User user = 1; +} + +message UpdateUserRequest { + string user_id = 1; + optional string username = 2; +} + +message UpdateUserResponse { + User user = 1; +} + +message DeleteUserRequest { + string user_id = 1; +} + +message DeleteUserResponse {} + +message ListUsersRequest { + int32 page_size = 1; + string page_token = 2; + optional string search = 3; // search across username, email +} + +message ListUsersResponse { + repeated User users = 1; + string next_page_token = 2; + int32 total_count = 3; +} + +// ─── Password management ───────────────────────────────────────────── + +message ChangePasswordRequest { + string user_id = 1; + string current_password = 2; + string new_password = 3; +} + +message ChangePasswordResponse {} + +// ─── Email management ──────────────────────────────────────────────── + +message AddEmailRequest { + string user_id = 1; + string email = 2; +} + +message AddEmailResponse { + UserEmail email = 1; +} + +message VerifyEmailRequest { + string user_id = 1; + string email = 2; +} + +message VerifyEmailResponse {} + +message RemoveEmailRequest { + string user_id = 1; + string email = 2; +} + +message RemoveEmailResponse {} + +// ─── OAuth / Social login ──────────────────────────────────────────── + +message OAuthLoginRequest { + OAuthProvider provider = 1; + string authorization_code = 2; + string redirect_uri = 3; +} + +message OAuthLoginResponse { + User user = 1; + AuthTokens tokens = 2; + bool is_new_user = 3; +} + +message LinkOAuthProviderRequest { + string user_id = 1; + OAuthProvider provider = 2; + string authorization_code = 3; + string redirect_uri = 4; +} + +message LinkOAuthProviderResponse { + OAuthConnection connection = 1; +} + +message UnlinkOAuthProviderRequest { + string user_id = 1; + OAuthProvider provider = 2; +} + +message UnlinkOAuthProviderResponse {} + +// ─── Personal access tokens ────────────────────────────────────────── + +message PersonalAccessToken { + string token_id = 1; // UUID + string name = 2; + repeated string scopes = 3; + google.protobuf.Timestamp expires_at = 4; + google.protobuf.Timestamp last_used = 5; + google.protobuf.Timestamp created_at = 6; +} + +message CreatePersonalAccessTokenRequest { + string user_id = 1; + string name = 2; + repeated string scopes = 3; + // Duration in seconds; 0 = no expiry + int64 expires_in_seconds = 4; +} + +message CreatePersonalAccessTokenResponse { + PersonalAccessToken token = 1; + // The raw token value, only returned on creation + string raw_token = 2; +} + +message ListPersonalAccessTokensRequest { + string user_id = 1; +} + +message ListPersonalAccessTokensResponse { + repeated PersonalAccessToken tokens = 1; +} + +message DeletePersonalAccessTokenRequest { + string token_id = 1; +} + +message DeletePersonalAccessTokenResponse {} + +// ─── MFA ───────────────────────────────────────────────────────────── + +enum MfaType { + MFA_TYPE_UNSPECIFIED = 0; + MFA_TYPE_TOTP = 1; +} + +message SetupMfaRequest { + string user_id = 1; + MfaType mfa_type = 2; +} + +message SetupMfaResponse { + string mfa_id = 1; // UUID + // TOTP provisioning URI (otpauth://...) + string provisioning_uri = 2; + // Base32-encoded secret for manual entry + string secret = 3; +} + +message VerifyMfaRequest { + string mfa_id = 1; + // The TOTP code to verify setup + string code = 2; +} + +message VerifyMfaResponse {} + +message DisableMfaRequest { + string user_id = 1; + // Current TOTP code to confirm disable + string code = 2; +} + +message DisableMfaResponse {} diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..367c3e7 --- /dev/null +++ b/mise.toml @@ -0,0 +1,109 @@ +[tools] +rust = "latest" + +# ─── Core Development ────────────────────────────────────────────── + +[tasks.develop] +description = "Start the forage development server" +run = "cargo run -p forage-server" + +[tasks.build] +description = "Build release binary" +run = "cargo build --release -p forage-server" + +[tasks.compile] +description = "Build entire workspace in release mode" +run = "cargo build --release" + +[tasks.test] +description = "Run all tests" +run = "cargo test --workspace" + +[tasks.check] +description = "Run cargo check and clippy" +run = ["cargo check --workspace", "cargo clippy --workspace -- -D warnings"] + +[tasks.fmt] +description = "Format all code" +run = "cargo fmt" + +[tasks."fmt:check"] +description = "Check formatting" +run = "cargo fmt -- --check" + +# ─── CI Pipelines ────────────────────────────────────────────────── + +[tasks."ci:pr"] +description = "Run PR pipeline (check, test, build)" +run = "cargo run -p ci -- pr" + +[tasks."ci:main"] +description = "Run main pipeline (check, test, build, publish)" +run = "cargo run -p ci -- main" + +# ─── Docker ──────────────────────────────────────────────────────── + +[tasks."docker:build"] +description = "Build forage-server Docker image" +run = "docker build -f templates/forage-server.Dockerfile -t forage/forage-server:dev ." + +[tasks."docker:publish"] +description = "Build and publish Docker image with tags" +run = """ +COMMIT=$(git rev-parse --short HEAD) +TIMESTAMP=$(date +%Y%m%d%H%M%S) +REGISTRY=${CI_REGISTRY:-registry.forage.sh} +IMAGE=${CI_IMAGE_NAME:-forage/forage-server} +docker build -f templates/forage-server.Dockerfile \ + -t $REGISTRY/$IMAGE:latest \ + -t $REGISTRY/$IMAGE:$COMMIT \ + -t $REGISTRY/$IMAGE:$TIMESTAMP \ + . +docker push $REGISTRY/$IMAGE:latest +docker push $REGISTRY/$IMAGE:$COMMIT +docker push $REGISTRY/$IMAGE:$TIMESTAMP +""" + +# ─── Local Infrastructure ────────────────────────────────────────── + +[tasks."local:up"] +description = "Start local services (postgres)" +run = "docker compose -f templates/docker-compose.yaml up -d --wait" + +[tasks."local:down"] +description = "Stop and remove local services" +run = "docker compose -f templates/docker-compose.yaml down -v" + +[tasks."local:logs"] +description = "Tail local service logs" +run = "docker compose -f templates/docker-compose.yaml logs -f" + +# ─── Database ────────────────────────────────────────────────────── + +[tasks."db:shell"] +description = "Connect to local postgres" +run = "psql postgresql://forageuser:foragepassword@localhost:5432/forage" + +[tasks."db:migrate"] +description = "Run database migrations" +run = "cargo sqlx migrate run --source crates/forage-db/src/migrations" + +[tasks."db:prepare"] +description = "Prepare sqlx offline query cache" +run = "cargo sqlx prepare --workspace" + +# ─── Tailwind CSS ────────────────────────────────────────────────── + +[tasks."tailwind:build"] +description = "Build tailwind CSS" +run = "npx @tailwindcss/cli -i static/css/input.css -o static/css/style.css --minify" + +[tasks."tailwind:watch"] +description = "Watch and rebuild tailwind CSS" +run = "npx @tailwindcss/cli -i static/css/input.css -o static/css/style.css --watch" + +# ─── Forest Commands ─────────────────────────────────────────────── + +[tasks."forest:run"] +description = "Run a forest command" +run = "forest run {{arg(name='cmd')}}" diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..24bc3ec --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1055 @@ +{ + "name": "client", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "client", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "@tailwindcss/cli": "^4.2.1", + "tailwindcss": "^4.2.1" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@tailwindcss/cli": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/cli/-/cli-4.2.1.tgz", + "integrity": "sha512-b7MGn51IA80oSG+7fuAgzfQ+7pZBgjzbqwmiv6NO7/+a1sev32cGqnwhscT7h0EcAvMa9r7gjRylqOH8Xhc4DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@parcel/watcher": "^2.5.1", + "@tailwindcss/node": "4.2.1", + "@tailwindcss/oxide": "4.2.1", + "enhanced-resolve": "^5.19.0", + "mri": "^1.2.0", + "picocolors": "^1.1.1", + "tailwindcss": "4.2.1" + }, + "bin": { + "tailwindcss": "dist/index.mjs" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.1.tgz", + "integrity": "sha512-jlx6sLk4EOwO6hHe1oCGm1Q4AN/s0rSrTTPBGPM0/RQ6Uylwq17FuU8IeJJKEjtc6K6O07zsvP+gDO6MMWo7pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.19.0", + "jiti": "^2.6.1", + "lightningcss": "1.31.1", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.1.tgz", + "integrity": "sha512-yv9jeEFWnjKCI6/T3Oq50yQEOqmpmpfzG1hcZsAOaXFQPfzWprWrlHSdGPEF3WQTi8zu8ohC9Mh9J470nT5pUw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-arm64": "4.2.1", + "@tailwindcss/oxide-darwin-x64": "4.2.1", + "@tailwindcss/oxide-freebsd-x64": "4.2.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.1", + "@tailwindcss/oxide-linux-x64-musl": "4.2.1", + "@tailwindcss/oxide-wasm32-wasi": "4.2.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.1.tgz", + "integrity": "sha512-eZ7G1Zm5EC8OOKaesIKuw77jw++QJ2lL9N+dDpdQiAB/c/B2wDh0QPFHbkBVrXnwNugvrbJFk1gK2SsVjwWReg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.1.tgz", + "integrity": "sha512-q/LHkOstoJ7pI1J0q6djesLzRvQSIfEto148ppAd+BVQK0JYjQIFSK3JgYZJa+Yzi0DDa52ZsQx2rqytBnf8Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.1.tgz", + "integrity": "sha512-/f/ozlaXGY6QLbpvd/kFTro2l18f7dHKpB+ieXz+Cijl4Mt9AI2rTrpq7V+t04nK+j9XBQHnSMdeQRhbGyt6fw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.1.tgz", + "integrity": "sha512-5e/AkgYJT/cpbkys/OU2Ei2jdETCLlifwm7ogMC7/hksI2fC3iiq6OcXwjibcIjPung0kRtR3TxEITkqgn0TcA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.1.tgz", + "integrity": "sha512-Uny1EcVTTmerCKt/1ZuKTkb0x8ZaiuYucg2/kImO5A5Y/kBz41/+j0gxUZl+hTF3xkWpDmHX+TaWhOtba2Fyuw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.1.tgz", + "integrity": "sha512-CTrwomI+c7n6aSSQlsPL0roRiNMDQ/YzMD9EjcR+H4f0I1SQ8QqIuPnsVp7QgMkC1Qi8rtkekLkOFjo7OlEFRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.1.tgz", + "integrity": "sha512-WZA0CHRL/SP1TRbA5mp9htsppSEkWuQ4KsSUumYQnyl8ZdT39ntwqmz4IUHGN6p4XdSlYfJwM4rRzZLShHsGAQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.1.tgz", + "integrity": "sha512-qMFzxI2YlBOLW5PhblzuSWlWfwLHaneBE0xHzLrBgNtqN6mWfs+qYbhryGSXQjFYB1Dzf5w+LN5qbUTPhW7Y5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.1.tgz", + "integrity": "sha512-5r1X2FKnCMUPlXTWRYpHdPYUY6a1Ar/t7P24OuiEdEOmms5lyqjDRvVY1yy9Rmioh+AunQ0rWiOTPE8F9A3v5g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.1.tgz", + "integrity": "sha512-MGFB5cVPvshR85MTJkEvqDUnuNoysrsRxd6vnk1Lf2tbiqNlXpHYZqkqOQalydienEWOHHFyyuTSYRsLfxFJ2Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.8.1", + "@emnapi/runtime": "^1.8.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.1", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", + "integrity": "sha512-YlUEHRHBGnCMh4Nj4GnqQyBtsshUPdiNroZj8VPkvTZSoHsilRCwXcVKnG9kyi0ZFAS/3u+qKHBdDc81SADTRA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.1.tgz", + "integrity": "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.20.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", + "integrity": "sha512-/ce7+jQ1PQ6rVXwe+jKEg5hW5ciicHwIQUagZkp6IufBoY3YDgdTTY1azVs0qoRgVmvsNB+rbjLJxDAeHHtwsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.31.1.tgz", + "integrity": "sha512-l51N2r93WmGUye3WuFoN5k10zyvrVs0qfKBhyC5ogUQ6Ew6JUSswh78mbSO+IU3nTWsyOArqPCcShdQSadghBQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.31.1", + "lightningcss-darwin-arm64": "1.31.1", + "lightningcss-darwin-x64": "1.31.1", + "lightningcss-freebsd-x64": "1.31.1", + "lightningcss-linux-arm-gnueabihf": "1.31.1", + "lightningcss-linux-arm64-gnu": "1.31.1", + "lightningcss-linux-arm64-musl": "1.31.1", + "lightningcss-linux-x64-gnu": "1.31.1", + "lightningcss-linux-x64-musl": "1.31.1", + "lightningcss-win32-arm64-msvc": "1.31.1", + "lightningcss-win32-x64-msvc": "1.31.1" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz", + "integrity": "sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz", + "integrity": "sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz", + "integrity": "sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz", + "integrity": "sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz", + "integrity": "sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz", + "integrity": "sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz", + "integrity": "sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz", + "integrity": "sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz", + "integrity": "sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz", + "integrity": "sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.31.1", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz", + "integrity": "sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mri": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", + "integrity": "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tailwindcss": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", + "integrity": "sha512-/tBrSQ36vCleJkAOsy9kbNTgaxvGbyOamC30PRePTQe/o1MFwEKHQk4Cn7BNGaPtjp+PuUrByJehM1hgxfq4sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..152cd4d --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "client", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "", + "devDependencies": { + "@tailwindcss/cli": "^4.2.1", + "tailwindcss": "^4.2.1" + } +} diff --git a/specs/PITCH.md b/specs/PITCH.md new file mode 100644 index 0000000..b33bb63 --- /dev/null +++ b/specs/PITCH.md @@ -0,0 +1,212 @@ +# Forage - The Platform for Forest + +## Elevator Pitch + +Forage is the managed platform for Forest. Push a `forest.cue` manifest, get production infrastructure. Think Heroku meets infrastructure-as-code, but built on the composable component model of Forest. + +## The Problem + +Modern infrastructure tooling is fragmented: +- Kubernetes is powerful but complex - teams spend months just on platform engineering +- Heroku is simple but inflexible - you outgrow it fast +- Infrastructure-as-code tools (Terraform, Pulumi) require deep expertise +- CI/CD pipelines are copy-pasted across projects with slight variations +- Component sharing across teams is ad-hoc at best + +Forest solves the composability problem: define workflows, components, and deployments in shareable, typed CUE files. But Forest still needs infrastructure to run on. + +## The Solution: Forage + +Forage is the missing runtime layer for Forest. It provides: + +### 1. Component Registry +- Publish and discover forest components +- Semantic versioning and dependency resolution +- Organisation-scoped and public components +- `forest components publish` pushes to Forage registry + +### 2. Managed Deployments +- Push a `forest.cue` with destinations pointing to Forage +- Forage provisions and manages the infrastructure +- Zero-config container runtime (no Kubernetes knowledge needed) +- Automatic scaling, health checks, rollbacks +- Multi-environment support (dev/staging/prod) out of the box + +### 3. Managed Services +- **Databases**: PostgreSQL, Redis - provisioned alongside your app +- **Object Storage**: S3-compatible storage +- **User Management**: Auth, teams, RBAC +- **Observability**: Logs, metrics, traces - included by default +- **Secrets Management**: Encrypted at rest, injected at runtime + +### 4. Organisation Management +- Team workspaces with role-based access +- Billing per organisation +- Audit logs for compliance +- SSO/SAML integration + +## How It Works + +```cue +// forest.cue - This is all you need +project: { + name: "my-api" + organisation: "acme" +} + +dependencies: { + "forage/service": version: "1.0" + "forage/postgres": version: "1.0" +} + +forage: service: { + config: { + name: "my-api" + image: "my-api:latest" + ports: [{ container: 8080, protocol: "http" }] + } + env: { + prod: { + destinations: [{ + type: { organisation: "forage", name: "managed", version: "1" } + metadata: { region: "eu-west-1", size: "small" } + }] + } + } +} + +forage: postgres: { + config: { + name: "my-db" + version: "16" + size: "small" + } +} +``` + +Then: +```bash +forest release create --env prod +# Forage handles everything: container runtime, database provisioning, +# networking, TLS, DNS, health checks, scaling +``` + +## Target Users + +### Primary: Small-to-Medium Engineering Teams (5-50 engineers) +- Need production infrastructure without a dedicated platform team +- Want the flexibility of IaC without the complexity +- Already using or willing to adopt Forest for workflow management + +### Secondary: Individual Developers / Startups +- Want to ship fast without infrastructure overhead +- Need a path that scales from prototype to production +- Price-sensitive - pay only for what you use + +### Tertiary: Enterprise Teams +- Want to standardize deployment across many teams +- Need compliance, audit, and access control +- Want to share internal components via private registry + +## Pricing Model + +### Free Tier +- 1 project, 1 environment +- 256MB RAM, shared CPU +- Community components only +- Ideal for experimentation + +### Developer - $10/month +- 3 projects, 3 environments each +- 512MB RAM per service, dedicated CPU +- 1GB PostgreSQL included +- Custom domains + +### Team - $25/user/month +- Unlimited projects and environments +- Configurable resources (up to 4GB RAM, 2 vCPU) +- 10GB PostgreSQL per project +- Private component registry +- Team management, RBAC + +### Enterprise - Custom +- Dedicated infrastructure +- SLA guarantees +- SSO/SAML +- Audit logs +- Priority support +- On-premise registry option + +### Usage-Based Add-ons +- Additional compute: $0.05/vCPU-hour +- Additional memory: $0.01/GB-hour +- Additional storage: $0.10/GB-month +- Bandwidth: $0.05/GB after 10GB free +- Managed databases: Starting at $5/month per instance + +## Competitive Positioning + +| Feature | Forage | Heroku | Railway | Fly.io | K8s (self-managed) | +|---------|--------|--------|---------|--------|---------------------| +| Simplicity | High | High | High | Medium | Low | +| Flexibility | High (CUE) | Low | Medium | Medium | Very High | +| Component Sharing | Native | None | None | None | Helm (limited) | +| Multi-environment | Native | Add-on | Basic | Manual | Manual | +| IaC Integration | Native (Forest) | None | None | Partial | Full | +| Price Entry | Free | $5/mo | $5/mo | $0 (usage) | $$$$ | +| Workflow Automation | Forest native | CI add-ons | Basic | Basic | Custom | + +## Differentiators + +1. **Forest-native**: Not another generic PaaS. Built specifically to make Forest's component model a deployable reality. +2. **Typed Manifests**: CUE gives you type-safe infrastructure definitions with validation before deploy. +3. **Component Ecosystem**: Publish once, use everywhere. Components are the unit of sharing. +4. **Progressive Complexity**: Start simple, add complexity only when needed. No cliff. +5. **Transparent Pricing**: No surprises. Usage-based with clear ceilings. + +## Technical Architecture + +### The Site (this repo) +- **Rust + Axum**: Fast, safe, minimal dependencies +- **MiniJinja**: Server-side rendered - fast page loads, SEO-friendly +- **Tailwind CSS**: Utility-first, consistent design +- **PostgreSQL**: Battle-tested data layer + +### The Platform (future repos) +- **Container Runtime**: Built on Firecracker/Cloud Run/ECS depending on region +- **Registry Service**: gRPC service for component distribution (extends forest-server) +- **Deployment Engine**: Receives forest manifests, provisions infrastructure +- **Billing Service**: Usage tracking, Stripe integration + +## Roadmap + +### Phase 0 - Foundation (Current) +- [ ] Marketing site with pitch, pricing, and waitlist +- [ ] Component registry browser (read-only, pulls from forest-server) +- [ ] Authentication (sign up, sign in, API keys) +- [ ] Organisation and project management UI + +### Phase 1 - Registry +- [ ] Component publishing via CLI (`forest components publish`) +- [ ] Component discovery and browsing +- [ ] Version management and dependency resolution +- [ ] Private organisation registries + +### Phase 2 - Managed Deployments +- [ ] Container runtime integration +- [ ] Push-to-deploy from forest CLI +- [ ] Health checks and automatic rollbacks +- [ ] Environment management (dev/staging/prod) +- [ ] Custom domains and TLS + +### Phase 3 - Managed Services +- [ ] PostgreSQL provisioning +- [ ] Redis provisioning +- [ ] Object storage +- [ ] Secrets management + +### Phase 4 - Enterprise +- [ ] SSO/SAML +- [ ] Audit logging +- [ ] Compliance features +- [ ] On-premise options diff --git a/specs/VSDD.md b/specs/VSDD.md new file mode 100644 index 0000000..b92cd83 --- /dev/null +++ b/specs/VSDD.md @@ -0,0 +1,111 @@ +# Verified Spec-Driven Development (VSDD) + +## The Fusion: VDD x TDD x SDD for AI-Native Engineering + +### Overview + +VSDD is the unified software engineering methodology used for all forage development. It fuses three paradigms into a single AI-orchestrated pipeline: + +- **Spec-Driven Development (SDD):** Define the contract before writing a single line of implementation. Specs are the source of truth. +- **Test-Driven Development (TDD):** Tests are written before code. Red -> Green -> Refactor. No code exists without a failing test that demanded it. +- **Verification-Driven Development (VDD):** Subject all surviving code to adversarial refinement until a hyper-critical reviewer is forced to hallucinate flaws. + +### The Toolchain + +| Role | Entity | Function | +|------|--------|----------| +| The Architect | Human Developer | Strategic vision, domain expertise, acceptance authority | +| The Builder | Claude | Spec authorship, test generation, code implementation, refactoring | +| The Adversary | External reviewer | Hyper-critical reviewer with zero patience | + +### The Pipeline + +#### Phase 1 - Spec Crystallization + +Nothing gets built until the contract is airtight. + +**Step 1a: Behavioral Specification** +- Behavioral Contract: preconditions, postconditions, invariants +- Interface Definition: input types, output types, error types +- Edge Case Catalog: exhaustive boundary conditions and failure modes +- Non-Functional Requirements: performance, memory, security + +**Step 1b: Verification Architecture** +- Provable Properties Catalog: which invariants must be formally verified +- Purity Boundary Map: deterministic pure core vs effectful shell +- Property Specifications: formal property definitions where applicable + +**Step 1c: Spec Review Gate** +- Reviewed by both human and adversary before any tests + +#### Phase 2 - Test-First Implementation (The TDD Core) + +Red -> Green -> Refactor, enforced by AI. + +**Step 2a: Test Suite Generation** +- Unit tests per behavioral contract item +- Edge case tests from the catalog +- Integration tests for system context +- Property-based tests for invariants + +**The Red Gate:** All tests must fail before implementation begins. +> **Enforcement note (from Review 002):** When writing tests alongside templates and routes, +> use stub handlers returning 501 to verify tests fail before implementing the real logic. +> This prevents false confidence from tests that were never red. + +**Step 2b: Minimal Implementation** +1. Pick the next failing test +2. Write the smallest implementation that makes it pass +3. Run the full suite - nothing else should break +4. Repeat + +**Step 2c: Refactor** +After all tests green, refactor for clarity and performance. + +#### Phase 3 - Adversarial Refinement + +The code survived testing. Now it faces the gauntlet. + +Reviews: spec fidelity, test quality, code quality, security surface, spec gaps. + +#### Phase 4 - Feedback Integration Loop + +Critique feeds back through the pipeline: +- Spec-level flaws -> Phase 1 +- Test-level flaws -> Phase 2a +- Implementation flaws -> Phase 2c +- New edge cases -> Spec update -> new tests -> fix + +#### Phase 5 - Formal Hardening + +- Fuzz testing on the pure core +- Security static analysis (cargo-audit, clippy) +- Mutation testing where applicable + +#### Phase 6 - Convergence + +Done when: +- Adversary critiques are nitpicks, not real issues +- No meaningful untested scenarios remain +- Implementation matches spec completely +- Security analysis is clean + +### Core Principles + +1. **Spec Supremacy**: The spec is the highest authority below the human developer +2. **Verification-First Architecture**: Pure core, effectful shell - designed from Phase 1 +3. **Red Before Green**: No implementation without a failing test +4. **Anti-Slop Bias**: First "correct" version assumed to contain hidden debt +5. **Minimal Implementation**: Three similar lines > premature abstraction + +### Applying VSDD in This Project + +Each feature follows this flow: + +1. Create spec in `specs/features/.md` +2. Spec review with human +3. Write failing tests in appropriate crate +4. Implement minimally in pure core (`forage-core`) +5. Wire up in effectful shell (`forage-server`, `forage-db`) +6. Adversarial review +7. Iterate until convergence diff --git a/specs/features/001-landing-page.md b/specs/features/001-landing-page.md new file mode 100644 index 0000000..da8d7be --- /dev/null +++ b/specs/features/001-landing-page.md @@ -0,0 +1,45 @@ +# Spec 001: Landing Page and Marketing Site + +## Status: Phase 2 (Implementation) + +## Behavioral Contract + +### Routes +- `GET /` returns the landing page with HTTP 200 +- `GET /pricing` returns the pricing page with HTTP 200 +- `GET /static/*` serves static files from the `static/` directory +- All pages use the shared base template layout +- Unknown routes return HTTP 404 + +### Landing Page Content +- Hero section with tagline and CTA buttons +- Code example showing a forest.cue manifest +- Feature grid highlighting: registry, deployments, managed services, type safety, teams, pricing +- Final CTA section + +### Pricing Page Content +- Displays 4 tiers: Free ($0), Developer ($10/mo), Team ($25/user/mo), Enterprise (Custom) +- Usage-based add-on pricing table +- Accurate pricing data matching specs/PITCH.md + +### Non-Functional Requirements +- Pages render server-side (no client-side JS required for content) +- Response time < 10ms for template rendering +- Valid HTML5 output +- Responsive layout (mobile + desktop) + +## Edge Cases +- Template file missing -> 500 with error logged +- Static file not found -> 404 +- Malformed path -> handled by axum routing (no panic) + +## Purity Boundary +- Template rendering is effectful (file I/O) -> lives in forage-server +- No pure core logic needed for static pages +- Template engine initialized once at startup + +## Verification +- Integration test: GET / returns 200 with expected content +- Integration test: GET /pricing returns 200 with expected content +- Integration test: GET /nonexistent returns 404 +- Compile check: `cargo check` passes diff --git a/specs/features/002-authentication.md b/specs/features/002-authentication.md new file mode 100644 index 0000000..cc0b804 --- /dev/null +++ b/specs/features/002-authentication.md @@ -0,0 +1,102 @@ +# Spec 002: Authentication (Forest-Server Frontend) + +## Status: Phase 2 Complete (20 tests passing) + +## Overview + +Forage is a server-side rendered frontend for forest-server. All user management +(register, login, sessions, tokens) is handled by forest-server's UsersService +via gRPC. Forage stores access/refresh tokens in HTTP-only cookies and proxies +auth operations to the forest-server backend. + +## Architecture + +``` +Browser <--HTML/cookies--> forage-server (axum) <--gRPC--> forest-server (UsersService) +``` + +- No local user database in forage +- forest-server owns all auth state (users, sessions, passwords) +- forage-server stores access_token + refresh_token in HTTP-only cookies +- forage-server has a gRPC client to forest-server's UsersService + +## Behavioral Contract + +### gRPC Client (`forage-core`) + +A typed client wrapping forest-server's UsersService: +- `register(username, email, password) -> Result` +- `login(identifier, password) -> Result` +- `refresh_token(refresh_token) -> Result` +- `logout(refresh_token) -> Result<()>` +- `get_user(access_token) -> Result` +- `list_personal_access_tokens(access_token, user_id) -> Result>` +- `create_personal_access_token(access_token, user_id, name, scopes, expires) -> Result<(Token, raw_key)>` +- `delete_personal_access_token(access_token, token_id) -> Result<()>` + +### Cookie Management + +- `forage_access` cookie: access_token, HttpOnly, Secure, SameSite=Lax, Path=/ +- `forage_refresh` cookie: refresh_token, HttpOnly, Secure, SameSite=Lax, Path=/ +- On every authenticated request: extract access_token from cookie +- If access_token expired but refresh_token valid: auto-refresh, set new cookies +- If both expired: redirect to /login + +### Routes + +#### Public Pages +- `GET /signup` -> signup form (200), redirect to /dashboard if authenticated +- `POST /signup` -> call Register RPC, set cookies, redirect to /dashboard (302) +- `GET /login` -> login form (200), redirect to /dashboard if authenticated +- `POST /login` -> call Login RPC, set cookies, redirect to /dashboard (302) +- `POST /logout` -> call Logout RPC, clear cookies, redirect to / (302) + +#### Authenticated Pages +- `GET /dashboard` -> home page showing user info + orgs (200), or redirect to /login +- `GET /settings/tokens` -> list PATs (200) +- `POST /settings/tokens` -> create PAT, show raw key once (200) +- `POST /settings/tokens/:id/delete` -> delete PAT, redirect to /settings/tokens (302) + +### Error Handling +- gRPC errors mapped to user-friendly messages in form re-renders +- Invalid credentials: "Invalid username/email or password" (no enumeration) +- Duplicate email/username on register: "Already registered" +- Network error to forest-server: 502 Bad Gateway page + +## Edge Cases +- Forest-server unreachable: show error page, don't crash +- Expired access token with valid refresh: auto-refresh transparently +- Both tokens expired: redirect to login, clear cookies +- Malformed cookie values: treat as unauthenticated +- Concurrent requests during token refresh: only refresh once + +## Purity Boundary + +### Pure Core (`forage-core`) +- ForestClient trait (mockable for tests) +- Token cookie helpers (build Set-Cookie headers, parse cookies) +- Form validation (email format, password length) + +### Effectful Shell (`forage-server`) +- Actual gRPC calls to forest-server +- HTTP cookie read/write +- Route handlers and template rendering +- Auth middleware (extractor) + +## Test Strategy + +### Unit Tests (forage-core) +- Cookie header building: correct flags, encoding +- Form validation: email format, password length +- Token expiry detection + +### Integration Tests (forage-server) +- All routes render correct templates (using mock ForestClient) +- POST /signup calls register, sets cookies on success +- POST /login calls login, sets cookies on success +- GET /dashboard without cookies -> redirect to /login +- GET /dashboard with valid token -> 200 with user content +- POST /logout clears cookies +- Error paths: bad credentials, server down + +The mock ForestClient allows testing all UI flows without a running forest-server. diff --git a/specs/features/003-bff-sessions.md b/specs/features/003-bff-sessions.md new file mode 100644 index 0000000..7643094 --- /dev/null +++ b/specs/features/003-bff-sessions.md @@ -0,0 +1,286 @@ +# Spec 003: BFF Session Management + +## Status: Phase 2 Complete (34 tests passing) + +## Problem + +The current auth implementation stores forest-server's raw access_token and +refresh_token directly in browser cookies. This has several problems: + +1. **Security**: Forest-server credentials are exposed to the browser. If XSS + ever bypasses HttpOnly (or we need to read auth state client-side), the raw + tokens are right there. +2. **No transparent refresh**: The extractor checks cookie existence but can't + detect token expiry. When the access_token expires, `get_user()` fails and + the user gets redirected to login - even though the refresh_token is still + valid. Users get randomly logged out. +3. **No user caching**: Every authenticated page makes 2-3 gRPC round-trips + (token_info + get_user + page-specific call). For server-rendered pages + this is noticeable latency. +4. **No session concept**: There's no way to list active sessions, revoke + sessions, or track "last seen". The server is stateless in a way that + hurts the product. + +## Solution: Backend-for-Frontend (BFF) Sessions + +Forage server owns sessions. The browser gets an opaque session ID cookie. +Forest-server tokens and cached user info live server-side only. + +``` +Browser --[forage_session cookie]--> forage-server --[access_token]--> forest-server + | + [session store] + sid -> { access_token, refresh_token, + expires_at, user_cache } +``` + +## Architecture + +### Session Store Trait + +A trait in `forage-core` so the store is swappable and testable: + +```rust +#[async_trait] +pub trait SessionStore: Send + Sync { + async fn create(&self, data: SessionData) -> Result; + async fn get(&self, id: &SessionId) -> Result, SessionError>; + async fn update(&self, id: &SessionId, data: SessionData) -> Result<(), SessionError>; + async fn delete(&self, id: &SessionId) -> Result<(), SessionError>; +} +``` + +### SessionId + +An opaque, cryptographically random token. Not a UUID - use 32 bytes of +`rand::OsRng` encoded as base64url. This is the only thing the browser sees. + +### SessionData + +```rust +pub struct SessionData { + pub access_token: String, + pub refresh_token: String, + pub access_expires_at: chrono::DateTime, // computed from expires_in_seconds + pub user: Option, // cached to avoid repeated get_user calls + pub created_at: chrono::DateTime, + pub last_seen_at: chrono::DateTime, +} + +pub struct CachedUser { + pub user_id: String, + pub username: String, + pub emails: Vec, +} +``` + +### In-Memory Store (Phase 1) + +`HashMap` behind a `RwLock`. Good enough for single-instance +deployment. A background task reaps expired sessions periodically. + +This is sufficient for now. When forage needs horizontal scaling, swap to a +Redis or PostgreSQL-backed store behind the same trait. + +### Cookie + +Single cookie: `forage_session` +- Value: the opaque SessionId (base64url, ~43 chars) +- HttpOnly: yes +- Secure: yes (always - even if we need to configure for local dev) +- SameSite: Lax +- Path: / +- Max-Age: 30 days (the session lifetime, not the access token lifetime) + +The previous `forage_access` and `forage_refresh` cookies are removed entirely. + +## Behavioral Contract + +### Login / Register Flow + +1. User submits login/signup form +2. Forage calls forest-server's Login/Register RPC, gets AuthTokens +3. Forage computes `access_expires_at = now + expires_in_seconds` +4. Forage calls `get_user` to populate the user cache +5. Forage creates a session in the store with tokens + user cache +6. Forage sets `forage_session` cookie with the session ID +7. Redirect to /dashboard + +### Authenticated Request Flow + +1. Extract `forage_session` cookie +2. Look up session in store +3. If no session: redirect to /login +4. If `access_expires_at` is in the future (with margin): use cached access_token +5. If access_token is expired or near-expiry (< 60s remaining): + a. Call forest-server's RefreshToken RPC with the stored refresh_token + b. On success: update session with new tokens + new expiry + c. On failure (refresh_token also expired): delete session, redirect to /login +6. Return session to the route handler (which has access_token + cached user) + +### Logout Flow + +1. Extract session ID from cookie +2. Get refresh_token from session store +3. Call forest-server's Logout RPC (best-effort) +4. Delete session from store +5. Clear the `forage_session` cookie +6. Redirect to / + +### Session Expiry + +- Sessions expire after 30 days of inactivity (configurable) +- `last_seen_at` is updated on each request +- A background reaper runs every 5 minutes, removes sessions where + `last_seen_at + 30 days < now` +- If the refresh_token is rejected by forest-server, the session is + destroyed immediately regardless of age + +## Changes to Existing Code + +### What Gets Replaced + +- `auth.rs`: `MaybeAuth` and `RequireAuth` extractors rewritten to use session store +- `auth.rs`: `auth_cookies()` and `clear_cookies()` replaced with session cookie helpers +- `routes/auth.rs`: Login/signup handlers create sessions instead of setting token cookies +- `routes/auth.rs`: Logout handler destroys session +- `routes/auth.rs`: Dashboard and token pages use `session.user` cache instead of calling `get_user()` every time + +### What Stays the Same + +- `ForestAuth` trait and `GrpcForestClient` - unchanged, still the interface to forest-server +- Validation functions in `forage-core` - unchanged +- Templates - unchanged (they receive the same data) +- Route structure and URLs - unchanged +- All existing tests continue to pass (mock gets wrapped in mock session store) + +### New Dependencies + +- `rand` (workspace): for cryptographic session ID generation +- No new external session framework - the store is simple enough to own + +### AppState Changes + +```rust +pub struct AppState { + pub templates: TemplateEngine, + pub forest_client: Arc, + pub sessions: Arc, // NEW +} +``` + +## Extractors (New Design) + +### `Session` extractor (replaces `RequireAuth`) + +Extracts the session, handles refresh transparently, provides both the +access_token (for forest-server calls that aren't cached) and cached user info. + +```rust +pub struct Session { + pub session_id: SessionId, + pub access_token: String, + pub user: CachedUser, +} +``` + +The extractor: +1. Reads cookie +2. Looks up session +3. Refreshes token if needed (updating the store) +4. Returns `Session` or redirects to /login + +Because refresh updates the session store (not the cookie), no response +headers need to be set during extraction. The cookie stays the same. + +### `MaybeSession` extractor (replaces `MaybeAuth`) + +Same as `Session` but returns `Option` instead of redirecting. +Used for pages like /signup and /login that behave differently when +already authenticated. + +## Edge Cases + +- **Concurrent requests during refresh**: Two requests arrive with the same + expired access_token. Both try to refresh. The session store update is + behind a RwLock, so the second one will see the already-refreshed token. + Alternatively, use a per-session Mutex for refresh operations to avoid + double-refresh. Start simple (accept occasional double-refresh), optimize + if it becomes a problem. + +- **Session ID collision**: 32 bytes of crypto-random = 256 bits of entropy. + Collision probability is negligible. + +- **Store grows unbounded**: The reaper task handles this. For in-memory store, + also enforce a max session count (e.g., 100k) as a safety valve. + +- **Server restart loses all sessions**: Yes. In-memory store is not durable. + All users will need to re-login after a deploy. This is acceptable for now + and is the primary motivation for eventually moving to Redis/Postgres. + +- **Cookie without valid session**: Treat as unauthenticated. Clear the stale + cookie. + +- **Forest-server down during refresh**: Keep the existing session alive with + the expired access_token. The next forest-server call will fail, and the + route handler deals with it (same as today). Don't destroy the session just + because refresh failed due to network - only destroy it if forest-server + explicitly rejects the refresh token. + +## Test Strategy + +### Unit Tests (forage-core) + +- `SessionId` generation: length, format, uniqueness (generate 1000, assert no dupes) +- `SessionData` expiry logic: `is_access_expired()`, `needs_refresh()` (with margin) +- `InMemorySessionStore`: create/get/update/delete round-trip +- `InMemorySessionStore`: get non-existent returns None +- `InMemorySessionStore`: delete then get returns None + +### Integration Tests (forage-server) + +All existing tests must continue passing. Additionally: + +- Login creates a session and sets `forage_session` cookie (not `forage_access`) +- Dashboard with valid session cookie returns 200 with user content +- Dashboard with expired access_token (but valid refresh) still returns 200 + (transparent refresh) +- Dashboard with expired session redirects to /login +- Logout destroys session and clears cookie +- Signup creates session same as login +- Old `forage_access` / `forage_refresh` cookies are ignored (clean break) + +### Mock Session Store + +For tests, use `InMemorySessionStore` directly (it's already simple). The mock +`ForestClient` stays as-is for controlling gRPC behavior. + +## Implementation Order + +1. Add `SessionId`, `SessionData`, `SessionStore` trait, `InMemorySessionStore` to `forage-core` +2. Add unit tests for session types and in-memory store +3. Add `rand` dependency, implement `SessionId::generate()` +4. Rewrite `auth.rs` extractors to use session store +5. Rewrite route handlers to use new extractors +6. Update `AppState` to include session store +7. Update `main.rs` to create the in-memory store +8. Update integration tests +9. Add session reaper background task +10. Remove old cookie helpers and constants + +## What This Does NOT Do + +- No Redis/Postgres session store yet (in-memory only) +- No "active sessions" UI for users +- No CSRF protection (SameSite=Lax is sufficient for form POSTs from same origin) +- No session fixation protection beyond generating new IDs on login +- No rate limiting on session creation (defer to forest-server's rate limiting) + +## Open Questions + +1. Should we invalidate all sessions for a user when they change their password? + (Requires either forest-server notification or polling.) +2. Session cookie name: `forage_session` or `__Host-forage_session`? + (`__Host-` prefix forces Secure + no Domain + Path=/, which is stricter.) +3. Should the user cache have a separate TTL (e.g., refresh user info every 5 min)? + Or only refresh on explicit actions like "edit profile"? diff --git a/specs/features/004-projects-and-usage.md b/specs/features/004-projects-and-usage.md new file mode 100644 index 0000000..82e14cb --- /dev/null +++ b/specs/features/004-projects-and-usage.md @@ -0,0 +1,187 @@ +# 004 - Projects View & Usage/Pricing + +**Status**: Phase 1 - Spec +**Depends on**: 003 (BFF Sessions) + +## Problem + +The dashboard currently shows placeholder text ("No projects yet"). Authenticated users need to: + +1. See their organisations and projects (pulled from forest-server via gRPC) +2. Understand their current usage and plan limits +3. Navigate between organisations and their projects + +The pricing page exists but is disconnected from the authenticated experience - there's no "your current plan" or usage visibility. + +## Scope + +This spec covers: +- **Projects view**: List organisations -> projects for the authenticated user +- **Usage view**: Show current plan, resource usage, and upgrade path +- **gRPC integration**: Add OrganisationService and ReleaseService clients +- **Navigation**: Authenticated sidebar/nav with org switcher + +Out of scope (future specs): +- Creating organisations or projects from the UI (CLI-first) +- Billing/Stripe integration +- Deployment management (viewing releases, logs) + +## Architecture + +### New gRPC Services + +We need to generate stubs for and integrate: +- `OrganisationService.ListMyOrganisations` - get orgs the user belongs to +- `ReleaseService.GetProjects` - get projects within an org +- `ReleaseService.GetArtifactsByProject` - get recent releases for a project + +These require copying `organisations.proto` and `releases.proto` into `interface/proto/forest/v1/` and regenerating with buf. + +### New Trait: `ForestPlatform` + +Separate from `ForestAuth` (which handles identity), this trait handles platform data: + +```rust +#[async_trait] +pub trait ForestPlatform: Send + Sync { + async fn list_my_organisations( + &self, + access_token: &str, + ) -> Result, PlatformError>; + + async fn list_projects( + &self, + access_token: &str, + organisation: &str, + ) -> Result, PlatformError>; + + async fn list_artifacts( + &self, + access_token: &str, + organisation: &str, + project: &str, + ) -> Result, PlatformError>; +} +``` + +### Domain Types (forage-core) + +```rust +// forage-core::platform + +pub struct Organisation { + pub organisation_id: String, + pub name: String, + pub role: String, // user's role in this org +} + +pub struct Artifact { + pub artifact_id: String, + pub slug: String, + pub context: ArtifactContext, + pub created_at: String, +} + +pub struct ArtifactContext { + pub title: String, + pub description: Option, +} + +#[derive(thiserror::Error)] +pub enum PlatformError { + #[error("not authenticated")] + NotAuthenticated, + #[error("not found: {0}")] + NotFound(String), + #[error("service unavailable: {0}")] + Unavailable(String), + #[error("{0}")] + Other(String), +} +``` + +### Routes + +| Route | Auth | Description | +|-------|------|-------------| +| `GET /dashboard` | Required | Redirect to first org's projects, or onboarding if no orgs | +| `GET /orgs/{org}/projects` | Required | List projects for an organisation | +| `GET /orgs/{org}/projects/{project}` | Required | Project detail: recent artifacts/releases | +| `GET /orgs/{org}/usage` | Required | Usage & plan info for the organisation | + +### Templates + +- `pages/projects.html.jinja` - Project list within an org +- `pages/project_detail.html.jinja` - Single project with recent artifacts +- `pages/usage.html.jinja` - Usage dashboard with plan info +- `components/app_nav.html.jinja` - Authenticated navigation with org switcher + +### Authenticated Navigation + +When logged in, replace the marketing nav with an app nav: +- Left: forage logo, org switcher dropdown +- Center: Projects, Usage links (scoped to current org) +- Right: user menu (settings, tokens, sign out) + +The base template needs to support both modes: marketing (unauthenticated) and app (authenticated). + +## Behavioral Contract + +### Dashboard redirect +- Authenticated user with orgs -> redirect to `/orgs/{first_org}/projects` +- Authenticated user with no orgs -> show onboarding: "Create your first organisation with the forest CLI" +- Unauthenticated -> redirect to `/login` (existing behavior) + +### Projects list +- Shows all projects in the organisation +- Each project shows: name, latest artifact slug, last deploy time +- Empty state: "No projects yet. Deploy with `forest release create`" +- User must be a member of the org (403 otherwise) + +### Project detail +- Shows project name, recent artifacts (last 10) +- Each artifact: slug, title, description, created_at +- Empty state: "No releases yet" + +### Usage page +- Current plan tier (hardcoded to "Early Access - Free" for now) +- Resource summary (placeholder - no real metering yet) +- "Upgrade" CTA pointing to pricing page +- Early access notice + +## Test Strategy + +### Unit tests (forage-core) - ~6 tests +- PlatformError display strings +- Organisation/Artifact type construction + +### Integration tests (forage-server) - ~12 tests +- Dashboard redirect: authenticated with orgs -> redirect to first org +- Dashboard redirect: authenticated no orgs -> onboarding page +- Projects list: returns 200 with projects +- Projects list: empty org shows empty state +- Projects list: unauthenticated -> redirect to login +- Project detail: returns 200 with artifacts +- Project detail: unknown project -> 404 +- Usage page: returns 200 with plan info +- Usage page: unauthenticated -> redirect to login +- Forest-server unavailable -> error page +- Org switcher: nav shows user's organisations +- Non-member org access -> 403 + +## Implementation Order + +1. Copy protos, regenerate stubs (buf generate) +2. Add domain types and `ForestPlatform` trait to forage-core +3. Write failing tests (Red) +4. Implement `GrpcForestPlatform` in forage-server +5. Add `MockForestPlatform` to tests +6. Implement routes and templates (Green) +7. Update dashboard redirect logic +8. Add authenticated nav component +9. Clippy + review (Phase 3) + +## Open Questions + +- Should org switcher persist selection in session or always default to first org? +- Do we want a `/orgs/{org}/settings` page in this spec or defer? diff --git a/specs/reviews/001-adversarial-review-phase2.md b/specs/reviews/001-adversarial-review-phase2.md new file mode 100644 index 0000000..f47060a --- /dev/null +++ b/specs/reviews/001-adversarial-review-phase2.md @@ -0,0 +1,281 @@ +# Adversarial Review: Forage Client (Post Phase 2) + +## Date: 2026-03-07 +## Scope: Full project review - architecture, workflow, business, security + +--- + +## 1. Forage Needs a Clear Ownership Boundary + +Forage is entirely dependent on forest-server for its core functionality. Every +route either renders static marketing content or proxies to forest-server. + +What does forage own today? +- Auth? No, forest-server owns it. +- User data? No, forest-server owns it. +- Component registry? Future, and forest-server will own that too. +- Deployment logic? Future, likely another backend service. + +This isn't wrong - forage is the web product layer on top of forest's API layer. +But this intent isn't crystallized anywhere. The PITCH.md lists a huge roadmap +(deployments, managed services, billing) without clarifying what lives in forage +vs. what lives in forest-server or new backend services. + +**Why this matters**: Every architectural decision (session management, crate +boundaries, database usage) depends on what forage will own. Without this +boundary, we risk either building too much (duplicating forest-server) or too +little (being a dumb proxy forever). + +**Action**: Write a clear architecture doc or update PITCH.md with an explicit +"forage owns X, forest-server owns Y, future services own Z" section. At +minimum, forage will own: web sessions, billing/subscription state, org-level +configuration, and the web UI itself. Forest-server owns: users, auth tokens, +components, deployments. + +Comment: +Forage in the future is going to have many services that forest is going to be relying on, hence the brand, and site. Also forest-client might be fine as a UI for forest itself, but a flutter app isn't that great at web apps, and we need something native for SEO and the likes. +We could adopt the forest-client as the dashboard tbd. Forage is the business entity of forest. + +--- + +## 2. The Crate Structure Is Premature + +Five crates today: +- **forage-db**: 3 lines. Re-exports `PgPool`. No queries, no migrations. +- **forage-core**: ~110 lines. A trait, types, 3 validation functions. +- **forage-grpc**: Generated code wrapper. Justified. +- **forage-server**: The actual application. All real logic lives here. +- **ci**: Separate build tool. Justified. + +The "pure core / effectful shell" split sounds principled, but `forage-core` +is mostly type definitions. The `ForestAuth` trait is defined in core but +implemented in server. The "pure" validation is 3 functions totaling ~50 lines. + +**forage-db is dead weight.** There are no database queries, no migrations, no +schema. It exists because CLAUDE.md says there should be a db crate. Either +remove it or explicitly acknowledge it as a placeholder for future forage-owned +state (sessions, billing, org config). + +**Action**: Either consolidate to 3 crates (server, grpc, ci) until there's +a real consumer for the core/db split, or commit to what forage-core and +forage-db will contain (tied to decision #1). Premature crate boundaries add +compile time and cognitive overhead without benefit. + +Comment +Lets keep the split for now, we're gonna fill it out shortly + +--- + +## 3. Token Refresh Is Specified But Not Implemented + +The spec says: +> If access_token expired but refresh_token valid: auto-refresh, set new cookies + +Reality: `RequireAuth` checks if a cookie exists. It doesn't validate the +token, check expiry, or attempt refresh. When the access_token expires, +`get_user()` fails and the user gets redirected to login - losing their +session even though the refresh_token is valid. + +Depending on forest-server's token lifetime configuration (could be 15 min to +1 hour), users will get randomly logged out. This is the single most impactful +missing feature. + +**Action**: Implement BFF sessions (spec 003) which solves this by moving +tokens server-side and handling refresh transparently. + +--- + +## 4. The get_user Double-Call Pattern + +Every authenticated page does: +1. `get_user(access_token)` which internally calls `token_info` then `get_user` + (2 gRPC calls in `forest_client.rs:161-192`) +2. Then page-specific calls (e.g., `list_tokens` - another gRPC call) + +That's 3 gRPC round-trips per page load. For server-rendered pages where +latency = perceived performance, this matters. + +The `get_user` implementation calls `token_info` to get the `user_id`, then +`get_user` with that ID. This should be a single call. + +**Action**: Short-term, BFF sessions with user caching (spec 003) eliminates +repeated get_user calls. Long-term, consider pushing for a "get current user" +endpoint in forest-server that combines token_info + get_user. + +We should be able to store most of this in the session, with a jwt etc. That should be fine for now + +--- + +## 5. Cookie Security Gap + +`auth_cookies()` sets `HttpOnly` and `SameSite=Lax` but does NOT set `Secure`. + +The spec explicitly requires: +> forage_access cookie: access_token, HttpOnly, **Secure**, SameSite=Lax + +Without `Secure`, cookies are sent over plain HTTP. Access tokens can be +intercepted on any non-HTTPS connection. + +**Action**: Fix immediately regardless of whether BFF sessions are implemented. +If BFF sessions come first, ensure the session cookie sets `Secure`. + +--- + +## 6. The Mock Is Too Friendly + +`MockForestClient` always succeeds (except one login check). Tests prove: +- Templates render without errors +- Redirects go to the right places +- Cookies get set + +Tests do NOT prove: +- Error handling for real error scenarios (only one bad-credentials test) +- What happens when `get_user` fails mid-flow (token expired between pages) +- What happens when `create_token` or `delete_token` fails +- What happens when forest-server returns unexpected/partial data +- Behavior under concurrent requests + +**Action**: Make the mock configurable per-test. A builder pattern or +`Arc>` would let tests control which calls succeed/fail. +Add error-path tests for every route, not just login. + +--- + +## 7. Navigation Links to Nowhere + +`base.html.jinja` links to: `/docs`, `/components`, `/about`, `/blog`, +`/privacy`, `/terms`, `/docs/deployments`, `/docs/registry`, `/docs/services`. + +None exist. They all 404. + +This isn't a code quality issue - it's a user experience issue for anyone +visiting the site. Every page has a nav and footer full of dead links. + +**Action**: Either remove links to unbuilt pages, add placeholder pages with +"coming soon" content, or use a `disabled` / `cursor-not-allowed` style that +makes it clear they're not yet available. + +Comment +add a place holder and a todo, also remove the docs, we don't need that yet. also remove the blog and other stuff. Lets just stick with the main things. components and the login etc. + +--- + +## 8. VSDD Methodology vs. Reality + +VSDD.md describes 6 phases: spec crystallization, test-first implementation, +adversarial refinement, feedback integration, formal hardening (fuzz testing, +mutation testing, static analysis), and convergence. + +In practice: +- Phase 1 (specs): Done well +- Phase 2 (TDD-ish): Tests written, but not strictly red-green-refactor +- Phase 3 (adversarial): This review +- Phases 4-6: Not done + +The full pipeline includes fuzz testing, mutation testing, and property-based +tests. None of these exist. The convergence criterion ("adversary must +hallucinate flaws") is unrealistic - real code always has real improvements. + +This isn't a problem if VSDD is treated as aspirational guidance rather than +a strict process. But if the methodology doc says one thing and practice does +another, the doc loses authority. + +**Action**: Either trim VSDD.md to match what's actually practiced (spec -> +test -> implement -> review -> iterate), or commit to running the full pipeline +on at least one feature to validate whether the overhead is worth it. + +Comment: Write in claude.md that we need to follow the process religiously + +--- + +## 9. The Pricing Page Sells Vapor + +The pricing page lists managed deployments, container runtimes, PostgreSQL +provisioning, private registries. None of this exists. The roadmap has 4 +phases before any of it works. + +The landing page has a "Get started for free" CTA leading to `/signup`, which +creates an account on forest-server. After signup, the dashboard is empty - +there's nothing to do. No components to browse, no deployments to create. + +If this site goes live as-is, you're either: +- Collecting signups for a waitlist (fine, but say so explicitly) +- Implying a product exists that doesn't (bad) + +**Action**: Add "early access" / "waitlist" framing. The dashboard should +explain what's coming and what the user can do today (manage tokens, explore +the registry when it exists). The pricing page should indicate which features +are available vs. planned. + +Comment: Only add container deployments for now, add the other things as tbd, forget postgresql for now + +--- + +## 10. Tailwind CSS Not Wired Up + +Templates use Tailwind classes (`bg-white`, `text-gray-900`, `max-w-6xl`, etc.) +throughout, but the CSS is loaded from `/static/css/style.css`. If this file +doesn't contain compiled Tailwind output, none of the styling works and the +site is unstyled HTML. + +`mise.toml` has `tailwind:build` and `tailwind:watch` tasks, but it's unclear +if these have been run or if the output is committed. + +**Action**: Verify the Tailwind pipeline works end-to-end. Either commit the +compiled CSS or ensure CI builds it. An unstyled site is worse than no site. + +--- + +## 11. forage-server Isn't Horizontally Scalable + +With in-memory session state (post BFF sessions), raw token cookies (today), +and no shared state layer, forage-server is a single-instance application. +That's fine for now, but it constrains deployment options. + +This isn't urgent - single-instance Rust serving SSR pages can handle +significant traffic. But it should be a conscious decision, not an accident. + +**Action**: Document this constraint. When horizontal scaling becomes needed, +the session store trait makes it straightforward to swap to Redis/Postgres. + +Comment: Set up postgresql like we do in forest and so forth + +--- + +## Summary: Prioritized Actions + +### Must Do (before any deployment) +1. **Fix cookie Secure flag** - real security gap +2. **Implement BFF sessions** (spec 003) - fixes token refresh, caching, security +3. **Remove dead nav links** or add placeholders - broken UX + +### Should Do (before public launch) +4. **Add "early access" framing** to pricing/dashboard - honesty about product state +5. **Verify Tailwind pipeline** - unstyled site is unusable +6. **Improve test mock** - configurable per-test, error path coverage + +### Do When Relevant +7. **Define ownership boundary** (forage vs. forest-server) - shapes all future work +8. **Simplify crate structure** or justify it with concrete plans +9. **Align VSDD doc with practice** - keep methodology honest +10. **Plan for horizontal scaling** - document the constraint, prepare the escape hatch + +--- + +## What's Good + +To be fair, the project has strong foundations: + +- **Architecture is sound.** Thin frontend proxying to forest-server is the + right call. Trait-based abstraction for testability is clean. +- **Spec-first approach works.** Specs are clear, implementation matches them, + tests verify the contract. +- **Tech choices are appropriate.** Axum + MiniJinja for SSR is fast, simple, + and right-sized. No over-engineering with SPAs or heavy frameworks. +- **Cookie-based auth proxy is correct** for this kind of frontend (once moved + to BFF sessions). +- **CI mirrors forest's patterns** - good for consistency across the ecosystem. +- **ForestAuth trait** makes testing painless and the gRPC boundary clean. +- **The gRPC client** is well-structured with proper error mapping. + +The issues are about what's missing, not what's wrong with what exists. diff --git a/specs/reviews/002-adversarial-review-phase3.md b/specs/reviews/002-adversarial-review-phase3.md new file mode 100644 index 0000000..307588c --- /dev/null +++ b/specs/reviews/002-adversarial-review-phase3.md @@ -0,0 +1,176 @@ +# Adversarial Review 002 - Post Spec 004 (Projects & Usage) + +**Date**: 2026-03-07 +**Scope**: Full codebase review after specs 001-004 +**Tests**: 53 total (17 core + 36 server), clippy clean +**Verified**: Against real forest-server on localhost:4040 + +--- + +## 1. Architecture: Repeated gRPC Calls Per Page Load + +**Severity: High** + +Every authenticated platform page (`projects_list`, `project_detail`, `usage`) calls `list_my_organisations` to verify membership. This means: + +- `/orgs/testorg/projects` -> 1 call to list orgs + 1 call to list projects = **2 gRPC calls** +- `/orgs/testorg/projects/my-api` -> 1 call to list orgs + 1 call to list artifacts = **2 gRPC calls** +- Dashboard -> 1 call to list orgs (redirect) then the target page makes its own calls + +This is the same pattern we fixed for `get_user()` in spec 003 (caching user in session). The org list should be cached in the session too, or at minimum passed through from the `Session` extractor. + +**Recommendation**: Cache the user's org memberships in `SessionData` / `CachedUser`. Refresh on session refresh or after a configurable TTL. This eliminates the most expensive repeated call. + +--- + +## 2. Architecture: Two Traits, One Struct, Inconsistent Error Handling + +**Severity: Medium** + +`GrpcForestClient` now implements both `ForestAuth` and `ForestPlatform`. The `authed_request` helper is duplicated: +- `GrpcForestClient::authed_request()` returns `AuthError` +- `platform_authed_request()` is a free function returning `PlatformError` + +Same logic, two copies, two error types. `AppState` holds `Arc` + `Arc` which in production point to the same struct. This is fine for testability but means the constructors are getting wide (4 args now). + +**Recommendation**: Consider a single `ForestClient` trait that combines both, or unify the auth helper into a generic form. Not urgent but will become pain as more services are added. + +--- + +## 3. Security: Org Name in URL Path is User-Controlled + +**Severity: Medium** + +Routes use `{org}` from the URL path and pass it directly to gRPC calls and template rendering: +- `format!("{org} - Projects - Forage")` in HTML title +- `format!("Projects in {org}")` in meta description + +MiniJinja auto-escapes by default in HTML context, so XSS via `