diff --git a/PROJECTS/bug-bounty-platform/.env.example b/PROJECTS/bug-bounty-platform/.env.example new file mode 100644 index 00000000..45170193 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/.env.example @@ -0,0 +1,109 @@ +# ============================================================================= +# AngelaMos | 2026 +# .env.example +# ============================================================================= +# Copy this file to .env and update values for your environment +# ============================================================================= + +# ============================================================================= +# HOST PORTS (change these to avoid conflicts between projects) +# ============================================================================= +NGINX_HOST_PORT=8420 +BACKEND_HOST_PORT=5420 +FRONTEND_HOST_PORT=3420 +POSTGRES_HOST_PORT=4420 +REDIS_HOST_PORT=6420 + +# ============================================================================= +# Application +# ============================================================================= +APP_NAME=FullStack-Template +ENVIRONMENT=development +DEBUG=true +LOG_LEVEL=INFO +LOG_JSON_FORMAT=false + +# ============================================================================= +# Server (internal container settings) +# ============================================================================= +HOST=0.0.0.0 +PORT=8000 +RELOAD=true + +# ============================================================================= +# PostgreSQL +# ============================================================================= +POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres +POSTGRES_DB=app_db +POSTGRES_HOST=db +POSTGRES_CONTAINER_PORT=5432 + +DATABASE_URL=postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_CONTAINER_PORT}/${POSTGRES_DB} + +DB_POOL_SIZE=20 +DB_MAX_OVERFLOW=10 +DB_POOL_TIMEOUT=30 +DB_POOL_RECYCLE=1800 + +# ============================================================================= +# Redis +# ============================================================================= +REDIS_HOST=redis +REDIS_CONTAINER_PORT=6379 +REDIS_PASSWORD= + +REDIS_URL=redis://${REDIS_HOST}:${REDIS_CONTAINER_PORT} + +# ============================================================================= +# Security / JWT +# ============================================================================= +SECRET_KEY=dev-only-change-this-in-production-minimum-32-characters-long + +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# ============================================================================= +# Admin Bootstrap (optional) +# ============================================================================= +# If set, registering with this email auto-promotes to admin role. +# Leave empty or remove to disable auto-admin (all users register as USER). +ADMIN_EMAIL= + +# ============================================================================= +# CORS (must match HOST PORTS above!) +# ============================================================================= +# Format: http://localhost:,http://localhost: +# Update these if you change the host ports above + +CORS_ORIGINS=http://localhost,http://localhost:8420,http://localhost:3420 + +# ============================================================================= +# Rate Limiting +# ============================================================================= +RATE_LIMIT_DEFAULT=100/minute +RATE_LIMIT_AUTH=20/minute + +# ============================================================================= +# Frontend (Vite) +# ============================================================================= +VITE_API_URL=/api +VITE_API_TARGET=http://localhost:8000 +VITE_APP_TITLE=My App + +# ============================================================================= +# Cloudflare Tunnel (Production only - or use whatever deployment method you want) +# ============================================================================= +# Option 1: Cloudflare Tunnel (zero config, no port forwarding needed) +# - Get token from Cloudflare Zero Trust dashboard +# - Access > Tunnels > Create a tunnel > Name it > Copy token below +# - Configure public hostname: yourdomain.com -> http://nginx:80 +# +# Option 2: Traditional hosting (VM + domain + DNS) +# - Host on a VM, buy a domain, create A/CNAME record in Cloudflare DNS +# - Point to your VM's IP, expose nginx port, configure reverse proxy/SSL +# +# Option 3: Whatever works for you +# - Just make sure your nginx container is accessible and SSL is configured +CLOUDFLARE_TUNNEL_TOKEN= diff --git a/PROJECTS/bug-bounty-platform/.gitignore b/PROJECTS/bug-bounty-platform/.gitignore new file mode 100644 index 00000000..0c265239 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/.gitignore @@ -0,0 +1,39 @@ +# AngelaMos | 2025 +# dev.compose.yml + +venv +*.venv +*.env +*.cache +*.egg + +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +.pytest_cache/ +.coverage +htmlcov/ +.tox/ +.mypy_cache/ +.dmypy.json +dmypy.json + +.DS_Store + diff --git a/PROJECTS/bug-bounty-platform/.pre-commit-config.yaml b/PROJECTS/bug-bounty-platform/.pre-commit-config.yaml new file mode 100644 index 00000000..3f9cb2ca --- /dev/null +++ b/PROJECTS/bug-bounty-platform/.pre-commit-config.yaml @@ -0,0 +1,36 @@ +# .pre-commit-config.yaml + +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.14.8 + hooks: + - id: ruff + args: ["backend/"] + always_run: true + + - repo: local + hooks: + - id: mypy-check + name: MyPy Type Checking + entry: bash -c 'cd backend && mypy .' + language: system + types: [python] + pass_filenames: false + always_run: true + + - id: pylint-check + name: PyLint Code Quality + entry: bash -c 'cd backend && pylint .' + language: system + types: [python] + pass_filenames: false + always_run: true + + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-toml + - id: debug-statements + - id: end-of-file-fixer + - id: check-yaml + args: [--allow-multiple-documents] diff --git a/PROJECTS/bug-bounty-platform/LICENSE b/PROJECTS/bug-bounty-platform/LICENSE new file mode 100644 index 00000000..00afa7bb --- /dev/null +++ b/PROJECTS/bug-bounty-platform/LICENSE @@ -0,0 +1,21 @@ +MIT License + +©AngelaMos | 2026 | CarterPerez-dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PROJECTS/bug-bounty-platform/README.md b/PROJECTS/bug-bounty-platform/README.md new file mode 100644 index 00000000..07ca4eea --- /dev/null +++ b/PROJECTS/bug-bounty-platform/README.md @@ -0,0 +1,532 @@ +# Bug Bounty Platform + +A production-ready, enterprise-grade bug bounty platform built with modern web technologies. This platform enables companies to run coordinated vulnerability disclosure programs, allowing security researchers to submit findings and receive rewards. + +**Live Demo:** [bugbounty.carterperez-dev.com](https://bugbounty.carterperez-dev.com) +**API Documentation:** [bugbounty.carterperez-dev.com/api/docs](https://bugbounty.carterperez-dev.com/api/docs) + +--- + +## Overview + +This project demonstrates enterprise-level software architecture with: +- Async-first FastAPI backend with strict type safety +- Modern React frontend with TypeScript +- Production-ready security (JWT with refresh token rotation, Argon2id hashing) +- Advanced design patterns (Dependency Injection, Repository Pattern, Layered Architecture) +- Docker containerization with multi-stage builds +- Database migrations with Alembic +- Comprehensive testing and linting infrastructure + +**Part of:** [Cybersecurity-Projects Repository](https://github.com/CarterPerez-dev/Cybersecurity-Projects) (60+ security-focused projects) + +--- + +## Features + +### Security Researcher Features +- User registration and authentication +- Browse public bug bounty programs +- Submit vulnerability reports with markdown support +- Track report status and receive updates +- Earn reputation and rewards + +### Company Features +- Create and manage bug bounty programs +- Define program scope (assets, reward tiers, SLA) +- Triage incoming vulnerability reports +- Assess severity using CVSS scoring +- Award bounties to researchers +- Communicate via comments and attachments + +### Platform Features +- Role-based access control (Researcher, Company, Admin) +- JWT authentication with refresh token rotation +- Token versioning for instant session invalidation +- Multi-device session management +- Rate limiting on all endpoints +- Comprehensive audit logging +- OpenAPI/Swagger documentation + +--- + +## Tech Stack + +### Backend +- **FastAPI** 0.123.0+ - Modern async Python web framework +- **Python** 3.12+ - Strict typing with mypy +- **PostgreSQL** 18 - Primary database with asyncpg driver +- **Redis** 7 - Caching and session storage +- **SQLAlchemy** 2.0+ - Async ORM +- **Alembic** - Database migrations +- **Pydantic** v2 - Data validation and settings +- **JWT** - Token-based authentication with rotation +- **Argon2id** - Password hashing via pwdlib + +### Frontend +- **React** 19.2+ - UI library +- **TypeScript** 5.9 - Static typing +- **Vite** 7 - Build tool with Rolldown +- **React Router** 7.1 - File-based routing +- **TanStack Query** v5 - Server state management +- **Zustand** - Client state management +- **Axios** - HTTP client +- **SASS** - CSS preprocessing + +### Infrastructure +- **Docker** + **Docker Compose** - Containerization +- **Nginx** - Reverse proxy and static file serving +- **Cloudflare Tunnel** - Zero-config deployment (optional) +- **Gunicorn** + **Uvicorn** - Production ASGI server + +### Development Tools +- **Ruff** - Python linting and formatting +- **Biome** - JavaScript/TypeScript linting +- **MyPy** - Static type checking +- **Pytest** - Testing framework +- **Just** - Task runner (30+ commands) +- **Pre-commit hooks** - Automated quality checks + +--- + +## Getting Started + +You have two options: + +### Option 1: Use the Live API (Easiest) + +The platform is already deployed and running! You can: +- Use the web interface: [bugbounty.carterperez-dev.com](https://bugbounty.carterperez-dev.com) +- Access the API directly: [bugbounty.carterperez-dev.com/api/](https://bugbounty.carterperez-dev.com/api/) +- View API documentation: [bugbounty.carterperez-dev.com/api/docs](https://bugbounty.carterperez-dev.com/api/docs) + +You can build your own client application using the deployed API endpoints. See the OpenAPI documentation for available endpoints and schemas. + +### Option 2: Run It Yourself + +If you want to run the entire platform locally or deploy your own instance: + +#### Prerequisites +- [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) +- [Just](https://github.com/casey/just) (task runner) - `cargo install just` or see [installation guide](https://github.com/casey/just#installation) +- Git + +#### Quick Start + +1. **Clone the repository:** + ```bash + git clone https://github.com/CarterPerez-dev/Cybersecurity-Projects.git + cd Cybersecurity-Projects/PROJECTS/bug-bounty-platform + ``` + +2. **Configure environment variables:** + ```bash + cp .env.example .env + ``` + + Edit `.env` and update these critical values: + - `SECRET_KEY` - Generate a secure random string (minimum 32 characters) + - `POSTGRES_PASSWORD` - Set a strong database password + - `ADMIN_EMAIL` - (Optional) First user with this email becomes admin + - `CORS_ORIGINS` - Update if using different ports + +3. **Start the platform (development mode with hot reload):** + ```bash + just dev-up + ``` + + Or in production mode: + ```bash + just up + ``` + +4. **Access the platform:** + - **Frontend:** http://localhost:8420 + - **API:** http://localhost:8420/api + - **API Docs:** http://localhost:8420/api/docs + - **Backend (direct):** http://localhost:5420 + - **Frontend Dev Server:** http://localhost:3420 (dev mode only) + +5. **Apply database migrations:** + ```bash + just migrate head + ``` + +6. **Create your first account:** + - Navigate to http://localhost:8420 + - Click "Register" + - If you set `ADMIN_EMAIL` in `.env`, registering with that email grants admin privileges + +#### Common Commands + +The `justfile` provides 30+ commands for development: + +```bash +just # List all available commands + +# Development +just dev-up # Start in development mode (hot reload) +just dev-down # Stop development containers +just dev-logs backend # View backend logs +just dev-shell backend # Open shell in backend container + +# Production +just up # Start in production mode +just down # Stop production containers +just build # Build all containers +just rebuild # Rebuild without cache + +# Database +just migrate head # Apply all migrations +just migration "message" # Create new migration +just rollback # Rollback last migration +just db-current # Show current migration + +# Linting & Type Checking +just lint # Run ruff + pylint +just ruff-fix # Auto-fix linting issues +just mypy # Type check with mypy +just biome-fix # Fix frontend linting issues +just typecheck # Run all type checks + +# Testing +just test # Run all tests +just test-cov # Run tests with coverage report + +# CI +just ci # Run full CI pipeline (lint + typecheck + test) +``` + +#### Project Structure + +``` +bug-bounty-platform/ +├── backend/ # FastAPI backend (~7,000 lines) +│ ├── src/ +│ │ ├── app/ +│ │ │ ├── core/ # Base classes, database, security, constants and enums, etc. +│ │ │ ├── user/ # User domain +│ │ │ ├── auth/ # Authentication +│ │ │ ├── program/ # Bug bounty programs +│ │ │ ├── report/ # Vulnerability reports +│ │ │ └── admin/ # Admin functionality +│ │ └── config.py # configuration values +│ │ └── factory.py # essentially the 'main.py' file +│ │ └── __main__.py # Where the run command lives +│ ├── alembic/ # Database migrations +│ ├── tests/ # Unit and integration tests +│ └── pyproject.toml # Python dependencies +│ +├── frontend/ # React + TypeScript frontend +│ ├── src/ +│ │ ├── routes/ # Pages +│ │ ├── api/ # API client and hooks +│ │ ├── components/ # Reusable components +│ │ ├── styles/ # SCSS global values +│ │ └── core/ # App configuration, zustand stores (ui state management), api configuration +│ └── package.json +│ +├── infra/ # Docker and Nginx configs +│ ├── nginx/ +│ └── docker/ +│ +├── learn/ # Educational documentation (see below) +├── compose.yml # Production Docker Compose +├── dev.compose.yml # Development Docker Compose +├── justfile # Task runner commands +└── .env.example # Environment variables template +``` + +--- + +## Configuration + +### Environment Variables + +All configuration is done via `.env` file. Key variables: + +| Variable | Description | Default | +|----------|-------------|---------| +| `NGINX_HOST_PORT` | External port for Nginx | 8420 | +| `BACKEND_HOST_PORT` | External port for backend API | 5420 | +| `FRONTEND_HOST_PORT` | External port for frontend dev server | 3420 | +| `POSTGRES_HOST_PORT` | External port for PostgreSQL | 4420 | +| `REDIS_HOST_PORT` | External port for Redis | 6420 | +| `SECRET_KEY` | JWT signing key (min 32 chars) | **MUST CHANGE** | +| `POSTGRES_PASSWORD` | Database password | **MUST CHANGE** | +| `ADMIN_EMAIL` | Auto-promote this email to admin | (empty) | +| `ENVIRONMENT` | dev/staging/production | development | +| `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT access token lifetime | 15 | +| `REFRESH_TOKEN_EXPIRE_DAYS` | Refresh token lifetime | 7 | + +See `.env.example` for all available options. + +### Port Configuration + +If the default ports conflict with other services, update these in `.env`: + +```bash +NGINX_HOST_PORT=8420 # Change to any available port +BACKEND_HOST_PORT=5420 # Change to any available port +FRONTEND_HOST_PORT=3420 # Change to any available port +POSTGRES_HOST_PORT=4420 # Change to any available port +REDIS_HOST_PORT=6420 # Change to any available port + +# IMPORTANT: Update CORS_ORIGINS to match NGINX_HOST_PORT +CORS_ORIGINS=http://localhost,http://localhost:8420,http://localhost:3420 +``` + +--- + +## Deployment + +### Option 1: Cloudflare Tunnel (Recommended for beginners) + +No port forwarding or reverse proxy configuration needed! + +1. Create a Cloudflare account and add your domain +2. Go to Zero Trust Dashboard > Access > Tunnels +3. Create a new tunnel, name it, and copy the token +4. Add the token to `.env`: + ```bash + CLOUDFLARE_TUNNEL_TOKEN=your-token-here + ``` +5. Configure public hostname in Cloudflare: + - Public hostname: `yourdomain.com` + - Service: `http://nginx:80` +6. Start the platform: + ```bash + just up + ``` + +Your platform is now live at `https://yourdomain.com`! + +### Option 2: Traditional Hosting (VPS) + +1. Rent a VPS (DigitalOcean, AWS, Linode, etc.) +2. Install Docker and Docker Compose +3. Clone the repository and configure `.env` +4. Point your domain's A record to your VPS IP +5. Configure SSL (Let's Encrypt with Certbot) +6. Start the platform: + ```bash + just up + ``` + +### Option 3: Use the Existing Deployment + +Just use the API at `bugbounty.carterperez-dev.com/api/` - no deployment needed! + +--- + +## Learning Resources + +This project includes comprehensive educational documentation in the `learn/` directory: + +- **[ARCHITECTURE.md](learn/ARCHITECTURE.md)** - Deep dive into system architecture and design decisions +- **[PATTERNS.md](learn/PATTERNS.md)** - Explanation of design patterns used (DI, Repository, etc.) +- **[GETTING-STARTED.md](learn/GETTING-STARTED.md)** - Step-by-step tutorial for building similar applications +- **[DATABASE.md](learn/DATABASE.md)** - Database schema design and migration strategies +- **[SECURITY.md](learn/SECURITY.md)** - Security features and best practices explained + +These documents are designed to help you understand not just *what* the code does, but *why* it's architected this way and *how* you can apply these patterns to your own projects. + +--- + +## API Documentation + +### Interactive Documentation + +When running locally, access interactive API docs at: +- **Swagger UI:** http://localhost:8420/api/docs +- **ReDoc:** http://localhost:8420/api/redoc + +For the live deployment: +- **Swagger UI:** [bugbounty.carterperez-dev.com/api/docs](https://bugbounty.carterperez-dev.com/api/docs) + +### Key Endpoints + +**Authentication:** +- `POST /api/v1/auth/register` - Create new account +- `POST /api/v1/auth/login` - Login (returns access + refresh tokens) +- `POST /api/v1/auth/refresh` - Refresh access token +- `POST /api/v1/auth/logout` - Logout (invalidates refresh token) +- `POST /api/v1/auth/logout-all` - Logout from all devices + +**Users:** +- `GET /api/v1/users/me` - Get current user profile +- `PATCH /api/v1/users/me` - Update profile +- `GET /api/v1/users/{id}` - Get public user profile + +**Programs:** +- `GET /api/v1/programs` - List all programs (paginated) +- `GET /api/v1/programs/{slug}` - Get program details +- `POST /api/v1/programs` - Create program (company only) +- `PATCH /api/v1/programs/{slug}` - Update program (owner only) +- `DELETE /api/v1/programs/{slug}` - Delete program (owner only) + +**Reports:** +- `GET /api/v1/reports` - List your reports +- `GET /api/v1/reports/{id}` - Get report details +- `POST /api/v1/reports` - Submit vulnerability report +- `PATCH /api/v1/reports/{id}` - Update report (various endpoints for status changes) + +**Admin:** +- `GET /api/v1/admin/stats` - Platform statistics +- `GET /api/v1/admin/users` - Manage users +- `GET /api/v1/admin/programs` - Manage programs +- `GET /api/v1/admin/reports` - Manage reports + +All endpoints return JSON and use standard HTTP status codes. + +--- + +## Development + +### Running Tests + +```bash +just test # Run all tests +just test-cov # Run with coverage report +``` + +### Type Checking + +```bash +just mypy # Check backend types +just tsc # Check frontend types +just typecheck # Check all types +``` + +### Linting + +```bash +just lint # Backend: ruff + pylint +just ruff-fix # Auto-fix backend linting issues +just biome-fix # Auto-fix frontend linting issues +just stylelint-fix # Auto-fix SCSS linting issues +``` + +### Database Migrations + +```bash +just migration "Add user reputation field" # Create new migration +just migrate head # Apply all migrations +just rollback # Rollback last migration +just db-history # View migration history +``` + +### Docker Management + +```bash +just dev-shell backend # Open shell in backend container +just dev-shell db # Open psql in database container +just dev-logs nginx # View nginx logs +just ps # List running containers +``` + +--- + +## Architecture Highlights + +### Dependency Injection + +FastAPI's dependency injection system is used extensively: + +```python +from fastapi import Depends +from typing import Annotated + +CurrentUser = Annotated[User, Depends(get_current_user)] + +@router.get("/me") +async def get_me(user: CurrentUser) -> UserSchema: + return user +``` + +### Repository Pattern + +All database operations go through repositories: + +```python +class UserRepository(BaseRepository[User]): + async def find_by_email(self, email: str) -> User | None: + stmt = select(User).where(User.email == email) + result = await self.session.execute(stmt) + return result.scalar_one_or_none() + +# Usage in service layer +async def authenticate_user(email: str, password: str) -> User: + user = await user_repo.find_by_email(email) + if not user or not verify_password(password, user.password_hash): + raise InvalidCredentialsError() + return user +``` + +### Type Safety + +Strict type checking with mypy and TypeScript: + +```python +from typing import Generic, TypeVar + +ModelT = TypeVar("ModelT", bound=Base) + +class BaseRepository(Generic[ModelT]): + def __init__(self, session: AsyncSession, model: type[ModelT]) -> None: + self.session = session + self.model = model +``` + +### Security + +Multiple layers of security: +- JWT tokens with HS256 algorithm +- Token versioning (instant invalidation on password change) +- Refresh token rotation (prevents replay attacks) +- Argon2id password hashing +- Rate limiting (100 req/min default, 20 req/min for auth) +- CORS protection +- Input validation with Pydantic + +See [learn/SECURITY.md](learn/SECURITY.md) for detailed explanations. + +--- + +## Contributing + +This is an educational project demonstrating production-level architecture. Feel free to: +- Fork the repository and build upon it +- Use it as a reference for your own projects +- Submit issues if you find bugs +- Share feedback and suggestions + +--- + +## License + +This project is part of the [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) repository. + +© AngelaMos | 2026 + +--- + +## Links + +- **Live Platform:** [bugbounty.carterperez-dev.com](https://bugbounty.carterperez-dev.com) +- **API Docs:** [bugbounty.carterperez-dev.com/api/docs](https://bugbounty.carterperez-dev.com/api/docs) +- **Parent Repository:** [Cybersecurity-Projects](https://github.com/CarterPerez-dev/Cybersecurity-Projects) + +--- + +## Support + +For questions, issues, or discussions: +1. Check the [learn/](learn/) directory for detailed documentation +2. Review the API documentation at `/api/docs` +3. Open an issue in the parent repository +4. Email: [contact information if applicable] + +--- + +**Happy Hacking! 🔒** diff --git a/PROJECTS/bug-bounty-platform/backend/.dockerignore b/PROJECTS/bug-bounty-platform/backend/.dockerignore new file mode 100644 index 00000000..48b05d2b --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/.dockerignore @@ -0,0 +1,85 @@ +# =============================== +# © AngelaMos | 2025 +# .dockerignore +# =============================== + +# Virtual environments +.venv/ +venv/ +ENV/ + +# Python cache +__pycache__/ +*.py[cod] +*$py.class +*.pyo + +# Type checker / linter caches +.mypy_cache/ +.ruff_cache/ +.pytest_cache/ +.ty_cache/ + +# Coverage +.coverage +.coverage.* +htmlcov/ +coverage.xml +*.cover + +# Build artifacts +dist/ +build/ +*.egg-info/ +*.egg + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# Environment files (keep .env.example) +.env +.env.local +.env.*.local + +# Git +.git/ +.gitignore +.gitattributes + +# Docker (don't need these in the image) +Dockerfile* +docker-compose* +.dockerignore + +# Tests (not needed in prod image) +tests/ +conftest.py +pytest.ini +.coveragerc + +# Documentation +docs/ +*.md +*.rst +LICENSE + +# Logs +*.log +logs/ + +# Local databases +*.db +*.sqlite +*.sqlite3 + +# Alembic versions not needed in image (migrations run separately) +# alembic/versions/ + +# Misc +.DS_Store +Thumbs.db +*.bak diff --git a/PROJECTS/bug-bounty-platform/backend/.style.yapf b/PROJECTS/bug-bounty-platform/backend/.style.yapf new file mode 100755 index 00000000..74d83416 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/.style.yapf @@ -0,0 +1,46 @@ +[style] +based_on_style = pep8 +column_limit = 75 +indent_width = 4 +continuation_indent_width = 4 +indent_closing_brackets = false +dedent_closing_brackets = true +indent_blank_lines = false +spaces_before_comment = 2 +spaces_around_power_operator = false +spaces_around_default_or_named_assign = true +space_between_ending_comma_and_closing_bracket = false +space_inside_brackets = false +spaces_around_subscript_colon = true +blank_line_before_nested_class_or_def = false +blank_line_before_class_docstring = false +blank_lines_around_top_level_definition = 2 +blank_lines_between_top_level_imports_and_variables = 2 +blank_line_before_module_docstring = false +split_before_logical_operator = true +split_before_first_argument = true +split_before_named_assigns = true +split_complex_comprehension = true +split_before_expression_after_opening_paren = false +split_before_closing_bracket = true +split_all_comma_separated_values = true +split_all_top_level_comma_separated_values = false +coalesce_brackets = false +each_dict_entry_on_separate_line = true +allow_multiline_lambdas = false +allow_multiline_dictionary_keys = false +split_penalty_import_names = 0 +join_multiple_lines = false +align_closing_bracket_with_visual_indent = true +arithmetic_precedence_indication = false +split_penalty_for_added_line_split = 275 +use_tabs = false +split_before_dot = false +split_arguments_when_comma_terminated = true +i18n_function_call = ['_', 'N_', 'gettext', 'ngettext'] +i18n_comment = ['# Translators:', '# i18n:'] +split_penalty_comprehension = 80 +split_penalty_after_opening_bracket = 280 +split_penalty_before_if_expr = 0 +split_penalty_bitwise_operator = 290 +split_penalty_logical_operator = 0 diff --git a/PROJECTS/bug-bounty-platform/backend/alembic.ini b/PROJECTS/bug-bounty-platform/backend/alembic.ini new file mode 100644 index 00000000..fc4ddbe4 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/alembic.ini @@ -0,0 +1,43 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +version_path_separator = os +file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d%%(second).2d_%%(slug)s + +sqlalchemy.url = driver://user:pass@localhost/dbname + +[post_write_hooks] + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/PROJECTS/bug-bounty-platform/backend/alembic/env.py b/PROJECTS/bug-bounty-platform/backend/alembic/env.py new file mode 100644 index 00000000..4128572d --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/alembic/env.py @@ -0,0 +1,124 @@ +""" +ⒸAngelaMos | 2025 +env.py +""" +import asyncio +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +from config import settings +from core.Base import Base +from core.enums import SafeEnum +from user.User import User +from auth.RefreshToken import RefreshToken +from program.Program import Program +from program.Asset import Asset +from program.RewardTier import RewardTier +from report.Report import Report +from report.Comment import Comment +from report.Attachment import Attachment + + +config = context.config + + +def render_item(type_, obj, autogen_context): + """ + Custom renderer for alembic autogenerate. + Converts SafeEnum to standard sa.Enum and ensures DateTime uses timezone. + """ + import sqlalchemy as sa + + if isinstance(obj, SafeEnum): + enum_class = obj.enum_class + values = [e.value for e in enum_class] + return f"sa.Enum({', '.join(repr(v) for v in values)}, name='{obj.name}')" + + if isinstance(obj, sa.DateTime): + return "sa.DateTime(timezone=True)" + + return False + + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def get_url() -> str: + """ + Get database URL from settings + """ + return str(settings.DATABASE_URL) + + +def run_migrations_offline() -> None: + """ + Run migrations in 'offline' mode + """ + url = get_url() + context.configure( + url = url, + target_metadata = target_metadata, + literal_binds = True, + dialect_opts = {"paramstyle": "named"}, + compare_type = True, + compare_server_default = True, + render_item = render_item, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + """ + Run migrations with connection + """ + context.configure( + connection = connection, + target_metadata = target_metadata, + compare_type = True, + compare_server_default = True, + render_item = render_item, + ) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """ + Run migrations in async mode + """ + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = get_url() + + connectable = async_engine_from_config( + configuration, + prefix = "sqlalchemy.", + poolclass = pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """ + Run migrations in 'online' mode + """ + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/PROJECTS/bug-bounty-platform/backend/alembic/script.py.mako b/PROJECTS/bug-bounty-platform/backend/alembic/script.py.mako new file mode 100644 index 00000000..590f5b3a --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/PROJECTS/bug-bounty-platform/backend/alembic/versions/20251224_033104_initial.py b/PROJECTS/bug-bounty-platform/backend/alembic/versions/20251224_033104_initial.py new file mode 100644 index 00000000..acf88147 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/alembic/versions/20251224_033104_initial.py @@ -0,0 +1,67 @@ +"""initial + +Revision ID: 801b86be184b +Revises: +Create Date: 2025-12-24 03:31:04.712921 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = '801b86be184b' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('users', + sa.Column('email', sa.String(length=320), nullable=False), + sa.Column('hashed_password', sa.String(length=1024), nullable=False), + sa.Column('full_name', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_verified', sa.Boolean(), nullable=False), + sa.Column('role', sa.Enum('unknown', 'user', 'admin', name='userrole'), nullable=False), + sa.Column('token_version', sa.Integer(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('pk_users')) + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True) + op.create_table('refresh_tokens', + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('user_id', sa.Uuid(), nullable=False), + sa.Column('family_id', sa.Uuid(), nullable=False), + sa.Column('device_id', sa.String(length=255), nullable=True), + sa.Column('device_name', sa.String(length=100), nullable=True), + sa.Column('ip_address', sa.String(length=45), nullable=True), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('is_revoked', sa.Boolean(), nullable=False), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_refresh_tokens_user_id_users'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_refresh_tokens')) + ) + op.create_index(op.f('ix_refresh_tokens_expires_at'), 'refresh_tokens', ['expires_at'], unique=False) + op.create_index(op.f('ix_refresh_tokens_family_id'), 'refresh_tokens', ['family_id'], unique=False) + op.create_index(op.f('ix_refresh_tokens_token_hash'), 'refresh_tokens', ['token_hash'], unique=True) + op.create_index(op.f('ix_refresh_tokens_user_id'), 'refresh_tokens', ['user_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_refresh_tokens_user_id'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_token_hash'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_family_id'), table_name='refresh_tokens') + op.drop_index(op.f('ix_refresh_tokens_expires_at'), table_name='refresh_tokens') + op.drop_table('refresh_tokens') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + # ### end Alembic commands ### diff --git a/PROJECTS/bug-bounty-platform/backend/alembic/versions/20260106_010122_add_bug_bounty_models.py b/PROJECTS/bug-bounty-platform/backend/alembic/versions/20260106_010122_add_bug_bounty_models.py new file mode 100644 index 00000000..bb755b30 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/alembic/versions/20260106_010122_add_bug_bounty_models.py @@ -0,0 +1,152 @@ +"""add bug bounty models + +Revision ID: de05481ca143 +Revises: 801b86be184b +Create Date: 2026-01-06 01:01:22.847036 +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'de05481ca143' +down_revision: Union[str, None] = '801b86be184b' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('programs', + sa.Column('company_id', sa.Uuid(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('slug', sa.String(length=100), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('rules', sa.Text(), nullable=True), + sa.Column('response_sla_hours', sa.Integer(), nullable=False), + sa.Column('status', sa.Enum('draft', 'active', 'paused', 'closed', name='programstatus'), nullable=False), + sa.Column('visibility', sa.Enum('public', 'private', 'invite_only', name='programvisibility'), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['company_id'], ['users.id'], name=op.f('fk_programs_company_id_users'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_programs')) + ) + op.create_index(op.f('ix_programs_company_id'), 'programs', ['company_id'], unique=False) + op.create_index(op.f('ix_programs_slug'), 'programs', ['slug'], unique=True) + op.create_index(op.f('ix_programs_status'), 'programs', ['status'], unique=False) + op.create_table('assets', + sa.Column('program_id', sa.Uuid(), nullable=False), + sa.Column('asset_type', sa.Enum('domain', 'api', 'mobile_app', 'source_code', 'hardware', 'other', name='assettype'), nullable=False), + sa.Column('identifier', sa.String(length=500), nullable=False), + sa.Column('in_scope', sa.Boolean(), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['program_id'], ['programs.id'], name=op.f('fk_assets_program_id_programs'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_assets')) + ) + op.create_index(op.f('ix_assets_program_id'), 'assets', ['program_id'], unique=False) + op.create_table('reports', + sa.Column('program_id', sa.Uuid(), nullable=False), + sa.Column('researcher_id', sa.Uuid(), nullable=False), + sa.Column('title', sa.String(length=500), nullable=False), + sa.Column('description', sa.Text(), nullable=False), + sa.Column('steps_to_reproduce', sa.Text(), nullable=True), + sa.Column('impact', sa.Text(), nullable=True), + sa.Column('severity_submitted', sa.Enum('critical', 'high', 'medium', 'low', 'informational', name='severity'), nullable=False), + sa.Column('severity_final', sa.Enum('critical', 'high', 'medium', 'low', 'informational', name='severity'), nullable=True), + sa.Column('status', sa.Enum('new', 'triaging', 'needs_more_info', 'accepted', 'duplicate', 'informative', 'not_applicable', 'resolved', 'disclosed', name='reportstatus'), nullable=False), + sa.Column('cvss_score', sa.Numeric(precision=3, scale=1), nullable=True), + sa.Column('cwe_id', sa.String(length=20), nullable=True), + sa.Column('bounty_amount', sa.Integer(), nullable=True), + sa.Column('duplicate_of_id', sa.Uuid(), nullable=True), + sa.Column('triaged_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('resolved_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('disclosed_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['duplicate_of_id'], ['reports.id'], name=op.f('fk_reports_duplicate_of_id_reports'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['program_id'], ['programs.id'], name=op.f('fk_reports_program_id_programs'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['researcher_id'], ['users.id'], name=op.f('fk_reports_researcher_id_users'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_reports')) + ) + op.create_index(op.f('ix_reports_program_id'), 'reports', ['program_id'], unique=False) + op.create_index(op.f('ix_reports_researcher_id'), 'reports', ['researcher_id'], unique=False) + op.create_index(op.f('ix_reports_status'), 'reports', ['status'], unique=False) + op.create_table('reward_tiers', + sa.Column('program_id', sa.Uuid(), nullable=False), + sa.Column('severity', sa.Enum('critical', 'high', 'medium', 'low', 'informational', name='severity'), nullable=False), + sa.Column('min_bounty', sa.Integer(), nullable=False), + sa.Column('max_bounty', sa.Integer(), nullable=False), + sa.Column('currency', sa.String(length=3), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['program_id'], ['programs.id'], name=op.f('fk_reward_tiers_program_id_programs'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_reward_tiers')) + ) + op.create_index(op.f('ix_reward_tiers_program_id'), 'reward_tiers', ['program_id'], unique=False) + op.create_table('comments', + sa.Column('report_id', sa.Uuid(), nullable=False), + sa.Column('author_id', sa.Uuid(), nullable=False), + sa.Column('content', sa.Text(), nullable=False), + sa.Column('is_internal', sa.Boolean(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['author_id'], ['users.id'], name=op.f('fk_comments_author_id_users'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['report_id'], ['reports.id'], name=op.f('fk_comments_report_id_reports'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_comments')) + ) + op.create_index(op.f('ix_comments_author_id'), 'comments', ['author_id'], unique=False) + op.create_index(op.f('ix_comments_report_id'), 'comments', ['report_id'], unique=False) + op.create_table('attachments', + sa.Column('report_id', sa.Uuid(), nullable=False), + sa.Column('comment_id', sa.Uuid(), nullable=True), + sa.Column('filename', sa.String(length=255), nullable=False), + sa.Column('storage_path', sa.String(length=500), nullable=False), + sa.Column('mime_type', sa.String(length=100), nullable=False), + sa.Column('size_bytes', sa.BigInteger(), nullable=False), + sa.Column('id', sa.Uuid(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['comment_id'], ['comments.id'], name=op.f('fk_attachments_comment_id_comments'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['report_id'], ['reports.id'], name=op.f('fk_attachments_report_id_reports'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_attachments')) + ) + op.create_index(op.f('ix_attachments_report_id'), 'attachments', ['report_id'], unique=False) + op.add_column('users', sa.Column('company_name', sa.String(length=255), nullable=True)) + op.add_column('users', sa.Column('bio', sa.Text(), nullable=True)) + op.add_column('users', sa.Column('website', sa.String(length=500), nullable=True)) + op.add_column('users', sa.Column('reputation_score', sa.Integer(), nullable=False)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'reputation_score') + op.drop_column('users', 'website') + op.drop_column('users', 'bio') + op.drop_column('users', 'company_name') + op.drop_index(op.f('ix_attachments_report_id'), table_name='attachments') + op.drop_table('attachments') + op.drop_index(op.f('ix_comments_report_id'), table_name='comments') + op.drop_index(op.f('ix_comments_author_id'), table_name='comments') + op.drop_table('comments') + op.drop_index(op.f('ix_reward_tiers_program_id'), table_name='reward_tiers') + op.drop_table('reward_tiers') + op.drop_index(op.f('ix_reports_status'), table_name='reports') + op.drop_index(op.f('ix_reports_researcher_id'), table_name='reports') + op.drop_index(op.f('ix_reports_program_id'), table_name='reports') + op.drop_table('reports') + op.drop_index(op.f('ix_assets_program_id'), table_name='assets') + op.drop_table('assets') + op.drop_index(op.f('ix_programs_status'), table_name='programs') + op.drop_index(op.f('ix_programs_slug'), table_name='programs') + op.drop_index(op.f('ix_programs_company_id'), table_name='programs') + op.drop_table('programs') + # ### end Alembic commands ### diff --git a/PROJECTS/bug-bounty-platform/backend/app/__main__.py b/PROJECTS/bug-bounty-platform/backend/app/__main__.py new file mode 100644 index 00000000..53488e8c --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/__main__.py @@ -0,0 +1,42 @@ +""" +ⒸAngelaMos | 2026 +__main__.py +""" + +import uvicorn + +from config import settings +from factory import create_app + + +app = create_app() + +if __name__ == "__main__": + uvicorn.run( + "app.__main__:app", + host = settings.HOST, + port = settings.PORT, + reload = settings.RELOAD, + ) +""" + +⠀⠀⠀⠀⠀⠴⣦⣤⡀⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⣨⣥⣄⣀⠀⡁⠀⠀⡀⡠⠀⠀⠀⠂⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⢠⣾⣿⣷⣮⣷⡦⠥⠈⡶⠮⣤⣀⡠⠀⡀⣐⣀⡈⠁⠀⠐⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⣾⣿⣿⣿⣿⠟⠀⠠⠊⠉⠀⠀⢀⠉⠙⠚⠧⣦⣀⡀⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⣿⣿⣿⣿⡏⠀⠀⠀⠀⠀⠠⠀⠁⠀⢤⠀⠀⠀⠨⡉⠛⠶⠤⣄⣄⢀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⢀⣿⣿⣿⣿⡀⠀⠀⢰⠀⠍⡾⠆⠀⠀⣠⡦⠄⡀⠄⠀⠠⠀⠀⠀⠈⠙⠓⠦⢤⣀⡀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠸⣿⣿⣿⣿⣿⣶⣦⢠⡈⠀⠀⠀⠀⠀⠋⠛⠉⡂⠈⠙⠀⣰⠀⠀⠀⠀⠀⠀⠀⠀⠉⠛⠺⠦⣄⣀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠻⢿⣿⣿⣿⣿⣿⣾⣿⣿⣦⢤⡀⢀⣂⣨⠀⢅⢱⡔⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠉⠙⠲⠴⣠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠈⠙⠻⠿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣶⣎⠘⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠈⠑⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⢀⣤⣶⣾⣽⡿⢿⣿⣿⣿⣿⣿⣿⣿⣿⠳⢄⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⣿⣿⠏⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠁⠀⠹⣦⣴⠖⠲⠆⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠘⢿⠀⢻⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠏⠀⠀⠈⠀⠀⠀⠒⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢩⠢⣙⠿⣿⣿⣿⣿⣿⣿⡿⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠸⣆⠈⠛⢶⣌⡉⣻⣿⡿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⣷⣄⣤⣙⣿⣿⣿⣷⣄⣀⣀⣀⣀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⣿⠿⠟⠛⠟⠠⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣴⣿⡿⣿⣿⣿⣿⣿⣿⡿⠋⠉⠀⠀⠀⠀⠀⠀⡀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀ +⠀⠀⠀⠀⠀⠀⠀⠀⠀⠁⠙⠁⠘⢮⣛⡽⠛⠿⡿⠥⠀ + +""" diff --git a/PROJECTS/bug-bounty-platform/backend/app/admin/__init__.py b/PROJECTS/bug-bounty-platform/backend/app/admin/__init__.py new file mode 100644 index 00000000..fe240105 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/admin/__init__.py @@ -0,0 +1,9 @@ +""" +ⒸAngelaMos | 2026 +admin module +""" + +from .routes import router + + +__all__ = ["router"] diff --git a/PROJECTS/bug-bounty-platform/backend/app/admin/dependencies.py b/PROJECTS/bug-bounty-platform/backend/app/admin/dependencies.py new file mode 100644 index 00000000..67f26139 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/admin/dependencies.py @@ -0,0 +1,25 @@ +""" +ⒸAngelaMos | 2026 +dependencies.py +""" + +from typing import Annotated + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.database import get_db_session +from .service import AdminService + + +async def get_admin_service( + session: Annotated[AsyncSession, + Depends(get_db_session)], +) -> AdminService: + """ + Dependency for AdminService + """ + return AdminService(session) + + +AdminServiceDep = Annotated[AdminService, Depends(get_admin_service)] diff --git a/PROJECTS/bug-bounty-platform/backend/app/admin/py.typed b/PROJECTS/bug-bounty-platform/backend/app/admin/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/bug-bounty-platform/backend/app/admin/repository.py b/PROJECTS/bug-bounty-platform/backend/app/admin/repository.py new file mode 100644 index 00000000..e7333ce1 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/admin/repository.py @@ -0,0 +1,314 @@ +""" +ⒸAngelaMos | 2026 +repository.py +""" + +from typing import Any +from collections.abc import Sequence +from datetime import UTC, datetime, timedelta +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import ProgramStatus, ReportStatus, UserRole +from program.Program import Program +from report.Report import Report +from user.User import User + + +class AdminRepository: + """ + Repository for admin-specific database operations + """ + @classmethod + async def get_all_programs( + cls, + session: AsyncSession, + skip: int = 0, + limit: int = 20, + status_filter: ProgramStatus | None = None, + ) -> Sequence[tuple[Program, + User, + int]]: + """ + Get all programs with company info and report count + """ + report_count_subq = ( + select( + Report.program_id, + func.count(Report.id).label("report_count") + ).group_by(Report.program_id).subquery() + ) + + query = ( + select( + Program, + User, + func.coalesce(report_count_subq.c.report_count, + 0).label("report_count") + ).join(User, + Program.company_id == User.id).outerjoin( + report_count_subq, + Program.id == report_count_subq.c.program_id + ) + ) + + if status_filter: + query = query.where(Program.status == status_filter) + + result = await session.execute( + query.order_by(Program.created_at.desc() + ).offset(skip).limit(limit) + ) + return [tuple(row) for row in result.all()] + + @classmethod + async def count_all_programs( + cls, + session: AsyncSession, + status_filter: ProgramStatus | None = None, + ) -> int: + """ + Count all programs + """ + query = select(func.count()).select_from(Program) + + if status_filter: + query = query.where(Program.status == status_filter) + + result = await session.execute(query) + return result.scalar_one() + + @classmethod + async def get_program_by_id( + cls, + session: AsyncSession, + program_id: UUID, + ) -> Program | None: + """ + Get program by ID for admin operations + """ + return await session.get(Program, program_id) + + @classmethod + async def get_all_reports( + cls, + session: AsyncSession, + skip: int = 0, + limit: int = 20, + status_filter: ReportStatus | None = None, + severity_filter: str | None = None, + ) -> Sequence[tuple[Report, + Program, + User]]: + """ + Get all reports with program and researcher info + """ + query = ( + select(Report, + Program, + User).join(Program, + Report.program_id == Program.id).join( + User, + Report.researcher_id == User.id + ) + ) + + if status_filter: + query = query.where(Report.status == status_filter) + + if severity_filter: + query = query.where( + Report.severity_submitted == severity_filter + ) + + result = await session.execute( + query.order_by(Report.created_at.desc() + ).offset(skip).limit(limit) + ) + return [tuple(row) for row in result.all()] + + @classmethod + async def count_all_reports( + cls, + session: AsyncSession, + status_filter: ReportStatus | None = None, + severity_filter: str | None = None, + ) -> int: + """ + Count all reports + """ + query = select(func.count()).select_from(Report) + + if status_filter: + query = query.where(Report.status == status_filter) + + if severity_filter: + query = query.where( + Report.severity_submitted == severity_filter + ) + + result = await session.execute(query) + return result.scalar_one() + + @classmethod + async def get_report_by_id( + cls, + session: AsyncSession, + report_id: UUID, + ) -> Report | None: + """ + Get report by ID for admin operations + """ + return await session.get(Report, report_id) + + @classmethod + async def get_all_users_with_stats( + cls, + session: AsyncSession, + skip: int = 0, + limit: int = 20, + role_filter: UserRole | None = None, + ) -> Sequence[tuple[User, + int, + int]]: + """ + Get all users with program and report counts + """ + program_count_subq = ( + select( + Program.company_id, + func.count(Program.id).label("program_count") + ).group_by(Program.company_id).subquery() + ) + + report_count_subq = ( + select( + Report.researcher_id, + func.count(Report.id).label("report_count") + ).group_by(Report.researcher_id).subquery() + ) + + query = ( + select( + User, + func.coalesce(program_count_subq.c.program_count, + 0).label("program_count"), + func.coalesce(report_count_subq.c.report_count, + 0).label("report_count") + ).outerjoin( + program_count_subq, + User.id == program_count_subq.c.company_id + ).outerjoin( + report_count_subq, + User.id == report_count_subq.c.researcher_id + ) + ) + + if role_filter: + query = query.where(User.role == role_filter) + + result = await session.execute( + query.order_by(User.created_at.desc() + ).offset(skip).limit(limit) + ) + return [tuple(row) for row in result.all()] + + @classmethod + async def count_all_users( + cls, + session: AsyncSession, + role_filter: UserRole | None = None, + ) -> int: + """ + Count all users + """ + query = select(func.count()).select_from(User) + + if role_filter: + query = query.where(User.role == role_filter) + + result = await session.execute(query) + return result.scalar_one() + + @classmethod + async def get_platform_stats( + cls, + session: AsyncSession, + ) -> dict[str, + Any]: + """ + Get platform-wide statistics + """ + now = datetime.now(UTC) + month_ago = now - timedelta(days = 30) + + total_users = await session.execute( + select(func.count()).select_from(User) + ) + + users_by_role = await session.execute( + select(User.role, + func.count()).group_by(User.role) + ) + role_counts = { + str(role): count + for role, count in users_by_role.all() + } + + total_programs = await session.execute( + select(func.count()).select_from(Program) + ) + + active_programs = await session.execute( + select(func.count()).select_from(Program).where( + Program.status == ProgramStatus.ACTIVE + ) + ) + + total_reports = await session.execute( + select(func.count()).select_from(Report) + ) + + reports_by_status = await session.execute( + select(Report.status, + func.count()).group_by(Report.status) + ) + status_counts = { + str(status): count + for status, count in reports_by_status.all() + } + + total_bounties = await session.execute( + select(func.coalesce(func.sum(Report.bounty_amount), + 0)).where( + Report.bounty_amount.isnot(None) + ) + ) + + reports_this_month = await session.execute( + select(func.count()).select_from(Report).where( + Report.created_at >= month_ago + ) + ) + + new_users_this_month = await session.execute( + select(func.count() + ).select_from(User).where(User.created_at >= month_ago) + ) + + return { + "total_users": total_users.scalar_one(), + "total_researchers": role_counts.get("user", + 0), + "total_companies": role_counts.get("company", + 0), + "total_programs": total_programs.scalar_one(), + "active_programs": active_programs.scalar_one(), + "total_reports": total_reports.scalar_one(), + "reports_by_status": status_counts, + "total_bounties_paid": total_bounties.scalar_one(), + "reports_this_month": reports_this_month.scalar_one(), + "new_users_this_month": new_users_this_month.scalar_one(), + } diff --git a/PROJECTS/bug-bounty-platform/backend/app/admin/routes.py b/PROJECTS/bug-bounty-platform/backend/app/admin/routes.py new file mode 100644 index 00000000..fe28d252 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/admin/routes.py @@ -0,0 +1,309 @@ +""" +ⒸAngelaMos | 2026 +routes.py +""" + +from uuid import UUID +from typing import Annotated + +from fastapi import ( + APIRouter, + Depends, + Query, + status, +) + +from config import ( + settings, + ProgramStatus, + ReportStatus, + UserRole, +) +from core.dependencies import RequireRole +from core.responses import ( + AUTH_401, + CONFLICT_409, + FORBIDDEN_403, + NOT_FOUND_404, +) +from user.schemas import ( + AdminUserCreate, + UserResponse, + UserUpdateAdmin, +) +from user.User import User +from user.dependencies import UserServiceDep +from .dependencies import AdminServiceDep +from .schemas import ( + AdminProgramListResponse, + AdminProgramResponse, + AdminProgramUpdate, + AdminReportListResponse, + AdminReportResponse, + AdminReportUpdate, + AdminUserListResponse, + PlatformStatsResponse, +) + + +router = APIRouter(prefix = "/admin", tags = ["admin"]) + +AdminOnly = Annotated[User, Depends(RequireRole(UserRole.ADMIN))] + + +@router.get( + "/stats", + response_model = PlatformStatsResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403 + }, +) +async def get_platform_stats( + admin_service: AdminServiceDep, + _: AdminOnly, +) -> PlatformStatsResponse: + """ + Get platform-wide statistics (admin only) + """ + return await admin_service.get_platform_stats() + + +@router.get( + "/programs", + response_model = AdminProgramListResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403 + }, +) +async def list_programs( + admin_service: AdminServiceDep, + _: AdminOnly, + page: int = Query(default = 1, + ge = 1), + size: int = Query( + default = settings.PAGINATION_DEFAULT_SIZE, + ge = 1, + le = settings.PAGINATION_MAX_SIZE + ), + status_filter: ProgramStatus + | None = Query(default = None, + alias = "status"), +) -> AdminProgramListResponse: + """ + List all programs (admin only) + """ + return await admin_service.list_programs(page, size, status_filter) + + +@router.patch( + "/programs/{program_id}", + response_model = AdminProgramResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def update_program( + admin_service: AdminServiceDep, + _: AdminOnly, + program_id: UUID, + data: AdminProgramUpdate, +) -> AdminProgramResponse: + """ + Update a program (admin only) + """ + return await admin_service.update_program(program_id, data) + + +@router.delete( + "/programs/{program_id}", + status_code = status.HTTP_204_NO_CONTENT, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def delete_program( + admin_service: AdminServiceDep, + _: AdminOnly, + program_id: UUID, +) -> None: + """ + Delete a program (admin only, hard delete) + """ + await admin_service.delete_program(program_id) + + +@router.get( + "/reports", + response_model = AdminReportListResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403 + }, +) +async def list_reports( + admin_service: AdminServiceDep, + _: AdminOnly, + page: int = Query(default = 1, + ge = 1), + size: int = Query( + default = settings.PAGINATION_DEFAULT_SIZE, + ge = 1, + le = settings.PAGINATION_MAX_SIZE + ), + status_filter: ReportStatus + | None = Query(default = None, + alias = "status"), + severity_filter: str + | None = Query(default = None, + alias = "severity"), +) -> AdminReportListResponse: + """ + List all reports (admin only) + """ + return await admin_service.list_reports( + page, + size, + status_filter, + severity_filter + ) + + +@router.patch( + "/reports/{report_id}", + response_model = AdminReportResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def update_report( + admin_service: AdminServiceDep, + _: AdminOnly, + report_id: UUID, + data: AdminReportUpdate, +) -> AdminReportResponse: + """ + Update a report (admin only, override) + """ + return await admin_service.update_report(report_id, data) + + +@router.get( + "/users", + response_model = AdminUserListResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403 + }, +) +async def list_users( + admin_service: AdminServiceDep, + _: AdminOnly, + page: int = Query(default = 1, + ge = 1), + size: int = Query( + default = settings.PAGINATION_DEFAULT_SIZE, + ge = 1, + le = settings.PAGINATION_MAX_SIZE + ), + role_filter: UserRole | None = Query(default = None, + alias = "role"), +) -> AdminUserListResponse: + """ + List all users with stats (admin only) + """ + return await admin_service.list_users_with_stats( + page, + size, + role_filter + ) + + +@router.post( + "/users", + response_model = UserResponse, + status_code = status.HTTP_201_CREATED, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **CONFLICT_409 + }, +) +async def create_user( + user_service: UserServiceDep, + _: AdminOnly, + user_data: AdminUserCreate, +) -> UserResponse: + """ + Create a new user (admin only, bypasses registration) + """ + return await user_service.admin_create_user(user_data) + + +@router.get( + "/users/{user_id}", + response_model = UserResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def get_user( + user_service: UserServiceDep, + _: AdminOnly, + user_id: UUID, +) -> UserResponse: + """ + Get user by ID (admin only) + """ + return await user_service.get_user_by_id(user_id) + + +@router.patch( + "/users/{user_id}", + response_model = UserResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404, + **CONFLICT_409 + }, +) +async def update_user( + user_service: UserServiceDep, + _: AdminOnly, + user_id: UUID, + user_data: UserUpdateAdmin, +) -> UserResponse: + """ + Update user (admin only) + """ + return await user_service.admin_update_user(user_id, user_data) + + +@router.delete( + "/users/{user_id}", + status_code = status.HTTP_204_NO_CONTENT, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def delete_user( + user_service: UserServiceDep, + _: AdminOnly, + user_id: UUID, +) -> None: + """ + Delete user (admin only, hard delete) + """ + await user_service.admin_delete_user(user_id) diff --git a/PROJECTS/bug-bounty-platform/backend/app/admin/schemas.py b/PROJECTS/bug-bounty-platform/backend/app/admin/schemas.py new file mode 100644 index 00000000..14ee2481 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/admin/schemas.py @@ -0,0 +1,136 @@ +""" +ⒸAngelaMos | 2026 +schemas.py +""" + +from datetime import datetime +from decimal import Decimal +from uuid import UUID + +from pydantic import Field + +from config import ( + ProgramStatus, + ProgramVisibility, + ReportStatus, + Severity, + UserRole, +) +from core.base_schema import BaseSchema, BaseResponseSchema + + +class AdminProgramUpdate(BaseSchema): + """ + Schema for admin updating a program + """ + name: str | None = None + status: ProgramStatus | None = None + visibility: ProgramVisibility | None = None + is_featured: bool | None = None + + +class AdminProgramResponse(BaseResponseSchema): + """ + Schema for admin program view with company info + """ + company_id: UUID + company_email: str + company_name: str | None + name: str + slug: str + description: str | None + status: ProgramStatus + visibility: ProgramVisibility + response_sla_hours: int + report_count: int + + +class AdminProgramListResponse(BaseSchema): + """ + Schema for paginated admin program list + """ + items: list[AdminProgramResponse] + total: int + page: int + size: int + + +class AdminReportUpdate(BaseSchema): + """ + Schema for admin overriding a report + """ + status: ReportStatus | None = None + severity_final: Severity | None = None + cvss_score: Decimal | None = Field(default = None, ge = 0, le = 10) + bounty_amount: int | None = Field(default = None, ge = 0) + admin_notes: str | None = None + + +class AdminReportResponse(BaseResponseSchema): + """ + Schema for admin report view with program/researcher info + """ + program_id: UUID + program_name: str + program_slug: str + researcher_id: UUID + researcher_email: str + researcher_name: str | None + title: str + severity_submitted: Severity + severity_final: Severity | None + status: ReportStatus + bounty_amount: int | None + triaged_at: datetime | None + resolved_at: datetime | None + + +class AdminReportListResponse(BaseSchema): + """ + Schema for paginated admin report list + """ + items: list[AdminReportResponse] + total: int + page: int + size: int + + +class PlatformStatsResponse(BaseSchema): + """ + Schema for platform-wide statistics + """ + total_users: int + total_researchers: int + total_companies: int + total_programs: int + active_programs: int + total_reports: int + reports_by_status: dict[str, int] + total_bounties_paid: int + reports_this_month: int + new_users_this_month: int + + +class AdminUserResponse(BaseResponseSchema): + """ + Schema for admin user view with stats + """ + email: str + full_name: str | None + company_name: str | None + is_active: bool + is_verified: bool + role: UserRole + reputation_score: int + program_count: int + report_count: int + + +class AdminUserListResponse(BaseSchema): + """ + Schema for paginated admin user list with stats + """ + items: list[AdminUserResponse] + total: int + page: int + size: int diff --git a/PROJECTS/bug-bounty-platform/backend/app/admin/service.py b/PROJECTS/bug-bounty-platform/backend/app/admin/service.py new file mode 100644 index 00000000..11671228 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/admin/service.py @@ -0,0 +1,303 @@ +""" +ⒸAngelaMos | 2026 +service.py +""" + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from config import ProgramStatus, ReportStatus, UserRole +from core.exceptions import ResourceNotFound +from .repository import AdminRepository +from .schemas import ( + AdminProgramListResponse, + AdminProgramResponse, + AdminProgramUpdate, + AdminReportListResponse, + AdminReportResponse, + AdminReportUpdate, + AdminUserListResponse, + AdminUserResponse, + PlatformStatsResponse, +) + + +class AdminService: + """ + Service for admin operations + """ + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def list_programs( + self, + page: int, + size: int, + status_filter: ProgramStatus | None = None, + ) -> AdminProgramListResponse: + """ + List all programs with company info + """ + skip = (page - 1) * size + + results = await AdminRepository.get_all_programs( + self.session, + skip = skip, + limit = size, + status_filter = status_filter, + ) + + total = await AdminRepository.count_all_programs( + self.session, + status_filter = status_filter, + ) + + items = [ + AdminProgramResponse( + id = program.id, + created_at = program.created_at, + updated_at = program.updated_at, + company_id = program.company_id, + company_email = company.email, + company_name = company.company_name or company.full_name, + name = program.name, + slug = program.slug, + description = program.description, + status = program.status, + visibility = program.visibility, + response_sla_hours = program.response_sla_hours, + report_count = report_count, + ) for program, company, report_count in results + ] + + return AdminProgramListResponse( + items = items, + total = total, + page = page, + size = size, + ) + + async def update_program( + self, + program_id: UUID, + data: AdminProgramUpdate, + ) -> AdminProgramResponse: + """ + Update a program (admin override) + """ + program = await AdminRepository.get_program_by_id( + self.session, + program_id + ) + if not program: + raise ResourceNotFound("Program", str(program_id)) + + update_data = data.model_dump(exclude_unset = True) + for key, value in update_data.items(): + setattr(program, key, value) + + await self.session.flush() + await self.session.refresh(program) + + results = await AdminRepository.get_all_programs( + self.session, + skip = 0, + limit = 1, + status_filter = None + ) + for p, company, report_count in results: + if p.id == program_id: + return AdminProgramResponse( + id = program.id, + created_at = program.created_at, + updated_at = program.updated_at, + company_id = program.company_id, + company_email = company.email, + company_name = company.company_name + or company.full_name, + name = program.name, + slug = program.slug, + description = program.description, + status = program.status, + visibility = program.visibility, + response_sla_hours = program.response_sla_hours, + report_count = report_count, + ) + + raise ResourceNotFound("Program", str(program_id)) + + async def delete_program(self, program_id: UUID) -> None: + """ + Delete a program (hard delete) + """ + program = await AdminRepository.get_program_by_id( + self.session, + program_id + ) + if not program: + raise ResourceNotFound("Program", str(program_id)) + + await self.session.delete(program) + await self.session.flush() + + async def list_reports( + self, + page: int, + size: int, + status_filter: ReportStatus | None = None, + severity_filter: str | None = None, + ) -> AdminReportListResponse: + """ + List all reports with program/researcher info + """ + skip = (page - 1) * size + + results = await AdminRepository.get_all_reports( + self.session, + skip = skip, + limit = size, + status_filter = status_filter, + severity_filter = severity_filter, + ) + + total = await AdminRepository.count_all_reports( + self.session, + status_filter = status_filter, + severity_filter = severity_filter, + ) + + items = [ + AdminReportResponse( + id = report.id, + created_at = report.created_at, + updated_at = report.updated_at, + program_id = report.program_id, + program_name = program.name, + program_slug = program.slug, + researcher_id = report.researcher_id, + researcher_email = researcher.email, + researcher_name = researcher.full_name, + title = report.title, + severity_submitted = report.severity_submitted, + severity_final = report.severity_final, + status = report.status, + bounty_amount = report.bounty_amount, + triaged_at = report.triaged_at, + resolved_at = report.resolved_at, + ) for report, program, researcher in results + ] + + return AdminReportListResponse( + items = items, + total = total, + page = page, + size = size, + ) + + async def update_report( + self, + report_id: UUID, + data: AdminReportUpdate, + ) -> AdminReportResponse: + """ + Update a report (admin override) + """ + report = await AdminRepository.get_report_by_id( + self.session, + report_id + ) + if not report: + raise ResourceNotFound("Report", str(report_id)) + + update_data = data.model_dump( + exclude_unset = True, + exclude = {"admin_notes"} + ) + for key, value in update_data.items(): + setattr(report, key, value) + + await self.session.flush() + await self.session.refresh(report) + + results = await AdminRepository.get_all_reports( + self.session, + skip = 0, + limit = 1000 + ) + for r, program, researcher in results: + if r.id == report_id: + return AdminReportResponse( + id = report.id, + created_at = report.created_at, + updated_at = report.updated_at, + program_id = report.program_id, + program_name = program.name, + program_slug = program.slug, + researcher_id = report.researcher_id, + researcher_email = researcher.email, + researcher_name = researcher.full_name, + title = report.title, + severity_submitted = report.severity_submitted, + severity_final = report.severity_final, + status = report.status, + bounty_amount = report.bounty_amount, + triaged_at = report.triaged_at, + resolved_at = report.resolved_at, + ) + + raise ResourceNotFound("Report", str(report_id)) + + async def list_users_with_stats( + self, + page: int, + size: int, + role_filter: UserRole | None = None, + ) -> AdminUserListResponse: + """ + List all users with program/report counts + """ + skip = (page - 1) * size + + results = await AdminRepository.get_all_users_with_stats( + self.session, + skip = skip, + limit = size, + role_filter = role_filter, + ) + + total = await AdminRepository.count_all_users( + self.session, + role_filter = role_filter, + ) + + items = [ + AdminUserResponse( + id = user.id, + created_at = user.created_at, + updated_at = user.updated_at, + email = user.email, + full_name = user.full_name, + company_name = user.company_name, + is_active = user.is_active, + is_verified = user.is_verified, + role = user.role, + reputation_score = user.reputation_score, + program_count = program_count, + report_count = report_count, + ) for user, program_count, report_count in results + ] + + return AdminUserListResponse( + items = items, + total = total, + page = page, + size = size, + ) + + async def get_platform_stats(self) -> PlatformStatsResponse: + """ + Get platform-wide statistics + """ + stats = await AdminRepository.get_platform_stats(self.session) + return PlatformStatsResponse(**stats) diff --git a/PROJECTS/bug-bounty-platform/backend/app/auth/RefreshToken.py b/PROJECTS/bug-bounty-platform/backend/app/auth/RefreshToken.py new file mode 100644 index 00000000..f827bb4f --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/auth/RefreshToken.py @@ -0,0 +1,117 @@ +""" +ⒸAngelaMos | 2025 +RefreshToken.py +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from typing import TYPE_CHECKING +from uuid import UUID + +import uuid6 +from sqlalchemy import ( + DateTime, + ForeignKey, + String, +) +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship, +) + +from config import ( + DEVICE_ID_MAX_LENGTH, + DEVICE_NAME_MAX_LENGTH, + IP_ADDRESS_MAX_LENGTH, + TOKEN_HASH_LENGTH, +) +from core.Base import ( + Base, + TimestampMixin, + UUIDMixin, +) + +if TYPE_CHECKING: + from user.User import User + + +class RefreshToken(Base, UUIDMixin, TimestampMixin): + """ + Refresh token for JWT authentication + + Tokens are stored as SHA 256 hashes, never raw + Family ID enables detection of token reuse attacks + """ + __tablename__ = "refresh_tokens" + + token_hash: Mapped[str] = mapped_column( + String(TOKEN_HASH_LENGTH), + unique = True, + index = True, + ) + + user_id: Mapped[UUID] = mapped_column( + ForeignKey("users.id", + ondelete = "CASCADE"), + index = True, + ) + + family_id: Mapped[UUID] = mapped_column( + default = uuid6.uuid7, + index = True, + ) + + device_id: Mapped[str | None] = mapped_column( + String(DEVICE_ID_MAX_LENGTH), + default = None, + ) + device_name: Mapped[str | None] = mapped_column( + String(DEVICE_NAME_MAX_LENGTH), + default = None, + ) + ip_address: Mapped[str | None] = mapped_column( + String(IP_ADDRESS_MAX_LENGTH), + default = None, + ) + + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone = True), + index = True, + ) + + is_revoked: Mapped[bool] = mapped_column(default = False) + revoked_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone = True), + default = None, + ) + + user: Mapped[User] = relationship(back_populates = "refresh_tokens") + + def revoke(self) -> None: + """ + Revoke this token + """ + self.is_revoked = True + self.revoked_at = datetime.now(UTC) + + @property + def is_expired(self) -> bool: + """ + Check if token has expired + + Handles both timezone aware and naive datetimes for SQLite compatibility + """ + now = datetime.now(UTC) + expires = self.expires_at + if expires.tzinfo is None: + expires = expires.replace(tzinfo = UTC) + return now > expires + + @property + def is_valid(self) -> bool: + """ + Check if token is usable + """ + return not self.is_revoked and not self.is_expired diff --git a/PROJECTS/bug-bounty-platform/backend/app/auth/__init__.py b/PROJECTS/bug-bounty-platform/backend/app/auth/__init__.py new file mode 100644 index 00000000..e2ef2457 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/auth/__init__.py @@ -0,0 +1,4 @@ +""" +ⒸAngelaMos | 2025 +Auth Domain +""" diff --git a/PROJECTS/bug-bounty-platform/backend/app/auth/dependencies.py b/PROJECTS/bug-bounty-platform/backend/app/auth/dependencies.py new file mode 100644 index 00000000..312deafd --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/auth/dependencies.py @@ -0,0 +1,21 @@ +""" +ⒸAngelaMos | 2025 +dependencies.py +""" + +from typing import Annotated + +from fastapi import Depends + +from core.dependencies import DBSession +from .service import AuthService + + +def get_auth_service(db: DBSession) -> AuthService: + """ + Dependency to inject AuthService instance + """ + return AuthService(db) + + +AuthServiceDep = Annotated[AuthService, Depends(get_auth_service)] diff --git a/PROJECTS/bug-bounty-platform/backend/app/auth/py.typed b/PROJECTS/bug-bounty-platform/backend/app/auth/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/bug-bounty-platform/backend/app/auth/repository.py b/PROJECTS/bug-bounty-platform/backend/app/auth/repository.py new file mode 100644 index 00000000..e97c04ce --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/auth/repository.py @@ -0,0 +1,178 @@ +""" +ⒸAngelaMos | 2025 +repository.py +""" + +from uuid import UUID +from datetime import UTC, datetime + +from sqlalchemy import select, update +from sqlalchemy.ext.asyncio import AsyncSession + +from .RefreshToken import RefreshToken +from core.base_repository import BaseRepository + + +class RefreshTokenRepository(BaseRepository[RefreshToken]): + """ + Repository for RefreshToken model database operations + """ + model = RefreshToken + + @classmethod + async def get_by_hash( + cls, + session: AsyncSession, + token_hash: str, + ) -> RefreshToken | None: + """ + Get refresh token by its hash + """ + result = await session.execute( + select(RefreshToken).where( + RefreshToken.token_hash == token_hash + ) + ) + return result.scalars().first() + + @classmethod + async def get_valid_by_hash( + cls, + session: AsyncSession, + token_hash: str, + ) -> RefreshToken | None: + """ + Get valid (not revoked, not expired) refresh token by hash + """ + result = await session.execute( + select(RefreshToken).where( + RefreshToken.token_hash == token_hash, + RefreshToken.is_revoked == False, + RefreshToken.expires_at > datetime.now(UTC), + ) + ) + return result.scalars().first() + + @classmethod + async def create_token( + cls, + session: AsyncSession, + user_id: UUID, + token_hash: str, + family_id: UUID, + expires_at: datetime, + device_id: str | None = None, + device_name: str | None = None, + ip_address: str | None = None, + ) -> RefreshToken: + """ + Create a new refresh token + """ + token = RefreshToken( + user_id = user_id, + token_hash = token_hash, + family_id = family_id, + expires_at = expires_at, + device_id = device_id, + device_name = device_name, + ip_address = ip_address, + ) + session.add(token) + await session.flush() + await session.refresh(token) + return token + + @classmethod + async def revoke_token( + cls, + session: AsyncSession, + token: RefreshToken, + ) -> RefreshToken: + """ + Revoke a single token + """ + token.revoke() + await session.flush() + await session.refresh(token) + return token + + @classmethod + async def revoke_family( + cls, + session: AsyncSession, + family_id: UUID, + ) -> int: + """ + Revoke all tokens in a family (for replay attack response) + + Returns count of revoked tokens + """ + result = await session.execute( + update(RefreshToken).where( + RefreshToken.family_id == family_id, + RefreshToken.is_revoked == False, + ).values(is_revoked = True, + revoked_at = datetime.now(UTC)) + ) + await session.flush() + return result.rowcount or 0 + + @classmethod + async def revoke_all_user_tokens( + cls, + session: AsyncSession, + user_id: UUID, + ) -> int: + """ + Revoke all tokens for a user (logout all devices) + + Returns count of revoked tokens + """ + result = await session.execute( + update(RefreshToken).where( + RefreshToken.user_id == user_id, + RefreshToken.is_revoked == False, + ).values(is_revoked = True, + revoked_at = datetime.now(UTC)) + ) + await session.flush() + return result.rowcount or 0 + + @classmethod + async def get_user_active_sessions( + cls, + session: AsyncSession, + user_id: UUID, + ) -> list[RefreshToken]: + """ + Get all active sessions for a user + """ + result = await session.execute( + select(RefreshToken).where( + RefreshToken.user_id == user_id, + RefreshToken.is_revoked == False, + RefreshToken.expires_at > datetime.now(UTC), + ) + ) + return list(result.scalars().all()) + + @classmethod + async def cleanup_expired( + cls, + session: AsyncSession, + ) -> int: + """ + Delete expired tokens (for maintenance job) + + Returns count of deleted tokens + """ + result = await session.execute( + select(RefreshToken).where( + RefreshToken.expires_at < datetime.now(UTC) + ) + ) + tokens = result.scalars().all() + for token in tokens: + await session.delete(token) + await session.flush() + return len(tokens) diff --git a/PROJECTS/bug-bounty-platform/backend/app/auth/routes.py b/PROJECTS/bug-bounty-platform/backend/app/auth/routes.py new file mode 100644 index 00000000..afbb10e3 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/auth/routes.py @@ -0,0 +1,154 @@ +""" +ⒸAngelaMos | 2025 +routes.py +""" + +from typing import Annotated + +from fastapi import ( + APIRouter, + Cookie, + Depends, + Request, + Response, + status, +) +from fastapi.security import ( + OAuth2PasswordRequestForm, +) + +from config import settings +from core.dependencies import ( + ClientIP, + CurrentUser, +) +from core.security import ( + clear_refresh_cookie, + set_refresh_cookie, +) +from core.rate_limit import limiter +from core.exceptions import TokenError +from .schemas import ( + PasswordChange, + TokenResponse, + TokenWithUserResponse, +) +from user.schemas import UserResponse +from .dependencies import AuthServiceDep +from user.dependencies import UserServiceDep +from core.responses import AUTH_401 + + +router = APIRouter(prefix = "/auth", tags = ["auth"]) + + +@router.post( + "/login", + response_model = TokenWithUserResponse, + responses = {**AUTH_401} +) +@limiter.limit(settings.RATE_LIMIT_AUTH) +async def login( + request: Request, + response: Response, + auth_service: AuthServiceDep, + ip: ClientIP, + form_data: Annotated[OAuth2PasswordRequestForm, + Depends()], +) -> TokenWithUserResponse: + """ + Login with email and password + """ + result, refresh_token = await auth_service.login( + email=form_data.username, + password=form_data.password, + ip_address=ip, + ) + set_refresh_cookie(response, refresh_token) + return result + + +@router.post( + "/refresh", + response_model = TokenResponse, + responses = {**AUTH_401} +) +async def refresh_token( + response: Response, + auth_service: AuthServiceDep, + ip: ClientIP, + refresh_token: str | None = Cookie(None), +) -> TokenResponse: + """ + Refresh access token + """ + if not refresh_token: + raise TokenError("Refresh token required") + result, new_refresh_token = await auth_service.refresh_tokens( + refresh_token, + ip_address = ip + ) + set_refresh_cookie(response, new_refresh_token) + return result + + +@router.post( + "/logout", + status_code = status.HTTP_204_NO_CONTENT, + responses = {**AUTH_401} +) +async def logout( + response: Response, + auth_service: AuthServiceDep, + refresh_token: str | None = Cookie(None), +) -> None: + """ + Logout current session + """ + if not refresh_token: + raise TokenError("Refresh token required") + await auth_service.logout(refresh_token) + clear_refresh_cookie(response) + + +@router.post("/logout-all", responses = {**AUTH_401}) +async def logout_all( + response: Response, + auth_service: AuthServiceDep, + current_user: CurrentUser, +) -> dict[str, + int]: + """ + Logout from all devices + """ + count = await auth_service.logout_all(current_user) + clear_refresh_cookie(response) + return {"revoked_sessions": count} + + +@router.get("/me", response_model = UserResponse, responses = {**AUTH_401}) +async def get_current_user(current_user: CurrentUser) -> UserResponse: + """ + Get current authenticated user + """ + return UserResponse.model_validate(current_user) + + +@router.post( + "/change-password", + status_code = status.HTTP_204_NO_CONTENT, + responses = {**AUTH_401} +) +async def change_password( + user_service: UserServiceDep, + current_user: CurrentUser, + data: PasswordChange, +) -> None: + """ + Change current user password + """ + await user_service.change_password( + current_user, + data.current_password, + data.new_password, + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/auth/schemas.py b/PROJECTS/bug-bounty-platform/backend/app/auth/schemas.py new file mode 100644 index 00000000..f5347924 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/auth/schemas.py @@ -0,0 +1,78 @@ +""" +ⒸAngelaMos | 2025 +schemas.py +""" + +from pydantic import ( + Field, + EmailStr, +) + +from config import ( + PASSWORD_MAX_LENGTH, + PASSWORD_MIN_LENGTH, +) +from core.base_schema import BaseSchema +from user.schemas import UserResponse + + +class LoginRequest(BaseSchema): + """ + Schema for login request + """ + email: EmailStr + password: str = Field( + min_length = PASSWORD_MIN_LENGTH, + max_length = PASSWORD_MAX_LENGTH + ) + + +class TokenResponse(BaseSchema): + """ + Schema for token response + """ + access_token: str + token_type: str = "bearer" + + +class TokenWithUserResponse(TokenResponse): + """ + Schema for login response with user data + """ + user: UserResponse + + +class RefreshTokenRequest(BaseSchema): + """ + Schema for refresh token request via body + """ + refresh_token: str + + +class PasswordResetRequest(BaseSchema): + """ + Schema for password reset request + """ + email: EmailStr + + +class PasswordResetConfirm(BaseSchema): + """ + Schema for password reset confirmation + """ + token: str + new_password: str = Field( + min_length = PASSWORD_MIN_LENGTH, + max_length = PASSWORD_MAX_LENGTH + ) + + +class PasswordChange(BaseSchema): + """ + Schema for changing password while authenticated + """ + current_password: str + new_password: str = Field( + min_length = PASSWORD_MIN_LENGTH, + max_length = PASSWORD_MAX_LENGTH + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/auth/service.py b/PROJECTS/bug-bounty-platform/backend/app/auth/service.py new file mode 100644 index 00000000..6e710b24 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/auth/service.py @@ -0,0 +1,213 @@ +""" +ⒸAngelaMos | 2025 +service.py +""" + +import uuid6 +from sqlalchemy.ext.asyncio import ( + AsyncSession, +) + +from core.exceptions import ( + InvalidCredentials, + TokenError, + TokenRevokedError, +) +from core.security import ( + hash_token, + create_access_token, + create_refresh_token, + verify_password_with_timing_safety, +) +from user.User import User +from user.repository import UserRepository +from .repository import RefreshTokenRepository +from .schemas import ( + TokenResponse, + TokenWithUserResponse, +) +from user.schemas import UserResponse + + +class AuthService: + """ + Business logic for authentication operations + """ + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def authenticate( + self, + email: str, + password: str, + device_id: str | None = None, + device_name: str | None = None, + ip_address: str | None = None, + ) -> tuple[str, + str, + User]: + """ + Authenticate user and create tokens + """ + user = await UserRepository.get_by_email(self.session, email) + hashed_password = user.hashed_password if user else None + + is_valid, new_hash = await verify_password_with_timing_safety( + password, hashed_password + ) + + if not is_valid or user is None: + raise InvalidCredentials() + + if not user.is_active: + raise InvalidCredentials() + + if new_hash: + await UserRepository.update_password( + self.session, + user, + new_hash + ) + + access_token = create_access_token(user.id, user.token_version) + + family_id = uuid6.uuid7() + raw_refresh, token_hash, expires_at = create_refresh_token(user.id, family_id) + + await RefreshTokenRepository.create_token( + self.session, + user_id = user.id, + token_hash = token_hash, + family_id = family_id, + expires_at = expires_at, + device_id = device_id, + device_name = device_name, + ip_address = ip_address, + ) + + return access_token, raw_refresh, user + + async def login( + self, + email: str, + password: str, + device_id: str | None = None, + device_name: str | None = None, + ip_address: str | None = None, + ) -> tuple[TokenWithUserResponse, + str]: + """ + Login and return tokens with user data + """ + access_token, refresh_token, user = await self.authenticate( + email, + password, + device_id, + device_name, + ip_address, + ) + + response = TokenWithUserResponse( + access_token = access_token, + user = UserResponse.model_validate(user), + ) + return response, refresh_token + + async def refresh_tokens( + self, + refresh_token: str, + device_id: str | None = None, + device_name: str | None = None, + ip_address: str | None = None, + ) -> tuple[TokenResponse, + str]: + """ + Refresh access token using refresh token + + Implements token rotation with replay attack detection + """ + token_hash = hash_token(refresh_token) + stored_token = await RefreshTokenRepository.get_by_hash( + self.session, + token_hash + ) + + if stored_token is None: + raise TokenError(message = "Invalid refresh token") + + if stored_token.is_revoked: + await RefreshTokenRepository.revoke_family( + self.session, + stored_token.family_id + ) + raise TokenRevokedError() + + if stored_token.is_expired: + raise TokenError(message = "Refresh token expired") + + user = await UserRepository.get_by_id( + self.session, + stored_token.user_id + ) + if user is None or not user.is_active: + raise TokenError(message = "User not found or inactive") + + await RefreshTokenRepository.revoke_token( + self.session, + stored_token + ) + + access_token = create_access_token(user.id, user.token_version) + + new_raw_token, new_hash, expires_at = create_refresh_token( + user.id, stored_token.family_id + ) + + await RefreshTokenRepository.create_token( + self.session, + user_id = user.id, + token_hash = new_hash, + family_id = stored_token.family_id, + expires_at = expires_at, + device_id = device_id, + device_name = device_name, + ip_address = ip_address, + ) + + return TokenResponse(access_token = access_token), new_raw_token + + async def logout( + self, + refresh_token: str, + ) -> None: + """ + Logout by revoking refresh token + + Silently succeeds if token is already revoked or doesn't exist + """ + token_hash = hash_token(refresh_token) + stored_token = await RefreshTokenRepository.get_by_hash( + self.session, + token_hash + ) + + if stored_token and not stored_token.is_revoked: + await RefreshTokenRepository.revoke_token( + self.session, + stored_token + ) + + async def logout_all( + self, + user: User, + ) -> int: + """ + Logout from all devices + + Returns count of revoked sessions + """ + await UserRepository.increment_token_version(self.session, user) + return await RefreshTokenRepository.revoke_all_user_tokens( + self.session, + user.id + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/config.py b/PROJECTS/bug-bounty-platform/backend/app/config.py new file mode 100644 index 00000000..75f34345 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/config.py @@ -0,0 +1,213 @@ +""" +ⒸAngelaMos | 2026 +config.py +""" + +from pathlib import Path +from typing import Literal +from functools import lru_cache + +from pydantic import ( + EmailStr, + Field, + RedisDsn, + SecretStr, + PostgresDsn, + model_validator, +) +from pydantic_settings import ( + BaseSettings, + SettingsConfigDict, +) + +from core.constants import ( + API_PREFIX, + API_VERSION, + ASSET_DESCRIPTION_MAX_LENGTH, + ASSET_IDENTIFIER_MAX_LENGTH, + BIO_MAX_LENGTH, + COMMENT_MAX_LENGTH, + COMPANY_NAME_MAX_LENGTH, + CURRENCY_MAX_LENGTH, + CWE_ID_MAX_LENGTH, + DEVICE_ID_MAX_LENGTH, + DEVICE_NAME_MAX_LENGTH, + EMAIL_MAX_LENGTH, + FILENAME_MAX_LENGTH, + FULL_NAME_MAX_LENGTH, + IP_ADDRESS_MAX_LENGTH, + MIME_TYPE_MAX_LENGTH, + PASSWORD_HASH_MAX_LENGTH, + PASSWORD_MAX_LENGTH, + PASSWORD_MIN_LENGTH, + PROGRAM_DESCRIPTION_MAX_LENGTH, + PROGRAM_NAME_MAX_LENGTH, + PROGRAM_RULES_MAX_LENGTH, + PROGRAM_SLUG_MAX_LENGTH, + REPORT_DESCRIPTION_MAX_LENGTH, + REPORT_IMPACT_MAX_LENGTH, + REPORT_STEPS_MAX_LENGTH, + REPORT_TITLE_MAX_LENGTH, + STORAGE_PATH_MAX_LENGTH, + TOKEN_HASH_LENGTH, + WEBSITE_MAX_LENGTH, +) +from core.enums import ( + AssetType, + Environment, + HealthStatus, + ProgramStatus, + ProgramVisibility, + ReportStatus, + SafeEnum, + Severity, + TokenType, + UserRole, +) + + +__all__ = [ + "API_PREFIX", + "API_VERSION", + "ASSET_DESCRIPTION_MAX_LENGTH", + "ASSET_IDENTIFIER_MAX_LENGTH", + "BIO_MAX_LENGTH", + "COMMENT_MAX_LENGTH", + "COMPANY_NAME_MAX_LENGTH", + "CURRENCY_MAX_LENGTH", + "CWE_ID_MAX_LENGTH", + "DEVICE_ID_MAX_LENGTH", + "DEVICE_NAME_MAX_LENGTH", + "EMAIL_MAX_LENGTH", + "FILENAME_MAX_LENGTH", + "FULL_NAME_MAX_LENGTH", + "IP_ADDRESS_MAX_LENGTH", + "MIME_TYPE_MAX_LENGTH", + "PASSWORD_HASH_MAX_LENGTH", + "PASSWORD_MAX_LENGTH", + "PASSWORD_MIN_LENGTH", + "PROGRAM_DESCRIPTION_MAX_LENGTH", + "PROGRAM_NAME_MAX_LENGTH", + "PROGRAM_RULES_MAX_LENGTH", + "PROGRAM_SLUG_MAX_LENGTH", + "REPORT_DESCRIPTION_MAX_LENGTH", + "REPORT_IMPACT_MAX_LENGTH", + "REPORT_STEPS_MAX_LENGTH", + "REPORT_TITLE_MAX_LENGTH", + "STORAGE_PATH_MAX_LENGTH", + "TOKEN_HASH_LENGTH", + "WEBSITE_MAX_LENGTH", + "AssetType", + "Environment", + "HealthStatus", + "ProgramStatus", + "ProgramVisibility", + "ReportStatus", + "SafeEnum", + "Settings", + "Severity", + "TokenType", + "UserRole", + "get_settings", + "settings", +] + +_PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent +_ENV_FILE = _PROJECT_ROOT / ".env" + + +class Settings(BaseSettings): + """ + Application settings loaded from environment variables + """ + model_config = SettingsConfigDict( + env_file = _ENV_FILE, + env_file_encoding = "utf-8", + case_sensitive = False, + extra = "ignore", + ) + + APP_NAME: str = "bug-bounty-platform" + APP_VERSION: str = "1.0.0" + APP_SUMMARY: str = "Developed CarterPerez-dev" + APP_DESCRIPTION: str = "FastAPI async first boilerplate - JWT, Asyncdb, PostgreSQL" + APP_CONTACT_NAME: str = "AngelaMos LLC" + APP_CONTACT_EMAIL: str = "support@certgames.com" + APP_LICENSE_NAME: str = "MIT" + APP_LICENSE_URL: str = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/blob/main/LICENSE" + + ENVIRONMENT: Environment = Environment.DEVELOPMENT + DEBUG: bool = False + + HOST: str = "0.0.0.0" + PORT: int = 8000 + RELOAD: bool = True + + DATABASE_URL: PostgresDsn + DB_POOL_SIZE: int = Field(default = 20, ge = 5, le = 100) + DB_MAX_OVERFLOW: int = Field(default = 10, ge = 0, le = 50) + DB_POOL_TIMEOUT: int = Field(default = 30, ge = 10) + DB_POOL_RECYCLE: int = Field(default = 1800, ge = 300) + + SECRET_KEY: SecretStr = Field(..., min_length = 32) + JWT_ALGORITHM: Literal["HS256", "HS384", "HS512"] = "HS256" + ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default = 15, ge = 5, le = 60) + REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default = 7, ge = 1, le = 30) + + ADMIN_EMAIL: EmailStr | None = None + + REDIS_URL: RedisDsn | None = None + + CORS_ORIGINS: list[str] = [ + "http://localhost", + "http://localhost:3420", + "http://localhost:8420", + ] + CORS_ALLOW_CREDENTIALS: bool = True + CORS_ALLOW_METHODS: list[str] = [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE", + "OPTIONS" + ] + CORS_ALLOW_HEADERS: list[str] = ["*"] + + RATE_LIMIT_DEFAULT: str = "100/minute" + RATE_LIMIT_AUTH: str = "20/minute" + + PAGINATION_DEFAULT_SIZE: int = Field(default = 20, ge = 1, le = 100) + PAGINATION_MAX_SIZE: int = Field(default = 100, ge = 1, le = 500) + + LOG_LEVEL: Literal["DEBUG", + "INFO", + "WARNING", + "ERROR", + "CRITICAL"] = "INFO" + LOG_JSON_FORMAT: bool = True + + @model_validator(mode = "after") + def validate_production_settings(self) -> "Settings": + """ + Enforce security constraints in production environment. + """ + if self.ENVIRONMENT == Environment.PRODUCTION: + if self.DEBUG: + raise ValueError("DEBUG must be False in production") + if self.CORS_ORIGINS == ["*"]: + raise ValueError( + "CORS_ORIGINS cannot be ['*'] in production" + ) + return self + + +@lru_cache +def get_settings() -> Settings: + """ + Cached settings instance to avoid repeated env parsing + """ + return Settings() + + +settings = get_settings() diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/Base.py b/PROJECTS/bug-bounty-platform/backend/app/core/Base.py new file mode 100644 index 00000000..e5f2f80e --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/Base.py @@ -0,0 +1,84 @@ +""" +ⒸAngelaMos | 2025 +Base.py +""" + +from uuid import UUID +from datetime import UTC, datetime + +import uuid6 +from sqlalchemy.orm import ( + Mapped, + mapped_column, + DeclarativeBase, +) +from sqlalchemy import ( + DateTime, + MetaData, + func, +) +from sqlalchemy.ext.asyncio import AsyncAttrs + + +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +class Base(AsyncAttrs, DeclarativeBase): + """ + Base class for all SQLAlchemy models + """ + metadata = MetaData(naming_convention = NAMING_CONVENTION) + + +class UUIDMixin: + """ + Mixin for UUID v7 primary key + + UUID v7 is time sortable and distributed safe, optimal for B tree indexes + """ + id: Mapped[UUID] = mapped_column( + primary_key = True, + default = uuid6.uuid7, + ) + + +class TimestampMixin: + """ + Mixin for created_at and updated_at timestamps + """ + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone = True), + default = lambda: datetime.now(UTC), + server_default = func.now(), + ) + updated_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone = True), + default = None, + onupdate = lambda: datetime.now(UTC), + server_onupdate = func.now(), + ) + + +class SoftDeleteMixin: + """ + Mixin for soft delete functionality + """ + deleted_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone = True), + default = None, + ) + is_deleted: Mapped[bool] = mapped_column(default = False) + + def soft_delete(self) -> None: + self.is_deleted = True + self.deleted_at = datetime.now(UTC) + + def restore(self) -> None: + self.is_deleted = False + self.deleted_at = None diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/__init__.py b/PROJECTS/bug-bounty-platform/backend/app/core/__init__.py new file mode 100644 index 00000000..e863581c --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/__init__.py @@ -0,0 +1,4 @@ +""" +ⒸAngelaMos | 2025 +__init__.py +""" diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/base_repository.py b/PROJECTS/bug-bounty-platform/backend/app/core/base_repository.py new file mode 100644 index 00000000..030ea6a5 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/base_repository.py @@ -0,0 +1,106 @@ +""" +ⒸAngelaMos | 2025 +base_repository.py +""" + +from collections.abc import Sequence +from typing import ( + Any, + Generic, + TypeVar, +) +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from .Base import Base + + +ModelT = TypeVar("ModelT", bound = Base) + + +class BaseRepository(Generic[ModelT]): + """ + Generic repository with common CRUD operations + """ + model: type[ModelT] + + @classmethod + async def get_by_id( + cls, + session: AsyncSession, + id: UUID, + ) -> ModelT | None: + """ + Get a single record by ID + """ + return await session.get(cls.model, id) + + @classmethod + async def get_multi( + cls, + session: AsyncSession, + skip: int = 0, + limit: int = 100, + ) -> Sequence[ModelT]: + """ + Get multiple records with pagination + """ + result = await session.execute( + select(cls.model).offset(skip).limit(limit) + ) + return result.scalars().all() + + @classmethod + async def count(cls, session: AsyncSession) -> int: + """ + Count total records + """ + result = await session.execute( + select(func.count()).select_from(cls.model) + ) + return result.scalar_one() + + @classmethod + async def create( + cls, + session: AsyncSession, + **kwargs: Any, + ) -> ModelT: + """ + Create a new record + """ + instance = cls.model(**kwargs) + session.add(instance) + await session.flush() + await session.refresh(instance) + return instance + + @classmethod + async def update( + cls, + session: AsyncSession, + instance: ModelT, + **kwargs: Any, + ) -> ModelT: + """ + Update an existing record + """ + for key, value in kwargs.items(): + setattr(instance, key, value) + await session.flush() + await session.refresh(instance) + return instance + + @classmethod + async def delete( + cls, + session: AsyncSession, + instance: ModelT, + ) -> None: + """ + Delete a record + """ + await session.delete(instance) + await session.flush() diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/base_schema.py b/PROJECTS/bug-bounty-platform/backend/app/core/base_schema.py new file mode 100644 index 00000000..183e7d6c --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/base_schema.py @@ -0,0 +1,50 @@ +""" +ⒸAngelaMos | 2025 +base.py +""" + +from typing import Any +from collections.abc import Callable +from decimal import Decimal +from uuid import UUID +from datetime import datetime + +from pydantic import ( + BaseModel, + ConfigDict, + field_serializer, +) + + +class BaseSchema(BaseModel): + """ + Base schema with common configuration + """ + model_config = ConfigDict( + from_attributes = True, + str_strip_whitespace = True, + ) + + @field_serializer('*', mode = 'wrap', when_used = 'json') + def serialize_decimals( + self, + value: Any, + nxt: Callable[[Any], + Any], + _info: Any, + ) -> Any: + """ + Serialize Decimal fields as float for JSON compatibility + """ + if isinstance(value, Decimal): + return float(value) + return nxt(value) + + +class BaseResponseSchema(BaseSchema): + """ + Base schema for API responses with common fields + """ + id: UUID + created_at: datetime + updated_at: datetime | None = None diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/common_schemas.py b/PROJECTS/bug-bounty-platform/backend/app/core/common_schemas.py new file mode 100644 index 00000000..c47029bd --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/common_schemas.py @@ -0,0 +1,34 @@ +""" +ⒸAngelaMos | 2025 +common_schemas.py +""" + +from config import HealthStatus +from .base_schema import BaseSchema + + +class HealthResponse(BaseSchema): + """ + Health check response + """ + status: HealthStatus + environment: str + version: str + + +class HealthDetailedResponse(HealthResponse): + """ + Detailed health check with component status + """ + database: HealthStatus + redis: HealthStatus | None = None + + +class AppInfoResponse(BaseSchema): + """ + Root endpoint response with API information + """ + name: str + version: str + environment: str + docs_url: str | None diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/constants.py b/PROJECTS/bug-bounty-platform/backend/app/core/constants.py new file mode 100644 index 00000000..7be8b006 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/constants.py @@ -0,0 +1,44 @@ +""" +ⒸAngelaMos | 2025 +constants.py +""" + +EMAIL_MAX_LENGTH = 320 +PASSWORD_MIN_LENGTH = 8 +PASSWORD_MAX_LENGTH = 128 +PASSWORD_HASH_MAX_LENGTH = 1024 +FULL_NAME_MAX_LENGTH = 255 + +TOKEN_HASH_LENGTH = 64 +DEVICE_ID_MAX_LENGTH = 255 +DEVICE_NAME_MAX_LENGTH = 100 +IP_ADDRESS_MAX_LENGTH = 45 + +API_VERSION = "v1" +API_PREFIX = f"/{API_VERSION}" + +COMPANY_NAME_MAX_LENGTH = 255 +BIO_MAX_LENGTH = 2000 +WEBSITE_MAX_LENGTH = 500 + +PROGRAM_NAME_MAX_LENGTH = 255 +PROGRAM_SLUG_MAX_LENGTH = 100 +PROGRAM_DESCRIPTION_MAX_LENGTH = 10000 +PROGRAM_RULES_MAX_LENGTH = 50000 + +ASSET_IDENTIFIER_MAX_LENGTH = 500 +ASSET_DESCRIPTION_MAX_LENGTH = 2000 + +CURRENCY_MAX_LENGTH = 3 + +REPORT_TITLE_MAX_LENGTH = 500 +REPORT_DESCRIPTION_MAX_LENGTH = 50000 +REPORT_STEPS_MAX_LENGTH = 50000 +REPORT_IMPACT_MAX_LENGTH = 10000 +CWE_ID_MAX_LENGTH = 20 + +COMMENT_MAX_LENGTH = 20000 + +FILENAME_MAX_LENGTH = 255 +STORAGE_PATH_MAX_LENGTH = 500 +MIME_TYPE_MAX_LENGTH = 100 diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/database.py b/PROJECTS/bug-bounty-platform/backend/app/core/database.py new file mode 100644 index 00000000..03ffeef1 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/database.py @@ -0,0 +1,160 @@ +""" +ⒸAngelaMos | 2025 +database.py +""" + +import contextlib +from collections.abc import ( + AsyncIterator, + Iterator, +) + +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.engine.url import make_url +from sqlalchemy.ext.asyncio import ( + AsyncEngine, + AsyncSession, + AsyncConnection, + async_sessionmaker, + create_async_engine, +) +from sqlalchemy.orm import Session, sessionmaker + +from config import settings + + +class DatabaseSessionManager: + """ + Manages database connections and sessions for both sync and async contexts + """ + def __init__(self) -> None: + self._async_engine: AsyncEngine | None = None + self._sync_engine: Engine | None = None + self._async_sessionmaker: async_sessionmaker[AsyncSession + ] | None = None + self._sync_sessionmaker: sessionmaker[Session] | None = None + + def init(self, database_url: str) -> None: + """ + Initialize database engines and session factories + """ + base_url = make_url(database_url) + + async_url = base_url.set(drivername = "postgresql+asyncpg") + self._async_engine = create_async_engine( + async_url, + pool_size = settings.DB_POOL_SIZE, + max_overflow = settings.DB_MAX_OVERFLOW, + pool_timeout = settings.DB_POOL_TIMEOUT, + pool_recycle = settings.DB_POOL_RECYCLE, + pool_pre_ping = True, + echo = settings.DEBUG, + ) + self._async_sessionmaker = async_sessionmaker( + bind = self._async_engine, + class_ = AsyncSession, + autocommit = False, + autoflush = False, + expire_on_commit = False, + ) + + sync_url = base_url.set(drivername = "postgresql+psycopg2") + self._sync_engine = create_engine( + sync_url, + pool_size = settings.DB_POOL_SIZE, + max_overflow = settings.DB_MAX_OVERFLOW, + pool_timeout = settings.DB_POOL_TIMEOUT, + pool_recycle = settings.DB_POOL_RECYCLE, + pool_pre_ping = True, + echo = settings.DEBUG, + ) + self._sync_sessionmaker = sessionmaker( + bind = self._sync_engine, + autocommit = False, + autoflush = False, + expire_on_commit = False, + ) + + async def close(self) -> None: + """ + Dispose of all database connections + """ + if self._async_engine: + await self._async_engine.dispose() + self._async_engine = None + self._async_sessionmaker = None + + if self._sync_engine: + self._sync_engine.dispose() + self._sync_engine = None + self._sync_sessionmaker = None + + @contextlib.asynccontextmanager + async def session(self) -> AsyncIterator[AsyncSession]: + """ + Async context manager for database sessions + + Handles commit on success, rollback on exception + """ + if self._async_sessionmaker is None: + raise RuntimeError("DatabaseSessionManager is not initialized") + + session = self._async_sessionmaker() + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + @contextlib.asynccontextmanager + async def connect(self) -> AsyncIterator[AsyncConnection]: + """ + Async context manager for raw database connections + """ + if self._async_engine is None: + raise RuntimeError("DatabaseSessionManager is not initialized") + + async with self._async_engine.begin() as connection: + yield connection + + @property + def sync_engine(self) -> Engine: + """ + Sync engine for Alembic migrations + """ + if self._sync_engine is None: + raise RuntimeError("DatabaseSessionManager is not initialized") + return self._sync_engine + + @contextlib.contextmanager + def sync_session(self) -> Iterator[Session]: + """ + Sync context manager for migrations and CLI tools + """ + if self._sync_sessionmaker is None: + raise RuntimeError("DatabaseSessionManager is not initialized") + + session = self._sync_sessionmaker() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + +sessionmanager = DatabaseSessionManager() + + +async def get_db_session() -> AsyncIterator[AsyncSession]: + """ + FastAPI dependency for database sessions + """ + async with sessionmanager.session() as session: + yield session diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/dependencies.py b/PROJECTS/bug-bounty-platform/backend/app/core/dependencies.py new file mode 100644 index 00000000..dfa80969 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/dependencies.py @@ -0,0 +1,146 @@ +""" +ⒸAngelaMos | 2025 +dependencies.py +""" + +from __future__ import annotations + +from typing import Annotated +from uuid import UUID + +import jwt +from fastapi import Depends, Request +from fastapi.security import OAuth2PasswordBearer +from sqlalchemy.ext.asyncio import AsyncSession + +from config import ( + API_PREFIX, + TokenType, + UserRole, +) +from .database import get_db_session +from .exceptions import ( + InactiveUser, + PermissionDenied, + TokenError, + TokenRevokedError, + UserNotFound, +) +from user.User import User +from .security import decode_access_token +from user.repository import UserRepository + + +oauth2_scheme = OAuth2PasswordBearer( + tokenUrl = f"{API_PREFIX}/auth/login", + auto_error = True, +) + +oauth2_scheme_optional = OAuth2PasswordBearer( + tokenUrl = f"{API_PREFIX}/auth/login", + auto_error = False, +) + +DBSession = Annotated[AsyncSession, Depends(get_db_session)] + + +async def get_current_user( + token: Annotated[str, + Depends(oauth2_scheme)], + db: DBSession, +) -> User: + """ + Validate access token and return current user + """ + try: + payload = decode_access_token(token) + except jwt.InvalidTokenError as e: + raise TokenError(message = str(e)) from e + + if payload.get("type") != TokenType.ACCESS.value: + raise TokenError(message = "Invalid token type") + + user_id = UUID(payload["sub"]) + user = await UserRepository.get_by_id(db, user_id) + + if user is None: + raise UserNotFound(identifier = str(user_id)) + + if payload.get("token_version") != user.token_version: + raise TokenRevokedError() + + return user + + +async def get_current_active_user( + user: Annotated[User, + Depends(get_current_user)], +) -> User: + """ + Ensure user is active + """ + if not user.is_active: + raise InactiveUser() + return user + + +async def get_optional_user( + token: Annotated[str | None, + Depends(oauth2_scheme_optional)], + db: DBSession, +) -> User | None: + """ + Return current user if authenticated, None otherwise + """ + if token is None: + return None + + try: + payload = decode_access_token(token) + if payload.get("type") != TokenType.ACCESS.value: + return None + user_id = UUID(payload["sub"]) + user = await UserRepository.get_by_id(db, user_id) + if user and user.token_version == payload.get("token_version"): + return user + except (jwt.InvalidTokenError, ValueError): + pass + + return None + + +class RequireRole: + """ + Dependency class to check user role + """ + def __init__(self, *allowed_roles: UserRole) -> None: + self.allowed_roles = allowed_roles + + async def __call__( + self, + user: Annotated[User, + Depends(get_current_active_user)], + ) -> User: + if user.role not in self.allowed_roles: + raise PermissionDenied( + message = + f"Requires one of roles: {', '.join(r.value for r in self.allowed_roles)}", + ) + return user + + +CurrentUser = Annotated["User", Depends(get_current_active_user)] +OptionalUser = Annotated["User | None", Depends(get_optional_user)] + + +def get_client_ip(request: Request) -> str: + """ + Extract client IP considering proxy headers + """ + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +ClientIP = Annotated[str, Depends(get_client_ip)] diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/enums.py b/PROJECTS/bug-bounty-platform/backend/app/core/enums.py new file mode 100644 index 00000000..1b56a8a1 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/enums.py @@ -0,0 +1,139 @@ +""" +ⒸAngelaMos | 2025 +enums.py +""" + +from enum import Enum +from typing import Any + +import sqlalchemy as sa + + +def enum_values_callable(enum_class: type[Enum]) -> list[str]: + """ + Returns enum VALUES (not names) for SQLAlchemy storage + + Prevents the common trap where SQLAlchemy stores enum NAMES by default, + causing database breakage if you rename an enum member + """ + return [str(item.value) for item in enum_class] + + +class SafeEnum(sa.Enum): + """ + SQLAlchemy Enum type that stores VALUES and handles unknown values gracefully + + https://blog.wrouesnel.com/posts/sqlalchemy-enums-careful-what-goes-into-the-database/ + """ + def __init__(self, *enums: type[Enum], **kw: Any) -> None: + if "values_callable" not in kw: + kw["values_callable"] = enum_values_callable + super().__init__(*enums, **kw) + self._unknown_value = ( + kw["_adapted_from"]._unknown_value + if "_adapted_from" in kw else kw.get("unknown_value") + ) + + def _object_value_for_elem(self, elem: str) -> Enum: + """ + Override to return unknown_value instead of raising LookupError + """ + try: + return self._object_lookup[elem] + except LookupError: + if self._unknown_value is not None: + return self._unknown_value + raise + + +class Environment(str, Enum): + """ + Application environment. + """ + DEVELOPMENT = "development" + STAGING = "staging" + PRODUCTION = "production" + + +class UserRole(str, Enum): + """ + User roles for authorization. + """ + UNKNOWN = "unknown" + USER = "user" + COMPANY = "company" + ADMIN = "admin" + + +class TokenType(str, Enum): + """ + JWT token types. + """ + ACCESS = "access" + REFRESH = "refresh" + + +class HealthStatus(str, Enum): + """ + Health check status values. + """ + HEALTHY = "healthy" + UNHEALTHY = "unhealthy" + DEGRADED = "degraded" + + +class ProgramStatus(str, Enum): + """ + Bug bounty program lifecycle status. + """ + DRAFT = "draft" + ACTIVE = "active" + PAUSED = "paused" + CLOSED = "closed" + + +class ProgramVisibility(str, Enum): + """ + Bug bounty program visibility level. + """ + PUBLIC = "public" + PRIVATE = "private" + INVITE_ONLY = "invite_only" + + +class AssetType(str, Enum): + """ + Type of asset in a bug bounty program scope. + """ + DOMAIN = "domain" + API = "api" + MOBILE_APP = "mobile_app" + SOURCE_CODE = "source_code" + HARDWARE = "hardware" + OTHER = "other" + + +class Severity(str, Enum): + """ + Vulnerability severity levels aligned with CVSS. + """ + CRITICAL = "critical" + HIGH = "high" + MEDIUM = "medium" + LOW = "low" + INFORMATIONAL = "informational" + + +class ReportStatus(str, Enum): + """ + Vulnerability report lifecycle status. + """ + NEW = "new" + TRIAGING = "triaging" + NEEDS_MORE_INFO = "needs_more_info" + ACCEPTED = "accepted" + DUPLICATE = "duplicate" + INFORMATIVE = "informative" + NOT_APPLICABLE = "not_applicable" + RESOLVED = "resolved" + DISCLOSED = "disclosed" diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/error_schemas.py b/PROJECTS/bug-bounty-platform/backend/app/core/error_schemas.py new file mode 100644 index 00000000..28795432 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/error_schemas.py @@ -0,0 +1,27 @@ +""" +ⒸAngelaMos | 2025 +errors.py +""" + +from typing import ClassVar +from pydantic import Field, ConfigDict +from core.base_schema import BaseSchema + + +class ErrorDetail(BaseSchema): + """ + Standard error response format + """ + detail: str = Field(..., description = "Human readable error message") + type: str = Field(..., description = "Exception class name") + + model_config: ClassVar[ConfigDict] = ConfigDict( + json_schema_extra = { + "examples": [ + { + "detail": "User with id '123' not found", + "type": "UserNotFound" + } + ] + } + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/exceptions.py b/PROJECTS/bug-bounty-platform/backend/app/core/exceptions.py new file mode 100644 index 00000000..480b764c --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/exceptions.py @@ -0,0 +1,340 @@ +""" +ⒸAngelaMos | 2025 +exceptions.py +""" + +from typing import Any + + +class BaseAppException(Exception): + """ + Base exception for all application specific errors + """ + def __init__( + self, + message: str, + status_code: int = 500, + extra: dict[str, + Any] | None = None, + ) -> None: + self.message = message + self.status_code = status_code + self.extra = extra or {} + super().__init__(self.message) + + +class ResourceNotFound(BaseAppException): + """ + Raised when a requested resource does not exist + """ + def __init__( + self, + resource: str, + identifier: str | int, + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + message = f"{resource} with id '{identifier}' not found", + status_code = 404, + extra = extra, + ) + self.resource = resource + self.identifier = identifier + + +class ConflictError(BaseAppException): + """ + Raised when an operation conflicts with existing state + """ + def __init__( + self, + message: str, + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + message = message, + status_code = 409, + extra = extra + ) + + +class ValidationError(BaseAppException): + """ + Raised when input validation fails outside of Pydantic + """ + def __init__( + self, + message: str, + field: str | None = None, + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + message = message, + status_code = 422, + extra = extra + ) + self.field = field + + +class AuthenticationError(BaseAppException): + """ + Raised when authentication fails + """ + def __init__( + self, + message: str = "Authentication failed", + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + message = message, + status_code = 401, + extra = extra + ) + + +class TokenError(AuthenticationError): + """ + Raised for JWT token specific errors + """ + def __init__( + self, + message: str = "Invalid or expired token", + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__(message = message, extra = extra) + + +class TokenRevokedError(TokenError): + """ + Raised when a revoked token is used + """ + def __init__(self, extra: dict[str, Any] | None = None) -> None: + super().__init__(message = "Token has been revoked", extra = extra) + + +class PermissionDenied(BaseAppException): + """ + Raised when user lacks required permissions + """ + def __init__( + self, + message: str = "Permission denied", + required_permission: str | None = None, + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + message = message, + status_code = 403, + extra = extra + ) + self.required_permission = required_permission + + +class RateLimitExceeded(BaseAppException): + """ + Raised when rate limit is exceeded + """ + def __init__( + self, + message: str = "Calm down a little bit...", + retry_after: int | None = None, + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + message = message, + status_code = 420, + extra = extra + ) + self.retry_after = retry_after + + +class UserNotFound(ResourceNotFound): + """ + Raised when a user is not found + """ + def __init__( + self, + identifier: str | int, + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + resource = "User", + identifier = identifier, + extra = extra + ) + + +class EmailAlreadyExists(ConflictError): + """ + Raised when attempting to register with an existing email + """ + def __init__( + self, + email: str, + extra: dict[str, + Any] | None = None + ) -> None: + super().__init__( + message = f"Email '{email}' is already registered", + extra = extra, + ) + self.email = email + + +class InvalidCredentials(AuthenticationError): + """ + Raised when login credentials are invalid + """ + def __init__(self, extra: dict[str, Any] | None = None) -> None: + super().__init__( + message = "Invalid email or password", + extra = extra + ) + + +class InactiveUser(AuthenticationError): + """ + Raised when an inactive user attempts to authenticate. + """ + def __init__(self, extra: dict[str, Any] | None = None) -> None: + super().__init__( + message = "User account is inactive", + extra = extra + ) + + +class ProgramNotFound(ResourceNotFound): + """ + Raised when a program is not found + """ + def __init__( + self, + identifier: str | int, + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + resource = "Program", + identifier = identifier, + extra = extra + ) + + +class SlugAlreadyExists(ConflictError): + """ + Raised when attempting to create a program with existing slug + """ + def __init__( + self, + slug: str, + extra: dict[str, + Any] | None = None + ) -> None: + super().__init__( + message = f"Program with slug '{slug}' already exists", + extra = extra, + ) + self.slug = slug + + +class AssetNotFound(ResourceNotFound): + """ + Raised when an asset is not found + """ + def __init__( + self, + identifier: str | int, + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + resource = "Asset", + identifier = identifier, + extra = extra + ) + + +class NotProgramOwner(PermissionDenied): + """ + Raised when user tries to modify a program they don't own + """ + def __init__(self, extra: dict[str, Any] | None = None) -> None: + super().__init__( + message = "You are not the owner of this program", + extra = extra + ) + + +class ReportNotFound(ResourceNotFound): + """ + Raised when a report is not found + """ + def __init__( + self, + identifier: str | int, + extra: dict[str, + Any] | None = None, + ) -> None: + super().__init__( + resource = "Report", + identifier = identifier, + extra = extra + ) + + +class NotReportOwner(PermissionDenied): + """ + Raised when user tries to modify a report they don't own + """ + def __init__(self, extra: dict[str, Any] | None = None) -> None: + super().__init__( + message = "You are not the owner of this report", + extra = extra + ) + + +class ProgramNotActive(ValidationError): + """ + Raised when trying to submit to an inactive program + """ + def __init__(self, extra: dict[str, Any] | None = None) -> None: + super().__init__( + message = "This program is not accepting submissions", + extra = extra + ) + + +class CannotSubmitToOwnProgram(ValidationError): + """ + Raised when user tries to submit a report to their own program + """ + def __init__(self, extra: dict[str, Any] | None = None) -> None: + super().__init__( + message = "You cannot submit reports to your own program", + extra = extra + ) + + +class InvalidStatusTransition(ValidationError): + """ + Raised when an invalid status transition is attempted + """ + def __init__( + self, + current: str, + target: str, + extra: dict[str, + Any] | None = None + ) -> None: + super().__init__( + message = f"Cannot transition from '{current}' to '{target}'", + extra = extra + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/health_routes.py b/PROJECTS/bug-bounty-platform/backend/app/core/health_routes.py new file mode 100644 index 00000000..b02a8ee6 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/health_routes.py @@ -0,0 +1,79 @@ +""" +ⒸAngelaMos | 2025 +health_routes.py +""" + +from fastapi import ( + APIRouter, + status, +) +import redis.asyncio as redis +from sqlalchemy import text + +from config import ( + settings, + HealthStatus, +) +from .common_schemas import ( + HealthResponse, + HealthDetailedResponse, +) +from .database import sessionmanager + + +router = APIRouter(tags = ["health"]) + + +@router.get( + "/health", + response_model = HealthResponse, + status_code = status.HTTP_200_OK, +) +async def health_check() -> HealthResponse: + """ + Basic health check + """ + return HealthResponse( + status = HealthStatus.HEALTHY, + environment = settings.ENVIRONMENT.value, + version = settings.APP_VERSION, + ) + + +@router.get( + "/health/detailed", + response_model = HealthDetailedResponse, + status_code = status.HTTP_200_OK, +) +async def health_check_detailed() -> HealthDetailedResponse: + """ + Detailed health check including database connectivity + """ + db_status = HealthStatus.UNHEALTHY + redis_status = None + + try: + async with sessionmanager.connect() as conn: + await conn.execute(text("SELECT 1")) + db_status = HealthStatus.HEALTHY + except Exception: + db_status = HealthStatus.UNHEALTHY + + if settings.REDIS_URL: + try: + r = redis.from_url(str(settings.REDIS_URL)) + await r.ping() + redis_status = HealthStatus.HEALTHY + await r.close() + except Exception: + redis_status = HealthStatus.UNHEALTHY + + overall = HealthStatus.HEALTHY if db_status == HealthStatus.HEALTHY else HealthStatus.DEGRADED + + return HealthDetailedResponse( + status = overall, + environment = settings.ENVIRONMENT.value, + version = settings.APP_VERSION, + database = db_status, + redis = redis_status, + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/logging.py b/PROJECTS/bug-bounty-platform/backend/app/core/logging.py new file mode 100644 index 00000000..515ac3f7 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/logging.py @@ -0,0 +1,79 @@ +""" +ⒸAngelaMos | 2025 +logging.py +""" + +import logging +import sys + +import structlog +from structlog.types import Processor + +from config import ( + settings, + Environment, +) + + +def configure_logging() -> None: + """ + Structlog with appropriate processors for the environment + """ + shared_processors: list[Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt = "iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.UnicodeDecoder(), + ] + + if settings.ENVIRONMENT == Environment.PRODUCTION: + shared_processors.append(structlog.processors.format_exc_info) + renderer: Processor = structlog.processors.JSONRenderer() + else: + renderer = structlog.dev.ConsoleRenderer(colors = True) + + structlog.configure( + processors = shared_processors + [ + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory = structlog.stdlib.LoggerFactory(), + wrapper_class = structlog.stdlib.BoundLogger, + cache_logger_on_first_use = True, + ) + + formatter = structlog.stdlib.ProcessorFormatter( + foreign_pre_chain = shared_processors, + processors = [ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer, + ], + ) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(settings.LOG_LEVEL) + + for logger_name in ["uvicorn", + "uvicorn.access", + "uvicorn.error", + "sqlalchemy.engine"]: + logger = logging.getLogger(logger_name) + logger.handlers.clear() + logger.addHandler(handler) + logger.propagate = False + + if settings.ENVIRONMENT != Environment.DEVELOPMENT: + logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING) + + +def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger: + """ + Get a structured logger instance + """ + return structlog.get_logger(name) diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/py.typed b/PROJECTS/bug-bounty-platform/backend/app/core/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/rate_limit.py b/PROJECTS/bug-bounty-platform/backend/app/core/rate_limit.py new file mode 100644 index 00000000..34efe551 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/rate_limit.py @@ -0,0 +1,44 @@ +""" +ⒸAngelaMos | 2025 +rate_limit.py +""" + +import jwt +from slowapi import Limiter +from slowapi.util import get_remote_address +from starlette.requests import Request + +from config import settings + + +def get_identifier(request: Request) -> str: + """ + Get rate limit identifier + + Uses user ID if authenticated, otherwise falls back to IP address + (Will add more fingerprinting if needed depending on project) + """ + auth_header = request.headers.get("Authorization") + if auth_header and auth_header.startswith("Bearer "): + try: + token = auth_header.split(" ")[1] + payload = jwt.decode( + token, + options = {"verify_signature": False}, + ) + user_id = payload.get("sub") + if user_id: + return f"user:{user_id}" + except Exception: + pass + + return get_remote_address(request) + + +limiter = Limiter( + key_func = get_identifier, + storage_uri = str(settings.REDIS_URL) if settings.REDIS_URL else None, + default_limits = [settings.RATE_LIMIT_DEFAULT], + headers_enabled = True, + in_memory_fallback_enabled = True, +) diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/responses.py b/PROJECTS/bug-bounty-platform/backend/app/core/responses.py new file mode 100644 index 00000000..4f7b0132 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/responses.py @@ -0,0 +1,45 @@ +""" +ⒸAngelaMos | 2025 +responses.py +""" + +from typing import Any + +from .error_schemas import ErrorDetail + + +AUTH_401: dict[int | str, + dict[str, + Any]] = { + 401: { + "model": ErrorDetail, + "description": "Authentication failed" + }, + } + +FORBIDDEN_403: dict[int | str, + dict[str, + Any]] = { + 403: { + "model": ErrorDetail, + "description": "Permission denied" + }, + } + +NOT_FOUND_404: dict[int | str, + dict[str, + Any]] = { + 404: { + "model": ErrorDetail, + "description": "Resource not found" + }, + } + +CONFLICT_409: dict[int | str, + dict[str, + Any]] = { + 409: { + "model": ErrorDetail, + "description": "Resource conflict" + }, + } diff --git a/PROJECTS/bug-bounty-platform/backend/app/core/security.py b/PROJECTS/bug-bounty-platform/backend/app/core/security.py new file mode 100644 index 00000000..76c36c4f --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/core/security.py @@ -0,0 +1,190 @@ +""" +ⒸAngelaMos | 2025 +security.py +""" + +import asyncio +import hashlib +import secrets +from datetime import ( + UTC, + datetime, + timedelta, +) +from typing import Any +from uuid import UUID + +import jwt +from fastapi import Response +from pwdlib import PasswordHash + +from config import ( + settings, + TokenType, +) + + +password_hasher = PasswordHash.recommended() + + +async def hash_password(password: str) -> str: + """ + Hash password using Argon2id + + Runs in thread pool to avoid blocking the async event loop + since Argon2 is CPU intensive by design + """ + return await asyncio.to_thread(password_hasher.hash, password) + + +async def verify_password(plain_password: str, + hashed_password: str) -> tuple[bool, + str | None]: + """ + Verify password and check if rehash is needed + + Returns: + Tuple of (is_valid, new_hash_if_needs_rehash) + If password is valid but hash params are outdated, returns new hash + """ + try: + return await asyncio.to_thread( + password_hasher.verify_and_update, + plain_password, + hashed_password + ) + except Exception: + return False, None + + +DUMMY_HASH = password_hasher.hash( + "dummy_password_for_timing_attack_prevention" +) + + +async def verify_password_with_timing_safety( + plain_password: str, + hashed_password: str | None, +) -> tuple[bool, + str | None]: + """ + Verify password with constant time behavior to prevent user enumeration + + If no hash is provided (user doesn't exist), still performs a dummy + hash operation to prevent timing attacks + """ + if hashed_password is None: + await asyncio.to_thread( + password_hasher.verify, + plain_password, + DUMMY_HASH + ) + return False, None + return await verify_password(plain_password, hashed_password) + + +def create_access_token( + user_id: UUID, + token_version: int, + extra_claims: dict[str, + Any] | None = None, +) -> str: + """ + Create a short lived access token + """ + now = datetime.now(UTC) + payload = { + "sub": str(user_id), + "type": TokenType.ACCESS.value, + "token_version": token_version, + "iat": now, + "exp": + now + timedelta(minutes = settings.ACCESS_TOKEN_EXPIRE_MINUTES), + } + if extra_claims: + payload.update(extra_claims) + + return jwt.encode( + payload, + settings.SECRET_KEY.get_secret_value(), + algorithm = settings.JWT_ALGORITHM, + ) + + +def create_refresh_token( + user_id: UUID, + family_id: UUID, +) -> tuple[str, + str, + datetime]: + """ + Create a long lived refresh token + + Returns: + Tuple of (raw_token, token_hash, expires_at) + Raw token is sent to client, hash is stored in database + """ + raw_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + expires_at = datetime.now(UTC) + timedelta( + days = settings.REFRESH_TOKEN_EXPIRE_DAYS + ) + + return raw_token, token_hash, expires_at + + +def decode_access_token(token: str) -> dict[str, Any]: + """ + Decode and validate an access token + + Raises: + jwt.InvalidTokenError: If token is invalid or expired + """ + return jwt.decode( + token, + settings.SECRET_KEY.get_secret_value(), + algorithms = [settings.JWT_ALGORITHM], + options = { + "require": ["exp", + "sub", + "iat", + "type", + "token_version"] + }, + ) + + +def hash_token(token: str) -> str: + """ + Hash a token for secure storage + """ + return hashlib.sha256(token.encode()).hexdigest() + + +def generate_secure_token(nbytes: int = 32) -> str: + """ + Generate a cryptographically secure random token + """ + return secrets.token_urlsafe(nbytes) + + +def set_refresh_cookie(response: Response, token: str) -> None: + """ + Set refresh token as HttpOnly cookie + """ + response.set_cookie( + key = "refresh_token", + value = token, + httponly = True, + secure = settings.ENVIRONMENT.value != "development", + samesite = "strict", + max_age = settings.REFRESH_TOKEN_EXPIRE_DAYS * 24 * 60 * 60, + path = "/", + ) + + +def clear_refresh_cookie(response: Response) -> None: + """ + Clear refresh token cookie + """ + response.delete_cookie(key = "refresh_token", path = "/") diff --git a/PROJECTS/bug-bounty-platform/backend/app/factory.py b/PROJECTS/bug-bounty-platform/backend/app/factory.py new file mode 100644 index 00000000..c8f790e4 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/factory.py @@ -0,0 +1,145 @@ +""" +ⒸAngelaMos | 2025 +factory.py +""" + +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from slowapi import _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded + +from config import settings, Environment, API_PREFIX +from core.database import sessionmanager +from core.exceptions import BaseAppException +from core.logging import configure_logging +from core.rate_limit import limiter +from middleware.correlation import CorrelationIdMiddleware +from core.common_schemas import AppInfoResponse +from core.health_routes import router as health_router +from user.routes import router as user_router +from auth.routes import router as auth_router +from admin.routes import router as admin_router +from program.routes import router as program_router +from report.routes import router as report_router + + +@asynccontextmanager +async def lifespan(app: FastAPI) -> AsyncIterator[None]: + """ + Application lifespan handler for startup and shutdown + """ + configure_logging() + sessionmanager.init(str(settings.DATABASE_URL)) + yield + await sessionmanager.close() + + +OPENAPI_TAGS = [ + { + "name": "root", + "description": "API information" + }, + { + "name": "health", + "description": "Health check endpoints" + }, + { + "name": "auth", + "description": "Authentication and authorization" + }, + { + "name": "users", + "description": "User registration and profile management" + }, + { + "name": "programs", + "description": "Bug bounty program management" + }, + { + "name": "reports", + "description": "Vulnerability report submission and triage" + }, + { + "name": "admin", + "description": "Admin only operations" + }, +] + + +def create_app() -> FastAPI: + """ + Application factory + """ + is_production = settings.ENVIRONMENT == Environment.PRODUCTION + + app = FastAPI( + title = settings.APP_NAME, + summary = settings.APP_SUMMARY, + description = settings.APP_DESCRIPTION, + version = settings.APP_VERSION, + contact = { + "name": settings.APP_CONTACT_NAME, + "email": settings.APP_CONTACT_EMAIL, + }, + license_info = { + "name": settings.APP_LICENSE_NAME, + "url": settings.APP_LICENSE_URL, + }, + openapi_tags = OPENAPI_TAGS, + openapi_version = "3.1.0", + lifespan = lifespan, + root_path = "/api", + openapi_url = "/openapi.json", + docs_url = "/docs", + redoc_url = "/redoc", + ) + + app.add_middleware(CorrelationIdMiddleware) + app.add_middleware( + CORSMiddleware, + allow_origins = settings.CORS_ORIGINS, + allow_credentials = settings.CORS_ALLOW_CREDENTIALS, + allow_methods = settings.CORS_ALLOW_METHODS, + allow_headers = settings.CORS_ALLOW_HEADERS, + ) + + app.state.limiter = limiter + app.add_exception_handler( + RateLimitExceeded, + _rate_limit_exceeded_handler + ) + + @app.exception_handler(BaseAppException) + async def app_exception_handler( + request: Request, + exc: BaseAppException, + ) -> JSONResponse: + return JSONResponse( + status_code = exc.status_code, + content = { + "detail": exc.message, + "type": exc.__class__.__name__, + }, + ) + + @app.get("/", response_model = AppInfoResponse, tags = ["root"]) + async def root() -> AppInfoResponse: + return AppInfoResponse( + name = settings.APP_NAME, + version = settings.APP_VERSION, + environment = settings.ENVIRONMENT.value, + docs_url = "/docs", + ) + + app.include_router(health_router) + app.include_router(admin_router, prefix = API_PREFIX) + app.include_router(auth_router, prefix = API_PREFIX) + app.include_router(user_router, prefix = API_PREFIX) + app.include_router(program_router, prefix = API_PREFIX) + app.include_router(report_router, prefix = API_PREFIX) + + return app diff --git a/PROJECTS/bug-bounty-platform/backend/app/middleware/__init__.py b/PROJECTS/bug-bounty-platform/backend/app/middleware/__init__.py new file mode 100644 index 00000000..f50bdec5 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/middleware/__init__.py @@ -0,0 +1,11 @@ +""" +AngelaMos | 2025 +__init__.py +""" + +from .correlation import CorrelationIdMiddleware + + +__all__ = [ + "CorrelationIdMiddleware", +] diff --git a/PROJECTS/bug-bounty-platform/backend/app/middleware/correlation.py b/PROJECTS/bug-bounty-platform/backend/app/middleware/correlation.py new file mode 100644 index 00000000..7f64addb --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/middleware/correlation.py @@ -0,0 +1,43 @@ +""" +ⒸAngelaMos | 2025 +correlation.py +""" + +import uuid +from collections.abc import ( + Awaitable, + Callable, +) +import structlog +from starlette.requests import Request +from starlette.responses import Response +from starlette.middleware.base import BaseHTTPMiddleware + + +RequestResponseEndpoint = Callable[[Request], Awaitable[Response]] + + +class CorrelationIdMiddleware(BaseHTTPMiddleware): + """ + Correlation ID to requests for distributed tracing + """ + async def dispatch( + self, + request: Request, + call_next: RequestResponseEndpoint, + ) -> Response: + correlation_id = request.headers.get( + "X-Correlation-ID", + str(uuid.uuid4()) + ) + + structlog.contextvars.clear_contextvars() + structlog.contextvars.bind_contextvars( + correlation_id = correlation_id, + method = request.method, + path = request.url.path, + ) + + response = await call_next(request) + response.headers["X-Correlation-ID"] = correlation_id + return response diff --git a/PROJECTS/bug-bounty-platform/backend/app/middleware/py.typed b/PROJECTS/bug-bounty-platform/backend/app/middleware/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/bug-bounty-platform/backend/app/program/Asset.py b/PROJECTS/bug-bounty-platform/backend/app/program/Asset.py new file mode 100644 index 00000000..ef0c3b0e --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/program/Asset.py @@ -0,0 +1,64 @@ +""" +ⒸAngelaMos | 2025 +Asset.py +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import ForeignKey, String, Text +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship, +) + +from config import ( + ASSET_IDENTIFIER_MAX_LENGTH, + AssetType, + SafeEnum, +) +from core.Base import ( + Base, + TimestampMixin, + UUIDMixin, +) + +if TYPE_CHECKING: + from program.Program import Program + + +class Asset(Base, UUIDMixin, TimestampMixin): + """ + Target asset within a bug bounty program scope + """ + __tablename__ = "assets" + + program_id: Mapped[UUID] = mapped_column( + ForeignKey("programs.id", + ondelete = "CASCADE"), + index = True, + ) + + asset_type: Mapped[AssetType] = mapped_column( + SafeEnum(AssetType), + default = AssetType.DOMAIN, + ) + + identifier: Mapped[str] = mapped_column( + String(ASSET_IDENTIFIER_MAX_LENGTH), + ) + + in_scope: Mapped[bool] = mapped_column(default = True) + + description: Mapped[str | None] = mapped_column( + Text, + default = None, + ) + + program: Mapped[Program] = relationship( + back_populates = "assets", + lazy = "raise", + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/program/Program.py b/PROJECTS/bug-bounty-platform/backend/app/program/Program.py new file mode 100644 index 00000000..735c7e9a --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/program/Program.py @@ -0,0 +1,114 @@ +""" +ⒸAngelaMos | 2025 +Program.py +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import ForeignKey, String, Text +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship, +) + +from config import ( + PROGRAM_NAME_MAX_LENGTH, + PROGRAM_SLUG_MAX_LENGTH, + ProgramStatus, + ProgramVisibility, + SafeEnum, +) +from core.Base import ( + Base, + TimestampMixin, + UUIDMixin, +) + +if TYPE_CHECKING: + from program.Asset import Asset + from program.RewardTier import RewardTier + from report.Report import Report + from user.User import User + + +class Program(Base, UUIDMixin, TimestampMixin): + """ + Bug bounty program hosted by a company or individual + """ + __tablename__ = "programs" + + company_id: Mapped[UUID] = mapped_column( + ForeignKey("users.id", + ondelete = "CASCADE"), + index = True, + ) + + name: Mapped[str] = mapped_column( + String(PROGRAM_NAME_MAX_LENGTH), + ) + slug: Mapped[str] = mapped_column( + String(PROGRAM_SLUG_MAX_LENGTH), + unique = True, + index = True, + ) + description: Mapped[str | None] = mapped_column( + Text, + default = None, + ) + rules: Mapped[str | None] = mapped_column( + Text, + default = None, + ) + + response_sla_hours: Mapped[int] = mapped_column(default = 72) + + status: Mapped[ProgramStatus] = mapped_column( + SafeEnum(ProgramStatus), + default = ProgramStatus.DRAFT, + index = True, + ) + visibility: Mapped[ProgramVisibility] = mapped_column( + SafeEnum(ProgramVisibility), + default = ProgramVisibility.PUBLIC, + ) + + company: Mapped[User] = relationship( + back_populates = "programs", + lazy = "raise", + ) + + assets: Mapped[list[Asset]] = relationship( + back_populates = "program", + cascade = "all, delete-orphan", + lazy = "raise", + ) + + reward_tiers: Mapped[list[RewardTier]] = relationship( + back_populates = "program", + cascade = "all, delete-orphan", + lazy = "raise", + ) + + reports: Mapped[list[Report]] = relationship( + back_populates = "program", + cascade = "all, delete-orphan", + lazy = "raise", + ) + + @property + def is_active(self) -> bool: + """ + Check if program is accepting submissions + """ + return self.status == ProgramStatus.ACTIVE + + @property + def is_public(self) -> bool: + """ + Check if program is publicly visible + """ + return self.visibility == ProgramVisibility.PUBLIC diff --git a/PROJECTS/bug-bounty-platform/backend/app/program/RewardTier.py b/PROJECTS/bug-bounty-platform/backend/app/program/RewardTier.py new file mode 100644 index 00000000..c84b4a83 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/program/RewardTier.py @@ -0,0 +1,60 @@ +""" +ⒸAngelaMos | 2025 +RewardTier.py +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import ForeignKey, String +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship, +) + +from config import ( + CURRENCY_MAX_LENGTH, + SafeEnum, + Severity, +) +from core.Base import ( + Base, + TimestampMixin, + UUIDMixin, +) + +if TYPE_CHECKING: + from program.Program import Program + + +class RewardTier(Base, UUIDMixin, TimestampMixin): + """ + Bounty reward tier by severity for a program + """ + __tablename__ = "reward_tiers" + + program_id: Mapped[UUID] = mapped_column( + ForeignKey("programs.id", + ondelete = "CASCADE"), + index = True, + ) + + severity: Mapped[Severity] = mapped_column( + SafeEnum(Severity), + ) + + min_bounty: Mapped[int] = mapped_column(default = 0) + max_bounty: Mapped[int] = mapped_column(default = 0) + + currency: Mapped[str] = mapped_column( + String(CURRENCY_MAX_LENGTH), + default = "USD", + ) + + program: Mapped[Program] = relationship( + back_populates = "reward_tiers", + lazy = "raise", + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/program/__init__.py b/PROJECTS/bug-bounty-platform/backend/app/program/__init__.py new file mode 100644 index 00000000..e863581c --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/program/__init__.py @@ -0,0 +1,4 @@ +""" +ⒸAngelaMos | 2025 +__init__.py +""" diff --git a/PROJECTS/bug-bounty-platform/backend/app/program/dependencies.py b/PROJECTS/bug-bounty-platform/backend/app/program/dependencies.py new file mode 100644 index 00000000..4f72cb88 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/program/dependencies.py @@ -0,0 +1,21 @@ +""" +ⒸAngelaMos | 2025 +dependencies.py +""" + +from typing import Annotated + +from fastapi import Depends + +from core.dependencies import DBSession +from .service import ProgramService + + +def get_program_service(db: DBSession) -> ProgramService: + """ + Dependency to inject ProgramService instance + """ + return ProgramService(db) + + +ProgramServiceDep = Annotated[ProgramService, Depends(get_program_service)] diff --git a/PROJECTS/bug-bounty-platform/backend/app/program/repository.py b/PROJECTS/bug-bounty-platform/backend/app/program/repository.py new file mode 100644 index 00000000..18b72aa8 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/program/repository.py @@ -0,0 +1,232 @@ +""" +ⒸAngelaMos | 2025 +repository.py +""" + +from collections.abc import Sequence +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from config import ProgramStatus, ProgramVisibility +from core.base_repository import BaseRepository +from .Program import Program +from .Asset import Asset +from .RewardTier import RewardTier + + +class ProgramRepository(BaseRepository[Program]): + """ + Repository for Program model database operations + """ + model = Program + + @classmethod + async def get_by_slug( + cls, + session: AsyncSession, + slug: str, + ) -> Program | None: + """ + Get program by slug + """ + result = await session.execute( + select(Program).where(Program.slug == slug) + ) + return result.scalars().first() + + @classmethod + async def get_by_slug_with_details( + cls, + session: AsyncSession, + slug: str, + ) -> Program | None: + """ + Get program by slug with assets and reward tiers + """ + result = await session.execute( + select(Program).where(Program.slug == slug).options( + selectinload(Program.assets), + selectinload(Program.reward_tiers), + ) + ) + return result.scalars().first() + + @classmethod + async def get_by_id_with_details( + cls, + session: AsyncSession, + program_id: UUID, + ) -> Program | None: + """ + Get program by ID with assets and reward tiers + """ + result = await session.execute( + select(Program).where(Program.id == program_id).options( + selectinload(Program.assets), + selectinload(Program.reward_tiers), + ) + ) + return result.scalars().first() + + @classmethod + async def slug_exists( + cls, + session: AsyncSession, + slug: str, + ) -> bool: + """ + Check if slug is already taken + """ + result = await session.execute( + select(Program.id).where(Program.slug == slug) + ) + return result.scalars().first() is not None + + @classmethod + async def get_public_programs( + cls, + session: AsyncSession, + skip: int = 0, + limit: int = 20, + ) -> Sequence[Program]: + """ + Get active public programs + """ + result = await session.execute( + select(Program).where( + Program.status == ProgramStatus.ACTIVE, + Program.visibility == ProgramVisibility.PUBLIC, + ).order_by(Program.created_at.desc() + ).offset(skip).limit(limit) + ) + return result.scalars().all() + + @classmethod + async def count_public_programs( + cls, + session: AsyncSession, + ) -> int: + """ + Count active public programs + """ + result = await session.execute( + select(func.count()).select_from(Program).where( + Program.status == ProgramStatus.ACTIVE, + Program.visibility == ProgramVisibility.PUBLIC, + ) + ) + return result.scalar_one() + + @classmethod + async def get_by_company( + cls, + session: AsyncSession, + company_id: UUID, + skip: int = 0, + limit: int = 20, + ) -> Sequence[Program]: + """ + Get programs by company/owner + """ + result = await session.execute( + select(Program).where(Program.company_id == company_id + ).order_by(Program.created_at.desc() + ).offset(skip).limit(limit) + ) + return result.scalars().all() + + @classmethod + async def count_by_company( + cls, + session: AsyncSession, + company_id: UUID, + ) -> int: + """ + Count programs by company + """ + result = await session.execute( + select(func.count()).select_from(Program).where( + Program.company_id == company_id + ) + ) + return result.scalar_one() + + +class AssetRepository(BaseRepository[Asset]): + """ + Repository for Asset model database operations + """ + model = Asset + + @classmethod + async def get_by_program( + cls, + session: AsyncSession, + program_id: UUID, + ) -> Sequence[Asset]: + """ + Get all assets for a program + """ + result = await session.execute( + select(Asset).where( + Asset.program_id == program_id + ).order_by(Asset.in_scope.desc(), + Asset.created_at) + ) + return result.scalars().all() + + +class RewardTierRepository(BaseRepository[RewardTier]): + """ + Repository for RewardTier model database operations + """ + model = RewardTier + + @classmethod + async def get_by_program( + cls, + session: AsyncSession, + program_id: UUID, + ) -> Sequence[RewardTier]: + """ + Get all reward tiers for a program + """ + result = await session.execute( + select(RewardTier).where(RewardTier.program_id == program_id) + ) + return result.scalars().all() + + @classmethod + async def get_by_program_and_severity( + cls, + session: AsyncSession, + program_id: UUID, + severity: str, + ) -> RewardTier | None: + """ + Get reward tier by program and severity + """ + result = await session.execute( + select(RewardTier).where( + RewardTier.program_id == program_id, + RewardTier.severity == severity, + ) + ) + return result.scalars().first() + + @classmethod + async def delete_by_program( + cls, + session: AsyncSession, + program_id: UUID, + ) -> None: + """ + Delete all reward tiers for a program + """ + tiers = await cls.get_by_program(session, program_id) + for tier in tiers: + await session.delete(tier) + await session.flush() diff --git a/PROJECTS/bug-bounty-platform/backend/app/program/routes.py b/PROJECTS/bug-bounty-platform/backend/app/program/routes.py new file mode 100644 index 00000000..08b1b470 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/program/routes.py @@ -0,0 +1,279 @@ +""" +ⒸAngelaMos | 2025 +routes.py +""" + +from uuid import UUID + +from fastapi import APIRouter, Query, status + +from core.dependencies import CurrentUser +from core.responses import ( + AUTH_401, + CONFLICT_409, + FORBIDDEN_403, + NOT_FOUND_404, +) +from .schemas import ( + AssetCreate, + AssetResponse, + AssetUpdate, + ProgramCreate, + ProgramDetailResponse, + ProgramListResponse, + ProgramResponse, + ProgramUpdate, + RewardTierCreate, + RewardTierResponse, +) +from .dependencies import ProgramServiceDep + + +router = APIRouter(prefix = "/programs", tags = ["programs"]) + + +@router.post( + "", + response_model = ProgramResponse, + status_code = status.HTTP_201_CREATED, + responses = { + **AUTH_401, + **CONFLICT_409 + }, +) +async def create_program( + program_service: ProgramServiceDep, + current_user: CurrentUser, + program_data: ProgramCreate, +) -> ProgramResponse: + """ + Create a new bug bounty program + """ + return await program_service.create_program(current_user, program_data) + + +@router.get( + "", + response_model = ProgramListResponse, +) +async def list_programs( + program_service: ProgramServiceDep, + page: int = Query(default = 1, + ge = 1), + size: int = Query(default = 20, + ge = 1, + le = 100), +) -> ProgramListResponse: + """ + List active public programs + """ + return await program_service.list_public_programs(page, size) + + +@router.get( + "/mine", + response_model = ProgramListResponse, + responses = {**AUTH_401}, +) +async def list_my_programs( + program_service: ProgramServiceDep, + current_user: CurrentUser, + page: int = Query(default = 1, + ge = 1), + size: int = Query(default = 20, + ge = 1, + le = 100), +) -> ProgramListResponse: + """ + List programs owned by current user + """ + return await program_service.list_my_programs(current_user, page, size) + + +@router.get( + "/{slug}", + response_model = ProgramDetailResponse, + responses = {**NOT_FOUND_404}, +) +async def get_program( + program_service: ProgramServiceDep, + slug: str, +) -> ProgramDetailResponse: + """ + Get program by slug with full details + """ + return await program_service.get_program_by_slug(slug) + + +@router.patch( + "/{program_id}", + response_model = ProgramResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def update_program( + program_service: ProgramServiceDep, + current_user: CurrentUser, + program_id: UUID, + program_data: ProgramUpdate, +) -> ProgramResponse: + """ + Update program details + """ + return await program_service.update_program( + current_user, + program_id, + program_data + ) + + +@router.delete( + "/{program_id}", + status_code = status.HTTP_204_NO_CONTENT, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def delete_program( + program_service: ProgramServiceDep, + current_user: CurrentUser, + program_id: UUID, +) -> None: + """ + Delete a program + """ + await program_service.delete_program(current_user, program_id) + + +@router.get( + "/{program_id}/assets", + response_model = list[AssetResponse], + responses = {**NOT_FOUND_404}, +) +async def list_assets( + program_service: ProgramServiceDep, + program_id: UUID, +) -> list[AssetResponse]: + """ + List program assets (scope) + """ + return await program_service.list_assets(program_id) + + +@router.post( + "/{program_id}/assets", + response_model = AssetResponse, + status_code = status.HTTP_201_CREATED, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def add_asset( + program_service: ProgramServiceDep, + current_user: CurrentUser, + program_id: UUID, + asset_data: AssetCreate, +) -> AssetResponse: + """ + Add asset to program scope + """ + return await program_service.add_asset( + current_user, + program_id, + asset_data + ) + + +@router.patch( + "/{program_id}/assets/{asset_id}", + response_model = AssetResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def update_asset( + program_service: ProgramServiceDep, + current_user: CurrentUser, + program_id: UUID, + asset_id: UUID, + asset_data: AssetUpdate, +) -> AssetResponse: + """ + Update an asset + """ + return await program_service.update_asset( + current_user, + program_id, + asset_id, + asset_data + ) + + +@router.delete( + "/{program_id}/assets/{asset_id}", + status_code = status.HTTP_204_NO_CONTENT, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def delete_asset( + program_service: ProgramServiceDep, + current_user: CurrentUser, + program_id: UUID, + asset_id: UUID, +) -> None: + """ + Delete an asset + """ + await program_service.delete_asset(current_user, program_id, asset_id) + + +@router.get( + "/{program_id}/rewards", + response_model = list[RewardTierResponse], + responses = {**NOT_FOUND_404}, +) +async def list_reward_tiers( + program_service: ProgramServiceDep, + program_id: UUID, +) -> list[RewardTierResponse]: + """ + List program reward tiers + """ + return await program_service.list_reward_tiers(program_id) + + +@router.put( + "/{program_id}/rewards", + response_model = list[RewardTierResponse], + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def set_reward_tiers( + program_service: ProgramServiceDep, + current_user: CurrentUser, + program_id: UUID, + tiers: list[RewardTierCreate], +) -> list[RewardTierResponse]: + """ + Set reward tiers for program (replaces existing) + """ + return await program_service.set_reward_tiers( + current_user, + program_id, + tiers + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/program/schemas.py b/PROJECTS/bug-bounty-platform/backend/app/program/schemas.py new file mode 100644 index 00000000..e9edf427 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/program/schemas.py @@ -0,0 +1,179 @@ +""" +ⒸAngelaMos | 2025 +schemas.py +""" + +from uuid import UUID +from decimal import Decimal + +from pydantic import Field, field_validator, ValidationInfo + +from config import ( + ASSET_IDENTIFIER_MAX_LENGTH, + AssetType, + PROGRAM_DESCRIPTION_MAX_LENGTH, + PROGRAM_NAME_MAX_LENGTH, + PROGRAM_RULES_MAX_LENGTH, + PROGRAM_SLUG_MAX_LENGTH, + ProgramStatus, + ProgramVisibility, + Severity, +) +from core.base_schema import ( + BaseSchema, + BaseResponseSchema, +) + + +class RewardTierCreate(BaseSchema): + """ + Schema for creating a reward tier + """ + severity: Severity + min_bounty: int = Field(ge = 0, default = 0) + max_bounty: int = Field(ge = 0, default = 0) + currency: str = Field(default = "USD", max_length = 3) + + @field_validator("max_bounty") + @classmethod + def max_gte_min(cls, v: int, info: ValidationInfo) -> int: + """ + Ensure max bounty is greater than or equal to min bounty + """ + if "min_bounty" in info.data and v < info.data["min_bounty"]: + raise ValueError("max_bounty must be >= min_bounty") + return v + + +class RewardTierResponse(BaseResponseSchema): + """ + Schema for reward tier API responses + """ + program_id: UUID + severity: Severity + min_bounty: int + max_bounty: int + currency: str + + +class AssetCreate(BaseSchema): + """ + Schema for creating an asset + """ + asset_type: AssetType = AssetType.DOMAIN + identifier: str = Field(max_length = ASSET_IDENTIFIER_MAX_LENGTH) + in_scope: bool = True + description: str | None = None + + +class AssetUpdate(BaseSchema): + """ + Schema for updating an asset + """ + asset_type: AssetType | None = None + identifier: str | None = Field( + default = None, + max_length = ASSET_IDENTIFIER_MAX_LENGTH + ) + in_scope: bool | None = None + description: str | None = None + + +class AssetResponse(BaseResponseSchema): + """ + Schema for asset API responses + """ + program_id: UUID + asset_type: AssetType + identifier: str + in_scope: bool + description: str | None + + +class ProgramCreate(BaseSchema): + """ + Schema for creating a program + """ + name: str = Field(max_length = PROGRAM_NAME_MAX_LENGTH) + slug: str = Field( + max_length = PROGRAM_SLUG_MAX_LENGTH, + pattern = r"^[a-z0-9-]+$" + ) + description: str | None = Field( + default = None, + max_length = PROGRAM_DESCRIPTION_MAX_LENGTH + ) + rules: str | None = Field( + default = None, + max_length = PROGRAM_RULES_MAX_LENGTH + ) + response_sla_hours: int = Field(default = 72, ge = 1, le = 720) + visibility: ProgramVisibility = ProgramVisibility.PUBLIC + + +class ProgramUpdate(BaseSchema): + """ + Schema for updating a program + """ + name: str | None = Field( + default = None, + max_length = PROGRAM_NAME_MAX_LENGTH + ) + description: str | None = Field( + default = None, + max_length = PROGRAM_DESCRIPTION_MAX_LENGTH + ) + rules: str | None = Field( + default = None, + max_length = PROGRAM_RULES_MAX_LENGTH + ) + response_sla_hours: int | None = Field( + default = None, + ge = 1, + le = 720 + ) + status: ProgramStatus | None = None + visibility: ProgramVisibility | None = None + + +class ProgramResponse(BaseResponseSchema): + """ + Schema for program API responses + """ + company_id: UUID + name: str + slug: str + description: str | None + rules: str | None + response_sla_hours: int + status: ProgramStatus + visibility: ProgramVisibility + + +class ProgramDetailResponse(ProgramResponse): + """ + Schema for program detail with assets and rewards + """ + assets: list[AssetResponse] + reward_tiers: list[RewardTierResponse] + + +class ProgramListResponse(BaseSchema): + """ + Schema for paginated program list + """ + items: list[ProgramResponse] + total: int + page: int + size: int + + +class ProgramStatsResponse(BaseSchema): + """ + Schema for program statistics + """ + total_reports: int + open_reports: int + resolved_reports: int + total_paid: int + average_response_hours: Decimal | None diff --git a/PROJECTS/bug-bounty-platform/backend/app/program/service.py b/PROJECTS/bug-bounty-platform/backend/app/program/service.py new file mode 100644 index 00000000..dc48a651 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/program/service.py @@ -0,0 +1,375 @@ +""" +ⒸAngelaMos | 2025 +service.py +""" + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from core.exceptions import ( + AssetNotFound, + NotProgramOwner, + ProgramNotFound, + SlugAlreadyExists, +) +from user.User import User +from .schemas import ( + AssetCreate, + AssetResponse, + AssetUpdate, + ProgramCreate, + ProgramDetailResponse, + ProgramListResponse, + ProgramResponse, + ProgramUpdate, + RewardTierCreate, + RewardTierResponse, +) +from .repository import ( + AssetRepository, + ProgramRepository, + RewardTierRepository, +) + + +class ProgramService: + """ + Business logic for program operations + """ + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def create_program( + self, + user: User, + program_data: ProgramCreate, + ) -> ProgramResponse: + """ + Create a new bug bounty program + """ + if await ProgramRepository.slug_exists(self.session, + program_data.slug): + raise SlugAlreadyExists(program_data.slug) + + program = await ProgramRepository.create( + self.session, + company_id = user.id, + name = program_data.name, + slug = program_data.slug, + description = program_data.description, + rules = program_data.rules, + response_sla_hours = program_data.response_sla_hours, + visibility = program_data.visibility, + ) + return ProgramResponse.model_validate(program) + + async def get_program_by_slug( + self, + slug: str, + ) -> ProgramDetailResponse: + """ + Get program by slug with full details + """ + program = await ProgramRepository.get_by_slug_with_details( + self.session, + slug + ) + if not program: + raise ProgramNotFound(slug) + + return ProgramDetailResponse( + id = program.id, + created_at = program.created_at, + updated_at = program.updated_at, + company_id = program.company_id, + name = program.name, + slug = program.slug, + description = program.description, + rules = program.rules, + response_sla_hours = program.response_sla_hours, + status = program.status, + visibility = program.visibility, + assets = [ + AssetResponse.model_validate(a) for a in program.assets + ], + reward_tiers = [ + RewardTierResponse.model_validate(r) + for r in program.reward_tiers + ], + ) + + async def get_program_by_id( + self, + program_id: UUID, + ) -> ProgramResponse: + """ + Get program by ID + """ + program = await ProgramRepository.get_by_id( + self.session, + program_id + ) + if not program: + raise ProgramNotFound(str(program_id)) + return ProgramResponse.model_validate(program) + + async def list_public_programs( + self, + page: int, + size: int, + ) -> ProgramListResponse: + """ + List active public programs + """ + skip = (page - 1) * size + programs = await ProgramRepository.get_public_programs( + self.session, + skip = skip, + limit = size, + ) + total = await ProgramRepository.count_public_programs(self.session) + return ProgramListResponse( + items = [ProgramResponse.model_validate(p) for p in programs], + total = total, + page = page, + size = size, + ) + + async def list_my_programs( + self, + user: User, + page: int, + size: int, + ) -> ProgramListResponse: + """ + List programs owned by user + """ + skip = (page - 1) * size + programs = await ProgramRepository.get_by_company( + self.session, + company_id = user.id, + skip = skip, + limit = size, + ) + total = await ProgramRepository.count_by_company( + self.session, + user.id + ) + return ProgramListResponse( + items = [ProgramResponse.model_validate(p) for p in programs], + total = total, + page = page, + size = size, + ) + + async def update_program( + self, + user: User, + program_id: UUID, + program_data: ProgramUpdate, + ) -> ProgramResponse: + """ + Update program details + """ + program = await ProgramRepository.get_by_id( + self.session, + program_id + ) + if not program: + raise ProgramNotFound(str(program_id)) + + if program.company_id != user.id: + raise NotProgramOwner() + + update_dict = program_data.model_dump(exclude_unset = True) + updated = await ProgramRepository.update( + self.session, + program, + **update_dict, + ) + return ProgramResponse.model_validate(updated) + + async def delete_program( + self, + user: User, + program_id: UUID, + ) -> None: + """ + Delete a program + """ + program = await ProgramRepository.get_by_id( + self.session, + program_id + ) + if not program: + raise ProgramNotFound(str(program_id)) + + if program.company_id != user.id: + raise NotProgramOwner() + + await ProgramRepository.delete(self.session, program) + + async def add_asset( + self, + user: User, + program_id: UUID, + asset_data: AssetCreate, + ) -> AssetResponse: + """ + Add asset to program scope + """ + program = await ProgramRepository.get_by_id( + self.session, + program_id + ) + if not program: + raise ProgramNotFound(str(program_id)) + + if program.company_id != user.id: + raise NotProgramOwner() + + asset = await AssetRepository.create( + self.session, + program_id = program_id, + asset_type = asset_data.asset_type, + identifier = asset_data.identifier, + in_scope = asset_data.in_scope, + description = asset_data.description, + ) + return AssetResponse.model_validate(asset) + + async def update_asset( + self, + user: User, + program_id: UUID, + asset_id: UUID, + asset_data: AssetUpdate, + ) -> AssetResponse: + """ + Update an asset + """ + program = await ProgramRepository.get_by_id( + self.session, + program_id + ) + if not program: + raise ProgramNotFound(str(program_id)) + + if program.company_id != user.id: + raise NotProgramOwner() + + asset = await AssetRepository.get_by_id(self.session, asset_id) + if not asset or asset.program_id != program_id: + raise AssetNotFound(str(asset_id)) + + update_dict = asset_data.model_dump(exclude_unset = True) + updated = await AssetRepository.update( + self.session, + asset, + **update_dict, + ) + return AssetResponse.model_validate(updated) + + async def delete_asset( + self, + user: User, + program_id: UUID, + asset_id: UUID, + ) -> None: + """ + Delete an asset + """ + program = await ProgramRepository.get_by_id( + self.session, + program_id + ) + if not program: + raise ProgramNotFound(str(program_id)) + + if program.company_id != user.id: + raise NotProgramOwner() + + asset = await AssetRepository.get_by_id(self.session, asset_id) + if not asset or asset.program_id != program_id: + raise AssetNotFound(str(asset_id)) + + await AssetRepository.delete(self.session, asset) + + async def list_assets( + self, + program_id: UUID, + ) -> list[AssetResponse]: + """ + List all assets for a program + """ + program = await ProgramRepository.get_by_id( + self.session, + program_id + ) + if not program: + raise ProgramNotFound(str(program_id)) + + assets = await AssetRepository.get_by_program( + self.session, + program_id + ) + return [AssetResponse.model_validate(a) for a in assets] + + async def set_reward_tiers( + self, + user: User, + program_id: UUID, + tiers: list[RewardTierCreate], + ) -> list[RewardTierResponse]: + """ + Set reward tiers for a program (replaces existing) + """ + program = await ProgramRepository.get_by_id( + self.session, + program_id + ) + if not program: + raise ProgramNotFound(str(program_id)) + + if program.company_id != user.id: + raise NotProgramOwner() + + await RewardTierRepository.delete_by_program( + self.session, + program_id + ) + + created_tiers = [] + for tier_data in tiers: + tier = await RewardTierRepository.create( + self.session, + program_id = program_id, + severity = tier_data.severity, + min_bounty = tier_data.min_bounty, + max_bounty = tier_data.max_bounty, + currency = tier_data.currency, + ) + created_tiers.append(RewardTierResponse.model_validate(tier)) + + return created_tiers + + async def list_reward_tiers( + self, + program_id: UUID, + ) -> list[RewardTierResponse]: + """ + List reward tiers for a program + """ + program = await ProgramRepository.get_by_id( + self.session, + program_id + ) + if not program: + raise ProgramNotFound(str(program_id)) + + tiers = await RewardTierRepository.get_by_program( + self.session, + program_id + ) + return [RewardTierResponse.model_validate(t) for t in tiers] diff --git a/PROJECTS/bug-bounty-platform/backend/app/py.typed b/PROJECTS/bug-bounty-platform/backend/app/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/bug-bounty-platform/backend/app/report/Attachment.py b/PROJECTS/bug-bounty-platform/backend/app/report/Attachment.py new file mode 100644 index 00000000..600155ee --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/report/Attachment.py @@ -0,0 +1,64 @@ +""" +ⒸAngelaMos | 2025 +Attachment.py +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import BigInteger, ForeignKey, String +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship, +) + +from config import ( + FILENAME_MAX_LENGTH, + MIME_TYPE_MAX_LENGTH, + STORAGE_PATH_MAX_LENGTH, +) +from core.Base import ( + Base, + TimestampMixin, + UUIDMixin, +) + +if TYPE_CHECKING: + from report.Report import Report + + +class Attachment(Base, UUIDMixin, TimestampMixin): + """ + File attachment on a vulnerability report + """ + __tablename__ = "attachments" + + report_id: Mapped[UUID] = mapped_column( + ForeignKey("reports.id", + ondelete = "CASCADE"), + index = True, + ) + comment_id: Mapped[UUID | None] = mapped_column( + ForeignKey("comments.id", + ondelete = "SET NULL"), + default = None, + ) + + filename: Mapped[str] = mapped_column( + String(FILENAME_MAX_LENGTH), + ) + storage_path: Mapped[str] = mapped_column( + String(STORAGE_PATH_MAX_LENGTH), + ) + mime_type: Mapped[str] = mapped_column( + String(MIME_TYPE_MAX_LENGTH), + ) + size_bytes: Mapped[int] = mapped_column(BigInteger) + + report: Mapped[Report] = relationship( + back_populates = "attachments", + lazy = "raise", + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/report/Comment.py b/PROJECTS/bug-bounty-platform/backend/app/report/Comment.py new file mode 100644 index 00000000..98ba7c7e --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/report/Comment.py @@ -0,0 +1,54 @@ +""" +ⒸAngelaMos | 2025 +Comment.py +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import ForeignKey, Text +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship, +) + +from core.Base import ( + Base, + TimestampMixin, + UUIDMixin, +) + +if TYPE_CHECKING: + from report.Report import Report + from user.User import User + + +class Comment(Base, UUIDMixin, TimestampMixin): + """ + Comment on a vulnerability report + """ + __tablename__ = "comments" + + report_id: Mapped[UUID] = mapped_column( + ForeignKey("reports.id", + ondelete = "CASCADE"), + index = True, + ) + author_id: Mapped[UUID] = mapped_column( + ForeignKey("users.id", + ondelete = "CASCADE"), + index = True, + ) + + content: Mapped[str] = mapped_column(Text) + + is_internal: Mapped[bool] = mapped_column(default = False) + + report: Mapped[Report] = relationship( + back_populates = "comments", + lazy = "raise", + ) + author: Mapped[User] = relationship(lazy = "raise") diff --git a/PROJECTS/bug-bounty-platform/backend/app/report/Report.py b/PROJECTS/bug-bounty-platform/backend/app/report/Report.py new file mode 100644 index 00000000..eba00c40 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/report/Report.py @@ -0,0 +1,185 @@ +""" +ⒸAngelaMos | 2025 +Report.py +""" + +from __future__ import annotations + +from datetime import UTC, datetime +from decimal import Decimal +from typing import TYPE_CHECKING +from uuid import UUID + +from sqlalchemy import DateTime, ForeignKey, Numeric, String, Text +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship, +) + +from config import ( + CWE_ID_MAX_LENGTH, + REPORT_TITLE_MAX_LENGTH, + ReportStatus, + SafeEnum, + Severity, +) +from core.Base import ( + Base, + TimestampMixin, + UUIDMixin, +) + +if TYPE_CHECKING: + from program.Program import Program + from report.Attachment import Attachment + from report.Comment import Comment + from user.User import User + + +class Report(Base, UUIDMixin, TimestampMixin): + """ + Vulnerability report submitted by a researcher + """ + __tablename__ = "reports" + + program_id: Mapped[UUID] = mapped_column( + ForeignKey("programs.id", + ondelete = "CASCADE"), + index = True, + ) + researcher_id: Mapped[UUID] = mapped_column( + ForeignKey("users.id", + ondelete = "CASCADE"), + index = True, + ) + + title: Mapped[str] = mapped_column( + String(REPORT_TITLE_MAX_LENGTH), + ) + description: Mapped[str] = mapped_column(Text) + steps_to_reproduce: Mapped[str | None] = mapped_column( + Text, + default = None, + ) + impact: Mapped[str | None] = mapped_column( + Text, + default = None, + ) + + severity_submitted: Mapped[Severity] = mapped_column( + SafeEnum(Severity), + default = Severity.MEDIUM, + ) + severity_final: Mapped[Severity | None] = mapped_column( + SafeEnum(Severity), + default = None, + ) + + status: Mapped[ReportStatus] = mapped_column( + SafeEnum(ReportStatus), + default = ReportStatus.NEW, + index = True, + ) + + cvss_score: Mapped[Decimal | None] = mapped_column( + Numeric(precision = 3, + scale = 1), + default = None, + ) + cwe_id: Mapped[str | None] = mapped_column( + String(CWE_ID_MAX_LENGTH), + default = None, + ) + + bounty_amount: Mapped[int | None] = mapped_column(default = None) + + duplicate_of_id: Mapped[UUID | None] = mapped_column( + ForeignKey("reports.id", + ondelete = "SET NULL"), + default = None, + ) + + triaged_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone = True), + default = None, + ) + resolved_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone = True), + default = None, + ) + disclosed_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone = True), + default = None, + ) + + program: Mapped[Program] = relationship( + back_populates = "reports", + lazy = "raise", + ) + researcher: Mapped[User] = relationship( + back_populates = "reports", + lazy = "raise", + ) + + comments: Mapped[list[Comment]] = relationship( + back_populates = "report", + cascade = "all, delete-orphan", + lazy = "raise", + ) + attachments: Mapped[list[Attachment]] = relationship( + back_populates = "report", + cascade = "all, delete-orphan", + lazy = "raise", + ) + + duplicate_of: Mapped[Report | None] = relationship( + remote_side = "Report.id", + lazy = "raise", + ) + + def mark_triaging(self) -> None: + """ + Transition report to triaging status + """ + self.status = ReportStatus.TRIAGING + self.triaged_at = datetime.now(UTC) + + def mark_resolved(self) -> None: + """ + Transition report to resolved status + """ + self.status = ReportStatus.RESOLVED + self.resolved_at = datetime.now(UTC) + + def mark_disclosed(self) -> None: + """ + Transition report to disclosed status + """ + self.status = ReportStatus.DISCLOSED + self.disclosed_at = datetime.now(UTC) + + @property + def is_open(self) -> bool: + """ + Check if report is still open for action + """ + return self.status in ( + ReportStatus.NEW, + ReportStatus.TRIAGING, + ReportStatus.NEEDS_MORE_INFO, + ) + + @property + def is_closed(self) -> bool: + """ + Check if report has reached a terminal state + """ + return self.status in ( + ReportStatus.ACCEPTED, + ReportStatus.DUPLICATE, + ReportStatus.INFORMATIVE, + ReportStatus.NOT_APPLICABLE, + ReportStatus.RESOLVED, + ReportStatus.DISCLOSED, + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/report/__init__.py b/PROJECTS/bug-bounty-platform/backend/app/report/__init__.py new file mode 100644 index 00000000..e863581c --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/report/__init__.py @@ -0,0 +1,4 @@ +""" +ⒸAngelaMos | 2025 +__init__.py +""" diff --git a/PROJECTS/bug-bounty-platform/backend/app/report/dependencies.py b/PROJECTS/bug-bounty-platform/backend/app/report/dependencies.py new file mode 100644 index 00000000..ab9fd9a6 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/report/dependencies.py @@ -0,0 +1,21 @@ +""" +ⒸAngelaMos | 2025 +dependencies.py +""" + +from typing import Annotated + +from fastapi import Depends + +from core.dependencies import DBSession +from .service import ReportService + + +def get_report_service(db: DBSession) -> ReportService: + """ + Dependency to inject ReportService instance + """ + return ReportService(db) + + +ReportServiceDep = Annotated[ReportService, Depends(get_report_service)] diff --git a/PROJECTS/bug-bounty-platform/backend/app/report/repository.py b/PROJECTS/bug-bounty-platform/backend/app/report/repository.py new file mode 100644 index 00000000..d6e2d18a --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/report/repository.py @@ -0,0 +1,256 @@ +""" +ⒸAngelaMos | 2025 +repository.py +""" + +from typing import Any +from collections.abc import Sequence +from uuid import UUID + +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from config import ReportStatus +from core.base_repository import BaseRepository +from program.Program import Program +from .Report import Report +from .Comment import Comment +from .Attachment import Attachment + + +class ReportRepository(BaseRepository[Report]): + """ + Repository for Report model database operations + """ + model = Report + + @classmethod + async def get_by_id_with_details( + cls, + session: AsyncSession, + report_id: UUID, + ) -> Report | None: + """ + Get report by ID with comments and attachments + """ + result = await session.execute( + select(Report).where(Report.id == report_id).options( + selectinload(Report.comments), + selectinload(Report.attachments), + ) + ) + return result.scalars().first() + + @classmethod + async def get_by_researcher( + cls, + session: AsyncSession, + researcher_id: UUID, + skip: int = 0, + limit: int = 20, + ) -> Sequence[Report]: + """ + Get reports by researcher + """ + result = await session.execute( + select(Report).where(Report.researcher_id == researcher_id + ).order_by(Report.created_at.desc() + ).offset(skip).limit(limit) + ) + return result.scalars().all() + + @classmethod + async def count_by_researcher( + cls, + session: AsyncSession, + researcher_id: UUID, + ) -> int: + """ + Count reports by researcher + """ + result = await session.execute( + select(func.count()).select_from(Report).where( + Report.researcher_id == researcher_id + ) + ) + return result.scalar_one() + + @classmethod + async def get_by_program( + cls, + session: AsyncSession, + program_id: UUID, + skip: int = 0, + limit: int = 20, + status_filter: ReportStatus | None = None, + ) -> Sequence[Report]: + """ + Get reports for a program (inbox view) + """ + query = select(Report).where(Report.program_id == program_id) + + if status_filter: + query = query.where(Report.status == status_filter) + + result = await session.execute( + query.order_by(Report.created_at.desc() + ).offset(skip).limit(limit) + ) + return result.scalars().all() + + @classmethod + async def count_by_program( + cls, + session: AsyncSession, + program_id: UUID, + status_filter: ReportStatus | None = None, + ) -> int: + """ + Count reports for a program + """ + query = ( + select(func.count()).select_from(Report).where( + Report.program_id == program_id + ) + ) + + if status_filter: + query = query.where(Report.status == status_filter) + + result = await session.execute(query) + return result.scalar_one() + + @classmethod + async def get_inbox_for_company( + cls, + session: AsyncSession, + company_id: UUID, + skip: int = 0, + limit: int = 20, + ) -> Sequence[Report]: + """ + Get all reports across all programs owned by company + """ + result = await session.execute( + select(Report).join(Program, + Report.program_id == Program.id).where( + Program.company_id == company_id + ).order_by(Report.created_at.desc() + ).offset(skip).limit(limit) + ) + return result.scalars().all() + + @classmethod + async def count_inbox_for_company( + cls, + session: AsyncSession, + company_id: UUID, + ) -> int: + """ + Count all reports for company's programs + """ + result = await session.execute( + select(func.count()).select_from(Report).join( + Program, + Report.program_id == Program.id + ).where(Program.company_id == company_id) + ) + return result.scalar_one() + + @classmethod + async def get_researcher_stats( + cls, + session: AsyncSession, + researcher_id: UUID, + ) -> dict[str, + Any]: + """ + Get statistics for a researcher + """ + total_result = await session.execute( + select(func.count()).select_from(Report).where( + Report.researcher_id == researcher_id + ) + ) + total = total_result.scalar_one() + + accepted_result = await session.execute( + select(func.count()).select_from(Report).where( + Report.researcher_id == researcher_id, + Report.status.in_( + [ + ReportStatus.ACCEPTED, + ReportStatus.RESOLVED, + ReportStatus.DISCLOSED, + ] + ) + ) + ) + accepted = accepted_result.scalar_one() + + earned_result = await session.execute( + select(func.coalesce(func.sum(Report.bounty_amount), + 0)).where( + Report.researcher_id == researcher_id, + Report.bounty_amount.isnot(None), + ) + ) + earned = earned_result.scalar_one() + + return { + "total_reports": total, + "accepted_reports": accepted, + "total_earned": earned, + } + + +class CommentRepository(BaseRepository[Comment]): + """ + Repository for Comment model database operations + """ + model = Comment + + @classmethod + async def get_by_report( + cls, + session: AsyncSession, + report_id: UUID, + include_internal: bool = False, + ) -> Sequence[Comment]: + """ + Get comments for a report + """ + query = select(Comment).where(Comment.report_id == report_id) + + if not include_internal: + query = query.where(Comment.is_internal == False) + + result = await session.execute( + query.order_by(Comment.created_at.asc()) + ) + return result.scalars().all() + + +class AttachmentRepository(BaseRepository[Attachment]): + """ + Repository for Attachment model database operations + """ + model = Attachment + + @classmethod + async def get_by_report( + cls, + session: AsyncSession, + report_id: UUID, + ) -> Sequence[Attachment]: + """ + Get attachments for a report + """ + result = await session.execute( + select(Attachment).where(Attachment.report_id == report_id + ).order_by( + Attachment.created_at.asc() + ) + ) + return result.scalars().all() diff --git a/PROJECTS/bug-bounty-platform/backend/app/report/routes.py b/PROJECTS/bug-bounty-platform/backend/app/report/routes.py new file mode 100644 index 00000000..1b08f996 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/report/routes.py @@ -0,0 +1,251 @@ +""" +ⒸAngelaMos | 2025 +routes.py +""" + +from uuid import UUID + +from fastapi import APIRouter, Query, status + +from config import ReportStatus +from core.dependencies import CurrentUser +from core.responses import ( + AUTH_401, + FORBIDDEN_403, + NOT_FOUND_404, +) +from .schemas import ( + CommentCreate, + CommentResponse, + ReportCreate, + ReportDetailResponse, + ReportListResponse, + ReportResponse, + ReportStatsResponse, + ReportTriageUpdate, + ReportUpdate, +) +from .dependencies import ReportServiceDep + + +router = APIRouter(prefix = "/reports", tags = ["reports"]) + + +@router.post( + "", + response_model = ReportResponse, + status_code = status.HTTP_201_CREATED, + responses = { + **AUTH_401, + **NOT_FOUND_404 + }, +) +async def submit_report( + report_service: ReportServiceDep, + current_user: CurrentUser, + report_data: ReportCreate, +) -> ReportResponse: + """ + Submit a new vulnerability report + """ + return await report_service.submit_report(current_user, report_data) + + +@router.get( + "", + response_model = ReportListResponse, + responses = {**AUTH_401}, +) +async def list_my_reports( + report_service: ReportServiceDep, + current_user: CurrentUser, + page: int = Query(default = 1, + ge = 1), + size: int = Query(default = 20, + ge = 1, + le = 100), +) -> ReportListResponse: + """ + List reports submitted by current user + """ + return await report_service.list_my_reports(current_user, page, size) + + +@router.get( + "/inbox", + response_model = ReportListResponse, + responses = {**AUTH_401}, +) +async def list_inbox( + report_service: ReportServiceDep, + current_user: CurrentUser, + page: int = Query(default = 1, + ge = 1), + size: int = Query(default = 20, + ge = 1, + le = 100), +) -> ReportListResponse: + """ + List all reports across user's programs (company inbox) + """ + return await report_service.list_inbox(current_user, page, size) + + +@router.get( + "/stats", + response_model = ReportStatsResponse, + responses = {**AUTH_401}, +) +async def get_my_stats( + report_service: ReportServiceDep, + current_user: CurrentUser, +) -> ReportStatsResponse: + """ + Get current user's report statistics + """ + return await report_service.get_my_stats(current_user) + + +@router.get( + "/program/{program_id}", + response_model = ReportListResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def list_program_reports( + report_service: ReportServiceDep, + current_user: CurrentUser, + program_id: UUID, + page: int = Query(default = 1, + ge = 1), + size: int = Query(default = 20, + ge = 1, + le = 100), + status_filter: ReportStatus | None = None, +) -> ReportListResponse: + """ + List reports for a specific program (program owner only) + """ + return await report_service.list_program_reports( + current_user, + program_id, + page, + size, + status_filter, + ) + + +@router.get( + "/{report_id}", + response_model = ReportDetailResponse, + responses = { + **AUTH_401, + **NOT_FOUND_404 + }, +) +async def get_report( + report_service: ReportServiceDep, + current_user: CurrentUser, + report_id: UUID, +) -> ReportDetailResponse: + """ + Get report by ID with full details + """ + return await report_service.get_report(current_user, report_id) + + +@router.patch( + "/{report_id}", + response_model = ReportResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def update_report( + report_service: ReportServiceDep, + current_user: CurrentUser, + report_id: UUID, + report_data: ReportUpdate, +) -> ReportResponse: + """ + Update report (researcher only, only if still open) + """ + return await report_service.update_report( + current_user, + report_id, + report_data + ) + + +@router.patch( + "/{report_id}/triage", + response_model = ReportResponse, + responses = { + **AUTH_401, + **FORBIDDEN_403, + **NOT_FOUND_404 + }, +) +async def triage_report( + report_service: ReportServiceDep, + current_user: CurrentUser, + report_id: UUID, + triage_data: ReportTriageUpdate, +) -> ReportResponse: + """ + Triage a report (program owner only) + """ + return await report_service.triage_report( + current_user, + report_id, + triage_data + ) + + +@router.get( + "/{report_id}/comments", + response_model = list[CommentResponse], + responses = { + **AUTH_401, + **NOT_FOUND_404 + }, +) +async def list_comments( + report_service: ReportServiceDep, + current_user: CurrentUser, + report_id: UUID, +) -> list[CommentResponse]: + """ + List comments for a report + """ + return await report_service.list_comments(current_user, report_id) + + +@router.post( + "/{report_id}/comments", + response_model = CommentResponse, + status_code = status.HTTP_201_CREATED, + responses = { + **AUTH_401, + **NOT_FOUND_404 + }, +) +async def add_comment( + report_service: ReportServiceDep, + current_user: CurrentUser, + report_id: UUID, + comment_data: CommentCreate, +) -> CommentResponse: + """ + Add comment to a report + """ + return await report_service.add_comment( + current_user, + report_id, + comment_data + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/report/schemas.py b/PROJECTS/bug-bounty-platform/backend/app/report/schemas.py new file mode 100644 index 00000000..6364fad2 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/report/schemas.py @@ -0,0 +1,141 @@ +""" +ⒸAngelaMos | 2025 +schemas.py +""" + +from uuid import UUID +from datetime import datetime +from decimal import Decimal + +from pydantic import Field + +from config import ( + CWE_ID_MAX_LENGTH, + REPORT_TITLE_MAX_LENGTH, + ReportStatus, + Severity, +) +from core.base_schema import ( + BaseSchema, + BaseResponseSchema, +) + + +class ReportCreate(BaseSchema): + """ + Schema for submitting a vulnerability report + """ + program_id: UUID + title: str = Field(max_length = REPORT_TITLE_MAX_LENGTH) + description: str + steps_to_reproduce: str | None = None + impact: str | None = None + severity_submitted: Severity = Severity.MEDIUM + + +class ReportUpdate(BaseSchema): + """ + Schema for researcher updating their report + """ + title: str | None = Field( + default = None, + max_length = REPORT_TITLE_MAX_LENGTH + ) + description: str | None = None + steps_to_reproduce: str | None = None + impact: str | None = None + severity_submitted: Severity | None = None + + +class ReportTriageUpdate(BaseSchema): + """ + Schema for company triaging a report + """ + status: ReportStatus | None = None + severity_final: Severity | None = None + cvss_score: Decimal | None = Field(default = None, ge = 0, le = 10) + cwe_id: str | None = Field( + default = None, + max_length = CWE_ID_MAX_LENGTH + ) + bounty_amount: int | None = Field(default = None, ge = 0) + duplicate_of_id: UUID | None = None + + +class ReportResponse(BaseResponseSchema): + """ + Schema for report API responses + """ + program_id: UUID + researcher_id: UUID + title: str + description: str + steps_to_reproduce: str | None + impact: str | None + severity_submitted: Severity + severity_final: Severity | None + status: ReportStatus + cvss_score: Decimal | None + cwe_id: str | None + bounty_amount: int | None + duplicate_of_id: UUID | None + triaged_at: datetime | None + resolved_at: datetime | None + disclosed_at: datetime | None + + +class ReportListResponse(BaseSchema): + """ + Schema for paginated report list + """ + items: list[ReportResponse] + total: int + page: int + size: int + + +class CommentCreate(BaseSchema): + """ + Schema for adding a comment to a report + """ + content: str + is_internal: bool = False + + +class CommentResponse(BaseResponseSchema): + """ + Schema for comment API responses + """ + report_id: UUID + author_id: UUID + content: str + is_internal: bool + + +class AttachmentResponse(BaseResponseSchema): + """ + Schema for attachment API responses + """ + report_id: UUID + comment_id: UUID | None + filename: str + mime_type: str + size_bytes: int + + +class ReportDetailResponse(ReportResponse): + """ + Schema for report detail with comments + """ + comments: list[CommentResponse] + attachments: list[AttachmentResponse] + + +class ReportStatsResponse(BaseSchema): + """ + Schema for researcher stats + """ + total_reports: int + accepted_reports: int + total_earned: int + reputation_score: int diff --git a/PROJECTS/bug-bounty-platform/backend/app/report/service.py b/PROJECTS/bug-bounty-platform/backend/app/report/service.py new file mode 100644 index 00000000..5c4b4ef2 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/report/service.py @@ -0,0 +1,381 @@ +""" +ⒸAngelaMos | 2025 +service.py +""" + +from uuid import UUID + +from sqlalchemy.ext.asyncio import AsyncSession + +from config import ReportStatus +from core.exceptions import ( + CannotSubmitToOwnProgram, + NotProgramOwner, + NotReportOwner, + ProgramNotActive, + ProgramNotFound, + ReportNotFound, +) +from program.repository import ProgramRepository +from user.User import User +from .schemas import ( + AttachmentResponse, + CommentCreate, + CommentResponse, + ReportCreate, + ReportDetailResponse, + ReportListResponse, + ReportResponse, + ReportStatsResponse, + ReportTriageUpdate, + ReportUpdate, +) +from .repository import ( + CommentRepository, + ReportRepository, +) + + +class ReportService: + """ + Business logic for report operations + """ + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def submit_report( + self, + user: User, + report_data: ReportCreate, + ) -> ReportResponse: + """ + Submit a new vulnerability report + """ + program = await ProgramRepository.get_by_id( + self.session, + report_data.program_id + ) + if not program: + raise ProgramNotFound(str(report_data.program_id)) + + if not program.is_active: + raise ProgramNotActive() + + if program.company_id == user.id: + raise CannotSubmitToOwnProgram() + + report = await ReportRepository.create( + self.session, + program_id = report_data.program_id, + researcher_id = user.id, + title = report_data.title, + description = report_data.description, + steps_to_reproduce = report_data.steps_to_reproduce, + impact = report_data.impact, + severity_submitted = report_data.severity_submitted, + ) + return ReportResponse.model_validate(report) + + async def get_report( + self, + user: User, + report_id: UUID, + ) -> ReportDetailResponse: + """ + Get report by ID with details + """ + report = await ReportRepository.get_by_id_with_details( + self.session, + report_id + ) + if not report: + raise ReportNotFound(str(report_id)) + + program = await ProgramRepository.get_by_id( + self.session, + report.program_id + ) + + is_researcher = report.researcher_id == user.id + is_program_owner = program and program.company_id == user.id + + if not is_researcher and not is_program_owner: + raise ReportNotFound(str(report_id)) + + comments = report.comments + if is_researcher and not is_program_owner: + comments = [c for c in comments if not c.is_internal] + + return ReportDetailResponse( + id = report.id, + created_at = report.created_at, + updated_at = report.updated_at, + program_id = report.program_id, + researcher_id = report.researcher_id, + title = report.title, + description = report.description, + steps_to_reproduce = report.steps_to_reproduce, + impact = report.impact, + severity_submitted = report.severity_submitted, + severity_final = report.severity_final, + status = report.status, + cvss_score = report.cvss_score, + cwe_id = report.cwe_id, + bounty_amount = report.bounty_amount, + duplicate_of_id = report.duplicate_of_id, + triaged_at = report.triaged_at, + resolved_at = report.resolved_at, + disclosed_at = report.disclosed_at, + comments = [ + CommentResponse.model_validate(c) for c in comments + ], + attachments = [ + AttachmentResponse.model_validate(a) + for a in report.attachments + ], + ) + + async def list_my_reports( + self, + user: User, + page: int, + size: int, + ) -> ReportListResponse: + """ + List reports submitted by current user + """ + skip = (page - 1) * size + reports = await ReportRepository.get_by_researcher( + self.session, + researcher_id = user.id, + skip = skip, + limit = size, + ) + total = await ReportRepository.count_by_researcher( + self.session, + user.id + ) + return ReportListResponse( + items = [ReportResponse.model_validate(r) for r in reports], + total = total, + page = page, + size = size, + ) + + async def list_program_reports( + self, + user: User, + program_id: UUID, + page: int, + size: int, + status_filter: ReportStatus | None = None, + ) -> ReportListResponse: + """ + List reports for a program (program owner only) + """ + program = await ProgramRepository.get_by_id( + self.session, + program_id + ) + if not program: + raise ProgramNotFound(str(program_id)) + + if program.company_id != user.id: + raise NotProgramOwner() + + skip = (page - 1) * size + reports = await ReportRepository.get_by_program( + self.session, + program_id = program_id, + skip = skip, + limit = size, + status_filter = status_filter, + ) + total = await ReportRepository.count_by_program( + self.session, + program_id, + status_filter + ) + return ReportListResponse( + items = [ReportResponse.model_validate(r) for r in reports], + total = total, + page = page, + size = size, + ) + + async def list_inbox( + self, + user: User, + page: int, + size: int, + ) -> ReportListResponse: + """ + List all reports across user's programs + """ + skip = (page - 1) * size + reports = await ReportRepository.get_inbox_for_company( + self.session, + company_id = user.id, + skip = skip, + limit = size, + ) + total = await ReportRepository.count_inbox_for_company( + self.session, + user.id + ) + return ReportListResponse( + items = [ReportResponse.model_validate(r) for r in reports], + total = total, + page = page, + size = size, + ) + + async def update_report( + self, + user: User, + report_id: UUID, + report_data: ReportUpdate, + ) -> ReportResponse: + """ + Update report (researcher only, only if still open) + """ + report = await ReportRepository.get_by_id(self.session, report_id) + if not report: + raise ReportNotFound(str(report_id)) + + if report.researcher_id != user.id: + raise NotReportOwner() + + if not report.is_open: + raise ReportNotFound(str(report_id)) + + update_dict = report_data.model_dump(exclude_unset = True) + updated = await ReportRepository.update( + self.session, + report, + **update_dict, + ) + return ReportResponse.model_validate(updated) + + async def triage_report( + self, + user: User, + report_id: UUID, + triage_data: ReportTriageUpdate, + ) -> ReportResponse: + """ + Triage a report (program owner only) + """ + report = await ReportRepository.get_by_id(self.session, report_id) + if not report: + raise ReportNotFound(str(report_id)) + + program = await ProgramRepository.get_by_id( + self.session, + report.program_id + ) + if not program or program.company_id != user.id: + raise NotProgramOwner() + + update_dict = triage_data.model_dump(exclude_unset = True) + + if "status" in update_dict: + new_status = update_dict["status"] + if new_status == ReportStatus.TRIAGING and report.triaged_at is None: + report.mark_triaging() + elif new_status == ReportStatus.RESOLVED: + report.mark_resolved() + elif new_status == ReportStatus.DISCLOSED: + report.mark_disclosed() + + updated = await ReportRepository.update( + self.session, + report, + **update_dict, + ) + return ReportResponse.model_validate(updated) + + async def add_comment( + self, + user: User, + report_id: UUID, + comment_data: CommentCreate, + ) -> CommentResponse: + """ + Add comment to a report + """ + report = await ReportRepository.get_by_id(self.session, report_id) + if not report: + raise ReportNotFound(str(report_id)) + + program = await ProgramRepository.get_by_id( + self.session, + report.program_id + ) + + is_researcher = report.researcher_id == user.id + is_program_owner = program and program.company_id == user.id + + if not is_researcher and not is_program_owner: + raise ReportNotFound(str(report_id)) + + if comment_data.is_internal and not is_program_owner: + comment_data.is_internal = False + + comment = await CommentRepository.create( + self.session, + report_id = report_id, + author_id = user.id, + content = comment_data.content, + is_internal = comment_data.is_internal, + ) + return CommentResponse.model_validate(comment) + + async def list_comments( + self, + user: User, + report_id: UUID, + ) -> list[CommentResponse]: + """ + List comments for a report + """ + report = await ReportRepository.get_by_id(self.session, report_id) + if not report: + raise ReportNotFound(str(report_id)) + + program = await ProgramRepository.get_by_id( + self.session, + report.program_id + ) + + is_researcher = report.researcher_id == user.id + is_program_owner = bool(program and program.company_id == user.id) + + if not is_researcher and not is_program_owner: + raise ReportNotFound(str(report_id)) + + include_internal = is_program_owner + comments = await CommentRepository.get_by_report( + self.session, + report_id, + include_internal = include_internal, + ) + return [CommentResponse.model_validate(c) for c in comments] + + async def get_my_stats( + self, + user: User, + ) -> ReportStatsResponse: + """ + Get current user's report statistics + """ + stats = await ReportRepository.get_researcher_stats( + self.session, + user.id + ) + return ReportStatsResponse( + total_reports = stats["total_reports"], + accepted_reports = stats["accepted_reports"], + total_earned = stats["total_earned"], + reputation_score = user.reputation_score, + ) diff --git a/PROJECTS/bug-bounty-platform/backend/app/user/User.py b/PROJECTS/bug-bounty-platform/backend/app/user/User.py new file mode 100644 index 00000000..50b82ff0 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/user/User.py @@ -0,0 +1,126 @@ +""" +ⒸAngelaMos | 2025 +User.py +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from sqlalchemy import String, Text +from sqlalchemy.orm import ( + Mapped, + mapped_column, + relationship, +) + +from config import ( + COMPANY_NAME_MAX_LENGTH, + EMAIL_MAX_LENGTH, + FULL_NAME_MAX_LENGTH, + PASSWORD_HASH_MAX_LENGTH, + SafeEnum, + UserRole, + WEBSITE_MAX_LENGTH, +) +from core.Base import ( + Base, + TimestampMixin, + UUIDMixin, +) + +if TYPE_CHECKING: + from auth.RefreshToken import RefreshToken + from program.Program import Program + from report.Report import Report + + +class User(Base, UUIDMixin, TimestampMixin): + """ + User account model supporting both researchers and companies + """ + __tablename__ = "users" + + email: Mapped[str] = mapped_column( + String(EMAIL_MAX_LENGTH), + unique = True, + index = True, + ) + hashed_password: Mapped[str] = mapped_column( + String(PASSWORD_HASH_MAX_LENGTH) + ) + + full_name: Mapped[str | None] = mapped_column( + String(FULL_NAME_MAX_LENGTH), + default = None, + ) + + is_active: Mapped[bool] = mapped_column(default = True) + is_verified: Mapped[bool] = mapped_column(default = False) + + role: Mapped[UserRole] = mapped_column( + SafeEnum(UserRole, + unknown_value = UserRole.UNKNOWN), + default = UserRole.USER, + ) + + token_version: Mapped[int] = mapped_column(default = 0) + + company_name: Mapped[str | None] = mapped_column( + String(COMPANY_NAME_MAX_LENGTH), + default = None, + ) + bio: Mapped[str | None] = mapped_column( + Text, + default = None, + ) + website: Mapped[str | None] = mapped_column( + String(WEBSITE_MAX_LENGTH), + default = None, + ) + reputation_score: Mapped[int] = mapped_column(default = 0) + + refresh_tokens: Mapped[list[RefreshToken]] = relationship( + back_populates = "user", + cascade = "all, delete-orphan", + lazy = "raise", + ) + + programs: Mapped[list[Program]] = relationship( + back_populates = "company", + cascade = "all, delete-orphan", + lazy = "raise", + ) + + reports: Mapped[list[Report]] = relationship( + back_populates = "researcher", + cascade = "all, delete-orphan", + lazy = "raise", + ) + + def increment_token_version(self) -> None: + """ + Invalidate all existing tokens for this user + """ + self.token_version += 1 + + @property + def is_verified_company(self) -> bool: + """ + Check if user is a verified organization (optional upgrade) + """ + return self.role == UserRole.COMPANY + + @property + def can_submit_reports(self) -> bool: + """ + Any authenticated user can submit reports + """ + return self.role != UserRole.UNKNOWN + + @property + def can_create_program(self) -> bool: + """ + Any authenticated user can create a program + """ + return self.role != UserRole.UNKNOWN diff --git a/PROJECTS/bug-bounty-platform/backend/app/user/__init__.py b/PROJECTS/bug-bounty-platform/backend/app/user/__init__.py new file mode 100644 index 00000000..10f02dc8 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/user/__init__.py @@ -0,0 +1,4 @@ +""" +ⒸAngelaMos | 2025 +User Domain +""" diff --git a/PROJECTS/bug-bounty-platform/backend/app/user/dependencies.py b/PROJECTS/bug-bounty-platform/backend/app/user/dependencies.py new file mode 100644 index 00000000..631bd79f --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/user/dependencies.py @@ -0,0 +1,21 @@ +""" +ⒸAngelaMos | 2025 +dependencies.py +""" + +from typing import Annotated + +from fastapi import Depends + +from core.dependencies import DBSession +from .service import UserService + + +def get_user_service(db: DBSession) -> UserService: + """ + Dependency to inject UserService instance + """ + return UserService(db) + + +UserServiceDep = Annotated[UserService, Depends(get_user_service)] diff --git a/PROJECTS/bug-bounty-platform/backend/app/user/py.typed b/PROJECTS/bug-bounty-platform/backend/app/user/py.typed new file mode 100644 index 00000000..e69de29b diff --git a/PROJECTS/bug-bounty-platform/backend/app/user/repository.py b/PROJECTS/bug-bounty-platform/backend/app/user/repository.py new file mode 100644 index 00000000..1c0eba39 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/user/repository.py @@ -0,0 +1,111 @@ +""" +ⒸAngelaMos | 2025 +repository.py +""" +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from config import UserRole +from .User import User +from core.base_repository import BaseRepository + + +class UserRepository(BaseRepository[User]): + """ + Repository for User model database operations + """ + model = User + + @classmethod + async def get_by_email( + cls, + session: AsyncSession, + email: str, + ) -> User | None: + """ + Get user by email address + """ + result = await session.execute( + select(User).where(User.email == email) + ) + return result.scalars().first() + + @classmethod + async def get_by_id( + cls, + session: AsyncSession, + id: UUID, + ) -> User | None: + """ + Get user by ID + """ + return await session.get(User, id) + + @classmethod + async def email_exists( + cls, + session: AsyncSession, + email: str, + ) -> bool: + """ + Check if email is already registered + """ + result = await session.execute( + select(User.id).where(User.email == email) + ) + return result.scalars().first() is not None + + @classmethod + async def create_user( + cls, + session: AsyncSession, + email: str, + hashed_password: str, + full_name: str | None = None, + role: UserRole = UserRole.USER, + ) -> User: + """ + Create a new user + """ + user = User( + email = email, + hashed_password = hashed_password, + full_name = full_name, + role = role, + ) + session.add(user) + await session.flush() + await session.refresh(user) + return user + + @classmethod + async def update_password( + cls, + session: AsyncSession, + user: User, + hashed_password: str, + ) -> User: + """ + Update user password and increment token version + """ + user.hashed_password = hashed_password + user.increment_token_version() + await session.flush() + await session.refresh(user) + return user + + @classmethod + async def increment_token_version( + cls, + session: AsyncSession, + user: User, + ) -> User: + """ + Invalidate all user tokens + """ + user.increment_token_version() + await session.flush() + await session.refresh(user) + return user diff --git a/PROJECTS/bug-bounty-platform/backend/app/user/routes.py b/PROJECTS/bug-bounty-platform/backend/app/user/routes.py new file mode 100644 index 00000000..4616804b --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/user/routes.py @@ -0,0 +1,78 @@ +""" +ⒸAngelaMos | 2025 +routes.py +""" + +from uuid import UUID + +from fastapi import ( + APIRouter, + status, +) + +from core.dependencies import CurrentUser +from core.responses import ( + AUTH_401, + CONFLICT_409, + NOT_FOUND_404, +) +from .schemas import ( + UserCreate, + UserResponse, + UserUpdate, +) +from .dependencies import UserServiceDep + + +router = APIRouter(prefix = "/users", tags = ["users"]) + + +@router.post( + "", + response_model = UserResponse, + status_code = status.HTTP_201_CREATED, + responses = {**CONFLICT_409}, +) +async def create_user( + user_service: UserServiceDep, + user_data: UserCreate, +) -> UserResponse: + """ + Register a new user + """ + return await user_service.create_user(user_data) + + +@router.get( + "/{user_id}", + response_model = UserResponse, + responses = { + **AUTH_401, + **NOT_FOUND_404 + }, +) +async def get_user( + user_service: UserServiceDep, + user_id: UUID, + _: CurrentUser, +) -> UserResponse: + """ + Get user by ID + """ + return await user_service.get_user_by_id(user_id) + + +@router.patch( + "/me", + response_model = UserResponse, + responses = {**AUTH_401}, +) +async def update_current_user( + user_service: UserServiceDep, + current_user: CurrentUser, + user_data: UserUpdate, +) -> UserResponse: + """ + Update current user profile + """ + return await user_service.update_user(current_user, user_data) diff --git a/PROJECTS/bug-bounty-platform/backend/app/user/schemas.py b/PROJECTS/bug-bounty-platform/backend/app/user/schemas.py new file mode 100644 index 00000000..be4c9e32 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/user/schemas.py @@ -0,0 +1,109 @@ +""" +ⒸAngelaMos | 2025 +schemas.py +""" + +from pydantic import ( + Field, + EmailStr, + field_validator, +) + +from config import ( + UserRole, + FULL_NAME_MAX_LENGTH, + PASSWORD_MAX_LENGTH, + PASSWORD_MIN_LENGTH, +) +from core.base_schema import ( + BaseSchema, + BaseResponseSchema, +) + + +class UserCreate(BaseSchema): + """ + Schema for user registration + """ + email: EmailStr + password: str = Field( + min_length = PASSWORD_MIN_LENGTH, + max_length = PASSWORD_MAX_LENGTH + ) + full_name: str | None = Field( + default = None, + max_length = FULL_NAME_MAX_LENGTH + ) + + @field_validator("password") + @classmethod + def validate_password_strength(cls, v: str) -> str: + """ + Ensure password has minimum complexity + """ + if not any(c.isupper() for c in v): + raise ValueError( + "Password must contain at least one uppercase letter" + ) + if not any(c.isdigit() for c in v): + raise ValueError("Password must contain at least one digit") + return v + + +class UserUpdate(BaseSchema): + """ + Schema for updating user profile + """ + full_name: str | None = Field( + default = None, + max_length = FULL_NAME_MAX_LENGTH + ) + + +class UserUpdateAdmin(UserUpdate): + """ + Schema for admin updating user + """ + email: EmailStr | None = None + is_active: bool | None = None + is_verified: bool | None = None + role: UserRole | None = None + + +class AdminUserCreate(BaseSchema): + """ + Schema for admin creating a user + """ + email: EmailStr + password: str = Field( + min_length = PASSWORD_MIN_LENGTH, + max_length = PASSWORD_MAX_LENGTH + ) + full_name: str | None = Field( + default = None, + max_length = FULL_NAME_MAX_LENGTH + ) + role: UserRole = UserRole.USER + is_active: bool = True + is_verified: bool = False + + +class UserResponse(BaseResponseSchema): + """ + Schema for user API responses + """ + email: EmailStr + full_name: str | None + is_active: bool + is_verified: bool + role: UserRole + + +class UserListResponse(BaseSchema): + """ + Schema for paginated user list + """ + items: list[UserResponse] + total: int + page: int + size: int diff --git a/PROJECTS/bug-bounty-platform/backend/app/user/service.py b/PROJECTS/bug-bounty-platform/backend/app/user/service.py new file mode 100644 index 00000000..3a7d085c --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/app/user/service.py @@ -0,0 +1,221 @@ +""" +ⒸAngelaMos | 2025 +service.py +""" + +from uuid import UUID +from sqlalchemy.ext.asyncio import ( + AsyncSession, +) + +from config import settings, UserRole +from core.exceptions import ( + EmailAlreadyExists, + InvalidCredentials, + UserNotFound, +) +from core.security import ( + hash_password, + verify_password, +) +from .schemas import ( + AdminUserCreate, + UserCreate, + UserListResponse, + UserResponse, + UserUpdate, + UserUpdateAdmin, +) +from .User import User +from .repository import UserRepository + + +class UserService: + """ + Business logic for user operations + """ + def __init__(self, session: AsyncSession) -> None: + self.session = session + + async def create_user( + self, + user_data: UserCreate, + ) -> UserResponse: + """ + Register a new user + """ + if await UserRepository.email_exists(self.session, + user_data.email): + raise EmailAlreadyExists(user_data.email) + + role = UserRole.USER + if settings.ADMIN_EMAIL and user_data.email.lower( + ) == settings.ADMIN_EMAIL.lower(): + role = UserRole.ADMIN + + hashed = await hash_password(user_data.password) + user = await UserRepository.create_user( + self.session, + email = user_data.email, + hashed_password = hashed, + full_name = user_data.full_name, + role = role, + ) + return UserResponse.model_validate(user) + + async def get_user_by_id( + self, + user_id: UUID, + ) -> UserResponse: + """ + Get user by ID + """ + user = await UserRepository.get_by_id(self.session, user_id) + if not user: + raise UserNotFound(str(user_id)) + return UserResponse.model_validate(user) + + async def get_user_model_by_id( + self, + user_id: UUID, + ) -> User: + """ + Get user model by ID (for internal use) + """ + user = await UserRepository.get_by_id(self.session, user_id) + if not user: + raise UserNotFound(str(user_id)) + return user + + async def update_user( + self, + user: User, + user_data: UserUpdate, + ) -> UserResponse: + """ + Update user profile + """ + update_dict = user_data.model_dump(exclude_unset = True) + updated_user = await UserRepository.update( + self.session, + user, + **update_dict + ) + return UserResponse.model_validate(updated_user) + + async def change_password( + self, + user: User, + current_password: str, + new_password: str, + ) -> None: + """ + Change user password + """ + is_valid, _ = await verify_password(current_password, user.hashed_password) + if not is_valid: + raise InvalidCredentials() + + hashed = await hash_password(new_password) + await UserRepository.update_password(self.session, user, hashed) + + async def deactivate_user( + self, + user: User, + ) -> UserResponse: + """ + Deactivate user account + """ + updated = await UserRepository.update( + self.session, + user, + is_active = False + ) + return UserResponse.model_validate(updated) + + async def list_users( + self, + page: int, + size: int, + ) -> UserListResponse: + """ + List users with pagination + """ + skip = (page - 1) * size + users = await UserRepository.get_multi( + self.session, + skip = skip, + limit = size + ) + total = await UserRepository.count(self.session) + return UserListResponse( + items = [UserResponse.model_validate(u) for u in users], + total = total, + page = page, + size = size, + ) + + async def admin_create_user( + self, + user_data: AdminUserCreate, + ) -> UserResponse: + """ + Admin creates a new user + """ + if await UserRepository.email_exists(self.session, + user_data.email): + raise EmailAlreadyExists(user_data.email) + + hashed = await hash_password(user_data.password) + user = await UserRepository.create( + self.session, + email = user_data.email, + hashed_password = hashed, + full_name = user_data.full_name, + role = user_data.role, + is_active = user_data.is_active, + is_verified = user_data.is_verified, + ) + return UserResponse.model_validate(user) + + async def admin_update_user( + self, + user_id: UUID, + user_data: UserUpdateAdmin, + ) -> UserResponse: + """ + Admin updates a user + """ + user = await UserRepository.get_by_id(self.session, user_id) + if not user: + raise UserNotFound(str(user_id)) + + update_dict = user_data.model_dump(exclude_unset = True) + + if "email" in update_dict: + existing = await UserRepository.get_by_email( + self.session, + update_dict["email"] + ) + if existing and existing.id != user_id: + raise EmailAlreadyExists(update_dict["email"]) + + updated_user = await UserRepository.update( + self.session, + user, + **update_dict + ) + return UserResponse.model_validate(updated_user) + + async def admin_delete_user( + self, + user_id: UUID, + ) -> None: + """ + Admin deletes a user (hard delete) + """ + user = await UserRepository.get_by_id(self.session, user_id) + if not user: + raise UserNotFound(str(user_id)) + + await UserRepository.delete(self.session, user) diff --git a/PROJECTS/bug-bounty-platform/backend/conftest.py b/PROJECTS/bug-bounty-platform/backend/conftest.py new file mode 100644 index 00000000..9b95ea0a --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/conftest.py @@ -0,0 +1,291 @@ +""" +©AngelaMos | 2025 +conftest.py + +Test configuration, fixtures, and factories +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent / "app")) + +import hashlib +import secrets +from datetime import ( + UTC, + datetime, + timedelta, +) +from uuid import uuid4 +from collections.abc import AsyncIterator + +import pytest +from httpx import ( + AsyncClient, + ASGITransport, +) +import pytest_asyncio +from sqlalchemy.ext.asyncio import ( + AsyncSession, + create_async_engine, +) +from sqlalchemy.pool import StaticPool + +from core.security import ( + hash_password, + create_access_token, +) +from config import UserRole +from core.database import get_db_session + +from core.Base import Base +from user.User import User +from auth.RefreshToken import RefreshToken + + +@pytest_asyncio.fixture(scope = "session", loop_scope = "session") +async def test_engine(): + """ + Session scoped async engine with in memory SQLite + StaticPool keeps single connection so DB persists + """ + engine = create_async_engine( + "sqlite+aiosqlite:///:memory:", + poolclass = StaticPool, + connect_args = {"check_same_thread": False}, + echo = False, + ) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield engine + await engine.dispose() + + +@pytest.fixture +async def db_session(test_engine) -> AsyncIterator[AsyncSession]: + """ + Per test session with transaction rollback for isolation + App commits become savepoints that rollback with test + """ + async with test_engine.connect() as conn: + await conn.begin() + + session = AsyncSession( + bind = conn, + expire_on_commit = False, + join_transaction_mode = "create_savepoint", + ) + + yield session + + await session.close() + await conn.rollback() + + +@pytest.fixture +async def client(db_session: AsyncSession) -> AsyncIterator[AsyncClient]: + """ + Async HTTP client with DB session override + """ + from factory import create_app + + app = create_app() + + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db_session] = override_get_db + + async with AsyncClient( + transport = ASGITransport(app = app), + base_url = "http://test", + ) as ac: + yield ac + + app.dependency_overrides.clear() + + +@pytest.fixture +def auth_headers(access_token: str) -> dict[str, str]: + """ + Authorization headers for authenticated requests + """ + return {"Authorization": f"Bearer {access_token}"} + + +@pytest.fixture +def admin_auth_headers(admin_access_token: str) -> dict[str, str]: + """ + Authorization headers for admin requests + """ + return {"Authorization": f"Bearer {admin_access_token}"} + + +class UserFactory: + """ + Factory for creating test users + """ + _counter = 0 + + @classmethod + async def create( + cls, + session: AsyncSession, + *, + email: str | None = None, + password: str = "TestPass123", + full_name: str | None = None, + role: UserRole = UserRole.USER, + is_active: bool = True, + is_verified: bool = True, + ) -> User: + cls._counter += 1 + + user = User( + email = email or f"user{cls._counter}@test.com", + hashed_password = await hash_password(password), + full_name = full_name or f"Test User {cls._counter}", + role = role, + is_active = is_active, + is_verified = is_verified, + ) + session.add(user) + await session.flush() + await session.refresh(user) + return user + + @classmethod + def reset(cls) -> None: + cls._counter = 0 + + +class RefreshTokenFactory: + """ + Factory for creating test refresh tokens + """ + @classmethod + async def create( + cls, + session: AsyncSession, + user: User, + *, + is_revoked: bool = False, + expires_delta: timedelta = timedelta(days = 7), + ) -> tuple[RefreshToken, + str]: + raw_token = secrets.token_urlsafe(32) + token_hash = hashlib.sha256(raw_token.encode()).hexdigest() + + token = RefreshToken( + user_id = user.id, + token_hash = token_hash, + family_id = uuid4(), + expires_at = datetime.now(UTC) + expires_delta, + is_revoked = is_revoked, + ) + session.add(token) + await session.flush() + await session.refresh(token) + return token, raw_token + + +@pytest.fixture +async def test_user(db_session: AsyncSession) -> User: + """ + Standard test user + """ + return await UserFactory.create(db_session) + + +@pytest.fixture +async def admin_user(db_session: AsyncSession) -> User: + """ + Admin test user + """ + return await UserFactory.create( + db_session, + email = "admin@test.com", + role = UserRole.ADMIN, + ) + + +@pytest.fixture +async def inactive_user(db_session: AsyncSession) -> User: + """ + Inactive test user + """ + return await UserFactory.create( + db_session, + email = "inactive@test.com", + is_active = False, + ) + + +@pytest.fixture +def access_token(test_user: User) -> str: + """ + Valid access token for test_user + """ + return create_access_token(test_user.id, test_user.token_version) + + +@pytest.fixture +def admin_access_token(admin_user: User) -> str: + """ + Valid access token for admin_user + """ + return create_access_token(admin_user.id, admin_user.token_version) + + +@pytest.fixture +async def refresh_token_pair( + db_session: AsyncSession, + test_user: User, +) -> tuple[RefreshToken, + str]: + """ + Refresh token DB record and raw token string + """ + return await RefreshTokenFactory.create(db_session, test_user) + + +@pytest.fixture +async def expired_refresh_token_pair( + db_session: AsyncSession, + test_user: User, +) -> tuple[RefreshToken, + str]: + """ + Expired refresh token for testing + """ + return await RefreshTokenFactory.create( + db_session, + test_user, + expires_delta = timedelta(days = -1), + ) + + +@pytest.fixture +async def revoked_refresh_token_pair( + db_session: AsyncSession, + test_user: User, +) -> tuple[RefreshToken, + str]: + """ + Revoked refresh token for testing + """ + return await RefreshTokenFactory.create( + db_session, + test_user, + is_revoked = True, + ) + + +@pytest.fixture(autouse = True) +def reset_factories(): + """ + Reset factory counters between tests + """ + yield + UserFactory.reset() diff --git a/PROJECTS/bug-bounty-platform/backend/pyproject.toml b/PROJECTS/bug-bounty-platform/backend/pyproject.toml new file mode 100644 index 00000000..eed0abf8 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/pyproject.toml @@ -0,0 +1,287 @@ +[project] +name = "bug-bounty-platform" +version = "1.0.0" +description = "Full stack bug bounty platform" +requires-python = ">=3.12" + +dependencies = [ + "fastapi[standard]>=0.123.0,<1.0.0", + "pydantic>=2.12.5,<3.0.0", + "pydantic-settings>=2.12.0,<3.0.0", + "psycopg2-binary>=2.9.11", + "sqlalchemy>=2.0.44,<3.0.0", + "alembic>=1.17.0,<2.0.0", + "asyncpg>=0.31.0,<1.0.0", + "python-multipart>=0.0.20", + "pyjwt>=2.10.0", + "pwdlib[argon2]>=0.3.0", + "uuid6>=2025.0.1", + "slowapi>=0.1.9", + "redis>=7.1.0", + "structlog>=24.4.0", + "gunicorn>=23.0.0", + "uvicorn[standard]>=0.38.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=9.0.2", + "pytest-asyncio>=1.3.0", + "pytest-cov>=6.0.0", + "httpx>=0.28.1", + "aiosqlite>=0.21.0", + "asgi-lifespan>=2.1.0", + "mypy>=1.19.0", + "types-redis>=4.6.0", + "ruff>=0.14.8", + "pylint>=4.0.4", + "pylint-pydantic>=0.4.1", + "pylint-per-file-ignores>=3.2.0", + "ty>=0.0.1a32", + "pre-commit>=4.2.0", +] + +[project.urls] +Homepage = "https://bugbountyplatform.sh" +Repository = "https://github.com/CarterPerez-dev/Cybersecurity-Projects" +Issues = "https://github.com/CarterPerez-dev/Cybersecurity-Projects/issues" + + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["app"] + +[tool.ruff] +target-version = "py312" +line-length = 88 +src = ["app"] +exclude = ["alembic"] + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "W", # pycodestyle warnings + "F", # pyflakes + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "UP", # pyupgrade + "ARG", # flake8-unused-arguments + "SIM", # flake8-simplify + "PTH", # flake8-use-pathlib + "RUF", # ruff-specific + "ASYNC", # flake8-async + "S", # flake8-bandit (security) + "N", # pep8-naming +] +ignore = [ + "E501", # line too long (formatter handles this) + "B008", # function call in default argument (FastAPI Depends) + "S101", # assert usage (needed for tests) + "S104", # 0.0.0.0 binding (intentional for Docker) + "S105", # "bearer" token_type is not a password + "ARG001", # unused function argument (common in FastAPI deps) + "E712", # == False is REQUIRED for SQLAlchemy WHERE clauses + "N999", # PascalCase module names (intentional: Base.py, User.py) + "N818", # exception naming convention (style preference) + "UP046", # Generic[T] syntax (keep for compatibility) + "RUF005", # list concatenation style (preference) +] + +[tool.ruff.lint.per-file-ignores] +"tests/**/*.py" = ["S101", "ARG001"] +"conftest.py" = ["S107"] +"app/core/rate_limit.py" = ["S110"] +"app/config.py" = ["F401"] +"app/**/schemas.py" = ["RUF012"] +"app/core/error_schemas.py" = ["RUF012"] + +[tool.mypy] +python_version = "3.12" +strict = true +warn_return_any = true +warn_unused_ignores = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +plugins = ["pydantic.mypy"] +exclude = ["alembic", ".venv", "venv"] + +[[tool.mypy.overrides]] +module = ["tests.*", "conftest"] +ignore_errors = true + +[[tool.mypy.overrides]] +module = ["core.logging"] +disable_error_code = ["no-any-return"] + +[[tool.mypy.overrides]] +module = [ + "uuid6", + "structlog", + "structlog.*", + "pwdlib", + "slowapi", + "slowapi.*", +] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["pydantic_settings.*"] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = ["config"] +implicit_reexport = true + +[[tool.mypy.overrides]] +module = ["core.enums", "core.security"] +disable_error_code = ["return-value", "no-any-return"] + +[[tool.mypy.overrides]] +module = ["user.repository", "auth.repository", "core.base_repository"] +disable_error_code = ["return-value", "no-any-return", "attr-defined"] + +[[tool.mypy.overrides]] +module = ["user.service", "auth.service"] +disable_error_code = ["no-any-return"] + +[[tool.mypy.overrides]] +module = ["factory"] +disable_error_code = ["arg-type"] + +[[tool.mypy.overrides]] +module = ["auth.routes"] +disable_error_code = ["misc"] + +[tool.pydantic-mypy] +init_forbid_extra = true +init_typed = true +warn_required_dynamic_aliases = true + + +[tool.pylint.main] +py-version = "3.11" +jobs = 4 +load-plugins = [ + "pylint_pydantic", + "pylint_per_file_ignores", +] +persistent = true +ignore = [ + "alembic", + "venv", + ".venv", + "__pycache__", + "build", + "dist", + ".git", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", +] +ignore-paths = [ + "^alembic/.*", + "^venv/.*", + "^.venv/.*", + "^build/.*", + "^dist/.*", +] + +[tool.pylint.messages_control] +disable = [ + "C0103", # invalid-name + "C0116", # missing-function-docstring (we use minimal docs) + "C0121", # singleton-comparison (== False required for SQLAlchemy) + "C0301", # line-too-long + "C0302", # too-many-lines + "C0303", # trailing-whitespace + "C0304", # final-newline-missing + "C0305", # trailing-newlines + "C0411", # wrong-import-order + "C0412", # ungrouped-imports (style preference) + "E0401", # import-error (uuid6/structlog/pwdlib not found by pylint) + "E0611", # no-name-in-module (false positive for config re-exports) + "E1102", # not-callable (false positive for SQLAlchemy func.now/count) + "E1136", # unsubscriptable-object (false positive for generics) + "R0801", # similar-lines + "R0901", # too-many-ancestors (SQLAlchemy inheritance) + "R0903", # too-few-public-methods + "R0917", # too-many-positional-arguments (FastAPI patterns) + "W0611", # unused-import (handled by ruff, config.py re-exports) + "W0612", # unused-variable (handled by ruff) + "W0613", # unused-argument (handled by ruff) + "W0621", # redefined-outer-name (FastAPI route/param naming) + "W0622", # redefined-builtin + "W0718", # broad-exception-caught (intentional in health/rate-limit) +] + +[tool.pylint-per-file-ignores] +"alembic/env.py" = "no-member" +"conftest.py" = "import-outside-toplevel" +"app/__main__.py" = "pointless-string-statement" + +[tool.pylint.format] +max-line-length = 95 + +[tool.pylint.design] +max-args = 12 +max-attributes = 10 +max-branches = 15 +max-locals = 20 +max-statements = 55 + + +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" +testpaths = ["tests"] +addopts = "-ra -q" +filterwarnings = [ + "ignore::DeprecationWarning", +] + +[tool.coverage.run] +branch = true +source = ["src"] + +[tool.coverage.report] +exclude_lines = [ + "pragma: no cover", + "if TYPE_CHECKING:", + "raise NotImplementedError", +] + + +[tool.ty.src] +include = ["app", "tests"] +exclude = ["alembic/versions/**", ".venv/**"] +respect-ignore-files = true + +[tool.ty.environment] +python-version = "3.12" +root = ["./app"] +python = "./.venv" + +[tool.ty.rules] +possibly-missing-attribute = "error" +possibly-missing-import = "error" +unused-ignore-comment = "warn" +redundant-cast = "warn" +undefined-reveal = "warn" + +[[tool.ty.overrides]] +include = ["tests/**"] +[tool.ty.overrides.rules] +unresolved-reference = "warn" +invalid-argument-type = "warn" + +[[tool.ty.overrides]] +include = ["app/repositories/**", "app/services/**"] +[tool.ty.overrides.rules] +unresolved-attribute = "warn" + +[tool.ty.terminal] +error-on-warning = false +output-format = "full" diff --git a/PROJECTS/bug-bounty-platform/backend/tests/__init__.py b/PROJECTS/bug-bounty-platform/backend/tests/__init__.py new file mode 100644 index 00000000..7fc7366b --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/tests/__init__.py @@ -0,0 +1,4 @@ +""" +AngelaMos | 2025 +__init__.py +""" diff --git a/PROJECTS/bug-bounty-platform/backend/tests/integration/__init__.py b/PROJECTS/bug-bounty-platform/backend/tests/integration/__init__.py new file mode 100644 index 00000000..7fc7366b --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/tests/integration/__init__.py @@ -0,0 +1,4 @@ +""" +AngelaMos | 2025 +__init__.py +""" diff --git a/PROJECTS/bug-bounty-platform/backend/tests/integration/test_admin.py b/PROJECTS/bug-bounty-platform/backend/tests/integration/test_admin.py new file mode 100644 index 00000000..6c97f62d --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/tests/integration/test_admin.py @@ -0,0 +1,256 @@ +""" +©AngelaMos | 2025 +test_admin.py +""" + +import pytest +from httpx import AsyncClient + +from user.User import User + + +URL_ADMIN_USERS = "/v1/admin/users" + + +def url_admin_user_by_id(user_id: str) -> str: + return f"{URL_ADMIN_USERS}/{user_id}" + + +@pytest.mark.asyncio +async def test_admin_create_user( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], +): + """ + Admin can create a new user + """ + response = await client.post( + URL_ADMIN_USERS, + headers = admin_auth_headers, + json = { + "email": "adminmade@test.com", + "password": "ValidPass123", + "full_name": "Admin Created User", + }, + ) + + assert response.status_code == 201 + data = response.json() + assert data["email"] == "adminmade@test.com" + assert data["full_name"] == "Admin Created User" + assert "id" in data + assert "hashed_password" not in data + + +@pytest.mark.asyncio +async def test_admin_create_user_non_admin_forbidden( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, str], +): + """ + Non admin cannot create user via admin endpoint + """ + response = await client.post( + URL_ADMIN_USERS, + headers = auth_headers, + json = { + "email": "shouldfail@test.com", + "password": "ValidPass123", + }, + ) + + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_admin_create_user_duplicate_email( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], + test_user: User, +): + """ + Admin create with duplicate email returns 409 + """ + response = await client.post( + URL_ADMIN_USERS, + headers = admin_auth_headers, + json = { + "email": test_user.email, + "password": "ValidPass123", + }, + ) + + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_admin_get_user_by_id( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], + test_user: User, +): + """ + Admin can get any user by ID + """ + response = await client.get( + url_admin_user_by_id(str(test_user.id)), + headers = admin_auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["id"] == str(test_user.id) + assert data["email"] == test_user.email + + +@pytest.mark.asyncio +async def test_admin_get_user_not_found( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], +): + """ + Admin get non existent user returns 404 + """ + fake_id = "00000000-0000-0000-0000-000000000000" + response = await client.get( + url_admin_user_by_id(fake_id), + headers = admin_auth_headers, + ) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_admin_update_user( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], + test_user: User, +): + """ + Admin can update any user + """ + response = await client.patch( + url_admin_user_by_id(str(test_user.id)), + headers = admin_auth_headers, + json = {"full_name": "Admin Updated Name"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["full_name"] == "Admin Updated Name" + assert data["id"] == str(test_user.id) + + +@pytest.mark.asyncio +async def test_admin_update_user_not_found( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], +): + """ + Admin update non existent user returns 404 + """ + fake_id = "00000000-0000-0000-0000-000000000000" + response = await client.patch( + url_admin_user_by_id(fake_id), + headers = admin_auth_headers, + json = {"full_name": "Should Fail"}, + ) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_admin_update_user_non_admin_forbidden( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, str], +): + """ + Non admin cannot update via admin endpoint + """ + response = await client.patch( + url_admin_user_by_id(str(test_user.id)), + headers = auth_headers, + json = {"full_name": "Should Fail"}, + ) + + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_admin_delete_user( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], + db_session, +): + """ + Admin can delete a user + """ + from user.User import User as UserModel + from core.security import hash_password + + user_to_delete = UserModel( + email = "deleteme@test.com", + hashed_password = await hash_password("TestPass123"), + full_name = "Delete Me", + ) + db_session.add(user_to_delete) + await db_session.flush() + await db_session.refresh(user_to_delete) + user_id = str(user_to_delete.id) + + response = await client.delete( + url_admin_user_by_id(user_id), + headers = admin_auth_headers, + ) + + assert response.status_code == 204 + + get_response = await client.get( + url_admin_user_by_id(user_id), + headers = admin_auth_headers, + ) + assert get_response.status_code == 404 + + +@pytest.mark.asyncio +async def test_admin_delete_user_not_found( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, str], +): + """ + Admin delete non existent user returns 404 + """ + fake_id = "00000000-0000-0000-0000-000000000000" + response = await client.delete( + url_admin_user_by_id(fake_id), + headers = admin_auth_headers, + ) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_admin_delete_user_non_admin_forbidden( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, str], +): + """ + Non admin cannot delete users + """ + response = await client.delete( + url_admin_user_by_id(str(test_user.id)), + headers = auth_headers, + ) + + assert response.status_code == 403 diff --git a/PROJECTS/bug-bounty-platform/backend/tests/integration/test_auth.py b/PROJECTS/bug-bounty-platform/backend/tests/integration/test_auth.py new file mode 100644 index 00000000..689b4970 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/tests/integration/test_auth.py @@ -0,0 +1,288 @@ +""" +©AngelaMos | 2025 +test_auth.py +""" + +import pytest +from httpx import AsyncClient + +from user.User import User +from auth.RefreshToken import RefreshToken + + +URL_LOGIN = "/v1/auth/login" +URL_REFRESH = "/v1/auth/refresh" +URL_LOGOUT = "/v1/auth/logout" +URL_LOGOUT_ALL = "/v1/auth/logout-all" +URL_ME = "/v1/auth/me" +URL_CHANGE_PASSWORD = "/v1/auth/change-password" + + +@pytest.mark.asyncio +async def test_login_success(client: AsyncClient, test_user: User): + """ + Valid credentials return access token and set refresh cookie + """ + response = await client.post( + URL_LOGIN, + data = { + "username": test_user.email, + "password": "TestPass123", + }, + ) + + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + assert "user" in data + assert data["user"]["email"] == test_user.email + assert "refresh_token" in response.cookies + + +@pytest.mark.asyncio +async def test_login_invalid_password( + client: AsyncClient, + test_user: User +): + """ + Wrong password returns 401 + """ + response = await client.post( + URL_LOGIN, + data = { + "username": test_user.email, + "password": "WrongPassword123", + }, + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_login_invalid_email(client: AsyncClient): + """ + Non-existent email returns 401 + """ + response = await client.post( + URL_LOGIN, + data = { + "username": "nonexistent@test.com", + "password": "TestPass123", + }, + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_login_inactive_user( + client: AsyncClient, + inactive_user: User +): + """ + Inactive user cannot login + """ + response = await client.post( + URL_LOGIN, + data = { + "username": inactive_user.email, + "password": "TestPass123", + }, + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_refresh_token_success( + client: AsyncClient, + refresh_token_pair: tuple[RefreshToken, + str], +): + """ + Valid refresh token returns new access token + """ + _, raw_token = refresh_token_pair + + response = await client.post( + URL_REFRESH, + cookies = {"refresh_token": raw_token}, + ) + + assert response.status_code == 200 + data = response.json() + assert "access_token" in data + assert data["token_type"] == "bearer" + + +@pytest.mark.asyncio +async def test_refresh_token_missing_returns_401(client: AsyncClient): + """ + Missing refresh token cookie returns 401, not 422. + """ + response = await client.post(URL_REFRESH) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_refresh_token_expired( + client: AsyncClient, + expired_refresh_token_pair: tuple[RefreshToken, + str], +): + """ + Expired refresh token returns 401 + """ + _, raw_token = expired_refresh_token_pair + + response = await client.post( + URL_REFRESH, + cookies = {"refresh_token": raw_token}, + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_refresh_token_revoked( + client: AsyncClient, + revoked_refresh_token_pair: tuple[RefreshToken, + str], +): + """ + Revoked refresh token returns 401. + """ + _, raw_token = revoked_refresh_token_pair + + response = await client.post( + URL_REFRESH, + cookies = {"refresh_token": raw_token}, + ) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_logout_success( + client: AsyncClient, + refresh_token_pair: tuple[RefreshToken, + str], +): + """ + Logout revokes refresh token and clears cookie. + """ + _, raw_token = refresh_token_pair + + response = await client.post( + URL_LOGOUT, + cookies = {"refresh_token": raw_token}, + ) + + assert response.status_code == 204 + + +@pytest.mark.asyncio +async def test_logout_missing_token_returns_401(client: AsyncClient): + """ + Logout without refresh token returns 401, not 422. + """ + response = await client.post(URL_LOGOUT) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_logout_all( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, + str], +): + """ + Logout all revokes all user sessions. + """ + response = await client.post( + URL_LOGOUT_ALL, + headers = auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert "revoked_sessions" in data + + +@pytest.mark.asyncio +async def test_get_current_user( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, + str], +): + """ + /me returns current authenticated user. + """ + response = await client.get( + URL_ME, + headers = auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["email"] == test_user.email + assert data["id"] == str(test_user.id) + + +@pytest.mark.asyncio +async def test_get_current_user_unauthenticated(client: AsyncClient): + """ + /me without auth returns 401. + """ + response = await client.get(URL_ME) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_change_password( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, + str], +): + """ + Password change works with valid current password. + """ + response = await client.post( + URL_CHANGE_PASSWORD, + headers = auth_headers, + json = { + "current_password": "TestPass123", + "new_password": "NewTestPass456", + }, + ) + + assert response.status_code == 204 + + +@pytest.mark.asyncio +async def test_change_password_wrong_current( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, + str], +): + """ + Password change fails with wrong current password. + """ + response = await client.post( + URL_CHANGE_PASSWORD, + headers = auth_headers, + json = { + "current_password": "WrongPassword123", + "new_password": "NewTestPass456", + }, + ) + + assert response.status_code == 401 diff --git a/PROJECTS/bug-bounty-platform/backend/tests/integration/test_health.py b/PROJECTS/bug-bounty-platform/backend/tests/integration/test_health.py new file mode 100644 index 00000000..78f69cd3 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/tests/integration/test_health.py @@ -0,0 +1,40 @@ +""" +©AngelaMos | 2025 +test_health.py +""" + +import pytest +from httpx import AsyncClient + + +URL_HEALTH = "/health" +URL_HEALTH_DETAILED = "/health/detailed" + + +@pytest.mark.asyncio +async def test_health_basic(client: AsyncClient): + """ + Basic health check returns 200 with healthy status + """ + response = await client.get(URL_HEALTH) + + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert "environment" in data + assert "version" in data + + +@pytest.mark.asyncio +async def test_health_detailed(client: AsyncClient): + """ + Detailed health check includes database status + """ + response = await client.get(URL_HEALTH_DETAILED) + + assert response.status_code == 200 + data = response.json() + assert data["status"] in ["healthy", "degraded"] + assert "database" in data + assert "environment" in data + assert "version" in data diff --git a/PROJECTS/bug-bounty-platform/backend/tests/integration/test_users.py b/PROJECTS/bug-bounty-platform/backend/tests/integration/test_users.py new file mode 100644 index 00000000..46a0800f --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/tests/integration/test_users.py @@ -0,0 +1,240 @@ +""" +©AngelaMos | 2025 +test_users.py +""" + +import pytest +from httpx import AsyncClient + +from user.User import User + + +URL_USERS = "/v1/users" +URL_ADMIN_USERS = "/v1/admin/users" +URL_USER_ME = "/v1/users/me" + + +def url_user_by_id(user_id: str) -> str: + return f"{URL_USERS}/{user_id}" + + +def url_admin_user_by_id(user_id: str) -> str: + return f"{URL_ADMIN_USERS}/{user_id}" + + +@pytest.mark.asyncio +async def test_create_user(client: AsyncClient): + """ + User registration creates new user + """ + response = await client.post( + URL_USERS, + json = { + "email": "newuser@test.com", + "password": "ValidPass123", + "full_name": "New User", + }, + ) + + assert response.status_code == 201 + data = response.json() + assert data["email"] == "newuser@test.com" + assert data["full_name"] == "New User" + assert "id" in data + assert "hashed_password" not in data + + +@pytest.mark.asyncio +async def test_create_user_duplicate_email( + client: AsyncClient, + test_user: User +): + """ + Duplicate email returns 409 conflict + """ + response = await client.post( + URL_USERS, + json = { + "email": test_user.email, + "password": "ValidPass123", + }, + ) + + assert response.status_code == 409 + + +@pytest.mark.asyncio +async def test_create_user_weak_password(client: AsyncClient): + """ + Weak password (no uppercase/digit) returns 422 + """ + response = await client.post( + URL_USERS, + json = { + "email": "weakpass@test.com", + "password": "weakpassword", + }, + ) + + assert response.status_code == 422 + + +@pytest.mark.asyncio +async def test_get_user_by_id( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, + str], +): + """ + Get user by ID returns user data + """ + response = await client.get( + url_user_by_id(str(test_user.id)), + headers = auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert data["email"] == test_user.email + assert data["id"] == str(test_user.id) + + +@pytest.mark.asyncio +async def test_get_user_unauthenticated( + client: AsyncClient, + test_user: User +): + """ + Get user without auth returns 401 + """ + response = await client.get(url_user_by_id(str(test_user.id))) + + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_user_not_found( + client: AsyncClient, + auth_headers: dict[str, + str], +): + """ + Get non existent user returns 404 + """ + fake_id = "00000000-0000-0000-0000-000000000000" + response = await client.get( + url_user_by_id(fake_id), + headers = auth_headers, + ) + + assert response.status_code == 404 + + +@pytest.mark.asyncio +async def test_update_current_user( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, + str], +): + """ + Update current user profile + """ + response = await client.patch( + URL_USER_ME, + headers = auth_headers, + json = {"full_name": "Updated Name"}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["full_name"] == "Updated Name" + + +@pytest.mark.asyncio +async def test_update_user_clear_field( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, + str], +): + """ + Setting field to null clears it + """ + response = await client.patch( + URL_USER_ME, + headers = auth_headers, + json = {"full_name": None}, + ) + + assert response.status_code == 200 + data = response.json() + assert data["full_name"] is None + + +@pytest.mark.asyncio +async def test_list_users_admin_only( + client: AsyncClient, + test_user: User, + auth_headers: dict[str, + str], +): + """ + Non admin cannot list users (403). + """ + response = await client.get( + URL_ADMIN_USERS, + headers = auth_headers, + ) + + assert response.status_code == 403 + + +@pytest.mark.asyncio +async def test_list_users_as_admin( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, + str], +): + """ + Admin can list users with pagination + """ + response = await client.get( + URL_ADMIN_USERS, + headers = admin_auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert "items" in data + assert "total" in data + assert "page" in data + assert "size" in data + assert isinstance(data["items"], list) + + +@pytest.mark.asyncio +async def test_list_users_pagination( + client: AsyncClient, + admin_user: User, + admin_auth_headers: dict[str, + str], +): + """ + Pagination params work correctly + """ + response = await client.get( + URL_ADMIN_USERS, + headers = admin_auth_headers, + params = { + "page": 1, + "size": 5 + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["page"] == 1 + assert data["size"] == 5 diff --git a/PROJECTS/bug-bounty-platform/backend/tests/unit/__init__.py b/PROJECTS/bug-bounty-platform/backend/tests/unit/__init__.py new file mode 100644 index 00000000..7fc7366b --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/tests/unit/__init__.py @@ -0,0 +1,4 @@ +""" +AngelaMos | 2025 +__init__.py +""" diff --git a/PROJECTS/bug-bounty-platform/backend/uv.lock b/PROJECTS/bug-bounty-platform/backend/uv.lock new file mode 100644 index 00000000..800e0454 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/backend/uv.lock @@ -0,0 +1,2068 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version < '3.14'", +] + +[[package]] +name = "aiosqlite" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" }, +] + +[[package]] +name = "alembic" +version = "1.17.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mako" }, + { name = "sqlalchemy" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/a6/74c8cadc2882977d80ad756a13857857dbcf9bd405bc80b662eb10651282/alembic-1.17.2.tar.gz", hash = "sha256:bbe9751705c5e0f14877f02d46c53d10885e377e3d90eda810a016f9baa19e8e", size = 1988064, upload-time = "2025-11-14T20:35:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/88/6237e97e3385b57b5f1528647addea5cc03d4d65d5979ab24327d41fb00d/alembic-1.17.2-py3-none-any.whl", hash = "sha256:f483dd1fe93f6c5d49217055e4d15b905b425b6af906746abb35b69c1996c4e6", size = 248554, upload-time = "2025-11-14T20:35:05.699Z" }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706, upload-time = "2025-06-03T06:55:32.073Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657, upload-time = "2025-06-03T06:55:30.804Z" }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441, upload-time = "2025-07-30T10:02:05.147Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393, upload-time = "2025-07-30T10:01:40.97Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328, upload-time = "2025-07-30T10:01:41.916Z" }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269, upload-time = "2025-07-30T10:01:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558, upload-time = "2025-07-30T10:01:43.943Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364, upload-time = "2025-07-30T10:01:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637, upload-time = "2025-07-30T10:01:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934, upload-time = "2025-07-30T10:01:47.203Z" }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158, upload-time = "2025-07-30T10:01:48.341Z" }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597, upload-time = "2025-07-30T10:01:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231, upload-time = "2025-07-30T10:01:49.92Z" }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121, upload-time = "2025-07-30T10:01:50.815Z" }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177, upload-time = "2025-07-30T10:01:51.681Z" }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090, upload-time = "2025-07-30T10:01:53.184Z" }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246, upload-time = "2025-07-30T10:01:54.145Z" }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126, upload-time = "2025-07-30T10:01:55.074Z" }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343, upload-time = "2025-07-30T10:01:56.007Z" }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777, upload-time = "2025-07-30T10:01:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180, upload-time = "2025-07-30T10:01:57.759Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715, upload-time = "2025-07-30T10:01:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149, upload-time = "2025-07-30T10:01:59.329Z" }, +] + +[[package]] +name = "asgi-lifespan" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sniffio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6a/da/e7908b54e0f8043725a990bf625f2041ecf6bfe8eb7b19407f1c00b630f7/asgi-lifespan-2.1.0.tar.gz", hash = "sha256:5e2effaf0bfe39829cf2d64e7ecc47c7d86d676a6599f7afba378c31f5e3a308", size = 15627, upload-time = "2023-03-28T17:35:49.126Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2f/f5/c36551e93acba41a59939ae6a0fb77ddb3f2e8e8caa716410c65f7341f72/asgi_lifespan-2.1.0-py3-none-any.whl", hash = "sha256:ed840706680e28428c01e14afb3875d7d76d3206f3d5b2f2294e059b5c23804f", size = 10895, upload-time = "2023-03-28T17:35:47.772Z" }, +] + +[[package]] +name = "astroid" +version = "4.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/ca/c17d0f83016532a1ad87d1de96837164c99d47a3b6bbba28bd597c25b37a/astroid-4.0.3.tar.gz", hash = "sha256:08d1de40d251cc3dc4a7a12726721d475ac189e4e583d596ece7422bc176bda3", size = 406224, upload-time = "2026-01-03T22:14:26.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/66/686ac4fc6ef48f5bacde625adac698f41d5316a9753c2b20bb0931c9d4e2/astroid-4.0.3-py3-none-any.whl", hash = "sha256:864a0a34af1bd70e1049ba1e61cee843a7252c826d97825fcee9b2fcbd9e1b14", size = 276443, upload-time = "2026-01-03T22:14:24.412Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.31.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" }, + { url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" }, + { url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" }, + { url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" }, + { url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" }, + { url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" }, + { url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" }, + { url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" }, + { url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" }, + { url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" }, + { url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" }, + { url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" }, + { url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" }, + { url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" }, + { url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" }, + { url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" }, + { url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" }, + { url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" }, + { url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" }, + { url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" }, + { url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" }, + { url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" }, + { url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" }, +] + +[[package]] +name = "bug-bounty-platform" +version = "1.0.0" +source = { editable = "." } +dependencies = [ + { name = "alembic" }, + { name = "asyncpg" }, + { name = "fastapi", extra = ["standard"] }, + { name = "gunicorn" }, + { name = "psycopg2-binary" }, + { name = "pwdlib", extra = ["argon2"] }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt" }, + { name = "python-multipart" }, + { name = "redis" }, + { name = "slowapi" }, + { name = "sqlalchemy" }, + { name = "structlog" }, + { name = "uuid6" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "aiosqlite" }, + { name = "asgi-lifespan" }, + { name = "httpx" }, + { name = "mypy" }, + { name = "pre-commit" }, + { name = "pylint" }, + { name = "pylint-per-file-ignores" }, + { name = "pylint-pydantic" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, + { name = "ty" }, + { name = "types-redis" }, +] + +[package.metadata] +requires-dist = [ + { name = "aiosqlite", marker = "extra == 'dev'", specifier = ">=0.21.0" }, + { name = "alembic", specifier = ">=1.17.0,<2.0.0" }, + { name = "asgi-lifespan", marker = "extra == 'dev'", specifier = ">=2.1.0" }, + { name = "asyncpg", specifier = ">=0.31.0,<1.0.0" }, + { name = "fastapi", extras = ["standard"], specifier = ">=0.123.0,<1.0.0" }, + { name = "gunicorn", specifier = ">=23.0.0" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.28.1" }, + { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.19.0" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.2.0" }, + { name = "psycopg2-binary", specifier = ">=2.9.11" }, + { name = "pwdlib", extras = ["argon2"], specifier = ">=0.3.0" }, + { name = "pydantic", specifier = ">=2.12.5,<3.0.0" }, + { name = "pydantic-settings", specifier = ">=2.12.0,<3.0.0" }, + { name = "pyjwt", specifier = ">=2.10.0" }, + { name = "pylint", marker = "extra == 'dev'", specifier = ">=4.0.4" }, + { name = "pylint-per-file-ignores", marker = "extra == 'dev'", specifier = ">=3.2.0" }, + { name = "pylint-pydantic", marker = "extra == 'dev'", specifier = ">=0.4.1" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.2" }, + { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.3.0" }, + { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=6.0.0" }, + { name = "python-multipart", specifier = ">=0.0.20" }, + { name = "redis", specifier = ">=7.1.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.8" }, + { name = "slowapi", specifier = ">=0.1.9" }, + { name = "sqlalchemy", specifier = ">=2.0.44,<3.0.0" }, + { name = "structlog", specifier = ">=24.4.0" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.1a32" }, + { name = "types-redis", marker = "extra == 'dev'", specifier = ">=4.6.0" }, + { name = "uuid6", specifier = ">=2025.0.1" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.38.0" }, +] +provides-extras = ["dev"] + +[[package]] +name = "certifi" +version = "2026.1.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/2d/a891ca51311197f6ad14a7ef42e2399f36cf2f9bd44752b3dc4eab60fdc5/certifi-2026.1.4.tar.gz", hash = "sha256:ac726dd470482006e014ad384921ed6438c457018f4b3d204aea4281258b2120", size = 154268, upload-time = "2026-01-04T02:42:41.825Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, +] + +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + +[[package]] +name = "cfgv" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" }, +] + +[[package]] +name = "click" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "coverage" +version = "7.13.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/23/f9/e92df5e07f3fc8d4c7f9a0f146ef75446bf870351cd37b788cf5897f8079/coverage-7.13.1.tar.gz", hash = "sha256:b7593fe7eb5feaa3fbb461ac79aac9f9fc0387a5ca8080b0c6fe2ca27b091afd", size = 825862, upload-time = "2025-12-28T15:42:56.969Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8a/87af46cccdfa78f53db747b09f5f9a21d5fc38d796834adac09b30a8ce74/coverage-7.13.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6f34591000f06e62085b1865c9bc5f7858df748834662a51edadfd2c3bfe0dd3", size = 218927, upload-time = "2025-12-28T15:40:52.814Z" }, + { url = "https://files.pythonhosted.org/packages/82/a8/6e22fdc67242a4a5a153f9438d05944553121c8f4ba70cb072af4c41362e/coverage-7.13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b67e47c5595b9224599016e333f5ec25392597a89d5744658f837d204e16c63e", size = 219288, upload-time = "2025-12-28T15:40:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0a/853a76e03b0f7c4375e2ca025df45c918beb367f3e20a0a8e91967f6e96c/coverage-7.13.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3e7b8bd70c48ffb28461ebe092c2345536fb18bbbf19d287c8913699735f505c", size = 250786, upload-time = "2025-12-28T15:40:56.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/694159c15c52b9f7ec7adf49d50e5f8ee71d3e9ef38adb4445d13dd56c20/coverage-7.13.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c223d078112e90dc0e5c4e35b98b9584164bea9fbbd221c0b21c5241f6d51b62", size = 253543, upload-time = "2025-12-28T15:40:57.585Z" }, + { url = "https://files.pythonhosted.org/packages/96/b2/7f1f0437a5c855f87e17cf5d0dc35920b6440ff2b58b1ba9788c059c26c8/coverage-7.13.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:794f7c05af0763b1bbd1b9e6eff0e52ad068be3b12cd96c87de037b01390c968", size = 254635, upload-time = "2025-12-28T15:40:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d1/73c3fdb8d7d3bddd9473c9c6a2e0682f09fc3dfbcb9c3f36412a7368bcab/coverage-7.13.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0642eae483cc8c2902e4af7298bf886d605e80f26382124cddc3967c2a3df09e", size = 251202, upload-time = "2025-12-28T15:41:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/66/3c/f0edf75dcc152f145d5598329e864bbbe04ab78660fe3e8e395f9fff010f/coverage-7.13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f5e772ed5fef25b3de9f2008fe67b92d46831bd2bc5bdc5dd6bfd06b83b316f", size = 252566, upload-time = "2025-12-28T15:41:03.319Z" }, + { url = "https://files.pythonhosted.org/packages/17/b3/e64206d3c5f7dcbceafd14941345a754d3dbc78a823a6ed526e23b9cdaab/coverage-7.13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:45980ea19277dc0a579e432aef6a504fe098ef3a9032ead15e446eb0f1191aee", size = 250711, upload-time = "2025-12-28T15:41:06.411Z" }, + { url = "https://files.pythonhosted.org/packages/dc/ad/28a3eb970a8ef5b479ee7f0c484a19c34e277479a5b70269dc652b730733/coverage-7.13.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f18eca6028ffa62adbd185a8f1e1dd242f2e68164dba5c2b74a5204850b4cf", size = 250278, upload-time = "2025-12-28T15:41:08.285Z" }, + { url = "https://files.pythonhosted.org/packages/54/e3/c8f0f1a93133e3e1291ca76cbb63565bd4b5c5df63b141f539d747fff348/coverage-7.13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8dca5590fec7a89ed6826fce625595279e586ead52e9e958d3237821fbc750c", size = 252154, upload-time = "2025-12-28T15:41:09.969Z" }, + { url = "https://files.pythonhosted.org/packages/d0/bf/9939c5d6859c380e405b19e736321f1c7d402728792f4c752ad1adcce005/coverage-7.13.1-cp312-cp312-win32.whl", hash = "sha256:ff86d4e85188bba72cfb876df3e11fa243439882c55957184af44a35bd5880b7", size = 221487, upload-time = "2025-12-28T15:41:11.468Z" }, + { url = "https://files.pythonhosted.org/packages/fa/dc/7282856a407c621c2aad74021680a01b23010bb8ebf427cf5eacda2e876f/coverage-7.13.1-cp312-cp312-win_amd64.whl", hash = "sha256:16cc1da46c04fb0fb128b4dc430b78fa2aba8a6c0c9f8eb391fd5103409a6ac6", size = 222299, upload-time = "2025-12-28T15:41:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/10/79/176a11203412c350b3e9578620013af35bcdb79b651eb976f4a4b32044fa/coverage-7.13.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d9bc218650022a768f3775dd7fdac1886437325d8d295d923ebcfef4892ad5c", size = 220941, upload-time = "2025-12-28T15:41:14.975Z" }, + { url = "https://files.pythonhosted.org/packages/a3/a4/e98e689347a1ff1a7f67932ab535cef82eb5e78f32a9e4132e114bbb3a0a/coverage-7.13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cb237bfd0ef4d5eb6a19e29f9e528ac67ac3be932ea6b44fb6cc09b9f3ecff78", size = 218951, upload-time = "2025-12-28T15:41:16.653Z" }, + { url = "https://files.pythonhosted.org/packages/32/33/7cbfe2bdc6e2f03d6b240d23dc45fdaf3fd270aaf2d640be77b7f16989ab/coverage-7.13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1dcb645d7e34dcbcc96cd7c132b1fc55c39263ca62eb961c064eb3928997363b", size = 219325, upload-time = "2025-12-28T15:41:18.609Z" }, + { url = "https://files.pythonhosted.org/packages/59/f6/efdabdb4929487baeb7cb2a9f7dac457d9356f6ad1b255be283d58b16316/coverage-7.13.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3d42df8201e00384736f0df9be2ced39324c3907607d17d50d50116c989d84cd", size = 250309, upload-time = "2025-12-28T15:41:20.629Z" }, + { url = "https://files.pythonhosted.org/packages/12/da/91a52516e9d5aea87d32d1523f9cdcf7a35a3b298e6be05d6509ba3cfab2/coverage-7.13.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fa3edde1aa8807de1d05934982416cb3ec46d1d4d91e280bcce7cca01c507992", size = 252907, upload-time = "2025-12-28T15:41:22.257Z" }, + { url = "https://files.pythonhosted.org/packages/75/38/f1ea837e3dc1231e086db1638947e00d264e7e8c41aa8ecacf6e1e0c05f4/coverage-7.13.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9edd0e01a343766add6817bc448408858ba6b489039eaaa2018474e4001651a4", size = 254148, upload-time = "2025-12-28T15:41:23.87Z" }, + { url = "https://files.pythonhosted.org/packages/7f/43/f4f16b881aaa34954ba446318dea6b9ed5405dd725dd8daac2358eda869a/coverage-7.13.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:985b7836931d033570b94c94713c6dba5f9d3ff26045f72c3e5dbc5fe3361e5a", size = 250515, upload-time = "2025-12-28T15:41:25.437Z" }, + { url = "https://files.pythonhosted.org/packages/84/34/8cba7f00078bd468ea914134e0144263194ce849ec3baad187ffb6203d1c/coverage-7.13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffed1e4980889765c84a5d1a566159e363b71d6b6fbaf0bebc9d3c30bc016766", size = 252292, upload-time = "2025-12-28T15:41:28.459Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a4/cffac66c7652d84ee4ac52d3ccb94c015687d3b513f9db04bfcac2ac800d/coverage-7.13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8842af7f175078456b8b17f1b73a0d16a65dcbdc653ecefeb00a56b3c8c298c4", size = 250242, upload-time = "2025-12-28T15:41:30.02Z" }, + { url = "https://files.pythonhosted.org/packages/f4/78/9a64d462263dde416f3c0067efade7b52b52796f489b1037a95b0dc389c9/coverage-7.13.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ccd7a6fca48ca9c131d9b0a2972a581e28b13416fc313fb98b6d24a03ce9a398", size = 250068, upload-time = "2025-12-28T15:41:32.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/c8/a8994f5fece06db7c4a97c8fc1973684e178599b42e66280dded0524ef00/coverage-7.13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:0403f647055de2609be776965108447deb8e384fe4a553c119e3ff6bfbab4784", size = 251846, upload-time = "2025-12-28T15:41:33.946Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f7/91fa73c4b80305c86598a2d4e54ba22df6bf7d0d97500944af7ef155d9f7/coverage-7.13.1-cp313-cp313-win32.whl", hash = "sha256:549d195116a1ba1e1ae2f5ca143f9777800f6636eab917d4f02b5310d6d73461", size = 221512, upload-time = "2025-12-28T15:41:35.519Z" }, + { url = "https://files.pythonhosted.org/packages/45/0b/0768b4231d5a044da8f75e097a8714ae1041246bb765d6b5563bab456735/coverage-7.13.1-cp313-cp313-win_amd64.whl", hash = "sha256:5899d28b5276f536fcf840b18b61a9fce23cc3aec1d114c44c07fe94ebeaa500", size = 222321, upload-time = "2025-12-28T15:41:37.371Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b8/bdcb7253b7e85157282450262008f1366aa04663f3e3e4c30436f596c3e2/coverage-7.13.1-cp313-cp313-win_arm64.whl", hash = "sha256:868a2fae76dfb06e87291bcbd4dcbcc778a8500510b618d50496e520bd94d9b9", size = 220949, upload-time = "2025-12-28T15:41:39.553Z" }, + { url = "https://files.pythonhosted.org/packages/70/52/f2be52cc445ff75ea8397948c96c1b4ee14f7f9086ea62fc929c5ae7b717/coverage-7.13.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:67170979de0dacac3f3097d02b0ad188d8edcea44ccc44aaa0550af49150c7dc", size = 219643, upload-time = "2025-12-28T15:41:41.567Z" }, + { url = "https://files.pythonhosted.org/packages/47/79/c85e378eaa239e2edec0c5523f71542c7793fe3340954eafb0bc3904d32d/coverage-7.13.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f80e2bb21bfab56ed7405c2d79d34b5dc0bc96c2c1d2a067b643a09fb756c43a", size = 219997, upload-time = "2025-12-28T15:41:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/fe/9b/b1ade8bfb653c0bbce2d6d6e90cc6c254cbb99b7248531cc76253cb4da6d/coverage-7.13.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f83351e0f7dcdb14d7326c3d8d8c4e915fa685cbfdc6281f9470d97a04e9dfe4", size = 261296, upload-time = "2025-12-28T15:41:45.207Z" }, + { url = "https://files.pythonhosted.org/packages/1f/af/ebf91e3e1a2473d523e87e87fd8581e0aa08741b96265730e2d79ce78d8d/coverage-7.13.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bb3f6562e89bad0110afbe64e485aac2462efdce6232cdec7862a095dc3412f6", size = 263363, upload-time = "2025-12-28T15:41:47.163Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8b/fb2423526d446596624ac7fde12ea4262e66f86f5120114c3cfd0bb2befa/coverage-7.13.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77545b5dcda13b70f872c3b5974ac64c21d05e65b1590b441c8560115dc3a0d1", size = 265783, upload-time = "2025-12-28T15:41:49.03Z" }, + { url = "https://files.pythonhosted.org/packages/9b/26/ef2adb1e22674913b89f0fe7490ecadcef4a71fa96f5ced90c60ec358789/coverage-7.13.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4d240d260a1aed814790bbe1f10a5ff31ce6c21bc78f0da4a1e8268d6c80dbd", size = 260508, upload-time = "2025-12-28T15:41:51.035Z" }, + { url = "https://files.pythonhosted.org/packages/ce/7d/f0f59b3404caf662e7b5346247883887687c074ce67ba453ea08c612b1d5/coverage-7.13.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d2287ac9360dec3837bfdad969963a5d073a09a85d898bd86bea82aa8876ef3c", size = 263357, upload-time = "2025-12-28T15:41:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b1/29896492b0b1a047604d35d6fa804f12818fa30cdad660763a5f3159e158/coverage-7.13.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:0d2c11f3ea4db66b5cbded23b20185c35066892c67d80ec4be4bab257b9ad1e0", size = 260978, upload-time = "2025-12-28T15:41:54.589Z" }, + { url = "https://files.pythonhosted.org/packages/48/f2/971de1238a62e6f0a4128d37adadc8bb882ee96afbe03ff1570291754629/coverage-7.13.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:3fc6a169517ca0d7ca6846c3c5392ef2b9e38896f61d615cb75b9e7134d4ee1e", size = 259877, upload-time = "2025-12-28T15:41:56.263Z" }, + { url = "https://files.pythonhosted.org/packages/6a/fc/0474efcbb590ff8628830e9aaec5f1831594874360e3251f1fdec31d07a3/coverage-7.13.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d10a2ed46386e850bb3de503a54f9fe8192e5917fcbb143bfef653a9355e9a53", size = 262069, upload-time = "2025-12-28T15:41:58.093Z" }, + { url = "https://files.pythonhosted.org/packages/88/4f/3c159b7953db37a7b44c0eab8a95c37d1aa4257c47b4602c04022d5cb975/coverage-7.13.1-cp313-cp313t-win32.whl", hash = "sha256:75a6f4aa904301dab8022397a22c0039edc1f51e90b83dbd4464b8a38dc87842", size = 222184, upload-time = "2025-12-28T15:41:59.763Z" }, + { url = "https://files.pythonhosted.org/packages/58/a5/6b57d28f81417f9335774f20679d9d13b9a8fb90cd6160957aa3b54a2379/coverage-7.13.1-cp313-cp313t-win_amd64.whl", hash = "sha256:309ef5706e95e62578cda256b97f5e097916a2c26247c287bbe74794e7150df2", size = 223250, upload-time = "2025-12-28T15:42:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/81/7c/160796f3b035acfbb58be80e02e484548595aa67e16a6345e7910ace0a38/coverage-7.13.1-cp313-cp313t-win_arm64.whl", hash = "sha256:92f980729e79b5d16d221038dbf2e8f9a9136afa072f9d5d6ed4cb984b126a09", size = 221521, upload-time = "2025-12-28T15:42:03.275Z" }, + { url = "https://files.pythonhosted.org/packages/aa/8e/ba0e597560c6563fc0adb902fda6526df5d4aa73bb10adf0574d03bd2206/coverage-7.13.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:97ab3647280d458a1f9adb85244e81587505a43c0c7cff851f5116cd2814b894", size = 218996, upload-time = "2025-12-28T15:42:04.978Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8e/764c6e116f4221dc7aa26c4061181ff92edb9c799adae6433d18eeba7a14/coverage-7.13.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8f572d989142e0908e6acf57ad1b9b86989ff057c006d13b76c146ec6a20216a", size = 219326, upload-time = "2025-12-28T15:42:06.691Z" }, + { url = "https://files.pythonhosted.org/packages/4f/a6/6130dc6d8da28cdcbb0f2bf8865aeca9b157622f7c0031e48c6cf9a0e591/coverage-7.13.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d72140ccf8a147e94274024ff6fd8fb7811354cf7ef88b1f0a988ebaa5bc774f", size = 250374, upload-time = "2025-12-28T15:42:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/82/2b/783ded568f7cd6b677762f780ad338bf4b4750205860c17c25f7c708995e/coverage-7.13.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3c9f051b028810f5a87c88e5d6e9af3c0ff32ef62763bf15d29f740453ca909", size = 252882, upload-time = "2025-12-28T15:42:10.515Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b2/9808766d082e6a4d59eb0cc881a57fc1600eb2c5882813eefff8254f71b5/coverage-7.13.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f398ba4df52d30b1763f62eed9de5620dcde96e6f491f4c62686736b155aa6e4", size = 254218, upload-time = "2025-12-28T15:42:12.208Z" }, + { url = "https://files.pythonhosted.org/packages/44/ea/52a985bb447c871cb4d2e376e401116520991b597c85afdde1ea9ef54f2c/coverage-7.13.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:132718176cc723026d201e347f800cd1a9e4b62ccd3f82476950834dad501c75", size = 250391, upload-time = "2025-12-28T15:42:14.21Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/125b36cc12310718873cfc8209ecfbc1008f14f4f5fa0662aa608e579353/coverage-7.13.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e549d642426e3579b3f4b92d0431543b012dcb6e825c91619d4e93b7363c3f9", size = 252239, upload-time = "2025-12-28T15:42:16.292Z" }, + { url = "https://files.pythonhosted.org/packages/6a/16/10c1c164950cade470107f9f14bbac8485f8fb8515f515fca53d337e4a7f/coverage-7.13.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:90480b2134999301eea795b3a9dbf606c6fbab1b489150c501da84a959442465", size = 250196, upload-time = "2025-12-28T15:42:18.54Z" }, + { url = "https://files.pythonhosted.org/packages/2a/c6/cd860fac08780c6fd659732f6ced1b40b79c35977c1356344e44d72ba6c4/coverage-7.13.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e825dbb7f84dfa24663dd75835e7257f8882629fc11f03ecf77d84a75134b864", size = 250008, upload-time = "2025-12-28T15:42:20.365Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/a8c58d3d38f82a5711e1e0a67268362af48e1a03df27c03072ac30feefcf/coverage-7.13.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:623dcc6d7a7ba450bbdbeedbaa0c42b329bdae16491af2282f12a7e809be7eb9", size = 251671, upload-time = "2025-12-28T15:42:22.114Z" }, + { url = "https://files.pythonhosted.org/packages/f0/bc/fd4c1da651d037a1e3d53e8cb3f8182f4b53271ffa9a95a2e211bacc0349/coverage-7.13.1-cp314-cp314-win32.whl", hash = "sha256:6e73ebb44dca5f708dc871fe0b90cf4cff1a13f9956f747cc87b535a840386f5", size = 221777, upload-time = "2025-12-28T15:42:23.919Z" }, + { url = "https://files.pythonhosted.org/packages/4b/50/71acabdc8948464c17e90b5ffd92358579bd0910732c2a1c9537d7536aa6/coverage-7.13.1-cp314-cp314-win_amd64.whl", hash = "sha256:be753b225d159feb397bd0bf91ae86f689bad0da09d3b301478cd39b878ab31a", size = 222592, upload-time = "2025-12-28T15:42:25.619Z" }, + { url = "https://files.pythonhosted.org/packages/f7/c8/a6fb943081bb0cc926499c7907731a6dc9efc2cbdc76d738c0ab752f1a32/coverage-7.13.1-cp314-cp314-win_arm64.whl", hash = "sha256:228b90f613b25ba0019361e4ab81520b343b622fc657daf7e501c4ed6a2366c0", size = 221169, upload-time = "2025-12-28T15:42:27.629Z" }, + { url = "https://files.pythonhosted.org/packages/16/61/d5b7a0a0e0e40d62e59bc8c7aa1afbd86280d82728ba97f0673b746b78e2/coverage-7.13.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:60cfb538fe9ef86e5b2ab0ca8fc8d62524777f6c611dcaf76dc16fbe9b8e698a", size = 219730, upload-time = "2025-12-28T15:42:29.306Z" }, + { url = "https://files.pythonhosted.org/packages/a3/2c/8881326445fd071bb49514d1ce97d18a46a980712b51fee84f9ab42845b4/coverage-7.13.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:57dfc8048c72ba48a8c45e188d811e5efd7e49b387effc8fb17e97936dde5bf6", size = 220001, upload-time = "2025-12-28T15:42:31.319Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d7/50de63af51dfa3a7f91cc37ad8fcc1e244b734232fbc8b9ab0f3c834a5cd/coverage-7.13.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3f2f725aa3e909b3c5fdb8192490bdd8e1495e85906af74fe6e34a2a77ba0673", size = 261370, upload-time = "2025-12-28T15:42:32.992Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/d31722f0ec918fd7453b2758312729f645978d212b410cd0f7c2aed88a94/coverage-7.13.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ee68b21909686eeb21dfcba2c3b81fee70dcf38b140dcd5aa70680995fa3aa5", size = 263485, upload-time = "2025-12-28T15:42:34.759Z" }, + { url = "https://files.pythonhosted.org/packages/fa/7a/2c114fa5c5fc08ba0777e4aec4c97e0b4a1afcb69c75f1f54cff78b073ab/coverage-7.13.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:724b1b270cb13ea2e6503476e34541a0b1f62280bc997eab443f87790202033d", size = 265890, upload-time = "2025-12-28T15:42:36.517Z" }, + { url = "https://files.pythonhosted.org/packages/65/d9/f0794aa1c74ceabc780fe17f6c338456bbc4e96bd950f2e969f48ac6fb20/coverage-7.13.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:916abf1ac5cf7eb16bc540a5bf75c71c43a676f5c52fcb9fe75a2bd75fb944e8", size = 260445, upload-time = "2025-12-28T15:42:38.646Z" }, + { url = "https://files.pythonhosted.org/packages/49/23/184b22a00d9bb97488863ced9454068c79e413cb23f472da6cbddc6cfc52/coverage-7.13.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:776483fd35b58d8afe3acbd9988d5de592ab6da2d2a865edfdbc9fdb43e7c486", size = 263357, upload-time = "2025-12-28T15:42:40.788Z" }, + { url = "https://files.pythonhosted.org/packages/7d/bd/58af54c0c9199ea4190284f389005779d7daf7bf3ce40dcd2d2b2f96da69/coverage-7.13.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b6f3b96617e9852703f5b633ea01315ca45c77e879584f283c44127f0f1ec564", size = 260959, upload-time = "2025-12-28T15:42:42.808Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2a/6839294e8f78a4891bf1df79d69c536880ba2f970d0ff09e7513d6e352e9/coverage-7.13.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd63e7b74661fed317212fab774e2a648bc4bb09b35f25474f8e3325d2945cd7", size = 259792, upload-time = "2025-12-28T15:42:44.818Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c3/528674d4623283310ad676c5af7414b9850ab6d55c2300e8aa4b945ec554/coverage-7.13.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:933082f161bbb3e9f90d00990dc956120f608cdbcaeea15c4d897f56ef4fe416", size = 262123, upload-time = "2025-12-28T15:42:47.108Z" }, + { url = "https://files.pythonhosted.org/packages/06/c5/8c0515692fb4c73ac379d8dc09b18eaf0214ecb76ea6e62467ba7a1556ff/coverage-7.13.1-cp314-cp314t-win32.whl", hash = "sha256:18be793c4c87de2965e1c0f060f03d9e5aff66cfeae8e1dbe6e5b88056ec153f", size = 222562, upload-time = "2025-12-28T15:42:49.144Z" }, + { url = "https://files.pythonhosted.org/packages/05/0e/c0a0c4678cb30dac735811db529b321d7e1c9120b79bd728d4f4d6b010e9/coverage-7.13.1-cp314-cp314t-win_amd64.whl", hash = "sha256:0e42e0ec0cd3e0d851cb3c91f770c9301f48647cb2877cb78f74bdaa07639a79", size = 223670, upload-time = "2025-12-28T15:42:51.218Z" }, + { url = "https://files.pythonhosted.org/packages/f5/5f/b177aa0011f354abf03a8f30a85032686d290fdeed4222b27d36b4372a50/coverage-7.13.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eaecf47ef10c72ece9a2a92118257da87e460e113b83cc0d2905cbbe931792b4", size = 221707, upload-time = "2025-12-28T15:42:53.034Z" }, + { url = "https://files.pythonhosted.org/packages/cc/48/d9f421cb8da5afaa1a64570d9989e00fb7955e6acddc5a12979f7666ef60/coverage-7.13.1-py3-none-any.whl", hash = "sha256:2016745cb3ba554469d02819d78958b571792bb68e31302610e898f80dd3a573", size = 210722, upload-time = "2025-12-28T15:42:54.901Z" }, +] + +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + +[[package]] +name = "dill" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976, upload-time = "2025-04-16T00:41:48.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668, upload-time = "2025-04-16T00:41:47.671Z" }, +] + +[[package]] +name = "distlib" +version = "0.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "fastapi" +version = "0.128.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/08/8c8508db6c7b9aae8f7175046af41baad690771c9bcde676419965e338c7/fastapi-0.128.0.tar.gz", hash = "sha256:1cc179e1cef10a6be60ffe429f79b829dce99d8de32d7acb7e6c8dfdf7f2645a", size = 365682, upload-time = "2025-12-27T15:21:13.714Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "email-validator" }, + { name = "fastapi-cli", extra = ["standard"] }, + { name = "httpx" }, + { name = "jinja2" }, + { name = "pydantic-extra-types" }, + { name = "pydantic-settings" }, + { name = "python-multipart" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cli" +version = "0.0.20" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "rich-toolkit" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/ca/d90fb3bfbcbd6e56c77afd9d114dd6ce8955d8bb90094399d1c70e659e40/fastapi_cli-0.0.20.tar.gz", hash = "sha256:d17c2634f7b96b6b560bc16b0035ed047d523c912011395f49f00a421692bc3a", size = 19786, upload-time = "2025-12-22T17:13:33.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/89/5c4eef60524d0fd704eb0706885b82cd5623a43396b94e4a5b17d3a3f516/fastapi_cli-0.0.20-py3-none-any.whl", hash = "sha256:e58b6a0038c0b1532b7a0af690656093dee666201b6b19d3c87175b358e9f783", size = 12390, upload-time = "2025-12-22T17:13:31.708Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "fastapi-cloud-cli" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[[package]] +name = "fastapi-cloud-cli" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastar" }, + { name = "httpx" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich-toolkit" }, + { name = "rignore" }, + { name = "sentry-sdk" }, + { name = "typer" }, + { name = "uvicorn", extra = ["standard"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/51/5d/3b33438de35521fab4968b232caa9a4bd568a5078f2b2dfb7bb8a4528603/fastapi_cloud_cli-0.8.0.tar.gz", hash = "sha256:cf07c502528bfd9e6b184776659f05d9212811d76bbec9fbb6bf34bed4c7456f", size = 30257, upload-time = "2025-12-23T12:08:33.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/8e/abb95ef59e91bb5adaa2d18fbf9ea70fd524010bb03f406a2dd2a4775ef9/fastapi_cloud_cli-0.8.0-py3-none-any.whl", hash = "sha256:e9f40bee671d985fd25d7a5409b56d4f103777bf8a0c6d746ea5fbf97a8186d9", size = 22306, upload-time = "2025-12-23T12:08:32.68Z" }, +] + +[[package]] +name = "fastar" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/e7/f89d54fb04104114dd0552836dc2b47914f416cc0e200b409dd04a33de5e/fastar-0.8.0.tar.gz", hash = "sha256:f4d4d68dbf1c4c2808f0e730fac5843493fc849f70fe3ad3af60dfbaf68b9a12", size = 68524, upload-time = "2025-11-26T02:36:00.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/f1/5b2ff898abac7f1a418284aad285e3a4f68d189c572ab2db0f6c9079dd16/fastar-0.8.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:0f10d2adfe40f47ff228f4efaa32d409d732ded98580e03ed37c9535b5fc923d", size = 706369, upload-time = "2025-11-26T02:34:37.783Z" }, + { url = "https://files.pythonhosted.org/packages/23/60/8046a386dca39154f80c927cbbeeb4b1c1267a3271bffe61552eb9995757/fastar-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b930da9d598e3bc69513d131f397e6d6be4643926ef3de5d33d1e826631eb036", size = 629097, upload-time = "2025-11-26T02:34:21.888Z" }, + { url = "https://files.pythonhosted.org/packages/22/7e/1ae005addc789924a9268da2394d3bb5c6f96836f7e37b7e3d23c2362675/fastar-0.8.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:9d210da2de733ca801de83e931012349d209f38b92d9630ccaa94bd445bdc9b8", size = 868938, upload-time = "2025-11-26T02:33:51.119Z" }, + { url = "https://files.pythonhosted.org/packages/a6/77/290a892b073b84bf82e6b2259708dfe79c54f356e252c2dd40180b16fe07/fastar-0.8.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aa02270721517078a5bd61a38719070ac2537a4aa6b6c48cf369cf2abc59174a", size = 765204, upload-time = "2025-11-26T02:32:47.02Z" }, + { url = "https://files.pythonhosted.org/packages/d0/00/c3155171b976003af3281f5258189f1935b15d1221bfc7467b478c631216/fastar-0.8.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:83c391e5b789a720e4d0029b9559f5d6dee3226693c5b39c0eab8eaece997e0f", size = 764717, upload-time = "2025-11-26T02:33:02.453Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/405b7ad76207b2c11b7b59335b70eac19e4a2653977f5588a1ac8fed54f4/fastar-0.8.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3258d7a78a72793cdd081545da61cabe85b1f37634a1d0b97ffee0ff11d105ef", size = 931502, upload-time = "2025-11-26T02:33:18.619Z" }, + { url = "https://files.pythonhosted.org/packages/da/8a/a3dde6d37cc3da4453f2845cdf16675b5686b73b164f37e2cc579b057c2c/fastar-0.8.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6eab95dd985cdb6a50666cbeb9e4814676e59cfe52039c880b69d67cfd44767", size = 821454, upload-time = "2025-11-26T02:33:33.427Z" }, + { url = "https://files.pythonhosted.org/packages/da/c1/904fe2468609c8990dce9fe654df3fbc7324a8d8e80d8240ae2c89757064/fastar-0.8.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:829b1854166141860887273c116c94e31357213fa8e9fe8baeb18bd6c38aa8d9", size = 821647, upload-time = "2025-11-26T02:34:07Z" }, + { url = "https://files.pythonhosted.org/packages/c8/73/a0642ab7a400bc07528091785e868ace598fde06fcd139b8f865ec1b6f3c/fastar-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b1667eae13f9457a3c737f4376d68e8c3e548353538b28f7e4273a30cb3965cd", size = 986342, upload-time = "2025-11-26T02:34:53.371Z" }, + { url = "https://files.pythonhosted.org/packages/af/af/60c1bfa6edab72366461a95f053d0f5f7ab1825fe65ca2ca367432cd8629/fastar-0.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b864a95229a7db0814cd9ef7987cb713fd43dce1b0d809dd17d9cd6f02fdde3e", size = 1040207, upload-time = "2025-11-26T02:35:10.65Z" }, + { url = "https://files.pythonhosted.org/packages/f6/a0/0d624290dec622e7fa084b6881f456809f68777d54a314f5dde932714506/fastar-0.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c05fbc5618ce17675a42576fa49858d79734627f0a0c74c0875ab45ee8de340c", size = 1045031, upload-time = "2025-11-26T02:35:28.108Z" }, + { url = "https://files.pythonhosted.org/packages/a7/74/cf663af53c4706ba88e6b4af44a6b0c3bd7d7ca09f079dc40647a8f06585/fastar-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7f41c51ee96f338662ee3c3df4840511ba3f9969606840f1b10b7cb633a3c716", size = 994877, upload-time = "2025-11-26T02:35:45.797Z" }, + { url = "https://files.pythonhosted.org/packages/52/17/444c8be6e77206050e350da7c338102b6cab384be937fa0b1d6d1f9ede73/fastar-0.8.0-cp312-cp312-win32.whl", hash = "sha256:d949a1a2ea7968b734632c009df0571c94636a5e1622c87a6e2bf712a7334f47", size = 455996, upload-time = "2025-11-26T02:36:26.938Z" }, + { url = "https://files.pythonhosted.org/packages/dc/34/fc3b5e56d71a17b1904800003d9251716e8fd65f662e1b10a26881698a74/fastar-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:fc645994d5b927d769121094e8a649b09923b3c13a8b0b98696d8f853f23c532", size = 490429, upload-time = "2025-11-26T02:36:12.707Z" }, + { url = "https://files.pythonhosted.org/packages/35/a8/5608cc837417107c594e2e7be850b9365bcb05e99645966a5d6a156285fe/fastar-0.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:d81ee82e8dc78a0adb81728383bd39611177d642a8fa2d601d4ad5ad59e5f3bd", size = 461297, upload-time = "2025-11-26T02:36:03.546Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a5/79ecba3646e22d03eef1a66fb7fc156567213e2e4ab9faab3bbd4489e483/fastar-0.8.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a3253a06845462ca2196024c7a18f5c0ba4de1532ab1c4bad23a40b332a06a6a", size = 706112, upload-time = "2025-11-26T02:34:39.237Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/4f883bce878218a8676c2d7ca09b50c856a5470bb3b7f63baf9521ea6995/fastar-0.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5cbeb3ebfa0980c68ff8b126295cc6b208ccd81b638aebc5a723d810a7a0e5d2", size = 628954, upload-time = "2025-11-26T02:34:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/4f/f1/892e471f156b03d10ba48ace9384f5a896702a54506137462545f38e40b8/fastar-0.8.0-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:1c0d5956b917daac77d333d48b3f0f3ff927b8039d5b32d8125462782369f761", size = 868685, upload-time = "2025-11-26T02:33:53.077Z" }, + { url = "https://files.pythonhosted.org/packages/39/ba/e24915045852e30014ec6840446975c03f4234d1c9270394b51d3ad18394/fastar-0.8.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:27b404db2b786b65912927ce7f3790964a4bcbde42cdd13091b82a89cd655e1c", size = 765044, upload-time = "2025-11-26T02:32:48.187Z" }, + { url = "https://files.pythonhosted.org/packages/14/2c/1aa11ac21a99984864c2fca4994e094319ff3a2046e7a0343c39317bd5b9/fastar-0.8.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0902fc89dcf1e7f07b8563032a4159fe2b835e4c16942c76fd63451d0e5f76a3", size = 764322, upload-time = "2025-11-26T02:33:03.859Z" }, + { url = "https://files.pythonhosted.org/packages/ba/f0/4b91902af39fe2d3bae7c85c6d789586b9fbcf618d7fdb3d37323915906d/fastar-0.8.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:069347e2f0f7a8b99bbac8cd1bc0e06c7b4a31dc964fc60d84b95eab3d869dc1", size = 931016, upload-time = "2025-11-26T02:33:19.902Z" }, + { url = "https://files.pythonhosted.org/packages/c9/97/8fc43a5a9c0a2dc195730f6f7a0f367d171282cd8be2511d0e87c6d2dad0/fastar-0.8.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7fd135306f6bfe9a835918280e0eb440b70ab303e0187d90ab51ca86e143f70d", size = 821308, upload-time = "2025-11-26T02:33:34.664Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e9/058615b63a7fd27965e8c5966f393ed0c169f7ff5012e1674f21684de3ba/fastar-0.8.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d06d6897f43c27154b5f2d0eb930a43a81b7eec73f6f0b0114814d4a10ab38", size = 821171, upload-time = "2025-11-26T02:34:08.498Z" }, + { url = "https://files.pythonhosted.org/packages/ca/cf/69e16a17961570a755c37ffb5b5aa7610d2e77807625f537989da66f2a9d/fastar-0.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a922f8439231fa0c32b15e8d70ff6d415619b9d40492029dabbc14a0c53b5f18", size = 986227, upload-time = "2025-11-26T02:34:55.06Z" }, + { url = "https://files.pythonhosted.org/packages/fb/83/2100192372e59b56f4ace37d7d9cabda511afd71b5febad1643d1c334271/fastar-0.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:a739abd51eb766384b4caff83050888e80cd75bbcfec61e6d1e64875f94e4a40", size = 1039395, upload-time = "2025-11-26T02:35:12.166Z" }, + { url = "https://files.pythonhosted.org/packages/75/15/cdd03aca972f55872efbb7cf7540c3fa7b97a75d626303a3ea46932163dc/fastar-0.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:5a65f419d808b23ac89d5cd1b13a2f340f15bc5d1d9af79f39fdb77bba48ff1b", size = 1044766, upload-time = "2025-11-26T02:35:29.62Z" }, + { url = "https://files.pythonhosted.org/packages/3d/29/945e69e4e2652329ace545999334ec31f1431fbae3abb0105587e11af2ae/fastar-0.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7bb2ae6c0cce58f0db1c9f20495e7557cca2c1ee9c69bbd90eafd54f139171c5", size = 994740, upload-time = "2025-11-26T02:35:47.887Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5d/dbfe28f8cd1eb484bba0c62e5259b2cf6fea229d6ef43e05c06b5a78c034/fastar-0.8.0-cp313-cp313-win32.whl", hash = "sha256:b28753e0d18a643272597cb16d39f1053842aa43131ad3e260c03a2417d38401", size = 455990, upload-time = "2025-11-26T02:36:28.502Z" }, + { url = "https://files.pythonhosted.org/packages/e1/01/e965740bd36e60ef4c5aa2cbe42b6c4eb1dc3551009238a97c2e5e96bd23/fastar-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:620e5d737dce8321d49a5ebb7997f1fd0047cde3512082c27dc66d6ac8c1927a", size = 490227, upload-time = "2025-11-26T02:36:14.363Z" }, + { url = "https://files.pythonhosted.org/packages/dd/10/c99202719b83e5249f26902ae53a05aea67d840eeb242019322f20fc171c/fastar-0.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:c4c4bd08df563120cd33e854fe0a93b81579e8571b11f9b7da9e84c37da2d6b6", size = 461078, upload-time = "2025-11-26T02:36:04.94Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9573b87a0ef07580ed111e7230259aec31bb33ca3667963ebee77022ec61/fastar-0.8.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:50b36ce654ba44b0e13fae607ae17ee6e1597b69f71df1bee64bb8328d881dfc", size = 706041, upload-time = "2025-11-26T02:34:40.638Z" }, + { url = "https://files.pythonhosted.org/packages/4a/19/f95444a1d4f375333af49300aa75ee93afa3335c0e40fda528e460ed859c/fastar-0.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63a892762683d7ab00df0227d5ea9677c62ff2cde9b875e666c0be569ed940f3", size = 628617, upload-time = "2025-11-26T02:34:24.893Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c9/b51481b38b7e3f16ef2b9e233b1a3623386c939d745d6e41bbd389eaae30/fastar-0.8.0-cp314-cp314-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:4ae6a145c1bff592644bde13f2115e0239f4b7babaf506d14e7d208483cf01a5", size = 869299, upload-time = "2025-11-26T02:33:54.274Z" }, + { url = "https://files.pythonhosted.org/packages/bf/02/3ba1267ee5ba7314e29c431cf82eaa68586f2c40cdfa08be3632b7d07619/fastar-0.8.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ae0ff7c0a1c7e1428404b81faee8aebef466bfd0be25bfe4dabf5d535c68741", size = 764667, upload-time = "2025-11-26T02:32:49.606Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/bf33530fd015b5d7c2cc69e0bce4a38d736754a6955487005aab1af6adcd/fastar-0.8.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dbfd87dbd217b45c898b2dbcd0169aae534b2c1c5cbe3119510881f6a5ac8ef5", size = 763993, upload-time = "2025-11-26T02:33:05.782Z" }, + { url = "https://files.pythonhosted.org/packages/da/e0/9564d24e7cea6321a8d921c6d2a457044a476ef197aa4708e179d3d97f0d/fastar-0.8.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5abd99fcba83ef28c8fe6ae2927edc79053db43a0457a962ed85c9bf150d37", size = 930153, upload-time = "2025-11-26T02:33:21.53Z" }, + { url = "https://files.pythonhosted.org/packages/35/b1/6f57fcd8d6e192cfebf97e58eb27751640ad93784c857b79039e84387b51/fastar-0.8.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:91d4c685620c3a9d6b5ae091dbabab4f98b20049b7ecc7976e19cc9016c0d5d6", size = 821177, upload-time = "2025-11-26T02:33:35.839Z" }, + { url = "https://files.pythonhosted.org/packages/b3/78/9e004ea9f3aa7466f5ddb6f9518780e1d2f0ed3ca55f093632982598bace/fastar-0.8.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f77c2f2cad76e9dc7b6701297adb1eba87d0485944b416fc2ccf5516c01219a3", size = 820652, upload-time = "2025-11-26T02:34:09.776Z" }, + { url = "https://files.pythonhosted.org/packages/42/95/b604ed536544005c9f1aee7c4c74b00150db3d8d535cd8232dc20f947063/fastar-0.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e7f07c4a3dada7757a8fc430a5b4a29e6ef696d2212747213f57086ffd970316", size = 985961, upload-time = "2025-11-26T02:34:56.401Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7b/fa9d4d96a5d494bdb8699363bb9de8178c0c21a02e1d89cd6f913d127018/fastar-0.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:90c0c3fe55105c0aed8a83135dbdeb31e683455dbd326a1c48fa44c378b85616", size = 1039316, upload-time = "2025-11-26T02:35:13.807Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f9/8462789243bc3f33e8401378ec6d54de4e20cfa60c96a0e15e3e9d1389bb/fastar-0.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fb9ee51e5bffe0dab3d3126d3a4fac8d8f7235cedcb4b8e74936087ce1c157f3", size = 1045028, upload-time = "2025-11-26T02:35:31.079Z" }, + { url = "https://files.pythonhosted.org/packages/a5/71/9abb128777e616127194b509e98fcda3db797d76288c1a8c23dd22afc14f/fastar-0.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e380b1e8d30317f52406c43b11e98d11e1d68723bbd031e18049ea3497b59a6d", size = 994677, upload-time = "2025-11-26T02:35:49.391Z" }, + { url = "https://files.pythonhosted.org/packages/de/c1/b81b3f194853d7ad232a67a1d768f5f51a016f165cfb56cb31b31bbc6177/fastar-0.8.0-cp314-cp314-win32.whl", hash = "sha256:1c4ffc06e9c4a8ca498c07e094670d8d8c0d25b17ca6465b9774da44ea997ab1", size = 456687, upload-time = "2025-11-26T02:36:30.205Z" }, + { url = "https://files.pythonhosted.org/packages/cb/87/9e0cd4768a98181d56f0cdbab2363404cc15deb93f4aad3b99cd2761bbaa/fastar-0.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:5517a8ad4726267c57a3e0e2a44430b782e00b230bf51c55b5728e758bb3a692", size = 490578, upload-time = "2025-11-26T02:36:16.218Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/580a76cf91847654f2ad6520e956e93218f778540975bc4190d363f709e2/fastar-0.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:58030551046ff4a8616931e52a36c83545ff05996db5beb6e0cd2b7e748aa309", size = 461473, upload-time = "2025-11-26T02:36:06.373Z" }, + { url = "https://files.pythonhosted.org/packages/58/4c/bdb5c6efe934f68708529c8c9d4055ebef5c4be370621966438f658b29bd/fastar-0.8.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:1e7d29b6bfecb29db126a08baf3c04a5ab667f6cea2b7067d3e623a67729c4a6", size = 705570, upload-time = "2025-11-26T02:34:42.01Z" }, + { url = "https://files.pythonhosted.org/packages/6d/78/f01ac7e71d5a37621bd13598a26e948a12b85ca8042f7ee1a0a8c9f59cda/fastar-0.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05eb7b96940f9526b485f1d0b02393839f0f61cac4b1f60024984f8b326d2640", size = 627761, upload-time = "2025-11-26T02:34:26.152Z" }, + { url = "https://files.pythonhosted.org/packages/06/45/6df0ecda86ea9d2e95053c1a655d153dee55fc121b6e13ea6d1e246a50b6/fastar-0.8.0-cp314-cp314t-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:619352d8ac011794e2345c462189dc02ba634750d23cd9d86a9267dd71b1f278", size = 869414, upload-time = "2025-11-26T02:33:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b2/72/486421f5a8c0c377cc82e7a50c8a8ea899a6ec2aa72bde8f09fb667a2dc8/fastar-0.8.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74ebfecef3fe6d7a90355fac1402fd30636988332a1d33f3e80019a10782bb24", size = 763863, upload-time = "2025-11-26T02:32:51.051Z" }, + { url = "https://files.pythonhosted.org/packages/d4/64/39f654dbb41a3867fb1f2c8081c014d8f1d32ea10585d84cacbef0b32995/fastar-0.8.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2975aca5a639e26a3ab0d23b4b0628d6dd6d521146c3c11486d782be621a35aa", size = 763065, upload-time = "2025-11-26T02:33:07.274Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bd/c011a34fb3534c4c3301f7c87c4ffd7e47f6113c904c092ddc8a59a303ea/fastar-0.8.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:afc438eaed8ff0dcdd9308268be5cb38c1db7e94c3ccca7c498ca13a4a4535a3", size = 930530, upload-time = "2025-11-26T02:33:23.117Z" }, + { url = "https://files.pythonhosted.org/packages/55/9d/aa6e887a7033c571b1064429222bbe09adc9a3c1e04f3d1788ba5838ebd5/fastar-0.8.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6ced0a5399cc0a84a858ef0a31ca2d0c24d3bbec4bcda506a9192d8119f3590a", size = 820572, upload-time = "2025-11-26T02:33:37.542Z" }, + { url = "https://files.pythonhosted.org/packages/ad/9c/7a3a2278a1052e1a5d98646de7c095a00cffd2492b3b84ce730e2f1cd93a/fastar-0.8.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec9b23da8c4c039da3fe2e358973c66976a0c8508aa06d6626b4403cb5666c19", size = 820649, upload-time = "2025-11-26T02:34:11.108Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/d38edc1f4438cd047e56137c26d94783ffade42e1b3bde620ccf17b771ef/fastar-0.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:dfba078fcd53478032fd0ceed56960ec6b7ff0511cfc013a8a3a4307e3a7bac4", size = 985653, upload-time = "2025-11-26T02:34:57.884Z" }, + { url = "https://files.pythonhosted.org/packages/69/d9/2147d0c19757e165cd62d41cec3f7b38fad2ad68ab784978b5f81716c7ea/fastar-0.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ade56c94c14be356d295fecb47a3fcd473dd43a8803ead2e2b5b9e58feb6dcfa", size = 1038140, upload-time = "2025-11-26T02:35:15.778Z" }, + { url = "https://files.pythonhosted.org/packages/7f/1d/ec4c717ffb8a308871e9602ec3197d957e238dc0227127ac573ec9bca952/fastar-0.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e48d938f9366db5e59441728f70b7f6c1ccfab7eff84f96f9b7e689b07786c52", size = 1045195, upload-time = "2025-11-26T02:35:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/637334dc8c8f3bb391388b064ae13f0ad9402bc5a6c3e77b8887d0c31921/fastar-0.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:79c441dc1482ff51a54fb3f57ae6f7bb3d2cff88fa2cc5d196c519f8aab64a56", size = 994686, upload-time = "2025-11-26T02:35:51.392Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e2/dfa19a4b260b8ab3581b7484dcb80c09b25324f4daa6b6ae1c7640d1607a/fastar-0.8.0-cp314-cp314t-win32.whl", hash = "sha256:187f61dc739afe45ac8e47ed7fd1adc45d52eac110cf27d579155720507d6fbe", size = 455767, upload-time = "2025-11-26T02:36:34.758Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/df65c72afc1297797b255f90c4778b5d6f1f0f80282a134d5ab610310ed9/fastar-0.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:40e9d763cf8bf85ce2fa256e010aa795c0fe3d3bd1326d5c3084e6ce7857127e", size = 489971, upload-time = "2025-11-26T02:36:22.081Z" }, + { url = "https://files.pythonhosted.org/packages/85/11/0aa8455af26f0ae89e42be67f3a874255ee5d7f0f026fc86e8d56f76b428/fastar-0.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:e59673307b6a08210987059a2bdea2614fe26e3335d0e5d1a3d95f49a05b1418", size = 460467, upload-time = "2025-11-26T02:36:07.978Z" }, +] + +[[package]] +name = "filelock" +version = "3.20.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c1/e0/a75dbe4bca1e7d41307323dad5ea2efdd95408f74ab2de8bd7dba9b51a1a/filelock-3.20.2.tar.gz", hash = "sha256:a2241ff4ddde2a7cebddf78e39832509cb045d18ec1a09d7248d6bfc6bfbbe64", size = 19510, upload-time = "2026-01-02T15:33:32.582Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/30/ab407e2ec752aa541704ed8f93c11e2a5d92c168b8a755d818b74a3c5c2d/filelock-3.20.2-py3-none-any.whl", hash = "sha256:fbba7237d6ea277175a32c54bb71ef814a8546d8601269e1bfc388de333974e8", size = 16697, upload-time = "2026-01-02T15:33:31.133Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/e5/40dbda2736893e3e53d25838e0f19a2b417dfc122b9989c91918db30b5d3/greenlet-3.3.0.tar.gz", hash = "sha256:a82bb225a4e9e4d653dd2fb7b8b2d36e4fb25bc0165422a11e48b88e9e6f78fb", size = 190651, upload-time = "2025-12-04T14:49:44.05Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/0a/a3871375c7b9727edaeeea994bfff7c63ff7804c9829c19309ba2e058807/greenlet-3.3.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:b01548f6e0b9e9784a2c99c5651e5dc89ffcbe870bc5fb2e5ef864e9cc6b5dcb", size = 276379, upload-time = "2025-12-04T14:23:30.498Z" }, + { url = "https://files.pythonhosted.org/packages/43/ab/7ebfe34dce8b87be0d11dae91acbf76f7b8246bf9d6b319c741f99fa59c6/greenlet-3.3.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:349345b770dc88f81506c6861d22a6ccd422207829d2c854ae2af8025af303e3", size = 597294, upload-time = "2025-12-04T14:50:06.847Z" }, + { url = "https://files.pythonhosted.org/packages/a4/39/f1c8da50024feecd0793dbd5e08f526809b8ab5609224a2da40aad3a7641/greenlet-3.3.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e8e18ed6995e9e2c0b4ed264d2cf89260ab3ac7e13555b8032b25a74c6d18655", size = 607742, upload-time = "2025-12-04T14:57:42.349Z" }, + { url = "https://files.pythonhosted.org/packages/77/cb/43692bcd5f7a0da6ec0ec6d58ee7cddb606d055ce94a62ac9b1aa481e969/greenlet-3.3.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c024b1e5696626890038e34f76140ed1daf858e37496d33f2af57f06189e70d7", size = 622297, upload-time = "2025-12-04T15:07:13.552Z" }, + { url = "https://files.pythonhosted.org/packages/75/b0/6bde0b1011a60782108c01de5913c588cf51a839174538d266de15e4bf4d/greenlet-3.3.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:047ab3df20ede6a57c35c14bf5200fcf04039d50f908270d3f9a7a82064f543b", size = 609885, upload-time = "2025-12-04T14:26:02.368Z" }, + { url = "https://files.pythonhosted.org/packages/49/0e/49b46ac39f931f59f987b7cd9f34bfec8ef81d2a1e6e00682f55be5de9f4/greenlet-3.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d9ad37fc657b1102ec880e637cccf20191581f75c64087a549e66c57e1ceb53", size = 1567424, upload-time = "2025-12-04T15:04:23.757Z" }, + { url = "https://files.pythonhosted.org/packages/05/f5/49a9ac2dff7f10091935def9165c90236d8f175afb27cbed38fb1d61ab6b/greenlet-3.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:83cd0e36932e0e7f36a64b732a6f60c2fc2df28c351bae79fbaf4f8092fe7614", size = 1636017, upload-time = "2025-12-04T14:27:29.688Z" }, + { url = "https://files.pythonhosted.org/packages/6c/79/3912a94cf27ec503e51ba493692d6db1e3cd8ac7ac52b0b47c8e33d7f4f9/greenlet-3.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a7a34b13d43a6b78abf828a6d0e87d3385680eaf830cd60d20d52f249faabf39", size = 301964, upload-time = "2025-12-04T14:36:58.316Z" }, + { url = "https://files.pythonhosted.org/packages/02/2f/28592176381b9ab2cafa12829ba7b472d177f3acc35d8fbcf3673d966fff/greenlet-3.3.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:a1e41a81c7e2825822f4e068c48cb2196002362619e2d70b148f20a831c00739", size = 275140, upload-time = "2025-12-04T14:23:01.282Z" }, + { url = "https://files.pythonhosted.org/packages/2c/80/fbe937bf81e9fca98c981fe499e59a3f45df2a04da0baa5c2be0dca0d329/greenlet-3.3.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f515a47d02da4d30caaa85b69474cec77b7929b2e936ff7fb853d42f4bf8808", size = 599219, upload-time = "2025-12-04T14:50:08.309Z" }, + { url = "https://files.pythonhosted.org/packages/c2/ff/7c985128f0514271b8268476af89aee6866df5eec04ac17dcfbc676213df/greenlet-3.3.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7d2d9fd66bfadf230b385fdc90426fcd6eb64db54b40c495b72ac0feb5766c54", size = 610211, upload-time = "2025-12-04T14:57:43.968Z" }, + { url = "https://files.pythonhosted.org/packages/79/07/c47a82d881319ec18a4510bb30463ed6891f2ad2c1901ed5ec23d3de351f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:30a6e28487a790417d036088b3bcb3f3ac7d8babaa7d0139edbaddebf3af9492", size = 624311, upload-time = "2025-12-04T15:07:14.697Z" }, + { url = "https://files.pythonhosted.org/packages/fd/8e/424b8c6e78bd9837d14ff7df01a9829fc883ba2ab4ea787d4f848435f23f/greenlet-3.3.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:087ea5e004437321508a8d6f20efc4cfec5e3c30118e1417ea96ed1d93950527", size = 612833, upload-time = "2025-12-04T14:26:03.669Z" }, + { url = "https://files.pythonhosted.org/packages/b5/ba/56699ff9b7c76ca12f1cdc27a886d0f81f2189c3455ff9f65246780f713d/greenlet-3.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ab97cf74045343f6c60a39913fa59710e4bd26a536ce7ab2397adf8b27e67c39", size = 1567256, upload-time = "2025-12-04T15:04:25.276Z" }, + { url = "https://files.pythonhosted.org/packages/1e/37/f31136132967982d698c71a281a8901daf1a8fbab935dce7c0cf15f942cc/greenlet-3.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5375d2e23184629112ca1ea89a53389dddbffcf417dad40125713d88eb5f96e8", size = 1636483, upload-time = "2025-12-04T14:27:30.804Z" }, + { url = "https://files.pythonhosted.org/packages/7e/71/ba21c3fb8c5dce83b8c01f458a42e99ffdb1963aeec08fff5a18588d8fd7/greenlet-3.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:9ee1942ea19550094033c35d25d20726e4f1c40d59545815e1128ac58d416d38", size = 301833, upload-time = "2025-12-04T14:32:23.929Z" }, + { url = "https://files.pythonhosted.org/packages/d7/7c/f0a6d0ede2c7bf092d00bc83ad5bafb7e6ec9b4aab2fbdfa6f134dc73327/greenlet-3.3.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:60c2ef0f578afb3c8d92ea07ad327f9a062547137afe91f38408f08aacab667f", size = 275671, upload-time = "2025-12-04T14:23:05.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/06/dac639ae1a50f5969d82d2e3dd9767d30d6dbdbab0e1a54010c8fe90263c/greenlet-3.3.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a5d554d0712ba1de0a6c94c640f7aeba3f85b3a6e1f2899c11c2c0428da9365", size = 646360, upload-time = "2025-12-04T14:50:10.026Z" }, + { url = "https://files.pythonhosted.org/packages/e0/94/0fb76fe6c5369fba9bf98529ada6f4c3a1adf19e406a47332245ef0eb357/greenlet-3.3.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3a898b1e9c5f7307ebbde4102908e6cbfcb9ea16284a3abe15cab996bee8b9b3", size = 658160, upload-time = "2025-12-04T14:57:45.41Z" }, + { url = "https://files.pythonhosted.org/packages/93/79/d2c70cae6e823fac36c3bbc9077962105052b7ef81db2f01ec3b9bf17e2b/greenlet-3.3.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dcd2bdbd444ff340e8d6bdf54d2f206ccddbb3ccfdcd3c25bf4afaa7b8f0cf45", size = 671388, upload-time = "2025-12-04T15:07:15.789Z" }, + { url = "https://files.pythonhosted.org/packages/b8/14/bab308fc2c1b5228c3224ec2bf928ce2e4d21d8046c161e44a2012b5203e/greenlet-3.3.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5773edda4dc00e173820722711d043799d3adb4f01731f40619e07ea2750b955", size = 660166, upload-time = "2025-12-04T14:26:05.099Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d2/91465d39164eaa0085177f61983d80ffe746c5a1860f009811d498e7259c/greenlet-3.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ac0549373982b36d5fd5d30beb8a7a33ee541ff98d2b502714a09f1169f31b55", size = 1615193, upload-time = "2025-12-04T15:04:27.041Z" }, + { url = "https://files.pythonhosted.org/packages/42/1b/83d110a37044b92423084d52d5d5a3b3a73cafb51b547e6d7366ff62eff1/greenlet-3.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d198d2d977460358c3b3a4dc844f875d1adb33817f0613f663a656f463764ccc", size = 1683653, upload-time = "2025-12-04T14:27:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/9030e6f9aa8fd7808e9c31ba4c38f87c4f8ec324ee67431d181fe396d705/greenlet-3.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:73f51dd0e0bdb596fb0417e475fa3c5e32d4c83638296e560086b8d7da7c4170", size = 305387, upload-time = "2025-12-04T14:26:51.063Z" }, + { url = "https://files.pythonhosted.org/packages/a0/66/bd6317bc5932accf351fc19f177ffba53712a202f9df10587da8df257c7e/greenlet-3.3.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d6ed6f85fae6cdfdb9ce04c9bf7a08d666cfcfb914e7d006f44f840b46741931", size = 282638, upload-time = "2025-12-04T14:25:20.941Z" }, + { url = "https://files.pythonhosted.org/packages/30/cf/cc81cb030b40e738d6e69502ccbd0dd1bced0588e958f9e757945de24404/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9125050fcf24554e69c4cacb086b87b3b55dc395a8b3ebe6487b045b2614388", size = 651145, upload-time = "2025-12-04T14:50:11.039Z" }, + { url = "https://files.pythonhosted.org/packages/9c/ea/1020037b5ecfe95ca7df8d8549959baceb8186031da83d5ecceff8b08cd2/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:87e63ccfa13c0a0f6234ed0add552af24cc67dd886731f2261e46e241608bee3", size = 654236, upload-time = "2025-12-04T14:57:47.007Z" }, + { url = "https://files.pythonhosted.org/packages/69/cc/1e4bae2e45ca2fa55299f4e85854606a78ecc37fead20d69322f96000504/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2662433acbca297c9153a4023fe2161c8dcfdcc91f10433171cf7e7d94ba2221", size = 662506, upload-time = "2025-12-04T15:07:16.906Z" }, + { url = "https://files.pythonhosted.org/packages/57/b9/f8025d71a6085c441a7eaff0fd928bbb275a6633773667023d19179fe815/greenlet-3.3.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3c6e9b9c1527a78520357de498b0e709fb9e2f49c3a513afd5a249007261911b", size = 653783, upload-time = "2025-12-04T14:26:06.225Z" }, + { url = "https://files.pythonhosted.org/packages/f6/c7/876a8c7a7485d5d6b5c6821201d542ef28be645aa024cfe1145b35c120c1/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:286d093f95ec98fdd92fcb955003b8a3d054b4e2cab3e2707a5039e7b50520fd", size = 1614857, upload-time = "2025-12-04T15:04:28.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/dc/041be1dff9f23dac5f48a43323cd0789cb798342011c19a248d9c9335536/greenlet-3.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c10513330af5b8ae16f023e8ddbfb486ab355d04467c4679c5cfe4659975dd9", size = 1676034, upload-time = "2025-12-04T14:27:33.531Z" }, +] + +[[package]] +name = "gunicorn" +version = "23.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/72/9614c465dc206155d93eff0ca20d42e1e35afc533971379482de953521a4/gunicorn-23.0.0.tar.gz", hash = "sha256:f014447a0101dc57e294f6c18ca6b40227a4c90e9bdb586042628030cba004ec", size = 375031, upload-time = "2024-08-10T20:25:27.378Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/7d/6dac2a6e1eba33ee43f318edbed4ff29151a49b5d37f080aad1e6469bca4/gunicorn-23.0.0-py3-none-any.whl", hash = "sha256:ec400d38950de4dfd418cff8328b2c8faed0edb0d517d3394e457c317908ca4d", size = 85029, upload-time = "2024-08-10T20:25:24.996Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httptools" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" }, + { url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" }, + { url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" }, + { url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" }, + { url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" }, + { url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" }, + { url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" }, + { url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" }, + { url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" }, + { url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" }, + { url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" }, + { url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" }, + { url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" }, + { url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" }, + { url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" }, + { url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" }, + { url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "identify" +version = "2.6.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" }, +] + +[[package]] +name = "idna" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, +] + +[[package]] +name = "librt" +version = "0.7.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/29/47f29026ca17f35cf299290292d5f8331f5077364974b7675a353179afa2/librt-0.7.7.tar.gz", hash = "sha256:81d957b069fed1890953c3b9c3895c7689960f233eea9a1d9607f71ce7f00b2c", size = 145910, upload-time = "2026-01-01T23:52:22.87Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/72/1cd9d752070011641e8aee046c851912d5f196ecd726fffa7aed2070f3e0/librt-0.7.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a85a1fc4ed11ea0eb0a632459ce004a2d14afc085a50ae3463cd3dfe1ce43fc", size = 55687, upload-time = "2026-01-01T23:51:16.291Z" }, + { url = "https://files.pythonhosted.org/packages/50/aa/d5a1d4221c4fe7e76ae1459d24d6037783cb83c7645164c07d7daf1576ec/librt-0.7.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87654e29a35938baead1c4559858f346f4a2a7588574a14d784f300ffba0efd", size = 57136, upload-time = "2026-01-01T23:51:17.363Z" }, + { url = "https://files.pythonhosted.org/packages/23/6f/0c86b5cb5e7ef63208c8cc22534df10ecc5278efc0d47fb8815577f3ca2f/librt-0.7.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c9faaebb1c6212c20afd8043cd6ed9de0a47d77f91a6b5b48f4e46ed470703fe", size = 165320, upload-time = "2026-01-01T23:51:18.455Z" }, + { url = "https://files.pythonhosted.org/packages/16/37/df4652690c29f645ffe405b58285a4109e9fe855c5bb56e817e3e75840b3/librt-0.7.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1908c3e5a5ef86b23391448b47759298f87f997c3bd153a770828f58c2bb4630", size = 174216, upload-time = "2026-01-01T23:51:19.599Z" }, + { url = "https://files.pythonhosted.org/packages/9a/d6/d3afe071910a43133ec9c0f3e4ce99ee6df0d4e44e4bddf4b9e1c6ed41cc/librt-0.7.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbc4900e95a98fc0729523be9d93a8fedebb026f32ed9ffc08acd82e3e181503", size = 189005, upload-time = "2026-01-01T23:51:21.052Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/74060a870fe2d9fd9f47824eba6717ce7ce03124a0d1e85498e0e7efc1b2/librt-0.7.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7ea4e1fbd253e5c68ea0fe63d08577f9d288a73f17d82f652ebc61fa48d878d", size = 183961, upload-time = "2026-01-01T23:51:22.493Z" }, + { url = "https://files.pythonhosted.org/packages/7c/5e/918a86c66304af66a3c1d46d54df1b2d0b8894babc42a14fb6f25511497f/librt-0.7.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef7699b7a5a244b1119f85c5bbc13f152cd38240cbb2baa19b769433bae98e50", size = 177610, upload-time = "2026-01-01T23:51:23.874Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d7/b5e58dc2d570f162e99201b8c0151acf40a03a39c32ab824dd4febf12736/librt-0.7.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:955c62571de0b181d9e9e0a0303c8bc90d47670a5eff54cf71bf5da61d1899cf", size = 199272, upload-time = "2026-01-01T23:51:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/18/87/8202c9bd0968bdddc188ec3811985f47f58ed161b3749299f2c0dd0f63fb/librt-0.7.7-cp312-cp312-win32.whl", hash = "sha256:1bcd79be209313b270b0e1a51c67ae1af28adad0e0c7e84c3ad4b5cb57aaa75b", size = 43189, upload-time = "2026-01-01T23:51:26.799Z" }, + { url = "https://files.pythonhosted.org/packages/61/8d/80244b267b585e7aa79ffdac19f66c4861effc3a24598e77909ecdd0850e/librt-0.7.7-cp312-cp312-win_amd64.whl", hash = "sha256:4353ee891a1834567e0302d4bd5e60f531912179578c36f3d0430f8c5e16b456", size = 49462, upload-time = "2026-01-01T23:51:27.813Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/75db802d6a4992d95e8a889682601af9b49d5a13bbfa246d414eede1b56c/librt-0.7.7-cp312-cp312-win_arm64.whl", hash = "sha256:a76f1d679beccccdf8c1958e732a1dfcd6e749f8821ee59d7bec009ac308c029", size = 42828, upload-time = "2026-01-01T23:51:28.804Z" }, + { url = "https://files.pythonhosted.org/packages/8d/5e/d979ccb0a81407ec47c14ea68fb217ff4315521730033e1dd9faa4f3e2c1/librt-0.7.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4a0b0a3c86ba9193a8e23bb18f100d647bf192390ae195d84dfa0a10fb6244", size = 55746, upload-time = "2026-01-01T23:51:29.828Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/3b65861fb32f802c3783d6ac66fc5589564d07452a47a8cf9980d531cad3/librt-0.7.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5335890fea9f9e6c4fdf8683061b9ccdcbe47c6dc03ab8e9b68c10acf78be78d", size = 57174, upload-time = "2026-01-01T23:51:31.226Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/030b50614b29e443607220097ebaf438531ea218c7a9a3e21ea862a919cd/librt-0.7.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b4346b1225be26def3ccc6c965751c74868f0578cbcba293c8ae9168483d811", size = 165834, upload-time = "2026-01-01T23:51:32.278Z" }, + { url = "https://files.pythonhosted.org/packages/5d/e1/bd8d1eacacb24be26a47f157719553bbd1b3fe812c30dddf121c0436fd0b/librt-0.7.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a10b8eebdaca6e9fdbaf88b5aefc0e324b763a5f40b1266532590d5afb268a4c", size = 174819, upload-time = "2026-01-01T23:51:33.461Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/91d6c3372acf54a019c1ad8da4c9ecf4fc27d039708880bf95f48dbe426a/librt-0.7.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:067be973d90d9e319e6eb4ee2a9b9307f0ecd648b8a9002fa237289a4a07a9e7", size = 189607, upload-time = "2026-01-01T23:51:34.604Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ac/44604d6d3886f791fbd1c6ae12d5a782a8f4aca927484731979f5e92c200/librt-0.7.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23d2299ed007812cccc1ecef018db7d922733382561230de1f3954db28433977", size = 184586, upload-time = "2026-01-01T23:51:35.845Z" }, + { url = "https://files.pythonhosted.org/packages/5c/26/d8a6e4c17117b7f9b83301319d9a9de862ae56b133efb4bad8b3aa0808c9/librt-0.7.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6b6f8ea465524aa4c7420c7cc4ca7d46fe00981de8debc67b1cc2e9957bb5b9d", size = 178251, upload-time = "2026-01-01T23:51:37.018Z" }, + { url = "https://files.pythonhosted.org/packages/99/ab/98d857e254376f8e2f668e807daccc1f445e4b4fc2f6f9c1cc08866b0227/librt-0.7.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8df32a99cc46eb0ee90afd9ada113ae2cafe7e8d673686cf03ec53e49635439", size = 199853, upload-time = "2026-01-01T23:51:38.195Z" }, + { url = "https://files.pythonhosted.org/packages/7c/55/4523210d6ae5134a5da959900be43ad8bab2e4206687b6620befddb5b5fd/librt-0.7.7-cp313-cp313-win32.whl", hash = "sha256:86f86b3b785487c7760247bcdac0b11aa8bf13245a13ed05206286135877564b", size = 43247, upload-time = "2026-01-01T23:51:39.629Z" }, + { url = "https://files.pythonhosted.org/packages/25/40/3ec0fed5e8e9297b1cf1a3836fb589d3de55f9930e3aba988d379e8ef67c/librt-0.7.7-cp313-cp313-win_amd64.whl", hash = "sha256:4862cb2c702b1f905c0503b72d9d4daf65a7fdf5a9e84560e563471e57a56949", size = 49419, upload-time = "2026-01-01T23:51:40.674Z" }, + { url = "https://files.pythonhosted.org/packages/1c/7a/aab5f0fb122822e2acbc776addf8b9abfb4944a9056c00c393e46e543177/librt-0.7.7-cp313-cp313-win_arm64.whl", hash = "sha256:0996c83b1cb43c00e8c87835a284f9057bc647abd42b5871e5f941d30010c832", size = 42828, upload-time = "2026-01-01T23:51:41.731Z" }, + { url = "https://files.pythonhosted.org/packages/69/9c/228a5c1224bd23809a635490a162e9cbdc68d99f0eeb4a696f07886b8206/librt-0.7.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:23daa1ab0512bafdd677eb1bfc9611d8ffbe2e328895671e64cb34166bc1b8c8", size = 55188, upload-time = "2026-01-01T23:51:43.14Z" }, + { url = "https://files.pythonhosted.org/packages/ba/c2/0e7c6067e2b32a156308205e5728f4ed6478c501947e9142f525afbc6bd2/librt-0.7.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:558a9e5a6f3cc1e20b3168fb1dc802d0d8fa40731f6e9932dcc52bbcfbd37111", size = 56895, upload-time = "2026-01-01T23:51:44.534Z" }, + { url = "https://files.pythonhosted.org/packages/0e/77/de50ff70c80855eb79d1d74035ef06f664dd073fb7fb9d9fb4429651b8eb/librt-0.7.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2567cb48dc03e5b246927ab35cbb343376e24501260a9b5e30b8e255dca0d1d2", size = 163724, upload-time = "2026-01-01T23:51:45.571Z" }, + { url = "https://files.pythonhosted.org/packages/6e/19/f8e4bf537899bdef9e0bb9f0e4b18912c2d0f858ad02091b6019864c9a6d/librt-0.7.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6066c638cdf85ff92fc6f932d2d73c93a0e03492cdfa8778e6d58c489a3d7259", size = 172470, upload-time = "2026-01-01T23:51:46.823Z" }, + { url = "https://files.pythonhosted.org/packages/42/4c/dcc575b69d99076768e8dd6141d9aecd4234cba7f0e09217937f52edb6ed/librt-0.7.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a609849aca463074c17de9cda173c276eb8fee9e441053529e7b9e249dc8b8ee", size = 186806, upload-time = "2026-01-01T23:51:48.009Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f8/4094a2b7816c88de81239a83ede6e87f1138477d7ee956c30f136009eb29/librt-0.7.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:add4e0a000858fe9bb39ed55f31085506a5c38363e6eb4a1e5943a10c2bfc3d1", size = 181809, upload-time = "2026-01-01T23:51:49.35Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ac/821b7c0ab1b5a6cd9aee7ace8309c91545a2607185101827f79122219a7e/librt-0.7.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a3bfe73a32bd0bdb9a87d586b05a23c0a1729205d79df66dee65bb2e40d671ba", size = 175597, upload-time = "2026-01-01T23:51:50.636Z" }, + { url = "https://files.pythonhosted.org/packages/71/f9/27f6bfbcc764805864c04211c6ed636fe1d58f57a7b68d1f4ae5ed74e0e0/librt-0.7.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ecce0544d3db91a40f8b57ae26928c02130a997b540f908cefd4d279d6c5848", size = 196506, upload-time = "2026-01-01T23:51:52.535Z" }, + { url = "https://files.pythonhosted.org/packages/46/ba/c9b9c6fc931dd7ea856c573174ccaf48714905b1a7499904db2552e3bbaf/librt-0.7.7-cp314-cp314-win32.whl", hash = "sha256:8f7a74cf3a80f0c3b0ec75b0c650b2f0a894a2cec57ef75f6f72c1e82cdac61d", size = 39747, upload-time = "2026-01-01T23:51:53.683Z" }, + { url = "https://files.pythonhosted.org/packages/c5/69/cd1269337c4cde3ee70176ee611ab0058aa42fc8ce5c9dce55f48facfcd8/librt-0.7.7-cp314-cp314-win_amd64.whl", hash = "sha256:3d1fe2e8df3268dd6734dba33ededae72ad5c3a859b9577bc00b715759c5aaab", size = 45971, upload-time = "2026-01-01T23:51:54.697Z" }, + { url = "https://files.pythonhosted.org/packages/79/fd/e0844794423f5583108c5991313c15e2b400995f44f6ec6871f8aaf8243c/librt-0.7.7-cp314-cp314-win_arm64.whl", hash = "sha256:2987cf827011907d3dfd109f1be0d61e173d68b1270107bb0e89f2fca7f2ed6b", size = 39075, upload-time = "2026-01-01T23:51:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/02/211fd8f7c381e7b2a11d0fdfcd410f409e89967be2e705983f7c6342209a/librt-0.7.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8e92c8de62b40bfce91d5e12c6e8b15434da268979b1af1a6589463549d491e6", size = 57368, upload-time = "2026-01-01T23:51:56.706Z" }, + { url = "https://files.pythonhosted.org/packages/4c/b6/aca257affae73ece26041ae76032153266d110453173f67d7603058e708c/librt-0.7.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f683dcd49e2494a7535e30f779aa1ad6e3732a019d80abe1309ea91ccd3230e3", size = 59238, upload-time = "2026-01-01T23:51:58.066Z" }, + { url = "https://files.pythonhosted.org/packages/96/47/7383a507d8e0c11c78ca34c9d36eab9000db5989d446a2f05dc40e76c64f/librt-0.7.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b15e5d17812d4d629ff576699954f74e2cc24a02a4fc401882dd94f81daba45", size = 183870, upload-time = "2026-01-01T23:51:59.204Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b8/50f3d8eec8efdaf79443963624175c92cec0ba84827a66b7fcfa78598e51/librt-0.7.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c084841b879c4d9b9fa34e5d5263994f21aea7fd9c6add29194dbb41a6210536", size = 194608, upload-time = "2026-01-01T23:52:00.419Z" }, + { url = "https://files.pythonhosted.org/packages/23/d9/1b6520793aadb59d891e3b98ee057a75de7f737e4a8b4b37fdbecb10d60f/librt-0.7.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c8fb9966f84737115513fecbaf257f9553d067a7dd45a69c2c7e5339e6a8dc", size = 206776, upload-time = "2026-01-01T23:52:01.705Z" }, + { url = "https://files.pythonhosted.org/packages/ff/db/331edc3bba929d2756fa335bfcf736f36eff4efcb4f2600b545a35c2ae58/librt-0.7.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5fb1ecb2c35362eab2dbd354fd1efa5a8440d3e73a68be11921042a0edc0ff", size = 203206, upload-time = "2026-01-01T23:52:03.315Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e1/6af79ec77204e85f6f2294fc171a30a91bb0e35d78493532ed680f5d98be/librt-0.7.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d1454899909d63cc9199a89fcc4f81bdd9004aef577d4ffc022e600c412d57f3", size = 196697, upload-time = "2026-01-01T23:52:04.857Z" }, + { url = "https://files.pythonhosted.org/packages/f3/46/de55ecce4b2796d6d243295c221082ca3a944dc2fb3a52dcc8660ce7727d/librt-0.7.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7ef28f2e7a016b29792fe0a2dd04dec75725b32a1264e390c366103f834a9c3a", size = 217193, upload-time = "2026-01-01T23:52:06.159Z" }, + { url = "https://files.pythonhosted.org/packages/41/61/33063e271949787a2f8dd33c5260357e3d512a114fc82ca7890b65a76e2d/librt-0.7.7-cp314-cp314t-win32.whl", hash = "sha256:5e419e0db70991b6ba037b70c1d5bbe92b20ddf82f31ad01d77a347ed9781398", size = 40277, upload-time = "2026-01-01T23:52:07.625Z" }, + { url = "https://files.pythonhosted.org/packages/06/21/1abd972349f83a696ea73159ac964e63e2d14086fdd9bc7ca878c25fced4/librt-0.7.7-cp314-cp314t-win_amd64.whl", hash = "sha256:d6b7d93657332c817b8d674ef6bf1ab7796b4f7ce05e420fd45bd258a72ac804", size = 46765, upload-time = "2026-01-01T23:52:08.647Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/b756c7708143a63fca65a51ca07990fa647db2cc8fcd65177b9e96680255/librt-0.7.7-cp314-cp314t-win_arm64.whl", hash = "sha256:142c2cd91794b79fd0ce113bd658993b7ede0fe93057668c2f98a45ca00b7e91", size = 39724, upload-time = "2026-01-01T23:52:09.745Z" }, +] + +[[package]] +name = "limits" +version = "5.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "packaging" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/e5/c968d43a65128cd54fb685f257aafb90cd5e4e1c67d084a58f0e4cbed557/limits-5.6.0.tar.gz", hash = "sha256:807fac75755e73912e894fdd61e2838de574c5721876a19f7ab454ae1fffb4b5", size = 182984, upload-time = "2025-09-29T17:15:22.689Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/40/96/4fcd44aed47b8fcc457653b12915fcad192cd646510ef3f29fd216f4b0ab/limits-5.6.0-py3-none-any.whl", hash = "sha256:b585c2104274528536a5b68864ec3835602b3c4a802cd6aa0b07419798394021", size = 60604, upload-time = "2025-09-29T17:15:18.419Z" }, +] + +[[package]] +name = "mako" +version = "1.3.10" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "mccabe" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658, upload-time = "2022-01-24T01:14:51.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350, upload-time = "2022-01-24T01:14:49.62Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "mypy" +version = "1.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/db/4efed9504bc01309ab9c2da7e352cc223569f05478012b5d9ece38fd44d2/mypy-1.19.1.tar.gz", hash = "sha256:19d88bb05303fe63f71dd2c6270daca27cb9401c4ca8255fe50d1d920e0eb9ba", size = 3582404, upload-time = "2025-12-15T05:03:48.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/06/8a/19bfae96f6615aa8a0604915512e0289b1fad33d5909bf7244f02935d33a/mypy-1.19.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a8174a03289288c1f6c46d55cef02379b478bfbc8e358e02047487cad44c6ca1", size = 13206053, upload-time = "2025-12-15T05:03:46.622Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/3e63879ab041602154ba2a9f99817bb0c85c4df19a23a1443c8986e4d565/mypy-1.19.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffcebe56eb09ff0c0885e750036a095e23793ba6c2e894e7e63f6d89ad51f22e", size = 12219134, upload-time = "2025-12-15T05:03:24.367Z" }, + { url = "https://files.pythonhosted.org/packages/89/cc/2db6f0e95366b630364e09845672dbee0cbf0bbe753a204b29a944967cd9/mypy-1.19.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b64d987153888790bcdb03a6473d321820597ab8dd9243b27a92153c4fa50fd2", size = 12731616, upload-time = "2025-12-15T05:02:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/00/be/dd56c1fd4807bc1eba1cf18b2a850d0de7bacb55e158755eb79f77c41f8e/mypy-1.19.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c35d298c2c4bba75feb2195655dfea8124d855dfd7343bf8b8c055421eaf0cf8", size = 13620847, upload-time = "2025-12-15T05:03:39.633Z" }, + { url = "https://files.pythonhosted.org/packages/6d/42/332951aae42b79329f743bf1da088cd75d8d4d9acc18fbcbd84f26c1af4e/mypy-1.19.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:34c81968774648ab5ac09c29a375fdede03ba253f8f8287847bd480782f73a6a", size = 13834976, upload-time = "2025-12-15T05:03:08.786Z" }, + { url = "https://files.pythonhosted.org/packages/6f/63/e7493e5f90e1e085c562bb06e2eb32cae27c5057b9653348d38b47daaecc/mypy-1.19.1-cp312-cp312-win_amd64.whl", hash = "sha256:b10e7c2cd7870ba4ad9b2d8a6102eb5ffc1f16ca35e3de6bfa390c1113029d13", size = 10118104, upload-time = "2025-12-15T05:03:10.834Z" }, + { url = "https://files.pythonhosted.org/packages/de/9f/a6abae693f7a0c697dbb435aac52e958dc8da44e92e08ba88d2e42326176/mypy-1.19.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e3157c7594ff2ef1634ee058aafc56a82db665c9438fd41b390f3bde1ab12250", size = 13201927, upload-time = "2025-12-15T05:02:29.138Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a4/45c35ccf6e1c65afc23a069f50e2c66f46bd3798cbe0d680c12d12935caa/mypy-1.19.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bdb12f69bcc02700c2b47e070238f42cb87f18c0bc1fc4cdb4fb2bc5fd7a3b8b", size = 12206730, upload-time = "2025-12-15T05:03:01.325Z" }, + { url = "https://files.pythonhosted.org/packages/05/bb/cdcf89678e26b187650512620eec8368fded4cfd99cfcb431e4cdfd19dec/mypy-1.19.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f859fb09d9583a985be9a493d5cfc5515b56b08f7447759a0c5deaf68d80506e", size = 12724581, upload-time = "2025-12-15T05:03:20.087Z" }, + { url = "https://files.pythonhosted.org/packages/d1/32/dd260d52babf67bad8e6770f8e1102021877ce0edea106e72df5626bb0ec/mypy-1.19.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9a6538e0415310aad77cb94004ca6482330fece18036b5f360b62c45814c4ef", size = 13616252, upload-time = "2025-12-15T05:02:49.036Z" }, + { url = "https://files.pythonhosted.org/packages/71/d0/5e60a9d2e3bd48432ae2b454b7ef2b62a960ab51292b1eda2a95edd78198/mypy-1.19.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:da4869fc5e7f62a88f3fe0b5c919d1d9f7ea3cef92d3689de2823fd27e40aa75", size = 13840848, upload-time = "2025-12-15T05:02:55.95Z" }, + { url = "https://files.pythonhosted.org/packages/98/76/d32051fa65ecf6cc8c6610956473abdc9b4c43301107476ac03559507843/mypy-1.19.1-cp313-cp313-win_amd64.whl", hash = "sha256:016f2246209095e8eda7538944daa1d60e1e8134d98983b9fc1e92c1fc0cb8dd", size = 10135510, upload-time = "2025-12-15T05:02:58.438Z" }, + { url = "https://files.pythonhosted.org/packages/de/eb/b83e75f4c820c4247a58580ef86fcd35165028f191e7e1ba57128c52782d/mypy-1.19.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06e6170bd5836770e8104c8fdd58e5e725cfeb309f0a6c681a811f557e97eac1", size = 13199744, upload-time = "2025-12-15T05:03:30.823Z" }, + { url = "https://files.pythonhosted.org/packages/94/28/52785ab7bfa165f87fcbb61547a93f98bb20e7f82f90f165a1f69bce7b3d/mypy-1.19.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:804bd67b8054a85447c8954215a906d6eff9cabeabe493fb6334b24f4bfff718", size = 12215815, upload-time = "2025-12-15T05:02:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c6/bdd60774a0dbfb05122e3e925f2e9e846c009e479dcec4821dad881f5b52/mypy-1.19.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21761006a7f497cb0d4de3d8ef4ca70532256688b0523eee02baf9eec895e27b", size = 12740047, upload-time = "2025-12-15T05:03:33.168Z" }, + { url = "https://files.pythonhosted.org/packages/32/2a/66ba933fe6c76bd40d1fe916a83f04fed253152f451a877520b3c4a5e41e/mypy-1.19.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28902ee51f12e0f19e1e16fbe2f8f06b6637f482c459dd393efddd0ec7f82045", size = 13601998, upload-time = "2025-12-15T05:03:13.056Z" }, + { url = "https://files.pythonhosted.org/packages/e3/da/5055c63e377c5c2418760411fd6a63ee2b96cf95397259038756c042574f/mypy-1.19.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:481daf36a4c443332e2ae9c137dfee878fcea781a2e3f895d54bd3002a900957", size = 13807476, upload-time = "2025-12-15T05:03:17.977Z" }, + { url = "https://files.pythonhosted.org/packages/cd/09/4ebd873390a063176f06b0dbf1f7783dd87bd120eae7727fa4ae4179b685/mypy-1.19.1-cp314-cp314-win_amd64.whl", hash = "sha256:8bb5c6f6d043655e055be9b542aa5f3bdd30e4f3589163e85f93f3640060509f", size = 10281872, upload-time = "2025-12-15T05:03:05.549Z" }, + { url = "https://files.pythonhosted.org/packages/8d/f4/4ce9a05ce5ded1de3ec1c1d96cf9f9504a04e54ce0ed55cfa38619a32b8d/mypy-1.19.1-py3-none-any.whl", hash = "sha256:f1235f5ea01b7db5468d53ece6aaddf1ad0b88d9e7462b86ef96fe04995d7247", size = 2471239, upload-time = "2025-12-15T05:03:07.248Z" }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, +] + +[[package]] +name = "nodeenv" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, +] + +[[package]] +name = "packaging" +version = "25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" }, +] + +[[package]] +name = "pathspec" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pre-commit" +version = "4.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cfgv" }, + { name = "identify" }, + { name = "nodeenv" }, + { name = "pyyaml" }, + { name = "virtualenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, +] + +[[package]] +name = "psycopg2-binary" +version = "2.9.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/6c/8767aaa597ba424643dc87348c6f1754dd9f48e80fdc1b9f7ca5c3a7c213/psycopg2-binary-2.9.11.tar.gz", hash = "sha256:b6aed9e096bf63f9e75edf2581aa9a7e7186d97ab5c177aa6c87797cd591236c", size = 379620, upload-time = "2025-10-10T11:14:48.041Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/91/f870a02f51be4a65987b45a7de4c2e1897dd0d01051e2b559a38fa634e3e/psycopg2_binary-2.9.11-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:be9b840ac0525a283a96b556616f5b4820e0526addb8dcf6525a0fa162730be4", size = 3756603, upload-time = "2025-10-10T11:11:52.213Z" }, + { url = "https://files.pythonhosted.org/packages/27/fa/cae40e06849b6c9a95eb5c04d419942f00d9eaac8d81626107461e268821/psycopg2_binary-2.9.11-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f090b7ddd13ca842ebfe301cd587a76a4cf0913b1e429eb92c1be5dbeb1a19bc", size = 3864509, upload-time = "2025-10-10T11:11:56.452Z" }, + { url = "https://files.pythonhosted.org/packages/2d/75/364847b879eb630b3ac8293798e380e441a957c53657995053c5ec39a316/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ab8905b5dcb05bf3fb22e0cf90e10f469563486ffb6a96569e51f897c750a76a", size = 4411159, upload-time = "2025-10-10T11:12:00.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a0/567f7ea38b6e1c62aafd58375665a547c00c608a471620c0edc364733e13/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf940cd7e7fec19181fdbc29d76911741153d51cab52e5c21165f3262125685e", size = 4468234, upload-time = "2025-10-10T11:12:04.892Z" }, + { url = "https://files.pythonhosted.org/packages/30/da/4e42788fb811bbbfd7b7f045570c062f49e350e1d1f3df056c3fb5763353/psycopg2_binary-2.9.11-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fa0f693d3c68ae925966f0b14b8edda71696608039f4ed61b1fe9ffa468d16db", size = 4166236, upload-time = "2025-10-10T11:12:11.674Z" }, + { url = "https://files.pythonhosted.org/packages/3c/94/c1777c355bc560992af848d98216148be5f1be001af06e06fc49cbded578/psycopg2_binary-2.9.11-cp312-cp312-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a1cf393f1cdaf6a9b57c0a719a1068ba1069f022a59b8b1fe44b006745b59757", size = 3983083, upload-time = "2025-10-30T02:55:15.73Z" }, + { url = "https://files.pythonhosted.org/packages/bd/42/c9a21edf0e3daa7825ed04a4a8588686c6c14904344344a039556d78aa58/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7a6beb4beaa62f88592ccc65df20328029d721db309cb3250b0aae0fa146c3", size = 3652281, upload-time = "2025-10-10T11:12:17.713Z" }, + { url = "https://files.pythonhosted.org/packages/12/22/dedfbcfa97917982301496b6b5e5e6c5531d1f35dd2b488b08d1ebc52482/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:31b32c457a6025e74d233957cc9736742ac5a6cb196c6b68499f6bb51390bd6a", size = 3298010, upload-time = "2025-10-10T11:12:22.671Z" }, + { url = "https://files.pythonhosted.org/packages/66/ea/d3390e6696276078bd01b2ece417deac954dfdd552d2edc3d03204416c0c/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:edcb3aeb11cb4bf13a2af3c53a15b3d612edeb6409047ea0b5d6a21a9d744b34", size = 3044641, upload-time = "2025-10-30T02:55:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/12/9a/0402ded6cbd321da0c0ba7d34dc12b29b14f5764c2fc10750daa38e825fc/psycopg2_binary-2.9.11-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:62b6d93d7c0b61a1dd6197d208ab613eb7dcfdcca0a49c42ceb082257991de9d", size = 3347940, upload-time = "2025-10-10T11:12:26.529Z" }, + { url = "https://files.pythonhosted.org/packages/b1/d2/99b55e85832ccde77b211738ff3925a5d73ad183c0b37bcbbe5a8ff04978/psycopg2_binary-2.9.11-cp312-cp312-win_amd64.whl", hash = "sha256:b33fabeb1fde21180479b2d4667e994de7bbf0eec22832ba5d9b5e4cf65b6c6d", size = 2714147, upload-time = "2025-10-10T11:12:29.535Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a8/a2709681b3ac11b0b1786def10006b8995125ba268c9a54bea6f5ae8bd3e/psycopg2_binary-2.9.11-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b8fb3db325435d34235b044b199e56cdf9ff41223a4b9752e8576465170bb38c", size = 3756572, upload-time = "2025-10-10T11:12:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/62/e1/c2b38d256d0dafd32713e9f31982a5b028f4a3651f446be70785f484f472/psycopg2_binary-2.9.11-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:366df99e710a2acd90efed3764bb1e28df6c675d33a7fb40df9b7281694432ee", size = 3864529, upload-time = "2025-10-10T11:12:36.791Z" }, + { url = "https://files.pythonhosted.org/packages/11/32/b2ffe8f3853c181e88f0a157c5fb4e383102238d73c52ac6d93a5c8bffe6/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c55b385daa2f92cb64b12ec4536c66954ac53654c7f15a203578da4e78105c0", size = 4411242, upload-time = "2025-10-10T11:12:42.388Z" }, + { url = "https://files.pythonhosted.org/packages/10/04/6ca7477e6160ae258dc96f67c371157776564679aefd247b66f4661501a2/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:c0377174bf1dd416993d16edc15357f6eb17ac998244cca19bc67cdc0e2e5766", size = 4468258, upload-time = "2025-10-10T11:12:48.654Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7e/6a1a38f86412df101435809f225d57c1a021307dd0689f7a5e7fe83588b1/psycopg2_binary-2.9.11-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5c6ff3335ce08c75afaed19e08699e8aacf95d4a260b495a4a8545244fe2ceb3", size = 4166295, upload-time = "2025-10-10T11:12:52.525Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7d/c07374c501b45f3579a9eb761cbf2604ddef3d96ad48679112c2c5aa9c25/psycopg2_binary-2.9.11-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:84011ba3109e06ac412f95399b704d3d6950e386b7994475b231cf61eec2fc1f", size = 3983133, upload-time = "2025-10-30T02:55:24.329Z" }, + { url = "https://files.pythonhosted.org/packages/82/56/993b7104cb8345ad7d4516538ccf8f0d0ac640b1ebd8c754a7b024e76878/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ba34475ceb08cccbdd98f6b46916917ae6eeb92b5ae111df10b544c3a4621dc4", size = 3652383, upload-time = "2025-10-10T11:12:56.387Z" }, + { url = "https://files.pythonhosted.org/packages/2d/ac/eaeb6029362fd8d454a27374d84c6866c82c33bfc24587b4face5a8e43ef/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b31e90fdd0f968c2de3b26ab014314fe814225b6c324f770952f7d38abf17e3c", size = 3298168, upload-time = "2025-10-10T11:13:00.403Z" }, + { url = "https://files.pythonhosted.org/packages/2b/39/50c3facc66bded9ada5cbc0de867499a703dc6bca6be03070b4e3b65da6c/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:d526864e0f67f74937a8fce859bd56c979f5e2ec57ca7c627f5f1071ef7fee60", size = 3044712, upload-time = "2025-10-30T02:55:27.975Z" }, + { url = "https://files.pythonhosted.org/packages/9c/8e/b7de019a1f562f72ada81081a12823d3c1590bedc48d7d2559410a2763fe/psycopg2_binary-2.9.11-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04195548662fa544626c8ea0f06561eb6203f1984ba5b4562764fbeb4c3d14b1", size = 3347549, upload-time = "2025-10-10T11:13:03.971Z" }, + { url = "https://files.pythonhosted.org/packages/80/2d/1bb683f64737bbb1f86c82b7359db1eb2be4e2c0c13b947f80efefa7d3e5/psycopg2_binary-2.9.11-cp313-cp313-win_amd64.whl", hash = "sha256:efff12b432179443f54e230fdf60de1f6cc726b6c832db8701227d089310e8aa", size = 2714215, upload-time = "2025-10-10T11:13:07.14Z" }, + { url = "https://files.pythonhosted.org/packages/64/12/93ef0098590cf51d9732b4f139533732565704f45bdc1ffa741b7c95fb54/psycopg2_binary-2.9.11-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:92e3b669236327083a2e33ccfa0d320dd01b9803b3e14dd986a4fc54aa00f4e1", size = 3756567, upload-time = "2025-10-10T11:13:11.885Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a9/9d55c614a891288f15ca4b5209b09f0f01e3124056924e17b81b9fa054cc/psycopg2_binary-2.9.11-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e0deeb03da539fa3577fcb0b3f2554a97f7e5477c246098dbb18091a4a01c16f", size = 3864755, upload-time = "2025-10-10T11:13:17.727Z" }, + { url = "https://files.pythonhosted.org/packages/13/1e/98874ce72fd29cbde93209977b196a2edae03f8490d1bd8158e7f1daf3a0/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:9b52a3f9bb540a3e4ec0f6ba6d31339727b2950c9772850d6545b7eae0b9d7c5", size = 4411646, upload-time = "2025-10-10T11:13:24.432Z" }, + { url = "https://files.pythonhosted.org/packages/5a/bd/a335ce6645334fb8d758cc358810defca14a1d19ffbc8a10bd38a2328565/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:db4fd476874ccfdbb630a54426964959e58da4c61c9feba73e6094d51303d7d8", size = 4468701, upload-time = "2025-10-10T11:13:29.266Z" }, + { url = "https://files.pythonhosted.org/packages/44/d6/c8b4f53f34e295e45709b7568bf9b9407a612ea30387d35eb9fa84f269b4/psycopg2_binary-2.9.11-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47f212c1d3be608a12937cc131bd85502954398aaa1320cb4c14421a0ffccf4c", size = 4166293, upload-time = "2025-10-10T11:13:33.336Z" }, + { url = "https://files.pythonhosted.org/packages/4b/e0/f8cc36eadd1b716ab36bb290618a3292e009867e5c97ce4aba908cb99644/psycopg2_binary-2.9.11-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e35b7abae2b0adab776add56111df1735ccc71406e56203515e228a8dc07089f", size = 3983184, upload-time = "2025-10-30T02:55:32.483Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/2a8fe18a4e61cfb3417da67b6318e12691772c0696d79434184a511906dc/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fcf21be3ce5f5659daefd2b3b3b6e4727b028221ddc94e6c1523425579664747", size = 3652650, upload-time = "2025-10-10T11:13:38.181Z" }, + { url = "https://files.pythonhosted.org/packages/76/36/03801461b31b29fe58d228c24388f999fe814dfc302856e0d17f97d7c54d/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:9bd81e64e8de111237737b29d68039b9c813bdf520156af36d26819c9a979e5f", size = 3298663, upload-time = "2025-10-10T11:13:44.878Z" }, + { url = "https://files.pythonhosted.org/packages/97/77/21b0ea2e1a73aa5fa9222b2a6b8ba325c43c3a8d54272839c991f2345656/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:32770a4d666fbdafab017086655bcddab791d7cb260a16679cc5a7338b64343b", size = 3044737, upload-time = "2025-10-30T02:55:35.69Z" }, + { url = "https://files.pythonhosted.org/packages/67/69/f36abe5f118c1dca6d3726ceae164b9356985805480731ac6712a63f24f0/psycopg2_binary-2.9.11-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c3cb3a676873d7506825221045bd70e0427c905b9c8ee8d6acd70cfcbd6e576d", size = 3347643, upload-time = "2025-10-10T11:13:53.499Z" }, + { url = "https://files.pythonhosted.org/packages/e1/36/9c0c326fe3a4227953dfb29f5d0c8ae3b8eb8c1cd2967aa569f50cb3c61f/psycopg2_binary-2.9.11-cp314-cp314-win_amd64.whl", hash = "sha256:4012c9c954dfaccd28f94e84ab9f94e12df76b4afb22331b1f0d3154893a6316", size = 2803913, upload-time = "2025-10-10T11:13:57.058Z" }, +] + +[[package]] +name = "pwdlib" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/41/a7c0d8a003c36ce3828ae3ed0391fe6a15aad65f082dbd6bec817ea95c0b/pwdlib-0.3.0.tar.gz", hash = "sha256:6ca30f9642a1467d4f5d0a4d18619de1c77f17dfccb42dd200b144127d3c83fc", size = 215810, upload-time = "2025-10-25T12:44:24.395Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/0c/9086a357d02a050fbb3270bf5043ac284dbfb845670e16c9389a41defc9e/pwdlib-0.3.0-py3-none-any.whl", hash = "sha256:f86c15c138858c09f3bba0a10984d4f9178158c55deaa72eac0210849b1a140d", size = 8633, upload-time = "2025-10-25T12:44:23.406Z" }, +] + +[package.optional-dependencies] +argon2 = [ + { name = "argon2-cffi" }, +] + +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, +] + +[[package]] +name = "pydantic-extra-types" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fd/35/2fee58b1316a73e025728583d3b1447218a97e621933fc776fb8c0f2ebdd/pydantic_extra_types-2.11.0.tar.gz", hash = "sha256:4e9991959d045b75feb775683437a97991d02c138e00b59176571db9ce634f0e", size = 157226, upload-time = "2025-12-31T16:18:27.944Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/17/fabd56da47096d240dd45ba627bead0333b0cf0ee8ada9bec579287dadf3/pydantic_extra_types-2.11.0-py3-none-any.whl", hash = "sha256:84b864d250a0fc62535b7ec591e36f2c5b4d1325fa0017eb8cda9aeb63b374a6", size = 74296, upload-time = "2025-12-31T16:18:26.38Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/ac7e0aae12027748076d72a8764ff1c9d82ca75a7a52622e67ed3f765c54/pydantic_settings-2.12.0.tar.gz", hash = "sha256:005538ef951e3c2a68e1c08b292b5f2e71490def8589d4221b95dab00dafcfd0", size = 194184, upload-time = "2025-11-10T14:25:47.013Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[[package]] +name = "pylint" +version = "4.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astroid" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "dill" }, + { name = "isort" }, + { name = "mccabe" }, + { name = "platformdirs" }, + { name = "tomlkit" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5a/d2/b081da1a8930d00e3fc06352a1d449aaf815d4982319fab5d8cdb2e9ab35/pylint-4.0.4.tar.gz", hash = "sha256:d9b71674e19b1c36d79265b5887bf8e55278cbe236c9e95d22dc82cf044fdbd2", size = 1571735, upload-time = "2025-11-30T13:29:04.315Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a6/92/d40f5d937517cc489ad848fc4414ecccc7592e4686b9071e09e64f5e378e/pylint-4.0.4-py3-none-any.whl", hash = "sha256:63e06a37d5922555ee2c20963eb42559918c20bd2b21244e4ef426e7c43b92e0", size = 536425, upload-time = "2025-11-30T13:29:02.53Z" }, +] + +[[package]] +name = "pylint-per-file-ignores" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pylint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/64/b2/cf916c3c8127282f60927a3fd382ef8e477e6ef090b3d1f1fedd62bff916/pylint_per_file_ignores-3.2.0.tar.gz", hash = "sha256:5eb30b2b64c49ca616b8940346b8b5b4973eeaa15700840c8b81a4b8ba565a02", size = 63854, upload-time = "2025-11-25T14:13:14.577Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/6a/09cbda0032e1040eea8c7daec3994e6d32b3edc26a81d226fd643537886b/pylint_per_file_ignores-3.2.0-py3-none-any.whl", hash = "sha256:8b995b7486f6652f942cf5721e24c29b72735fa911b6d22b65b2f87bad323590", size = 5576, upload-time = "2025-11-25T14:13:13.208Z" }, +] + +[[package]] +name = "pylint-plugin-utils" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pylint" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/73/85/24eaf5d0d078fc8799ae6d89faf326d6e4d27d862fc9a710a52ab07b7bb5/pylint_plugin_utils-0.9.0.tar.gz", hash = "sha256:5468d763878a18d5cc4db46eaffdda14313b043c962a263a7d78151b90132055", size = 10474, upload-time = "2025-06-24T07:14:00.534Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/c9/a3b871b0b590c49e38884af6dab58ab9711053bd5c39b8899b72e367b9f6/pylint_plugin_utils-0.9.0-py3-none-any.whl", hash = "sha256:16e9b84e5326ba893a319a0323fcc8b4bcc9c71fc654fcabba0605596c673818", size = 11129, upload-time = "2025-06-24T07:13:58.993Z" }, +] + +[[package]] +name = "pylint-pydantic" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "pylint" }, + { name = "pylint-plugin-utils" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/ac/5de3c91c7f9354444af251f053cc9953c89cce1defa74b907f67be4f770a/pylint_pydantic-0.4.1-py3-none-any.whl", hash = "sha256:d1b937abe5c346d38de69ee1ada80c93d38ee2356addbabb687e2eb44036ac93", size = 16161, upload-time = "2025-10-27T08:03:33.641Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/90/2c/8af215c0f776415f3590cac4f9086ccefd6fd463befeae41cd4d3f193e5a/pytest_asyncio-1.3.0.tar.gz", hash = "sha256:d7f52f36d231b80ee124cd216ffb19369aa168fc10095013c6b014a34d3ee9e5", size = 50087, upload-time = "2025-11-10T16:07:47.256Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/35/f8b19922b6a25bc0880171a2f1a003eaeb93657475193ab516fd87cac9da/pytest_asyncio-1.3.0-py3-none-any.whl", hash = "sha256:611e26147c7f77640e6d0a92a38ed17c3e9848063698d5c93d5aa7aa11cebff5", size = 15075, upload-time = "2025-11-10T16:07:45.537Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage" }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.21" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/96/804520d0850c7db98e5ccb70282e29208723f0964e88ffd9d0da2f52ea09/python_multipart-0.0.21.tar.gz", hash = "sha256:7137ebd4d3bbf70ea1622998f902b97a29434a9e8dc40eb203bbcf7c2a2cba92", size = 37196, upload-time = "2025-12-17T09:24:22.446Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/76/03af049af4dcee5d27442f71b6924f01f3efb5d2bd34f23fcd563f2cc5f5/python_multipart-0.0.21-py3-none-any.whl", hash = "sha256:cf7a6713e01c87aa35387f4774e812c4361150938d20d232800f75ffcf266090", size = 24541, upload-time = "2025-12-17T09:24:21.153Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + +[[package]] +name = "rich" +version = "14.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, +] + +[[package]] +name = "rich-toolkit" +version = "0.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/09/3f9b8d9daaf235195c626f21e03604c05b987404ee3bcacee0c1f67f2a8e/rich_toolkit-0.17.1.tar.gz", hash = "sha256:5af54df8d1dd9c8530e462e1bdcaed625c9b49f5a55b035aa0ba1c17bdb87c9a", size = 187925, upload-time = "2025-12-17T10:49:22.583Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/7b/15e55fa8a76d0d41bf34d965af78acdaf80a315907adb30de8b63c272694/rich_toolkit-0.17.1-py3-none-any.whl", hash = "sha256:96d24bb921ecd225ffce7c526a9149e74006410c05e6d405bd74ffd54d5631ed", size = 31412, upload-time = "2025-12-17T10:49:21.793Z" }, +] + +[[package]] +name = "rignore" +version = "0.7.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e5/f5/8bed2310abe4ae04b67a38374a4d311dd85220f5d8da56f47ae9361be0b0/rignore-0.7.6.tar.gz", hash = "sha256:00d3546cd793c30cb17921ce674d2c8f3a4b00501cb0e3dd0e82217dbeba2671", size = 57140, upload-time = "2025-11-05T21:41:21.968Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/0e/012556ef3047a2628842b44e753bb15f4dc46806780ff090f1e8fe4bf1eb/rignore-0.7.6-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:03e82348cb7234f8d9b2834f854400ddbbd04c0f8f35495119e66adbd37827a8", size = 883488, upload-time = "2025-11-05T20:42:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/93/b0/d4f1f3fe9eb3f8e382d45ce5b0547ea01c4b7e0b4b4eb87bcd66a1d2b888/rignore-0.7.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9e624f6be6116ea682e76c5feb71ea91255c67c86cb75befe774365b2931961", size = 820411, upload-time = "2025-11-05T20:42:24.782Z" }, + { url = "https://files.pythonhosted.org/packages/4a/c8/dea564b36dedac8de21c18e1851789545bc52a0c22ece9843444d5608a6a/rignore-0.7.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bda49950d405aa8d0ebe26af807c4e662dd281d926530f03f29690a2e07d649a", size = 897821, upload-time = "2025-11-05T20:40:52.613Z" }, + { url = "https://files.pythonhosted.org/packages/b3/2b/ee96db17ac1835e024c5d0742eefb7e46de60020385ac883dd3d1cde2c1f/rignore-0.7.6-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5fd5ab3840b8c16851d327ed06e9b8be6459702a53e5ab1fc4073b684b3789e", size = 873963, upload-time = "2025-11-05T20:41:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8c/ad5a57bbb9d14d5c7e5960f712a8a0b902472ea3f4a2138cbf70d1777b75/rignore-0.7.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ced2a248352636a5c77504cb755dc02c2eef9a820a44d3f33061ce1bb8a7f2d2", size = 1169216, upload-time = "2025-11-05T20:41:23.73Z" }, + { url = "https://files.pythonhosted.org/packages/80/e6/5b00bc2a6bc1701e6878fca798cf5d9125eb3113193e33078b6fc0d99123/rignore-0.7.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a04a3b73b75ddc12c9c9b21efcdaab33ca3832941d6f1d67bffd860941cd448a", size = 942942, upload-time = "2025-11-05T20:41:39.393Z" }, + { url = "https://files.pythonhosted.org/packages/85/e5/7f99bd0cc9818a91d0e8b9acc65b792e35750e3bdccd15a7ee75e64efca4/rignore-0.7.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d24321efac92140b7ec910ac7c53ab0f0c86a41133d2bb4b0e6a7c94967f44dd", size = 959787, upload-time = "2025-11-05T20:42:09.765Z" }, + { url = "https://files.pythonhosted.org/packages/55/54/2ffea79a7c1eabcede1926347ebc2a81bc6b81f447d05b52af9af14948b9/rignore-0.7.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c7aa109d41e593785c55fdaa89ad80b10330affa9f9d3e3a51fa695f739b20", size = 984245, upload-time = "2025-11-05T20:41:54.062Z" }, + { url = "https://files.pythonhosted.org/packages/41/f7/e80f55dfe0f35787fa482aa18689b9c8251e045076c35477deb0007b3277/rignore-0.7.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1734dc49d1e9501b07852ef44421f84d9f378da9fbeda729e77db71f49cac28b", size = 1078647, upload-time = "2025-11-05T21:40:13.463Z" }, + { url = "https://files.pythonhosted.org/packages/d4/cf/2c64f0b6725149f7c6e7e5a909d14354889b4beaadddaa5fff023ec71084/rignore-0.7.6-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5719ea14ea2b652c0c0894be5dfde954e1853a80dea27dd2fbaa749618d837f5", size = 1139186, upload-time = "2025-11-05T21:40:31.27Z" }, + { url = "https://files.pythonhosted.org/packages/75/95/a86c84909ccc24af0d094b50d54697951e576c252a4d9f21b47b52af9598/rignore-0.7.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8e23424fc7ce35726854f639cb7968151a792c0c3d9d082f7f67e0c362cfecca", size = 1117604, upload-time = "2025-11-05T21:40:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/7f/5e/13b249613fd5d18d58662490ab910a9f0be758981d1797789913adb4e918/rignore-0.7.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3efdcf1dd84d45f3e2bd2f93303d9be103888f56dfa7c3349b5bf4f0657ec696", size = 1127725, upload-time = "2025-11-05T21:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/c7/28/fa5dcd1e2e16982c359128664e3785f202d3eca9b22dd0b2f91c4b3d242f/rignore-0.7.6-cp312-cp312-win32.whl", hash = "sha256:ccca9d1a8b5234c76b71546fc3c134533b013f40495f394a65614a81f7387046", size = 646145, upload-time = "2025-11-05T21:41:51.096Z" }, + { url = "https://files.pythonhosted.org/packages/26/87/69387fb5dd81a0f771936381431780b8cf66fcd2cfe9495e1aaf41548931/rignore-0.7.6-cp312-cp312-win_amd64.whl", hash = "sha256:c96a285e4a8bfec0652e0bfcf42b1aabcdda1e7625f5006d188e3b1c87fdb543", size = 726090, upload-time = "2025-11-05T21:41:36.485Z" }, + { url = "https://files.pythonhosted.org/packages/24/5f/e8418108dcda8087fb198a6f81caadbcda9fd115d61154bf0df4d6d3619b/rignore-0.7.6-cp312-cp312-win_arm64.whl", hash = "sha256:a64a750e7a8277a323f01ca50b7784a764845f6cce2fe38831cb93f0508d0051", size = 656317, upload-time = "2025-11-05T21:41:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b7/8a/a4078f6e14932ac7edb171149c481de29969d96ddee3ece5dc4c26f9e0c3/rignore-0.7.6-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:2bdab1d31ec9b4fb1331980ee49ea051c0d7f7bb6baa28b3125ef03cdc48fdaf", size = 883057, upload-time = "2025-11-05T20:42:42.741Z" }, + { url = "https://files.pythonhosted.org/packages/f9/8f/f8daacd177db4bf7c2223bab41e630c52711f8af9ed279be2058d2fe4982/rignore-0.7.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:90f0a00ce0c866c275bf888271f1dc0d2140f29b82fcf33cdbda1e1a6af01010", size = 820150, upload-time = "2025-11-05T20:42:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/b65b837e39c3f7064c426754714ac633b66b8c2290978af9d7f513e14aa9/rignore-0.7.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ad295537041dc2ed4b540fb1a3906bd9ede6ccdad3fe79770cd89e04e3c73c", size = 897406, upload-time = "2025-11-05T20:40:53.854Z" }, + { url = "https://files.pythonhosted.org/packages/ca/58/1970ce006c427e202ac7c081435719a076c478f07b3a23f469227788dc23/rignore-0.7.6-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f782dbd3a65a5ac85adfff69e5c6b101285ef3f845c3a3cae56a54bebf9fe116", size = 874050, upload-time = "2025-11-05T20:41:08.922Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/eb45db9f90137329072a732273be0d383cb7d7f50ddc8e0bceea34c1dfdf/rignore-0.7.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65cece3b36e5b0826d946494734c0e6aaf5a0337e18ff55b071438efe13d559e", size = 1167835, upload-time = "2025-11-05T20:41:24.997Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f1/6f1d72ddca41a64eed569680587a1236633587cc9f78136477ae69e2c88a/rignore-0.7.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d7e4bb66c13cd7602dc8931822c02dfbbd5252015c750ac5d6152b186f0a8be0", size = 941945, upload-time = "2025-11-05T20:41:40.628Z" }, + { url = "https://files.pythonhosted.org/packages/48/6f/2f178af1c1a276a065f563ec1e11e7a9e23d4996fd0465516afce4b5c636/rignore-0.7.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:297e500c15766e196f68aaaa70e8b6db85fa23fdc075b880d8231fdfba738cd7", size = 959067, upload-time = "2025-11-05T20:42:11.09Z" }, + { url = "https://files.pythonhosted.org/packages/5b/db/423a81c4c1e173877c7f9b5767dcaf1ab50484a94f60a0b2ed78be3fa765/rignore-0.7.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a07084211a8d35e1a5b1d32b9661a5ed20669970b369df0cf77da3adea3405de", size = 984438, upload-time = "2025-11-05T20:41:55.443Z" }, + { url = "https://files.pythonhosted.org/packages/31/eb/c4f92cc3f2825d501d3c46a244a671eb737fc1bcf7b05a3ecd34abb3e0d7/rignore-0.7.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:181eb2a975a22256a1441a9d2f15eb1292839ea3f05606620bd9e1938302cf79", size = 1078365, upload-time = "2025-11-05T21:40:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/26/09/99442f02794bd7441bfc8ed1c7319e890449b816a7493b2db0e30af39095/rignore-0.7.6-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:7bbcdc52b5bf9f054b34ce4af5269df5d863d9c2456243338bc193c28022bd7b", size = 1139066, upload-time = "2025-11-05T21:40:32.771Z" }, + { url = "https://files.pythonhosted.org/packages/2c/88/bcfc21e520bba975410e9419450f4b90a2ac8236b9a80fd8130e87d098af/rignore-0.7.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:f2e027a6da21a7c8c0d87553c24ca5cc4364def18d146057862c23a96546238e", size = 1118036, upload-time = "2025-11-05T21:40:49.646Z" }, + { url = "https://files.pythonhosted.org/packages/e2/25/d37215e4562cda5c13312636393aea0bafe38d54d4e0517520a4cc0753ec/rignore-0.7.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee4a18b82cbbc648e4aac1510066682fe62beb5dc88e2c67c53a83954e541360", size = 1127550, upload-time = "2025-11-05T21:41:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/dc/76/a264ab38bfa1620ec12a8ff1c07778da89e16d8c0f3450b0333020d3d6dc/rignore-0.7.6-cp313-cp313-win32.whl", hash = "sha256:a7d7148b6e5e95035d4390396895adc384d37ff4e06781a36fe573bba7c283e5", size = 646097, upload-time = "2025-11-05T21:41:53.201Z" }, + { url = "https://files.pythonhosted.org/packages/62/44/3c31b8983c29ea8832b6082ddb1d07b90379c2d993bd20fce4487b71b4f4/rignore-0.7.6-cp313-cp313-win_amd64.whl", hash = "sha256:b037c4b15a64dced08fc12310ee844ec2284c4c5c1ca77bc37d0a04f7bff386e", size = 726170, upload-time = "2025-11-05T21:41:38.131Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/e26a075cab83debe41a42661262f606166157df84e0e02e2d904d134c0d8/rignore-0.7.6-cp313-cp313-win_arm64.whl", hash = "sha256:e47443de9b12fe569889bdbe020abe0e0b667516ee2ab435443f6d0869bd2804", size = 656184, upload-time = "2025-11-05T21:41:27.396Z" }, + { url = "https://files.pythonhosted.org/packages/9a/b9/1f5bd82b87e5550cd843ceb3768b4a8ef274eb63f29333cf2f29644b3d75/rignore-0.7.6-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:8e41be9fa8f2f47239ded8920cc283699a052ac4c371f77f5ac017ebeed75732", size = 882632, upload-time = "2025-11-05T20:42:44.063Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6b/07714a3efe4a8048864e8a5b7db311ba51b921e15268b17defaebf56d3db/rignore-0.7.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:6dc1e171e52cefa6c20e60c05394a71165663b48bca6c7666dee4f778f2a7d90", size = 820760, upload-time = "2025-11-05T20:42:27.885Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0f/348c829ea2d8d596e856371b14b9092f8a5dfbb62674ec9b3f67e4939a9d/rignore-0.7.6-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ce2268837c3600f82ab8db58f5834009dc638ee17103582960da668963bebc5", size = 899044, upload-time = "2025-11-05T20:40:55.336Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/2e1841a19b4dd23878d73edd5d82e998a83d5ed9570a89675f140ca8b2ad/rignore-0.7.6-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:690a3e1b54bfe77e89c4bacb13f046e642f8baadafc61d68f5a726f324a76ab6", size = 874144, upload-time = "2025-11-05T20:41:10.195Z" }, + { url = "https://files.pythonhosted.org/packages/c2/bf/0ce9beb2e5f64c30e3580bef09f5829236889f01511a125f98b83169b993/rignore-0.7.6-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:09d12ac7a0b6210c07bcd145007117ebd8abe99c8eeb383e9e4673910c2754b2", size = 1168062, upload-time = "2025-11-05T20:41:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/b9/8b/571c178414eb4014969865317da8a02ce4cf5241a41676ef91a59aab24de/rignore-0.7.6-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2a2b2b74a8c60203b08452479b90e5ce3dbe96a916214bc9eb2e5af0b6a9beb0", size = 942542, upload-time = "2025-11-05T20:41:41.838Z" }, + { url = "https://files.pythonhosted.org/packages/19/62/7a3cf601d5a45137a7e2b89d10c05b5b86499190c4b7ca5c3c47d79ee519/rignore-0.7.6-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8fc5a531ef02131e44359419a366bfac57f773ea58f5278c2cdd915f7d10ea94", size = 958739, upload-time = "2025-11-05T20:42:12.463Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1f/4261f6a0d7caf2058a5cde2f5045f565ab91aa7badc972b57d19ce58b14e/rignore-0.7.6-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b7a1f77d9c4cd7e76229e252614d963442686bfe12c787a49f4fe481df49e7a9", size = 984138, upload-time = "2025-11-05T20:41:56.775Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bf/628dfe19c75e8ce1f45f7c248f5148b17dfa89a817f8e3552ab74c3ae812/rignore-0.7.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ead81f728682ba72b5b1c3d5846b011d3e0174da978de87c61645f2ed36659a7", size = 1079299, upload-time = "2025-11-05T21:40:16.639Z" }, + { url = "https://files.pythonhosted.org/packages/af/a5/be29c50f5c0c25c637ed32db8758fdf5b901a99e08b608971cda8afb293b/rignore-0.7.6-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:12ffd50f520c22ffdabed8cd8bfb567d9ac165b2b854d3e679f4bcaef11a9441", size = 1139618, upload-time = "2025-11-05T21:40:34.507Z" }, + { url = "https://files.pythonhosted.org/packages/2a/40/3c46cd7ce4fa05c20b525fd60f599165e820af66e66f2c371cd50644558f/rignore-0.7.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e5a16890fbe3c894f8ca34b0fcacc2c200398d4d46ae654e03bc9b3dbf2a0a72", size = 1117626, upload-time = "2025-11-05T21:40:51.494Z" }, + { url = "https://files.pythonhosted.org/packages/8c/b9/aea926f263b8a29a23c75c2e0d8447965eb1879d3feb53cfcf84db67ed58/rignore-0.7.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3abab3bf99e8a77488ef6c7c9a799fac22224c28fe9f25cc21aa7cc2b72bfc0b", size = 1128144, upload-time = "2025-11-05T21:41:09.169Z" }, + { url = "https://files.pythonhosted.org/packages/a4/f6/0d6242f8d0df7f2ecbe91679fefc1f75e7cd2072cb4f497abaab3f0f8523/rignore-0.7.6-cp314-cp314-win32.whl", hash = "sha256:eeef421c1782953c4375aa32f06ecae470c1285c6381eee2a30d2e02a5633001", size = 646385, upload-time = "2025-11-05T21:41:55.105Z" }, + { url = "https://files.pythonhosted.org/packages/d5/38/c0dcd7b10064f084343d6af26fe9414e46e9619c5f3224b5272e8e5d9956/rignore-0.7.6-cp314-cp314-win_amd64.whl", hash = "sha256:6aeed503b3b3d5af939b21d72a82521701a4bd3b89cd761da1e7dc78621af304", size = 725738, upload-time = "2025-11-05T21:41:39.736Z" }, + { url = "https://files.pythonhosted.org/packages/d9/7a/290f868296c1ece914d565757ab363b04730a728b544beb567ceb3b2d96f/rignore-0.7.6-cp314-cp314-win_arm64.whl", hash = "sha256:104f215b60b3c984c386c3e747d6ab4376d5656478694e22c7bd2f788ddd8304", size = 656008, upload-time = "2025-11-05T21:41:29.028Z" }, + { url = "https://files.pythonhosted.org/packages/ca/d2/3c74e3cd81fe8ea08a8dcd2d755c09ac2e8ad8fe409508904557b58383d3/rignore-0.7.6-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:bb24a5b947656dd94cb9e41c4bc8b23cec0c435b58be0d74a874f63c259549e8", size = 882835, upload-time = "2025-11-05T20:42:45.443Z" }, + { url = "https://files.pythonhosted.org/packages/77/61/a772a34b6b63154877433ac2d048364815b24c2dd308f76b212c408101a2/rignore-0.7.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5b1e33c9501cefe24b70a1eafd9821acfd0ebf0b35c3a379430a14df089993e3", size = 820301, upload-time = "2025-11-05T20:42:29.226Z" }, + { url = "https://files.pythonhosted.org/packages/71/30/054880b09c0b1b61d17eeb15279d8bf729c0ba52b36c3ada52fb827cbb3c/rignore-0.7.6-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bec3994665a44454df86deb762061e05cd4b61e3772f5b07d1882a8a0d2748d5", size = 897611, upload-time = "2025-11-05T20:40:56.475Z" }, + { url = "https://files.pythonhosted.org/packages/1e/40/b2d1c169f833d69931bf232600eaa3c7998ba4f9a402e43a822dad2ea9f2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26cba2edfe3cff1dfa72bddf65d316ddebf182f011f2f61538705d6dbaf54986", size = 873875, upload-time = "2025-11-05T20:41:11.561Z" }, + { url = "https://files.pythonhosted.org/packages/55/59/ca5ae93d83a1a60e44b21d87deb48b177a8db1b85e82fc8a9abb24a8986d/rignore-0.7.6-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffa86694fec604c613696cb91e43892aa22e1fec5f9870e48f111c603e5ec4e9", size = 1167245, upload-time = "2025-11-05T20:41:28.29Z" }, + { url = "https://files.pythonhosted.org/packages/a5/52/cf3dce392ba2af806cba265aad6bcd9c48bb2a6cb5eee448d3319f6e505b/rignore-0.7.6-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48efe2ed95aa8104145004afb15cdfa02bea5cdde8b0344afeb0434f0d989aa2", size = 941750, upload-time = "2025-11-05T20:41:43.111Z" }, + { url = "https://files.pythonhosted.org/packages/ec/be/3f344c6218d779395e785091d05396dfd8b625f6aafbe502746fcd880af2/rignore-0.7.6-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dcae43eb44b7f2457fef7cc87f103f9a0013017a6f4e62182c565e924948f21", size = 958896, upload-time = "2025-11-05T20:42:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/c9/34/d3fa71938aed7d00dcad87f0f9bcb02ad66c85d6ffc83ba31078ce53646a/rignore-0.7.6-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2cd649a7091c0dad2f11ef65630d30c698d505cbe8660dd395268e7c099cc99f", size = 983992, upload-time = "2025-11-05T20:41:58.022Z" }, + { url = "https://files.pythonhosted.org/packages/24/a4/52a697158e9920705bdbd0748d59fa63e0f3233fb92e9df9a71afbead6ca/rignore-0.7.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:42de84b0289d478d30ceb7ae59023f7b0527786a9a5b490830e080f0e4ea5aeb", size = 1078181, upload-time = "2025-11-05T21:40:18.151Z" }, + { url = "https://files.pythonhosted.org/packages/ac/65/aa76dbcdabf3787a6f0fd61b5cc8ed1e88580590556d6c0207960d2384bb/rignore-0.7.6-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:875a617e57b53b4acbc5a91de418233849711c02e29cc1f4f9febb2f928af013", size = 1139232, upload-time = "2025-11-05T21:40:35.966Z" }, + { url = "https://files.pythonhosted.org/packages/08/44/31b31a49b3233c6842acc1c0731aa1e7fb322a7170612acf30327f700b44/rignore-0.7.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8703998902771e96e49968105207719f22926e4431b108450f3f430b4e268b7c", size = 1117349, upload-time = "2025-11-05T21:40:53.013Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ae/1b199a2302c19c658cf74e5ee1427605234e8c91787cfba0015f2ace145b/rignore-0.7.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:602ef33f3e1b04c1e9a10a3c03f8bc3cef2d2383dcc250d309be42b49923cabc", size = 1127702, upload-time = "2025-11-05T21:41:10.881Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d3/18210222b37e87e36357f7b300b7d98c6dd62b133771e71ae27acba83a4f/rignore-0.7.6-cp314-cp314t-win32.whl", hash = "sha256:c1d8f117f7da0a4a96a8daef3da75bc090e3792d30b8b12cfadc240c631353f9", size = 647033, upload-time = "2025-11-05T21:42:00.095Z" }, + { url = "https://files.pythonhosted.org/packages/3e/87/033eebfbee3ec7d92b3bb1717d8f68c88e6fc7de54537040f3b3a405726f/rignore-0.7.6-cp314-cp314t-win_amd64.whl", hash = "sha256:ca36e59408bec81de75d307c568c2d0d410fb880b1769be43611472c61e85c96", size = 725647, upload-time = "2025-11-05T21:41:44.449Z" }, + { url = "https://files.pythonhosted.org/packages/79/62/b88e5879512c55b8ee979c666ee6902adc4ed05007226de266410ae27965/rignore-0.7.6-cp314-cp314t-win_arm64.whl", hash = "sha256:b83adabeb3e8cf662cabe1931b83e165b88c526fa6af6b3aa90429686e474896", size = 656035, upload-time = "2025-11-05T21:41:31.13Z" }, +] + +[[package]] +name = "ruff" +version = "0.14.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/57/08/52232a877978dd8f9cf2aeddce3e611b40a63287dfca29b6b8da791f5e8d/ruff-0.14.10.tar.gz", hash = "sha256:9a2e830f075d1a42cd28420d7809ace390832a490ed0966fe373ba288e77aaf4", size = 5859763, upload-time = "2025-12-18T19:28:57.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/01/933704d69f3f05ee16ef11406b78881733c186fe14b6a46b05cfcaf6d3b2/ruff-0.14.10-py3-none-linux_armv6l.whl", hash = "sha256:7a3ce585f2ade3e1f29ec1b92df13e3da262178df8c8bdf876f48fa0e8316c49", size = 13527080, upload-time = "2025-12-18T19:29:25.642Z" }, + { url = "https://files.pythonhosted.org/packages/df/58/a0349197a7dfa603ffb7f5b0470391efa79ddc327c1e29c4851e85b09cc5/ruff-0.14.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:674f9be9372907f7257c51f1d4fc902cb7cf014b9980152b802794317941f08f", size = 13797320, upload-time = "2025-12-18T19:29:02.571Z" }, + { url = "https://files.pythonhosted.org/packages/7b/82/36be59f00a6082e38c23536df4e71cdbc6af8d7c707eade97fcad5c98235/ruff-0.14.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d85713d522348837ef9df8efca33ccb8bd6fcfc86a2cde3ccb4bc9d28a18003d", size = 12918434, upload-time = "2025-12-18T19:28:51.202Z" }, + { url = "https://files.pythonhosted.org/packages/a6/00/45c62a7f7e34da92a25804f813ebe05c88aa9e0c25e5cb5a7d23dd7450e3/ruff-0.14.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6987ebe0501ae4f4308d7d24e2d0fe3d7a98430f5adfd0f1fead050a740a3a77", size = 13371961, upload-time = "2025-12-18T19:29:04.991Z" }, + { url = "https://files.pythonhosted.org/packages/40/31/a5906d60f0405f7e57045a70f2d57084a93ca7425f22e1d66904769d1628/ruff-0.14.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:16a01dfb7b9e4eee556fbfd5392806b1b8550c9b4a9f6acd3dbe6812b193c70a", size = 13275629, upload-time = "2025-12-18T19:29:21.381Z" }, + { url = "https://files.pythonhosted.org/packages/3e/60/61c0087df21894cf9d928dc04bcd4fb10e8b2e8dca7b1a276ba2155b2002/ruff-0.14.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7165d31a925b7a294465fa81be8c12a0e9b60fb02bf177e79067c867e71f8b1f", size = 14029234, upload-time = "2025-12-18T19:29:00.132Z" }, + { url = "https://files.pythonhosted.org/packages/44/84/77d911bee3b92348b6e5dab5a0c898d87084ea03ac5dc708f46d88407def/ruff-0.14.10-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:c561695675b972effb0c0a45db233f2c816ff3da8dcfbe7dfc7eed625f218935", size = 15449890, upload-time = "2025-12-18T19:28:53.573Z" }, + { url = "https://files.pythonhosted.org/packages/e9/36/480206eaefa24a7ec321582dda580443a8f0671fdbf6b1c80e9c3e93a16a/ruff-0.14.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bb98fcbbc61725968893682fd4df8966a34611239c9fd07a1f6a07e7103d08e", size = 15123172, upload-time = "2025-12-18T19:29:23.453Z" }, + { url = "https://files.pythonhosted.org/packages/5c/38/68e414156015ba80cef5473d57919d27dfb62ec804b96180bafdeaf0e090/ruff-0.14.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f24b47993a9d8cb858429e97bdf8544c78029f09b520af615c1d261bf827001d", size = 14460260, upload-time = "2025-12-18T19:29:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/b3/19/9e050c0dca8aba824d67cc0db69fb459c28d8cd3f6855b1405b3f29cc91d/ruff-0.14.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59aabd2e2c4fd614d2862e7939c34a532c04f1084476d6833dddef4afab87e9f", size = 14229978, upload-time = "2025-12-18T19:29:11.32Z" }, + { url = "https://files.pythonhosted.org/packages/51/eb/e8dd1dd6e05b9e695aa9dd420f4577debdd0f87a5ff2fedda33c09e9be8c/ruff-0.14.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:213db2b2e44be8625002dbea33bb9c60c66ea2c07c084a00d55732689d697a7f", size = 14338036, upload-time = "2025-12-18T19:29:09.184Z" }, + { url = "https://files.pythonhosted.org/packages/6a/12/f3e3a505db7c19303b70af370d137795fcfec136d670d5de5391e295c134/ruff-0.14.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b914c40ab64865a17a9a5b67911d14df72346a634527240039eb3bd650e5979d", size = 13264051, upload-time = "2025-12-18T19:29:13.431Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/8c3a47eaccfef8ac20e0484e68e0772013eb85802f8a9f7603ca751eb166/ruff-0.14.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:1484983559f026788e3a5c07c81ef7d1e97c1c78ed03041a18f75df104c45405", size = 13283998, upload-time = "2025-12-18T19:29:06.994Z" }, + { url = "https://files.pythonhosted.org/packages/12/84/534a5506f4074e5cc0529e5cd96cfc01bb480e460c7edf5af70d2bcae55e/ruff-0.14.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c70427132db492d25f982fffc8d6c7535cc2fd2c83fc8888f05caaa248521e60", size = 13601891, upload-time = "2025-12-18T19:28:55.811Z" }, + { url = "https://files.pythonhosted.org/packages/0d/1e/14c916087d8598917dbad9b2921d340f7884824ad6e9c55de948a93b106d/ruff-0.14.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5bcf45b681e9f1ee6445d317ce1fa9d6cba9a6049542d1c3d5b5958986be8830", size = 14336660, upload-time = "2025-12-18T19:29:16.531Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1c/d7b67ab43f30013b47c12b42d1acd354c195351a3f7a1d67f59e54227ede/ruff-0.14.10-py3-none-win32.whl", hash = "sha256:104c49fc7ab73f3f3a758039adea978869a918f31b73280db175b43a2d9b51d6", size = 13196187, upload-time = "2025-12-18T19:29:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/896c862e13886fae2af961bef3e6312db9ebc6adc2b156fe95e615dee8c1/ruff-0.14.10-py3-none-win_amd64.whl", hash = "sha256:466297bd73638c6bdf06485683e812db1c00c7ac96d4ddd0294a338c62fdc154", size = 14661283, upload-time = "2025-12-18T19:29:30.16Z" }, + { url = "https://files.pythonhosted.org/packages/74/31/b0e29d572670dca3674eeee78e418f20bdf97fa8aa9ea71380885e175ca0/ruff-0.14.10-py3-none-win_arm64.whl", hash = "sha256:e51d046cf6dda98a4633b8a8a771451107413b0f07183b2bef03f075599e44e6", size = 13729839, upload-time = "2025-12-18T19:28:48.636Z" }, +] + +[[package]] +name = "sentry-sdk" +version = "2.48.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/40/f0/0e9dc590513d5e742d7799e2038df3a05167cba084c6ca4f3cdd75b55164/sentry_sdk-2.48.0.tar.gz", hash = "sha256:5213190977ff7fdff8a58b722fb807f8d5524a80488626ebeda1b5676c0c1473", size = 384828, upload-time = "2025-12-16T14:55:41.722Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/19/8d77f9992e5cbfcaa9133c3bf63b4fbbb051248802e1e803fed5c552fbb2/sentry_sdk-2.48.0-py2.py3-none-any.whl", hash = "sha256:6b12ac256769d41825d9b7518444e57fa35b5642df4c7c5e322af4d2c8721172", size = 414555, upload-time = "2025-12-16T14:55:40.152Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "slowapi" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "limits" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a0/99/adfc7f94ca024736f061257d39118e1542bade7a52e86415a4c4ae92d8ff/slowapi-0.1.9.tar.gz", hash = "sha256:639192d0f1ca01b1c6d95bf6c71d794c3a9ee189855337b4821f7f457dddad77", size = 14028, upload-time = "2024-02-05T12:11:52.13Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/bb/f71c4b7d7e7eb3fc1e8c0458a8979b912f40b58002b9fbf37729b8cb464b/slowapi-0.1.9-py3-none-any.whl", hash = "sha256:cfad116cfb84ad9d763ee155c1e5c5cbf00b0d47399a769b227865f5df576e36", size = 14670, upload-time = "2024-02-05T12:11:50.898Z" }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.45" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/f9/5e4491e5ccf42f5d9cfc663741d261b3e6e1683ae7812114e7636409fcc6/sqlalchemy-2.0.45.tar.gz", hash = "sha256:1632a4bda8d2d25703fdad6363058d882541bdaaee0e5e3ddfa0cd3229efce88", size = 9869912, upload-time = "2025-12-09T21:05:16.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/c7/1900b56ce19bff1c26f39a4ce427faec7716c81ac792bfac8b6a9f3dca93/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3ee2aac15169fb0d45822983631466d60b762085bc4535cd39e66bea362df5f", size = 3333760, upload-time = "2025-12-09T22:11:02.66Z" }, + { url = "https://files.pythonhosted.org/packages/0a/93/3be94d96bb442d0d9a60e55a6bb6e0958dd3457751c6f8502e56ef95fed0/sqlalchemy-2.0.45-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba547ac0b361ab4f1608afbc8432db669bd0819b3e12e29fb5fa9529a8bba81d", size = 3348268, upload-time = "2025-12-09T22:13:49.054Z" }, + { url = "https://files.pythonhosted.org/packages/48/4b/f88ded696e61513595e4a9778f9d3f2bf7332cce4eb0c7cedaabddd6687b/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:215f0528b914e5c75ef2559f69dca86878a3beeb0c1be7279d77f18e8d180ed4", size = 3278144, upload-time = "2025-12-09T22:11:04.14Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6a/310ecb5657221f3e1bd5288ed83aa554923fb5da48d760a9f7622afeb065/sqlalchemy-2.0.45-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:107029bf4f43d076d4011f1afb74f7c3e2ea029ec82eb23d8527d5e909e97aa6", size = 3313907, upload-time = "2025-12-09T22:13:50.598Z" }, + { url = "https://files.pythonhosted.org/packages/5c/39/69c0b4051079addd57c84a5bfb34920d87456dd4c90cf7ee0df6efafc8ff/sqlalchemy-2.0.45-cp312-cp312-win32.whl", hash = "sha256:0c9f6ada57b58420a2c0277ff853abe40b9e9449f8d7d231763c6bc30f5c4953", size = 2112182, upload-time = "2025-12-09T21:39:30.824Z" }, + { url = "https://files.pythonhosted.org/packages/f7/4e/510db49dd89fc3a6e994bee51848c94c48c4a00dc905e8d0133c251f41a7/sqlalchemy-2.0.45-cp312-cp312-win_amd64.whl", hash = "sha256:8defe5737c6d2179c7997242d6473587c3beb52e557f5ef0187277009f73e5e1", size = 2139200, upload-time = "2025-12-09T21:39:32.321Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c8/7cc5221b47a54edc72a0140a1efa56e0a2730eefa4058d7ed0b4c4357ff8/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe187fc31a54d7fd90352f34e8c008cf3ad5d064d08fedd3de2e8df83eb4a1cf", size = 3277082, upload-time = "2025-12-09T22:11:06.167Z" }, + { url = "https://files.pythonhosted.org/packages/0e/50/80a8d080ac7d3d321e5e5d420c9a522b0aa770ec7013ea91f9a8b7d36e4a/sqlalchemy-2.0.45-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:672c45cae53ba88e0dad74b9027dddd09ef6f441e927786b05bec75d949fbb2e", size = 3293131, upload-time = "2025-12-09T22:13:52.626Z" }, + { url = "https://files.pythonhosted.org/packages/da/4c/13dab31266fc9904f7609a5dc308a2432a066141d65b857760c3bef97e69/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:470daea2c1ce73910f08caf10575676a37159a6d16c4da33d0033546bddebc9b", size = 3225389, upload-time = "2025-12-09T22:11:08.093Z" }, + { url = "https://files.pythonhosted.org/packages/74/04/891b5c2e9f83589de202e7abaf24cd4e4fa59e1837d64d528829ad6cc107/sqlalchemy-2.0.45-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9c6378449e0940476577047150fd09e242529b761dc887c9808a9a937fe990c8", size = 3266054, upload-time = "2025-12-09T22:13:54.262Z" }, + { url = "https://files.pythonhosted.org/packages/f1/24/fc59e7f71b0948cdd4cff7a286210e86b0443ef1d18a23b0d83b87e4b1f7/sqlalchemy-2.0.45-cp313-cp313-win32.whl", hash = "sha256:4b6bec67ca45bc166c8729910bd2a87f1c0407ee955df110d78948f5b5827e8a", size = 2110299, upload-time = "2025-12-09T21:39:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c5/d17113020b2d43073412aeca09b60d2009442420372123b8d49cc253f8b8/sqlalchemy-2.0.45-cp313-cp313-win_amd64.whl", hash = "sha256:afbf47dc4de31fa38fd491f3705cac5307d21d4bb828a4f020ee59af412744ee", size = 2136264, upload-time = "2025-12-09T21:39:36.801Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8d/bb40a5d10e7a5f2195f235c0b2f2c79b0bf6e8f00c0c223130a4fbd2db09/sqlalchemy-2.0.45-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:83d7009f40ce619d483d26ac1b757dfe3167b39921379a8bd1b596cf02dab4a6", size = 3521998, upload-time = "2025-12-09T22:13:28.622Z" }, + { url = "https://files.pythonhosted.org/packages/75/a5/346128b0464886f036c039ea287b7332a410aa2d3fb0bb5d404cb8861635/sqlalchemy-2.0.45-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d8a2ca754e5415cde2b656c27900b19d50ba076aa05ce66e2207623d3fe41f5a", size = 3473434, upload-time = "2025-12-09T22:13:30.188Z" }, + { url = "https://files.pythonhosted.org/packages/cc/64/4e1913772646b060b025d3fc52ce91a58967fe58957df32b455de5a12b4f/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f46ec744e7f51275582e6a24326e10c49fbdd3fc99103e01376841213028774", size = 3272404, upload-time = "2025-12-09T22:11:09.662Z" }, + { url = "https://files.pythonhosted.org/packages/b3/27/caf606ee924282fe4747ee4fd454b335a72a6e018f97eab5ff7f28199e16/sqlalchemy-2.0.45-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:883c600c345123c033c2f6caca18def08f1f7f4c3ebeb591a63b6fceffc95cce", size = 3277057, upload-time = "2025-12-09T22:13:56.213Z" }, + { url = "https://files.pythonhosted.org/packages/85/d0/3d64218c9724e91f3d1574d12eb7ff8f19f937643815d8daf792046d88ab/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2c0b74aa79e2deade948fe8593654c8ef4228c44ba862bb7c9585c8e0db90f33", size = 3222279, upload-time = "2025-12-09T22:11:11.1Z" }, + { url = "https://files.pythonhosted.org/packages/24/10/dd7688a81c5bc7690c2a3764d55a238c524cd1a5a19487928844cb247695/sqlalchemy-2.0.45-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a420169cef179d4c9064365f42d779f1e5895ad26ca0c8b4c0233920973db74", size = 3244508, upload-time = "2025-12-09T22:13:57.932Z" }, + { url = "https://files.pythonhosted.org/packages/aa/41/db75756ca49f777e029968d9c9fee338c7907c563267740c6d310a8e3f60/sqlalchemy-2.0.45-cp314-cp314-win32.whl", hash = "sha256:e50dcb81a5dfe4b7b4a4aa8f338116d127cb209559124f3694c70d6cd072b68f", size = 2113204, upload-time = "2025-12-09T21:39:38.365Z" }, + { url = "https://files.pythonhosted.org/packages/89/a2/0e1590e9adb292b1d576dbcf67ff7df8cf55e56e78d2c927686d01080f4b/sqlalchemy-2.0.45-cp314-cp314-win_amd64.whl", hash = "sha256:4748601c8ea959e37e03d13dcda4a44837afcd1b21338e637f7c935b8da06177", size = 2138785, upload-time = "2025-12-09T21:39:39.503Z" }, + { url = "https://files.pythonhosted.org/packages/42/39/f05f0ed54d451156bbed0e23eb0516bcad7cbb9f18b3bf219c786371b3f0/sqlalchemy-2.0.45-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd337d3526ec5298f67d6a30bbbe4ed7e5e68862f0bf6dd21d289f8d37b7d60b", size = 3522029, upload-time = "2025-12-09T22:13:32.09Z" }, + { url = "https://files.pythonhosted.org/packages/54/0f/d15398b98b65c2bce288d5ee3f7d0a81f77ab89d9456994d5c7cc8b2a9db/sqlalchemy-2.0.45-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9a62b446b7d86a3909abbcd1cd3cc550a832f99c2bc37c5b22e1925438b9367b", size = 3475142, upload-time = "2025-12-09T22:13:33.739Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e1/3ccb13c643399d22289c6a9786c1a91e3dcbb68bce4beb44926ac2c557bf/sqlalchemy-2.0.45-py3-none-any.whl", hash = "sha256:5225a288e4c8cc2308dbdd874edad6e7d0fd38eac1e9e5f23503425c8eee20d0", size = 1936672, upload-time = "2025-12-09T21:54:52.608Z" }, +] + +[[package]] +name = "starlette" +version = "0.50.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" }, +] + +[[package]] +name = "structlog" +version = "25.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/52/9ba0f43b686e7f3ddfeaa78ac3af750292662284b3661e91ad5494f21dbc/structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98", size = 1460830, upload-time = "2025-10-27T08:28:23.028Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/45/a132b9074aa18e799b891b91ad72133c98d8042c70f6240e4c5f9dabee2f/structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f", size = 72510, upload-time = "2025-10-27T08:28:21.535Z" }, +] + +[[package]] +name = "tomlkit" +version = "0.13.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/18/0bbf3884e9eaa38819ebe46a7bd25dcd56b67434402b66a58c4b8e552575/tomlkit-0.13.3.tar.gz", hash = "sha256:430cf247ee57df2b94ee3fbe588e71d362a941ebb545dec29b53961d61add2a1", size = 185207, upload-time = "2025-06-05T07:13:44.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/75/8539d011f6be8e29f339c42e633aae3cb73bffa95dd0f9adec09b9c58e85/tomlkit-0.13.3-py3-none-any.whl", hash = "sha256:c89c649d79ee40629a9fda55f8ace8c6a1b42deb912b2a8fd8d942ddadb606b0", size = 38901, upload-time = "2025-06-05T07:13:43.546Z" }, +] + +[[package]] +name = "ty" +version = "0.0.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/7b/4f677c622d58563c593c32081f8a8572afd90e43dc15b0dedd27b4305038/ty-0.0.9.tar.gz", hash = "sha256:83f980c46df17586953ab3060542915827b43c4748a59eea04190c59162957fe", size = 4858642, upload-time = "2026-01-05T12:24:56.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/3f/c1ee119738b401a8081ff84341781122296b66982e5982e6f162d946a1ff/ty-0.0.9-py3-none-linux_armv6l.whl", hash = "sha256:dd270d4dd6ebeb0abb37aee96cbf9618610723677f500fec1ba58f35bfa8337d", size = 9763596, upload-time = "2026-01-05T12:24:37.43Z" }, + { url = "https://files.pythonhosted.org/packages/63/41/6b0669ef4cd806d4bd5c30263e6b732a362278abac1bc3a363a316cde896/ty-0.0.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:debfb2ba418b00e86ffd5403cb666b3f04e16853f070439517dd1eaaeeff9255", size = 9591514, upload-time = "2026-01-05T12:24:26.891Z" }, + { url = "https://files.pythonhosted.org/packages/02/a1/874aa756aee5118e690340a771fb9ded0d0c2168c0b7cc7d9561c2a750b0/ty-0.0.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:107c76ebb05a13cdb669172956421f7ffd289ad98f36d42a44a465588d434d58", size = 9097773, upload-time = "2026-01-05T12:24:14.442Z" }, + { url = "https://files.pythonhosted.org/packages/32/62/cb9a460cf03baab77b3361d13106b93b40c98e274d07c55f333ce3c716f6/ty-0.0.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6868ca5c87ca0caa1b3cb84603c767356242b0659b88307eda69b2fb0bfa416b", size = 9581824, upload-time = "2026-01-05T12:24:35.074Z" }, + { url = "https://files.pythonhosted.org/packages/5a/97/633ecb348c75c954f09f8913669de8c440b13b43ea7d214503f3f1c4bb60/ty-0.0.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14a4aa0eb5c1d3591c2adbdda4e44429a6bb5d2e298a704398bb2a7ccdafdfe", size = 9591050, upload-time = "2026-01-05T12:24:08.804Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/4b0c6a7a8a234e2113f88c80cc7aaa9af5868de7a693859f3c49da981934/ty-0.0.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:01bd4466504cefa36b465c6608e9af4504415fa67f6affc01c7d6ce36663c7f4", size = 10018262, upload-time = "2026-01-05T12:24:53.791Z" }, + { url = "https://files.pythonhosted.org/packages/cb/97/076d72a028f6b31e0b87287aa27c5b71a2f9927ee525260ea9f2f56828b8/ty-0.0.9-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:76c8253d1b30bc2c3eaa1b1411a1c34423decde0f4de0277aa6a5ceacfea93d9", size = 10911642, upload-time = "2026-01-05T12:24:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/705d6a5ed07ea36b1f23592c3f0dbc8fc7649267bfbb3bf06464cdc9a98a/ty-0.0.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8992fa4a9c6a5434eae4159fdd4842ec8726259bfd860e143ab95d078de6f8e3", size = 10632468, upload-time = "2026-01-05T12:24:24.118Z" }, + { url = "https://files.pythonhosted.org/packages/44/78/4339a254537488d62bf392a936b3ec047702c0cc33d6ce3a5d613f275cd0/ty-0.0.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c79d503d151acb4a145a3d98702d07cb641c47292f63e5ffa0151e4020a5d33", size = 10273422, upload-time = "2026-01-05T12:24:45.8Z" }, + { url = "https://files.pythonhosted.org/packages/90/40/e7f386e87c9abd3670dcee8311674d7e551baa23b2e4754e2405976e6c92/ty-0.0.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b7a7ebf89ed276b564baa1f0dd9cd708e7b5aa89f19ce1b2f7d7132075abf93e", size = 10120289, upload-time = "2026-01-05T12:24:17.424Z" }, + { url = "https://files.pythonhosted.org/packages/f7/46/1027442596e725c50d0d1ab5179e9fa78a398ab412994b3006d0ee0899c7/ty-0.0.9-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:ae3866e50109d2400a886bb11d9ef607f23afc020b226af773615cf82ae61141", size = 9566657, upload-time = "2026-01-05T12:24:51.048Z" }, + { url = "https://files.pythonhosted.org/packages/56/be/df921cf1967226aa01690152002b370a7135c6cced81e86c12b86552cdc4/ty-0.0.9-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:185244a5eacfcd8f5e2d85b95e4276316772f1e586520a6cb24aa072ec1bac26", size = 9610334, upload-time = "2026-01-05T12:24:20.334Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e8/f085268860232cc92ebe95415e5c8640f7f1797ac3a49ddd137c6222924d/ty-0.0.9-py3-none-musllinux_1_2_i686.whl", hash = "sha256:f834ff27d940edb24b2e86bbb3fb45ab9e07cf59ca8c5ac615095b2542786408", size = 9726701, upload-time = "2026-01-05T12:24:29.785Z" }, + { url = "https://files.pythonhosted.org/packages/42/b4/9394210c66041cd221442e38f68a596945103d9446ece505889ffa9b3da9/ty-0.0.9-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:773f4b3ba046de952d7c1ad3a2c09b24f3ed4bc8342ae3cbff62ebc14aa6d48c", size = 10227082, upload-time = "2026-01-05T12:24:40.132Z" }, + { url = "https://files.pythonhosted.org/packages/dc/9f/75951eb573b473d35dd9570546fc1319f7ca2d5b5c50a5825ba6ea6cb33a/ty-0.0.9-py3-none-win32.whl", hash = "sha256:1f20f67e373038ff20f36d5449e787c0430a072b92d5933c5b6e6fc79d3de4c8", size = 9176458, upload-time = "2026-01-05T12:24:32.559Z" }, + { url = "https://files.pythonhosted.org/packages/9b/80/b1cdf71ac874e72678161e25e2326a7d30bc3489cd3699561355a168e54f/ty-0.0.9-py3-none-win_amd64.whl", hash = "sha256:2c415f3bbb730f8de2e6e0b3c42eb3a91f1b5fbbcaaead2e113056c3b361c53c", size = 10040479, upload-time = "2026-01-05T12:24:42.697Z" }, + { url = "https://files.pythonhosted.org/packages/b5/8f/abc75c4bb774b12698629f02d0d12501b0a7dff9c31dc3bd6b6c6467e90a/ty-0.0.9-py3-none-win_arm64.whl", hash = "sha256:48e339d794542afeed710ea4f846ead865cc38cecc335a9c781804d02eaa2722", size = 9543127, upload-time = "2026-01-05T12:24:11.731Z" }, +] + +[[package]] +name = "typer" +version = "0.21.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "rich" }, + { name = "shellingham" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/30/ff9ede605e3bd086b4dd842499814e128500621f7951ca1e5ce84bbf61b1/typer-0.21.0.tar.gz", hash = "sha256:c87c0d2b6eee3b49c5c64649ec92425492c14488096dfbc8a0c2799b2f6f9c53", size = 106781, upload-time = "2025-12-25T09:54:53.651Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/e4/5ebc1899d31d2b1601b32d21cfb4bba022ae6fce323d365f0448031b1660/typer-0.21.0-py3-none-any.whl", hash = "sha256:c79c01ca6b30af9fd48284058a7056ba0d3bf5cf10d0ff3d0c5b11b68c258ac6", size = 47109, upload-time = "2025-12-25T09:54:51.918Z" }, +] + +[[package]] +name = "types-cffi" +version = "1.17.0.20250915" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "types-setuptools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2a/98/ea454cea03e5f351323af6a482c65924f3c26c515efd9090dede58f2b4b6/types_cffi-1.17.0.20250915.tar.gz", hash = "sha256:4362e20368f78dabd5c56bca8004752cc890e07a71605d9e0d9e069dbaac8c06", size = 17229, upload-time = "2025-09-15T03:01:25.31Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/aa/ec/092f2b74b49ec4855cdb53050deb9699f7105b8fda6fe034c0781b8687f3/types_cffi-1.17.0.20250915-py3-none-any.whl", hash = "sha256:cef4af1116c83359c11bb4269283c50f0688e9fc1d7f0eeb390f3661546da52c", size = 20112, upload-time = "2025-09-15T03:01:24.187Z" }, +] + +[[package]] +name = "types-pyopenssl" +version = "24.1.0.20240722" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "types-cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/93/29/47a346550fd2020dac9a7a6d033ea03fccb92fa47c726056618cc889745e/types-pyOpenSSL-24.1.0.20240722.tar.gz", hash = "sha256:47913b4678a01d879f503a12044468221ed8576263c1540dcb0484ca21b08c39", size = 8458, upload-time = "2024-07-22T02:32:22.558Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/98/05/c868a850b6fbb79c26f5f299b768ee0adc1f9816d3461dcf4287916f655b/types_pyOpenSSL-24.1.0.20240722-py3-none-any.whl", hash = "sha256:6a7a5d2ec042537934cfb4c9d4deb0e16c4c6250b09358df1f083682fe6fda54", size = 7499, upload-time = "2024-07-22T02:32:21.232Z" }, +] + +[[package]] +name = "types-redis" +version = "4.6.0.20241004" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "types-pyopenssl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/95/c054d3ac940e8bac4ca216470c80c26688a0e79e09f520a942bb27da3386/types-redis-4.6.0.20241004.tar.gz", hash = "sha256:5f17d2b3f9091ab75384153bfa276619ffa1cf6a38da60e10d5e6749cc5b902e", size = 49679, upload-time = "2024-10-04T02:43:59.224Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/82/7d25dce10aad92d2226b269bce2f85cfd843b4477cd50245d7d40ecf8f89/types_redis-4.6.0.20241004-py3-none-any.whl", hash = "sha256:ef5da68cb827e5f606c8f9c0b49eeee4c2669d6d97122f301d3a55dc6a63f6ed", size = 58737, upload-time = "2024-10-04T02:43:57.968Z" }, +] + +[[package]] +name = "types-setuptools" +version = "80.9.0.20251223" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/00/07/d1b605230730990de20477150191d6dccf6aecc037da94c9960a5d563bc8/types_setuptools-80.9.0.20251223.tar.gz", hash = "sha256:d3411059ae2f5f03985217d86ac6084efea2c9e9cacd5f0869ef950f308169b2", size = 42420, upload-time = "2025-12-23T03:18:26.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/5c/b8877da94012dbc6643e4eeca22bca9b99b295be05d161f8a403ae9387c0/types_setuptools-80.9.0.20251223-py3-none-any.whl", hash = "sha256:1b36db79d724c2287d83dc052cf887b47c0da6a2fff044378be0b019545f56e6", size = 64318, upload-time = "2025-12-23T03:18:25.868Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "urllib3" +version = "2.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/24/a2a2ed9addd907787d7aa0355ba36a6cadf1768b934c652ea78acbd59dcd/urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797", size = 432930, upload-time = "2025-12-11T15:56:40.252Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/b9/4095b668ea3678bf6a0af005527f39de12fb026516fb3df17495a733b7f8/urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd", size = 131182, upload-time = "2025-12-11T15:56:38.584Z" }, +] + +[[package]] +name = "uuid6" +version = "2025.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/b7/4c0f736ca824b3a25b15e8213d1bcfc15f8ac2ae48d1b445b310892dc4da/uuid6-2025.0.1.tar.gz", hash = "sha256:cd0af94fa428675a44e32c5319ec5a3485225ba2179eefcf4c3f205ae30a81bd", size = 13932, upload-time = "2025-07-04T18:30:35.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/b2/93faaab7962e2aa8d6e174afb6f76be2ca0ce89fde14d3af835acebcaa59/uuid6-2025.0.1-py3-none-any.whl", hash = "sha256:80530ce4d02a93cdf82e7122ca0da3ebbbc269790ec1cb902481fa3e9cc9ff99", size = 6979, upload-time = "2025-07-04T18:30:34.001Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.40.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c3/d1/8f3c683c9561a4e6689dd3b1d345c815f10f86acd044ee1fb9a4dcd0b8c5/uvicorn-0.40.0.tar.gz", hash = "sha256:839676675e87e73694518b5574fd0f24c9d97b46bea16df7b8c05ea1a51071ea", size = 81761, upload-time = "2025-12-21T14:16:22.45Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/d8/2083a1daa7439a66f3a48589a57d576aa117726762618f6bb09fe3798796/uvicorn-0.40.0-py3-none-any.whl", hash = "sha256:c6c8f55bc8bf13eb6fa9ff87ad62308bbbc33d0b67f84293151efe87e0d5f2ee", size = 68502, upload-time = "2025-12-21T14:16:21.041Z" }, +] + +[package.optional-dependencies] +standard = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "httptools" }, + { name = "python-dotenv" }, + { name = "pyyaml" }, + { name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "uvloop" +version = "0.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" }, + { url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" }, + { url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" }, + { url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" }, + { url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" }, + { url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" }, + { url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" }, + { url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" }, + { url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" }, + { url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" }, + { url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" }, + { url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" }, + { url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" }, + { url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" }, + { url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" }, + { url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" }, + { url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" }, + { url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" }, + { url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" }, +] + +[[package]] +name = "virtualenv" +version = "20.35.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "distlib" }, + { name = "filelock" }, + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" }, + { url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" }, + { url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" }, + { url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" }, + { url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" }, + { url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" }, + { url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" }, + { url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" }, + { url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" }, + { url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" }, + { url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" }, + { url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" }, + { url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" }, + { url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" }, + { url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" }, + { url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" }, + { url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" }, + { url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" }, + { url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" }, + { url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" }, + { url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" }, + { url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" }, + { url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" }, + { url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" }, + { url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" }, + { url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" }, + { url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" }, + { url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" }, + { url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" }, + { url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" }, + { url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" }, + { url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" }, + { url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" }, + { url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" }, + { url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" }, + { url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" }, + { url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" }, + { url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" }, + { url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" }, +] + +[[package]] +name = "websockets" +version = "15.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, + { url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, + { url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, + { url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, + { url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, + { url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, + { url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, + { url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, + { url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, + { url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, + { url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, + { url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, + { url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, + { url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, + { url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, + { url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, + { url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, + { url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +] + +[[package]] +name = "wrapt" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2a/6de8a50cb435b7f42c46126cf1a54b2aab81784e74c8595c8e025e8f36d3/wrapt-2.0.1.tar.gz", hash = "sha256:9c9c635e78497cacb81e84f8b11b23e0aacac7a136e73b8e5b2109a1d9fc468f", size = 82040, upload-time = "2025-11-07T00:45:33.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/73/8cb252858dc8254baa0ce58ce382858e3a1cf616acebc497cb13374c95c6/wrapt-2.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1fdbb34da15450f2b1d735a0e969c24bdb8d8924892380126e2a293d9902078c", size = 78129, upload-time = "2025-11-07T00:43:48.852Z" }, + { url = "https://files.pythonhosted.org/packages/19/42/44a0db2108526ee6e17a5ab72478061158f34b08b793df251d9fbb9a7eb4/wrapt-2.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d32794fe940b7000f0519904e247f902f0149edbe6316c710a8562fb6738841", size = 61205, upload-time = "2025-11-07T00:43:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/4d/8a/5b4b1e44b791c22046e90d9b175f9a7581a8cc7a0debbb930f81e6ae8e25/wrapt-2.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:386fb54d9cd903ee0012c09291336469eb7b244f7183d40dc3e86a16a4bace62", size = 61692, upload-time = "2025-11-07T00:43:51.678Z" }, + { url = "https://files.pythonhosted.org/packages/11/53/3e794346c39f462bcf1f58ac0487ff9bdad02f9b6d5ee2dc84c72e0243b2/wrapt-2.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7b219cb2182f230676308cdcacd428fa837987b89e4b7c5c9025088b8a6c9faf", size = 121492, upload-time = "2025-11-07T00:43:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/c6/7e/10b7b0e8841e684c8ca76b462a9091c45d62e8f2de9c4b1390b690eadf16/wrapt-2.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:641e94e789b5f6b4822bb8d8ebbdfc10f4e4eae7756d648b717d980f657a9eb9", size = 123064, upload-time = "2025-11-07T00:43:56.323Z" }, + { url = "https://files.pythonhosted.org/packages/0e/d1/3c1e4321fc2f5ee7fd866b2d822aa89b84495f28676fd976c47327c5b6aa/wrapt-2.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe21b118b9f58859b5ebaa4b130dee18669df4bd111daad082b7beb8799ad16b", size = 117403, upload-time = "2025-11-07T00:43:53.258Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b0/d2f0a413cf201c8c2466de08414a15420a25aa83f53e647b7255cc2fab5d/wrapt-2.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:17fb85fa4abc26a5184d93b3efd2dcc14deb4b09edcdb3535a536ad34f0b4dba", size = 121500, upload-time = "2025-11-07T00:43:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/bd/45/bddb11d28ca39970a41ed48a26d210505120f925918592283369219f83cc/wrapt-2.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b89ef9223d665ab255ae42cc282d27d69704d94be0deffc8b9d919179a609684", size = 116299, upload-time = "2025-11-07T00:43:58.877Z" }, + { url = "https://files.pythonhosted.org/packages/81/af/34ba6dd570ef7a534e7eec0c25e2615c355602c52aba59413411c025a0cb/wrapt-2.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a453257f19c31b31ba593c30d997d6e5be39e3b5ad9148c2af5a7314061c63eb", size = 120622, upload-time = "2025-11-07T00:43:59.962Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/693a13b4146646fb03254636f8bafd20c621955d27d65b15de07ab886187/wrapt-2.0.1-cp312-cp312-win32.whl", hash = "sha256:3e271346f01e9c8b1130a6a3b0e11908049fe5be2d365a5f402778049147e7e9", size = 58246, upload-time = "2025-11-07T00:44:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/a7/36/715ec5076f925a6be95f37917b66ebbeaa1372d1862c2ccd7a751574b068/wrapt-2.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2da620b31a90cdefa9cd0c2b661882329e2e19d1d7b9b920189956b76c564d75", size = 60492, upload-time = "2025-11-07T00:44:01.027Z" }, + { url = "https://files.pythonhosted.org/packages/ef/3e/62451cd7d80f65cc125f2b426b25fbb6c514bf6f7011a0c3904fc8c8df90/wrapt-2.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:aea9c7224c302bc8bfc892b908537f56c430802560e827b75ecbde81b604598b", size = 58987, upload-time = "2025-11-07T00:44:02.095Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/41af4c46b5e498c90fc87981ab2972fbd9f0bccda597adb99d3d3441b94b/wrapt-2.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:47b0f8bafe90f7736151f61482c583c86b0693d80f075a58701dd1549b0010a9", size = 78132, upload-time = "2025-11-07T00:44:04.628Z" }, + { url = "https://files.pythonhosted.org/packages/1c/92/d68895a984a5ebbbfb175512b0c0aad872354a4a2484fbd5552e9f275316/wrapt-2.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cbeb0971e13b4bd81d34169ed57a6dda017328d1a22b62fda45e1d21dd06148f", size = 61211, upload-time = "2025-11-07T00:44:05.626Z" }, + { url = "https://files.pythonhosted.org/packages/e8/26/ba83dc5ae7cf5aa2b02364a3d9cf74374b86169906a1f3ade9a2d03cf21c/wrapt-2.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb7cffe572ad0a141a7886a1d2efa5bef0bf7fe021deeea76b3ab334d2c38218", size = 61689, upload-time = "2025-11-07T00:44:06.719Z" }, + { url = "https://files.pythonhosted.org/packages/cf/67/d7a7c276d874e5d26738c22444d466a3a64ed541f6ef35f740dbd865bab4/wrapt-2.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c8d60527d1ecfc131426b10d93ab5d53e08a09c5fa0175f6b21b3252080c70a9", size = 121502, upload-time = "2025-11-07T00:44:09.557Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6b/806dbf6dd9579556aab22fc92908a876636e250f063f71548a8660382184/wrapt-2.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c654eafb01afac55246053d67a4b9a984a3567c3808bb7df2f8de1c1caba2e1c", size = 123110, upload-time = "2025-11-07T00:44:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/e5/08/cdbb965fbe4c02c5233d185d070cabed2ecc1f1e47662854f95d77613f57/wrapt-2.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:98d873ed6c8b4ee2418f7afce666751854d6d03e3c0ec2a399bb039cd2ae89db", size = 117434, upload-time = "2025-11-07T00:44:08.138Z" }, + { url = "https://files.pythonhosted.org/packages/2d/d1/6aae2ce39db4cb5216302fa2e9577ad74424dfbe315bd6669725569e048c/wrapt-2.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9e850f5b7fc67af856ff054c71690d54fa940c3ef74209ad9f935b4f66a0233", size = 121533, upload-time = "2025-11-07T00:44:12.142Z" }, + { url = "https://files.pythonhosted.org/packages/79/35/565abf57559fbe0a9155c29879ff43ce8bd28d2ca61033a3a3dd67b70794/wrapt-2.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e505629359cb5f751e16e30cf3f91a1d3ddb4552480c205947da415d597f7ac2", size = 116324, upload-time = "2025-11-07T00:44:13.28Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e0/53ff5e76587822ee33e560ad55876d858e384158272cd9947abdd4ad42ca/wrapt-2.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2879af909312d0baf35f08edeea918ee3af7ab57c37fe47cb6a373c9f2749c7b", size = 120627, upload-time = "2025-11-07T00:44:14.431Z" }, + { url = "https://files.pythonhosted.org/packages/7c/7b/38df30fd629fbd7612c407643c63e80e1c60bcc982e30ceeae163a9800e7/wrapt-2.0.1-cp313-cp313-win32.whl", hash = "sha256:d67956c676be5a24102c7407a71f4126d30de2a569a1c7871c9f3cabc94225d7", size = 58252, upload-time = "2025-11-07T00:44:17.814Z" }, + { url = "https://files.pythonhosted.org/packages/85/64/d3954e836ea67c4d3ad5285e5c8fd9d362fd0a189a2db622df457b0f4f6a/wrapt-2.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:9ca66b38dd642bf90c59b6738af8070747b610115a39af2498535f62b5cdc1c3", size = 60500, upload-time = "2025-11-07T00:44:15.561Z" }, + { url = "https://files.pythonhosted.org/packages/89/4e/3c8b99ac93527cfab7f116089db120fef16aac96e5f6cdb724ddf286086d/wrapt-2.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:5a4939eae35db6b6cec8e7aa0e833dcca0acad8231672c26c2a9ab7a0f8ac9c8", size = 58993, upload-time = "2025-11-07T00:44:16.65Z" }, + { url = "https://files.pythonhosted.org/packages/f9/f4/eff2b7d711cae20d220780b9300faa05558660afb93f2ff5db61fe725b9a/wrapt-2.0.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a52f93d95c8d38fed0669da2ebdb0b0376e895d84596a976c15a9eb45e3eccb3", size = 82028, upload-time = "2025-11-07T00:44:18.944Z" }, + { url = "https://files.pythonhosted.org/packages/0c/67/cb945563f66fd0f61a999339460d950f4735c69f18f0a87ca586319b1778/wrapt-2.0.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4e54bbf554ee29fcceee24fa41c4d091398b911da6e7f5d7bffda963c9aed2e1", size = 62949, upload-time = "2025-11-07T00:44:20.074Z" }, + { url = "https://files.pythonhosted.org/packages/ec/ca/f63e177f0bbe1e5cf5e8d9b74a286537cd709724384ff20860f8f6065904/wrapt-2.0.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:908f8c6c71557f4deaa280f55d0728c3bca0960e8c3dd5ceeeafb3c19942719d", size = 63681, upload-time = "2025-11-07T00:44:21.345Z" }, + { url = "https://files.pythonhosted.org/packages/39/a1/1b88fcd21fd835dca48b556daef750952e917a2794fa20c025489e2e1f0f/wrapt-2.0.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e2f84e9af2060e3904a32cea9bb6db23ce3f91cfd90c6b426757cf7cc01c45c7", size = 152696, upload-time = "2025-11-07T00:44:24.318Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/d9185500c1960d9f5f77b9c0b890b7fc62282b53af7ad1b6bd779157f714/wrapt-2.0.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e3612dc06b436968dfb9142c62e5dfa9eb5924f91120b3c8ff501ad878f90eb3", size = 158859, upload-time = "2025-11-07T00:44:25.494Z" }, + { url = "https://files.pythonhosted.org/packages/91/60/5d796ed0f481ec003220c7878a1d6894652efe089853a208ea0838c13086/wrapt-2.0.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d2d947d266d99a1477cd005b23cbd09465276e302515e122df56bb9511aca1b", size = 146068, upload-time = "2025-11-07T00:44:22.81Z" }, + { url = "https://files.pythonhosted.org/packages/04/f8/75282dd72f102ddbfba137e1e15ecba47b40acff32c08ae97edbf53f469e/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7d539241e87b650cbc4c3ac9f32c8d1ac8a54e510f6dca3f6ab60dcfd48c9b10", size = 155724, upload-time = "2025-11-07T00:44:26.634Z" }, + { url = "https://files.pythonhosted.org/packages/5a/27/fe39c51d1b344caebb4a6a9372157bdb8d25b194b3561b52c8ffc40ac7d1/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:4811e15d88ee62dbf5c77f2c3ff3932b1e3ac92323ba3912f51fc4016ce81ecf", size = 144413, upload-time = "2025-11-07T00:44:27.939Z" }, + { url = "https://files.pythonhosted.org/packages/83/2b/9f6b643fe39d4505c7bf926d7c2595b7cb4b607c8c6b500e56c6b36ac238/wrapt-2.0.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c1c91405fcf1d501fa5d55df21e58ea49e6b879ae829f1039faaf7e5e509b41e", size = 150325, upload-time = "2025-11-07T00:44:29.29Z" }, + { url = "https://files.pythonhosted.org/packages/bb/b6/20ffcf2558596a7f58a2e69c89597128781f0b88e124bf5a4cadc05b8139/wrapt-2.0.1-cp313-cp313t-win32.whl", hash = "sha256:e76e3f91f864e89db8b8d2a8311d57df93f01ad6bb1e9b9976d1f2e83e18315c", size = 59943, upload-time = "2025-11-07T00:44:33.211Z" }, + { url = "https://files.pythonhosted.org/packages/87/6a/0e56111cbb3320151eed5d3821ee1373be13e05b376ea0870711f18810c3/wrapt-2.0.1-cp313-cp313t-win_amd64.whl", hash = "sha256:83ce30937f0ba0d28818807b303a412440c4b63e39d3d8fc036a94764b728c92", size = 63240, upload-time = "2025-11-07T00:44:30.935Z" }, + { url = "https://files.pythonhosted.org/packages/1d/54/5ab4c53ea1f7f7e5c3e7c1095db92932cc32fd62359d285486d00c2884c3/wrapt-2.0.1-cp313-cp313t-win_arm64.whl", hash = "sha256:4b55cacc57e1dc2d0991dbe74c6419ffd415fb66474a02335cb10efd1aa3f84f", size = 60416, upload-time = "2025-11-07T00:44:32.002Z" }, + { url = "https://files.pythonhosted.org/packages/73/81/d08d83c102709258e7730d3cd25befd114c60e43ef3891d7e6877971c514/wrapt-2.0.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:5e53b428f65ece6d9dad23cb87e64506392b720a0b45076c05354d27a13351a1", size = 78290, upload-time = "2025-11-07T00:44:34.691Z" }, + { url = "https://files.pythonhosted.org/packages/f6/14/393afba2abb65677f313aa680ff0981e829626fed39b6a7e3ec807487790/wrapt-2.0.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:ad3ee9d0f254851c71780966eb417ef8e72117155cff04821ab9b60549694a55", size = 61255, upload-time = "2025-11-07T00:44:35.762Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/a4a1f2fba205a9462e36e708ba37e5ac95f4987a0f1f8fd23f0bf1fc3b0f/wrapt-2.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7b822c61ed04ee6ad64bc90d13368ad6eb094db54883b5dde2182f67a7f22c0", size = 61797, upload-time = "2025-11-07T00:44:37.22Z" }, + { url = "https://files.pythonhosted.org/packages/12/db/99ba5c37cf1c4fad35349174f1e38bd8d992340afc1ff27f526729b98986/wrapt-2.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7164a55f5e83a9a0b031d3ffab4d4e36bbec42e7025db560f225489fa929e509", size = 120470, upload-time = "2025-11-07T00:44:39.425Z" }, + { url = "https://files.pythonhosted.org/packages/30/3f/a1c8d2411eb826d695fc3395a431757331582907a0ec59afce8fe8712473/wrapt-2.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e60690ba71a57424c8d9ff28f8d006b7ad7772c22a4af432188572cd7fa004a1", size = 122851, upload-time = "2025-11-07T00:44:40.582Z" }, + { url = "https://files.pythonhosted.org/packages/b3/8d/72c74a63f201768d6a04a8845c7976f86be6f5ff4d74996c272cefc8dafc/wrapt-2.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3cd1a4bd9a7a619922a8557e1318232e7269b5fb69d4ba97b04d20450a6bf970", size = 117433, upload-time = "2025-11-07T00:44:38.313Z" }, + { url = "https://files.pythonhosted.org/packages/c7/5a/df37cf4042cb13b08256f8e27023e2f9b3d471d553376616591bb99bcb31/wrapt-2.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b4c2e3d777e38e913b8ce3a6257af72fb608f86a1df471cb1d4339755d0a807c", size = 121280, upload-time = "2025-11-07T00:44:41.69Z" }, + { url = "https://files.pythonhosted.org/packages/54/34/40d6bc89349f9931e1186ceb3e5fbd61d307fef814f09fbbac98ada6a0c8/wrapt-2.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3d366aa598d69416b5afedf1faa539fac40c1d80a42f6b236c88c73a3c8f2d41", size = 116343, upload-time = "2025-11-07T00:44:43.013Z" }, + { url = "https://files.pythonhosted.org/packages/70/66/81c3461adece09d20781dee17c2366fdf0cb8754738b521d221ca056d596/wrapt-2.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c235095d6d090aa903f1db61f892fffb779c1eaeb2a50e566b52001f7a0f66ed", size = 119650, upload-time = "2025-11-07T00:44:44.523Z" }, + { url = "https://files.pythonhosted.org/packages/46/3a/d0146db8be8761a9e388cc9cc1c312b36d583950ec91696f19bbbb44af5a/wrapt-2.0.1-cp314-cp314-win32.whl", hash = "sha256:bfb5539005259f8127ea9c885bdc231978c06b7a980e63a8a61c8c4c979719d0", size = 58701, upload-time = "2025-11-07T00:44:48.277Z" }, + { url = "https://files.pythonhosted.org/packages/1a/38/5359da9af7d64554be63e9046164bd4d8ff289a2dd365677d25ba3342c08/wrapt-2.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:4ae879acc449caa9ed43fc36ba08392b9412ee67941748d31d94e3cedb36628c", size = 60947, upload-time = "2025-11-07T00:44:46.086Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3f/96db0619276a833842bf36343685fa04f987dd6e3037f314531a1e00492b/wrapt-2.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:8639b843c9efd84675f1e100ed9e99538ebea7297b62c4b45a7042edb84db03e", size = 59359, upload-time = "2025-11-07T00:44:47.164Z" }, + { url = "https://files.pythonhosted.org/packages/71/49/5f5d1e867bf2064bf3933bc6cf36ade23505f3902390e175e392173d36a2/wrapt-2.0.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:9219a1d946a9b32bb23ccae66bdb61e35c62773ce7ca6509ceea70f344656b7b", size = 82031, upload-time = "2025-11-07T00:44:49.4Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/0009a218d88db66ceb83921e5685e820e2c61b59bbbb1324ba65342668bc/wrapt-2.0.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:fa4184e74197af3adad3c889a1af95b53bb0466bced92ea99a0c014e48323eec", size = 62952, upload-time = "2025-11-07T00:44:50.74Z" }, + { url = "https://files.pythonhosted.org/packages/ae/18/9b968e920dd05d6e44bcc918a046d02afea0fb31b2f1c80ee4020f377cbe/wrapt-2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c5ef2f2b8a53b7caee2f797ef166a390fef73979b15778a4a153e4b5fedce8fa", size = 63688, upload-time = "2025-11-07T00:44:52.248Z" }, + { url = "https://files.pythonhosted.org/packages/a6/7d/78bdcb75826725885d9ea26c49a03071b10c4c92da93edda612910f150e4/wrapt-2.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:e042d653a4745be832d5aa190ff80ee4f02c34b21f4b785745eceacd0907b815", size = 152706, upload-time = "2025-11-07T00:44:54.613Z" }, + { url = "https://files.pythonhosted.org/packages/dd/77/cac1d46f47d32084a703df0d2d29d47e7eb2a7d19fa5cbca0e529ef57659/wrapt-2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2afa23318136709c4b23d87d543b425c399887b4057936cd20386d5b1422b6fa", size = 158866, upload-time = "2025-11-07T00:44:55.79Z" }, + { url = "https://files.pythonhosted.org/packages/8a/11/b521406daa2421508903bf8d5e8b929216ec2af04839db31c0a2c525eee0/wrapt-2.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c72328f668cf4c503ffcf9434c2b71fdd624345ced7941bc6693e61bbe36bef", size = 146148, upload-time = "2025-11-07T00:44:53.388Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c0/340b272bed297baa7c9ce0c98ef7017d9c035a17a6a71dce3184b8382da2/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3793ac154afb0e5b45d1233cb94d354ef7a983708cc3bb12563853b1d8d53747", size = 155737, upload-time = "2025-11-07T00:44:56.971Z" }, + { url = "https://files.pythonhosted.org/packages/f3/93/bfcb1fb2bdf186e9c2883a4d1ab45ab099c79cbf8f4e70ea453811fa3ea7/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:fec0d993ecba3991645b4857837277469c8cc4c554a7e24d064d1ca291cfb81f", size = 144451, upload-time = "2025-11-07T00:44:58.515Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6b/dca504fb18d971139d232652656180e3bd57120e1193d9a5899c3c0b7cdd/wrapt-2.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:949520bccc1fa227274da7d03bf238be15389cd94e32e4297b92337df9b7a349", size = 150353, upload-time = "2025-11-07T00:44:59.753Z" }, + { url = "https://files.pythonhosted.org/packages/1d/f6/a1de4bd3653afdf91d250ca5c721ee51195df2b61a4603d4b373aa804d1d/wrapt-2.0.1-cp314-cp314t-win32.whl", hash = "sha256:be9e84e91d6497ba62594158d3d31ec0486c60055c49179edc51ee43d095f79c", size = 60609, upload-time = "2025-11-07T00:45:03.315Z" }, + { url = "https://files.pythonhosted.org/packages/01/3a/07cd60a9d26fe73efead61c7830af975dfdba8537632d410462672e4432b/wrapt-2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:61c4956171c7434634401db448371277d07032a81cc21c599c22953374781395", size = 64038, upload-time = "2025-11-07T00:45:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/99/8a06b8e17dddbf321325ae4eb12465804120f699cd1b8a355718300c62da/wrapt-2.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:35cdbd478607036fee40273be8ed54a451f5f23121bd9d4be515158f9498f7ad", size = 60634, upload-time = "2025-11-07T00:45:02.087Z" }, + { url = "https://files.pythonhosted.org/packages/15/d1/b51471c11592ff9c012bd3e2f7334a6ff2f42a7aed2caffcf0bdddc9cb89/wrapt-2.0.1-py3-none-any.whl", hash = "sha256:4d2ce1bf1a48c5277d7969259232b57645aae5686dba1eaeade39442277afbca", size = 44046, upload-time = "2025-11-07T00:45:32.116Z" }, +] diff --git a/PROJECTS/bug-bounty-platform/compose.yml b/PROJECTS/bug-bounty-platform/compose.yml new file mode 100644 index 00000000..2c05e5b2 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/compose.yml @@ -0,0 +1,158 @@ +# ========================================= +# AngelaMos | 2026 +# Production | compose.yml +# ========================================= + + +name: ${APP_NAME:-template} + +services: + # Nginx + Frontend + nginx: + build: + context: . + dockerfile: infra/docker/frontend-builder.prod + args: + - VITE_API_URL=${VITE_API_URL:-/api} + - VITE_APP_TITLE=${VITE_APP_TITLE:-My App} + container_name: ${APP_NAME:-template}-nginx + ports: + - "${NGINX_HOST_PORT:-8420}:80" + depends_on: + backend: + condition: service_healthy + networks: + - frontend + - backend + deploy: + resources: + limits: + cpus: '1.0' + memory: 256M + reservations: + cpus: '0.25' + memory: 64M + restart: unless-stopped + + # FastAPI backend + backend: + build: + context: ./backend + dockerfile: ../infra/docker/fastapi.prod + container_name: ${APP_NAME:-template}-backend + expose: + - "8000" + env_file: + - .env + environment: + - ENVIRONMENT=production + - DEBUG=false + - RELOAD=false + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - backend + deploy: + resources: + limits: + cpus: '2.0' + memory: 1G + reservations: + cpus: '0.5' + memory: 256M + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 40s + restart: unless-stopped + + # PostgreSQL DB + db: + image: postgres:18-alpine + container_name: ${APP_NAME:-template}-db + ports: + - "${POSTGRES_HOST_PORT:-3420}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - POSTGRES_DB=${POSTGRES_DB:-app_db} + networks: + - backend + deploy: + resources: + limits: + cpus: '1.0' + memory: 512M + reservations: + cpus: '0.25' + memory: 128M + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-app_db}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + # Redis + redis: + image: redis:7-alpine + container_name: ${APP_NAME:-template}-redis + ports: + - "${REDIS_HOST_PORT:-6420}:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes ${REDIS_PASSWORD:+--requirepass ${REDIS_PASSWORD}} + networks: + - backend + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 64M + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # Cloudflare Tunnel + cloudflared: + image: cloudflare/cloudflared:latest + container_name: ${APP_NAME:-template}-tunnel + command: tunnel run --token ${CLOUDFLARE_TUNNEL_TOKEN} + networks: + - backend + depends_on: + nginx: + condition: service_started + deploy: + resources: + limits: + cpus: '0.5' + memory: 128M + reservations: + cpus: '0.1' + memory: 32M + restart: unless-stopped + +networks: + frontend: + driver: bridge + backend: + driver: bridge + +volumes: + postgres_data: + redis_data: diff --git a/PROJECTS/bug-bounty-platform/dev.compose.yml b/PROJECTS/bug-bounty-platform/dev.compose.yml new file mode 100644 index 00000000..980ede82 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/dev.compose.yml @@ -0,0 +1,128 @@ +# ============================================================================= +# AngelaMos | 2026 +# dev.compose.yml +# ============================================================================= + +name: ${APP_NAME:-template}-dev + +services: + # Nginx + nginx: + image: nginx:1.27-alpine + container_name: ${APP_NAME:-template}-nginx-dev + ports: + - "${NGINX_HOST_PORT:-8420}:80" + volumes: + - ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./infra/nginx/dev.nginx:/etc/nginx/conf.d/default.conf:ro + depends_on: + backend: + condition: service_healthy + frontend: + condition: service_started + networks: + - frontend + - backend + restart: unless-stopped + + # FastAPI + backend: + build: + context: ./backend + dockerfile: ../infra/docker/fastapi.dev + container_name: ${APP_NAME:-template}-backend-dev + ports: + - "${BACKEND_HOST_PORT:-5420}:8000" + volumes: + - ./backend:/app + - backend_cache:/app/.venv + env_file: + - .env + environment: + - ENVIRONMENT=development + - DEBUG=true + - RELOAD=true + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + networks: + - backend + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + # Vite dev server + frontend: + build: + context: ./frontend + dockerfile: ../infra/docker/vite.dev + container_name: ${APP_NAME:-template}-frontend-dev + ports: + - "${FRONTEND_HOST_PORT:-3420}:5173" + volumes: + - ./frontend:/app + - frontend_modules:/app/node_modules + environment: + - VITE_API_URL=${VITE_API_URL:-/api} + - VITE_APP_TITLE=${VITE_APP_TITLE:-My App} + networks: + - frontend + restart: unless-stopped + + # PostgreSQL DB + db: + image: postgres:16-alpine + container_name: ${APP_NAME:-template}-db-dev + ports: + - "${POSTGRES_HOST_PORT:-3420}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-postgres} + - POSTGRES_DB=${POSTGRES_DB:-app_db} + networks: + - backend + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-app_db}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 30s + restart: unless-stopped + + # Redis + redis: + image: redis:7-alpine + container_name: ${APP_NAME:-template}-redis-dev + ports: + - "${REDIS_HOST_PORT:-6420}:6379" + volumes: + - redis_data:/data + command: redis-server --appendonly yes + networks: + - backend + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + +networks: + frontend: + driver: bridge + backend: + driver: bridge + +volumes: + postgres_data: + redis_data: + backend_cache: + frontend_modules: diff --git a/PROJECTS/bug-bounty-platform/frontend/.dockerignore b/PROJECTS/bug-bounty-platform/frontend/.dockerignore new file mode 100644 index 00000000..a0256ec7 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/.dockerignore @@ -0,0 +1,15 @@ +node_modules +build +dist +.git +.gitignore +*.md +.env* +.vscode +.idea +*.log +npm-debug.log* +pnpm-debug.log* +.DS_Store +coverage +.nyc_output diff --git a/PROJECTS/bug-bounty-platform/frontend/.gitignore b/PROJECTS/bug-bounty-platform/frontend/.gitignore new file mode 100644 index 00000000..61cb0c2e --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.vite + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/PROJECTS/bug-bounty-platform/frontend/.stylelintignore b/PROJECTS/bug-bounty-platform/frontend/.stylelintignore new file mode 100755 index 00000000..1ae0ac7c --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/.stylelintignore @@ -0,0 +1,22 @@ +# ©AngelaMos | 2026 +# .stylelintignore + +# Dependencies +node_modules/ + +# Production builds +dist/ +build/ +out/ + +# JS/TS files +**/*.js +**/*.jsx +**/*.ts +**/*.tsx + +# Generated files +*.min.css + +# Error system styles - ignore from linting +src/core/app/_toastStyles.scss diff --git a/PROJECTS/bug-bounty-platform/frontend/biome.json b/PROJECTS/bug-bounty-platform/frontend/biome.json new file mode 100644 index 00000000..fae39124 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/biome.json @@ -0,0 +1,94 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.3.11/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "includes": ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx", "**/*.json"] + }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 82, + "lineEnding": "lf" + }, + "javascript": { + "formatter": { + "quoteStyle": "single", + "jsxQuoteStyle": "double", + "semicolons": "asNeeded", + "trailingCommas": "es5", + "arrowParentheses": "always" + } + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { "maxAllowedComplexity": 25 } + }, + "noForEach": "off", + "useLiteralKeys": "off" + }, + "correctness": { + "noUnusedVariables": "error", + "noUnusedImports": "error", + "useExhaustiveDependencies": "warn", + "useHookAtTopLevel": "error", + "noUndeclaredVariables": "error" + }, + "style": { + "useImportType": "error", + "useConst": "error", + "useTemplate": "error", + "useSelfClosingElements": "error", + "useFragmentSyntax": "error", + "noNonNullAssertion": "error", + "useConsistentArrayType": { + "level": "error", + "options": { "syntax": "shorthand" } + }, + "useNamingConvention": "off" + }, + "suspicious": { + "noExplicitAny": "error", + "noDebugger": "error", + "noConsole": "warn", + "noArrayIndexKey": "warn", + "noAssignInExpressions": "error", + "noDoubleEquals": "error", + "noRedeclare": "error", + "noVar": "error" + }, + "security": { + "noDangerouslySetInnerHtml": "error" + }, + "a11y": { + "useAltText": "error", + "useAnchorContent": "error", + "useKeyWithClickEvents": "error", + "noStaticElementInteractions": "error", + "useButtonType": "error", + "useValidAnchor": "error" + } + } + }, + "overrides": [ + { + "includes": ["src/main.tsx"], + "linter": { + "rules": { + "style": { + "noNonNullAssertion": "off" + } + } + } + } + ] +} diff --git a/PROJECTS/bug-bounty-platform/frontend/index.html b/PROJECTS/bug-bounty-platform/frontend/index.html new file mode 100644 index 00000000..65ee0f76 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/index.html @@ -0,0 +1,48 @@ + + + + + + + + + + + Bug Bounty Platform + + + + +
+ + + diff --git a/PROJECTS/bug-bounty-platform/frontend/package.json b/PROJECTS/bug-bounty-platform/frontend/package.json new file mode 100644 index 00000000..388a2c2d --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/package.json @@ -0,0 +1,50 @@ +{ + "name": "bug-bounty-platform", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "lint": "biome check .", + "lint:fix": "biome check --write .", + "format": "biome format --write .", + "typecheck": "tsc --noEmit", + "lint:scss": "stylelint '**/*.scss'", + "lint:scss:fix": "stylelint '**/*.scss' --fix" + }, + "dependencies": { + "@tanstack/react-query": "^5.90.12", + "axios": "^1.13.0", + "react": "^19.2.1", + "react-dom": "^19.2.0", + "react-error-boundary": "^6.0.0", + "react-icon": "^1.0.0", + "react-icons": "^5.5.0", + "react-router-dom": "^7.1.1", + "sonner": "^2.0.7", + "zod": "^4.1.13", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@biomejs/biome": "^2.3.8", + "@tanstack/react-query-devtools": "^5.91.1", + "@types/node": "^24.10.2", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "sass": "^1.95.0", + "stylelint": "^16.26.1", + "stylelint-config-prettier-scss": "^1.0.0", + "stylelint-config-standard-scss": "^16.0.0", + "typescript": "~5.9.3", + "vite": "npm:rolldown-vite@7.2.5", + "vite-tsconfig-paths": "^5.1.0" + }, + "pnpm": { + "overrides": { + "vite": "npm:rolldown-vite@7.2.5" + } + } +} diff --git a/PROJECTS/bug-bounty-platform/frontend/pnpm-lock.yaml b/PROJECTS/bug-bounty-platform/frontend/pnpm-lock.yaml new file mode 100644 index 00000000..0b8c1441 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/pnpm-lock.yaml @@ -0,0 +1,2598 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +overrides: + vite: npm:rolldown-vite@7.2.5 + +importers: + + .: + dependencies: + '@tanstack/react-query': + specifier: ^5.90.12 + version: 5.90.16(react@19.2.3) + axios: + specifier: ^1.13.0 + version: 1.13.2 + react: + specifier: ^19.2.1 + version: 19.2.3 + react-dom: + specifier: ^19.2.0 + version: 19.2.3(react@19.2.3) + react-error-boundary: + specifier: ^6.0.0 + version: 6.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + react-icon: + specifier: ^1.0.0 + version: 1.0.0(babel-runtime@5.8.38)(react@19.2.3) + react-icons: + specifier: ^5.5.0 + version: 5.5.0(react@19.2.3) + react-router-dom: + specifier: ^7.1.1 + version: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + zod: + specifier: ^4.1.13 + version: 4.3.5 + zustand: + specifier: ^5.0.9 + version: 5.0.9(@types/react@19.2.7)(react@19.2.3) + devDependencies: + '@biomejs/biome': + specifier: ^2.3.8 + version: 2.3.11 + '@tanstack/react-query-devtools': + specifier: ^5.91.1 + version: 5.91.2(@tanstack/react-query@5.90.16(react@19.2.3))(react@19.2.3) + '@types/node': + specifier: ^24.10.2 + version: 24.10.4 + '@types/react': + specifier: ^19.2.7 + version: 19.2.7 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.7) + '@vitejs/plugin-react': + specifier: ^5.1.1 + version: 5.1.2(rolldown-vite@7.2.5(@types/node@24.10.4)(sass@1.97.2)) + sass: + specifier: ^1.95.0 + version: 1.97.2 + stylelint: + specifier: ^16.26.1 + version: 16.26.1(typescript@5.9.3) + stylelint-config-prettier-scss: + specifier: ^1.0.0 + version: 1.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard-scss: + specifier: ^16.0.0 + version: 16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: npm:rolldown-vite@7.2.5 + version: rolldown-vite@7.2.5(@types/node@24.10.4)(sass@1.97.2) + vite-tsconfig-paths: + specifier: ^5.1.0 + version: 5.1.4(rolldown-vite@7.2.5(@types/node@24.10.4)(sass@1.97.2))(typescript@5.9.3) + +packages: + + '@babel/code-frame@7.27.1': + resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.28.5': + resolution: {integrity: sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.28.5': + resolution: {integrity: sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.28.5': + resolution: {integrity: sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.27.2': + resolution: {integrity: sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.28.0': + resolution: {integrity: sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.27.1': + resolution: {integrity: sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.28.3': + resolution: {integrity: sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-plugin-utils@7.27.1': + resolution: {integrity: sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.27.1': + resolution: {integrity: sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.28.4': + resolution: {integrity: sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.28.5': + resolution: {integrity: sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/plugin-transform-react-jsx-self@7.27.1': + resolution: {integrity: sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/plugin-transform-react-jsx-source@7.27.1': + resolution: {integrity: sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + + '@babel/template@7.27.2': + resolution: {integrity: sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.28.5': + resolution: {integrity: sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.28.5': + resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} + engines: {node: '>=6.9.0'} + + '@biomejs/biome@2.3.11': + resolution: {integrity: sha512-/zt+6qazBWguPG6+eWmiELqO+9jRsMZ/DBU3lfuU2ngtIQYzymocHhKiZRyrbra4aCOoyTg/BmY+6WH5mv9xmQ==} + engines: {node: '>=14.21.3'} + hasBin: true + + '@biomejs/cli-darwin-arm64@2.3.11': + resolution: {integrity: sha512-/uXXkBcPKVQY7rc9Ys2CrlirBJYbpESEDme7RKiBD6MmqR2w3j0+ZZXRIL2xiaNPsIMMNhP1YnA+jRRxoOAFrA==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [darwin] + + '@biomejs/cli-darwin-x64@2.3.11': + resolution: {integrity: sha512-fh7nnvbweDPm2xEmFjfmq7zSUiox88plgdHF9OIW4i99WnXrAC3o2P3ag9judoUMv8FCSUnlwJCM1B64nO5Fbg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [darwin] + + '@biomejs/cli-linux-arm64-musl@2.3.11': + resolution: {integrity: sha512-XPSQ+XIPZMLaZ6zveQdwNjbX+QdROEd1zPgMwD47zvHV+tCGB88VH+aynyGxAHdzL+Tm/+DtKST5SECs4iwCLg==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-arm64@2.3.11': + resolution: {integrity: sha512-l4xkGa9E7Uc0/05qU2lMYfN1H+fzzkHgaJoy98wO+b/7Gl78srbCRRgwYSW+BTLixTBrM6Ede5NSBwt7rd/i6g==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [linux] + + '@biomejs/cli-linux-x64-musl@2.3.11': + resolution: {integrity: sha512-vU7a8wLs5C9yJ4CB8a44r12aXYb8yYgBn+WeyzbMjaCMklzCv1oXr8x+VEyWodgJt9bDmhiaW/I0RHbn7rsNmw==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-linux-x64@2.3.11': + resolution: {integrity: sha512-/1s9V/H3cSe0r0Mv/Z8JryF5x9ywRxywomqZVLHAoa/uN0eY7F8gEngWKNS5vbbN/BsfpCG5yeBT5ENh50Frxg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [linux] + + '@biomejs/cli-win32-arm64@2.3.11': + resolution: {integrity: sha512-PZQ6ElCOnkYapSsysiTy0+fYX+agXPlWugh6+eQ6uPKI3vKAqNp6TnMhoM3oY2NltSB89hz59o8xIfOdyhi9Iw==} + engines: {node: '>=14.21.3'} + cpu: [arm64] + os: [win32] + + '@biomejs/cli-win32-x64@2.3.11': + resolution: {integrity: sha512-43VrG813EW+b5+YbDbz31uUsheX+qFKCpXeY9kfdAx+ww3naKxeVkTD9zLIWxUPfJquANMHrmW3wbe/037G0Qg==} + engines: {node: '>=14.21.3'} + cpu: [x64] + os: [win32] + + '@cacheable/memory@2.0.7': + resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==} + + '@cacheable/utils@2.3.3': + resolution: {integrity: sha512-JsXDL70gQ+1Vc2W/KUFfkAJzgb4puKwwKehNLuB+HrNKWf91O736kGfxn4KujXCCSuh6mRRL4XEB0PkAFjWS0A==} + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.22': + resolution: {integrity: sha512-qBcx6zYlhleiFfdtzkRgwNC7VVoAwfK76Vmsw5t+PbvtdknO9StgRk7ROvq9so1iqbdW4uLIDAsXRsTfUrIoOw==} + engines: {node: '>=18'} + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + + '@csstools/media-query-list-parser@4.0.3': + resolution: {integrity: sha512-HAYH7d3TLRHDOUQK4mZKf9k9Ph/m8Akstg66ywKR4SFAigjs3yBiUeZtFxywiTm5moZMAp/5W/ZuFnNXXYLuuQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/selector-specificity@5.0.0': + resolution: {integrity: sha512-PCqQV3c4CoVm3kdPhyeZ07VmBRdH2EpMFA/pd9OASpOEC3aXNGoqPDAZ80D0cLpMBxnmk0+yNhGsEx31hq7Gtw==} + engines: {node: '>=18'} + peerDependencies: + postcss-selector-parser: ^7.0.0 + + '@dual-bundle/import-meta-resolve@4.2.1': + resolution: {integrity: sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==} + + '@emnapi/core@1.8.1': + resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} + + '@emnapi/runtime@1.8.1': + resolution: {integrity: sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==} + + '@emnapi/wasi-threads@1.1.0': + resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@keyv/bigmap@1.3.0': + resolution: {integrity: sha512-KT01GjzV6AQD5+IYrcpoYLkCu1Jod3nau1Z7EsEuViO3TZGRacSbO9MfHmbJ1WaOXFtWLxPVj169cn2WNKPkIg==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.5.4 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + + '@napi-rs/wasm-runtime@1.1.1': + resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + + '@nodelib/fs.scandir@2.1.5': + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + + '@nodelib/fs.stat@2.0.5': + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + + '@nodelib/fs.walk@1.2.8': + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + + '@oxc-project/runtime@0.97.0': + resolution: {integrity: sha512-yH0zw7z+jEws4dZ4IUKoix5Lh3yhqIJWF9Dc8PWvhpo7U7O+lJrv7ZZL4BeRO0la8LBQFwcCewtLBnVV7hPe/w==} + engines: {node: ^20.19.0 || >=22.12.0} + + '@oxc-project/types@0.97.0': + resolution: {integrity: sha512-lxmZK4xFrdvU0yZiDwgVQTCvh2gHWBJCBk5ALsrtsBWhs0uDIi+FTOnXRQeQfs304imdvTdaakT/lqwQ8hkOXQ==} + + '@parcel/watcher-android-arm64@2.5.1': + resolution: {integrity: sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.5.1': + resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.5.1': + resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.5.1': + resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.5.1': + resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm-musl@2.5.1': + resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-arm64-musl@2.5.1': + resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + + '@parcel/watcher-linux-x64-glibc@2.5.1': + resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-linux-x64-musl@2.5.1': + resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + + '@parcel/watcher-win32-arm64@2.5.1': + resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-ia32@2.5.1': + resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + + '@parcel/watcher-win32-x64@2.5.1': + resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.5.1': + resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} + engines: {node: '>= 10.0.0'} + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-XlEkrOIHLyGT3avOgzfTFSjG+f+dZMw+/qd+Y3HLN86wlndrB/gSimrJCk4gOhr1XtRtEKfszpadI3Md4Z4/Ag==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-+JRqKJhoFlt5r9q+DecAGPLZ5PxeLva+wCMtAuoFMWPoZzgcYrr599KQ+Ix0jwll4B4HGP43avu9My8KtSOR+w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + resolution: {integrity: sha512-fFXDjXnuX7/gQZQm/1FoivVtRcyAzdjSik7Eo+9iwPQ9EgtA5/nB2+jmbzaKtMGG3q+BnZbdKHCtOacmNrkIDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + resolution: {integrity: sha512-F1b6vARy49tjmT/hbloplzgJS7GIvwWZqt+tAHEstCh0JIh9sa8FAMVqEmYxDviqKBaAI8iVvUREm/Kh/PD26Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + resolution: {integrity: sha512-U6cR76N8T8M6lHj7EZrQ3xunLPxSvYYxA8vJsBKZiFZkT8YV4kjgCO3KwMJL0NOjQCPGKyiXO07U+KmJzdPGRw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-ONgyjofCrrE3bnh5GZb8EINSFyR/hmwTzZ7oVuyUB170lboza1VMCnb8jgE6MsyyRgHYmN8Lb59i3NKGrxrYjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-L0zRdH2oDPkmB+wvuTl+dJbXCsx62SkqcEqdM+79LOcB+PxbAxxjzHU14BuZIQdXcAVDzfpMfaHWzZuwhhBTcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + resolution: {integrity: sha512-gyoI8o/TGpQd3OzkJnh1M2kxy1Bisg8qJ5Gci0sXm9yLFzEXIFdtc4EAzepxGvrT2ri99ar5rdsmNG0zP0SbIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + resolution: {integrity: sha512-zti8A7M+xFDpKlghpcCAzyOi+e5nfUl3QhU023ce5NCgUxRG5zGP2GR9LTydQ1rnIPwZUVBWd4o7NjZDaQxaXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + resolution: {integrity: sha512-eZUssog7qljrrRU9Mi0eqYEPm3Ch0UwB+qlWPMKSUXHNqhm3TvDZarJQdTevGEfu3EHAXJvBIe0YFYr0TPVaMA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + resolution: {integrity: sha512-nmCN0nIdeUnmgeDXiQ+2HU6FT162o+rxnF7WMkBm4M5Ds8qTU7Dzv2Wrf22bo4ftnlrb2hKK6FSwAJSAe2FWLg==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-7kcNLi7Ua59JTTLvbe1dYb028QEPaJPJQHqkmSZ5q3tJueUeb6yjRtx8mw4uIqgWZcnQHAR3PrLN4XRJxvgIkA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-lL70VTNvSCdSZkDPPVMwWn/M2yQiYvSoXw9hTLgdIWdUfC3g72UaruezusR6ceRuwHCY1Ayu2LtKqXkBO5LIwg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + resolution: {integrity: sha512-4qU4x5DXWB4JPjyTne/wBNPqkbQU8J45bl21geERBKtEittleonioACBL1R0PsBu0Aq21SwMK5a9zdBkWSlQtQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.0-beta.50': + resolution: {integrity: sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==} + + '@rolldown/pluginutils@1.0.0-beta.53': + resolution: {integrity: sha512-vENRlFU4YbrwVqNDZ7fLvy+JR1CRkyr01jhSiDpE1u6py3OMzQfztQU2jxykW3ALNxO4kSlqIDeYyD0Y9RcQeQ==} + + '@tanstack/query-core@5.90.16': + resolution: {integrity: sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==} + + '@tanstack/query-devtools@5.92.0': + resolution: {integrity: sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==} + + '@tanstack/react-query-devtools@5.91.2': + resolution: {integrity: sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==} + peerDependencies: + '@tanstack/react-query': ^5.90.14 + react: ^18 || ^19 + + '@tanstack/react-query@5.90.16': + resolution: {integrity: sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==} + peerDependencies: + react: ^18 || ^19 + + '@tybys/wasm-util@0.10.1': + resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.27.0': + resolution: {integrity: sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.28.0': + resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==} + + '@types/node@24.10.4': + resolution: {integrity: sha512-vnDVpYPMzs4wunl27jHrfmwojOGKya0xyM3sH+UE5iv5uPS6vX7UIoh6m+vQc5LGBq52HBKPIn/zcSZVzeDEZg==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.7': + resolution: {integrity: sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==} + + '@vitejs/plugin-react@5.1.2': + resolution: {integrity: sha512-EcA07pHJouywpzsoTUqNh5NwGayl2PPVEJKUSinGGSxFGYn+shYbqMGBg6FXDqgXum9Ou/ecb+411ssw8HImJQ==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.13.2: + resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} + + babel-runtime@5.8.38: + resolution: {integrity: sha512-KpgoA8VE/pMmNCrnEeeXqFG24TIH11Z3ZaimIhJWsin8EbfZy3WzFKUTIan10ZIDgRVvi9EkLbruJElJC9dRlg==} + + balanced-match@2.0.0: + resolution: {integrity: sha512-1ugUSr8BHXRnK23KfuYS+gVMC3LB8QGH9W1iGtDPsNWoQbgtXSExkBu2aDR4epiGWZOjZsj6lDl/N/AqqTC3UA==} + + baseline-browser-mapping@2.9.11: + resolution: {integrity: sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ==} + hasBin: true + + braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + + browserslist@4.28.1: + resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + cacheable@2.3.1: + resolution: {integrity: sha512-yr+FSHWn1ZUou5LkULX/S+jhfgfnLbuKQjE40tyEd4fxGZVMbBL5ifno0J0OauykS8UiCSgHi+DV/YD+rjFxFg==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + caniuse-lite@1.0.30001762: + resolution: {integrity: sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + + core-js@1.2.7: + resolution: {integrity: sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==} + deprecated: core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js. + + cosmiconfig@9.0.0: + resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + + css-functions-list@3.2.3: + resolution: {integrity: sha512-IQOkD3hbR5KrN93MtcYuad6YPuTSUhntLHDuLEbFWE+ff2/XSZNdZG+LcbbIW5AXKg/WFIfYItIzVoHngHXZzA==} + engines: {node: '>=12 || >=16'} + + css-tree@3.1.0: + resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + cssesc@3.0.0: + resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} + engines: {node: '>=4'} + hasBin: true + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@1.0.3: + resolution: {integrity: sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==} + engines: {node: '>=0.10'} + hasBin: true + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + electron-to-chromium@1.5.267: + resolution: {integrity: sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==} + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + + error-ex@1.3.4: + resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-glob@3.3.3: + resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} + engines: {node: '>=8.6.0'} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + + fastq@1.20.1: + resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@11.1.1: + resolution: {integrity: sha512-TPVFSDE7q91Dlk1xpFLvFllf8r0HyOMOlnWy7Z2HBku5H3KhIeOGInexrIeg2D64DosVB/JXkrrk6N/7Wriq4A==} + + fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + + flat-cache@6.1.19: + resolution: {integrity: sha512-l/K33newPTZMTGAnnzaiqSl6NnH7Namh8jBNjrgjprWxGmZUuxx/sJNIRaijOh3n7q7ESbhNZC+pvVZMFdeU4A==} + + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + + globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hashery@1.4.0: + resolution: {integrity: sha512-Wn2i1In6XFxl8Az55kkgnFRiAlIAushzh26PTjL2AKtQcEfXrcLa7Hn5QOWGZEf3LU057P9TwwZjFyxfS1VuvQ==} + engines: {node: '>=20'} + + hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + + hookified@1.15.0: + resolution: {integrity: sha512-51w+ZZGt7Zw5q7rM3nC4t3aLn/xvKDETsXqMczndvwyVQhAHfUmUuFBRFcos8Iyebtk7OAE9dL26wFNzZVVOkw==} + + html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + immutable@5.1.4: + resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + + import-fresh@3.3.1: + resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} + engines: {node: '>=6'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.1.1: + resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} + hasBin: true + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@5.5.5: + resolution: {integrity: sha512-FA5LmZVF1VziNc0bIdCSA1IoSVnDCqE8HJIZZv2/W8YmoAM50+tnUgJR/gQZwEeIMleuIOnRnHA/UaZRNeV4iQ==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + + lightningcss-android-arm64@1.30.2: + resolution: {integrity: sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.30.2: + resolution: {integrity: sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.30.2: + resolution: {integrity: sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.30.2: + resolution: {integrity: sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.30.2: + resolution: {integrity: sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.30.2: + resolution: {integrity: sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.30.2: + resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.30.2: + resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.30.2: + resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.30.2: + resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.30.2: + resolution: {integrity: sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.30.2: + resolution: {integrity: sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==} + engines: {node: '>= 12.0.0'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + mathml-tag-names@2.1.3: + resolution: {integrity: sha512-APMBEanjybaPzUrfqU0IMU5I0AswKMH7k8OTLs0vvV4KZpExkTkY87nR/zpbuTPj+gARop7aGUbl11pnDfW6xg==} + + mdn-data@2.12.2: + resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} + + mdn-data@2.25.0: + resolution: {integrity: sha512-T2LPsjgUE/tgMmRXREVmwsux89DwWfNjiynOeXuLd2mX6jphGQ2YE3Ukz7LQ2VOFKiVZU/Ee1GqzHiipZCjymw==} + + meow@13.2.0: + resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} + engines: {node: '>=18'} + + merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + + micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + node-releases@2.0.27: + resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==} + + normalize-path@3.0.0: + resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} + engines: {node: '>=0.10.0'} + + parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + + path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + + postcss-selector-parser@7.1.1: + resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} + engines: {node: '>=4'} + + postcss-value-parser@4.2.0: + resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} + + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + + qified@0.5.3: + resolution: {integrity: sha512-kXuQdQTB6oN3KhI6V4acnBSZx8D2I4xzZvn9+wFLLFCoBNQY/sFnCW6c43OL7pOQ2HvGV4lnWIXNmgfp7cTWhQ==} + engines: {node: '>=20'} + + queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + + react-dom@19.2.3: + resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==} + peerDependencies: + react: ^19.2.3 + + react-error-boundary@6.0.2: + resolution: {integrity: sha512-yvWErn55ag/ywZEFqYpXYX9rxIDPIabXIX25F184KY3F5Szk2x/cVieOflw5R47ltN3KzWOw82Lmlb4vNjyn9A==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + + react-icon@1.0.0: + resolution: {integrity: sha512-VzSlpBHnLanVw79mOxyq98hWDi6DlxK9qPiZ1bAK6bLurMBCaxO/jjyYUrRx9+JGLc/NbnwOmyE/W5Qglbb2QA==} + peerDependencies: + babel-runtime: ^5.3.3 + react: '>=0.12.0' + + react-icons@5.5.0: + resolution: {integrity: sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==} + peerDependencies: + react: '*' + + react-refresh@0.18.0: + resolution: {integrity: sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==} + engines: {node: '>=0.10.0'} + + react-router-dom@7.11.0: + resolution: {integrity: sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.11.0: + resolution: {integrity: sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.3: + resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} + engines: {node: '>=0.10.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + reusify@1.1.0: + resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + + rolldown-vite@7.2.5: + resolution: {integrity: sha512-u09tdk/huMiN8xwoiBbig197jKdCamQTtOruSalOzbqGje3jdHiV0njQlAW0YvzoahkirFePNQ4RYlfnRQpXZA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + esbuild: ^0.25.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + rolldown@1.0.0-beta.50: + resolution: {integrity: sha512-JFULvCNl/anKn99eKjOSEubi0lLmNqQDAjyEMME2T4CwezUDL0i6t1O9xZsu2OMehPnV2caNefWpGF+8TnzB6A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + + sass@1.97.2: + resolution: {integrity: sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==} + engines: {node: '>=14.0.0'} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + stylelint-config-prettier-scss@1.0.0: + resolution: {integrity: sha512-Gr2qLiyvJGKeDk0E/+awNTrZB/UtNVPLqCDOr07na/sLekZwm26Br6yYIeBYz3ulsEcQgs5j+2IIMXCC+wsaQA==} + engines: {node: 14.* || 16.* || >= 18} + hasBin: true + peerDependencies: + stylelint: '>=15.0.0' + + stylelint-config-recommended-scss@16.0.2: + resolution: {integrity: sha512-aUTHhPPWCvFyWaxtckJlCPaXTDFsp4pKO8evXNCsW9OwsaUWyMd6jvcUhSmfGWPrTddvzNqK4rS/UuSLcbVGdQ==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.24.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-recommended@17.0.0: + resolution: {integrity: sha512-WaMSdEiPfZTSFVoYmJbxorJfA610O0tlYuU2aEwY33UQhSPgFbClrVJYWvy3jGJx+XW37O+LyNLiZOEXhKhJmA==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-config-standard-scss@16.0.0: + resolution: {integrity: sha512-/FHECLUu+med/e6OaPFpprG86ShC4SYT7Tzb2PTVdDjJsehhFBOioSlWqYFqJxmGPIwO3AMBxNo+kY3dxrbczA==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^16.23.1 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-standard@39.0.1: + resolution: {integrity: sha512-b7Fja59EYHRNOTa3aXiuWnhUWXFU2Nfg6h61bLfAb5GS5fX3LMUD0U5t4S8N/4tpHQg3Acs2UVPR9jy2l1g/3A==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.23.0 + + stylelint-scss@6.14.0: + resolution: {integrity: sha512-ZKmHMZolxeuYsnB+PCYrTpFce0/QWX9i9gh0hPXzp73WjuIMqUpzdQaBCrKoLWh6XtCFSaNDErkMPqdjy1/8aA==} + engines: {node: '>=18.12.0'} + peerDependencies: + stylelint: ^16.8.2 + + stylelint@16.26.1: + resolution: {integrity: sha512-v20V59/crfc8sVTAtge0mdafI3AdnzQ2KsWe6v523L4OA1bJO02S7MO2oyXDCS6iWb9ckIPnqAFVItqSBQr7jw==} + engines: {node: '>=18.12.0'} + hasBin: true + + supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + + supports-hyperlinks@3.2.0: + resolution: {integrity: sha512-zFObLMyZeEwzAoKCyu1B91U79K2t7ApXuQfo8OuxwXLDgcKxuwM+YvcbIhm6QWqz7mHUH1TVytR1PwVVjEuMig==} + engines: {node: '>=14.18'} + + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + + tsconfck@3.1.6: + resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + which@1.3.1: + resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} + hasBin: true + + write-file-atomic@5.0.1: + resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + zod@4.3.5: + resolution: {integrity: sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==} + + zustand@5.0.9: + resolution: {integrity: sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + +snapshots: + + '@babel/code-frame@7.27.1': + dependencies: + '@babel/helper-validator-identifier': 7.28.5 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.28.5': {} + + '@babel/core@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-compilation-targets': 7.27.2 + '@babel/helper-module-transforms': 7.28.3(@babel/core@7.28.5) + '@babel/helpers': 7.28.4 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.28.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.27.2': + dependencies: + '@babel/compat-data': 7.28.5 + '@babel/helper-validator-option': 7.27.1 + browserslist: 4.28.1 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.28.0': {} + + '@babel/helper-module-imports@7.27.1': + dependencies: + '@babel/traverse': 7.28.5 + '@babel/types': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.28.3(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-module-imports': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + '@babel/traverse': 7.28.5 + transitivePeerDependencies: + - supports-color + + '@babel/helper-plugin-utils@7.27.1': {} + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/helper-validator-option@7.27.1': {} + + '@babel/helpers@7.28.4': + dependencies: + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + + '@babel/parser@7.28.5': + dependencies: + '@babel/types': 7.28.5 + + '@babel/plugin-transform-react-jsx-self@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/plugin-transform-react-jsx-source@7.27.1(@babel/core@7.28.5)': + dependencies: + '@babel/core': 7.28.5 + '@babel/helper-plugin-utils': 7.27.1 + + '@babel/template@7.27.2': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@babel/traverse@7.28.5': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.28.5 + '@babel/helper-globals': 7.28.0 + '@babel/parser': 7.28.5 + '@babel/template': 7.27.2 + '@babel/types': 7.28.5 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.28.5': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@biomejs/biome@2.3.11': + optionalDependencies: + '@biomejs/cli-darwin-arm64': 2.3.11 + '@biomejs/cli-darwin-x64': 2.3.11 + '@biomejs/cli-linux-arm64': 2.3.11 + '@biomejs/cli-linux-arm64-musl': 2.3.11 + '@biomejs/cli-linux-x64': 2.3.11 + '@biomejs/cli-linux-x64-musl': 2.3.11 + '@biomejs/cli-win32-arm64': 2.3.11 + '@biomejs/cli-win32-x64': 2.3.11 + + '@biomejs/cli-darwin-arm64@2.3.11': + optional: true + + '@biomejs/cli-darwin-x64@2.3.11': + optional: true + + '@biomejs/cli-linux-arm64-musl@2.3.11': + optional: true + + '@biomejs/cli-linux-arm64@2.3.11': + optional: true + + '@biomejs/cli-linux-x64-musl@2.3.11': + optional: true + + '@biomejs/cli-linux-x64@2.3.11': + optional: true + + '@biomejs/cli-win32-arm64@2.3.11': + optional: true + + '@biomejs/cli-win32-x64@2.3.11': + optional: true + + '@cacheable/memory@2.0.7': + dependencies: + '@cacheable/utils': 2.3.3 + '@keyv/bigmap': 1.3.0(keyv@5.5.5) + hookified: 1.15.0 + keyv: 5.5.5 + + '@cacheable/utils@2.3.3': + dependencies: + hashery: 1.4.0 + keyv: 5.5.5 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-syntax-patches-for-csstree@1.0.22': {} + + '@csstools/css-tokenizer@3.0.4': {} + + '@csstools/media-query-list-parser@4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/selector-specificity@5.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@dual-bundle/import-meta-resolve@4.2.1': {} + + '@emnapi/core@1.8.1': + dependencies: + '@emnapi/wasi-threads': 1.1.0 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.8.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.1.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@keyv/bigmap@1.3.0(keyv@5.5.5)': + dependencies: + hashery: 1.4.0 + hookified: 1.15.0 + keyv: 5.5.5 + + '@keyv/serialize@1.1.1': {} + + '@napi-rs/wasm-runtime@1.1.1': + dependencies: + '@emnapi/core': 1.8.1 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@oxc-project/runtime@0.97.0': {} + + '@oxc-project/types@0.97.0': {} + + '@parcel/watcher-android-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-arm64@2.5.1': + optional: true + + '@parcel/watcher-darwin-x64@2.5.1': + optional: true + + '@parcel/watcher-freebsd-x64@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.5.1': + optional: true + + '@parcel/watcher-linux-x64-musl@2.5.1': + optional: true + + '@parcel/watcher-win32-arm64@2.5.1': + optional: true + + '@parcel/watcher-win32-ia32@2.5.1': + optional: true + + '@parcel/watcher-win32-x64@2.5.1': + optional: true + + '@parcel/watcher@2.5.1': + dependencies: + detect-libc: 1.0.3 + is-glob: 4.0.3 + micromatch: 4.0.8 + node-addon-api: 7.1.1 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.1 + '@parcel/watcher-darwin-arm64': 2.5.1 + '@parcel/watcher-darwin-x64': 2.5.1 + '@parcel/watcher-freebsd-x64': 2.5.1 + '@parcel/watcher-linux-arm-glibc': 2.5.1 + '@parcel/watcher-linux-arm-musl': 2.5.1 + '@parcel/watcher-linux-arm64-glibc': 2.5.1 + '@parcel/watcher-linux-arm64-musl': 2.5.1 + '@parcel/watcher-linux-x64-glibc': 2.5.1 + '@parcel/watcher-linux-x64-musl': 2.5.1 + '@parcel/watcher-win32-arm64': 2.5.1 + '@parcel/watcher-win32-ia32': 2.5.1 + '@parcel/watcher-win32-x64': 2.5.1 + optional: true + + '@rolldown/binding-android-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-darwin-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.0-beta.50': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.0-beta.50': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.0-beta.50': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.0-beta.50': + dependencies: + '@napi-rs/wasm-runtime': 1.1.1 + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-ia32-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.0-beta.50': + optional: true + + '@rolldown/pluginutils@1.0.0-beta.50': {} + + '@rolldown/pluginutils@1.0.0-beta.53': {} + + '@tanstack/query-core@5.90.16': {} + + '@tanstack/query-devtools@5.92.0': {} + + '@tanstack/react-query-devtools@5.91.2(@tanstack/react-query@5.90.16(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/query-devtools': 5.92.0 + '@tanstack/react-query': 5.90.16(react@19.2.3) + react: 19.2.3 + + '@tanstack/react-query@5.90.16(react@19.2.3)': + dependencies: + '@tanstack/query-core': 5.90.16 + react: 19.2.3 + + '@tybys/wasm-util@0.10.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + '@types/babel__generator': 7.27.0 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.28.0 + + '@types/babel__generator@7.27.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.28.5 + '@babel/types': 7.28.5 + + '@types/babel__traverse@7.28.0': + dependencies: + '@babel/types': 7.28.5 + + '@types/node@24.10.4': + dependencies: + undici-types: 7.16.0 + + '@types/react-dom@19.2.3(@types/react@19.2.7)': + dependencies: + '@types/react': 19.2.7 + + '@types/react@19.2.7': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@5.1.2(rolldown-vite@7.2.5(@types/node@24.10.4)(sass@1.97.2))': + dependencies: + '@babel/core': 7.28.5 + '@babel/plugin-transform-react-jsx-self': 7.27.1(@babel/core@7.28.5) + '@babel/plugin-transform-react-jsx-source': 7.27.1(@babel/core@7.28.5) + '@rolldown/pluginutils': 1.0.0-beta.53 + '@types/babel__core': 7.20.5 + react-refresh: 0.18.0 + vite: rolldown-vite@7.2.5(@types/node@24.10.4)(sass@1.97.2) + transitivePeerDependencies: + - supports-color + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-regex@5.0.1: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + array-union@2.1.0: {} + + astral-regex@2.0.0: {} + + asynckit@0.4.0: {} + + axios@1.13.2: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + + babel-runtime@5.8.38: + dependencies: + core-js: 1.2.7 + + balanced-match@2.0.0: {} + + baseline-browser-mapping@2.9.11: {} + + braces@3.0.3: + dependencies: + fill-range: 7.1.1 + + browserslist@4.28.1: + dependencies: + baseline-browser-mapping: 2.9.11 + caniuse-lite: 1.0.30001762 + electron-to-chromium: 1.5.267 + node-releases: 2.0.27 + update-browserslist-db: 1.2.3(browserslist@4.28.1) + + cacheable@2.3.1: + dependencies: + '@cacheable/memory': 2.0.7 + '@cacheable/utils': 2.3.3 + hookified: 1.15.0 + keyv: 5.5.5 + qified: 0.5.3 + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + callsites@3.1.0: {} + + caniuse-lite@1.0.30001762: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + colord@2.9.3: {} + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + convert-source-map@2.0.0: {} + + cookie@1.1.1: {} + + core-js@1.2.7: {} + + cosmiconfig@9.0.0(typescript@5.9.3): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 5.9.3 + + css-functions-list@3.2.3: {} + + css-tree@3.1.0: + dependencies: + mdn-data: 2.12.2 + source-map-js: 1.2.1 + + cssesc@3.0.0: {} + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + detect-libc@1.0.3: + optional: true + + detect-libc@2.1.2: {} + + dir-glob@3.0.1: + dependencies: + path-type: 4.0.0 + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + electron-to-chromium@1.5.267: {} + + emoji-regex@8.0.0: {} + + env-paths@2.2.1: {} + + error-ex@1.3.4: + dependencies: + is-arrayish: 0.2.1 + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + + escalade@3.2.0: {} + + fast-deep-equal@3.1.3: {} + + fast-glob@3.3.3: + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + + fast-uri@3.1.0: {} + + fastest-levenshtein@1.0.16: {} + + fastq@1.20.1: + dependencies: + reusify: 1.1.0 + + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + + file-entry-cache@11.1.1: + dependencies: + flat-cache: 6.1.19 + + fill-range@7.1.1: + dependencies: + to-regex-range: 5.0.1 + + flat-cache@6.1.19: + dependencies: + cacheable: 2.3.1 + flatted: 3.3.3 + hookified: 1.15.0 + + flatted@3.3.3: {} + + follow-redirects@1.15.11: {} + + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + gensync@1.0.0-beta.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.2 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + glob-parent@5.1.2: + dependencies: + is-glob: 4.0.3 + + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + + globby@11.1.0: + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.3 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + + globjoin@0.1.4: {} + + globrex@0.1.2: {} + + gopd@1.2.0: {} + + has-flag@4.0.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hashery@1.4.0: + dependencies: + hookified: 1.15.0 + + hasown@2.0.2: + dependencies: + function-bind: 1.1.2 + + hookified@1.15.0: {} + + html-tags@3.3.1: {} + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + immutable@5.1.4: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + ini@1.3.8: {} + + is-arrayish@0.2.1: {} + + is-extglob@2.1.1: {} + + is-fullwidth-code-point@3.0.0: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + is-number@7.0.0: {} + + is-plain-object@5.0.0: {} + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + js-yaml@4.1.1: + dependencies: + argparse: 2.0.1 + + jsesc@3.1.0: {} + + json-parse-even-better-errors@2.3.1: {} + + json-schema-traverse@1.0.0: {} + + json5@2.2.3: {} + + keyv@5.5.5: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + + known-css-properties@0.37.0: {} + + lightningcss-android-arm64@1.30.2: + optional: true + + lightningcss-darwin-arm64@1.30.2: + optional: true + + lightningcss-darwin-x64@1.30.2: + optional: true + + lightningcss-freebsd-x64@1.30.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.30.2: + optional: true + + lightningcss-linux-arm64-gnu@1.30.2: + optional: true + + lightningcss-linux-arm64-musl@1.30.2: + optional: true + + lightningcss-linux-x64-gnu@1.30.2: + optional: true + + lightningcss-linux-x64-musl@1.30.2: + optional: true + + lightningcss-win32-arm64-msvc@1.30.2: + optional: true + + lightningcss-win32-x64-msvc@1.30.2: + optional: true + + lightningcss@1.30.2: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.30.2 + lightningcss-darwin-arm64: 1.30.2 + lightningcss-darwin-x64: 1.30.2 + lightningcss-freebsd-x64: 1.30.2 + lightningcss-linux-arm-gnueabihf: 1.30.2 + lightningcss-linux-arm64-gnu: 1.30.2 + lightningcss-linux-arm64-musl: 1.30.2 + lightningcss-linux-x64-gnu: 1.30.2 + lightningcss-linux-x64-musl: 1.30.2 + lightningcss-win32-arm64-msvc: 1.30.2 + lightningcss-win32-x64-msvc: 1.30.2 + + lines-and-columns@1.2.4: {} + + lodash.truncate@4.4.2: {} + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + math-intrinsics@1.1.0: {} + + mathml-tag-names@2.1.3: {} + + mdn-data@2.12.2: {} + + mdn-data@2.25.0: {} + + meow@13.2.0: {} + + merge2@1.4.1: {} + + micromatch@4.0.8: + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + ms@2.1.3: {} + + nanoid@3.3.11: {} + + node-addon-api@7.1.1: + optional: true + + node-releases@2.0.27: {} + + normalize-path@3.0.0: {} + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.27.1 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + + path-type@4.0.0: {} + + picocolors@1.1.1: {} + + picomatch@2.3.1: {} + + picomatch@4.0.3: {} + + postcss-media-query-parser@0.2.3: {} + + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.1(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-scss@4.0.9(postcss@8.5.6): + dependencies: + postcss: 8.5.6 + + postcss-selector-parser@7.1.1: + dependencies: + cssesc: 3.0.0 + util-deprecate: 1.0.2 + + postcss-value-parser@4.2.0: {} + + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proxy-from-env@1.1.0: {} + + qified@0.5.3: + dependencies: + hookified: 1.15.0 + + queue-microtask@1.2.3: {} + + react-dom@19.2.3(react@19.2.3): + dependencies: + react: 19.2.3 + scheduler: 0.27.0 + + react-error-boundary@6.0.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + react-icon@1.0.0(babel-runtime@5.8.38)(react@19.2.3): + dependencies: + babel-runtime: 5.8.38 + react: 19.2.3 + + react-icons@5.5.0(react@19.2.3): + dependencies: + react: 19.2.3 + + react-refresh@0.18.0: {} + + react-router-dom@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + react-router: 7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + + react-router@7.11.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + cookie: 1.1.1 + react: 19.2.3 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + + react@19.2.3: {} + + readdirp@4.1.2: {} + + require-from-string@2.0.2: {} + + resolve-from@4.0.0: {} + + resolve-from@5.0.0: {} + + reusify@1.1.0: {} + + rolldown-vite@7.2.5(@types/node@24.10.4)(sass@1.97.2): + dependencies: + '@oxc-project/runtime': 0.97.0 + fdir: 6.5.0(picomatch@4.0.3) + lightningcss: 1.30.2 + picomatch: 4.0.3 + postcss: 8.5.6 + rolldown: 1.0.0-beta.50 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 24.10.4 + fsevents: 2.3.3 + sass: 1.97.2 + + rolldown@1.0.0-beta.50: + dependencies: + '@oxc-project/types': 0.97.0 + '@rolldown/pluginutils': 1.0.0-beta.50 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-arm64': 1.0.0-beta.50 + '@rolldown/binding-darwin-x64': 1.0.0-beta.50 + '@rolldown/binding-freebsd-x64': 1.0.0-beta.50 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-arm64-musl': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-gnu': 1.0.0-beta.50 + '@rolldown/binding-linux-x64-musl': 1.0.0-beta.50 + '@rolldown/binding-openharmony-arm64': 1.0.0-beta.50 + '@rolldown/binding-wasm32-wasi': 1.0.0-beta.50 + '@rolldown/binding-win32-arm64-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.50 + '@rolldown/binding-win32-x64-msvc': 1.0.0-beta.50 + + run-parallel@1.2.0: + dependencies: + queue-microtask: 1.2.3 + + sass@1.97.2: + dependencies: + chokidar: 4.0.3 + immutable: 5.1.4 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.5.1 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + set-cookie-parser@2.7.2: {} + + signal-exit@4.1.0: {} + + slash@3.0.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + + sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + + source-map-js@1.2.1: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + stylelint-config-prettier-scss@1.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-recommended-scss@16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + postcss-scss: 4.0.9(postcss@8.5.6) + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + stylelint-scss: 6.14.0(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-recommended@17.0.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + + stylelint-config-standard-scss@16.0.0(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended-scss: 16.0.2(postcss@8.5.6)(stylelint@16.26.1(typescript@5.9.3)) + stylelint-config-standard: 39.0.1(stylelint@16.26.1(typescript@5.9.3)) + optionalDependencies: + postcss: 8.5.6 + + stylelint-config-standard@39.0.1(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + stylelint: 16.26.1(typescript@5.9.3) + stylelint-config-recommended: 17.0.0(stylelint@16.26.1(typescript@5.9.3)) + + stylelint-scss@6.14.0(stylelint@16.26.1(typescript@5.9.3)): + dependencies: + css-tree: 3.1.0 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mdn-data: 2.25.0 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + stylelint: 16.26.1(typescript@5.9.3) + + stylelint@16.26.1(typescript@5.9.3): + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-syntax-patches-for-csstree': 1.0.22 + '@csstools/css-tokenizer': 3.0.4 + '@csstools/media-query-list-parser': 4.0.3(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/selector-specificity': 5.0.0(postcss-selector-parser@7.1.1) + '@dual-bundle/import-meta-resolve': 4.2.1 + balanced-match: 2.0.0 + colord: 2.9.3 + cosmiconfig: 9.0.0(typescript@5.9.3) + css-functions-list: 3.2.3 + css-tree: 3.1.0 + debug: 4.4.3 + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.1 + global-modules: 2.0.0 + globby: 11.1.0 + globjoin: 0.1.4 + html-tags: 3.3.1 + ignore: 7.0.5 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mathml-tag-names: 2.1.3 + meow: 13.2.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.6 + postcss-resolve-nested-selector: 0.1.6 + postcss-safe-parser: 7.0.1(postcss@8.5.6) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + resolve-from: 5.0.0 + string-width: 4.2.3 + supports-hyperlinks: 3.2.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 5.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + supports-hyperlinks@3.2.0: + dependencies: + has-flag: 4.0.0 + supports-color: 7.2.0 + + svg-tags@1.0.0: {} + + table@6.9.0: + dependencies: + ajv: 8.17.1 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + to-regex-range@5.0.1: + dependencies: + is-number: 7.0.0 + + tsconfck@3.1.6(typescript@5.9.3): + optionalDependencies: + typescript: 5.9.3 + + tslib@2.8.1: + optional: true + + typescript@5.9.3: {} + + undici-types@7.16.0: {} + + update-browserslist-db@1.2.3(browserslist@4.28.1): + dependencies: + browserslist: 4.28.1 + escalade: 3.2.0 + picocolors: 1.1.1 + + util-deprecate@1.0.2: {} + + vite-tsconfig-paths@5.1.4(rolldown-vite@7.2.5(@types/node@24.10.4)(sass@1.97.2))(typescript@5.9.3): + dependencies: + debug: 4.4.3 + globrex: 0.1.2 + tsconfck: 3.1.6(typescript@5.9.3) + optionalDependencies: + vite: rolldown-vite@7.2.5(@types/node@24.10.4)(sass@1.97.2) + transitivePeerDependencies: + - supports-color + - typescript + + which@1.3.1: + dependencies: + isexe: 2.0.0 + + write-file-atomic@5.0.1: + dependencies: + imurmurhash: 0.1.4 + signal-exit: 4.1.0 + + yallist@3.1.1: {} + + zod@4.3.5: {} + + zustand@5.0.9(@types/react@19.2.7)(react@19.2.3): + optionalDependencies: + '@types/react': 19.2.7 + react: 19.2.3 diff --git a/PROJECTS/bug-bounty-platform/frontend/public/assets/android-chrome-192x192.png b/PROJECTS/bug-bounty-platform/frontend/public/assets/android-chrome-192x192.png new file mode 100644 index 00000000..01d0a08e Binary files /dev/null and b/PROJECTS/bug-bounty-platform/frontend/public/assets/android-chrome-192x192.png differ diff --git a/PROJECTS/bug-bounty-platform/frontend/public/assets/android-chrome-512x512.png b/PROJECTS/bug-bounty-platform/frontend/public/assets/android-chrome-512x512.png new file mode 100644 index 00000000..3ea8cdf1 Binary files /dev/null and b/PROJECTS/bug-bounty-platform/frontend/public/assets/android-chrome-512x512.png differ diff --git a/PROJECTS/bug-bounty-platform/frontend/public/assets/apple-touch-icon.png b/PROJECTS/bug-bounty-platform/frontend/public/assets/apple-touch-icon.png new file mode 100644 index 00000000..ec126db9 Binary files /dev/null and b/PROJECTS/bug-bounty-platform/frontend/public/assets/apple-touch-icon.png differ diff --git a/PROJECTS/bug-bounty-platform/frontend/public/assets/favicon-16x16.png b/PROJECTS/bug-bounty-platform/frontend/public/assets/favicon-16x16.png new file mode 100644 index 00000000..75b62905 Binary files /dev/null and b/PROJECTS/bug-bounty-platform/frontend/public/assets/favicon-16x16.png differ diff --git a/PROJECTS/bug-bounty-platform/frontend/public/assets/favicon-32x32.png b/PROJECTS/bug-bounty-platform/frontend/public/assets/favicon-32x32.png new file mode 100644 index 00000000..338e90d0 Binary files /dev/null and b/PROJECTS/bug-bounty-platform/frontend/public/assets/favicon-32x32.png differ diff --git a/PROJECTS/bug-bounty-platform/frontend/public/assets/favicon.ico b/PROJECTS/bug-bounty-platform/frontend/public/assets/favicon.ico new file mode 100644 index 00000000..cf617467 Binary files /dev/null and b/PROJECTS/bug-bounty-platform/frontend/public/assets/favicon.ico differ diff --git a/PROJECTS/bug-bounty-platform/frontend/public/assets/site.webmanifest b/PROJECTS/bug-bounty-platform/frontend/public/assets/site.webmanifest new file mode 100644 index 00000000..45dc8a20 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/public/assets/site.webmanifest @@ -0,0 +1 @@ +{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"} \ No newline at end of file diff --git a/PROJECTS/bug-bounty-platform/frontend/src/App.tsx b/PROJECTS/bug-bounty-platform/frontend/src/App.tsx new file mode 100644 index 00000000..a7d82d6a --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/App.tsx @@ -0,0 +1,36 @@ +// =========================== +// ©AngelaMos | 2026 +// App.tsx +// =========================== + +import { QueryClientProvider } from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' +import { RouterProvider } from 'react-router-dom' +import { Toaster } from 'sonner' + +import { queryClient } from '@/core/api' +import { router } from '@/core/app/routers' +import '@/core/app/toast.module.scss' + +export default function App(): React.ReactElement { + return ( + +
+ + +
+ +
+ ) +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/index.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/index.ts new file mode 100644 index 00000000..e04ac577 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/index.ts @@ -0,0 +1,10 @@ +// =================== +// AngelaMos | 2025 +// index.ts +// =================== + +export * from './useAdmin' +export * from './useAuth' +export * from './usePrograms' +export * from './useReports' +export * from './useUsers' diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/useAdmin.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/useAdmin.ts new file mode 100644 index 00000000..2b86c9d5 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/useAdmin.ts @@ -0,0 +1,600 @@ +// =================== +// AngelaMos | 2026 +// useAdmin.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { + ADMIN_ERROR_MESSAGES, + ADMIN_SUCCESS_MESSAGES, + type AdminProgramListResponse, + type AdminProgramResponse, + type AdminProgramUpdate, + type AdminReportListResponse, + type AdminReportResponse, + type AdminReportUpdate, + AdminResponseError, + type AdminUserCreateRequest, + type AdminUserListResponse, + type AdminUserUpdateRequest, + isValidAdminProgramListResponse, + isValidAdminProgramResponse, + isValidAdminReportListResponse, + isValidAdminReportResponse, + isValidAdminUserListResponse, + isValidPlatformStatsResponse, + isValidUserListResponse, + isValidUserResponse, + type PlatformStatsResponse, + USER_ERROR_MESSAGES, + USER_SUCCESS_MESSAGES, + type UserListResponse, + type UserResponse, + UserResponseError, +} from '@/api/types' +import { API_ENDPOINTS, PAGINATION, QUERY_KEYS } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' + +export const adminQueries = { + all: () => QUERY_KEYS.ADMIN.ALL, + stats: () => QUERY_KEYS.ADMIN.STATS(), + users: { + all: () => QUERY_KEYS.ADMIN.USERS.ALL(), + list: (page: number, size: number, role?: string) => + QUERY_KEYS.ADMIN.USERS.LIST(page, size, role), + byId: (id: string) => QUERY_KEYS.ADMIN.USERS.BY_ID(id), + }, + programs: { + all: () => QUERY_KEYS.ADMIN.PROGRAMS.ALL(), + list: (page: number, size: number, status?: string) => + QUERY_KEYS.ADMIN.PROGRAMS.LIST(page, size, status), + }, + reports: { + all: () => QUERY_KEYS.ADMIN.REPORTS.ALL(), + list: (page: number, size: number, status?: string, severity?: string) => + QUERY_KEYS.ADMIN.REPORTS.LIST(page, size, status, severity), + }, +} as const + +interface UseAdminUsersParams { + page?: number + size?: number +} + +const fetchAdminUsers = async ( + page: number, + size: number +): Promise => { + const response = await apiClient.get(API_ENDPOINTS.ADMIN.USERS.LIST, { + params: { page, size }, + }) + const data: unknown = response.data + + if (!isValidUserListResponse(data)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_LIST_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.LIST + ) + } + + return data +} + +export const useAdminUsers = ( + params: UseAdminUsersParams = {} +): UseQueryResult => { + const page = params.page ?? PAGINATION.DEFAULT_PAGE + const size = params.size ?? PAGINATION.DEFAULT_SIZE + + return useQuery({ + queryKey: adminQueries.users.list(page, size), + queryFn: () => fetchAdminUsers(page, size), + ...QUERY_STRATEGIES.standard, + }) +} + +const fetchAdminUserById = async (id: string): Promise => { + const response = await apiClient.get( + API_ENDPOINTS.ADMIN.USERS.BY_ID(id) + ) + const data: unknown = response.data + + if (!isValidUserResponse(data)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.BY_ID(id) + ) + } + + return data +} + +export const useAdminUser = (id: string): UseQueryResult => { + return useQuery({ + queryKey: adminQueries.users.byId(id), + queryFn: () => fetchAdminUserById(id), + enabled: id.length > 0, + ...QUERY_STRATEGIES.standard, + }) +} + +const performAdminCreateUser = async ( + data: AdminUserCreateRequest +): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.ADMIN.USERS.CREATE, + data + ) + const responseData: unknown = response.data + + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.CREATE + ) + } + + return responseData +} + +export const useAdminCreateUser = (): UseMutationResult< + UserResponse, + Error, + AdminUserCreateRequest +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminCreateUser, + onSuccess: (newUser: UserResponse): void => { + queryClient.setQueriesData( + { queryKey: adminQueries.users.all() }, + (oldData) => { + if (!oldData) return oldData + return { + ...oldData, + items: [newUser, ...oldData.items], + total: oldData.total + 1, + } + } + ) + toast.success(USER_SUCCESS_MESSAGES.CREATED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_CREATE + toast.error(message) + }, + }) +} + +interface AdminUpdateUserParams { + id: string + data: AdminUserUpdateRequest +} + +const performAdminUpdateUser = async ( + params: AdminUpdateUserParams +): Promise => { + const response = await apiClient.patch( + API_ENDPOINTS.ADMIN.USERS.UPDATE(params.id), + params.data + ) + const responseData: unknown = response.data + + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.UPDATE(params.id) + ) + } + + return responseData +} + +export const useAdminUpdateUser = (): UseMutationResult< + UserResponse, + Error, + AdminUpdateUserParams +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminUpdateUser, + onSuccess: ( + updatedUser: UserResponse, + variables: AdminUpdateUserParams + ): void => { + queryClient.setQueryData(adminQueries.users.byId(variables.id), updatedUser) + queryClient.setQueriesData( + { queryKey: adminQueries.users.all() }, + (oldData) => { + if (!oldData) return oldData + return { + ...oldData, + items: oldData.items.map((user) => + user.id === updatedUser.id ? updatedUser : user + ), + } + } + ) + toast.success(USER_SUCCESS_MESSAGES.UPDATED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_UPDATE + toast.error(message) + }, + }) +} + +const performAdminDeleteUser = async (id: string): Promise => { + await apiClient.delete(API_ENDPOINTS.ADMIN.USERS.DELETE(id)) +} + +export const useAdminDeleteUser = (): UseMutationResult => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminDeleteUser, + onSuccess: (_, deletedId: string): void => { + queryClient.removeQueries({ queryKey: adminQueries.users.byId(deletedId) }) + queryClient.setQueriesData( + { queryKey: adminQueries.users.all() }, + (oldData) => { + if (!oldData) return oldData + return { + ...oldData, + items: oldData.items.filter((user) => user.id !== deletedId), + total: oldData.total - 1, + } + } + ) + toast.success(USER_SUCCESS_MESSAGES.DELETED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_DELETE + toast.error(message) + }, + }) +} + +const fetchPlatformStats = async (): Promise => { + const response = await apiClient.get(API_ENDPOINTS.ADMIN.STATS) + const data: unknown = response.data + + if (!isValidPlatformStatsResponse(data)) { + throw new AdminResponseError( + ADMIN_ERROR_MESSAGES.INVALID_STATS_RESPONSE, + API_ENDPOINTS.ADMIN.STATS + ) + } + + return data +} + +export const usePlatformStats = (): UseQueryResult< + PlatformStatsResponse, + Error +> => { + return useQuery({ + queryKey: adminQueries.stats(), + queryFn: fetchPlatformStats, + ...QUERY_STRATEGIES.standard, + }) +} + +interface UseAdminProgramsParams { + page?: number + size?: number + status?: string +} + +const fetchAdminPrograms = async ( + page: number, + size: number, + status?: string +): Promise => { + const params: Record = { page, size } + if (status) { + params.status = status + } + + const response = await apiClient.get( + API_ENDPOINTS.ADMIN.PROGRAMS.LIST, + { + params, + } + ) + const data: unknown = response.data + + if (!isValidAdminProgramListResponse(data)) { + throw new AdminResponseError( + ADMIN_ERROR_MESSAGES.INVALID_PROGRAM_LIST_RESPONSE, + API_ENDPOINTS.ADMIN.PROGRAMS.LIST + ) + } + + return data +} + +export const useAdminPrograms = ( + params: UseAdminProgramsParams = {} +): UseQueryResult => { + const page = params.page ?? PAGINATION.DEFAULT_PAGE + const size = params.size ?? PAGINATION.DEFAULT_SIZE + + return useQuery({ + queryKey: adminQueries.programs.list(page, size, params.status), + queryFn: () => fetchAdminPrograms(page, size, params.status), + ...QUERY_STRATEGIES.standard, + }) +} + +interface AdminUpdateProgramParams { + id: string + data: AdminProgramUpdate +} + +const performAdminUpdateProgram = async ( + params: AdminUpdateProgramParams +): Promise => { + const response = await apiClient.patch( + API_ENDPOINTS.ADMIN.PROGRAMS.UPDATE(params.id), + params.data + ) + const responseData: unknown = response.data + + if (!isValidAdminProgramResponse(responseData)) { + throw new AdminResponseError( + ADMIN_ERROR_MESSAGES.INVALID_PROGRAM_RESPONSE, + API_ENDPOINTS.ADMIN.PROGRAMS.UPDATE(params.id) + ) + } + + return responseData +} + +export const useAdminUpdateProgram = (): UseMutationResult< + AdminProgramResponse, + Error, + AdminUpdateProgramParams +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminUpdateProgram, + onSuccess: (updatedProgram: AdminProgramResponse): void => { + queryClient.setQueriesData( + { queryKey: adminQueries.programs.all() }, + (oldData) => { + if (!oldData) return oldData + return { + ...oldData, + items: oldData.items.map((program) => + program.id === updatedProgram.id ? updatedProgram : program + ), + } + } + ) + toast.success(ADMIN_SUCCESS_MESSAGES.PROGRAM_UPDATED) + }, + onError: (error: Error): void => { + const message = + error instanceof AdminResponseError + ? error.message + : ADMIN_ERROR_MESSAGES.FAILED_TO_UPDATE_PROGRAM + toast.error(message) + }, + }) +} + +const performAdminDeleteProgram = async (id: string): Promise => { + await apiClient.delete(API_ENDPOINTS.ADMIN.PROGRAMS.DELETE(id)) +} + +export const useAdminDeleteProgram = (): UseMutationResult< + void, + Error, + string +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminDeleteProgram, + onSuccess: (_, deletedId: string): void => { + queryClient.setQueriesData( + { queryKey: adminQueries.programs.all() }, + (oldData) => { + if (!oldData) return oldData + return { + ...oldData, + items: oldData.items.filter((program) => program.id !== deletedId), + total: oldData.total - 1, + } + } + ) + toast.success(ADMIN_SUCCESS_MESSAGES.PROGRAM_DELETED) + }, + onError: (error: Error): void => { + const message = + error instanceof AdminResponseError + ? error.message + : ADMIN_ERROR_MESSAGES.FAILED_TO_DELETE_PROGRAM + toast.error(message) + }, + }) +} + +interface UseAdminReportsParams { + page?: number + size?: number + status?: string + severity?: string +} + +const fetchAdminReports = async ( + page: number, + size: number, + status?: string, + severity?: string +): Promise => { + const params: Record = { page, size } + if (status) { + params.status = status + } + if (severity) { + params.severity = severity + } + + const response = await apiClient.get( + API_ENDPOINTS.ADMIN.REPORTS.LIST, + { + params, + } + ) + const data: unknown = response.data + + if (!isValidAdminReportListResponse(data)) { + throw new AdminResponseError( + ADMIN_ERROR_MESSAGES.INVALID_REPORT_LIST_RESPONSE, + API_ENDPOINTS.ADMIN.REPORTS.LIST + ) + } + + return data +} + +export const useAdminReports = ( + params: UseAdminReportsParams = {} +): UseQueryResult => { + const page = params.page ?? PAGINATION.DEFAULT_PAGE + const size = params.size ?? PAGINATION.DEFAULT_SIZE + + return useQuery({ + queryKey: adminQueries.reports.list( + page, + size, + params.status, + params.severity + ), + queryFn: () => fetchAdminReports(page, size, params.status, params.severity), + ...QUERY_STRATEGIES.standard, + }) +} + +interface AdminUpdateReportParams { + id: string + data: AdminReportUpdate +} + +const performAdminUpdateReport = async ( + params: AdminUpdateReportParams +): Promise => { + const response = await apiClient.patch( + API_ENDPOINTS.ADMIN.REPORTS.UPDATE(params.id), + params.data + ) + const responseData: unknown = response.data + + if (!isValidAdminReportResponse(responseData)) { + throw new AdminResponseError( + ADMIN_ERROR_MESSAGES.INVALID_REPORT_RESPONSE, + API_ENDPOINTS.ADMIN.REPORTS.UPDATE(params.id) + ) + } + + return responseData +} + +export const useAdminUpdateReport = (): UseMutationResult< + AdminReportResponse, + Error, + AdminUpdateReportParams +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: performAdminUpdateReport, + onSuccess: (updatedReport: AdminReportResponse): void => { + queryClient.setQueriesData( + { queryKey: adminQueries.reports.all() }, + (oldData) => { + if (!oldData) return oldData + return { + ...oldData, + items: oldData.items.map((report) => + report.id === updatedReport.id ? updatedReport : report + ), + } + } + ) + toast.success(ADMIN_SUCCESS_MESSAGES.REPORT_UPDATED) + }, + onError: (error: Error): void => { + const message = + error instanceof AdminResponseError + ? error.message + : ADMIN_ERROR_MESSAGES.FAILED_TO_UPDATE_REPORT + toast.error(message) + }, + }) +} + +interface UseAdminUsersWithStatsParams { + page?: number + size?: number + role?: string +} + +const fetchAdminUsersWithStats = async ( + page: number, + size: number, + role?: string +): Promise => { + const params: Record = { page, size } + if (role) { + params.role = role + } + + const response = await apiClient.get(API_ENDPOINTS.ADMIN.USERS.LIST, { + params, + }) + const data: unknown = response.data + + if (!isValidAdminUserListResponse(data)) { + throw new AdminResponseError( + ADMIN_ERROR_MESSAGES.INVALID_USER_LIST_RESPONSE, + API_ENDPOINTS.ADMIN.USERS.LIST + ) + } + + return data +} + +export const useAdminUsersWithStats = ( + params: UseAdminUsersWithStatsParams = {} +): UseQueryResult => { + const page = params.page ?? PAGINATION.DEFAULT_PAGE + const size = params.size ?? PAGINATION.DEFAULT_SIZE + + return useQuery({ + queryKey: adminQueries.users.list(page, size, params.role), + queryFn: () => fetchAdminUsersWithStats(page, size, params.role), + ...QUERY_STRATEGIES.standard, + }) +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/useAuth.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/useAuth.ts new file mode 100644 index 00000000..a0aea655 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/useAuth.ts @@ -0,0 +1,243 @@ +// =================== +// © AngelaMos | 2025 +// useAuth.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { + AUTH_ERROR_MESSAGES, + AUTH_SUCCESS_MESSAGES, + AuthResponseError, + isValidLogoutAllResponse, + isValidTokenResponse, + isValidTokenWithUserResponse, + isValidUserResponse, + type LoginRequest, + type LogoutAllResponse, + type PasswordChangeRequest, + type TokenWithUserResponse, + type UserResponse, +} from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS, ROUTES } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' +import { useAuthStore } from '@/core/lib' + +export const authQueries = { + all: () => QUERY_KEYS.AUTH.ALL, + me: () => QUERY_KEYS.AUTH.ME(), +} as const + +const fetchCurrentUser = async (): Promise => { + const response = await apiClient.get(API_ENDPOINTS.AUTH.ME) + const data: unknown = response.data + + if (!isValidUserResponse(data)) { + throw new AuthResponseError( + AUTH_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.AUTH.ME + ) + } + + return data +} + +export const useCurrentUser = (): UseQueryResult => { + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) + + return useQuery({ + queryKey: authQueries.me(), + queryFn: fetchCurrentUser, + enabled: isAuthenticated, + ...QUERY_STRATEGIES.auth, + }) +} + +const performLogin = async ( + credentials: LoginRequest +): Promise => { + const formData = new URLSearchParams() + formData.append('username', credentials.username) + formData.append('password', credentials.password) + + const response = await apiClient.post( + API_ENDPOINTS.AUTH.LOGIN, + formData, + { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + } + ) + + const data: unknown = response.data + + if (!isValidTokenWithUserResponse(data)) { + throw new AuthResponseError( + AUTH_ERROR_MESSAGES.INVALID_LOGIN_RESPONSE, + API_ENDPOINTS.AUTH.LOGIN + ) + } + + return data +} + +export const useLogin = (): UseMutationResult< + TokenWithUserResponse, + Error, + LoginRequest +> => { + const queryClient = useQueryClient() + const login = useAuthStore((s) => s.login) + + return useMutation({ + mutationFn: performLogin, + onSuccess: (data: TokenWithUserResponse): void => { + login(data.user, data.access_token) + + queryClient.setQueryData(authQueries.me(), data.user) + + const welcomeMessage = AUTH_SUCCESS_MESSAGES.WELCOME_BACK( + data.user.full_name + ) + toast.success(welcomeMessage) + }, + onError: (error: Error): void => { + const message = + error instanceof AuthResponseError ? error.message : 'Login failed' + toast.error(message) + }, + }) +} + +const performLogout = async (): Promise => { + await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT) +} + +export const useLogout = (): UseMutationResult => { + const queryClient = useQueryClient() + const logout = useAuthStore((s) => s.logout) + + return useMutation({ + mutationFn: performLogout, + onSuccess: (): void => { + logout() + + queryClient.removeQueries({ queryKey: authQueries.all() }) + + toast.success(AUTH_SUCCESS_MESSAGES.LOGOUT_SUCCESS) + + window.location.href = ROUTES.LOGIN + }, + onError: (): void => { + logout() + queryClient.removeQueries({ queryKey: authQueries.all() }) + window.location.href = ROUTES.LOGIN + }, + }) +} + +const performLogoutAll = async (): Promise => { + const response = await apiClient.post(API_ENDPOINTS.AUTH.LOGOUT_ALL) + const data: unknown = response.data + + if (!isValidLogoutAllResponse(data)) { + throw new AuthResponseError( + AUTH_ERROR_MESSAGES.INVALID_LOGOUT_RESPONSE, + API_ENDPOINTS.AUTH.LOGOUT_ALL + ) + } + + return data +} + +export const useLogoutAll = (): UseMutationResult< + LogoutAllResponse, + Error, + void +> => { + const queryClient = useQueryClient() + const logout = useAuthStore((s) => s.logout) + + return useMutation({ + mutationFn: performLogoutAll, + onSuccess: (data: LogoutAllResponse): void => { + logout() + + queryClient.removeQueries({ queryKey: authQueries.all() }) + + toast.success(`Logged out from ${data.revoked_sessions} session(s)`) + + window.location.href = ROUTES.LOGIN + }, + onError: (error: Error): void => { + const message = + error instanceof AuthResponseError + ? error.message + : 'Failed to logout all sessions' + toast.error(message) + }, + }) +} + +const performPasswordChange = async ( + data: PasswordChangeRequest +): Promise => { + await apiClient.post(API_ENDPOINTS.AUTH.CHANGE_PASSWORD, data) +} + +export const useChangePassword = (): UseMutationResult< + void, + Error, + PasswordChangeRequest +> => { + return useMutation({ + mutationFn: performPasswordChange, + onSuccess: (): void => { + toast.success(AUTH_SUCCESS_MESSAGES.PASSWORD_CHANGED) + }, + onError: (error: Error): void => { + const message = + error instanceof AuthResponseError + ? error.message + : 'Failed to change password' + toast.error(message) + }, + }) +} + +export const useRefreshAuth = (): (() => Promise) => { + const queryClient = useQueryClient() + const { setAccessToken, login, logout } = useAuthStore() + + return async (): Promise => { + try { + const response = await apiClient.post(API_ENDPOINTS.AUTH.REFRESH) + const data: unknown = response.data + + if (!isValidTokenResponse(data)) { + throw new AuthResponseError( + AUTH_ERROR_MESSAGES.INVALID_TOKEN_RESPONSE, + API_ENDPOINTS.AUTH.REFRESH + ) + } + + setAccessToken(data.access_token) + + const userResponse = await apiClient.get(API_ENDPOINTS.AUTH.ME) + const userData: unknown = userResponse.data + + if (isValidUserResponse(userData)) { + login(userData, data.access_token) + queryClient.setQueryData(authQueries.me(), userData) + } + } catch { + logout() + queryClient.removeQueries({ queryKey: authQueries.all() }) + } + } +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/usePrograms.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/usePrograms.ts new file mode 100644 index 00000000..0e54a310 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/usePrograms.ts @@ -0,0 +1,337 @@ +// =================== +// AngelaMos | 2025 +// usePrograms.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' + +import { + type Asset, + type AssetCreate, + isValidProgramDetail, + isValidProgramList, + type Program, + type ProgramCreate, + type ProgramDetail, + type ProgramList, + type ProgramUpdate, + type RewardTier, + type RewardTierCreate, +} from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' + +export const programQueries = { + all: () => QUERY_KEYS.PROGRAMS.ALL, + list: (page: number, size: number) => QUERY_KEYS.PROGRAMS.LIST(page, size), + mine: (page: number, size: number) => QUERY_KEYS.PROGRAMS.MINE(page, size), + bySlug: (slug: string) => QUERY_KEYS.PROGRAMS.BY_SLUG(slug), +} as const + +const fetchPrograms = async ( + page: number, + size: number +): Promise => { + const response = await apiClient.get(API_ENDPOINTS.PROGRAMS.LIST, { + params: { page, size }, + }) + + if (!isValidProgramList(response.data)) { + throw new Error('Invalid program list response') + } + + return response.data +} + +export const usePrograms = ( + page: number = 1, + size: number = 20 +): UseQueryResult => { + return useQuery({ + queryKey: programQueries.list(page, size), + queryFn: () => fetchPrograms(page, size), + }) +} + +const fetchMyPrograms = async ( + page: number, + size: number +): Promise => { + const response = await apiClient.get(API_ENDPOINTS.PROGRAMS.MINE, { + params: { page, size }, + }) + + if (!isValidProgramList(response.data)) { + throw new Error('Invalid program list response') + } + + return response.data +} + +export const useMyPrograms = ( + page: number = 1, + size: number = 20 +): UseQueryResult => { + return useQuery({ + queryKey: programQueries.mine(page, size), + queryFn: () => fetchMyPrograms(page, size), + }) +} + +const fetchProgramBySlug = async (slug: string): Promise => { + const response = await apiClient.get( + API_ENDPOINTS.PROGRAMS.BY_SLUG(slug) + ) + + if (!isValidProgramDetail(response.data)) { + throw new Error('Invalid program detail response') + } + + return response.data +} + +export const useProgram = ( + slug: string +): UseQueryResult => { + return useQuery({ + queryKey: programQueries.bySlug(slug), + queryFn: () => fetchProgramBySlug(slug), + enabled: !!slug, + }) +} + +const createProgram = async (data: ProgramCreate): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.PROGRAMS.CREATE, + data + ) + return response.data +} + +export const useCreateProgram = (): UseMutationResult< + Program, + Error, + ProgramCreate +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: createProgram, + onSuccess: (newProgram) => { + queryClient.invalidateQueries({ queryKey: programQueries.list(1, 20) }) + queryClient.invalidateQueries({ queryKey: programQueries.mine(1, 20) }) + + toast.success(`Program "${newProgram.name}" created successfully`) + }, + onError: () => { + toast.error('Failed to create program') + }, + }) +} + +const updateProgram = async ({ + id, + data, +}: { + id: string + data: ProgramUpdate +}): Promise => { + const response = await apiClient.patch( + API_ENDPOINTS.PROGRAMS.BY_ID(id), + data + ) + return response.data +} + +export const useUpdateProgram = (): UseMutationResult< + Program, + Error, + { id: string; data: ProgramUpdate } +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: updateProgram, + onSuccess: (updatedProgram) => { + const queries = queryClient.getQueriesData({ + queryKey: programQueries.all(), + }) + + for (const [key, data] of queries) { + if (data && data.id === updatedProgram.id) { + queryClient.setQueryData(key, { + ...data, + ...updatedProgram, + }) + } + } + + queryClient.invalidateQueries({ queryKey: programQueries.list(1, 20) }) + queryClient.invalidateQueries({ queryKey: programQueries.mine(1, 20) }) + + toast.success(`Program "${updatedProgram.name}" updated successfully`) + }, + onError: () => { + toast.error('Failed to update program') + }, + }) +} + +const deleteProgram = async (id: string): Promise => { + await apiClient.delete(API_ENDPOINTS.PROGRAMS.BY_ID(id)) +} + +export const useDeleteProgram = (): UseMutationResult => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: deleteProgram, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: programQueries.list(1, 20) }) + queryClient.invalidateQueries({ queryKey: programQueries.mine(1, 20) }) + + toast.success('Program deleted successfully') + }, + onError: () => { + toast.error('Failed to delete program') + }, + }) +} + +const addAsset = async ({ + programId, + data, +}: { + programId: string + data: AssetCreate +}): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.PROGRAMS.ASSETS(programId), + data + ) + return response.data +} + +export const useAddAsset = (): UseMutationResult< + Asset, + Error, + { programId: string; data: AssetCreate } +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: addAsset, + onSuccess: (newAsset, variables) => { + const queries = queryClient.getQueriesData({ + queryKey: programQueries.all(), + }) + + for (const [key, data] of queries) { + if (data && 'assets' in data && data.id === variables.programId) { + queryClient.setQueryData(key, { + ...data, + assets: [...data.assets, newAsset], + }) + } + } + + toast.success('Asset added successfully') + }, + onError: () => { + toast.error('Failed to add asset') + }, + }) +} + +const deleteAsset = async ({ + programId, + assetId, +}: { + programId: string + assetId: string +}): Promise => { + await apiClient.delete(API_ENDPOINTS.PROGRAMS.ASSET(programId, assetId)) +} + +export const useDeleteAsset = (): UseMutationResult< + void, + Error, + { programId: string; assetId: string } +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: deleteAsset, + onSuccess: (_, variables) => { + const queries = queryClient.getQueriesData({ + queryKey: programQueries.all(), + }) + + for (const [key, data] of queries) { + if (data && 'assets' in data && data.id === variables.programId) { + queryClient.setQueryData(key, { + ...data, + assets: data.assets.filter((a) => a.id !== variables.assetId), + }) + } + } + + toast.success('Asset deleted successfully') + }, + onError: () => { + toast.error('Failed to delete asset') + }, + }) +} + +const setRewardTiers = async ({ + programId, + tiers, +}: { + programId: string + tiers: RewardTierCreate[] +}): Promise => { + const response = await apiClient.put( + API_ENDPOINTS.PROGRAMS.REWARDS(programId), + tiers + ) + return response.data +} + +export const useSetRewardTiers = (): UseMutationResult< + RewardTier[], + Error, + { programId: string; tiers: RewardTierCreate[] } +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: setRewardTiers, + onSuccess: (updatedTiers, variables) => { + const queries = queryClient.getQueriesData({ + queryKey: programQueries.all(), + }) + + for (const [key, data] of queries) { + if (data && 'reward_tiers' in data && data.id === variables.programId) { + queryClient.setQueryData(key, { + ...data, + reward_tiers: updatedTiers, + }) + } + } + + toast.success('Reward tiers updated successfully') + }, + onError: () => { + toast.error('Failed to update reward tiers') + }, + }) +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/useReports.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/useReports.ts new file mode 100644 index 00000000..406a6949 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/useReports.ts @@ -0,0 +1,323 @@ +// =================== +// AngelaMos | 2025 +// useReports.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' + +import { + type Comment, + type CommentCreate, + isValidReportDetail, + isValidReportList, + isValidReportStats, + type Report, + type ReportCreate, + type ReportDetail, + type ReportList, + type ReportStats, + type ReportTriage, + type ReportUpdate, +} from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient } from '@/core/api' + +export const reportQueries = { + all: () => QUERY_KEYS.REPORTS.ALL, + list: (page: number, size: number) => QUERY_KEYS.REPORTS.LIST(page, size), + inbox: (page: number, size: number) => QUERY_KEYS.REPORTS.INBOX(page, size), + stats: () => QUERY_KEYS.REPORTS.STATS(), + byId: (id: string) => QUERY_KEYS.REPORTS.BY_ID(id), + byProgram: (programId: string, page: number, size: number) => + QUERY_KEYS.REPORTS.BY_PROGRAM(programId, page, size), +} as const + +const fetchMyReports = async ( + page: number, + size: number +): Promise => { + const response = await apiClient.get(API_ENDPOINTS.REPORTS.LIST, { + params: { page, size }, + }) + + if (!isValidReportList(response.data)) { + throw new Error('Invalid report list response') + } + + return response.data +} + +export const useMyReports = ( + page: number = 1, + size: number = 20 +): UseQueryResult => { + return useQuery({ + queryKey: reportQueries.list(page, size), + queryFn: () => fetchMyReports(page, size), + }) +} + +const fetchInbox = async (page: number, size: number): Promise => { + const response = await apiClient.get(API_ENDPOINTS.REPORTS.INBOX, { + params: { page, size }, + }) + + if (!isValidReportList(response.data)) { + throw new Error('Invalid report list response') + } + + return response.data +} + +export const useInbox = ( + page: number = 1, + size: number = 20 +): UseQueryResult => { + return useQuery({ + queryKey: reportQueries.inbox(page, size), + queryFn: () => fetchInbox(page, size), + }) +} + +const fetchReportStats = async (): Promise => { + const response = await apiClient.get(API_ENDPOINTS.REPORTS.STATS) + + if (!isValidReportStats(response.data)) { + throw new Error('Invalid report stats response') + } + + return response.data +} + +export const useReportStats = (): UseQueryResult => { + return useQuery({ + queryKey: reportQueries.stats(), + queryFn: fetchReportStats, + }) +} + +const fetchReport = async (id: string): Promise => { + const response = await apiClient.get(API_ENDPOINTS.REPORTS.BY_ID(id)) + + if (!isValidReportDetail(response.data)) { + throw new Error('Invalid report detail response') + } + + return response.data +} + +export const useReport = (id: string): UseQueryResult => { + return useQuery({ + queryKey: reportQueries.byId(id), + queryFn: () => fetchReport(id), + enabled: !!id, + }) +} + +const fetchProgramReports = async ( + programId: string, + page: number, + size: number +): Promise => { + const response = await apiClient.get( + API_ENDPOINTS.REPORTS.BY_PROGRAM(programId), + { + params: { page, size }, + } + ) + + if (!isValidReportList(response.data)) { + throw new Error('Invalid report list response') + } + + return response.data +} + +export const useProgramReports = ( + programId: string, + page: number = 1, + size: number = 20 +): UseQueryResult => { + return useQuery({ + queryKey: reportQueries.byProgram(programId, page, size), + queryFn: () => fetchProgramReports(programId, page, size), + enabled: !!programId, + }) +} + +const submitReport = async (data: ReportCreate): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.REPORTS.SUBMIT, + data + ) + return response.data +} + +export const useSubmitReport = (): UseMutationResult< + Report, + Error, + ReportCreate +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: submitReport, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: reportQueries.list(1, 20) }) + queryClient.invalidateQueries({ queryKey: reportQueries.inbox(1, 20) }) + queryClient.invalidateQueries({ queryKey: reportQueries.stats() }) + + toast.success('Report submitted successfully') + }, + onError: () => { + toast.error('Failed to submit report') + }, + }) +} + +const updateReport = async ({ + id, + data, +}: { + id: string + data: ReportUpdate +}): Promise => { + const response = await apiClient.patch( + API_ENDPOINTS.REPORTS.BY_ID(id), + data + ) + return response.data +} + +export const useUpdateReport = (): UseMutationResult< + Report, + Error, + { id: string; data: ReportUpdate } +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: updateReport, + onSuccess: (updatedReport) => { + const queries = queryClient.getQueriesData({ + queryKey: reportQueries.all(), + }) + + for (const [key, data] of queries) { + if (data && 'comments' in data && data.id === updatedReport.id) { + queryClient.setQueryData(key, { + ...data, + ...updatedReport, + }) + } + } + + queryClient.invalidateQueries({ queryKey: reportQueries.list(1, 20) }) + queryClient.invalidateQueries({ queryKey: reportQueries.inbox(1, 20) }) + + toast.success('Report updated successfully') + }, + onError: () => { + toast.error('Failed to update report') + }, + }) +} + +const triageReport = async ({ + id, + data, +}: { + id: string + data: ReportTriage +}): Promise => { + const response = await apiClient.patch( + API_ENDPOINTS.REPORTS.TRIAGE(id), + data + ) + return response.data +} + +export const useTriageReport = (): UseMutationResult< + Report, + Error, + { id: string; data: ReportTriage } +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: triageReport, + onSuccess: (updatedReport) => { + const queries = queryClient.getQueriesData({ + queryKey: reportQueries.all(), + }) + + for (const [key, data] of queries) { + if (data && 'comments' in data && data.id === updatedReport.id) { + queryClient.setQueryData(key, { + ...data, + ...updatedReport, + }) + } + } + + queryClient.invalidateQueries({ queryKey: reportQueries.list(1, 20) }) + queryClient.invalidateQueries({ queryKey: reportQueries.inbox(1, 20) }) + queryClient.invalidateQueries({ queryKey: reportQueries.stats() }) + + toast.success('Report triaged successfully') + }, + onError: () => { + toast.error('Failed to triage report') + }, + }) +} + +const addComment = async ({ + reportId, + data, +}: { + reportId: string + data: CommentCreate +}): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.REPORTS.COMMENTS(reportId), + data + ) + return response.data +} + +export const useAddComment = (): UseMutationResult< + Comment, + Error, + { reportId: string; data: CommentCreate } +> => { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: addComment, + onSuccess: (newComment, variables) => { + queryClient.setQueryData( + reportQueries.byId(variables.reportId), + (old) => { + if (!old || !('comments' in old)) return old + return { + ...old, + comments: [...old.comments, newComment], + } + } + ) + + toast.success('Comment added successfully') + }, + onError: () => { + toast.error('Failed to add comment') + }, + }) +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/useUsers.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/useUsers.ts new file mode 100644 index 00000000..ba3a2264 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/hooks/useUsers.ts @@ -0,0 +1,138 @@ +// =================== +// © AngelaMos | 2025 +// useUsers.ts +// =================== + +import { + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import { toast } from 'sonner' +import { + isValidUserResponse, + USER_ERROR_MESSAGES, + USER_SUCCESS_MESSAGES, + type UserCreateRequest, + type UserResponse, + UserResponseError, + type UserUpdateRequest, +} from '@/api/types' +import { API_ENDPOINTS, QUERY_KEYS } from '@/config' +import { apiClient, QUERY_STRATEGIES } from '@/core/api' +import { useAuthStore } from '@/core/lib' +import { authQueries } from './useAuth' + +export const userQueries = { + all: () => QUERY_KEYS.USERS.ALL, + byId: (id: string) => QUERY_KEYS.USERS.BY_ID(id), + me: () => QUERY_KEYS.USERS.ME(), +} as const + +const fetchUserById = async (id: string): Promise => { + const response = await apiClient.get(API_ENDPOINTS.USERS.BY_ID(id)) + const data: unknown = response.data + + if (!isValidUserResponse(data)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.USERS.BY_ID(id) + ) + } + + return data +} + +export const useUser = (id: string): UseQueryResult => { + return useQuery({ + queryKey: userQueries.byId(id), + queryFn: () => fetchUserById(id), + enabled: id.length > 0, + ...QUERY_STRATEGIES.standard, + }) +} + +const performRegister = async ( + data: UserCreateRequest +): Promise => { + const response = await apiClient.post( + API_ENDPOINTS.USERS.REGISTER, + data + ) + const responseData: unknown = response.data + + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.USERS.REGISTER + ) + } + + return responseData +} + +export const useRegister = (): UseMutationResult< + UserResponse, + Error, + UserCreateRequest +> => { + return useMutation({ + mutationFn: performRegister, + onSuccess: (): void => { + toast.success(USER_SUCCESS_MESSAGES.REGISTERED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_CREATE + toast.error(message) + }, + }) +} + +const performUpdateProfile = async ( + data: UserUpdateRequest +): Promise => { + const response = await apiClient.patch(API_ENDPOINTS.USERS.ME, data) + const responseData: unknown = response.data + + if (!isValidUserResponse(responseData)) { + throw new UserResponseError( + USER_ERROR_MESSAGES.INVALID_USER_RESPONSE, + API_ENDPOINTS.USERS.ME + ) + } + + return responseData +} + +export const useUpdateProfile = (): UseMutationResult< + UserResponse, + Error, + UserUpdateRequest +> => { + const queryClient = useQueryClient() + const updateUser = useAuthStore((s) => s.updateUser) + + return useMutation({ + mutationFn: performUpdateProfile, + onSuccess: (data: UserResponse): void => { + updateUser(data) + + queryClient.setQueryData(authQueries.me(), data) + queryClient.setQueryData(userQueries.me(), data) + + toast.success(USER_SUCCESS_MESSAGES.PROFILE_UPDATED) + }, + onError: (error: Error): void => { + const message = + error instanceof UserResponseError + ? error.message + : USER_ERROR_MESSAGES.FAILED_TO_UPDATE + toast.error(message) + }, + }) +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/index.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/index.ts new file mode 100644 index 00000000..0840a7c1 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/index.ts @@ -0,0 +1,7 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './hooks' +export * from './types' diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/types/admin.types.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/types/admin.types.ts new file mode 100644 index 00000000..505fddf3 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/types/admin.types.ts @@ -0,0 +1,187 @@ +// =================== +// AngelaMos | 2026 +// admin.types.ts +// =================== + +import { z } from 'zod' +import { UserRole } from './auth.types' +import { ProgramStatus, ProgramVisibility, Severity } from './program.types' +import { ReportStatus } from './report.types' + +export const adminProgramResponseSchema = z.object({ + id: z.string().uuid(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().nullable(), + company_id: z.string().uuid(), + company_email: z.string(), + company_name: z.string().nullable(), + name: z.string(), + slug: z.string(), + description: z.string().nullable(), + status: z.nativeEnum(ProgramStatus), + visibility: z.nativeEnum(ProgramVisibility), + response_sla_hours: z.number(), + report_count: z.number(), +}) + +export const adminProgramListResponseSchema = z.object({ + items: z.array(adminProgramResponseSchema), + total: z.number(), + page: z.number(), + size: z.number(), +}) + +export const adminProgramUpdateSchema = z.object({ + name: z.string().optional(), + status: z.nativeEnum(ProgramStatus).optional(), + visibility: z.nativeEnum(ProgramVisibility).optional(), + is_featured: z.boolean().optional(), +}) + +export const adminReportResponseSchema = z.object({ + id: z.string().uuid(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().nullable(), + program_id: z.string().uuid(), + program_name: z.string(), + program_slug: z.string(), + researcher_id: z.string().uuid(), + researcher_email: z.string(), + researcher_name: z.string().nullable(), + title: z.string(), + severity_submitted: z.nativeEnum(Severity), + severity_final: z.nativeEnum(Severity).nullable(), + status: z.nativeEnum(ReportStatus), + bounty_amount: z.number().nullable(), + triaged_at: z.string().datetime().nullable(), + resolved_at: z.string().datetime().nullable(), +}) + +export const adminReportListResponseSchema = z.object({ + items: z.array(adminReportResponseSchema), + total: z.number(), + page: z.number(), + size: z.number(), +}) + +export const adminReportUpdateSchema = z.object({ + status: z.nativeEnum(ReportStatus).optional(), + severity_final: z.nativeEnum(Severity).optional(), + cvss_score: z.number().min(0).max(10).optional(), + bounty_amount: z.number().min(0).optional(), + admin_notes: z.string().optional(), +}) + +export const platformStatsResponseSchema = z.object({ + total_users: z.number(), + total_researchers: z.number(), + total_companies: z.number(), + total_programs: z.number(), + active_programs: z.number(), + total_reports: z.number(), + reports_by_status: z.record(z.string(), z.number()), + total_bounties_paid: z.number(), + reports_this_month: z.number(), + new_users_this_month: z.number(), +}) + +export const adminUserResponseSchema = z.object({ + id: z.string().uuid(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().nullable(), + email: z.string(), + full_name: z.string().nullable(), + company_name: z.string().nullable(), + is_active: z.boolean(), + is_verified: z.boolean(), + role: z.nativeEnum(UserRole), + reputation_score: z.number(), + program_count: z.number(), + report_count: z.number(), +}) + +export const adminUserListResponseSchema = z.object({ + items: z.array(adminUserResponseSchema), + total: z.number(), + page: z.number(), + size: z.number(), +}) + +export type AdminProgramResponse = z.infer +export type AdminProgramListResponse = z.infer< + typeof adminProgramListResponseSchema +> +export type AdminProgramUpdate = z.infer +export type AdminReportResponse = z.infer +export type AdminReportListResponse = z.infer< + typeof adminReportListResponseSchema +> +export type AdminReportUpdate = z.infer +export type PlatformStatsResponse = z.infer +export type AdminUserResponse = z.infer +export type AdminUserListResponse = z.infer + +export const isValidAdminProgramListResponse = ( + data: unknown +): data is AdminProgramListResponse => { + return adminProgramListResponseSchema.safeParse(data).success +} + +export const isValidAdminReportListResponse = ( + data: unknown +): data is AdminReportListResponse => { + return adminReportListResponseSchema.safeParse(data).success +} + +export const isValidPlatformStatsResponse = ( + data: unknown +): data is PlatformStatsResponse => { + return platformStatsResponseSchema.safeParse(data).success +} + +export const isValidAdminUserListResponse = ( + data: unknown +): data is AdminUserListResponse => { + return adminUserListResponseSchema.safeParse(data).success +} + +export const isValidAdminProgramResponse = ( + data: unknown +): data is AdminProgramResponse => { + return adminProgramResponseSchema.safeParse(data).success +} + +export const isValidAdminReportResponse = ( + data: unknown +): data is AdminReportResponse => { + return adminReportResponseSchema.safeParse(data).success +} + +export class AdminResponseError extends Error { + readonly endpoint?: string + + constructor(message: string, endpoint?: string) { + super(message) + this.name = 'AdminResponseError' + this.endpoint = endpoint + Object.setPrototypeOf(this, AdminResponseError.prototype) + } +} + +export const ADMIN_ERROR_MESSAGES = { + INVALID_PROGRAM_LIST_RESPONSE: 'Invalid admin program list from server', + INVALID_PROGRAM_RESPONSE: 'Invalid admin program data from server', + INVALID_REPORT_LIST_RESPONSE: 'Invalid admin report list from server', + INVALID_REPORT_RESPONSE: 'Invalid admin report data from server', + INVALID_STATS_RESPONSE: 'Invalid platform stats from server', + INVALID_USER_LIST_RESPONSE: 'Invalid admin user list from server', + FAILED_TO_UPDATE_PROGRAM: 'Failed to update program', + FAILED_TO_DELETE_PROGRAM: 'Failed to delete program', + FAILED_TO_UPDATE_REPORT: 'Failed to update report', +} as const + +export const ADMIN_SUCCESS_MESSAGES = { + PROGRAM_UPDATED: 'Program updated successfully', + PROGRAM_DELETED: 'Program deleted successfully', + REPORT_UPDATED: 'Report updated successfully', +} as const diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/types/auth.types.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/types/auth.types.ts new file mode 100644 index 00000000..bf1ff764 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/types/auth.types.ts @@ -0,0 +1,140 @@ +// =================== +// © AngelaMos | 2025 +// auth.types.ts +// =================== + +import { z } from 'zod' +import { PASSWORD_CONSTRAINTS } from '@/config' + +export const UserRole = { + UNKNOWN: 'unknown', + USER: 'user', + ADMIN: 'admin', +} as const + +export type UserRole = (typeof UserRole)[keyof typeof UserRole] + +export const userResponseSchema = z.object({ + id: z.string().uuid(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().nullable(), + email: z.string().email(), + full_name: z.string().nullable(), + is_active: z.boolean(), + is_verified: z.boolean(), + role: z.nativeEnum(UserRole), +}) + +export const tokenResponseSchema = z.object({ + access_token: z.string(), + token_type: z.string(), +}) + +export const tokenWithUserResponseSchema = tokenResponseSchema.extend({ + user: userResponseSchema, +}) + +export const loginRequestSchema = z.object({ + username: z.string().email(), + password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), +}) + +export const registerRequestSchema = z.object({ + email: z.string().email(), + password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), + full_name: z.string().max(255).optional(), +}) + +export const passwordChangeRequestSchema = z.object({ + current_password: z.string(), + new_password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), +}) + +export const logoutAllResponseSchema = z.object({ + revoked_sessions: z.number(), +}) + +export type UserResponse = z.infer +export type TokenResponse = z.infer +export type TokenWithUserResponse = z.infer +export type LoginRequest = z.infer +export type RegisterRequest = z.infer +export type PasswordChangeRequest = z.infer +export type LogoutAllResponse = z.infer + +export const isValidUserResponse = (data: unknown): data is UserResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = userResponseSchema.safeParse(data) + return result.success +} + +export const isValidTokenResponse = (data: unknown): data is TokenResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = tokenResponseSchema.safeParse(data) + return result.success +} + +export const isValidTokenWithUserResponse = ( + data: unknown +): data is TokenWithUserResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = tokenWithUserResponseSchema.safeParse(data) + return result.success +} + +export const isValidLogoutAllResponse = ( + data: unknown +): data is LogoutAllResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = logoutAllResponseSchema.safeParse(data) + return result.success +} + +export class AuthResponseError extends Error { + readonly endpoint?: string + + constructor(message: string, endpoint?: string) { + super(message) + this.name = 'AuthResponseError' + this.endpoint = endpoint + Object.setPrototypeOf(this, AuthResponseError.prototype) + } +} + +export const AUTH_ERROR_MESSAGES = { + INVALID_USER_RESPONSE: 'Invalid user data from server', + INVALID_LOGIN_RESPONSE: 'Invalid login response from server', + INVALID_TOKEN_RESPONSE: 'Invalid token response from server', + INVALID_LOGOUT_RESPONSE: 'Invalid logout response from server', + NO_REFRESH_TOKEN: 'No refresh token available', + SESSION_EXPIRED: 'Session expired', +} as const + +export const AUTH_SUCCESS_MESSAGES = { + WELCOME_BACK: (name: string | null) => + `Welcome back${name !== null ? `, ${name}` : ''}!`, + LOGOUT_SUCCESS: 'Logged out successfully', + PASSWORD_CHANGED: 'Password changed successfully', + REGISTERED: 'Account created successfully!', +} as const + +export type AuthErrorMessage = + (typeof AUTH_ERROR_MESSAGES)[keyof typeof AUTH_ERROR_MESSAGES] +export type AuthSuccessMessage = typeof AUTH_SUCCESS_MESSAGES diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/types/index.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/types/index.ts new file mode 100644 index 00000000..f814525e --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/types/index.ts @@ -0,0 +1,10 @@ +// =================== +// AngelaMos | 2026 +// index.ts +// =================== + +export * from './admin.types' +export * from './auth.types' +export * from './program.types' +export * from './report.types' +export * from './user.types' diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/types/program.types.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/types/program.types.ts new file mode 100644 index 00000000..272f12e4 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/types/program.types.ts @@ -0,0 +1,183 @@ +// =================== +// AngelaMos | 2025 +// program.types.ts +// =================== + +import { z } from 'zod' + +export const ProgramStatus = { + DRAFT: 'draft', + ACTIVE: 'active', + PAUSED: 'paused', + CLOSED: 'closed', +} as const + +export type ProgramStatus = (typeof ProgramStatus)[keyof typeof ProgramStatus] + +export const ProgramVisibility = { + PUBLIC: 'public', + PRIVATE: 'private', + INVITE_ONLY: 'invite_only', +} as const + +export type ProgramVisibility = + (typeof ProgramVisibility)[keyof typeof ProgramVisibility] + +export const AssetType = { + DOMAIN: 'domain', + API: 'api', + MOBILE_APP: 'mobile_app', + SOURCE_CODE: 'source_code', + HARDWARE: 'hardware', + OTHER: 'other', +} as const + +export type AssetType = (typeof AssetType)[keyof typeof AssetType] + +export const Severity = { + CRITICAL: 'critical', + HIGH: 'high', + MEDIUM: 'medium', + LOW: 'low', + INFORMATIONAL: 'informational', +} as const + +export type Severity = (typeof Severity)[keyof typeof Severity] + +export const rewardTierSchema = z.object({ + id: z.string().uuid(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().nullable(), + program_id: z.string().uuid(), + severity: z.nativeEnum(Severity), + min_bounty: z.number(), + max_bounty: z.number(), + currency: z.string(), +}) + +export const assetSchema = z.object({ + id: z.string().uuid(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().nullable(), + program_id: z.string().uuid(), + asset_type: z.nativeEnum(AssetType), + identifier: z.string(), + in_scope: z.boolean(), + description: z.string().nullable(), +}) + +export const programSchema = z.object({ + id: z.string().uuid(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().nullable(), + company_id: z.string().uuid(), + name: z.string(), + slug: z.string(), + description: z.string().nullable(), + rules: z.string().nullable(), + response_sla_hours: z.number(), + status: z.nativeEnum(ProgramStatus), + visibility: z.nativeEnum(ProgramVisibility), +}) + +export const programDetailSchema = programSchema.extend({ + assets: z.array(assetSchema), + reward_tiers: z.array(rewardTierSchema), +}) + +export const programListSchema = z.object({ + items: z.array(programSchema), + total: z.number(), + page: z.number(), + size: z.number(), +}) + +export const programCreateSchema = z.object({ + name: z.string().min(1).max(255), + slug: z + .string() + .min(1) + .max(100) + .regex(/^[a-z0-9-]+$/), + description: z.string().max(10000).optional(), + rules: z.string().max(50000).optional(), + response_sla_hours: z.number().min(1).max(720).default(72), + visibility: z.nativeEnum(ProgramVisibility).default(ProgramVisibility.PUBLIC), +}) + +export const programUpdateSchema = z.object({ + name: z.string().min(1).max(255).optional(), + description: z.string().max(10000).optional(), + rules: z.string().max(50000).optional(), + response_sla_hours: z.number().min(1).max(720).optional(), + status: z.nativeEnum(ProgramStatus).optional(), + visibility: z.nativeEnum(ProgramVisibility).optional(), +}) + +export const assetCreateSchema = z.object({ + asset_type: z.nativeEnum(AssetType).default(AssetType.DOMAIN), + identifier: z.string().min(1).max(500), + in_scope: z.boolean().default(true), + description: z.string().max(2000).optional(), +}) + +export const rewardTierCreateSchema = z.object({ + severity: z.nativeEnum(Severity), + min_bounty: z.number().min(0).default(0), + max_bounty: z.number().min(0).default(0), + currency: z.string().max(3).default('USD'), +}) + +export type RewardTier = z.infer +export type Asset = z.infer +export type Program = z.infer +export type ProgramDetail = z.infer +export type ProgramList = z.infer +export type ProgramCreate = z.infer +export type ProgramUpdate = z.infer +export type AssetCreate = z.infer +export type RewardTierCreate = z.infer + +export const isValidProgram = (data: unknown): data is Program => { + return programSchema.safeParse(data).success +} + +export const isValidProgramDetail = (data: unknown): data is ProgramDetail => { + return programDetailSchema.safeParse(data).success +} + +export const isValidProgramList = (data: unknown): data is ProgramList => { + return programListSchema.safeParse(data).success +} + +export const SEVERITY_LABELS: Record = { + critical: 'Critical', + high: 'High', + medium: 'Medium', + low: 'Low', + informational: 'Informational', +} + +export const SEVERITY_COLORS: Record = { + critical: '#dc2626', + high: '#ea580c', + medium: '#ca8a04', + low: '#2563eb', + informational: '#6b7280', +} + +export const STATUS_LABELS: Record = { + draft: 'Draft', + active: 'Active', + paused: 'Paused', + closed: 'Closed', +} + +export const ASSET_TYPE_LABELS: Record = { + domain: 'Domain', + api: 'API', + mobile_app: 'Mobile App', + source_code: 'Source Code', + hardware: 'Hardware', + other: 'Other', +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/types/report.types.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/types/report.types.ts new file mode 100644 index 00000000..1fe09922 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/types/report.types.ts @@ -0,0 +1,177 @@ +// =================== +// AngelaMos | 2025 +// report.types.ts +// =================== + +import { z } from 'zod' +import { Severity } from './program.types' + +export const ReportStatus = { + NEW: 'new', + TRIAGING: 'triaging', + NEEDS_MORE_INFO: 'needs_more_info', + ACCEPTED: 'accepted', + DUPLICATE: 'duplicate', + INFORMATIVE: 'informative', + NOT_APPLICABLE: 'not_applicable', + RESOLVED: 'resolved', + DISCLOSED: 'disclosed', +} as const + +export type ReportStatus = (typeof ReportStatus)[keyof typeof ReportStatus] + +export const commentSchema = z.object({ + id: z.string().uuid(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().nullable(), + report_id: z.string().uuid(), + author_id: z.string().uuid(), + content: z.string(), + is_internal: z.boolean(), +}) + +export const attachmentSchema = z.object({ + id: z.string().uuid(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().nullable(), + report_id: z.string().uuid(), + comment_id: z.string().uuid().nullable(), + filename: z.string(), + mime_type: z.string(), + size_bytes: z.number(), +}) + +export const reportSchema = z.object({ + id: z.string().uuid(), + created_at: z.string().datetime(), + updated_at: z.string().datetime().nullable(), + program_id: z.string().uuid(), + researcher_id: z.string().uuid(), + title: z.string(), + description: z.string(), + steps_to_reproduce: z.string().nullable(), + impact: z.string().nullable(), + severity_submitted: z.nativeEnum(Severity), + severity_final: z.nativeEnum(Severity).nullable(), + status: z.nativeEnum(ReportStatus), + cvss_score: z.number().nullable(), + cwe_id: z.string().nullable(), + bounty_amount: z.number().nullable(), + duplicate_of_id: z.string().uuid().nullable(), + triaged_at: z.string().datetime().nullable(), + resolved_at: z.string().datetime().nullable(), + disclosed_at: z.string().datetime().nullable(), +}) + +export const reportDetailSchema = reportSchema.extend({ + comments: z.array(commentSchema), + attachments: z.array(attachmentSchema), +}) + +export const reportListSchema = z.object({ + items: z.array(reportSchema), + total: z.number(), + page: z.number(), + size: z.number(), +}) + +export const reportStatsSchema = z.object({ + total_reports: z.number(), + accepted_reports: z.number(), + total_earned: z.number(), + reputation_score: z.number(), +}) + +export const reportCreateSchema = z.object({ + program_id: z.string().uuid(), + title: z.string().min(1).max(500), + description: z.string().min(1), + steps_to_reproduce: z.string().optional(), + impact: z.string().optional(), + severity_submitted: z.nativeEnum(Severity).default(Severity.MEDIUM), +}) + +export const reportUpdateSchema = z.object({ + title: z.string().min(1).max(500).optional(), + description: z.string().min(1).optional(), + steps_to_reproduce: z.string().optional(), + impact: z.string().optional(), + severity_submitted: z.nativeEnum(Severity).optional(), +}) + +export const reportTriageSchema = z.object({ + status: z.nativeEnum(ReportStatus).optional(), + severity_final: z.nativeEnum(Severity).optional(), + cvss_score: z.number().min(0).max(10).optional(), + cwe_id: z.string().max(20).optional(), + bounty_amount: z.number().min(0).optional(), + duplicate_of_id: z.string().uuid().optional(), +}) + +export const commentCreateSchema = z.object({ + content: z.string().min(1), + is_internal: z.boolean().default(false), +}) + +export type Comment = z.infer +export type Attachment = z.infer +export type Report = z.infer +export type ReportDetail = z.infer +export type ReportList = z.infer +export type ReportStats = z.infer +export type ReportCreate = z.infer +export type ReportUpdate = z.infer +export type ReportTriage = z.infer +export type CommentCreate = z.infer + +export const isValidReport = (data: unknown): data is Report => { + return reportSchema.safeParse(data).success +} + +export const isValidReportDetail = (data: unknown): data is ReportDetail => { + return reportDetailSchema.safeParse(data).success +} + +export const isValidReportList = (data: unknown): data is ReportList => { + return reportListSchema.safeParse(data).success +} + +export const isValidReportStats = (data: unknown): data is ReportStats => { + return reportStatsSchema.safeParse(data).success +} + +export const REPORT_STATUS_LABELS: Record = { + new: 'New', + triaging: 'Triaging', + needs_more_info: 'Needs More Info', + accepted: 'Accepted', + duplicate: 'Duplicate', + informative: 'Informative', + not_applicable: 'N/A', + resolved: 'Resolved', + disclosed: 'Disclosed', +} + +export const REPORT_STATUS_COLORS: Record = { + new: '#3b82f6', + triaging: '#f59e0b', + needs_more_info: '#8b5cf6', + accepted: '#22c55e', + duplicate: '#6b7280', + informative: '#06b6d4', + not_applicable: '#6b7280', + resolved: '#22c55e', + disclosed: '#10b981', +} + +export const isOpenStatus = (status: ReportStatus): boolean => { + return ([ + ReportStatus.NEW, + ReportStatus.TRIAGING, + ReportStatus.NEEDS_MORE_INFO, + ] as ReportStatus[]).includes(status) +} + +export const isClosedStatus = (status: ReportStatus): boolean => { + return !isOpenStatus(status) +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/api/types/user.types.ts b/PROJECTS/bug-bounty-platform/frontend/src/api/types/user.types.ts new file mode 100644 index 00000000..2fd36234 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/api/types/user.types.ts @@ -0,0 +1,127 @@ +// =================== +// © AngelaMos | 2025 +// user.types.ts +// =================== + +import { z } from 'zod' +import { PASSWORD_CONSTRAINTS } from '@/config' +import { UserRole, userResponseSchema } from './auth.types' + +export { type UserResponse, UserRole, userResponseSchema } from './auth.types' + +export const userListResponseSchema = z.object({ + items: z.array(userResponseSchema), + total: z.number(), + page: z.number(), + size: z.number(), +}) + +export const userCreateRequestSchema = z.object({ + email: z.string().email(), + password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), + full_name: z.string().max(255).nullable().optional(), +}) + +export const userUpdateRequestSchema = z.object({ + full_name: z.string().max(255).nullable().optional(), +}) + +export const adminUserCreateRequestSchema = z.object({ + email: z.string().email(), + password: z + .string() + .min(PASSWORD_CONSTRAINTS.MIN_LENGTH) + .max(PASSWORD_CONSTRAINTS.MAX_LENGTH), + full_name: z.string().max(255).nullable().optional(), + role: z.nativeEnum(UserRole).optional(), + is_active: z.boolean().optional(), + is_verified: z.boolean().optional(), +}) + +export const adminUserUpdateRequestSchema = z.object({ + email: z.string().email().optional(), + full_name: z.string().max(255).nullable().optional(), + role: z.nativeEnum(UserRole).optional(), + is_active: z.boolean().optional(), + is_verified: z.boolean().optional(), +}) + +export const paginationParamsSchema = z.object({ + page: z.number().min(1), + size: z.number().min(1).max(100), +}) + +export type UserListResponse = z.infer +export type UserCreateRequest = z.infer +export type UserUpdateRequest = z.infer +export type AdminUserCreateRequest = z.infer +export type AdminUserUpdateRequest = z.infer +export type PaginationParams = z.infer + +export const isValidUserListResponse = ( + data: unknown +): data is UserListResponse => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = userListResponseSchema.safeParse(data) + return result.success +} + +export const isValidUserCreateRequest = ( + data: unknown +): data is UserCreateRequest => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = userCreateRequestSchema.safeParse(data) + return result.success +} + +export const isValidAdminUserCreateRequest = ( + data: unknown +): data is AdminUserCreateRequest => { + if (data === null || data === undefined) return false + if (typeof data !== 'object') return false + + const result = adminUserCreateRequestSchema.safeParse(data) + return result.success +} + +export class UserResponseError extends Error { + readonly endpoint?: string + + constructor(message: string, endpoint?: string) { + super(message) + this.name = 'UserResponseError' + this.endpoint = endpoint + Object.setPrototypeOf(this, UserResponseError.prototype) + } +} + +export const USER_ERROR_MESSAGES = { + INVALID_USER_RESPONSE: 'Invalid user data from server', + INVALID_USER_LIST_RESPONSE: 'Invalid user list from server', + USER_NOT_FOUND: 'User not found', + EMAIL_ALREADY_EXISTS: 'Email already exists', + FAILED_TO_CREATE: 'Failed to create user', + FAILED_TO_UPDATE: 'Failed to update user', + FAILED_TO_DELETE: 'Failed to delete user', +} as const + +export const USER_SUCCESS_MESSAGES = { + CREATED: 'User created successfully', + UPDATED: 'User updated successfully', + DELETED: 'User deleted successfully', + PROFILE_UPDATED: 'Profile updated successfully', + REGISTERED: + 'Registration successful! Please check your email to verify your account.', +} as const + +export type UserErrorMessage = + (typeof USER_ERROR_MESSAGES)[keyof typeof USER_ERROR_MESSAGES] +export type UserSuccessMessage = + (typeof USER_SUCCESS_MESSAGES)[keyof typeof USER_SUCCESS_MESSAGES] diff --git a/PROJECTS/bug-bounty-platform/frontend/src/config.ts b/PROJECTS/bug-bounty-platform/frontend/src/config.ts new file mode 100644 index 00000000..936d1513 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/config.ts @@ -0,0 +1,208 @@ +// =================== +// © AngelaMos | 2026 +// config.ts +// =================== +const API_VERSION = 'v1' + +export const API_ENDPOINTS = { + AUTH: { + LOGIN: `/${API_VERSION}/auth/login`, + REFRESH: `/${API_VERSION}/auth/refresh`, + LOGOUT: `/${API_VERSION}/auth/logout`, + LOGOUT_ALL: `/${API_VERSION}/auth/logout-all`, + ME: `/${API_VERSION}/auth/me`, + CHANGE_PASSWORD: `/${API_VERSION}/auth/change-password`, + }, + USERS: { + BASE: `/${API_VERSION}/users`, + BY_ID: (id: string) => `/${API_VERSION}/users/${id}`, + ME: `/${API_VERSION}/users/me`, + REGISTER: `/${API_VERSION}/users`, + }, + PROGRAMS: { + LIST: `/${API_VERSION}/programs`, + MINE: `/${API_VERSION}/programs/mine`, + CREATE: `/${API_VERSION}/programs`, + BY_SLUG: (slug: string) => `/${API_VERSION}/programs/${slug}`, + BY_ID: (id: string) => `/${API_VERSION}/programs/${id}`, + ASSETS: (id: string) => `/${API_VERSION}/programs/${id}/assets`, + ASSET: (programId: string, assetId: string) => + `/${API_VERSION}/programs/${programId}/assets/${assetId}`, + REWARDS: (id: string) => `/${API_VERSION}/programs/${id}/rewards`, + }, + REPORTS: { + LIST: `/${API_VERSION}/reports`, + INBOX: `/${API_VERSION}/reports/inbox`, + STATS: `/${API_VERSION}/reports/stats`, + SUBMIT: `/${API_VERSION}/reports`, + BY_PROGRAM: (programId: string) => + `/${API_VERSION}/reports/program/${programId}`, + BY_ID: (id: string) => `/${API_VERSION}/reports/${id}`, + TRIAGE: (id: string) => `/${API_VERSION}/reports/${id}/triage`, + COMMENTS: (id: string) => `/${API_VERSION}/reports/${id}/comments`, + }, + ADMIN: { + STATS: `/${API_VERSION}/admin/stats`, + USERS: { + LIST: `/${API_VERSION}/admin/users`, + CREATE: `/${API_VERSION}/admin/users`, + BY_ID: (id: string) => `/${API_VERSION}/admin/users/${id}`, + UPDATE: (id: string) => `/${API_VERSION}/admin/users/${id}`, + DELETE: (id: string) => `/${API_VERSION}/admin/users/${id}`, + }, + PROGRAMS: { + LIST: `/${API_VERSION}/admin/programs`, + BY_ID: (id: string) => `/${API_VERSION}/admin/programs/${id}`, + UPDATE: (id: string) => `/${API_VERSION}/admin/programs/${id}`, + DELETE: (id: string) => `/${API_VERSION}/admin/programs/${id}`, + }, + REPORTS: { + LIST: `/${API_VERSION}/admin/reports`, + BY_ID: (id: string) => `/${API_VERSION}/admin/reports/${id}`, + UPDATE: (id: string) => `/${API_VERSION}/admin/reports/${id}`, + }, + }, +} as const + +export const QUERY_KEYS = { + AUTH: { + ALL: ['auth'] as const, + ME: () => [...QUERY_KEYS.AUTH.ALL, 'me'] as const, + }, + USERS: { + ALL: ['users'] as const, + BY_ID: (id: string) => [...QUERY_KEYS.USERS.ALL, 'detail', id] as const, + ME: () => [...QUERY_KEYS.USERS.ALL, 'me'] as const, + }, + PROGRAMS: { + ALL: ['programs'] as const, + LIST: (page: number, size: number) => + [...QUERY_KEYS.PROGRAMS.ALL, 'list', { page, size }] as const, + MINE: (page: number, size: number) => + [...QUERY_KEYS.PROGRAMS.ALL, 'mine', { page, size }] as const, + BY_SLUG: (slug: string) => + [...QUERY_KEYS.PROGRAMS.ALL, 'detail', slug] as const, + }, + REPORTS: { + ALL: ['reports'] as const, + LIST: (page: number, size: number) => + [...QUERY_KEYS.REPORTS.ALL, 'list', { page, size }] as const, + INBOX: (page: number, size: number) => + [...QUERY_KEYS.REPORTS.ALL, 'inbox', { page, size }] as const, + STATS: () => [...QUERY_KEYS.REPORTS.ALL, 'stats'] as const, + BY_ID: (id: string) => [...QUERY_KEYS.REPORTS.ALL, 'detail', id] as const, + BY_PROGRAM: (programId: string, page: number, size: number) => + [...QUERY_KEYS.REPORTS.ALL, 'program', programId, { page, size }] as const, + }, + ADMIN: { + ALL: ['admin'] as const, + STATS: () => [...QUERY_KEYS.ADMIN.ALL, 'stats'] as const, + USERS: { + ALL: () => [...QUERY_KEYS.ADMIN.ALL, 'users'] as const, + LIST: (page: number, size: number, role?: string) => + [...QUERY_KEYS.ADMIN.USERS.ALL(), 'list', { page, size, role }] as const, + BY_ID: (id: string) => + [...QUERY_KEYS.ADMIN.USERS.ALL(), 'detail', id] as const, + }, + PROGRAMS: { + ALL: () => [...QUERY_KEYS.ADMIN.ALL, 'programs'] as const, + LIST: (page: number, size: number, status?: string) => + [ + ...QUERY_KEYS.ADMIN.PROGRAMS.ALL(), + 'list', + { page, size, status }, + ] as const, + }, + REPORTS: { + ALL: () => [...QUERY_KEYS.ADMIN.ALL, 'reports'] as const, + LIST: (page: number, size: number, status?: string, severity?: string) => + [ + ...QUERY_KEYS.ADMIN.REPORTS.ALL(), + 'list', + { page, size, status, severity }, + ] as const, + }, + }, +} as const + +export const ROUTES = { + HOME: '/', + LOGIN: '/login', + REGISTER: '/register', + DASHBOARD: '/dashboard', + SETTINGS: '/settings', + UNAUTHORIZED: '/unauthorized', + PROGRAMS: { + LIST: '/programs', + DETAIL: (slug: string) => `/programs/${slug}`, + SUBMIT: (slug: string) => `/programs/${slug}/submit`, + }, + REPORTS: { + LIST: '/reports', + DETAIL: (id: string) => `/reports/${id}`, + }, + COMPANY: { + DASHBOARD: '/company', + PROGRAMS: '/company/programs', + NEW_PROGRAM: '/company/programs/new', + EDIT_PROGRAM: (slug: string) => `/company/programs/${slug}/edit`, + INBOX: '/company/inbox', + REPORT: (id: string) => `/company/reports/${id}`, + }, + ADMIN: { + DASHBOARD: '/admin', + USERS: '/admin/users', + USER_DETAIL: (id: string) => `/admin/users/${id}`, + PROGRAMS: '/admin/programs', + REPORTS: '/admin/reports', + }, +} as const + +export const STORAGE_KEYS = { + AUTH: 'auth-storage', + UI: 'ui-storage', +} as const + +export const QUERY_CONFIG = { + STALE_TIME: { + USER: 0, + STATIC: Infinity, + FREQUENT: 1000 * 30, + }, + GC_TIME: { + DEFAULT: 1000 * 60 * 30, + LONG: 1000 * 60 * 60, + }, + RETRY: { + DEFAULT: 3, + NONE: 0, + }, +} as const + +export const HTTP_STATUS = { + OK: 200, + CREATED: 201, + NO_CONTENT: 204, + BAD_REQUEST: 400, + UNAUTHORIZED: 401, + FORBIDDEN: 403, + NOT_FOUND: 404, + CONFLICT: 409, + TOO_MANY_REQUESTS: 429, + INTERNAL_SERVER: 500, +} as const + +export const PASSWORD_CONSTRAINTS = { + MIN_LENGTH: 8, + MAX_LENGTH: 128, +} as const + +export const PAGINATION = { + DEFAULT_PAGE: 1, + DEFAULT_SIZE: 20, + MAX_SIZE: 100, +} as const + +export type ApiEndpoint = typeof API_ENDPOINTS +export type QueryKey = typeof QUERY_KEYS +export type Route = typeof ROUTES diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/api/api.config.ts b/PROJECTS/bug-bounty-platform/frontend/src/core/api/api.config.ts new file mode 100644 index 00000000..8347fa00 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/api/api.config.ts @@ -0,0 +1,160 @@ +// =================== +// © AngelaMos | 2025 +// api.config.ts +// =================== + +import axios, { + type AxiosError, + type AxiosInstance, + type InternalAxiosRequestConfig, +} from 'axios' +import { API_ENDPOINTS, HTTP_STATUS } from '@/config' +import { useAuthStore } from '@/core/lib' +import { ApiError, ApiErrorCode, transformAxiosError } from './errors' + +interface RequestConfig extends InternalAxiosRequestConfig { + _retry?: boolean +} + +interface RefreshSubscriber { + resolve: (token: string) => void + reject: (error: Error) => void +} + +const getBaseURL = (): string => { + return import.meta.env.VITE_API_URL ?? '/api' +} + +export const apiClient: AxiosInstance = axios.create({ + baseURL: getBaseURL(), + timeout: 15000, + headers: { 'Content-Type': 'application/json' }, + withCredentials: true, +}) + +let isRefreshing = false +let refreshSubscribers: RefreshSubscriber[] = [] + +const processRefreshQueue = (error: Error | null, token: string | null): void => { + refreshSubscribers.forEach((subscriber) => { + if (error !== null) { + subscriber.reject(error) + } else if (token !== null) { + subscriber.resolve(token) + } + }) + refreshSubscribers = [] +} + +const addRefreshSubscriber = ( + resolve: (token: string) => void, + reject: (error: Error) => void +): void => { + refreshSubscribers.push({ resolve, reject }) +} + +const handleTokenRefresh = async (): Promise => { + const response = await apiClient.post<{ access_token: string }>( + API_ENDPOINTS.AUTH.REFRESH + ) + + if ( + response.data === null || + response.data === undefined || + typeof response.data !== 'object' + ) { + throw new ApiError( + 'Invalid refresh response', + ApiErrorCode.AUTHENTICATION_ERROR, + HTTP_STATUS.UNAUTHORIZED + ) + } + + const accessToken = response.data.access_token + if (typeof accessToken !== 'string' || accessToken.length === 0) { + throw new ApiError( + 'Invalid access token', + ApiErrorCode.AUTHENTICATION_ERROR, + HTTP_STATUS.UNAUTHORIZED + ) + } + + return accessToken +} + +const handleAuthFailure = (): void => { + useAuthStore.getState().logout() + window.location.href = '/login' +} + +apiClient.interceptors.request.use( + (config: InternalAxiosRequestConfig): InternalAxiosRequestConfig => { + const token = useAuthStore.getState().accessToken + if (token !== null && token.length > 0) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + (error: unknown): Promise => { + return Promise.reject(error) + } +) + +apiClient.interceptors.response.use( + (response) => response, + async (error: AxiosError): Promise => { + const originalRequest = error.config as RequestConfig | undefined + + if (originalRequest === undefined) { + return Promise.reject(transformAxiosError(error)) + } + + const isUnauthorized = error.response?.status === HTTP_STATUS.UNAUTHORIZED + const isNotRetried = originalRequest._retry !== true + const isNotRefreshEndpoint = + originalRequest.url?.includes(API_ENDPOINTS.AUTH.REFRESH) !== true + + if (isUnauthorized && isNotRetried && isNotRefreshEndpoint) { + if (isRefreshing) { + return new Promise((resolve, reject) => { + addRefreshSubscriber( + (newToken: string): void => { + originalRequest.headers.Authorization = `Bearer ${newToken}` + resolve(apiClient(originalRequest)) + }, + (refreshError: Error): void => { + reject(refreshError) + } + ) + }) + } + + originalRequest._retry = true + isRefreshing = true + + try { + const newToken = await handleTokenRefresh() + useAuthStore.getState().setAccessToken(newToken) + processRefreshQueue(null, newToken) + originalRequest.headers.Authorization = `Bearer ${newToken}` + return await apiClient(originalRequest) + } catch (refreshError: unknown) { + const apiError = + refreshError instanceof ApiError + ? refreshError + : new ApiError( + 'Session expired', + ApiErrorCode.AUTHENTICATION_ERROR, + HTTP_STATUS.UNAUTHORIZED + ) + processRefreshQueue(apiError, null) + handleAuthFailure() + return Promise.reject(apiError) + } finally { + isRefreshing = false + } + } + + return Promise.reject(transformAxiosError(error)) + } +) diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/api/errors.ts b/PROJECTS/bug-bounty-platform/frontend/src/core/api/errors.ts new file mode 100644 index 00000000..fde5ec65 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/api/errors.ts @@ -0,0 +1,114 @@ +/** + * ©AngelaMos | 2025 + * errors.ts + */ + +import type { AxiosError } from 'axios' + +export const ApiErrorCode = { + NETWORK_ERROR: 'NETWORK_ERROR', + VALIDATION_ERROR: 'VALIDATION_ERROR', + AUTHENTICATION_ERROR: 'AUTHENTICATION_ERROR', + AUTHORIZATION_ERROR: 'AUTHORIZATION_ERROR', + NOT_FOUND: 'NOT_FOUND', + CONFLICT: 'CONFLICT', + RATE_LIMITED: 'RATE_LIMITED', + SERVER_ERROR: 'SERVER_ERROR', + UNKNOWN_ERROR: 'UNKNOWN_ERROR', +} as const + +export type ApiErrorCode = (typeof ApiErrorCode)[keyof typeof ApiErrorCode] + +export class ApiError extends Error { + readonly code: ApiErrorCode + readonly statusCode: number + readonly details?: Record + + constructor( + message: string, + code: ApiErrorCode, + statusCode: number, + details?: Record + ) { + super(message) + this.name = 'ApiError' + this.code = code + this.statusCode = statusCode + this.details = details + } + + getUserMessage(): string { + const messages: Record = { + [ApiErrorCode.NETWORK_ERROR]: + 'Unable to connect. Please check your internet connection.', + [ApiErrorCode.VALIDATION_ERROR]: 'Please check your input and try again.', + [ApiErrorCode.AUTHENTICATION_ERROR]: + 'Your session has expired. Please log in again.', + [ApiErrorCode.AUTHORIZATION_ERROR]: + 'You do not have permission to perform this action.', + [ApiErrorCode.NOT_FOUND]: 'The requested resource was not found.', + [ApiErrorCode.CONFLICT]: + 'This operation conflicts with an existing resource.', + [ApiErrorCode.RATE_LIMITED]: + 'Too many requests. Please wait a moment and try again.', + [ApiErrorCode.SERVER_ERROR]: + 'Something went wrong on our end. Please try again later.', + [ApiErrorCode.UNKNOWN_ERROR]: + 'An unexpected error occurred. Please try again.', + } + return messages[this.code] + } +} + +interface ApiErrorResponse { + detail?: string | { msg: string; type: string }[] + message?: string +} + +export function transformAxiosError(error: AxiosError): ApiError { + if (!error.response) { + return new ApiError('Network error', ApiErrorCode.NETWORK_ERROR, 0) + } + + const { status } = error.response + const data = error.response.data as ApiErrorResponse | undefined + let message = 'An error occurred' + let details: Record | undefined + + if (data?.detail) { + if (typeof data.detail === 'string') { + message = data.detail + } else if (Array.isArray(data.detail)) { + details = { validation: [] } + data.detail.forEach((err) => { + details?.validation.push(err.msg) + }) + message = 'Validation error' + } + } else if (data?.message) { + message = data.message + } + + const codeMap: Record = { + 400: ApiErrorCode.VALIDATION_ERROR, + 401: ApiErrorCode.AUTHENTICATION_ERROR, + 403: ApiErrorCode.AUTHORIZATION_ERROR, + 404: ApiErrorCode.NOT_FOUND, + 409: ApiErrorCode.CONFLICT, + 429: ApiErrorCode.RATE_LIMITED, + 500: ApiErrorCode.SERVER_ERROR, + 502: ApiErrorCode.SERVER_ERROR, + 503: ApiErrorCode.SERVER_ERROR, + 504: ApiErrorCode.SERVER_ERROR, + } + + const code = codeMap[status] || ApiErrorCode.UNKNOWN_ERROR + + return new ApiError(message, code, status, details) +} + +declare module '@tanstack/react-query' { + interface Register { + defaultError: ApiError + } +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/api/index.ts b/PROJECTS/bug-bounty-platform/frontend/src/core/api/index.ts new file mode 100644 index 00000000..1818cd7e --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/api/index.ts @@ -0,0 +1,8 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './api.config' +export * from './errors' +export * from './query.config' diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/api/query.config.ts b/PROJECTS/bug-bounty-platform/frontend/src/core/api/query.config.ts new file mode 100644 index 00000000..8244b22d --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/api/query.config.ts @@ -0,0 +1,105 @@ +// =================== +// © AngelaMos | 2025 +// query.config.ts +// =================== + +import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query' +import { toast } from 'sonner' +import { QUERY_CONFIG } from '@/config' +import { ApiError, ApiErrorCode } from './errors' + +const NO_RETRY_ERROR_CODES: readonly ApiErrorCode[] = [ + ApiErrorCode.AUTHENTICATION_ERROR, + ApiErrorCode.AUTHORIZATION_ERROR, + ApiErrorCode.NOT_FOUND, + ApiErrorCode.VALIDATION_ERROR, +] as const + +const shouldRetryQuery = (failureCount: number, error: Error): boolean => { + if (error instanceof ApiError) { + if (NO_RETRY_ERROR_CODES.includes(error.code)) { + return false + } + } + return failureCount < QUERY_CONFIG.RETRY.DEFAULT +} + +const calculateRetryDelay = (attemptIndex: number): number => { + const baseDelay = 1000 + const maxDelay = 30000 + return Math.min(baseDelay * 2 ** attemptIndex, maxDelay) +} + +const handleQueryCacheError = ( + error: Error, + query: { state: { data: unknown } } +): void => { + if (query.state.data !== undefined) { + const message = + error instanceof ApiError + ? error.getUserMessage() + : 'Background update failed' + toast.error(message) + } +} + +const handleMutationCacheError = ( + error: Error, + _variables: unknown, + _context: unknown, + mutation: { options: { onError?: unknown } } +): void => { + if (mutation.options.onError === undefined) { + const message = + error instanceof ApiError ? error.getUserMessage() : 'Operation failed' + toast.error(message) + } +} + +export const QUERY_STRATEGIES = { + standard: { + staleTime: QUERY_CONFIG.STALE_TIME.USER, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + }, + frequent: { + staleTime: QUERY_CONFIG.STALE_TIME.FREQUENT, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + refetchInterval: QUERY_CONFIG.STALE_TIME.FREQUENT, + }, + static: { + staleTime: QUERY_CONFIG.STALE_TIME.STATIC, + gcTime: QUERY_CONFIG.GC_TIME.LONG, + refetchOnMount: false, + refetchOnWindowFocus: false, + }, + auth: { + staleTime: QUERY_CONFIG.STALE_TIME.USER, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + retry: QUERY_CONFIG.RETRY.NONE, + }, +} as const + +export type QueryStrategy = keyof typeof QUERY_STRATEGIES + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: QUERY_CONFIG.STALE_TIME.USER, + gcTime: QUERY_CONFIG.GC_TIME.DEFAULT, + retry: shouldRetryQuery, + retryDelay: calculateRetryDelay, + refetchOnWindowFocus: true, + refetchOnMount: true, + refetchOnReconnect: true, + }, + mutations: { + retry: QUERY_CONFIG.RETRY.NONE, + }, + }, + queryCache: new QueryCache({ + onError: handleQueryCacheError, + }), + mutationCache: new MutationCache({ + onError: handleMutationCacheError, + }), +}) diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/app/protected-route.tsx b/PROJECTS/bug-bounty-platform/frontend/src/core/app/protected-route.tsx new file mode 100644 index 00000000..486eec78 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/app/protected-route.tsx @@ -0,0 +1,47 @@ +// =================== +// © AngelaMos | 2025 +// protected-route.tsx +// =================== + +import { Navigate, Outlet, useLocation } from 'react-router-dom' +import type { UserRole } from '@/api/types' +import { ROUTES } from '@/config' +import { useAuthStore } from '@/core/lib' + +interface ProtectedRouteProps { + allowedRoles?: UserRole[] + redirectTo?: string +} + +export function ProtectedRoute({ + allowedRoles, + redirectTo = ROUTES.LOGIN, +}: ProtectedRouteProps): React.ReactElement { + const location = useLocation() + const { isAuthenticated, isLoading, user } = useAuthStore() + + if (isLoading) { + return
Loading...
+ } + + if (!isAuthenticated) { + return ( + + ) + } + + if ( + allowedRoles !== undefined && + allowedRoles.length > 0 && + user !== null && + !allowedRoles.includes(user.role) + ) { + return + } + + return +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/app/routers.tsx b/PROJECTS/bug-bounty-platform/frontend/src/core/app/routers.tsx new file mode 100644 index 00000000..1701d48a --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/app/routers.tsx @@ -0,0 +1,119 @@ +// =================== +// AngelaMos | 2026 +// routers.tsx +// =================== + +import { createBrowserRouter, type RouteObject } from 'react-router-dom' +import { UserRole } from '@/api/types' +import { ROUTES } from '@/config' +import { ProtectedRoute } from './protected-route' +import { Shell } from './shell' + +const routes: RouteObject[] = [ + { + path: ROUTES.HOME, + lazy: () => import('@/routes/landing'), + }, + { + path: ROUTES.LOGIN, + lazy: () => import('@/routes/login'), + }, + { + path: ROUTES.REGISTER, + lazy: () => import('@/routes/register'), + }, + { + element: , + children: [ + { + element: , + children: [ + { + path: ROUTES.DASHBOARD, + lazy: () => import('@/routes/dashboard'), + }, + { + path: ROUTES.SETTINGS, + lazy: () => import('@/routes/settings'), + }, + { + path: ROUTES.PROGRAMS.LIST, + lazy: () => import('@/routes/programs'), + }, + { + path: '/programs/:slug', + lazy: () => import('@/routes/programs/[slug]'), + }, + { + path: '/programs/:slug/submit', + lazy: () => import('@/routes/programs/[slug]/submit'), + }, + { + path: ROUTES.REPORTS.LIST, + lazy: () => import('@/routes/reports'), + }, + { + path: '/reports/:id', + lazy: () => import('@/routes/reports/[id]'), + }, + { + path: ROUTES.COMPANY.PROGRAMS, + lazy: () => import('@/routes/company/programs'), + }, + { + path: ROUTES.COMPANY.NEW_PROGRAM, + lazy: () => import('@/routes/company/programs/new'), + }, + { + path: '/company/programs/:slug/edit', + lazy: () => import('@/routes/company/programs/[id]/edit'), + }, + { + path: ROUTES.COMPANY.INBOX, + lazy: () => import('@/routes/company/inbox'), + }, + { + path: '/company/reports/:id', + lazy: () => import('@/routes/company/reports/[id]'), + }, + ], + }, + ], + }, + { + element: , + children: [ + { + element: , + children: [ + { + path: ROUTES.ADMIN.DASHBOARD, + lazy: () => import('@/routes/admin/stats'), + }, + { + path: ROUTES.ADMIN.USERS, + lazy: () => import('@/routes/admin'), + }, + { + path: ROUTES.ADMIN.PROGRAMS, + lazy: () => import('@/routes/admin/programs'), + }, + { + path: ROUTES.ADMIN.REPORTS, + lazy: () => import('@/routes/admin/reports'), + }, + ], + }, + ], + }, + { + path: ROUTES.UNAUTHORIZED, + lazy: () => import('@/routes/landing'), + }, + { + path: '*', + lazy: () => import('@/routes/landing'), + }, +] + +export const router = createBrowserRouter(routes) diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/app/shell.module.scss b/PROJECTS/bug-bounty-platform/frontend/src/core/app/shell.module.scss new file mode 100644 index 00000000..4946285e --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/app/shell.module.scss @@ -0,0 +1,375 @@ +// =================== +// © AngelaMos | 2025 +// shell.module.scss +// =================== + +@use '@/styles' as *; + +$sidebar-width: 240px; +$sidebar-collapsed-width: 64px; +$header-height: 56px; + +.shell { + display: flex; + min-height: 100vh; + min-height: 100dvh; +} + +.sidebar { + position: fixed; + top: 0; + left: 0; + bottom: 0; + width: $sidebar-width; + background-color: $bg-shell-base; + background-image: radial-gradient( + circle, + $bg-shell-dot 1px, + transparent 1px + ); + background-size: 17px 17px; + border-right: 1px solid $border-default; + display: flex; + flex-direction: column; + z-index: $z-fixed; + @include transition-fast; + + &.collapsed { + width: $sidebar-collapsed-width; + } + + @include breakpoint-down('sm') { + transform: translateX(-100%); + + &.open { + transform: translateX(0); + } + + &.collapsed { + width: $sidebar-width; + } + } +} + +.sidebarHeader { + height: $header-height; + padding: 0 $space-3; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid $border-default; + + .sidebar.collapsed & { + justify-content: center; + padding: 0; + } +} + +.logo { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $text-default; + @include transition-fast; + + .sidebar.collapsed & { + display: none; + } +} + +.nav { + flex: 1; + padding: $space-3; + display: flex; + flex-direction: column; + gap: $space-1; +} + +.navItem { + display: flex; + align-items: center; + gap: $space-3; + padding: $space-2 $space-3; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-light; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &.active { + background: $bg-selection; + color: $text-default; + } + + .sidebar.collapsed & { + justify-content: center; + } +} + +.navIcon { + width: 17px; + height: 17px; + flex-shrink: 0; + color:$accent-orange; +} + +.navLabel { + @include transition-fast; + + .sidebar.collapsed & { + display: none; + } +} + +.navDivider { + height: 1px; + background: $border-muted; + margin: $space-2 0; + + .sidebar.collapsed & { + margin: $space-2 $space-1; + } +} + +.navSection { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: $text-muted; + text-transform: uppercase; + letter-spacing: $tracking-wide; + padding: $space-2 $space-3; + + .sidebar.collapsed & { + display: none; + } +} + +.adminItem { + margin-top: auto; + border-top: 1px solid $border-default; + padding-top: $space-3; +} + +.collapseBtn { + width: 45px; + height: 45px; + border-radius: $radius-md; + color: $accent-orange;; + @include flex-center; + @include transition-fast; + + svg { + width: 23.5px; + height: 23.5px; + } + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + @include breakpoint-down('sm') { + display: none; + } +} + +.sidebarFooter { + padding: $space-3; + border-top: 1px solid $border-default; +} + +.logoutBtn { + width: 100%; + display: flex; + align-items: center; + gap: $space-3; + padding: $space-3; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + } + + .sidebar.collapsed & { + justify-content: center; + + .logoutText { + display: none; + } + } +} + +.logoutIcon { + width: 18px; + height: 18px; + flex-shrink: 0; + color:$accent-orange; +} + +.logoutText { + font-weight: $font-weight-medium; + @include transition-fast; +} + +.overlay { + position: fixed; + inset: 0; + background: rgb(0, 0, 0, 50%); + z-index: calc($z-fixed - 1); + display: none; + border: none; + padding: 0; + cursor: pointer; + + @include breakpoint-down('sm') { + display: block; + } +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + margin-left: $sidebar-width; + min-width: 0; + @include transition-fast; + + &.collapsed { + margin-left: $sidebar-collapsed-width; + } + + @include breakpoint-down('sm') { + margin-left: 0; + + &.collapsed { + margin-left: 0; + } + } +} + +.header { + position: sticky; + top: 0; + height: $header-height; + background-color: $bg-shell-base; + background-image: radial-gradient( + circle, + $bg-shell-dot 1px, + transparent 1px + ); + background-size: 17px 17px; + border-bottom: 1px solid $border-default; + z-index: $z-sticky; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 $space-4; +} + +.headerLeft { + display: flex; + align-items: center; + gap: $space-3; +} + +.menuBtn { + display: none; + width: 36px; + height: 36px; + border-radius: $radius-md; + color: $text-light; + align-items: center; + justify-content: center; + @include transition-fast; + + svg { + width: 20px; + height: 20px; + } + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + @media (width <= 479px) { + display: flex; + } +} + +.pageTitle { + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $text-default; + margin-left: 7px; +} + +.headerRight { + display: flex; + align-items: center; + gap: $space-3; +} + +.avatar { + width: 32px; + height: 32px; + border-radius: $radius-full; + background: $bg-surface-300; + color:$accent-orange; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + @include flex-center; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(1.2); + } +} + +.content { + flex: 1; + overflow-y: auto; + background-color: $bg-content-base; + background-image: radial-gradient( + circle, + $bg-content-dot 1px, + transparent 1px + ); + background-size: 20px 20px; +} + +.loading { + @include flex-center; + height: 100%; + color: $text-muted; +} + +.error { + @include flex-column-center; + height: 100%; + gap: $space-4; + padding: $space-6; + color: $error-default; + + h2 { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + } + + pre { + font-family: $font-mono; + font-size: $font-size-sm; + padding: $space-4; + background: $bg-surface-200; + border-radius: $radius-lg; + overflow-x: auto; + max-width: 100%; + } +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/app/shell.tsx b/PROJECTS/bug-bounty-platform/frontend/src/core/app/shell.tsx new file mode 100644 index 00000000..23c3a23e --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/app/shell.tsx @@ -0,0 +1,227 @@ +/** + * AngelaMos | 2026 + * shell.tsx + */ + +import { Suspense } from 'react' +import { ErrorBoundary } from 'react-error-boundary' +import { + LuBuilding2, + LuChartNetwork, + LuChevronLeft, + LuChevronRight, + LuFileText, + LuInbox, + LuLayoutDashboard, + LuLogOut, + LuMenu, + LuSettings, + LuTarget, + LuUsers, +} from 'react-icons/lu' +import { Link, NavLink, Outlet, useLocation } from 'react-router-dom' +import { useLogout } from '@/api/hooks' +import { ROUTES } from '@/config' +import { useIsAdmin, useUIStore, useUser } from '@/core/lib' +import styles from './shell.module.scss' + +const NAV_ITEMS = [ + { path: ROUTES.DASHBOARD, label: 'Dashboard', icon: LuLayoutDashboard }, + { path: ROUTES.PROGRAMS.LIST, label: 'Programs', icon: LuTarget }, + { path: ROUTES.REPORTS.LIST, label: 'My Reports', icon: LuFileText }, + { path: ROUTES.SETTINGS, label: 'Settings', icon: LuSettings }, +] + +const COMPANY_NAV_ITEMS = [ + { path: ROUTES.COMPANY.PROGRAMS, label: 'My Programs', icon: LuBuilding2 }, + { path: ROUTES.COMPANY.INBOX, label: 'Inbox', icon: LuInbox }, +] + +const ADMIN_NAV_ITEMS = [ + { path: ROUTES.ADMIN.DASHBOARD, label: 'Statistics', icon: LuChartNetwork }, + { path: ROUTES.ADMIN.USERS, label: 'Users', icon: LuUsers }, + { path: ROUTES.ADMIN.PROGRAMS, label: 'Programs', icon: LuTarget }, + { path: ROUTES.ADMIN.REPORTS, label: 'Reports', icon: LuFileText }, +] + +function ShellErrorFallback({ error }: { error: Error }): React.ReactElement { + return ( +
+

Something went wrong

+
{error.message}
+
+ ) +} + +function ShellLoading(): React.ReactElement { + return
Loading...
+} + +function getPageTitle(pathname: string, isAdmin: boolean): string { + if (isAdmin) { + const adminItem = ADMIN_NAV_ITEMS.find((i) => i.path === pathname) + if (adminItem) { + return `Admin: ${adminItem.label}` + } + } + if (pathname.startsWith('/programs/') && pathname.includes('/submit')) { + return 'Submit Report' + } + if (pathname.startsWith('/programs/')) { + return 'Program' + } + if (pathname.startsWith('/reports/')) { + return 'Report' + } + if (pathname === ROUTES.COMPANY.NEW_PROGRAM) { + return 'New Program' + } + if (pathname.startsWith('/company/programs/') && pathname.endsWith('/edit')) { + return 'Edit Program' + } + if (pathname.startsWith('/company/reports/')) { + return 'Triage Report' + } + const companyItem = COMPANY_NAV_ITEMS.find((i) => i.path === pathname) + if (companyItem) { + return companyItem.label + } + const item = NAV_ITEMS.find((i) => i.path === pathname) + return item?.label ?? 'Dashboard' +} + +export function Shell(): React.ReactElement { + const location = useLocation() + const { sidebarOpen, sidebarCollapsed, toggleSidebar, toggleSidebarCollapsed } = + useUIStore() + const { mutate: logout } = useLogout() + const isAdmin = useIsAdmin() + const user = useUser() + + const pageTitle = getPageTitle(location.pathname, isAdmin) + const avatarLetter = + user?.full_name?.[0]?.toUpperCase() ?? user?.email?.[0]?.toUpperCase() ?? 'U' + + return ( +
+ + + {sidebarOpen && ( + +

{pageTitle}

+
+ +
+ + {avatarLetter} + +
+ + +
+ + }> + + + +
+ + + ) +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/app/toast.module.scss b/PROJECTS/bug-bounty-platform/frontend/src/core/app/toast.module.scss new file mode 100644 index 00000000..d50ab7fb --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/app/toast.module.scss @@ -0,0 +1,67 @@ +// =================== +// © AngelaMos | 2025 +// toast.module.scss +// =================== + +@use '@/styles' as *; + +:global { + [data-sonner-toaster] { + --normal-bg: #{$bg-surface-100}; + --normal-border: #{$border-default}; + --normal-text: #{$text-default}; + + --success-bg: #{$bg-surface-100}; + --success-border: #{$border-default}; + --success-text: #{$text-default}; + + --error-bg: #{$bg-surface-100}; + --error-border: #{$error-default}; + --error-text: #{$text-default}; + + --warning-bg: #{$bg-surface-100}; + --warning-border: #{$border-default}; + --warning-text: #{$text-default}; + + --info-bg: #{$bg-surface-100}; + --info-border: #{$border-default}; + --info-text: #{$text-default}; + + font-family: $font-sans; + } + + [data-sonner-toast] { + border-radius: $radius-md; + padding: $space-3 $space-4; + font-size: $font-size-sm; + border: 1px solid $border-default; + background: $bg-surface-100; + color: $text-default; + + [data-title] { + font-weight: $font-weight-medium; + } + + [data-description] { + color: $text-light; + font-size: $font-size-xs; + } + + [data-close-button] { + background: none; + border: none; + padding: 0; + cursor: pointer; + color: $text-muted; + @include transition-fast; + + @include hover { + color: $text-default; + } + } + } + + [data-sonner-toast][data-type='error'] { + border-color: $error-default; + } +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/lib/auth.form.store.ts b/PROJECTS/bug-bounty-platform/frontend/src/core/lib/auth.form.store.ts new file mode 100644 index 00000000..22c422c5 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/lib/auth.form.store.ts @@ -0,0 +1,43 @@ +/** + * ©AngelaMos | 2025 + * auth.form.store.ts + */ + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +interface AuthFormState { + loginEmail: string + registerEmail: string + setLoginEmail: (email: string) => void + setRegisterEmail: (email: string) => void + clearLoginForm: () => void + clearRegisterForm: () => void +} + +export const useAuthFormStore = create()( + devtools( + persist( + (set) => ({ + loginEmail: '', + registerEmail: '', + + setLoginEmail: (email) => + set({ loginEmail: email }, false, 'authForm/setLoginEmail'), + + setRegisterEmail: (email) => + set({ registerEmail: email }, false, 'authForm/setRegisterEmail'), + + clearLoginForm: () => + set({ loginEmail: '' }, false, 'authForm/clearLoginForm'), + + clearRegisterForm: () => + set({ registerEmail: '' }, false, 'authForm/clearRegisterForm'), + }), + { + name: 'auth-form-storage', + } + ), + { name: 'AuthFormStore' } + ) +) diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/lib/auth.store.ts b/PROJECTS/bug-bounty-platform/frontend/src/core/lib/auth.store.ts new file mode 100644 index 00000000..a02cd0c9 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/lib/auth.store.ts @@ -0,0 +1,105 @@ +// =================== +// © AngelaMos | 2025 +// auth.store.ts +// =================== + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' +import { type UserResponse, UserRole } from '@/api/types' +import { STORAGE_KEYS } from '@/config' + +interface AuthState { + user: UserResponse | null + accessToken: string | null + isAuthenticated: boolean + isLoading: boolean +} + +interface AuthActions { + login: (user: UserResponse, accessToken: string) => void + logout: () => void + setLoading: (loading: boolean) => void + setAccessToken: (token: string | null) => void + updateUser: (updates: Partial) => void +} + +type AuthStore = AuthState & AuthActions + +export const useAuthStore = create()( + devtools( + persist( + (set) => ({ + user: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + + login: (user, accessToken) => + set( + { + user, + accessToken, + isAuthenticated: true, + isLoading: false, + }, + false, + 'auth/login' + ), + + logout: () => + set( + { + user: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + }, + false, + 'auth/logout' + ), + + setLoading: (loading) => + set({ isLoading: loading }, false, 'auth/setLoading'), + + setAccessToken: (token) => + set({ accessToken: token }, false, 'auth/setAccessToken'), + + updateUser: (updates) => + set( + (state) => ({ + user: state.user !== null ? { ...state.user, ...updates } : null, + }), + false, + 'auth/updateUser' + ), + }), + { + name: STORAGE_KEYS.AUTH, + partialize: (state) => ({ + user: state.user, + isAuthenticated: state.isAuthenticated, + }), + } + ), + { name: 'AuthStore' } + ) +) + +export const useUser = (): UserResponse | null => useAuthStore((s) => s.user) +export const useIsAuthenticated = (): boolean => + useAuthStore((s) => s.isAuthenticated) +export const useIsAuthLoading = (): boolean => useAuthStore((s) => s.isLoading) +export const useAccessToken = (): string | null => + useAuthStore((s) => s.accessToken) + +export const useHasRole = (role: UserRole): boolean => { + const user = useAuthStore((s) => s.user) + return user !== null && user.role === role +} + +export const useIsAdmin = (): boolean => { + const user = useAuthStore((s) => s.user) + return user !== null && user.role === UserRole.ADMIN +} + +export { UserRole } diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/lib/index.ts b/PROJECTS/bug-bounty-platform/frontend/src/core/lib/index.ts new file mode 100644 index 00000000..ced5cfc9 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/lib/index.ts @@ -0,0 +1,10 @@ +// =================== +// © AngelaMos | 2025 +// index.ts +// =================== + +export * from './auth.form.store' +export * from './auth.store' +export * from './program.form.store' +export * from './settings.form.store' +export * from './shell.ui.store' diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/lib/program.form.store.ts b/PROJECTS/bug-bounty-platform/frontend/src/core/lib/program.form.store.ts new file mode 100644 index 00000000..f07c1248 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/lib/program.form.store.ts @@ -0,0 +1,68 @@ +/** + * ©AngelaMos | 2026 + * program.form.store.ts + */ + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' +import { ProgramVisibility } from '@/api/types' + +interface ProgramFormState { + name: string + slug: string + description: string + rules: string + responseSlaHours: number + visibility: ProgramVisibility + setName: (name: string) => void + setSlug: (slug: string) => void + setDescription: (description: string) => void + setRules: (rules: string) => void + setResponseSlaHours: (hours: number) => void + setVisibility: (visibility: ProgramVisibility) => void + clearForm: () => void +} + +const initialState = { + name: '', + slug: '', + description: '', + rules: '', + responseSlaHours: 72, + visibility: ProgramVisibility.PUBLIC, +} + +export const useProgramFormStore = create()( + devtools( + persist( + (set) => ({ + ...initialState, + + setName: (name) => set({ name }, false, 'programForm/setName'), + + setSlug: (slug) => set({ slug }, false, 'programForm/setSlug'), + + setDescription: (description) => + set({ description }, false, 'programForm/setDescription'), + + setRules: (rules) => set({ rules }, false, 'programForm/setRules'), + + setResponseSlaHours: (hours) => + set( + { responseSlaHours: hours }, + false, + 'programForm/setResponseSlaHours' + ), + + setVisibility: (visibility) => + set({ visibility }, false, 'programForm/setVisibility'), + + clearForm: () => set(initialState, false, 'programForm/clearForm'), + }), + { + name: 'program-form-storage', + } + ), + { name: 'ProgramFormStore' } + ) +) diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/lib/settings.form.store.ts b/PROJECTS/bug-bounty-platform/frontend/src/core/lib/settings.form.store.ts new file mode 100644 index 00000000..d40ebcd8 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/lib/settings.form.store.ts @@ -0,0 +1,36 @@ +/** + * ©AngelaMos | 2026 + * settings.form.store.ts + */ + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +interface SettingsFormState { + fullName: string + setFullName: (fullName: string) => void + clearForm: () => void +} + +const initialState = { + fullName: '', +} + +export const useSettingsFormStore = create()( + devtools( + persist( + (set) => ({ + ...initialState, + + setFullName: (fullName) => + set({ fullName }, false, 'settingsForm/setFullName'), + + clearForm: () => set(initialState, false, 'settingsForm/clearForm'), + }), + { + name: 'settings-form-storage', + } + ), + { name: 'SettingsFormStore' } + ) +) diff --git a/PROJECTS/bug-bounty-platform/frontend/src/core/lib/shell.ui.store.ts b/PROJECTS/bug-bounty-platform/frontend/src/core/lib/shell.ui.store.ts new file mode 100644 index 00000000..d601a53b --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/core/lib/shell.ui.store.ts @@ -0,0 +1,63 @@ +/** + * ©AngelaMos | 2025 + * ui.store.ts + */ + +import { create } from 'zustand' +import { devtools, persist } from 'zustand/middleware' + +type Theme = 'light' | 'dark' | 'system' + +interface UIState { + theme: Theme + sidebarOpen: boolean + sidebarCollapsed: boolean + setTheme: (theme: Theme) => void + toggleSidebar: () => void + setSidebarOpen: (open: boolean) => void + toggleSidebarCollapsed: () => void +} + +export const useUIStore = create()( + devtools( + persist( + (set) => ({ + theme: 'dark', + sidebarOpen: false, + sidebarCollapsed: false, + + setTheme: (theme) => set({ theme }, false, 'ui/setTheme'), + + toggleSidebar: () => + set( + (state) => ({ sidebarOpen: !state.sidebarOpen }), + false, + 'ui/toggleSidebar' + ), + + setSidebarOpen: (open) => + set({ sidebarOpen: open }, false, 'ui/setSidebarOpen'), + + toggleSidebarCollapsed: () => + set( + (state) => ({ sidebarCollapsed: !state.sidebarCollapsed }), + false, + 'ui/toggleSidebarCollapsed' + ), + }), + { + name: 'ui-storage', + partialize: (state) => ({ + theme: state.theme, + sidebarCollapsed: state.sidebarCollapsed, + }), + } + ), + { name: 'UIStore' } + ) +) + +export const useTheme = (): Theme => useUIStore((s) => s.theme) +export const useSidebarOpen = (): boolean => useUIStore((s) => s.sidebarOpen) +export const useSidebarCollapsed = (): boolean => + useUIStore((s) => s.sidebarCollapsed) diff --git a/PROJECTS/bug-bounty-platform/frontend/src/main.tsx b/PROJECTS/bug-bounty-platform/frontend/src/main.tsx new file mode 100644 index 00000000..ac319510 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/main.tsx @@ -0,0 +1,15 @@ +// =========================== +// ©AngelaMos | 2026 +// main.tsx +// =========================== + +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import App from './App' +import './styles.scss' + +createRoot(document.getElementById('root')!).render( + + + +) diff --git a/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/admin.module.scss b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/admin.module.scss new file mode 100644 index 00000000..bb31d56e --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/admin.module.scss @@ -0,0 +1,463 @@ +// =================== +// © AngelaMos | 2025 +// admin.module.scss +// =================== + +@use '@/styles' as *; + +.page { + padding: $space-6; + min-height: calc(100vh - 56px); +} + +.header { + @include flex-between; + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.createBtn { + display: flex; + align-items: center; + justify-content: center; + height: 40px; + padding: 0 $space-5; + background-color: $accent-orange; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $white; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(1.1); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.table { + width: 100%; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + overflow: hidden; +} + +.tableHeader { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr 100px; + gap: $space-4; + padding: $space-3 $space-4; + background: $bg-surface-200; + border-bottom: 1px solid $border-default; + + @include breakpoint-down('md') { + display: none; + } +} + +.tableHeaderCell { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: $text-lighter; + text-transform: uppercase; + letter-spacing: $tracking-wide; +} + +.tableBody { + @include flex-column; +} + +.tableRow { + display: grid; + grid-template-columns: 2fr 1fr 1fr 1fr 100px; + gap: $space-4; + padding: $space-3 $space-4; + border-bottom: 1px solid $border-default; + @include transition-fast; + + &:last-child { + border-bottom: none; + } + + @include hover { + background: $bg-surface-75; + } + + @include breakpoint-down('md') { + grid-template-columns: 1fr; + gap: $space-2; + } +} + +.tableCell { + display: flex; + align-items: center; + font-size: $font-size-sm; + color: $text-default; + min-width: 0; + + @include breakpoint-down('md') { + &::before { + content: attr(data-label); + font-size: $font-size-xs; + color: $text-lighter; + margin-right: $space-2; + min-width: 80px; + } + } +} + +.email { + @include truncate; +} + +.badge { + display: inline-flex; + align-items: center; + padding: $space-1 $space-2; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + + &.admin { + background: $bg-selection; + color: $text-default; + } + + &.user { + background: $bg-surface-200; + color: $text-light; + } + + &.active { + background: hsl(142 76% 36% / 20%); + color: hsl(142, 76%, 46%); + } + + &.inactive { + background: hsl(0 72% 51% / 20%); + color: $error-light; + } +} + +.actions { + display: flex; + gap: $space-2; + justify-content: flex-end; + + @include breakpoint-down('md') { + justify-content: flex-start; + } +} + +.actionBtn { + width: 32px; + height: 32px; + @include flex-center; + border: 1px solid $border-default; + border-radius: $radius-md; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &.delete { + @include hover { + border-color: $error-default; + color: $error-default; + } + } + + svg { + width: 16px; + height: 16px; + } +} + +.pagination { + @include flex-between; + padding: $space-4; + border-top: 1px solid $border-default; +} + +.paginationInfo { + font-size: $font-size-sm; + color: $text-lighter; +} + +.paginationBtns { + display: flex; + gap: $space-2; +} + +.paginationBtn { + padding: $space-2 $space-3; + border: 1px solid $border-default; + border-radius: $radius-md; + background: transparent; + font-size: $font-size-sm; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.empty { + @include flex-column-center; + padding: $space-12; + color: $text-muted; + font-size: $font-size-sm; +} + +.loading { + @include flex-center; + padding: $space-12; + color: $text-muted; +} + +.modal { + position: fixed; + inset: 0; + z-index: $z-modal; + @include flex-center; +} + +.modalOverlay { + @include absolute-fill; + background: rgb(0, 0, 0, 70%); +} + +.modalContent { + position: relative; + width: 100%; + max-width: 400px; + margin: $space-4; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-6; +} + +.modalHeader { + @include flex-between; + margin-bottom: $space-5; +} + +.modalTitle { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.modalClose { + width: 32px; + height: 32px; + @include flex-center; + border: none; + border-radius: $radius-md; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + svg { + width: 20px; + height: 20px; + } +} + +.form { + @include flex-column; + gap: $space-4; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; +} + +.input { + width: 100%; + height: 44px; + padding: 0 $space-3; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + @include transition-fast; + + &::placeholder { + color: $text-muted; + } + + &:focus { + outline: none; + border-color: $border-strong; + } +} + +.select { + width: 100%; + height: 44px; + padding: 0 $space-3; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + cursor: pointer; + @include transition-fast; + + &:focus { + outline: none; + border-color: $border-strong; + } + + option { + background: $bg-surface-100; + color: $text-default; + } +} + +.checkbox { + display: flex; + align-items: center; + gap: $space-2; + cursor: pointer; + + input { + width: 18px; + height: 18px; + accent-color: $white; + } + + span { + font-size: $font-size-sm; + color: $text-light; + } +} + +.formActions { + display: flex; + gap: $space-3; + margin-top: $space-2; +} + +.submitBtn { + flex: 1; + height: 44px; + @include flex-center; + background: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.cancelBtn { + flex: 1; + height: 44px; + @include flex-center; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } +} + +.deleteConfirm { + @include flex-column; + gap: $space-4; +} + +.deleteText { + font-size: $font-size-sm; + color: $text-light; + line-height: $line-height-relaxed; +} + +.deleteEmail { + font-weight: $font-weight-medium; + color: $text-default; +} + +.deleteBtn { + flex: 1; + height: 44px; + @include flex-center; + background: $error-default; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $white; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/index.tsx b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/index.tsx new file mode 100644 index 00000000..401a27ec --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/index.tsx @@ -0,0 +1,414 @@ +/** + * ©AngelaMos | 2025 + * index.tsx + */ + +import { useState } from 'react' +import { LuPencil, LuTrash2, LuX } from 'react-icons/lu' +import { + useAdminCreateUser, + useAdminDeleteUser, + useAdminUpdateUser, + useAdminUsers, +} from '@/api/hooks' +import type { UserResponse } from '@/api/types' +import { UserRole } from '@/api/types' +import { PAGINATION } from '@/config' +import styles from './admin.module.scss' + +type ModalState = + | { type: 'closed' } + | { type: 'create' } + | { type: 'edit'; user: UserResponse } + | { type: 'delete'; user: UserResponse } + +export function Component(): React.ReactElement { + const [page, setPage] = useState(PAGINATION.DEFAULT_PAGE) + const [modal, setModal] = useState({ type: 'closed' }) + + const { data, isLoading } = useAdminUsers({ + page, + size: PAGINATION.DEFAULT_SIZE, + }) + const createUser = useAdminCreateUser() + const updateUser = useAdminUpdateUser() + const deleteUser = useAdminDeleteUser() + + const handleCreate = (formData: FormData): void => { + const email = formData.get('email') as string + const password = formData.get('password') as string + const fullName = (formData.get('fullName') as string) || undefined + const role = formData.get('role') as UserRole + const isActive = formData.get('isActive') === 'on' + + createUser.mutate( + { email, password, full_name: fullName, role, is_active: isActive }, + { onSuccess: () => setModal({ type: 'closed' }) } + ) + } + + const handleUpdate = (userId: string, formData: FormData): void => { + const email = formData.get('email') as string + const fullName = (formData.get('fullName') as string) || undefined + const role = formData.get('role') as UserRole + const isActive = formData.get('isActive') === 'on' + + updateUser.mutate( + { + id: userId, + data: { email, full_name: fullName, role, is_active: isActive }, + }, + { onSuccess: () => setModal({ type: 'closed' }) } + ) + } + + const handleDelete = (userId: string): void => { + deleteUser.mutate(userId, { onSuccess: () => setModal({ type: 'closed' }) }) + } + + const totalPages = data ? Math.ceil(data.total / PAGINATION.DEFAULT_SIZE) : 0 + + return ( +
+
+

Users

+ +
+ +
+
+
Email
+
Name
+
Role
+
Status
+
Actions
+
+ +
+ {isLoading &&
Loading...
} + + {!isLoading && data?.items.length === 0 && ( +
No users found
+ )} + + {data?.items.map((user) => ( +
+
+ {user.email} +
+
+ {user.full_name ?? '—'} +
+
+ + {user.role} + +
+
+ + {user.is_active ? 'Active' : 'Inactive'} + +
+
+ + +
+
+ ))} +
+ + {data && data.total > PAGINATION.DEFAULT_SIZE && ( +
+ + Page {page} of {totalPages} ({data.total} users) + +
+ + +
+
+ )} +
+ + {modal.type === 'create' && ( +
+ +
+
{ + e.preventDefault() + handleCreate(new FormData(e.currentTarget)) + }} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+
+ + )} + + {modal.type === 'edit' && ( +
+ +
+
{ + e.preventDefault() + handleUpdate(modal.user.id, new FormData(e.currentTarget)) + }} + > +
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+
+ + + )} + + {modal.type === 'delete' && ( +
+ +
+
+

+ Are you sure you want to delete{' '} + {modal.user.email}? + This action cannot be undone. +

+
+ + +
+
+ + + )} + + ) +} + +Component.displayName = 'AdminUsers' diff --git a/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/programs/index.tsx b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/programs/index.tsx new file mode 100644 index 00000000..c2350435 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/programs/index.tsx @@ -0,0 +1,313 @@ +/** + * AngelaMos | 2026 + * index.tsx + */ + +import { useState } from 'react' +import { LuPencil, LuTrash2, LuX } from 'react-icons/lu' +import { + useAdminDeleteProgram, + useAdminPrograms, + useAdminUpdateProgram, +} from '@/api/hooks' +import { + type AdminProgramResponse, + type ProgramStatus, + ProgramVisibility, + STATUS_LABELS, +} from '@/api/types' +import { PAGINATION } from '@/config' +import styles from './programs.module.scss' + +type ModalState = + | { type: 'closed' } + | { type: 'edit'; program: AdminProgramResponse } + | { type: 'delete'; program: AdminProgramResponse } + +export function Component(): React.ReactElement { + const [page, setPage] = useState(PAGINATION.DEFAULT_PAGE) + const [statusFilter, setStatusFilter] = useState('') + const [modal, setModal] = useState({ type: 'closed' }) + + const { data, isLoading } = useAdminPrograms({ + page, + size: PAGINATION.DEFAULT_SIZE, + status: statusFilter || undefined, + }) + const updateProgram = useAdminUpdateProgram() + const deleteProgram = useAdminDeleteProgram() + + const handleUpdate = (programId: string, formData: FormData): void => { + const status = formData.get('status') as ProgramStatus + const visibility = formData.get('visibility') as ProgramVisibility + + updateProgram.mutate( + { id: programId, data: { status, visibility } }, + { onSuccess: () => setModal({ type: 'closed' }) } + ) + } + + const handleDelete = (programId: string): void => { + deleteProgram.mutate(programId, { + onSuccess: () => setModal({ type: 'closed' }), + }) + } + + const totalPages = data ? Math.ceil(data.total / PAGINATION.DEFAULT_SIZE) : 0 + + return ( +
+
+

Programs

+ +
+ +
+
+
Program
+
Company
+
Status
+
Visibility
+
Reports
+
Actions
+
+ +
+ {isLoading &&
Loading...
} + + {!isLoading && data?.items.length === 0 && ( +
No programs found
+ )} + + {data?.items.map((program) => ( +
+
+
+ {program.name} + /{program.slug} +
+
+
+
+ + {program.company_name ?? 'N/A'} + + + {program.company_email} + +
+
+
+ + {STATUS_LABELS[program.status]} + +
+
+ {program.visibility} +
+
+ {program.report_count} +
+
+ + +
+
+ ))} +
+ + {data && data.total > PAGINATION.DEFAULT_SIZE && ( +
+ + Page {page} of {totalPages} ({data.total} programs) + +
+ + +
+
+ )} +
+ + {modal.type === 'edit' && ( +
+ +
+
{ + e.preventDefault() + handleUpdate(modal.program.id, new FormData(e.currentTarget)) + }} + > +
+ {modal.program.name} + + by {modal.program.company_name ?? modal.program.company_email} + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + )} + + {modal.type === 'delete' && ( +
+ +
+
+

+ Are you sure you want to delete{' '} + {modal.program.name}? + This will permanently remove the program and all associated data. +

+
+ + +
+
+ + + )} + + ) +} + +Component.displayName = 'AdminPrograms' diff --git a/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/programs/programs.module.scss b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/programs/programs.module.scss new file mode 100644 index 00000000..596705a3 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/programs/programs.module.scss @@ -0,0 +1,480 @@ +// =================== +// AngelaMos | 2026 +// programs.module.scss +// =================== + +@use '@/styles' as *; + +.page { + padding: $space-6; + min-height: calc(100vh - 56px); +} + +.header { + @include flex-between; + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.filterSelect { + height: 40px; + padding: 0 $space-3; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + cursor: pointer; + @include transition-fast; + + &:focus { + outline: none; + border-color: $border-strong; + } + + option { + background: $bg-surface-100; + color: $text-default; + } +} + +.table { + width: 100%; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + overflow: hidden; +} + +.tableHeader { + display: grid; + grid-template-columns: 2fr 1.5fr 1fr 1fr 0.5fr 100px; + gap: $space-4; + padding: $space-3 $space-4; + background: $bg-surface-200; + border-bottom: 1px solid $border-default; + + @include breakpoint-down('lg') { + display: none; + } +} + +.tableHeaderCell { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: $text-lighter; + text-transform: uppercase; + letter-spacing: $tracking-wide; +} + +.tableBody { + @include flex-column; +} + +.tableRow { + display: grid; + grid-template-columns: 2fr 1.5fr 1fr 1fr 0.5fr 100px; + gap: $space-4; + padding: $space-3 $space-4; + border-bottom: 1px solid $border-default; + @include transition-fast; + + &:last-child { + border-bottom: none; + } + + @include hover { + background: $bg-surface-75; + } + + @include breakpoint-down('lg') { + grid-template-columns: 1fr; + gap: $space-2; + } +} + +.tableCell { + display: flex; + align-items: center; + font-size: $font-size-sm; + color: $text-default; + min-width: 0; + + @include breakpoint-down('lg') { + &::before { + content: attr(data-label); + font-size: $font-size-xs; + color: $text-lighter; + margin-right: $space-2; + min-width: 80px; + } + } +} + +.programInfo { + @include flex-column; + gap: $space-1; + min-width: 0; +} + +.programName { + font-weight: $font-weight-medium; + color: $text-default; + @include truncate; +} + +.programSlug { + font-size: $font-size-xs; + color: $text-lighter; + @include truncate; +} + +.companyInfo { + @include flex-column; + gap: $space-1; + min-width: 0; +} + +.companyName { + color: $text-default; + @include truncate; +} + +.companyEmail { + font-size: $font-size-xs; + color: $text-lighter; + @include truncate; +} + +.badge { + display: inline-flex; + align-items: center; + padding: $space-1 $space-2; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + + &.draft { + background: $bg-surface-200; + color: $text-light; + } + + &.active { + background: hsl(142 76% 36% / 20%); + color: hsl(142, 76%, 46%); + } + + &.paused { + background: hsl(45 93% 47% / 20%); + color: hsl(45, 93%, 57%); + } + + &.closed { + background: hsl(0 72% 51% / 20%); + color: $error-light; + } +} + +.visibility { + font-size: $font-size-sm; + color: $text-light; + text-transform: capitalize; +} + +.reportCount { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $accent-orange; +} + +.actions { + display: flex; + gap: $space-2; + justify-content: flex-end; + + @include breakpoint-down('lg') { + justify-content: flex-start; + } +} + +.actionBtn { + width: 32px; + height: 32px; + @include flex-center; + border: 1px solid $border-default; + border-radius: $radius-md; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &.delete { + @include hover { + border-color: $error-default; + color: $error-default; + } + } + + svg { + width: 16px; + height: 16px; + } +} + +.pagination { + @include flex-between; + padding: $space-4; + border-top: 1px solid $border-default; +} + +.paginationInfo { + font-size: $font-size-sm; + color: $text-lighter; +} + +.paginationBtns { + display: flex; + gap: $space-2; +} + +.paginationBtn { + padding: $space-2 $space-3; + border: 1px solid $border-default; + border-radius: $radius-md; + background: transparent; + font-size: $font-size-sm; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.empty { + @include flex-column-center; + padding: $space-12; + color: $text-muted; + font-size: $font-size-sm; +} + +.loading { + @include flex-center; + padding: $space-12; + color: $text-muted; +} + +.modal { + position: fixed; + inset: 0; + z-index: $z-modal; + @include flex-center; +} + +.modalOverlay { + @include absolute-fill; + background: rgb(0, 0, 0, 70%); +} + +.modalContent { + position: relative; + width: 100%; + max-width: 400px; + margin: $space-4; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-6; +} + +.modalHeader { + @include flex-between; + margin-bottom: $space-5; +} + +.modalTitle { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.modalClose { + width: 32px; + height: 32px; + @include flex-center; + border: none; + border-radius: $radius-md; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + svg { + width: 20px; + height: 20px; + } +} + +.form { + @include flex-column; + gap: $space-4; +} + +.programLabel { + @include flex-column; + gap: $space-1; + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $text-default; + padding-bottom: $space-3; + border-bottom: 1px solid $border-default; +} + +.programSubLabel { + font-size: $font-size-sm; + font-weight: $font-weight-regular; + color: $text-lighter; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; +} + +.select { + width: 100%; + height: 44px; + padding: 0 $space-3; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + cursor: pointer; + @include transition-fast; + + &:focus { + outline: none; + border-color: $border-strong; + } + + option { + background: $bg-surface-100; + color: $text-default; + } +} + +.formActions { + display: flex; + gap: $space-3; + margin-top: $space-2; +} + +.submitBtn { + flex: 1; + height: 44px; + @include flex-center; + background: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.cancelBtn { + flex: 1; + height: 44px; + @include flex-center; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } +} + +.deleteConfirm { + @include flex-column; + gap: $space-4; +} + +.deleteText { + font-size: $font-size-sm; + color: $text-light; + line-height: $line-height-relaxed; +} + +.deleteName { + font-weight: $font-weight-medium; + color: $text-default; +} + +.deleteBtn { + flex: 1; + height: 44px; + @include flex-center; + background: $error-default; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $white; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/reports/index.tsx b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/reports/index.tsx new file mode 100644 index 00000000..3c2e3823 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/reports/index.tsx @@ -0,0 +1,321 @@ +/** + * AngelaMos | 2026 + * index.tsx + */ + +import { useState } from 'react' +import { LuPencil, LuX } from 'react-icons/lu' +import { useAdminReports, useAdminUpdateReport } from '@/api/hooks' +import { + type AdminReportResponse, + type AdminReportUpdate, + REPORT_STATUS_LABELS, + type ReportStatus, + SEVERITY_LABELS, + type Severity, +} from '@/api/types' +import { PAGINATION } from '@/config' +import styles from './reports.module.scss' + +type ModalState = + | { type: 'closed' } + | { type: 'edit'; report: AdminReportResponse } + +export function Component(): React.ReactElement { + const [page, setPage] = useState(PAGINATION.DEFAULT_PAGE) + const [statusFilter, setStatusFilter] = useState('') + const [severityFilter, setSeverityFilter] = useState('') + const [modal, setModal] = useState({ type: 'closed' }) + + const { data, isLoading } = useAdminReports({ + page, + size: PAGINATION.DEFAULT_SIZE, + status: statusFilter || undefined, + severity: severityFilter || undefined, + }) + const updateReport = useAdminUpdateReport() + + const handleUpdate = (reportId: string, formData: FormData): void => { + const updateData: AdminReportUpdate = {} + + const status = formData.get('status') as ReportStatus + if (status) updateData.status = status + + const severityFinal = formData.get('severity_final') as Severity + if (severityFinal) updateData.severity_final = severityFinal + + const bountyAmountStr = formData.get('bounty_amount') as string + if (bountyAmountStr) { + updateData.bounty_amount = Math.round(parseFloat(bountyAmountStr) * 100) + } + + updateReport.mutate( + { id: reportId, data: updateData }, + { onSuccess: () => setModal({ type: 'closed' }) } + ) + } + + const formatCurrency = (cents: number | null): string => { + if (cents === null) return '-' + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + }).format(cents / 100) + } + + const toCamelCase = (str: string): string => + str.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase()) + + const totalPages = data ? Math.ceil(data.total / PAGINATION.DEFAULT_SIZE) : 0 + + return ( +
+
+

Reports

+
+ + +
+
+ +
+
+
Report
+
Program
+
Researcher
+
Severity
+
Status
+
Bounty
+
Actions
+
+ +
+ {isLoading &&
Loading...
} + + {!isLoading && data?.items.length === 0 && ( +
No reports found
+ )} + + {data?.items.map((report) => ( +
+
+ {report.title} +
+
+ {report.program_name} +
+
+
+ + {report.researcher_name ?? 'Anonymous'} + + + {report.researcher_email} + +
+
+
+ + { + SEVERITY_LABELS[ + report.severity_final ?? report.severity_submitted + ] + } + +
+
+ + {REPORT_STATUS_LABELS[report.status]} + +
+
+ + {formatCurrency(report.bounty_amount)} + +
+
+ +
+
+ ))} +
+ + {data && data.total > PAGINATION.DEFAULT_SIZE && ( +
+ + Page {page} of {totalPages} ({data.total} reports) + +
+ + +
+
+ )} +
+ + {modal.type === 'edit' && ( +
+ +
+
{ + e.preventDefault() + handleUpdate(modal.report.id, new FormData(e.currentTarget)) + }} + > +
+ {modal.report.title} + + {modal.report.program_name} - {modal.report.researcher_email} + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + )} + + ) +} + +Component.displayName = 'AdminReports' diff --git a/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/reports/reports.module.scss b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/reports/reports.module.scss new file mode 100644 index 00000000..aa128757 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/reports/reports.module.scss @@ -0,0 +1,497 @@ +// =================== +// AngelaMos | 2026 +// reports.module.scss +// =================== + +@use '@/styles' as *; + +.page { + padding: $space-6; + min-height: calc(100vh - 56px); +} + +.header { + @include flex-between; + flex-wrap: wrap; + gap: $space-4; + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.filters { + display: flex; + gap: $space-3; +} + +.filterSelect { + height: 40px; + padding: 0 $space-3; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + cursor: pointer; + @include transition-fast; + + &:focus { + outline: none; + border-color: $border-strong; + } + + option { + background: $bg-surface-100; + color: $text-default; + } +} + +.table { + width: 100%; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + overflow: hidden; +} + +.tableHeader { + display: grid; + grid-template-columns: 2fr 1fr 1.5fr 0.8fr 0.8fr 0.8fr 80px; + gap: $space-4; + padding: $space-3 $space-4; + background: $bg-surface-200; + border-bottom: 1px solid $border-default; + + @include breakpoint-down('xl') { + display: none; + } +} + +.tableHeaderCell { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: $text-lighter; + text-transform: uppercase; + letter-spacing: $tracking-wide; +} + +.tableBody { + @include flex-column; +} + +.tableRow { + display: grid; + grid-template-columns: 2fr 1fr 1.5fr 0.8fr 0.8fr 0.8fr 80px; + gap: $space-4; + padding: $space-3 $space-4; + border-bottom: 1px solid $border-default; + @include transition-fast; + + &:last-child { + border-bottom: none; + } + + @include hover { + background: $bg-surface-75; + } + + @include breakpoint-down('xl') { + grid-template-columns: 1fr; + gap: $space-2; + } +} + +.tableCell { + display: flex; + align-items: center; + font-size: $font-size-sm; + color: $text-default; + min-width: 0; + + @include breakpoint-down('xl') { + &::before { + content: attr(data-label); + font-size: $font-size-xs; + color: $text-lighter; + margin-right: $space-2; + min-width: 80px; + } + } +} + +.reportTitle { + @include truncate; + font-weight: $font-weight-medium; +} + +.programName { + @include truncate; + color: $text-light; +} + +.researcherInfo { + @include flex-column; + gap: $space-1; + min-width: 0; +} + +.researcherName { + @include truncate; +} + +.researcherEmail { + font-size: $font-size-xs; + color: $text-lighter; + @include truncate; +} + +.severityBadge { + display: inline-flex; + align-items: center; + padding: $space-1 $space-2; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + + &.critical { + background: hsl(0 72% 51% / 20%); + color: hsl(0, 72%, 65%); + } + + &.high { + background: hsl(25 95% 53% / 20%); + color: hsl(25, 95%, 63%); + } + + &.medium { + background: hsl(45 93% 47% / 20%); + color: hsl(45, 93%, 57%); + } + + &.low { + background: hsl(217 91% 60% / 20%); + color: hsl(217, 91%, 70%); + } + + &.informational { + background: $bg-surface-200; + color: $text-light; + } +} + +.statusBadge { + display: inline-flex; + align-items: center; + padding: $space-1 $space-2; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + + &.new { + background: hsl(217 91% 60% / 20%); + color: hsl(217, 91%, 70%); + } + + &.triaging { + background: hsl(45 93% 47% / 20%); + color: hsl(45, 93%, 57%); + } + + &.needsMoreInfo { + background: hsl(280 65% 60% / 20%); + color: hsl(280, 65%, 70%); + } + + &.accepted { + background: hsl(142 76% 36% / 20%); + color: hsl(142, 76%, 46%); + } + + &.duplicate, + &.informative, + &.notApplicable { + background: $bg-surface-200; + color: $text-light; + } + + &.resolved { + background: hsl(142 76% 36% / 20%); + color: hsl(142, 76%, 46%); + } + + &.disclosed { + background: hsl(160 60% 45% / 20%); + color: hsl(160, 60%, 55%); + } +} + +.bountyAmount { + font-weight: $font-weight-medium; + color: $accent-orange; +} + +.actions { + display: flex; + gap: $space-2; + justify-content: flex-end; + + @include breakpoint-down('xl') { + justify-content: flex-start; + } +} + +.actionBtn { + width: 32px; + height: 32px; + @include flex-center; + border: 1px solid $border-default; + border-radius: $radius-md; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + svg { + width: 16px; + height: 16px; + } +} + +.pagination { + @include flex-between; + padding: $space-4; + border-top: 1px solid $border-default; +} + +.paginationInfo { + font-size: $font-size-sm; + color: $text-lighter; +} + +.paginationBtns { + display: flex; + gap: $space-2; +} + +.paginationBtn { + padding: $space-2 $space-3; + border: 1px solid $border-default; + border-radius: $radius-md; + background: transparent; + font-size: $font-size-sm; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.empty { + @include flex-column-center; + padding: $space-12; + color: $text-muted; + font-size: $font-size-sm; +} + +.loading { + @include flex-center; + padding: $space-12; + color: $text-muted; +} + +.modal { + position: fixed; + inset: 0; + z-index: $z-modal; + @include flex-center; +} + +.modalOverlay { + @include absolute-fill; + background: rgb(0, 0, 0, 70%); +} + +.modalContent { + position: relative; + width: 100%; + max-width: 400px; + margin: $space-4; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-6; +} + +.modalHeader { + @include flex-between; + margin-bottom: $space-5; +} + +.modalTitle { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.modalClose { + width: 32px; + height: 32px; + @include flex-center; + border: none; + border-radius: $radius-md; + background: transparent; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } + + svg { + width: 20px; + height: 20px; + } +} + +.form { + @include flex-column; + gap: $space-4; +} + +.reportLabel { + @include flex-column; + gap: $space-1; + font-size: $font-size-base; + font-weight: $font-weight-medium; + color: $text-default; + padding-bottom: $space-3; + border-bottom: 1px solid $border-default; +} + +.reportSubLabel { + font-size: $font-size-sm; + font-weight: $font-weight-regular; + color: $text-lighter; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; +} + +.select { + width: 100%; + height: 44px; + padding: 0 $space-3; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + cursor: pointer; + @include transition-fast; + + &:focus { + outline: none; + border-color: $border-strong; + } + + option { + background: $bg-surface-100; + color: $text-default; + } +} + +.input { + width: 100%; + height: 44px; + padding: 0 $space-3; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + @include transition-fast; + + &::placeholder { + color: $text-muted; + } + + &:focus { + outline: none; + border-color: $border-strong; + } +} + +.formActions { + display: flex; + gap: $space-3; + margin-top: $space-2; +} + +.submitBtn { + flex: 1; + height: 44px; + @include flex-center; + background: $white; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $black; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(0.9); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.cancelBtn { + flex: 1; + height: 44px; + @include flex-center; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/stats/index.tsx b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/stats/index.tsx new file mode 100644 index 00000000..0ff2bf4b --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/stats/index.tsx @@ -0,0 +1,104 @@ +/** + * AngelaMos | 2026 + * index.tsx + */ + +import { usePlatformStats } from '@/api/hooks' +import styles from './stats.module.scss' + +export function Component(): React.ReactElement { + const { data: stats, isLoading } = usePlatformStats() + + if (isLoading) { + return ( +
+
Loading stats...
+
+ ) + } + + if (!stats) { + return ( +
+
Failed to load platform statistics
+
+ ) + } + + const formatCurrency = (cents: number): string => { + return new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + minimumFractionDigits: 0, + }).format(cents / 100) + } + + return ( +
+
+

Platform Statistics

+
+ +
+
+ Total Users + {stats.total_users} +
+
+ Researchers + {stats.total_researchers} +
+
+ Companies + {stats.total_companies} +
+
+ New Users (30d) + {stats.new_users_this_month} +
+
+ +
+
+ Total Programs + {stats.total_programs} +
+
+ Active Programs + + {stats.active_programs} + +
+
+ Total Reports + {stats.total_reports} +
+
+ Reports (30d) + {stats.reports_this_month} +
+
+ +
+

Total Bounties Paid

+
+ {formatCurrency(stats.total_bounties_paid)} +
+
+ +
+

Reports by Status

+
+ {Object.entries(stats.reports_by_status).map(([status, count]) => ( +
+ {status} + {count} +
+ ))} +
+
+
+ ) +} + +Component.displayName = 'AdminStats' diff --git a/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/stats/stats.module.scss b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/stats/stats.module.scss new file mode 100644 index 00000000..94eea304 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/routes/admin/stats/stats.module.scss @@ -0,0 +1,115 @@ +// =================== +// AngelaMos | 2026 +// stats.module.scss +// =================== + +@use '@/styles' as *; + +.page { + padding: $space-6; + min-height: calc(100vh - 56px); +} + +.header { + margin-bottom: $space-6; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.loading, +.error { + @include flex-center; + padding: $space-12; + color: $text-muted; +} + +.statsGrid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: $space-4; + margin-bottom: $space-6; + + @include breakpoint-down('lg') { + grid-template-columns: repeat(2, 1fr); + } + + @include breakpoint-down('sm') { + grid-template-columns: 1fr; + } +} + +.statCard { + @include flex-column; + gap: $space-2; + padding: $space-5; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; +} + +.statLabel { + font-size: $font-size-sm; + color: $text-lighter; +} + +.statValue { + font-size: $font-size-3xl; + font-weight: $font-weight-semibold; + color: $text-default; + + &.highlight { + color: $accent-orange; + } +} + +.section { + margin-bottom: $space-6; +} + +.sectionTitle { + font-size: $font-size-lg; + font-weight: $font-weight-semibold; + color: $text-default; + margin-bottom: $space-4; +} + +.bountyTotal { + font-size: $font-size-4xl; + font-weight: $font-weight-semibold; + color: $accent-orange; + padding: $space-6; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + text-align: center; +} + +.statusGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: $space-3; +} + +.statusItem { + @include flex-between; + padding: $space-3 $space-4; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-md; +} + +.statusLabel { + font-size: $font-size-sm; + color: $text-light; + text-transform: capitalize; +} + +.statusCount { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $text-default; +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/routes/company/inbox/inbox.module.scss b/PROJECTS/bug-bounty-platform/frontend/src/routes/company/inbox/inbox.module.scss new file mode 100644 index 00000000..e4832e5a --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/routes/company/inbox/inbox.module.scss @@ -0,0 +1,254 @@ +// =================== +// AngelaMos | 2026 +// inbox.module.scss +// =================== + +@use '@/styles' as *; + +.page { + min-height: calc(100vh - 56px); + padding: $space-6; + background-color: $bg-default; +} + +.container { + max-width: 1000px; + margin: 0 auto; +} + +.header { + @include flex-between; + margin-bottom: $space-6; + gap: $space-4; + flex-wrap: wrap; +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; + margin-bottom: $space-2; +} + +.subtitle { + font-size: $font-size-sm; + color: $text-lighter; +} + +.stats { + display: flex; + align-items: baseline; + gap: $space-2; + padding: $space-3 $space-4; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-md; +} + +.statValue { + font-size: $font-size-xl; + font-weight: $font-weight-semibold; + color: $accent-orange; +} + +.statLabel { + font-size: $font-size-sm; + color: $text-lighter; +} + +.table { + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + overflow: hidden; +} + +.tableHeader { + display: grid; + grid-template-columns: 2fr 1fr 1fr 120px; + gap: $space-4; + padding: $space-3 $space-4; + background: $bg-surface-200; + border-bottom: 1px solid $border-default; + + @include breakpoint-down('md') { + display: none; + } +} + +.headerCell { + font-size: $font-size-xs; + font-weight: $font-weight-medium; + color: $text-lighter; + text-transform: uppercase; + letter-spacing: $tracking-wide; +} + +.tableBody { + @include flex-column; +} + +.row { + display: grid; + grid-template-columns: 2fr 1fr 1fr 120px; + gap: $space-4; + padding: $space-4; + border-bottom: 1px solid $border-muted; + text-decoration: none; + @include transition-fast; + + &:last-child { + border-bottom: none; + } + + @include hover { + background: $bg-surface-75; + } + + @include breakpoint-down('md') { + grid-template-columns: 1fr; + gap: $space-3; + } +} + +.cell { + display: flex; + align-items: center; + min-width: 0; + + @include breakpoint-down('md') { + &:first-child { + flex-direction: column; + align-items: flex-start; + } + } +} + +.reportTitle { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; + @include truncate; +} + +.status { + display: inline-flex; + padding: $space-1 $space-2; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + + &[data-status='new'] { + background: hsl(217 91% 60% / 20%); + color: hsl(217, 91%, 70%); + } + + &[data-status='triaging'] { + background: hsl(38 92% 50% / 20%); + color: hsl(38, 92%, 60%); + } + + &[data-status='needs_more_info'] { + background: hsl(263 70% 50% / 20%); + color: hsl(263, 70%, 70%); + } + + &[data-status='accepted'] { + background: hsl(142 76% 36% / 20%); + color: hsl(142, 76%, 46%); + } + + &[data-status='duplicate'], + &[data-status='not_applicable'] { + background: $bg-surface-200; + color: $text-lighter; + } + + &[data-status='informative'] { + background: hsl(188 78% 41% / 20%); + color: hsl(188, 78%, 51%); + } + + &[data-status='resolved'] { + background: hsl(142 76% 36% / 20%); + color: hsl(142, 76%, 46%); + } + + &[data-status='disclosed'] { + background: hsl(160 84% 39% / 20%); + color: hsl(160, 84%, 49%); + } +} + +.severity { + display: inline-flex; + padding: $space-1 $space-2; + border-radius: $radius-sm; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + + &[data-severity='critical'] { + background: hsl(0 72% 51% / 20%); + color: hsl(0, 72%, 60%); + } + + &[data-severity='high'] { + background: hsl(24 95% 53% / 20%); + color: hsl(24, 95%, 63%); + } + + &[data-severity='medium'] { + background: hsl(45 93% 47% / 20%); + color: hsl(45, 93%, 57%); + } + + &[data-severity='low'] { + background: hsl(217 91% 60% / 20%); + color: hsl(217, 91%, 70%); + } + + &[data-severity='informational'] { + background: $bg-surface-200; + color: $text-lighter; + } +} + +.date { + font-size: $font-size-sm; + color: $text-light; +} + +.loading { + @include flex-center; + padding: $space-12; + color: $text-muted; + font-size: $font-size-sm; +} + +.error { + @include flex-center; + padding: $space-12; + color: $error-default; + font-size: $font-size-sm; +} + +.empty { + @include flex-column-center; + padding: $space-12; + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + gap: $space-2; + + p { + color: $text-light; + font-size: $font-size-sm; + } +} + +.emptyHint { + color: $text-muted; + font-size: $font-size-xs; + text-align: center; + max-width: 320px; +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/routes/company/inbox/index.tsx b/PROJECTS/bug-bounty-platform/frontend/src/routes/company/inbox/index.tsx new file mode 100644 index 00000000..7c4c4741 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/routes/company/inbox/index.tsx @@ -0,0 +1,115 @@ +/** + * AngelaMos | 2026 + * index.tsx + */ + +import { Link } from 'react-router-dom' +import { useInbox } from '@/api/hooks' +import { REPORT_STATUS_LABELS, type Report, SEVERITY_LABELS } from '@/api/types' +import { ROUTES } from '@/config' +import styles from './inbox.module.scss' + +function formatDate(dateString: string): string { + return new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }).format(new Date(dateString)) +} + +function ReportRow({ report }: { report: Report }): React.ReactElement { + return ( + +
+ {report.title} +
+
+ + {REPORT_STATUS_LABELS[report.status]} + +
+
+ + {SEVERITY_LABELS[report.severity_submitted]} + +
+
+ {formatDate(report.created_at)} +
+ + ) +} + +export function Component(): React.ReactElement { + const { data, isLoading, error } = useInbox(1, 50) + + if (isLoading) { + return ( +
+
+
Loading inbox...
+
+
+ ) + } + + if (error) { + return ( +
+
+
Failed to load inbox
+
+
+ ) + } + + const reports = data?.items ?? [] + + return ( +
+
+
+
+

Inbox

+

+ Review and triage incoming vulnerability reports +

+
+
+ {data?.total ?? 0} + total reports +
+
+ + {reports.length === 0 ? ( +
+

No reports in your inbox

+ + Reports will appear here when researchers submit vulnerabilities to + your programs + +
+ ) : ( +
+
+ Report + Status + Severity + Submitted +
+
+ {reports.map((report) => ( + + ))} +
+
+ )} +
+
+ ) +} + +Component.displayName = 'CompanyInbox' diff --git a/PROJECTS/bug-bounty-platform/frontend/src/routes/company/programs/[id]/edit.module.scss b/PROJECTS/bug-bounty-platform/frontend/src/routes/company/programs/[id]/edit.module.scss new file mode 100644 index 00000000..f3fef408 --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/routes/company/programs/[id]/edit.module.scss @@ -0,0 +1,375 @@ +// =================== +// AngelaMos | 2026 +// edit.module.scss +// =================== + +@use '@/styles' as *; + +.page { + min-height: calc(100vh - 56px); + padding: $space-6; + background-color: $bg-default; +} + +.container { + max-width: 900px; + margin: 0 auto; +} + +.header { + margin-bottom: $space-6; +} + +.backLink { + display: inline-flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + color: $text-light; + margin-bottom: $space-4; + @include transition-fast; + + @include hover { + color: $text-default; + } +} + +.title { + font-size: $font-size-2xl; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.section { + background: $bg-surface-100; + border: 1px solid $border-default; + border-radius: $radius-lg; + padding: $space-5; + margin-bottom: $space-6; +} + +.sectionHeader { + @include flex-between; + margin-bottom: $space-4; +} + +.sectionTitle { + font-size: $font-size-base; + font-weight: $font-weight-semibold; + color: $text-default; +} + +.fields { + @include flex-column; + gap: $space-4; +} + +.field { + @include flex-column; + gap: $space-2; +} + +.label { + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-light; +} + +.input { + width: 100%; + height: 40px; + padding: 0 $space-3; + background: $bg-control; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + @include transition-fast; + + &:focus { + outline: none; + border-color: $border-strong; + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &[type='number'] { + -moz-appearance: textfield; + } +} + +.select { + width: 100%; + height: 40px; + padding: 0 $space-3; + background: $bg-control; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + cursor: pointer; + + &:focus { + outline: none; + border-color: $border-strong; + } + + option { + background: $bg-surface-100; + } +} + +.textarea { + width: 100%; + padding: $space-3; + background: $bg-control; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + resize: vertical; + min-height: 80px; + font-family: inherit; + + &:focus { + outline: none; + border-color: $border-strong; + } +} + +.row { + display: grid; + gap: $space-4; + + @include breakpoint-up('md') { + grid-template-columns: repeat(3, 1fr); + } +} + +.checkLabel { + display: flex; + align-items: center; + gap: $space-2; + font-size: $font-size-sm; + color: $text-light; + cursor: pointer; + + input { + width: 16px; + height: 16px; + accent-color: $accent-orange; + } +} + +.saveBtn { + align-self: flex-start; + height: 40px; + padding: 0 $space-5; + background: $accent-orange; + border: none; + border-radius: $radius-md; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $white; + cursor: pointer; + @include transition-fast; + + @include hover { + filter: brightness(1.1); + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } +} + +.addBtn { + height: 36px; + padding: 0 $space-4; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + background: $bg-surface-200; + color: $text-default; + } +} + +.addForm { + @include flex-column; + gap: $space-4; + padding: $space-4; + background: $bg-surface-75; + border: 1px solid $border-muted; + border-radius: $radius-md; + margin-bottom: $space-4; +} + +.assetList { + @include flex-column; + gap: $space-2; +} + +.assetItem { + @include flex-between; + padding: $space-3; + background: $bg-surface-75; + border: 1px solid $border-muted; + border-radius: $radius-md; +} + +.assetInfo { + display: flex; + align-items: center; + gap: $space-3; + flex-wrap: wrap; +} + +.assetType { + padding: $space-0-5 $space-2; + background: $bg-surface-300; + border-radius: $radius-sm; + font-size: $font-size-xs; + color: $text-lighter; +} + +.assetId { + font-size: $font-size-sm; + font-family: $font-mono; + color: $text-default; +} + +.scopeBadge { + padding: $space-0-5 $space-2; + border-radius: $radius-full; + font-size: $font-size-xs; + font-weight: $font-weight-medium; + + &[data-scope='in'] { + background: hsl(142 76% 36% / 20%); + color: hsl(142, 76%, 46%); + } + + &[data-scope='out'] { + background: hsl(0 72% 51% / 20%); + color: $error-light; + } +} + +.deleteBtn { + padding: $space-1 $space-3; + background: transparent; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-xs; + color: $text-light; + cursor: pointer; + @include transition-fast; + + @include hover { + border-color: $error-default; + color: $error-default; + } +} + +.emptyText { + font-size: $font-size-sm; + color: $text-muted; + padding: $space-4; + text-align: center; +} + +.rewardGrid { + @include flex-column; + gap: $space-3; + margin-bottom: $space-4; +} + +.rewardRow { + display: flex; + align-items: center; + gap: $space-4; + padding: $space-3; + background: $bg-surface-75; + border: 1px solid $border-muted; + border-radius: $radius-md; + + @include breakpoint-down('sm') { + flex-direction: column; + align-items: flex-start; + } +} + +.severityLabel { + width: 120px; + font-size: $font-size-sm; + font-weight: $font-weight-medium; + color: $text-default; + flex-shrink: 0; +} + +.rewardInputs { + display: flex; + align-items: center; + gap: $space-2; +} + +.currency { + font-size: $font-size-sm; + color: $text-muted; +} + +.rewardInput { + width: 100px; + height: 36px; + padding: 0 $space-2; + background: $bg-control; + border: 1px solid $border-default; + border-radius: $radius-md; + font-size: $font-size-sm; + color: $text-default; + text-align: right; + + &:focus { + outline: none; + border-color: $border-strong; + } + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + + &[type='number'] { + -moz-appearance: textfield; + } +} + +.rewardSep { + color: $text-muted; +} + +.loading { + @include flex-center; + padding: $space-12; + color: $text-muted; + font-size: $font-size-sm; +} + +.error { + @include flex-center; + padding: $space-12; + color: $error-default; + font-size: $font-size-sm; +} diff --git a/PROJECTS/bug-bounty-platform/frontend/src/routes/company/programs/[id]/edit.tsx b/PROJECTS/bug-bounty-platform/frontend/src/routes/company/programs/[id]/edit.tsx new file mode 100644 index 00000000..0d23917f --- /dev/null +++ b/PROJECTS/bug-bounty-platform/frontend/src/routes/company/programs/[id]/edit.tsx @@ -0,0 +1,439 @@ +/** + * AngelaMos | 2026 + * edit.tsx + */ + +import { useEffect, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { + useAddAsset, + useDeleteAsset, + useProgram, + useSetRewardTiers, + useUpdateProgram, +} from '@/api/hooks' +import { + ASSET_TYPE_LABELS, + type Asset, + AssetType, + type ProgramDetail, + type ProgramStatus, + ProgramVisibility, + SEVERITY_LABELS, + Severity, + STATUS_LABELS, +} from '@/api/types' +import { ROUTES } from '@/config' +import styles from './edit.module.scss' + +function ProgramDetails({ + program, +}: { + program: ProgramDetail +}): React.ReactElement { + const updateProgram = useUpdateProgram() + const [name, setName] = useState(program.name) + const [description, setDescription] = useState(program.description ?? '') + const [rules, setRules] = useState(program.rules ?? '') + const [sla, setSla] = useState(program.response_sla_hours) + const [status, setStatus] = useState(program.status) + const [visibility, setVisibility] = useState(program.visibility) + + const handleSave = async (): Promise => { + await updateProgram.mutateAsync({ + id: program.id, + data: { + name, + description: description || undefined, + rules: rules || undefined, + response_sla_hours: sla, + status, + visibility, + }, + }) + } + + return ( +
+

Program Details

+
+ +
+ + + +
+