From b439762877da133f6b773d1c89d25decb5b4d326 Mon Sep 17 00:00:00 2001 From: kjuulh Date: Sat, 7 Mar 2026 19:46:13 +0100 Subject: [PATCH] feat: add basic website Signed-off-by: kjuulh --- .../memory/MEMORY.md | 41 + .env.example | 8 + .gitignore | 7 + CLAUDE.md | 92 + Cargo.lock | 3887 +++++++++++++++++ Cargo.toml | 32 + buf.gen.yaml | 9 + buf.yaml | 3 + ci/Cargo.toml | 15 + ci/src/main.rs | 325 ++ crates/forage-core/Cargo.toml | 16 + crates/forage-core/src/auth/mod.rs | 112 + crates/forage-core/src/auth/validation.rs | 120 + crates/forage-core/src/billing/mod.rs | 1 + crates/forage-core/src/deployments/mod.rs | 1 + crates/forage-core/src/lib.rs | 6 + crates/forage-core/src/platform/mod.rs | 101 + crates/forage-core/src/registry/mod.rs | 1 + crates/forage-core/src/session/mod.rs | 260 ++ crates/forage-core/src/session/store.rs | 66 + crates/forage-db/Cargo.toml | 15 + crates/forage-db/src/lib.rs | 9 + .../20260307000001_create_sessions.sql | 13 + .../20260307000002_add_csrf_token.sql | 1 + crates/forage-db/src/sessions.rs | 163 + crates/forage-grpc/Cargo.toml | 15 + .../src/grpc/forest/v1/forest.v1.rs | 821 ++++ .../src/grpc/forest/v1/forest.v1.tonic.rs | 3579 +++++++++++++++ crates/forage-grpc/src/lib.rs | 6 + crates/forage-server/Cargo.toml | 25 + crates/forage-server/src/auth.rs | 164 + crates/forage-server/src/forest_client.rs | 497 +++ crates/forage-server/src/main.rs | 1339 ++++++ crates/forage-server/src/routes/auth.rs | 500 +++ crates/forage-server/src/routes/mod.rs | 35 + crates/forage-server/src/routes/pages.rs | 59 + crates/forage-server/src/routes/platform.rs | 166 + crates/forage-server/src/state.rs | 30 + crates/forage-server/src/templates.rs | 35 + forest.cue | 18 + interface/proto/forest/v1/organisations.proto | 104 + interface/proto/forest/v1/releases.proto | 151 + interface/proto/forest/v1/users.proto | 317 ++ mise.toml | 109 + package-lock.json | 1055 +++++ package.json | 16 + specs/PITCH.md | 212 + specs/VSDD.md | 111 + specs/features/001-landing-page.md | 45 + specs/features/002-authentication.md | 102 + specs/features/003-bff-sessions.md | 286 ++ specs/features/004-projects-and-usage.md | 187 + .../reviews/001-adversarial-review-phase2.md | 281 ++ .../reviews/002-adversarial-review-phase3.md | 176 + static/css/input.css | 1 + static/css/style.css | 2 + templates/base.html.jinja | 91 + templates/docker-compose.yaml | 19 + templates/forage-server.Dockerfile | 57 + templates/pages/components.html.jinja | 28 + templates/pages/dashboard.html.jinja | 57 + templates/pages/error.html.jinja | 10 + templates/pages/landing.html.jinja | 104 + templates/pages/login.html.jinja | 48 + templates/pages/onboarding.html.jinja | 26 + templates/pages/pricing.html.jinja | 123 + templates/pages/project_detail.html.jinja | 40 + templates/pages/projects.html.jinja | 24 + templates/pages/signup.html.jinja | 75 + templates/pages/tokens.html.jinja | 71 + templates/pages/usage.html.jinja | 55 + 71 files changed, 16576 insertions(+) create mode 100644 .claude/projects/-home-kjuulh-git-git-kjuulh-io-forage-client/memory/MEMORY.md create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 buf.gen.yaml create mode 100644 buf.yaml create mode 100644 ci/Cargo.toml create mode 100644 ci/src/main.rs create mode 100644 crates/forage-core/Cargo.toml create mode 100644 crates/forage-core/src/auth/mod.rs create mode 100644 crates/forage-core/src/auth/validation.rs create mode 100644 crates/forage-core/src/billing/mod.rs create mode 100644 crates/forage-core/src/deployments/mod.rs create mode 100644 crates/forage-core/src/lib.rs create mode 100644 crates/forage-core/src/platform/mod.rs create mode 100644 crates/forage-core/src/registry/mod.rs create mode 100644 crates/forage-core/src/session/mod.rs create mode 100644 crates/forage-core/src/session/store.rs create mode 100644 crates/forage-db/Cargo.toml create mode 100644 crates/forage-db/src/lib.rs create mode 100644 crates/forage-db/src/migrations/20260307000001_create_sessions.sql create mode 100644 crates/forage-db/src/migrations/20260307000002_add_csrf_token.sql create mode 100644 crates/forage-db/src/sessions.rs create mode 100644 crates/forage-grpc/Cargo.toml create mode 100644 crates/forage-grpc/src/grpc/forest/v1/forest.v1.rs create mode 100644 crates/forage-grpc/src/grpc/forest/v1/forest.v1.tonic.rs create mode 100644 crates/forage-grpc/src/lib.rs create mode 100644 crates/forage-server/Cargo.toml create mode 100644 crates/forage-server/src/auth.rs create mode 100644 crates/forage-server/src/forest_client.rs create mode 100644 crates/forage-server/src/main.rs create mode 100644 crates/forage-server/src/routes/auth.rs create mode 100644 crates/forage-server/src/routes/mod.rs create mode 100644 crates/forage-server/src/routes/pages.rs create mode 100644 crates/forage-server/src/routes/platform.rs create mode 100644 crates/forage-server/src/state.rs create mode 100644 crates/forage-server/src/templates.rs create mode 100644 forest.cue create mode 100644 interface/proto/forest/v1/organisations.proto create mode 100644 interface/proto/forest/v1/releases.proto create mode 100644 interface/proto/forest/v1/users.proto create mode 100644 mise.toml create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 specs/PITCH.md create mode 100644 specs/VSDD.md create mode 100644 specs/features/001-landing-page.md create mode 100644 specs/features/002-authentication.md create mode 100644 specs/features/003-bff-sessions.md create mode 100644 specs/features/004-projects-and-usage.md create mode 100644 specs/reviews/001-adversarial-review-phase2.md create mode 100644 specs/reviews/002-adversarial-review-phase3.md create mode 100644 static/css/input.css create mode 100644 static/css/style.css create mode 100644 templates/base.html.jinja create mode 100644 templates/docker-compose.yaml create mode 100644 templates/forage-server.Dockerfile create mode 100644 templates/pages/components.html.jinja create mode 100644 templates/pages/dashboard.html.jinja create mode 100644 templates/pages/error.html.jinja create mode 100644 templates/pages/landing.html.jinja create mode 100644 templates/pages/login.html.jinja create mode 100644 templates/pages/onboarding.html.jinja create mode 100644 templates/pages/pricing.html.jinja create mode 100644 templates/pages/project_detail.html.jinja create mode 100644 templates/pages/projects.html.jinja create mode 100644 templates/pages/signup.html.jinja create mode 100644 templates/pages/tokens.html.jinja create mode 100644 templates/pages/usage.html.jinja 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 `