From ab1bd90c6520082efc62d68d9e8642f06977da54 Mon Sep 17 00:00:00 2001 From: chy88 Date: Wed, 4 Mar 2026 00:18:33 +0800 Subject: [PATCH] =?UTF-8?q?feat(core):=20=E5=88=9D=E5=A7=8B=E5=8C=96=20Key?= =?UTF-8?q?-IP=20Sentinel=20=E6=9C=8D=E5=8A=A1=E4=B8=8E=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 搭建 FastAPI、Redis、PostgreSQL、Nginx 与 Docker Compose 基础结构 - 实现反向代理、首用绑定、拦截告警、归档任务和管理接口 - 提供 Vue3 管理后台初版,以及 uv/requirements 双依赖配置 --- .env.example | 14 + .gitignore | 10 + .python-version | 1 + Dockerfile | 10 + PRD.md | 424 ++++++++++++++ README.md | 147 +++++ app/api/auth.py | 49 ++ app/api/bindings.py | 153 +++++ app/api/dashboard.py | 109 ++++ app/api/logs.py | 107 ++++ app/api/settings.py | 77 +++ app/config.py | 98 ++++ app/core/ip_utils.py | 35 ++ app/core/security.py | 104 ++++ app/dependencies.py | 53 ++ app/main.py | 193 +++++++ app/models/db.py | 48 ++ app/models/intercept_log.py | 29 + app/models/token_binding.py | 46 ++ app/proxy/handler.py | 111 ++++ app/schemas/auth.py | 13 + app/schemas/binding.py | 42 ++ app/schemas/log.py | 23 + app/services/alert_service.py | 123 ++++ app/services/archive_service.py | 84 +++ app/services/binding_service.py | 464 +++++++++++++++ db/init.sql | 26 + docker-compose.yml | 73 +++ frontend/index.html | 12 + frontend/package.json | 22 + frontend/src/App.vue | 293 ++++++++++ frontend/src/api/index.js | 103 ++++ frontend/src/components/MetricTile.vue | 28 + frontend/src/components/PageHero.vue | 35 ++ frontend/src/composables/usePolling.js | 26 + frontend/src/main.js | 18 + frontend/src/router/index.js | 76 +++ frontend/src/styles.css | 477 ++++++++++++++++ frontend/src/utils/formatters.js | 51 ++ frontend/src/views/Bindings.vue | 266 +++++++++ frontend/src/views/Dashboard.vue | 235 ++++++++ frontend/src/views/Login.vue | 88 +++ frontend/src/views/Logs.vue | 183 ++++++ frontend/src/views/Settings.vue | 169 ++++++ frontend/vite.config.js | 21 + main.py | 6 + nginx/nginx.conf | 90 +++ pyproject.toml | 20 + requirements.txt | 9 + uv.lock | 751 +++++++++++++++++++++++++ 50 files changed, 5645 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .python-version create mode 100644 Dockerfile create mode 100644 PRD.md create mode 100644 README.md create mode 100644 app/api/auth.py create mode 100644 app/api/bindings.py create mode 100644 app/api/dashboard.py create mode 100644 app/api/logs.py create mode 100644 app/api/settings.py create mode 100644 app/config.py create mode 100644 app/core/ip_utils.py create mode 100644 app/core/security.py create mode 100644 app/dependencies.py create mode 100644 app/main.py create mode 100644 app/models/db.py create mode 100644 app/models/intercept_log.py create mode 100644 app/models/token_binding.py create mode 100644 app/proxy/handler.py create mode 100644 app/schemas/auth.py create mode 100644 app/schemas/binding.py create mode 100644 app/schemas/log.py create mode 100644 app/services/alert_service.py create mode 100644 app/services/archive_service.py create mode 100644 app/services/binding_service.py create mode 100644 db/init.sql create mode 100644 docker-compose.yml create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/index.js create mode 100644 frontend/src/components/MetricTile.vue create mode 100644 frontend/src/components/PageHero.vue create mode 100644 frontend/src/composables/usePolling.js create mode 100644 frontend/src/main.js create mode 100644 frontend/src/router/index.js create mode 100644 frontend/src/styles.css create mode 100644 frontend/src/utils/formatters.js create mode 100644 frontend/src/views/Bindings.vue create mode 100644 frontend/src/views/Dashboard.vue create mode 100644 frontend/src/views/Login.vue create mode 100644 frontend/src/views/Logs.vue create mode 100644 frontend/src/views/Settings.vue create mode 100644 frontend/vite.config.js create mode 100644 main.py create mode 100644 nginx/nginx.conf create mode 100644 pyproject.toml create mode 100644 requirements.txt create mode 100644 uv.lock diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..48a2d0c --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +DOWNSTREAM_URL=http://new-api:3000 +REDIS_ADDR=redis://redis:6379 +REDIS_PASSWORD= +PG_DSN=postgresql+asyncpg://sentinel:password@postgres:5432/sentinel +SENTINEL_HMAC_SECRET=replace-with-a-random-32-byte-secret +ADMIN_PASSWORD=replace-with-a-strong-password +ADMIN_JWT_SECRET=replace-with-a-random-jwt-secret +TRUSTED_PROXY_IPS=172.18.0.0/16 +SENTINEL_FAILSAFE_MODE=closed +APP_PORT=7000 +ALERT_WEBHOOK_URL= +ALERT_THRESHOLD_COUNT=5 +ALERT_THRESHOLD_SECONDS=300 +ARCHIVE_DAYS=90 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..24ee5b1 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.13 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..b76bd7b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.13-slim AS builder +WORKDIR /build +COPY requirements.txt . +RUN pip install --no-cache-dir --prefix=/install -r requirements.txt + +FROM python:3.13-slim +WORKDIR /app +COPY --from=builder /install /usr/local +COPY app/ ./app/ +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7000", "--workers", "4"] diff --git a/PRD.md b/PRD.md new file mode 100644 index 0000000..17c5cb0 --- /dev/null +++ b/PRD.md @@ -0,0 +1,424 @@ +# 产品需求文档 (PRD) +# Key-IP Sentinel:大模型 API 密钥 IP 防泄漏网关 +**版本**:v1.1 | **状态**:待开发 | **作者**:算力平台管理员 + +*** + +## 一、产品背景与目标 + +### 1.1 背景 +企业内部算力平台(基于 New API + vLLM 自部署)面向几百至上千名内部研发人员开放使用。用户注册账号后可自助申请 API Key,但现有机制无法防止用户将 API Key 分享给外部人员或在未授权设备上使用,存在算力被盗刷的风险。 + +### 1.2 解决方案定位 +在调用方与 New API 之间部署一个独立的轻量级反向代理服务 **Key-IP Sentinel**,实现"**首次使用即绑定(First-Use-Bind)**"机制——API Key 在第一次被调用时,系统自动将该 Key 与发起调用的客户端 IP 绑定;此后该 Key 只能从绑定的 IP 发起请求,其他 IP 全部拦截。整个过程对用户无感知,无需管理员手动介入。 + +### 1.3 产品目标 +- 彻底杜绝 API Key 被分享到外部或其他未授权设备上使用。 +- 系统自动完成 IP 绑定,管理员只需处理少数"换 IP"的运维操作。 +- 提供清晰的管理后台,支持查看绑定状态、拦截日志、手动运维。 + +*** + +## 二、整体架构 + +### 2.1 流量链路 + +``` +调用方 (Client) + │ + │ HTTPS (443) + ▼ +┌─────────────────────────────────────────┐ +│ Nginx │ +│ 职责:TLS终止 / 路径路由 / │ +│ 静态文件 / 内网鉴权 / 粗粒度限流 │ +└────────────────┬────────────────────────┘ + │ HTTP 内网转发 + ▼ +┌─────────────────────────────────────────┐ +│ Key-IP Sentinel App │ +│ 职责:Token提取 / IP绑定校验 / │ +│ 代理转发 / 管理 API / 告警 │ +└───────┬─────────────────┬───────────────┘ + │ │ 异步写 + ┌────▼────┐ ┌─────▼──────┐ + │ Redis │ │ PostgreSQL │ + │ (热缓存) │ │ (持久化) │ + └─────────┘ └────────────┘ + │ + ▼ +┌─────────────────────────────────────────┐ +│ New API (UDPI) │ +│ 职责:用户鉴权 / 额度管理 / 计费 / 路由 │ +└───────────────┬─────────────────────────┘ + │ + ┌──────▼──────┐ + │ vLLM 节点 │ + └─────────────┘ +``` + +### 2.2 技术选型 + +| 模块 | 技术方案 | 说明 | +|---|---|---| +| 反向代理 & 业务后端 | **Go (Gin)** 或 **Python (FastAPI)** | 优先推荐 Go,高并发下内存占用极低,SSE 透传天然支持 | +| 缓存层 | **Redis 7+** | Token-IP 绑定热数据,TTL 7 天 | +| 持久化层 | **PostgreSQL 15+** | 绑定记录与审计日志,使用 `inet`/`cidr` 原生类型做 IP 范围匹配 | +| 前端管理 UI | **Vue3 + Element Plus** | 纯静态 SPA,打包后由 Nginx 直接托管 | +| 外层网关 | **Nginx** | TLS 终止、路径隔离、静态文件、`limit_req_zone` 限流 | +| 部署方式 | **Docker Compose** | 共 4 个容器:nginx / sentinel-app / redis / postgres | + +*** + +## 三、核心业务流程 + +### 3.1 请求拦截与动态绑定(网关核心逻辑) + +``` +收到请求 + │ + ├─ 无 Authorization Header? ─→ 直接透传给 New API(由其拒绝) + │ + ├─ 提取 Token(sk-xxx) + ├─ 提取真实 IP(只信任 Nginx 写入的 X-Real-IP,见安全说明 §6.1) + ├─ 对 Token 做 HMAC-SHA256 哈希 → token_hash + │ + ├─ 查 Redis: sentinel:token:{token_hash} + │ │ + │ 命中 ──→ 比对 IP(支持 CIDR 匹配) + │ │ ├─ 匹配 → 刷新 last_used_at(异步写 PG)→ 放行 + │ │ └─ 不匹配 → 写拦截日志 → 检查告警阈值 → 返回 403 + │ │ + │ 未命中 → 查 PG: SELECT * FROM token_bindings WHERE token_hash=? + │ │ + │ 有记录 → 回写 Redis → 比对 IP(同上) + │ │ + │ 无记录 → 【首次绑定】 + │ ├─ INSERT INTO token_bindings (token_hash, bound_ip, status=Active) + │ ├─ 写入 Redis,TTL 7天 + │ └─ 放行请求 +``` + +### 3.2 SSE 流式输出透传要求 +- 网关必须支持 **逐 chunk 实时转发**,不得缓冲整个响应再转发。 +- 响应 Header 中的 `Content-Type: text/event-stream` 和 `Transfer-Encoding: chunked` 必须透传。 +- 连接超时时间应设为不低于 **600 秒**(大模型长上下文推理可能耗时较长)。 + +### 3.3 管理员运维场景 + +| 场景 | 触发条件 | 管理员操作 | 系统行为 | +|---|---|---|---| +| 用户换电脑/换 IP | 用户反馈 Key 无法使用 | 在后台搜索该 Token,点击【解绑】 | 清除 PG 记录 + 清除 Redis 缓存,用户下次调用重新触发首次绑定 | +| 用户转岗,需绑定新 IP | 用户提交申请 | 在后台点击【编辑 IP】,填入新 IP 或网段 | 更新 PG + 使 Redis 缓存失效(强制下次从 PG 重新加载)| +| 发现 Key 被泄露 | 拦截告警推送 | 点击【封禁】 | 将该 Token 状态置为 Banned,Redis 同步更新,后续所有请求直接被 Sentinel 拒绝 | +| 用户离职 | HR 通知 | 点击【封禁】 | 同上 | + +*** + +## 四、功能需求 + +### 4.1 Nginx 层职责 + +在 `nginx.conf` 中需要实现以下配置: + +```nginx +# 1. TLS 终止(HTTPS → HTTP 转发给 sentinel-app) +# 2. 代理路径:/ 全部转发给 sentinel-app:7000 +# 3. 管理后台访问限制 +location /admin/ { + allow 10.0.0.0/8; # 内网 IP 段 + allow 192.168.0.0/16; + deny all; + proxy_pass http://sentinel-app:7000; +} +# 4. 静态文件(前端 UI) +location /admin/ui/ { + root /etc/nginx/html; + try_files $uri $uri/ /admin/ui/index.html; +} +# 5. 基础限流 +limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; +# 6. 强制写入真实 IP(防客户端伪造) +proxy_set_header X-Real-IP $remote_addr; +proxy_set_header X-Forwarded-For $remote_addr; # 覆盖,不信任客户端传入的值 +``` + +### 4.2 Sentinel App 反向代理模块 +- **受信 IP Header**:只读取 `X-Real-IP`(Nginx 写入的),忽略请求中原始的 `X-Forwarded-For`。 +- **Token 提取**:从 `Authorization: Bearer {token}` Header 中提取。 +- **连接池**:与下游 New API 保持 HTTP Keep-Alive 长连接,连接池最大连接数可通过配置项设置,默认 512。 +- **异步落库**:放行后,`last_used_at` 更新通过内部消息通道异步批量写入 PG(每 5 秒 flush 一次),避免阻塞代理主协程。 +- **降级策略**: + - Redis 不可用时:降级查询 PG,同时触发内部限流(最大 QPS 降为正常值的 50%),并写入系统告警日志。 + - PG 也不可用时:行为由环境变量 `SENTINEL_FAILSAFE_MODE` 决定:`open`(放行所有,保业务)或 `closed`(拒绝所有,保安全),生产环境默认 `closed`。 + +### 4.3 Web 管理后台(Admin UI) + +**页面一:数据大盘(Dashboard)** +- 卡片指标: + - 今日总请求数 + - 今日放行数 / 拦截数 + - 当前已绑定 Token 数量 + - 当前封禁 Token 数量 +- 折线图:最近 7 天每日放行 vs 拦截数量趋势 +- 列表:最近 10 条拦截记录(实时刷新) + +**页面二:绑定管理(Bindings)** + +表格字段: + +| 字段 | 说明 | +|---|---| +| ID | 数据库主键 | +| Token(脱敏)| 展示格式 `sk-ab**...**xy`(前 4 后 4)| +| 绑定 IP / 网段 | 支持展示 CIDR,如 `192.168.1.0/24` | +| 状态 | `Active` / `Banned` | +| 首次绑定时间 | | +| 最近调用时间 | | +| 操作 | 解绑 / 编辑 IP / 封禁 / 解封 | + +筛选功能:按 Token 尾号搜索、按 IP 搜索、按状态筛选。 + +**页面三:拦截日志(Intercept Logs)** + +表格字段: + +| 字段 | 说明 | +|---|---| +| 时间 | 拦截发生的精确时间 | +| Token(脱敏)| 被拦截的 Token | +| 绑定 IP | 该 Token 注册的合法 IP | +| 尝试 IP | 发起非法请求的 IP | +| 是否已告警 | 是否触发过告警推送 | + +支持按时间范围、Token、尝试 IP 筛选,支持导出 CSV。 + +**页面四:系统设置(Settings)** +- 告警阈值配置:N 分钟内同一 Token 被拦截 M 次触发告警(默认:5 分钟内 5 次)。 +- 告警方式:Webhook URL(调用方自定义,POST JSON 格式)。 +- 自动归档策略:last_used_at 超过 N 天的记录自动归档(默认 90 天)。 +- `FAILSAFE_MODE` 开关显示与切换。 + +### 4.4 Admin REST API + +所有 `/admin/api/*` 接口需要携带 JWT Token(通过 `/admin/api/login` 获取),登录凭证从环境变量读取。 + +| Method | 路径 | 说明 | +|---|---|---| +| `POST` | `/admin/api/login` | 管理员登录,返回 JWT | +| `GET` | `/admin/api/dashboard` | 大盘统计数据 | +| `GET` | `/admin/api/bindings` | 获取绑定列表(支持分页 & 筛选) | +| `POST` | `/admin/api/bindings/unbind` | 解除绑定(清除 PG + Redis) | +| `PUT` | `/admin/api/bindings/ip` | 手动修改绑定 IP | +| `POST` | `/admin/api/bindings/ban` | 封禁 Token | +| `POST` | `/admin/api/bindings/unban` | 解封 Token | +| `GET` | `/admin/api/logs` | 获取拦截日志(分页 & 筛选) | +| `GET` | `/admin/api/logs/export` | 导出日志 CSV | +| `GET` | `/admin/api/settings` | 获取当前系统配置 | +| `PUT` | `/admin/api/settings` | 更新系统配置 | +| `GET` | `/health` | 健康检查(无需鉴权,供 Nginx/Docker 使用)| + +*** + +## 五、数据结构设计 + +### 5.1 `token_bindings`(主表) + +```sql +CREATE TABLE token_bindings ( + id BIGSERIAL PRIMARY KEY, + token_hash VARCHAR(64) NOT NULL UNIQUE, -- HMAC-SHA256 哈希值 + token_display VARCHAR(20) NOT NULL, -- 脱敏展示用,如 sk-ab****xy + bound_ip CIDR NOT NULL, -- 使用 PG 原生 CIDR 类型,支持网段 + status SMALLINT NOT NULL DEFAULT 1, -- 1=Active, 2=Banned + first_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_token_bindings_token_hash ON token_bindings(token_hash); +CREATE INDEX idx_token_bindings_bound_ip ON token_bindings USING GIST (bound_ip inet_ops); +``` + +IP 范围匹配查询(使用 PG `<<` 操作符,性能极高): + +```sql +-- 检查请求 IP 是否在绑定的网段内 +SELECT status FROM token_bindings +WHERE token_hash = $1 + AND $2::inet << bound_ip -- $2 为请求方真实 IP +LIMIT 1; +``` + +### 5.2 `intercept_logs`(拦截审计日志表) + +```sql +CREATE TABLE intercept_logs ( + id BIGSERIAL PRIMARY KEY, + token_hash VARCHAR(64) NOT NULL, + token_display VARCHAR(20) NOT NULL, + bound_ip CIDR NOT NULL, + attempt_ip INET NOT NULL, + alerted BOOLEAN NOT NULL DEFAULT FALSE, + intercepted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_intercept_logs_token_hash ON intercept_logs(token_hash); +CREATE INDEX idx_intercept_logs_intercepted_at ON intercept_logs(intercepted_at DESC); +``` + +### 5.3 Redis 数据结构 + +**绑定记录缓存** + +- **Key**:`sentinel:binding:{token_hash}` +- **Value**(JSON): + ```json + { + "bound_ip": "192.168.1.0/24", + "status": 1 + } + ``` +- **TTL**:604800 秒(7 天),每次命中时刷新 + +**拦截计数器(用于告警)** + +- **Key**:`sentinel:alert:{token_hash}` +- **Value**:Integer(拦截次数计数) +- **TTL**:由告警配置的时间窗口决定(默认 300 秒/5 分钟) +- 当计数达到阈值时,App 触发告警并重置计数器 + +*** + +## 六、安全规范 + +### 6.1 IP 防伪造(最高优先级) +- Nginx 必须使用 `proxy_set_header X-Real-IP $remote_addr;` 强制覆盖,`$remote_addr` 是 TCP 层观察到的连接 IP,客户端**无法伪造**。 +- Sentinel App 内部必须明确配置受信上游 IP 列表(即 Nginx 容器的内网 IP,如 `172.18.0.2`),只有来自受信上游的 `X-Real-IP` 才被采信,否则直接用 TCP 连接的原始 IP。 +- **禁止**在任何情况下直接信任客户端传入的 `X-Forwarded-For` Header。 + +### 6.2 Token 存储安全 +- **禁止明文存储** API Key 的完整内容。 +- 存储时使用 **HMAC-SHA256**(密钥从环境变量 `SENTINEL_HMAC_SECRET` 读取,32 字节随机字符串)对 Token 进行哈希。 +- `token_display` 字段仅存储供人识别的脱敏格式(`sk-ab****xy`),无法被逆推出原始 Key。 + +### 6.3 Admin 接口安全 +- 管理员登录接口需要防暴力破解:连续 5 次失败则锁定 IP 15 分钟。 +- JWT Token 有效期设为 8 小时,支持手动吊销(退出登录清除服务端 Session 记录)。 +- 所有管理接口的调用需要记录操作日志(操作人 IP + 操作内容)。 + +*** + +## 七、环境变量配置清单 + +AI 开发时应将所有配置项做成环境变量,以下为完整列表: + +| 环境变量名 | 必填 | 说明 | 示例值 | +|---|---|---|---| +| `DOWNSTREAM_URL` | ✅ | 下游 New API 的地址 | `http://new-api:3000` | +| `REDIS_ADDR` | ✅ | Redis 连接地址 | `redis:6379` | +| `REDIS_PASSWORD` | | Redis 密码 | | +| `PG_DSN` | ✅ | PostgreSQL 连接串 | `postgres://user:pass@postgres:5432/sentinel` | +| `SENTINEL_HMAC_SECRET` | ✅ | Token 哈希的 HMAC 密钥(32字节) | 随机生成 | +| `ADMIN_PASSWORD` | ✅ | 管理员登录密码 | | +| `ADMIN_JWT_SECRET` | ✅ | JWT 签名密钥 | 随机生成 | +| `TRUSTED_PROXY_IPS` | ✅ | 受信上游代理 IP(Nginx 的内网 IP)| `172.18.0.2` | +| `SENTINEL_FAILSAFE_MODE` | ✅ | 全链路故障时行为:`open`/`closed` | `closed` | +| `APP_PORT` | | App 监听端口 | `7000` | +| `ALERT_WEBHOOK_URL` | | 告警推送 Webhook 地址 | | +| `ALERT_THRESHOLD_COUNT` | | 触发告警的拦截次数阈值 | `5` | +| `ALERT_THRESHOLD_SECONDS` | | 告警计数时间窗口(秒)| `300` | +| `ARCHIVE_DAYS` | | 自动归档的不活跃天数 | `90` | + +*** + +## 八、Docker Compose 部署结构 + +```yaml +version: '3.8' +services: + + nginx: + image: nginx:alpine + container_name: sentinel-nginx + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - ./frontend/dist:/etc/nginx/html/admin/ui:ro + depends_on: + - sentinel-app + networks: + - sentinel-net + + sentinel-app: + image: key-ip-sentinel:latest # 本地 build + container_name: sentinel-app + build: . + restart: always + # 不暴露端口到宿主机,只在内网被 Nginx 访问 + environment: + - DOWNSTREAM_URL=http://new-api:3000 # 通过 external network 访问 New API + - REDIS_ADDR=redis:6379 + - PG_DSN=postgres://sentinel:password@postgres:5432/sentinel + - SENTINEL_HMAC_SECRET=${SENTINEL_HMAC_SECRET} + - ADMIN_PASSWORD=${ADMIN_PASSWORD} + - ADMIN_JWT_SECRET=${ADMIN_JWT_SECRET} + - TRUSTED_PROXY_IPS=172.18.0.0/16 + - SENTINEL_FAILSAFE_MODE=closed + depends_on: + - redis + - postgres + networks: + - sentinel-net + - llm-shared-net # 与 New API 的共享网络(external) + + redis: + image: redis:7-alpine + container_name: sentinel-redis + restart: always + command: redis-server --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + networks: + - sentinel-net + + postgres: + image: postgres:15 + container_name: sentinel-postgres + restart: always + environment: + POSTGRES_USER: sentinel + POSTGRES_PASSWORD: ${PG_PASSWORD} + POSTGRES_DB: sentinel + volumes: + - pg_data:/var/lib/postgresql/data + - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro # 初始化建表 SQL + networks: + - sentinel-net + +volumes: + redis_data: + pg_data: + +networks: + sentinel-net: + driver: bridge + llm-shared-net: + external: true # 与 New API 共享的已存在网络 +``` + +*** + +## 九、非功能需求 + +| 指标 | 要求 | +|---|---| +| **代理延迟增加(p99)** | Redis 命中路径增加延迟 ≤ 5ms | +| **并发能力** | 单实例支持 ≥ 1000 并发连接(Go 实现)| +| **SSE 透传** | 流式输出无缓冲实时转发,延迟增加 ≤ 10ms | +| **可扩展性** | App 本体无状态,可横向扩展多实例,负载由 Nginx upstream 均衡 | +| **日志格式** | 结构化 JSON 日志,兼容 ELK / Loki 采集 | +| **健康检查** | `GET /health` 需 200ms 内响应 `{"status":"ok"}` | + +*** diff --git a/README.md b/README.md new file mode 100644 index 0000000..7362a7c --- /dev/null +++ b/README.md @@ -0,0 +1,147 @@ +# Key-IP Sentinel + +Key-IP Sentinel is a FastAPI-based reverse proxy that enforces first-use IP binding for model API keys before traffic reaches a downstream New API service. + +## Features + +- First-use bind with HMAC-SHA256 token hashing, Redis cache-aside, and PostgreSQL CIDR matching. +- Streaming reverse proxy built on `httpx.AsyncClient` and FastAPI `StreamingResponse`. +- Trusted proxy IP extraction that only accepts `X-Real-IP` from configured upstream networks. +- Redis-backed intercept alert counters with webhook delivery and PostgreSQL audit logs. +- Admin API protected by JWT and Redis-backed login lockout. +- Vue 3 + Element Plus admin console for dashboarding, binding operations, audit logs, and live runtime settings. +- Docker Compose deployment with Nginx, app, Redis, and PostgreSQL. + +## Repository Layout + +```text +sentinel/ +├── app/ +├── db/ +├── nginx/ +├── frontend/ +├── docker-compose.yml +├── Dockerfile +├── requirements.txt +└── README.md +``` + +## Runtime Notes + +- Redis stores binding cache, alert counters, daily dashboard metrics, and mutable runtime settings. +- PostgreSQL stores authoritative token bindings and intercept logs. +- Archive retention removes inactive bindings from the active table after `ARCHIVE_DAYS`. A later request from the same token will bind again on first use. +- `SENTINEL_FAILSAFE_MODE=closed` rejects requests when both Redis and PostgreSQL are unavailable. `open` allows traffic through. + +## Local Development + +### Backend + +1. Install `uv` and ensure Python 3.13 is available. +2. Create the environment and sync dependencies: + +```bash +uv sync +``` + +3. Copy `.env.example` to `.env` and update secrets plus addresses. +4. Start PostgreSQL and Redis. +5. Run the API: + +```bash +uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 7000 +``` + +### Frontend + +1. Install dependencies: + +```bash +cd frontend +npm install +``` + +2. Start Vite dev server: + +```bash +npm run dev +``` + +The Vite config proxies `/admin/api/*` to `http://127.0.0.1:7000`. + +## Dependency Management + +- Local Python development uses `uv` via [`pyproject.toml`](/d:/project/sentinel/pyproject.toml). +- Container builds still use [`requirements.txt`](/d:/project/sentinel/requirements.txt) because the Dockerfile is intentionally minimal and matches the delivery requirements. + +## Production Deployment + +### 1. Prepare environment + +1. Copy `.env.example` to `.env`. +2. Replace `SENTINEL_HMAC_SECRET`, `ADMIN_PASSWORD`, and `ADMIN_JWT_SECRET`. +3. Verify `DOWNSTREAM_URL` points to the internal New API service. +4. Keep `PG_DSN` aligned with the fixed PostgreSQL container password in `docker-compose.yml`, or update both together. + +### 2. Build the frontend bundle + +```bash +cd frontend +npm install +npm run build +cd .. +``` + +This produces `frontend/dist`, which Nginx serves at `/admin/ui/`. + +### 3. Provide TLS assets + +Place certificate files at: + +- `nginx/ssl/server.crt` +- `nginx/ssl/server.key` + +### 4. Start the stack + +```bash +docker compose up --build -d +``` + +Services: + +- `https:///` forwards model API traffic through Sentinel. +- `https:///admin/ui/` serves the admin console. +- `https:///admin/api/*` serves the admin API. +- `https:///health` exposes the app health check. + +## Admin API Summary + +- `POST /admin/api/login` +- `GET /admin/api/dashboard` +- `GET /admin/api/bindings` +- `POST /admin/api/bindings/unbind` +- `PUT /admin/api/bindings/ip` +- `POST /admin/api/bindings/ban` +- `POST /admin/api/bindings/unban` +- `GET /admin/api/logs` +- `GET /admin/api/logs/export` +- `GET /admin/api/settings` +- `PUT /admin/api/settings` + +All admin endpoints except `/admin/api/login` require `Authorization: Bearer `. + +## Key Implementation Details + +- `app/proxy/handler.py` keeps the downstream response fully streamed, including SSE responses. +- `app/core/ip_utils.py` never trusts client-supplied `X-Forwarded-For`. +- `app/services/binding_service.py` batches `last_used_at` updates every 5 seconds through an `asyncio.Queue`. +- `app/services/alert_service.py` pushes webhooks once the Redis counter reaches the configured threshold. +- `app/services/archive_service.py` prunes stale bindings on a scheduler interval. + +## Suggested Smoke Checks + +1. `GET /health` returns `{"status":"ok"}`. +2. A first request with a new bearer token creates a binding in PostgreSQL and Redis. +3. A second request from the same IP is allowed and refreshes `last_used_at`. +4. A request from a different IP is rejected with `403` and creates an `intercept_logs` record. +5. `/admin/api/login` returns a JWT and the frontend can load `/admin/api/dashboard`. diff --git a/app/api/auth.py b/app/api/auth.py new file mode 100644 index 0000000..fbb909a --- /dev/null +++ b/app/api/auth.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from redis.asyncio import Redis + +from app.config import Settings +from app.core.ip_utils import extract_client_ip +from app.core.security import ( + clear_login_failures, + create_admin_jwt, + ensure_login_allowed, + register_login_failure, + verify_admin_password, +) +from app.dependencies import get_redis, get_settings +from app.schemas.auth import LoginRequest, TokenResponse + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/api", tags=["auth"]) + + +@router.post("/login", response_model=TokenResponse) +async def login( + payload: LoginRequest, + request: Request, + settings: Settings = Depends(get_settings), + redis: Redis | None = Depends(get_redis), +) -> TokenResponse: + if redis is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Login service is unavailable because Redis is offline.", + ) + + client_ip = extract_client_ip(request, settings) + await ensure_login_allowed(redis, client_ip, settings) + + if not verify_admin_password(payload.password, settings): + await register_login_failure(redis, client_ip, settings) + logger.warning("Admin login failed.", extra={"client_ip": client_ip}) + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid admin password.") + + await clear_login_failures(redis, client_ip) + token, expires_in = create_admin_jwt(settings) + logger.info("Admin login succeeded.", extra={"client_ip": client_ip}) + return TokenResponse(access_token=token, expires_in=expires_in) diff --git a/app/api/bindings.py b/app/api/bindings.py new file mode 100644 index 0000000..10dbcfa --- /dev/null +++ b/app/api/bindings.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query, Request, status +from sqlalchemy import String, cast, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import Settings +from app.core.ip_utils import extract_client_ip +from app.dependencies import get_binding_service, get_db_session, get_settings, require_admin +from app.models.token_binding import STATUS_ACTIVE, STATUS_BANNED, TokenBinding +from app.schemas.binding import ( + BindingActionRequest, + BindingIPUpdateRequest, + BindingItem, + BindingListResponse, +) +from app.services.binding_service import BindingService + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/api/bindings", tags=["bindings"], dependencies=[Depends(require_admin)]) + + +def to_binding_item(binding: TokenBinding, binding_service: BindingService) -> BindingItem: + return BindingItem( + id=binding.id, + token_display=binding.token_display, + bound_ip=str(binding.bound_ip), + status=binding.status, + status_label=binding_service.status_label(binding.status), + first_used_at=binding.first_used_at, + last_used_at=binding.last_used_at, + created_at=binding.created_at, + ) + + +async def get_binding_or_404(session: AsyncSession, binding_id: int) -> TokenBinding: + binding = await session.get(TokenBinding, binding_id) + if binding is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Binding was not found.") + return binding + + +def log_admin_action(request: Request, settings: Settings, action: str, binding_id: int) -> None: + logger.info( + "Admin binding action.", + extra={ + "client_ip": extract_client_ip(request, settings), + "action": action, + "binding_id": binding_id, + }, + ) + + +@router.get("", response_model=BindingListResponse) +async def list_bindings( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=200), + token_suffix: str | None = Query(default=None), + ip: str | None = Query(default=None), + status_filter: int | None = Query(default=None, alias="status"), + session: AsyncSession = Depends(get_db_session), + binding_service: BindingService = Depends(get_binding_service), +) -> BindingListResponse: + statement = select(TokenBinding) + if token_suffix: + statement = statement.where(TokenBinding.token_display.ilike(f"%{token_suffix}%")) + if ip: + statement = statement.where(cast(TokenBinding.bound_ip, String).ilike(f"%{ip}%")) + if status_filter in {STATUS_ACTIVE, STATUS_BANNED}: + statement = statement.where(TokenBinding.status == status_filter) + + total_result = await session.execute(select(func.count()).select_from(statement.subquery())) + total = int(total_result.scalar_one()) + bindings = ( + await session.scalars( + statement.order_by(TokenBinding.last_used_at.desc()).offset((page - 1) * page_size).limit(page_size) + ) + ).all() + + return BindingListResponse( + items=[to_binding_item(item, binding_service) for item in bindings], + total=total, + page=page, + page_size=page_size, + ) + + +@router.post("/unbind") +async def unbind_token( + payload: BindingActionRequest, + request: Request, + settings: Settings = Depends(get_settings), + session: AsyncSession = Depends(get_db_session), + binding_service: BindingService = Depends(get_binding_service), +): + binding = await get_binding_or_404(session, payload.id) + token_hash = binding.token_hash + await session.delete(binding) + await session.commit() + await binding_service.invalidate_binding_cache(token_hash) + log_admin_action(request, settings, "unbind", payload.id) + return {"success": True} + + +@router.put("/ip") +async def update_bound_ip( + payload: BindingIPUpdateRequest, + request: Request, + settings: Settings = Depends(get_settings), + session: AsyncSession = Depends(get_db_session), + binding_service: BindingService = Depends(get_binding_service), +): + binding = await get_binding_or_404(session, payload.id) + binding.bound_ip = payload.bound_ip + await session.commit() + await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status) + log_admin_action(request, settings, "update_ip", payload.id) + return {"success": True} + + +@router.post("/ban") +async def ban_token( + payload: BindingActionRequest, + request: Request, + settings: Settings = Depends(get_settings), + session: AsyncSession = Depends(get_db_session), + binding_service: BindingService = Depends(get_binding_service), +): + binding = await get_binding_or_404(session, payload.id) + binding.status = STATUS_BANNED + await session.commit() + await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status) + log_admin_action(request, settings, "ban", payload.id) + return {"success": True} + + +@router.post("/unban") +async def unban_token( + payload: BindingActionRequest, + request: Request, + settings: Settings = Depends(get_settings), + session: AsyncSession = Depends(get_db_session), + binding_service: BindingService = Depends(get_binding_service), +): + binding = await get_binding_or_404(session, payload.id) + binding.status = STATUS_ACTIVE + await session.commit() + await binding_service.sync_binding_cache(binding.token_hash, str(binding.bound_ip), binding.status) + log_admin_action(request, settings, "unban", payload.id) + return {"success": True} diff --git a/app/api/dashboard.py b/app/api/dashboard.py new file mode 100644 index 0000000..13475c1 --- /dev/null +++ b/app/api/dashboard.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from datetime import UTC, datetime, time, timedelta + +from fastapi import APIRouter, Depends +from pydantic import BaseModel +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_binding_service, get_db_session, require_admin +from app.models.intercept_log import InterceptLog +from app.models.token_binding import STATUS_ACTIVE, STATUS_BANNED, TokenBinding +from app.schemas.log import InterceptLogItem +from app.services.binding_service import BindingService + +router = APIRouter(prefix="/admin/api", tags=["dashboard"], dependencies=[Depends(require_admin)]) + + +class MetricSummary(BaseModel): + total: int + allowed: int + intercepted: int + + +class BindingSummary(BaseModel): + active: int + banned: int + + +class TrendPoint(BaseModel): + date: str + total: int + allowed: int + intercepted: int + + +class DashboardResponse(BaseModel): + today: MetricSummary + bindings: BindingSummary + trend: list[TrendPoint] + recent_intercepts: list[InterceptLogItem] + + +async def build_trend( + session: AsyncSession, + binding_service: BindingService, +) -> list[TrendPoint]: + series = await binding_service.get_metrics_window(days=7) + start_day = datetime.combine(datetime.now(UTC).date() - timedelta(days=6), time.min, tzinfo=UTC) + + intercept_counts_result = await session.execute( + select(func.date(InterceptLog.intercepted_at), func.count()) + .where(InterceptLog.intercepted_at >= start_day) + .group_by(func.date(InterceptLog.intercepted_at)) + ) + db_intercept_counts = { + row[0].isoformat(): int(row[1]) + for row in intercept_counts_result.all() + } + + trend: list[TrendPoint] = [] + for item in series: + day = str(item["date"]) + allowed = int(item["allowed"]) + intercepted = max(int(item["intercepted"]), db_intercept_counts.get(day, 0)) + total = max(int(item["total"]), allowed + intercepted) + trend.append(TrendPoint(date=day, total=total, allowed=allowed, intercepted=intercepted)) + return trend + + +async def build_recent_intercepts(session: AsyncSession) -> list[InterceptLogItem]: + recent_logs = ( + await session.scalars(select(InterceptLog).order_by(InterceptLog.intercepted_at.desc()).limit(10)) + ).all() + return [ + InterceptLogItem( + id=item.id, + token_display=item.token_display, + bound_ip=str(item.bound_ip), + attempt_ip=str(item.attempt_ip), + alerted=item.alerted, + intercepted_at=item.intercepted_at, + ) + for item in recent_logs + ] + + +@router.get("/dashboard", response_model=DashboardResponse) +async def get_dashboard( + session: AsyncSession = Depends(get_db_session), + binding_service: BindingService = Depends(get_binding_service), +) -> DashboardResponse: + trend = await build_trend(session, binding_service) + + active_count = await session.scalar( + select(func.count()).select_from(TokenBinding).where(TokenBinding.status == STATUS_ACTIVE) + ) + banned_count = await session.scalar( + select(func.count()).select_from(TokenBinding).where(TokenBinding.status == STATUS_BANNED) + ) + recent_intercepts = await build_recent_intercepts(session) + + today = trend[-1] if trend else TrendPoint(date=datetime.now(UTC).date().isoformat(), total=0, allowed=0, intercepted=0) + return DashboardResponse( + today=MetricSummary(total=today.total, allowed=today.allowed, intercepted=today.intercepted), + bindings=BindingSummary(active=int(active_count or 0), banned=int(banned_count or 0)), + trend=trend, + recent_intercepts=recent_intercepts, + ) diff --git a/app/api/logs.py b/app/api/logs.py new file mode 100644 index 0000000..6797828 --- /dev/null +++ b/app/api/logs.py @@ -0,0 +1,107 @@ +from __future__ import annotations + +import csv +import io +from datetime import datetime + +from fastapi import APIRouter, Depends, Query +from fastapi.responses import StreamingResponse +from sqlalchemy import String, cast, func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.dependencies import get_db_session, require_admin +from app.models.intercept_log import InterceptLog +from app.schemas.log import InterceptLogItem, LogListResponse + +router = APIRouter(prefix="/admin/api/logs", tags=["logs"], dependencies=[Depends(require_admin)]) + + +def apply_log_filters( + statement, + token: str | None, + attempt_ip: str | None, + start_time: datetime | None, + end_time: datetime | None, +): + if token: + statement = statement.where(InterceptLog.token_display.ilike(f"%{token}%")) + if attempt_ip: + statement = statement.where(cast(InterceptLog.attempt_ip, String).ilike(f"%{attempt_ip}%")) + if start_time: + statement = statement.where(InterceptLog.intercepted_at >= start_time) + if end_time: + statement = statement.where(InterceptLog.intercepted_at <= end_time) + return statement + + +@router.get("", response_model=LogListResponse) +async def list_logs( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=200), + token: str | None = Query(default=None), + attempt_ip: str | None = Query(default=None), + start_time: datetime | None = Query(default=None), + end_time: datetime | None = Query(default=None), + session: AsyncSession = Depends(get_db_session), +) -> LogListResponse: + statement = apply_log_filters(select(InterceptLog), token, attempt_ip, start_time, end_time) + total_result = await session.execute(select(func.count()).select_from(statement.subquery())) + total = int(total_result.scalar_one()) + logs = ( + await session.scalars( + statement.order_by(InterceptLog.intercepted_at.desc()).offset((page - 1) * page_size).limit(page_size) + ) + ).all() + + return LogListResponse( + items=[ + InterceptLogItem( + id=item.id, + token_display=item.token_display, + bound_ip=str(item.bound_ip), + attempt_ip=str(item.attempt_ip), + alerted=item.alerted, + intercepted_at=item.intercepted_at, + ) + for item in logs + ], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/export") +async def export_logs( + token: str | None = Query(default=None), + attempt_ip: str | None = Query(default=None), + start_time: datetime | None = Query(default=None), + end_time: datetime | None = Query(default=None), + session: AsyncSession = Depends(get_db_session), +): + statement = apply_log_filters(select(InterceptLog), token, attempt_ip, start_time, end_time).order_by( + InterceptLog.intercepted_at.desc() + ) + logs = (await session.scalars(statement)).all() + + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow(["id", "token_display", "bound_ip", "attempt_ip", "alerted", "intercepted_at"]) + for item in logs: + writer.writerow( + [ + item.id, + item.token_display, + str(item.bound_ip), + str(item.attempt_ip), + item.alerted, + item.intercepted_at.isoformat(), + ] + ) + + filename = f"sentinel-logs-{datetime.utcnow().strftime('%Y%m%d%H%M%S')}.csv" + return StreamingResponse( + iter([buffer.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) diff --git a/app/api/settings.py b/app/api/settings.py new file mode 100644 index 0000000..b04f7df --- /dev/null +++ b/app/api/settings.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import logging +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel, Field +from redis.asyncio import Redis + +from app.config import RUNTIME_SETTINGS_REDIS_KEY, RuntimeSettings, Settings +from app.core.ip_utils import extract_client_ip +from app.dependencies import get_redis, get_runtime_settings, get_settings, require_admin + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/api/settings", tags=["settings"], dependencies=[Depends(require_admin)]) + + +class SettingsResponse(BaseModel): + alert_webhook_url: str | None = None + alert_threshold_count: int = Field(ge=1) + alert_threshold_seconds: int = Field(ge=1) + archive_days: int = Field(ge=1) + failsafe_mode: Literal["open", "closed"] + + +class SettingsUpdateRequest(SettingsResponse): + pass + + +def serialize_runtime_settings(runtime_settings: RuntimeSettings) -> dict[str, str]: + return { + "alert_webhook_url": runtime_settings.alert_webhook_url or "", + "alert_threshold_count": str(runtime_settings.alert_threshold_count), + "alert_threshold_seconds": str(runtime_settings.alert_threshold_seconds), + "archive_days": str(runtime_settings.archive_days), + "failsafe_mode": runtime_settings.failsafe_mode, + } + + +@router.get("", response_model=SettingsResponse) +async def get_runtime_config( + runtime_settings: RuntimeSettings = Depends(get_runtime_settings), +) -> SettingsResponse: + return SettingsResponse(**runtime_settings.model_dump()) + + +@router.put("", response_model=SettingsResponse) +async def update_runtime_config( + payload: SettingsUpdateRequest, + request: Request, + settings: Settings = Depends(get_settings), + redis: Redis | None = Depends(get_redis), +): + if redis is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Settings persistence is unavailable because Redis is offline.", + ) + + updated = RuntimeSettings(**payload.model_dump()) + try: + await redis.hset(RUNTIME_SETTINGS_REDIS_KEY, mapping=serialize_runtime_settings(updated)) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Failed to persist runtime settings.", + ) from exc + + async with request.app.state.runtime_settings_lock: + request.app.state.runtime_settings = updated + + logger.info( + "Runtime settings updated.", + extra={"client_ip": extract_client_ip(request, settings)}, + ) + return SettingsResponse(**updated.model_dump()) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..5533c54 --- /dev/null +++ b/app/config.py @@ -0,0 +1,98 @@ +from __future__ import annotations + +from functools import cached_property +from ipaddress import ip_network +from typing import Literal + +from pydantic import BaseModel, Field, field_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + +RUNTIME_SETTINGS_REDIS_KEY = "sentinel:settings" + + +class RuntimeSettings(BaseModel): + alert_webhook_url: str | None = None + alert_threshold_count: int = Field(default=5, ge=1) + alert_threshold_seconds: int = Field(default=300, ge=1) + archive_days: int = Field(default=90, ge=1) + failsafe_mode: Literal["open", "closed"] = "closed" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict( + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + case_sensitive=False, + ) + + downstream_url: str = Field(alias="DOWNSTREAM_URL") + redis_addr: str = Field(alias="REDIS_ADDR") + redis_password: str = Field(default="", alias="REDIS_PASSWORD") + pg_dsn: str = Field(alias="PG_DSN") + sentinel_hmac_secret: str = Field(alias="SENTINEL_HMAC_SECRET", min_length=32) + admin_password: str = Field(alias="ADMIN_PASSWORD", min_length=8) + admin_jwt_secret: str = Field(alias="ADMIN_JWT_SECRET", min_length=16) + trusted_proxy_ips: tuple[str, ...] = Field(default_factory=tuple, alias="TRUSTED_PROXY_IPS") + sentinel_failsafe_mode: Literal["open", "closed"] = Field( + default="closed", + alias="SENTINEL_FAILSAFE_MODE", + ) + app_port: int = Field(default=7000, alias="APP_PORT") + alert_webhook_url: str | None = Field(default=None, alias="ALERT_WEBHOOK_URL") + alert_threshold_count: int = Field(default=5, alias="ALERT_THRESHOLD_COUNT", ge=1) + alert_threshold_seconds: int = Field(default=300, alias="ALERT_THRESHOLD_SECONDS", ge=1) + archive_days: int = Field(default=90, alias="ARCHIVE_DAYS", ge=1) + + redis_binding_ttl_seconds: int = 604800 + downstream_max_connections: int = 512 + downstream_max_keepalive_connections: int = 128 + last_used_flush_interval_seconds: int = 5 + last_used_queue_size: int = 10000 + login_lockout_threshold: int = 5 + login_lockout_seconds: int = 900 + admin_jwt_expire_hours: int = 8 + archive_job_interval_minutes: int = 60 + archive_batch_size: int = 500 + metrics_ttl_days: int = 30 + webhook_timeout_seconds: int = 5 + + @field_validator("downstream_url") + @classmethod + def normalize_downstream_url(cls, value: str) -> str: + return value.rstrip("/") + + @field_validator("trusted_proxy_ips", mode="before") + @classmethod + def split_proxy_ips(cls, value: object) -> tuple[str, ...]: + if value is None: + return tuple() + if isinstance(value, str): + parts = [item.strip() for item in value.split(",")] + return tuple(item for item in parts if item) + if isinstance(value, (list, tuple, set)): + return tuple(str(item).strip() for item in value if str(item).strip()) + return (str(value).strip(),) + + @cached_property + def trusted_proxy_networks(self): + return tuple(ip_network(item, strict=False) for item in self.trusted_proxy_ips) + + def build_runtime_settings(self) -> RuntimeSettings: + return RuntimeSettings( + alert_webhook_url=self.alert_webhook_url or None, + alert_threshold_count=self.alert_threshold_count, + alert_threshold_seconds=self.alert_threshold_seconds, + archive_days=self.archive_days, + failsafe_mode=self.sentinel_failsafe_mode, + ) + + +_settings: Settings | None = None + + +def get_settings() -> Settings: + global _settings + if _settings is None: + _settings = Settings() + return _settings diff --git a/app/core/ip_utils.py b/app/core/ip_utils.py new file mode 100644 index 0000000..9f417d4 --- /dev/null +++ b/app/core/ip_utils.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from ipaddress import ip_address, ip_network + +from fastapi import Request + +from app.config import Settings + + +def is_ip_in_network(candidate_ip: str, network_value: str) -> bool: + return ip_address(candidate_ip) in ip_network(network_value, strict=False) + + +def is_trusted_proxy(source_ip: str, settings: Settings) -> bool: + try: + parsed_ip = ip_address(source_ip) + except ValueError: + return False + return any(parsed_ip in network for network in settings.trusted_proxy_networks) + + +def extract_client_ip(request: Request, settings: Settings) -> str: + client_host = request.client.host if request.client else "127.0.0.1" + if not is_trusted_proxy(client_host, settings): + return client_host + + real_ip = request.headers.get("x-real-ip") + if not real_ip: + return client_host + + try: + ip_address(real_ip) + except ValueError: + return client_host + return real_ip diff --git a/app/core/security.py b/app/core/security.py new file mode 100644 index 0000000..279a59b --- /dev/null +++ b/app/core/security.py @@ -0,0 +1,104 @@ +from __future__ import annotations + +import hashlib +import hmac +from datetime import UTC, datetime, timedelta + +from fastapi import HTTPException, status +from jose import JWTError, jwt +from redis.asyncio import Redis + +from app.config import Settings + +ALGORITHM = "HS256" + + +def mask_token(token: str) -> str: + if not token: + return "unknown" + if len(token) <= 8: + return f"{token[:2]}...{token[-2:]}" + return f"{token[:4]}...{token[-4:]}"[:20] + + +def hash_token(token: str, secret: str) -> str: + return hmac.new(secret.encode("utf-8"), token.encode("utf-8"), hashlib.sha256).hexdigest() + + +def extract_bearer_token(authorization: str | None) -> str | None: + if not authorization: + return None + scheme, _, token = authorization.partition(" ") + if scheme.lower() != "bearer" or not token: + return None + return token.strip() + + +def verify_admin_password(password: str, settings: Settings) -> bool: + return hmac.compare_digest(password, settings.admin_password) + + +def create_admin_jwt(settings: Settings) -> tuple[str, int]: + expires_in = settings.admin_jwt_expire_hours * 3600 + now = datetime.now(UTC) + payload = { + "sub": "admin", + "iat": int(now.timestamp()), + "exp": int((now + timedelta(seconds=expires_in)).timestamp()), + } + token = jwt.encode(payload, settings.admin_jwt_secret, algorithm=ALGORITHM) + return token, expires_in + + +def decode_admin_jwt(token: str, settings: Settings) -> dict: + try: + payload = jwt.decode(token, settings.admin_jwt_secret, algorithms=[ALGORITHM]) + except JWTError as exc: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired admin token.", + ) from exc + + if payload.get("sub") != "admin": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid admin token subject.", + ) + return payload + + +def login_failure_key(client_ip: str) -> str: + return f"sentinel:login:fail:{client_ip}" + + +async def ensure_login_allowed(redis: Redis, client_ip: str, settings: Settings) -> None: + try: + current = await redis.get(login_failure_key(client_ip)) + if current is None: + return + if int(current) >= settings.login_lockout_threshold: + ttl = await redis.ttl(login_failure_key(client_ip)) + retry_after = max(ttl, 0) + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail=f"Too many failed login attempts. Retry after {retry_after} seconds.", + ) + except HTTPException: + raise + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Login lock service is unavailable.", + ) from exc + + +async def register_login_failure(redis: Redis, client_ip: str, settings: Settings) -> None: + key = login_failure_key(client_ip) + async with redis.pipeline(transaction=True) as pipeline: + pipeline.incr(key) + pipeline.expire(key, settings.login_lockout_seconds) + await pipeline.execute() + + +async def clear_login_failures(redis: Redis, client_ip: str) -> None: + await redis.delete(login_failure_key(client_ip)) diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..7c0dc79 --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from collections.abc import AsyncIterator + +from fastapi import Depends, HTTPException, Request, status +from redis.asyncio import Redis +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import RuntimeSettings, Settings +from app.core.security import decode_admin_jwt, extract_bearer_token +from app.services.alert_service import AlertService +from app.services.archive_service import ArchiveService +from app.services.binding_service import BindingService + + +def get_settings(request: Request) -> Settings: + return request.app.state.settings + + +def get_redis(request: Request) -> Redis | None: + return request.app.state.redis + + +async def get_db_session(request: Request) -> AsyncIterator[AsyncSession]: + session_factory = request.app.state.session_factory + async with session_factory() as session: + yield session + + +def get_binding_service(request: Request) -> BindingService: + return request.app.state.binding_service + + +def get_alert_service(request: Request) -> AlertService: + return request.app.state.alert_service + + +def get_archive_service(request: Request) -> ArchiveService: + return request.app.state.archive_service + + +def get_runtime_settings(request: Request) -> RuntimeSettings: + return request.app.state.runtime_settings + + +async def require_admin(request: Request, settings: Settings = Depends(get_settings)) -> dict: + token = extract_bearer_token(request.headers.get("authorization")) + if token is None: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Missing admin bearer token.", + ) + return decode_admin_jwt(token, settings) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..2cf9e9d --- /dev/null +++ b/app/main.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +import asyncio +import json +import logging +from contextlib import asynccontextmanager +from datetime import UTC, datetime + +import httpx +from fastapi import FastAPI +from redis.asyncio import Redis +from redis.asyncio import from_url as redis_from_url + +from app.api import auth, bindings, dashboard, logs, settings as settings_api +from app.config import RUNTIME_SETTINGS_REDIS_KEY, RuntimeSettings, Settings, get_settings +from app.models import intercept_log, token_binding # noqa: F401 +from app.models.db import close_db, get_session_factory, init_db +from app.proxy.handler import router as proxy_router +from app.services.alert_service import AlertService +from app.services.archive_service import ArchiveService +from app.services.binding_service import BindingService + + +class JsonFormatter(logging.Formatter): + reserved = { + "args", + "asctime", + "created", + "exc_info", + "exc_text", + "filename", + "funcName", + "levelname", + "levelno", + "lineno", + "module", + "msecs", + "message", + "msg", + "name", + "pathname", + "process", + "processName", + "relativeCreated", + "stack_info", + "thread", + "threadName", + } + + def format(self, record: logging.LogRecord) -> str: + payload = { + "timestamp": datetime.now(UTC).isoformat(), + "level": record.levelname, + "logger": record.name, + "message": record.getMessage(), + } + for key, value in record.__dict__.items(): + if key in self.reserved or key.startswith("_"): + continue + payload[key] = value + if record.exc_info: + payload["exception"] = self.formatException(record.exc_info) + return json.dumps(payload, default=str) + + +def configure_logging() -> None: + root_logger = logging.getLogger() + handler = logging.StreamHandler() + handler.setFormatter(JsonFormatter()) + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(logging.INFO) + + +configure_logging() +logger = logging.getLogger(__name__) + + +async def load_runtime_settings(redis: Redis | None, settings: Settings) -> RuntimeSettings: + runtime_settings = settings.build_runtime_settings() + if redis is None: + return runtime_settings + try: + raw = await redis.hgetall(RUNTIME_SETTINGS_REDIS_KEY) + except Exception: + logger.warning("Failed to load runtime settings from Redis; using environment defaults.") + return runtime_settings + if not raw: + return runtime_settings + return RuntimeSettings( + alert_webhook_url=raw.get("alert_webhook_url") or None, + alert_threshold_count=int(raw.get("alert_threshold_count", runtime_settings.alert_threshold_count)), + alert_threshold_seconds=int(raw.get("alert_threshold_seconds", runtime_settings.alert_threshold_seconds)), + archive_days=int(raw.get("archive_days", runtime_settings.archive_days)), + failsafe_mode=raw.get("failsafe_mode", runtime_settings.failsafe_mode), + ) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + settings = get_settings() + init_db(settings) + session_factory = get_session_factory() + + redis: Redis | None = redis_from_url( + settings.redis_addr, + password=settings.redis_password or None, + encoding="utf-8", + decode_responses=True, + ) + try: + await redis.ping() + except Exception: + logger.warning("Redis is unavailable at startup; continuing in degraded mode.") + try: + await redis.aclose() + except Exception: + pass + redis = None + + downstream_client = httpx.AsyncClient( + timeout=httpx.Timeout(connect=10.0, read=600.0, write=600.0, pool=10.0), + limits=httpx.Limits( + max_connections=settings.downstream_max_connections, + max_keepalive_connections=settings.downstream_max_keepalive_connections, + ), + follow_redirects=False, + ) + webhook_client = httpx.AsyncClient(timeout=httpx.Timeout(settings.webhook_timeout_seconds)) + + runtime_settings = await load_runtime_settings(redis, settings) + app.state.settings = settings + app.state.redis = redis + app.state.session_factory = session_factory + app.state.downstream_client = downstream_client + app.state.webhook_client = webhook_client + app.state.runtime_settings = runtime_settings + app.state.runtime_settings_lock = asyncio.Lock() + + binding_service = BindingService( + settings=settings, + session_factory=session_factory, + redis=redis, + runtime_settings_getter=lambda: app.state.runtime_settings, + ) + alert_service = AlertService( + settings=settings, + session_factory=session_factory, + redis=redis, + http_client=webhook_client, + runtime_settings_getter=lambda: app.state.runtime_settings, + ) + archive_service = ArchiveService( + settings=settings, + session_factory=session_factory, + binding_service=binding_service, + runtime_settings_getter=lambda: app.state.runtime_settings, + ) + app.state.binding_service = binding_service + app.state.alert_service = alert_service + app.state.archive_service = archive_service + + await binding_service.start() + await archive_service.start() + logger.info("Application started.") + try: + yield + finally: + await archive_service.stop() + await binding_service.stop() + await downstream_client.aclose() + await webhook_client.aclose() + if redis is not None: + await redis.aclose() + await close_db() + logger.info("Application stopped.") + + +app = FastAPI(title="Key-IP Sentinel", lifespan=lifespan) + +app.include_router(auth.router) +app.include_router(dashboard.router) +app.include_router(bindings.router) +app.include_router(logs.router) +app.include_router(settings_api.router) + + +@app.get("/health") +async def health() -> dict[str, str]: + return {"status": "ok"} + + +app.include_router(proxy_router) diff --git a/app/models/db.py b/app/models/db.py new file mode 100644 index 0000000..0109be3 --- /dev/null +++ b/app/models/db.py @@ -0,0 +1,48 @@ +from __future__ import annotations + +from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker, create_async_engine +from sqlalchemy.orm import DeclarativeBase + +from app.config import Settings + + +class Base(DeclarativeBase): + pass + + +_engine: AsyncEngine | None = None +_session_factory: async_sessionmaker[AsyncSession] | None = None + + +def init_db(settings: Settings) -> None: + global _engine, _session_factory + if _engine is not None and _session_factory is not None: + return + + _engine = create_async_engine( + settings.pg_dsn, + pool_pre_ping=True, + pool_size=20, + max_overflow=40, + ) + _session_factory = async_sessionmaker(_engine, expire_on_commit=False) + + +def get_engine() -> AsyncEngine: + if _engine is None: + raise RuntimeError("Database engine has not been initialized.") + return _engine + + +def get_session_factory() -> async_sessionmaker[AsyncSession]: + if _session_factory is None: + raise RuntimeError("Database session factory has not been initialized.") + return _session_factory + + +async def close_db() -> None: + global _engine, _session_factory + if _engine is not None: + await _engine.dispose() + _engine = None + _session_factory = None diff --git a/app/models/intercept_log.py b/app/models/intercept_log.py new file mode 100644 index 0000000..934618a --- /dev/null +++ b/app/models/intercept_log.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, Index, String, func, text +from sqlalchemy.dialects.postgresql import CIDR, INET +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.db import Base + + +class InterceptLog(Base): + __tablename__ = "intercept_logs" + __table_args__ = ( + Index("idx_intercept_logs_hash", "token_hash"), + Index("idx_intercept_logs_time", text("intercepted_at DESC")), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + token_hash: Mapped[str] = mapped_column(String(64), nullable=False) + token_display: Mapped[str] = mapped_column(String(20), nullable=False) + bound_ip: Mapped[str] = mapped_column(CIDR, nullable=False) + attempt_ip: Mapped[str] = mapped_column(INET, nullable=False) + alerted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("FALSE")) + intercepted_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/app/models/token_binding.py b/app/models/token_binding.py new file mode 100644 index 0000000..1af36f0 --- /dev/null +++ b/app/models/token_binding.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import DateTime, Index, SmallInteger, String, func, text +from sqlalchemy.dialects.postgresql import CIDR +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.db import Base + +STATUS_ACTIVE = 1 +STATUS_BANNED = 2 + + +class TokenBinding(Base): + __tablename__ = "token_bindings" + __table_args__ = ( + Index("idx_token_bindings_hash", "token_hash"), + Index("idx_token_bindings_ip", "bound_ip", postgresql_using="gist", postgresql_ops={"bound_ip": "inet_ops"}), + ) + + id: Mapped[int] = mapped_column(primary_key=True, autoincrement=True) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, nullable=False) + token_display: Mapped[str] = mapped_column(String(20), nullable=False) + bound_ip: Mapped[str] = mapped_column(CIDR, nullable=False) + status: Mapped[int] = mapped_column( + SmallInteger, + nullable=False, + default=STATUS_ACTIVE, + server_default=text("1"), + ) + first_used_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + last_used_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + server_default=func.now(), + ) diff --git a/app/proxy/handler.py b/app/proxy/handler.py new file mode 100644 index 0000000..e929855 --- /dev/null +++ b/app/proxy/handler.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import logging +from urllib.parse import urlsplit + +import httpx +from fastapi import APIRouter, Depends, Request +from fastapi.responses import JSONResponse, StreamingResponse + +from app.config import Settings +from app.core.ip_utils import extract_client_ip +from app.core.security import extract_bearer_token +from app.dependencies import get_alert_service, get_binding_service, get_settings +from app.services.alert_service import AlertService +from app.services.binding_service import BindingService + +logger = logging.getLogger(__name__) + +router = APIRouter() +CONTENT_LENGTH_HEADER = "content-length" + + +def build_upstream_headers(request: Request, downstream_url: str) -> list[tuple[str, str]]: + downstream_host = urlsplit(downstream_url).netloc + headers: list[tuple[str, str]] = [] + for header_name, header_value in request.headers.items(): + if header_name.lower() == "host": + continue + headers.append((header_name, header_value)) + headers.append(("host", downstream_host)) + return headers + + +def build_upstream_url(settings: Settings, request: Request) -> str: + return f"{settings.downstream_url}{request.url.path}" + + +def apply_downstream_headers(response: StreamingResponse, upstream_response: httpx.Response) -> None: + for header_name, header_value in upstream_response.headers.multi_items(): + if header_name.lower() == CONTENT_LENGTH_HEADER: + continue + response.headers.append(header_name, header_value) + + +@router.api_route("/", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], include_in_schema=False) +@router.api_route( + "/{path:path}", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], + include_in_schema=False, +) +async def reverse_proxy( + request: Request, + path: str = "", + settings: Settings = Depends(get_settings), + binding_service: BindingService = Depends(get_binding_service), + alert_service: AlertService = Depends(get_alert_service), +): + client_ip = extract_client_ip(request, settings) + token = extract_bearer_token(request.headers.get("authorization")) + + if token: + binding_result = await binding_service.evaluate_token_binding(token, client_ip) + if binding_result.allowed: + await binding_service.increment_request_metric("allowed") + else: + await binding_service.increment_request_metric("intercepted" if binding_result.should_alert else None) + if binding_result.should_alert and binding_result.token_hash and binding_result.token_display and binding_result.bound_ip: + await alert_service.handle_intercept( + token_hash=binding_result.token_hash, + token_display=binding_result.token_display, + bound_ip=binding_result.bound_ip, + attempt_ip=client_ip, + ) + return JSONResponse( + status_code=binding_result.status_code, + content={"detail": binding_result.detail}, + ) + else: + await binding_service.increment_request_metric("allowed") + + downstream_client: httpx.AsyncClient = request.app.state.downstream_client + upstream_url = build_upstream_url(settings, request) + upstream_headers = build_upstream_headers(request, settings.downstream_url) + + try: + upstream_request = downstream_client.build_request( + request.method, + upstream_url, + params=request.query_params.multi_items(), + headers=upstream_headers, + content=request.stream(), + ) + upstream_response = await downstream_client.send(upstream_request, stream=True) + except httpx.HTTPError as exc: + logger.exception("Failed to reach downstream service.") + return JSONResponse(status_code=502, content={"detail": f"Downstream request failed: {exc!s}"}) + + async def stream_response(): + try: + async for chunk in upstream_response.aiter_raw(): + yield chunk + finally: + await upstream_response.aclose() + + response = StreamingResponse( + stream_response(), + status_code=upstream_response.status_code, + media_type=None, + ) + apply_downstream_headers(response, upstream_response) + return response diff --git a/app/schemas/auth.py b/app/schemas/auth.py new file mode 100644 index 0000000..9057467 --- /dev/null +++ b/app/schemas/auth.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class LoginRequest(BaseModel): + password: str = Field(min_length=1, max_length=256) + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" + expires_in: int diff --git a/app/schemas/binding.py b/app/schemas/binding.py new file mode 100644 index 0000000..30a3204 --- /dev/null +++ b/app/schemas/binding.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class BindingItem(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + token_display: str + bound_ip: str + status: int + status_label: str + first_used_at: datetime + last_used_at: datetime + created_at: datetime + + +class BindingListResponse(BaseModel): + items: list[BindingItem] + total: int + page: int + page_size: int + + +class BindingActionRequest(BaseModel): + id: int = Field(gt=0) + + +class BindingIPUpdateRequest(BaseModel): + id: int = Field(gt=0) + bound_ip: str = Field(min_length=3, max_length=64) + + @field_validator("bound_ip") + @classmethod + def validate_bound_ip(cls, value: str) -> str: + from ipaddress import ip_network + + ip_network(value, strict=False) + return value diff --git a/app/schemas/log.py b/app/schemas/log.py new file mode 100644 index 0000000..64aba2c --- /dev/null +++ b/app/schemas/log.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + + +class InterceptLogItem(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + token_display: str + bound_ip: str + attempt_ip: str + alerted: bool + intercepted_at: datetime + + +class LogListResponse(BaseModel): + items: list[InterceptLogItem] + total: int + page: int + page_size: int diff --git a/app/services/alert_service.py b/app/services/alert_service.py new file mode 100644 index 0000000..5d809b2 --- /dev/null +++ b/app/services/alert_service.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import logging +from typing import Callable + +import httpx +from redis.asyncio import Redis +from sqlalchemy import text +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.config import RuntimeSettings, Settings +from app.models.intercept_log import InterceptLog + +logger = logging.getLogger(__name__) + + +class AlertService: + def __init__( + self, + settings: Settings, + session_factory: async_sessionmaker[AsyncSession], + redis: Redis | None, + http_client: httpx.AsyncClient, + runtime_settings_getter: Callable[[], RuntimeSettings], + ) -> None: + self.settings = settings + self.session_factory = session_factory + self.redis = redis + self.http_client = http_client + self.runtime_settings_getter = runtime_settings_getter + + def alert_key(self, token_hash: str) -> str: + return f"sentinel:alert:{token_hash}" + + async def handle_intercept( + self, + token_hash: str, + token_display: str, + bound_ip: str, + attempt_ip: str, + ) -> None: + await self._write_intercept_log(token_hash, token_display, bound_ip, attempt_ip) + + runtime_settings = self.runtime_settings_getter() + if self.redis is None: + logger.warning("Redis is unavailable. Intercept alert counters are disabled.") + return + + try: + async with self.redis.pipeline(transaction=True) as pipeline: + pipeline.incr(self.alert_key(token_hash)) + pipeline.expire(self.alert_key(token_hash), runtime_settings.alert_threshold_seconds) + result = await pipeline.execute() + except Exception: + logger.warning("Failed to update intercept alert counter.", extra={"token_hash": token_hash}) + return + + count = int(result[0]) + if count < runtime_settings.alert_threshold_count: + return + + payload = { + "token_display": token_display, + "attempt_ip": attempt_ip, + "bound_ip": bound_ip, + "count": count, + } + if runtime_settings.alert_webhook_url: + try: + await self.http_client.post(runtime_settings.alert_webhook_url, json=payload) + except httpx.HTTPError: + logger.exception("Failed to deliver alert webhook.", extra={"token_hash": token_hash}) + + try: + await self.redis.delete(self.alert_key(token_hash)) + except Exception: + logger.warning("Failed to clear intercept alert counter.", extra={"token_hash": token_hash}) + + await self._mark_alerted_records(token_hash, runtime_settings.alert_threshold_seconds) + + async def _write_intercept_log( + self, + token_hash: str, + token_display: str, + bound_ip: str, + attempt_ip: str, + ) -> None: + async with self.session_factory() as session: + try: + session.add( + InterceptLog( + token_hash=token_hash, + token_display=token_display, + bound_ip=bound_ip, + attempt_ip=attempt_ip, + alerted=False, + ) + ) + await session.commit() + except SQLAlchemyError: + await session.rollback() + logger.exception("Failed to write intercept log.", extra={"token_hash": token_hash}) + + async def _mark_alerted_records(self, token_hash: str, threshold_seconds: int) -> None: + statement = text( + """ + UPDATE intercept_logs + SET alerted = TRUE + WHERE token_hash = :token_hash + AND intercepted_at >= NOW() - (:threshold_seconds || ' seconds')::interval + """ + ) + async with self.session_factory() as session: + try: + await session.execute( + statement, + {"token_hash": token_hash, "threshold_seconds": threshold_seconds}, + ) + await session.commit() + except SQLAlchemyError: + await session.rollback() + logger.exception("Failed to mark intercept logs as alerted.", extra={"token_hash": token_hash}) diff --git a/app/services/archive_service.py b/app/services/archive_service.py new file mode 100644 index 0000000..3f37d32 --- /dev/null +++ b/app/services/archive_service.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import logging +from datetime import UTC, datetime, timedelta +from typing import Callable + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from sqlalchemy import delete, select +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.config import RuntimeSettings, Settings +from app.models.token_binding import TokenBinding +from app.services.binding_service import BindingService + +logger = logging.getLogger(__name__) + + +class ArchiveService: + def __init__( + self, + settings: Settings, + session_factory: async_sessionmaker[AsyncSession], + binding_service: BindingService, + runtime_settings_getter: Callable[[], RuntimeSettings], + ) -> None: + self.settings = settings + self.session_factory = session_factory + self.binding_service = binding_service + self.runtime_settings_getter = runtime_settings_getter + self.scheduler = AsyncIOScheduler(timezone="UTC") + + async def start(self) -> None: + if self.scheduler.running: + return + self.scheduler.add_job( + self.archive_inactive_bindings, + trigger="interval", + minutes=self.settings.archive_job_interval_minutes, + id="archive-inactive-bindings", + replace_existing=True, + max_instances=1, + coalesce=True, + ) + self.scheduler.start() + + async def stop(self) -> None: + if self.scheduler.running: + self.scheduler.shutdown(wait=False) + + async def archive_inactive_bindings(self) -> int: + runtime_settings = self.runtime_settings_getter() + cutoff = datetime.now(UTC) - timedelta(days=runtime_settings.archive_days) + total_archived = 0 + + while True: + async with self.session_factory() as session: + try: + result = await session.execute( + select(TokenBinding.token_hash) + .where(TokenBinding.last_used_at < cutoff) + .order_by(TokenBinding.last_used_at.asc()) + .limit(self.settings.archive_batch_size) + ) + token_hashes = list(result.scalars()) + if not token_hashes: + break + + await session.execute(delete(TokenBinding).where(TokenBinding.token_hash.in_(token_hashes))) + await session.commit() + except SQLAlchemyError: + await session.rollback() + logger.exception("Failed to archive inactive bindings.") + break + + await self.binding_service.invalidate_many(token_hashes) + total_archived += len(token_hashes) + + if len(token_hashes) < self.settings.archive_batch_size: + break + + if total_archived: + logger.info("Archived inactive bindings.", extra={"count": total_archived}) + return total_archived diff --git a/app/services/binding_service.py b/app/services/binding_service.py new file mode 100644 index 0000000..96c1386 --- /dev/null +++ b/app/services/binding_service.py @@ -0,0 +1,464 @@ +from __future__ import annotations + +import asyncio +import json +import logging +import time +from dataclasses import dataclass +from datetime import UTC, date, timedelta +from typing import Callable + +from redis.asyncio import Redis +from sqlalchemy import func, select, text, update +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from app.config import RuntimeSettings, Settings +from app.core.ip_utils import is_ip_in_network +from app.core.security import hash_token, mask_token +from app.models.token_binding import STATUS_ACTIVE, STATUS_BANNED, TokenBinding + +logger = logging.getLogger(__name__) + + +@dataclass(slots=True) +class BindingRecord: + id: int + token_hash: str + token_display: str + bound_ip: str + status: int + ip_matched: bool + + +@dataclass(slots=True) +class BindingCheckResult: + allowed: bool + status_code: int + detail: str + token_hash: str | None = None + token_display: str | None = None + bound_ip: str | None = None + should_alert: bool = False + newly_bound: bool = False + + +class InMemoryRateLimiter: + def __init__(self, max_per_second: int) -> None: + self.max_per_second = max(1, max_per_second) + self._window = int(time.monotonic()) + self._count = 0 + self._lock = asyncio.Lock() + + async def allow(self) -> bool: + async with self._lock: + current_window = int(time.monotonic()) + if current_window != self._window: + self._window = current_window + self._count = 0 + if self._count >= self.max_per_second: + return False + self._count += 1 + return True + + +class BindingService: + def __init__( + self, + settings: Settings, + session_factory: async_sessionmaker[AsyncSession], + redis: Redis | None, + runtime_settings_getter: Callable[[], RuntimeSettings], + ) -> None: + self.settings = settings + self.session_factory = session_factory + self.redis = redis + self.runtime_settings_getter = runtime_settings_getter + self.last_used_queue: asyncio.Queue[str] = asyncio.Queue(maxsize=settings.last_used_queue_size) + self._flush_task: asyncio.Task[None] | None = None + self._stop_event = asyncio.Event() + self._redis_degraded_limiter = InMemoryRateLimiter(settings.downstream_max_connections // 2) + + async def start(self) -> None: + if self._flush_task is None: + self._stop_event.clear() + self._flush_task = asyncio.create_task(self._flush_loop(), name="binding-last-used-flush") + + async def stop(self) -> None: + self._stop_event.set() + if self._flush_task is not None: + self._flush_task.cancel() + try: + await self._flush_task + except asyncio.CancelledError: + pass + self._flush_task = None + await self.flush_last_used_updates() + + def status_label(self, status_code: int) -> str: + return "Active" if status_code == STATUS_ACTIVE else "Banned" + + def cache_key(self, token_hash: str) -> str: + return f"sentinel:binding:{token_hash}" + + def metrics_key(self, target_date: date) -> str: + return f"sentinel:metrics:{target_date.isoformat()}" + + async def evaluate_token_binding(self, token: str, client_ip: str) -> BindingCheckResult: + token_hash = hash_token(token, self.settings.sentinel_hmac_secret) + token_display = mask_token(token) + + cache_hit, cache_available = await self._load_binding_from_cache(token_hash) + if cache_hit is not None: + if cache_hit.status == STATUS_BANNED: + return BindingCheckResult( + allowed=False, + status_code=403, + detail="Token is banned.", + token_hash=token_hash, + token_display=token_display, + bound_ip=cache_hit.bound_ip, + should_alert=True, + ) + if is_ip_in_network(client_ip, cache_hit.bound_ip): + await self._touch_cache(token_hash) + self.record_last_used(token_hash) + return BindingCheckResult( + allowed=True, + status_code=200, + detail="Allowed from cache.", + token_hash=token_hash, + token_display=token_display, + bound_ip=cache_hit.bound_ip, + ) + return BindingCheckResult( + allowed=False, + status_code=403, + detail="Client IP does not match the bound CIDR.", + token_hash=token_hash, + token_display=token_display, + bound_ip=cache_hit.bound_ip, + should_alert=True, + ) + + if not cache_available: + logger.warning("Redis is unavailable. Falling back to PostgreSQL for token binding.") + if not await self._redis_degraded_limiter.allow(): + logger.warning("Redis degraded limiter rejected a request during PostgreSQL fallback.") + return BindingCheckResult( + allowed=False, + status_code=429, + detail="Redis degraded mode rate limit reached.", + token_hash=token_hash, + token_display=token_display, + ) + + try: + record = await self._load_binding_from_db(token_hash, client_ip) + except SQLAlchemyError: + return self._handle_backend_failure(token_hash, token_display) + + if record is not None: + await self.sync_binding_cache(record.token_hash, record.bound_ip, record.status) + if record.status == STATUS_BANNED: + return BindingCheckResult( + allowed=False, + status_code=403, + detail="Token is banned.", + token_hash=token_hash, + token_display=token_display, + bound_ip=record.bound_ip, + should_alert=True, + ) + if record.ip_matched: + self.record_last_used(token_hash) + return BindingCheckResult( + allowed=True, + status_code=200, + detail="Allowed from PostgreSQL.", + token_hash=token_hash, + token_display=token_display, + bound_ip=record.bound_ip, + ) + return BindingCheckResult( + allowed=False, + status_code=403, + detail="Client IP does not match the bound CIDR.", + token_hash=token_hash, + token_display=token_display, + bound_ip=record.bound_ip, + should_alert=True, + ) + + try: + created = await self._create_binding(token_hash, token_display, client_ip) + except SQLAlchemyError: + return self._handle_backend_failure(token_hash, token_display) + + if created is None: + try: + existing = await self._load_binding_from_db(token_hash, client_ip) + except SQLAlchemyError: + return self._handle_backend_failure(token_hash, token_display) + if existing is None: + return self._handle_backend_failure(token_hash, token_display) + await self.sync_binding_cache(existing.token_hash, existing.bound_ip, existing.status) + if existing.status == STATUS_BANNED: + return BindingCheckResult( + allowed=False, + status_code=403, + detail="Token is banned.", + token_hash=token_hash, + token_display=token_display, + bound_ip=existing.bound_ip, + should_alert=True, + ) + if existing.ip_matched: + self.record_last_used(token_hash) + return BindingCheckResult( + allowed=True, + status_code=200, + detail="Allowed after concurrent bind resolution.", + token_hash=token_hash, + token_display=token_display, + bound_ip=existing.bound_ip, + ) + return BindingCheckResult( + allowed=False, + status_code=403, + detail="Client IP does not match the bound CIDR.", + token_hash=token_hash, + token_display=token_display, + bound_ip=existing.bound_ip, + should_alert=True, + ) + + await self.sync_binding_cache(created.token_hash, created.bound_ip, created.status) + return BindingCheckResult( + allowed=True, + status_code=200, + detail="First-use bind created.", + token_hash=token_hash, + token_display=token_display, + bound_ip=created.bound_ip, + newly_bound=True, + ) + + async def sync_binding_cache(self, token_hash: str, bound_ip: str, status_code: int) -> None: + if self.redis is None: + return + payload = json.dumps({"bound_ip": bound_ip, "status": status_code}) + try: + await self.redis.set(self.cache_key(token_hash), payload, ex=self.settings.redis_binding_ttl_seconds) + except Exception: + logger.warning("Failed to write binding cache.", extra={"token_hash": token_hash}) + + async def invalidate_binding_cache(self, token_hash: str) -> None: + if self.redis is None: + return + try: + await self.redis.delete(self.cache_key(token_hash)) + except Exception: + logger.warning("Failed to delete binding cache.", extra={"token_hash": token_hash}) + + async def invalidate_many(self, token_hashes: list[str]) -> None: + if self.redis is None or not token_hashes: + return + keys = [self.cache_key(item) for item in token_hashes] + try: + await self.redis.delete(*keys) + except Exception: + logger.warning("Failed to delete multiple binding cache keys.", extra={"count": len(keys)}) + + def record_last_used(self, token_hash: str) -> None: + try: + self.last_used_queue.put_nowait(token_hash) + except asyncio.QueueFull: + logger.warning("last_used queue is full; dropping update.", extra={"token_hash": token_hash}) + + async def flush_last_used_updates(self) -> None: + token_hashes: set[str] = set() + while True: + try: + token_hashes.add(self.last_used_queue.get_nowait()) + except asyncio.QueueEmpty: + break + + if not token_hashes: + return + + async with self.session_factory() as session: + try: + stmt = ( + update(TokenBinding) + .where(TokenBinding.token_hash.in_(token_hashes)) + .values(last_used_at=func.now()) + ) + await session.execute(stmt) + await session.commit() + except SQLAlchemyError: + await session.rollback() + logger.exception("Failed to flush last_used_at updates.", extra={"count": len(token_hashes)}) + + async def increment_request_metric(self, outcome: str | None) -> None: + if self.redis is None: + return + key = self.metrics_key(date.today()) + ttl = self.settings.metrics_ttl_days * 86400 + try: + async with self.redis.pipeline(transaction=True) as pipeline: + pipeline.hincrby(key, "total", 1) + if outcome in {"allowed", "intercepted"}: + pipeline.hincrby(key, outcome, 1) + pipeline.expire(key, ttl) + await pipeline.execute() + except Exception: + logger.warning("Failed to increment request metrics.", extra={"outcome": outcome}) + + async def get_metrics_window(self, days: int = 7) -> list[dict[str, int | str]]: + if self.redis is None: + return [ + {"date": (date.today() - timedelta(days=offset)).isoformat(), "allowed": 0, "intercepted": 0, "total": 0} + for offset in range(days - 1, -1, -1) + ] + + series: list[dict[str, int | str]] = [] + for offset in range(days - 1, -1, -1): + target = date.today() - timedelta(days=offset) + raw = await self.redis.hgetall(self.metrics_key(target)) + series.append( + { + "date": target.isoformat(), + "allowed": int(raw.get("allowed", 0)), + "intercepted": int(raw.get("intercepted", 0)), + "total": int(raw.get("total", 0)), + } + ) + return series + + async def _load_binding_from_cache(self, token_hash: str) -> tuple[BindingRecord | None, bool]: + if self.redis is None: + return None, False + try: + raw = await self.redis.get(self.cache_key(token_hash)) + except Exception: + logger.warning("Failed to read binding cache.", extra={"token_hash": token_hash}) + return None, False + if raw is None: + return None, True + + data = json.loads(raw) + return ( + BindingRecord( + id=0, + token_hash=token_hash, + token_display="", + bound_ip=data["bound_ip"], + status=int(data["status"]), + ip_matched=False, + ), + True, + ) + + async def _touch_cache(self, token_hash: str) -> None: + if self.redis is None: + return + try: + await self.redis.expire(self.cache_key(token_hash), self.settings.redis_binding_ttl_seconds) + except Exception: + logger.warning("Failed to extend binding cache TTL.", extra={"token_hash": token_hash}) + + async def _load_binding_from_db(self, token_hash: str, client_ip: str) -> BindingRecord | None: + query = text( + """ + SELECT + id, + token_hash, + token_display, + bound_ip::text AS bound_ip, + status, + CAST(:client_ip AS inet) << bound_ip AS ip_matched + FROM token_bindings + WHERE token_hash = :token_hash + LIMIT 1 + """ + ) + async with self.session_factory() as session: + result = await session.execute(query, {"token_hash": token_hash, "client_ip": client_ip}) + row = result.mappings().first() + if row is None: + return None + return BindingRecord( + id=int(row["id"]), + token_hash=str(row["token_hash"]), + token_display=str(row["token_display"]), + bound_ip=str(row["bound_ip"]), + status=int(row["status"]), + ip_matched=bool(row["ip_matched"]), + ) + + async def _create_binding(self, token_hash: str, token_display: str, client_ip: str) -> BindingRecord | None: + statement = text( + """ + INSERT INTO token_bindings (token_hash, token_display, bound_ip, status) + VALUES (:token_hash, :token_display, CAST(:bound_ip AS cidr), :status) + ON CONFLICT (token_hash) DO NOTHING + RETURNING id, token_hash, token_display, bound_ip::text AS bound_ip, status + """ + ) + async with self.session_factory() as session: + try: + result = await session.execute( + statement, + { + "token_hash": token_hash, + "token_display": token_display, + "bound_ip": client_ip, + "status": STATUS_ACTIVE, + }, + ) + row = result.mappings().first() + await session.commit() + except SQLAlchemyError: + await session.rollback() + raise + if row is None: + return None + return BindingRecord( + id=int(row["id"]), + token_hash=str(row["token_hash"]), + token_display=str(row["token_display"]), + bound_ip=str(row["bound_ip"]), + status=int(row["status"]), + ip_matched=True, + ) + + def _handle_backend_failure(self, token_hash: str, token_display: str) -> BindingCheckResult: + runtime_settings = self.runtime_settings_getter() + logger.exception( + "Binding storage backend failed.", + extra={"failsafe_mode": runtime_settings.failsafe_mode, "token_hash": token_hash}, + ) + if runtime_settings.failsafe_mode == "open": + return BindingCheckResult( + allowed=True, + status_code=200, + detail="Allowed by failsafe mode.", + token_hash=token_hash, + token_display=token_display, + ) + return BindingCheckResult( + allowed=False, + status_code=503, + detail="Binding backend unavailable and failsafe mode is closed.", + token_hash=token_hash, + token_display=token_display, + ) + + async def _flush_loop(self) -> None: + try: + while not self._stop_event.is_set(): + await asyncio.sleep(self.settings.last_used_flush_interval_seconds) + await self.flush_last_used_updates() + except asyncio.CancelledError: + raise diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 0000000..87f2e04 --- /dev/null +++ b/db/init.sql @@ -0,0 +1,26 @@ +CREATE EXTENSION IF NOT EXISTS btree_gist; + +CREATE TABLE token_bindings ( + id BIGSERIAL PRIMARY KEY, + token_hash VARCHAR(64) NOT NULL UNIQUE, + token_display VARCHAR(20) NOT NULL, + bound_ip CIDR NOT NULL, + status SMALLINT NOT NULL DEFAULT 1, + first_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_token_bindings_hash ON token_bindings(token_hash); +CREATE INDEX idx_token_bindings_ip ON token_bindings USING GIST (bound_ip inet_ops); + +CREATE TABLE intercept_logs ( + id BIGSERIAL PRIMARY KEY, + token_hash VARCHAR(64) NOT NULL, + token_display VARCHAR(20) NOT NULL, + bound_ip CIDR NOT NULL, + attempt_ip INET NOT NULL, + alerted BOOLEAN NOT NULL DEFAULT FALSE, + intercepted_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX idx_intercept_logs_hash ON intercept_logs(token_hash); +CREATE INDEX idx_intercept_logs_time ON intercept_logs(intercepted_at DESC); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fc60a4c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,73 @@ +services: + nginx: + image: nginx:alpine + container_name: sentinel-nginx + restart: unless-stopped + ports: + - "80:80" + - "443:443" + depends_on: + - sentinel-app + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/ssl:/etc/nginx/ssl:ro + - ./frontend/dist:/etc/nginx/html/admin/ui:ro + networks: + - sentinel-net + + sentinel-app: + build: + context: . + dockerfile: Dockerfile + image: key-ip-sentinel:latest + container_name: sentinel-app + restart: unless-stopped + env_file: + - .env + depends_on: + - redis + - postgres + networks: + - sentinel-net + - llm-shared-net + + redis: + image: redis:7-alpine + container_name: sentinel-redis + restart: unless-stopped + command: + [ + "sh", + "-c", + "if [ -n \"$REDIS_PASSWORD\" ]; then exec redis-server --requirepass \"$REDIS_PASSWORD\"; else exec redis-server; fi" + ] + env_file: + - .env + volumes: + - redis_data:/data + networks: + - sentinel-net + + postgres: + image: postgres:15 + container_name: sentinel-postgres + restart: unless-stopped + environment: + POSTGRES_USER: sentinel + POSTGRES_PASSWORD: password + POSTGRES_DB: sentinel + volumes: + - pg_data:/var/lib/postgresql/data + - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + networks: + - sentinel-net + +volumes: + redis_data: + pg_data: + +networks: + sentinel-net: + driver: bridge + llm-shared-net: + external: true diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..fbbf46b --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + Key-IP Sentinel + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..f1eb994 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "key-ip-sentinel-frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.8.3", + "echarts": "^5.6.0", + "element-plus": "^2.9.6", + "vue": "^3.5.13", + "vue-router": "^4.5.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "vite": "^6.2.1" + } +} diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..950ad34 --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,293 @@ + + + + + diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js new file mode 100644 index 0000000..baeac98 --- /dev/null +++ b/frontend/src/api/index.js @@ -0,0 +1,103 @@ +import axios from 'axios' + +const TOKEN_KEY = 'sentinel_admin_token' + +export const api = axios.create({ + baseURL: '/', + timeout: 20000, +}) + +api.interceptors.request.use((config) => { + const token = getAuthToken() + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config +}) + +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + clearAuthToken() + if (!window.location.pathname.endsWith('/login')) { + window.location.assign(`${import.meta.env.BASE_URL}login`) + } + } + return Promise.reject(error) + }, +) + +export function getAuthToken() { + return localStorage.getItem(TOKEN_KEY) +} + +export function setAuthToken(token) { + localStorage.setItem(TOKEN_KEY, token) +} + +export function clearAuthToken() { + localStorage.removeItem(TOKEN_KEY) +} + +export function humanizeError(error, fallback = 'Request failed.') { + return error?.response?.data?.detail || error?.message || fallback +} + +export async function login(password) { + const { data } = await api.post('/admin/api/login', { password }) + return data +} + +export async function fetchDashboard() { + const { data } = await api.get('/admin/api/dashboard') + return data +} + +export async function fetchBindings(params) { + const { data } = await api.get('/admin/api/bindings', { params }) + return data +} + +export async function unbindBinding(id) { + const { data } = await api.post('/admin/api/bindings/unbind', { id }) + return data +} + +export async function updateBindingIp(payload) { + const { data } = await api.put('/admin/api/bindings/ip', payload) + return data +} + +export async function banBinding(id) { + const { data } = await api.post('/admin/api/bindings/ban', { id }) + return data +} + +export async function unbanBinding(id) { + const { data } = await api.post('/admin/api/bindings/unban', { id }) + return data +} + +export async function fetchLogs(params) { + const { data } = await api.get('/admin/api/logs', { params }) + return data +} + +export async function exportLogs(params) { + const response = await api.get('/admin/api/logs/export', { + params, + responseType: 'blob', + }) + return response.data +} + +export async function fetchSettings() { + const { data } = await api.get('/admin/api/settings') + return data +} + +export async function updateSettings(payload) { + const { data } = await api.put('/admin/api/settings', payload) + return data +} diff --git a/frontend/src/components/MetricTile.vue b/frontend/src/components/MetricTile.vue new file mode 100644 index 0000000..0b86484 --- /dev/null +++ b/frontend/src/components/MetricTile.vue @@ -0,0 +1,28 @@ + + + diff --git a/frontend/src/components/PageHero.vue b/frontend/src/components/PageHero.vue new file mode 100644 index 0000000..24d6ec8 --- /dev/null +++ b/frontend/src/components/PageHero.vue @@ -0,0 +1,35 @@ + + + diff --git a/frontend/src/composables/usePolling.js b/frontend/src/composables/usePolling.js new file mode 100644 index 0000000..cdbb960 --- /dev/null +++ b/frontend/src/composables/usePolling.js @@ -0,0 +1,26 @@ +import { onBeforeUnmount } from 'vue' + +export function usePolling(task, intervalMs) { + let timerId = null + + function stop() { + if (timerId) { + window.clearInterval(timerId) + timerId = null + } + } + + function start() { + stop() + timerId = window.setInterval(() => { + task() + }, intervalMs) + } + + onBeforeUnmount(stop) + + return { + start, + stop, + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..062ea48 --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,18 @@ +import { createApp } from 'vue' +import ElementPlus from 'element-plus' +import 'element-plus/dist/index.css' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' + +import App from './App.vue' +import router from './router' +import './styles.css' + +const app = createApp(App) + +Object.entries(ElementPlusIconsVue).forEach(([key, component]) => { + app.component(key, component) +}) + +app.use(router) +app.use(ElementPlus) +app.mount('#app') diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js new file mode 100644 index 0000000..73ef3d1 --- /dev/null +++ b/frontend/src/router/index.js @@ -0,0 +1,76 @@ +import { createRouter, createWebHistory } from 'vue-router' + +import { getAuthToken } from '../api' +import Bindings from '../views/Bindings.vue' +import Dashboard from '../views/Dashboard.vue' +import Login from '../views/Login.vue' +import Logs from '../views/Logs.vue' +import Settings from '../views/Settings.vue' + +const router = createRouter({ + history: createWebHistory(import.meta.env.BASE_URL), + routes: [ + { + path: '/login', + name: 'login', + component: Login, + meta: { + public: true, + title: 'Admin Login', + }, + }, + { + path: '/', + redirect: '/dashboard', + }, + { + path: '/dashboard', + name: 'dashboard', + component: Dashboard, + meta: { + title: 'Traffic Pulse', + kicker: 'Observability', + }, + }, + { + path: '/bindings', + name: 'bindings', + component: Bindings, + meta: { + title: 'Token Bindings', + kicker: 'Control', + }, + }, + { + path: '/logs', + name: 'logs', + component: Logs, + meta: { + title: 'Intercept Logs', + kicker: 'Audit', + }, + }, + { + path: '/settings', + name: 'settings', + component: Settings, + meta: { + title: 'Runtime Settings', + kicker: 'Operations', + }, + }, + ], +}) + +router.beforeEach((to) => { + const authed = Boolean(getAuthToken()) + if (to.meta.public && authed) { + return { name: 'dashboard' } + } + if (!to.meta.public && !authed) { + return { name: 'login' } + } + return true +}) + +export default router diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 0000000..6a46162 --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,477 @@ +:root { + --sentinel-bg: #08131c; + --sentinel-bg-soft: #102734; + --sentinel-panel: rgba(252, 255, 255, 0.82); + --sentinel-panel-strong: rgba(255, 255, 255, 0.9); + --sentinel-border: rgba(255, 255, 255, 0.24); + --sentinel-ink: #09161e; + --sentinel-ink-soft: #57717d; + --sentinel-accent: #07b093; + --sentinel-accent-deep: #0d7e8b; + --sentinel-warn: #ef7f41; + --sentinel-danger: #dc4f53; + --sentinel-shadow: 0 30px 80px rgba(2, 12, 18, 0.22); + --el-color-primary: #0b9e88; + --el-color-success: #1aa36f; + --el-color-warning: #ef7f41; + --el-color-danger: #dc4f53; + color: var(--sentinel-ink); + font-family: "Avenir Next", "Segoe UI Variable", "Segoe UI", "PingFang SC", sans-serif; + line-height: 1.5; + font-weight: 400; +} + +* { + box-sizing: border-box; +} + +*:focus-visible { + outline: 3px solid rgba(11, 158, 136, 0.34); + outline-offset: 2px; +} + +html { + min-height: 100%; + background: + radial-gradient(circle at top left, rgba(12, 193, 152, 0.22), transparent 34%), + radial-gradient(circle at top right, rgba(255, 170, 76, 0.18), transparent 30%), + linear-gradient(180deg, #09131d 0%, #0d1d29 35%, #112d3d 100%); +} + +body { + margin: 0; + min-height: 100vh; + color: var(--sentinel-ink); +} + +body::before { + content: ""; + position: fixed; + inset: 0; + background: + linear-gradient(rgba(255, 255, 255, 0.02) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px); + background-size: 34px 34px; + mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.5), transparent 95%); + pointer-events: none; +} + +#app { + min-height: 100vh; +} + +.panel { + background: var(--sentinel-panel); + border: 1px solid var(--sentinel-border); + border-radius: 28px; + backdrop-filter: blur(18px); + box-shadow: var(--sentinel-shadow); +} + +.glass-panel { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.88), rgba(250, 255, 252, 0.74)); +} + +.eyebrow { + margin: 0; + color: var(--sentinel-accent-deep); + text-transform: uppercase; + letter-spacing: 0.16em; + font-size: 0.74rem; + font-weight: 700; +} + +.muted { + color: var(--sentinel-ink-soft); +} + +.page-grid { + display: grid; + gap: 24px; +} + +.hero-panel { + position: relative; + padding: 26px; + overflow: hidden; +} + +.hero-layout { + display: grid; + grid-template-columns: minmax(0, 1.3fr) minmax(260px, 0.7fr); + gap: 20px; + align-items: stretch; +} + +.hero-copy { + display: flex; + flex-direction: column; + justify-content: center; +} + +.hero-description { + max-width: 60ch; +} + +.hero-side { + display: grid; + gap: 12px; + align-content: start; +} + +.hero-aside, +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + justify-content: flex-end; +} + +.hero-panel::after { + content: ""; + position: absolute; + top: -80px; + right: -40px; + width: 220px; + height: 220px; + background: radial-gradient(circle, rgba(7, 176, 147, 0.28), transparent 70%); + pointer-events: none; +} + +.hero-panel h3, +.section-title { + margin: 10px 0 8px; + font-size: 1.4rem; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; +} + +.metric-card { + position: relative; + overflow: hidden; + padding: 20px; +} + +.metric-card--enhanced { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(244, 252, 249, 0.78)); +} + +.metric-card::before { + content: ""; + position: absolute; + inset: auto -60px -60px auto; + width: 140px; + height: 140px; + border-radius: 999px; + background: radial-gradient(circle, rgba(7, 176, 147, 0.16), transparent 70%); +} + +.metric-card[data-accent="amber"]::before { + background: radial-gradient(circle, rgba(239, 127, 65, 0.16), transparent 70%); +} + +.metric-card[data-accent="slate"]::before { + background: radial-gradient(circle, rgba(54, 97, 135, 0.16), transparent 70%); +} + +.metric-value { + margin: 10px 0 0; + font-size: clamp(1.8rem, 3vw, 2.5rem); + font-weight: 800; +} + +.metric-footnote { + margin: 10px 0 0; + color: var(--sentinel-ink-soft); +} + +.content-grid { + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.9fr); + gap: 24px; +} + +.chart-card, +.table-card, +.form-card { + padding: 24px; +} + +.chart-surface { + width: 100%; + min-height: 340px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; + justify-content: space-between; +} + +.toolbar-left, +.toolbar-right { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.data-table .el-table { + --el-table-border-color: rgba(9, 22, 30, 0.08); + --el-table-header-bg-color: rgba(7, 176, 147, 0.08); + --el-table-row-hover-bg-color: rgba(7, 176, 147, 0.05); + border-radius: 18px; + overflow: hidden; +} + +.soft-grid { + display: grid; + gap: 16px; +} + +.insight-card { + padding: 18px 20px; + border-radius: 22px; + background: linear-gradient(180deg, rgba(8, 31, 45, 0.92), rgba(12, 24, 33, 0.8)); + color: #f2fffd; +} + +.insight-value { + margin: 6px 0 0; + font-size: 1.65rem; + font-weight: 800; +} + +.insight-note { + margin: 8px 0 0; + color: rgba(242, 255, 253, 0.72); +} + +.table-stack { + display: grid; + gap: 14px; +} + +.inline-meta { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.login-shell { + min-height: 100vh; + display: grid; + grid-template-columns: 1.1fr 0.9fr; + gap: 24px; + padding: 24px; +} + +.login-stage, +.login-card { + position: relative; + z-index: 1; +} + +.login-stage { + padding: 42px; + display: flex; + flex-direction: column; + justify-content: space-between; + color: #f7fffe; + background: + radial-gradient(circle at top left, rgba(17, 231, 181, 0.24), transparent 28%), + linear-gradient(160deg, rgba(8, 24, 34, 0.95), rgba(15, 37, 50, 0.92)); +} + +.login-stage h1 { + margin: 12px 0; + font-size: clamp(2.4rem, 4vw, 4rem); + line-height: 0.96; +} + +.login-copy { + max-width: 520px; + font-size: 1rem; + color: rgba(247, 255, 254, 0.78); +} + +.login-card { + display: grid; + place-items: center; + padding: 36px; +} + +.login-card-inner { + width: min(100%, 460px); + padding: 34px; + background: var(--sentinel-panel-strong); + border-radius: 32px; + border: 1px solid var(--sentinel-border); + box-shadow: var(--sentinel-shadow); +} + +.status-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + background: rgba(7, 176, 147, 0.12); + color: var(--sentinel-accent-deep); + font-weight: 700; + font-size: 0.82rem; +} + +.stack { + display: grid; + gap: 24px; +} + +.empty-state { + padding: 28px; + text-align: center; + color: var(--sentinel-ink-soft); +} + +.brand-subtitle { + margin: 6px 0 0; + color: var(--sentinel-ink-soft); + font-size: 0.92rem; +} + +.rail-grid { + display: grid; + gap: 10px; +} + +.rail-card { + display: grid; + gap: 4px; + padding: 14px 16px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.44); + border: 1px solid rgba(255, 255, 255, 0.26); +} + +.rail-label, +.header-chip-label { + font-size: 0.72rem; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--sentinel-ink-soft); +} + +.rail-meta { + color: var(--sentinel-ink-soft); + font-size: 0.86rem; +} + +.header-copy { + display: grid; + gap: 4px; +} + +.header-note { + margin: 0; +} + +.header-chip-group { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.header-chip { + display: grid; + gap: 2px; + min-width: 140px; + padding: 10px 14px; + border-radius: 18px; + background: rgba(255, 255, 255, 0.56); + border: 1px solid rgba(255, 255, 255, 0.26); +} + +.hero-stat-pair { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.hero-stat { + padding: 14px 16px; + border-radius: 20px; + background: linear-gradient(180deg, rgba(255, 255, 255, 0.78), rgba(248, 255, 252, 0.64)); + border: 1px solid rgba(255, 255, 255, 0.36); +} + +.hero-stat strong { + display: block; + margin-top: 6px; + font-size: 1.35rem; +} + +.table-toolbar-block { + display: grid; + gap: 8px; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + scroll-behavior: auto !important; + transition-duration: 0.01ms !important; + } +} + +@media (max-width: 1180px) { + .metric-grid, + .content-grid, + .login-shell { + grid-template-columns: 1fr; + } + + .hero-layout { + grid-template-columns: 1fr; + } + + .hero-side { + justify-items: start; + } + + .hero-aside, + .hero-actions { + justify-content: flex-start; + } +} + +@media (max-width: 760px) { + .metric-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .chart-card, + .table-card, + .form-card, + .hero-panel { + padding: 18px; + } +} + +@media (max-width: 560px) { + .metric-grid { + grid-template-columns: 1fr; + } + + .login-shell { + padding: 16px; + } +} diff --git a/frontend/src/utils/formatters.js b/frontend/src/utils/formatters.js new file mode 100644 index 0000000..910323e --- /dev/null +++ b/frontend/src/utils/formatters.js @@ -0,0 +1,51 @@ +const integerFormatter = new Intl.NumberFormat() +const compactFormatter = new Intl.NumberFormat(undefined, { + notation: 'compact', + maximumFractionDigits: 1, +}) +const dateTimeFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', + timeStyle: 'short', +}) +const dateFormatter = new Intl.DateTimeFormat(undefined, { + dateStyle: 'medium', +}) + +export function formatInteger(value) { + return integerFormatter.format(Number(value || 0)) +} + +export function formatCompactNumber(value) { + return compactFormatter.format(Number(value || 0)) +} + +export function formatDateTime(value) { + if (!value) { + return '--' + } + return dateTimeFormatter.format(new Date(value)) +} + +export function formatDate(value) { + if (!value) { + return '--' + } + return dateFormatter.format(new Date(value)) +} + +export function formatPercent(value, digits = 1) { + return `${(Number(value || 0) * 100).toFixed(digits)}%` +} + +export function downloadBlob(blob, filename) { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + URL.revokeObjectURL(url) +} + +export function nonEmptyCount(items, predicate) { + return items.filter(predicate).length +} diff --git a/frontend/src/views/Bindings.vue b/frontend/src/views/Bindings.vue new file mode 100644 index 0000000..6c74945 --- /dev/null +++ b/frontend/src/views/Bindings.vue @@ -0,0 +1,266 @@ + + + diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..7ccee5f --- /dev/null +++ b/frontend/src/views/Dashboard.vue @@ -0,0 +1,235 @@ + + + diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue new file mode 100644 index 0000000..121f9aa --- /dev/null +++ b/frontend/src/views/Login.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/frontend/src/views/Logs.vue b/frontend/src/views/Logs.vue new file mode 100644 index 0000000..29151f7 --- /dev/null +++ b/frontend/src/views/Logs.vue @@ -0,0 +1,183 @@ + + + diff --git a/frontend/src/views/Settings.vue b/frontend/src/views/Settings.vue new file mode 100644 index 0000000..d1840f5 --- /dev/null +++ b/frontend/src/views/Settings.vue @@ -0,0 +1,169 @@ + + + diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 0000000..7018dea --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +export default defineConfig({ + base: '/admin/ui/', + plugins: [vue()], + server: { + host: '0.0.0.0', + port: 5173, + proxy: { + '/admin/api': { + target: 'http://127.0.0.1:7000', + changeOrigin: true, + }, + '/health': { + target: 'http://127.0.0.1:7000', + changeOrigin: true, + }, + }, + }, +}) diff --git a/main.py b/main.py new file mode 100644 index 0000000..af4f783 --- /dev/null +++ b/main.py @@ -0,0 +1,6 @@ +def main(): + print("Hello from sentinel!") + + +if __name__ == "__main__": + main() diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..c55bc38 --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,90 @@ +worker_processes auto; + +events { + worker_connections 4096; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + + limit_req_zone $binary_remote_addr zone=api:10m rate=60r/m; + + upstream sentinel_app { + server sentinel-app:7000; + keepalive 128; + } + + server { + listen 80; + server_name _; + return 301 https://$host$request_uri; + } + + server { + listen 443 ssl http2; + server_name _; + + ssl_certificate /etc/nginx/ssl/server.crt; + ssl_certificate_key /etc/nginx/ssl/server.key; + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:10m; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + + client_max_body_size 32m; + proxy_http_version 1.1; + proxy_read_timeout 600s; + proxy_send_timeout 600s; + proxy_buffering off; + + location ^~ /admin/ui/ { + allow 10.0.0.0/8; + allow 192.168.0.0/16; + allow 172.16.0.0/12; + deny all; + + root /etc/nginx/html; + try_files $uri $uri/ /admin/ui/index.html; + } + + location ^~ /admin/api/ { + allow 10.0.0.0/8; + allow 192.168.0.0/16; + allow 172.16.0.0/12; + deny all; + + limit_req zone=api burst=30 nodelay; + proxy_pass http://sentinel_app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Connection ""; + } + + location = /health { + proxy_pass http://sentinel_app/health; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + } + + location / { + limit_req zone=api burst=60 nodelay; + proxy_pass http://sentinel_app; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + proxy_set_header Connection ""; + } + } +} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9773937 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,20 @@ +[project] +name = "sentinel" +version = "0.1.0" +description = "Key-IP Sentinel reverse proxy and admin control plane" +readme = "README.md" +requires-python = ">=3.13" +dependencies = [ + "apscheduler==3.11.0", + "asyncpg==0.30.0", + "fastapi==0.115.12", + "httpx==0.28.1", + "pydantic-settings==2.8.1", + "python-jose[cryptography]==3.4.0", + "redis==5.2.1", + "sqlalchemy==2.0.39", + "uvicorn[standard]==0.34.0", +] + +[dependency-groups] +dev = [] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eb656a3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.12 +uvicorn[standard]==0.34.0 +SQLAlchemy==2.0.39 +asyncpg==0.30.0 +redis==5.2.1 +httpx==0.28.1 +python-jose[cryptography]==3.4.0 +pydantic-settings==2.8.1 +apscheduler==3.11.0 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..2ecd02b --- /dev/null +++ b/uv.lock @@ -0,0 +1,751 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[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.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" }, +] + +[[package]] +name = "apscheduler" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, +] + +[[package]] +name = "asyncpg" +version = "0.30.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/4c/7c991e080e106d854809030d8584e15b2e996e26f16aee6d757e387bc17d/asyncpg-0.30.0.tar.gz", hash = "sha256:c551e9928ab6707602f44811817f82ba3c446e018bfe1d3abecc8ba5f3eac851", size = 957746, upload-time = "2024-10-20T00:30:41.127Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/22/e20602e1218dc07692acf70d5b902be820168d6282e69ef0d3cb920dc36f/asyncpg-0.30.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05b185ebb8083c8568ea8a40e896d5f7af4b8554b64d7719c0eaa1eb5a5c3a70", size = 670373, upload-time = "2024-10-20T00:29:55.165Z" }, + { url = "https://files.pythonhosted.org/packages/3d/b3/0cf269a9d647852a95c06eb00b815d0b95a4eb4b55aa2d6ba680971733b9/asyncpg-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c47806b1a8cbb0a0db896f4cd34d89942effe353a5035c62734ab13b9f938da3", size = 634745, upload-time = "2024-10-20T00:29:57.14Z" }, + { url = "https://files.pythonhosted.org/packages/8e/6d/a4f31bf358ce8491d2a31bfe0d7bcf25269e80481e49de4d8616c4295a34/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b6fde867a74e8c76c71e2f64f80c64c0f3163e687f1763cfaf21633ec24ec33", size = 3512103, upload-time = "2024-10-20T00:29:58.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/19/139227a6e67f407b9c386cb594d9628c6c78c9024f26df87c912fabd4368/asyncpg-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46973045b567972128a27d40001124fbc821c87a6cade040cfcd4fa8a30bcdc4", size = 3592471, upload-time = "2024-10-20T00:30:00.354Z" }, + { url = "https://files.pythonhosted.org/packages/67/e4/ab3ca38f628f53f0fd28d3ff20edff1c975dd1cb22482e0061916b4b9a74/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9110df111cabc2ed81aad2f35394a00cadf4f2e0635603db6ebbd0fc896f46a4", size = 3496253, upload-time = "2024-10-20T00:30:02.794Z" }, + { url = "https://files.pythonhosted.org/packages/ef/5f/0bf65511d4eeac3a1f41c54034a492515a707c6edbc642174ae79034d3ba/asyncpg-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:04ff0785ae7eed6cc138e73fc67b8e51d54ee7a3ce9b63666ce55a0bf095f7ba", size = 3662720, upload-time = "2024-10-20T00:30:04.501Z" }, + { url = "https://files.pythonhosted.org/packages/e7/31/1513d5a6412b98052c3ed9158d783b1e09d0910f51fbe0e05f56cc370bc4/asyncpg-0.30.0-cp313-cp313-win32.whl", hash = "sha256:ae374585f51c2b444510cdf3595b97ece4f233fde739aa14b50e0d64e8a7a590", size = 560404, upload-time = "2024-10-20T00:30:06.537Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a4/cec76b3389c4c5ff66301cd100fe88c318563ec8a520e0b2e792b5b84972/asyncpg-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:f59b430b8e27557c3fb9869222559f7417ced18688375825f8f12302c34e915e", size = 621623, upload-time = "2024-10-20T00:30:09.024Z" }, +] + +[[package]] +name = "certifi" +version = "2026.2.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, +] + +[[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/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 = "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 = "cryptography" +version = "46.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, +] + +[[package]] +name = "ecdsa" +version = "0.19.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/1f/924e3caae75f471eae4b26bd13b698f6af2c44279f67af317439c2f4c46a/ecdsa-0.19.1.tar.gz", hash = "sha256:478cba7b62555866fcb3bb3fe985e06decbdb68ef55713c4e5ab98c57d508e61", size = 201793, upload-time = "2025-03-13T11:52:43.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/a3/460c57f094a4a165c84a1341c373b0a4f5ec6ac244b998d5021aade89b77/ecdsa-0.19.1-py2.py3-none-any.whl", hash = "sha256:30638e27cf77b7e15c4c4cc1973720149e1033827cfd00661ca5c8cc0cdb24c3", size = 150607, upload-time = "2025-03-13T11:52:41.757Z" }, +] + +[[package]] +name = "fastapi" +version = "0.115.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f4/55/ae499352d82338331ca1e28c7f4a63bfd09479b16395dce38cf50a39e2c2/fastapi-0.115.12.tar.gz", hash = "sha256:1e2c2a2646905f9e83d32f04a3f86aff4a286669c6c950ca95b5fd68c2602681", size = 295236, upload-time = "2025-03-23T22:55:43.822Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/b3/b51f09c2ba432a576fe63758bddc81f78f0c6309d9e5c10d194313bf021e/fastapi-0.115.12-py3-none-any.whl", hash = "sha256:e94613d6c05e27be7ffebdd6ea5f388112e5e430c8f7d6494a9d1d88d43e814d", size = 95164, upload-time = "2025-03-23T22:55:42.101Z" }, +] + +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" }, + { url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" }, + { url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" }, + { url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" }, + { url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" }, + { url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" }, + { url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + +[[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/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 = "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 = "pyasn1" +version = "0.4.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/db/fffec68299e6d7bad3d504147f9094830b704527a7fc098b721d38cc7fa7/pyasn1-0.4.8.tar.gz", hash = "sha256:aef77c9fb94a3ac588e87841208bdec464471d9871bd5050a287cc9a475cd0ba", size = 146820, upload-time = "2019-11-16T17:27:38.772Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/1e/a94a8d635fa3ce4cfc7f506003548d0a2447ae76fd5ca53932970fe3053f/pyasn1-0.4.8-py2.py3-none-any.whl", hash = "sha256:39c7e2ec30515947ff4e87fb6f456dfc6e84857d34be479c9d4a4ba4bf46aa5d", size = 77145, upload-time = "2019-11-16T17:27:11.07Z" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[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]] +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/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" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/82/c79424d7d8c29b994fb01d277da57b0a9b09cc03c3ff875f9bd8a86b2145/pydantic_settings-2.8.1.tar.gz", hash = "sha256:d5c663dfbe9db9d5e1c646b2e161da12f0d734d422ee56f567d0ea2cee4e8585", size = 83550, upload-time = "2025-02-27T10:10:32.338Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/53/a64f03044927dc47aafe029c42a5b7aabc38dfb813475e0e1bf71c4a59d0/pydantic_settings-2.8.1-py3-none-any.whl", hash = "sha256:81942d5ac3d905f7f3ee1a70df5dfb62d5569c12f51a5a647defc1c3d9ee2e9c", size = 30839, upload-time = "2025-02-27T10:10:30.711Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-jose" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ecdsa" }, + { name = "pyasn1" }, + { name = "rsa" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8e/a0/c49687cf40cb6128ea4e0559855aff92cd5ebd1a60a31c08526818c0e51e/python-jose-3.4.0.tar.gz", hash = "sha256:9a9a40f418ced8ecaf7e3b28d69887ceaa76adad3bcaa6dae0d9e596fec1d680", size = 92145, upload-time = "2025-02-18T17:26:41.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/b0/2586ea6b6fd57a994ece0b56418cbe93fff0efb85e2c9eb6b0caf24a4e37/python_jose-3.4.0-py2.py3-none-any.whl", hash = "sha256:9c9f616819652d109bd889ecd1e15e9a162b9b94d682534c9c2146092945b78f", size = 34616, upload-time = "2025-02-18T17:26:40.826Z" }, +] + +[package.optional-dependencies] +cryptography = [ + { name = "cryptography" }, +] + +[[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/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 = "5.2.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/da/d283a37303a995cd36f8b92db85135153dc4f7a8e4441aa827721b442cfb/redis-5.2.1.tar.gz", hash = "sha256:16f2e22dff21d5125e8481515e386711a34cbec50f0e44413dd7d9c060a54e0f", size = 4608355, upload-time = "2024-12-06T09:50:41.956Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/5f/fa26b9b2672cbe30e07d9a5bdf39cf16e3b80b42916757c5f92bca88e4ba/redis-5.2.1-py3-none-any.whl", hash = "sha256:ee7e1056b9aea0f04c6c2ed59452947f34c4940ee025f5dd83e6a6418b6989e4", size = 261502, upload-time = "2024-12-06T09:50:39.656Z" }, +] + +[[package]] +name = "rsa" +version = "4.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasn1" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/8a/22b7beea3ee0d44b1916c0c1cb0ee3af23b700b6da9f04991899d0c555d4/rsa-4.9.1.tar.gz", hash = "sha256:e7bdbfdb5497da4c07dfd35530e1a902659db6ff241e39d9953cad06ebd0ae75", size = 29034, upload-time = "2025-04-16T09:51:18.218Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/8d/0133e4eb4beed9e425d9a98ed6e081a55d195481b7632472be1af08d2f6b/rsa-4.9.1-py3-none-any.whl", hash = "sha256:68635866661c6836b8d39430f97a996acbd61bfa49406748ea243539fe239762", size = 34696, upload-time = "2025-04-16T09:51:17.142Z" }, +] + +[[package]] +name = "sentinel" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "apscheduler" }, + { name = "asyncpg" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "pydantic-settings" }, + { name = "python-jose", extra = ["cryptography"] }, + { name = "redis" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.metadata] +requires-dist = [ + { name = "apscheduler", specifier = "==3.11.0" }, + { name = "asyncpg", specifier = "==0.30.0" }, + { name = "fastapi", specifier = "==0.115.12" }, + { name = "httpx", specifier = "==0.28.1" }, + { name = "pydantic-settings", specifier = "==2.8.1" }, + { name = "python-jose", extras = ["cryptography"], specifier = "==3.4.0" }, + { name = "redis", specifier = "==5.2.1" }, + { name = "sqlalchemy", specifier = "==2.0.39" }, + { name = "uvicorn", extras = ["standard"], specifier = "==0.34.0" }, +] + +[package.metadata.requires-dev] +dev = [] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sqlalchemy" +version = "2.0.39" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "(python_full_version < '3.14' and platform_machine == 'AMD64') or (python_full_version < '3.14' and platform_machine == 'WIN32') or (python_full_version < '3.14' and platform_machine == 'aarch64') or (python_full_version < '3.14' and platform_machine == 'amd64') or (python_full_version < '3.14' and platform_machine == 'ppc64le') or (python_full_version < '3.14' and platform_machine == 'win32') or (python_full_version < '3.14' and platform_machine == 'x86_64')" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/00/8e/e77fcaa67f8b9f504b4764570191e291524575ddbfe78a90fc656d671fdc/sqlalchemy-2.0.39.tar.gz", hash = "sha256:5d2d1fe548def3267b4c70a8568f108d1fed7cbbeccb9cc166e05af2abc25c22", size = 9644602, upload-time = "2025-03-11T18:27:09.744Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/47/55778362642344324a900b6b2b1b26f7f02225b374eb93adc4a363a2d8ae/sqlalchemy-2.0.39-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:fe193d3ae297c423e0e567e240b4324d6b6c280a048e64c77a3ea6886cc2aa87", size = 2102484, upload-time = "2025-03-11T19:21:54.018Z" }, + { url = "https://files.pythonhosted.org/packages/1b/e1/f5f26f67d095f408138f0fb2c37f827f3d458f2ae51881546045e7e55566/sqlalchemy-2.0.39-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:79f4f502125a41b1b3b34449e747a6abfd52a709d539ea7769101696bdca6716", size = 2092955, upload-time = "2025-03-11T19:21:55.658Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c2/0db0022fc729a54fc7aef90a3457bf20144a681baef82f7357832b44c566/sqlalchemy-2.0.39-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8a10ca7f8a1ea0fd5630f02feb055b0f5cdfcd07bb3715fc1b6f8cb72bf114e4", size = 3179367, upload-time = "2025-03-11T19:09:31.059Z" }, + { url = "https://files.pythonhosted.org/packages/33/b7/f33743d87d0b4e7a1f12e1631a4b9a29a8d0d7c0ff9b8c896d0bf897fb60/sqlalchemy-2.0.39-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e6b0a1c7ed54a5361aaebb910c1fa864bae34273662bb4ff788a527eafd6e14d", size = 3192705, upload-time = "2025-03-11T19:32:50.795Z" }, + { url = "https://files.pythonhosted.org/packages/c9/74/6814f31719109c973ddccc87bdfc2c2a9bc013bec64a375599dc5269a310/sqlalchemy-2.0.39-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52607d0ebea43cf214e2ee84a6a76bc774176f97c5a774ce33277514875a718e", size = 3125927, upload-time = "2025-03-11T19:09:32.678Z" }, + { url = "https://files.pythonhosted.org/packages/e8/6b/18f476f4baaa9a0e2fbc6808d8f958a5268b637c8eccff497bf96908d528/sqlalchemy-2.0.39-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c08a972cbac2a14810463aec3a47ff218bb00c1a607e6689b531a7c589c50723", size = 3154055, upload-time = "2025-03-11T19:32:53.344Z" }, + { url = "https://files.pythonhosted.org/packages/b4/60/76714cecb528da46bc53a0dd36d1ccef2f74ef25448b630a0a760ad07bdb/sqlalchemy-2.0.39-cp313-cp313-win32.whl", hash = "sha256:23c5aa33c01bd898f879db158537d7e7568b503b15aad60ea0c8da8109adf3e7", size = 2075315, upload-time = "2025-03-11T18:43:16.946Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7c/76828886d913700548bac5851eefa5b2c0251ebc37921fe476b93ce81b50/sqlalchemy-2.0.39-cp313-cp313-win_amd64.whl", hash = "sha256:4dabd775fd66cf17f31f8625fc0e4cfc5765f7982f94dc09b9e5868182cb71c0", size = 2099175, upload-time = "2025-03-11T18:43:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/7b/0f/d69904cb7d17e65c65713303a244ec91fd3c96677baf1d6331457fd47e16/sqlalchemy-2.0.39-py3-none-any.whl", hash = "sha256:a1c6b0a5e3e326a466d809b651c63f278b1256146a377a528b6938a279da334f", size = 1898621, upload-time = "2025-03-11T19:20:33.027Z" }, +] + +[[package]] +name = "starlette" +version = "0.46.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/20/08dfcd9c983f6a6f4a1000d934b9e6d626cff8d2eeb77a89a68eef20a2b7/starlette-0.46.2.tar.gz", hash = "sha256:7f7361f34eed179294600af672f565727419830b54b7b084efe44bb82d2fccd5", size = 2580846, upload-time = "2025-04-13T13:56:17.942Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/0c/9d30a4ebeb6db2b25a841afbb80f6ef9a854fc3b41be131d249a977b4959/starlette-0.46.2-py3-none-any.whl", hash = "sha256:595633ce89f8ffa71a015caed34a5b2dc1c0cdb3f0f1fbd1e69339cf2abeec35", size = 72037, upload-time = "2025-04-13T13:56:16.21Z" }, +] + +[[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 = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.34.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4b/4d/938bd85e5bf2edeec766267a5015ad969730bb91e31b44021dfe8b22df6c/uvicorn-0.34.0.tar.gz", hash = "sha256:404051050cd7e905de2c9a7e61790943440b3416f49cb409f965d9dcd0fa73e9", size = 76568, upload-time = "2024-12-15T13:33:30.42Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/14/33a3a1352cfa71812a3a21e8c9bfb83f60b0011f5e36f2b1399d51928209/uvicorn-0.34.0-py3-none-any.whl", hash = "sha256:023dc038422502fa28a09c7a30bf2b6991512da7dcdb8fd35fe57cfc154126f4", size = 62315, upload-time = "2024-12-15T13:33:27.467Z" }, +] + +[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/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 = "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/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 = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +]