feat(core): 初始化 Key-IP Sentinel 服务与部署骨架

- 搭建 FastAPI、Redis、PostgreSQL、Nginx 与 Docker Compose 基础结构
- 实现反向代理、首用绑定、拦截告警、归档任务和管理接口
- 提供 Vue3 管理后台初版,以及 uv/requirements 双依赖配置
This commit is contained in:
2026-03-04 00:18:33 +08:00
commit ab1bd90c65
50 changed files with 5645 additions and 0 deletions

14
.env.example Normal file
View File

@@ -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

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

10
Dockerfile Normal file
View File

@@ -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"]

424
PRD.md Normal file
View File

@@ -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由其拒绝
├─ 提取 Tokensk-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)
│ ├─ 写入 RedisTTL 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 状态置为 BannedRedis 同步更新,后续所有请求直接被 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` | ✅ | 受信上游代理 IPNginx 的内网 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"}` |
***

147
README.md Normal file
View File

@@ -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://<host>/` forwards model API traffic through Sentinel.
- `https://<host>/admin/ui/` serves the admin console.
- `https://<host>/admin/api/*` serves the admin API.
- `https://<host>/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 <jwt>`.
## 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`.

49
app/api/auth.py Normal file
View File

@@ -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)

153
app/api/bindings.py Normal file
View File

@@ -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}

109
app/api/dashboard.py Normal file
View File

@@ -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,
)

107
app/api/logs.py Normal file
View File

@@ -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}"'},
)

77
app/api/settings.py Normal file
View File

@@ -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())

98
app/config.py Normal file
View File

@@ -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

35
app/core/ip_utils.py Normal file
View File

@@ -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

104
app/core/security.py Normal file
View File

@@ -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))

53
app/dependencies.py Normal file
View File

@@ -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)

193
app/main.py Normal file
View File

@@ -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)

48
app/models/db.py Normal file
View File

@@ -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

View File

@@ -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(),
)

View File

@@ -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(),
)

111
app/proxy/handler.py Normal file
View File

@@ -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

13
app/schemas/auth.py Normal file
View File

@@ -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

42
app/schemas/binding.py Normal file
View File

@@ -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

23
app/schemas/log.py Normal file
View File

@@ -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

View File

@@ -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})

View File

@@ -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

View File

@@ -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

26
db/init.sql Normal file
View File

@@ -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);

73
docker-compose.yml Normal file
View File

@@ -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

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Key-IP Sentinel</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

22
frontend/package.json Normal file
View File

@@ -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"
}
}

293
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,293 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { clearAuthToken } from './api'
const route = useRoute()
const router = useRouter()
const clockLabel = ref('')
let clockTimer
const navItems = [
{ label: 'Dashboard', name: 'dashboard', icon: 'DataAnalysis' },
{ label: 'Bindings', name: 'bindings', icon: 'Connection' },
{ label: 'Logs', name: 'logs', icon: 'WarningFilled' },
{ label: 'Settings', name: 'settings', icon: 'Setting' },
]
const hideShell = computed(() => Boolean(route.meta.public))
const currentSection = computed(() => route.meta.kicker || 'Operations')
function updateClock() {
clockLabel.value = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date())
}
async function logout() {
clearAuthToken()
await router.push({ name: 'login' })
}
onMounted(() => {
updateClock()
clockTimer = window.setInterval(updateClock, 60000)
})
onBeforeUnmount(() => {
if (clockTimer) {
window.clearInterval(clockTimer)
}
})
</script>
<template>
<router-view v-if="hideShell" />
<div v-else class="shell">
<div class="shell-glow shell-glow--mint" />
<div class="shell-glow shell-glow--amber" />
<aside class="shell-sidebar panel">
<div class="brand-block">
<div class="brand-mark">S</div>
<div>
<p class="eyebrow">Key-IP Sentinel</p>
<h1 class="brand-title">Control Plane</h1>
<p class="brand-subtitle">First-use bind enforcement edge</p>
</div>
</div>
<nav class="nav-list">
<router-link
v-for="item in navItems"
:key="item.name"
:to="{ name: item.name }"
class="nav-link"
active-class="is-active"
>
<component :is="item.icon" class="nav-icon" />
<span>{{ item.label }}</span>
</router-link>
</nav>
<div class="sidebar-note">
<p class="eyebrow">Operating mode</p>
<h3>Zero-trust token perimeter</h3>
<p class="muted">
Every API key is pinned to the first observed client address or CIDR and inspected at the edge.
</p>
</div>
<div class="rail-grid">
<div class="rail-card">
<span class="rail-label">Surface</span>
<strong>Admin UI</strong>
<span class="rail-meta">JWT protected</span>
</div>
<div class="rail-card">
<span class="rail-label">Proxy</span>
<strong>Streaming</strong>
<span class="rail-meta">SSE passthrough</span>
</div>
</div>
</aside>
<main class="shell-main">
<header class="shell-header panel">
<div class="header-copy">
<p class="eyebrow">{{ currentSection }}</p>
<h2 class="page-title">{{ route.meta.title || 'Sentinel' }}</h2>
<p class="muted header-note">Edge policy, runtime settings, and operator visibility in one secure surface.</p>
</div>
<div class="header-actions">
<div class="header-chip-group">
<div class="header-chip">
<span class="header-chip-label">Mode</span>
<strong>Secure Proxy</strong>
</div>
<div class="header-chip">
<span class="header-chip-label">Updated</span>
<strong>{{ clockLabel }}</strong>
</div>
</div>
<el-button type="primary" plain @click="logout">Logout</el-button>
</div>
</header>
<section class="shell-content">
<router-view />
</section>
</main>
</div>
</template>
<style scoped>
.shell {
position: relative;
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 24px;
min-height: 100vh;
padding: 24px;
}
.shell-sidebar,
.shell-header {
position: relative;
z-index: 1;
}
.shell-sidebar {
display: flex;
flex-direction: column;
gap: 28px;
padding: 28px;
}
.brand-block {
display: flex;
align-items: center;
gap: 16px;
}
.brand-mark {
display: grid;
place-items: center;
width: 56px;
height: 56px;
border-radius: 18px;
background: linear-gradient(135deg, rgba(17, 231, 181, 0.95), rgba(21, 132, 214, 0.95));
color: #071016;
font-size: 1.45rem;
font-weight: 800;
}
.brand-title,
.page-title {
margin: 0;
font-size: clamp(1.5rem, 2vw, 2.1rem);
}
.nav-list {
display: grid;
gap: 10px;
}
.nav-link {
display: flex;
align-items: center;
gap: 12px;
padding: 14px 16px;
border-radius: 18px;
color: var(--sentinel-ink-soft);
text-decoration: none;
transition: transform 160ms ease, background 160ms ease, color 160ms ease, box-shadow 160ms ease;
}
.nav-link:hover,
.nav-link.is-active {
color: var(--sentinel-ink);
background: rgba(7, 176, 147, 0.14);
box-shadow: inset 0 0 0 1px rgba(7, 176, 147, 0.18);
transform: translateX(4px);
}
.nav-icon {
width: 18px;
}
.sidebar-note {
margin-top: auto;
padding: 18px;
border-radius: 22px;
background: linear-gradient(180deg, rgba(8, 31, 45, 0.92), rgba(10, 26, 35, 0.8));
color: #f3fffd;
}
.sidebar-note h3 {
margin: 10px 0;
font-size: 1.15rem;
}
.shell-main {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 24px;
}
.shell-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 22px 26px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.shell-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.shell-glow {
position: fixed;
inset: auto;
z-index: 0;
border-radius: 999px;
filter: blur(90px);
opacity: 0.7;
pointer-events: none;
}
.shell-glow--mint {
top: 80px;
right: 160px;
width: 240px;
height: 240px;
background: rgba(17, 231, 181, 0.22);
}
.shell-glow--amber {
bottom: 100px;
left: 420px;
width: 280px;
height: 280px;
background: rgba(255, 170, 76, 0.18);
}
@media (max-width: 1080px) {
.shell {
grid-template-columns: 1fr;
}
.shell-sidebar {
order: 2;
}
.shell-main {
order: 1;
}
}
@media (max-width: 720px) {
.shell {
padding: 16px;
}
.shell-header {
flex-direction: column;
align-items: flex-start;
}
}
</style>

103
frontend/src/api/index.js Normal file
View File

@@ -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
}

View File

@@ -0,0 +1,28 @@
<script setup>
defineProps({
eyebrow: {
type: String,
required: true,
},
value: {
type: String,
required: true,
},
note: {
type: String,
required: true,
},
accent: {
type: String,
default: 'mint',
},
})
</script>
<template>
<article class="metric-card metric-card--enhanced panel" :data-accent="accent">
<p class="eyebrow">{{ eyebrow }}</p>
<div class="metric-value">{{ value }}</div>
<p class="metric-footnote">{{ note }}</p>
</article>
</template>

View File

@@ -0,0 +1,35 @@
<script setup>
defineProps({
eyebrow: {
type: String,
required: true,
},
title: {
type: String,
required: true,
},
description: {
type: String,
required: true,
},
})
</script>
<template>
<section class="hero-panel hero-layout panel">
<div class="hero-copy">
<p class="eyebrow">{{ eyebrow }}</p>
<h3>{{ title }}</h3>
<p class="muted hero-description">{{ description }}</p>
</div>
<div v-if="$slots.aside || $slots.actions" class="hero-side">
<div v-if="$slots.aside" class="hero-aside">
<slot name="aside" />
</div>
<div v-if="$slots.actions" class="hero-actions">
<slot name="actions" />
</div>
</div>
</section>
</template>

View File

@@ -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,
}
}

18
frontend/src/main.js Normal file
View File

@@ -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')

View File

@@ -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

477
frontend/src/styles.css Normal file
View File

@@ -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;
}
}

View File

@@ -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
}

View File

@@ -0,0 +1,266 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
import {
banBinding,
fetchBindings,
humanizeError,
unbanBinding,
unbindBinding,
updateBindingIp,
} from '../api'
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
const loading = ref(false)
const dialogVisible = ref(false)
const rows = ref([])
const total = ref(0)
const filters = reactive({
token_suffix: '',
ip: '',
status: '',
page: 1,
page_size: 20,
})
const form = reactive({
id: null,
bound_ip: '',
})
const activeCount = computed(() => rows.value.filter((item) => item.status === 1).length)
const bannedCount = computed(() => rows.value.filter((item) => item.status === 2).length)
const visibleProtectedRate = computed(() => {
if (!rows.value.length) {
return 0
}
return activeCount.value / rows.value.length
})
function requestParams() {
return {
page: filters.page,
page_size: filters.page_size,
token_suffix: filters.token_suffix || undefined,
ip: filters.ip || undefined,
status: filters.status || undefined,
}
}
async function loadBindings() {
loading.value = true
try {
const data = await fetchBindings(requestParams())
rows.value = data.items
total.value = data.total
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to load bindings.'))
} finally {
loading.value = false
}
}
function resetFilters() {
filters.token_suffix = ''
filters.ip = ''
filters.status = ''
filters.page = 1
loadBindings()
}
function openEdit(row) {
form.id = row.id
form.bound_ip = row.bound_ip
dialogVisible.value = true
}
async function submitEdit() {
if (!form.bound_ip) {
ElMessage.warning('Provide a CIDR or single IP.')
return
}
try {
await updateBindingIp({ id: form.id, bound_ip: form.bound_ip })
ElMessage.success('Binding updated.')
dialogVisible.value = false
await loadBindings()
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to update binding.'))
}
}
async function confirmAction(title, action) {
try {
await ElMessageBox.confirm(title, 'Confirm action', {
confirmButtonText: 'Confirm',
cancelButtonText: 'Cancel',
type: 'warning',
})
await action()
await loadBindings()
} catch (error) {
if (error === 'cancel') {
return
}
ElMessage.error(humanizeError(error, 'Operation failed.'))
}
}
function onPageChange(value) {
filters.page = value
loadBindings()
}
onMounted(() => {
loadBindings()
})
</script>
<template>
<div class="page-grid">
<PageHero
eyebrow="Binding control"
title="Inspect first-use bindings and intervene without touching proxy workers"
description="Edit CIDRs for device changes, remove stale registrations, or move leaked keys into a banned state."
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Visible active share</span>
<strong>{{ formatPercent(visibleProtectedRate) }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Page volume</span>
<strong>{{ formatCompactNumber(rows.length) }}</strong>
</div>
</div>
</template>
</PageHero>
<section class="metric-grid">
<MetricTile
eyebrow="Visible rows"
:value="formatCompactNumber(rows.length)"
note="Records loaded on the current page."
accent="slate"
/>
<MetricTile
eyebrow="Matching total"
:value="formatCompactNumber(total)"
note="Bindings matching current filters."
accent="mint"
/>
<MetricTile
eyebrow="Active rows"
:value="formatCompactNumber(activeCount)"
note="Active items visible in the current slice."
accent="mint"
/>
<MetricTile
eyebrow="Banned rows"
:value="formatCompactNumber(bannedCount)"
note="Banned items currently visible in the table."
accent="amber"
/>
</section>
<section class="table-card panel">
<div class="toolbar">
<div class="toolbar-left">
<el-input v-model="filters.token_suffix" placeholder="Token suffix" clearable style="width: 180px;" />
<el-input v-model="filters.ip" placeholder="Bound IP or CIDR" clearable style="width: 220px;" />
<el-select v-model="filters.status" placeholder="Status" clearable style="width: 150px;">
<el-option label="Active" :value="1" />
<el-option label="Banned" :value="2" />
</el-select>
</div>
<div class="toolbar-right">
<el-button @click="resetFilters">Reset</el-button>
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadBindings()">Search</el-button>
</div>
</div>
<div class="data-table" style="margin-top: 20px;">
<el-table :data="rows" v-loading="loading">
<el-table-column prop="id" label="ID" width="90" />
<el-table-column prop="token_display" label="Token" min-width="170" />
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="180" />
<el-table-column label="Status" width="120">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'" round>
{{ row.status_label }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="first_used_at" label="First used" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.first_used_at) }}</template>
</el-table-column>
<el-table-column prop="last_used_at" label="Last used" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.last_used_at) }}</template>
</el-table-column>
<el-table-column label="Actions" min-width="280" fixed="right">
<template #default="{ row }">
<div class="toolbar-left">
<el-button size="small" @click="openEdit(row)">Edit IP</el-button>
<el-button
size="small"
type="danger"
plain
@click="confirmAction('Remove this binding and allow first-use bind again?', () => unbindBinding(row.id))"
>
Unbind
</el-button>
<el-button
v-if="row.status === 1"
size="small"
type="warning"
plain
@click="confirmAction('Ban this token?', () => banBinding(row.id))"
>
Ban
</el-button>
<el-button
v-else
size="small"
type="success"
plain
@click="confirmAction('Restore this token to active state?', () => unbanBinding(row.id))"
>
Unban
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</div>
<div class="toolbar" style="margin-top: 18px;">
<span class="muted">Page {{ filters.page }} of {{ Math.max(1, Math.ceil(total / filters.page_size)) }}</span>
<el-pagination
background
layout="prev, pager, next"
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="onPageChange"
/>
</div>
</section>
<el-dialog v-model="dialogVisible" title="Update bound CIDR" width="420px">
<el-form label-position="top">
<el-form-item label="CIDR or single IP">
<el-input v-model="form.bound_ip" placeholder="192.168.1.0/24" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">Cancel</el-button>
<el-button type="primary" @click="submitEdit">Save</el-button>
</template>
</el-dialog>
</div>
</template>

View File

@@ -0,0 +1,235 @@
<script setup>
import * as echarts from 'echarts'
import { ElMessage } from 'element-plus'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
import { fetchDashboard, humanizeError } from '../api'
import { usePolling } from '../composables/usePolling'
import { formatCompactNumber, formatDateTime, formatPercent } from '../utils/formatters'
const loading = ref(false)
const dashboard = ref({
today: { total: 0, allowed: 0, intercepted: 0 },
bindings: { active: 0, banned: 0 },
trend: [],
recent_intercepts: [],
})
const chartElement = ref(null)
let chart
const interceptRate = computed(() => {
const total = dashboard.value.today.total || 0
if (!total) {
return 0
}
return dashboard.value.today.intercepted / total
})
const bindingCoverage = computed(() => {
const active = dashboard.value.bindings.active || 0
const banned = dashboard.value.bindings.banned || 0
const total = active + banned
if (!total) {
return 0
}
return active / total
})
async function renderChart() {
await nextTick()
if (!chartElement.value) {
return
}
chart ||= echarts.init(chartElement.value)
chart.setOption({
animationDuration: 500,
color: ['#0b9e88', '#ef7f41'],
grid: {
left: 24,
right: 24,
top: 40,
bottom: 28,
containLabel: true,
},
legend: {
top: 0,
textStyle: {
color: '#516a75',
fontWeight: 600,
},
},
tooltip: {
trigger: 'axis',
backgroundColor: 'rgba(8, 24, 34, 0.9)',
borderWidth: 0,
textStyle: {
color: '#f7fffe',
},
},
xAxis: {
type: 'category',
boundaryGap: false,
data: dashboard.value.trend.map((item) => item.date.slice(5)),
axisLine: { lineStyle: { color: 'rgba(9, 22, 30, 0.18)' } },
axisLabel: { color: '#516a75', fontWeight: 600 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: 'rgba(9, 22, 30, 0.08)' } },
axisLabel: { color: '#516a75' },
},
series: [
{
name: 'Allowed',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { color: 'rgba(11, 158, 136, 0.14)' },
lineStyle: { width: 3 },
data: dashboard.value.trend.map((item) => item.allowed),
},
{
name: 'Intercepted',
type: 'line',
smooth: true,
showSymbol: false,
areaStyle: { color: 'rgba(239, 127, 65, 0.12)' },
lineStyle: { width: 3 },
data: dashboard.value.trend.map((item) => item.intercepted),
},
],
})
}
async function loadDashboard() {
loading.value = true
try {
dashboard.value = await fetchDashboard()
await renderChart()
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to load dashboard.'))
} finally {
loading.value = false
}
}
function resizeChart() {
chart?.resize()
}
const { start: startPolling, stop: stopPolling } = usePolling(loadDashboard, 30000)
onMounted(async () => {
await loadDashboard()
startPolling()
window.addEventListener('resize', resizeChart)
})
onBeforeUnmount(() => {
stopPolling()
window.removeEventListener('resize', resizeChart)
chart?.dispose()
})
</script>
<template>
<div class="page-grid">
<PageHero
eyebrow="Traffic pulse"
title="Edge decisions and security drift in one pass"
description="The dashboard combines live proxy metrics with persisted intercept records so security events remain visible even if Redis rolls over."
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Intercept rate</span>
<strong>{{ formatPercent(interceptRate) }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Active share</span>
<strong>{{ formatPercent(bindingCoverage) }}</strong>
</div>
</div>
</template>
<template #actions>
<el-button :loading="loading" type="primary" plain @click="loadDashboard">Refresh dashboard</el-button>
</template>
</PageHero>
<section class="metric-grid">
<MetricTile
eyebrow="Today"
:value="formatCompactNumber(dashboard.today.total)"
note="Total edge decisions recorded today."
accent="slate"
/>
<MetricTile
eyebrow="Allowed"
:value="formatCompactNumber(dashboard.today.allowed)"
note="Requests that passed binding enforcement."
accent="mint"
/>
<MetricTile
eyebrow="Intercepted"
:value="formatCompactNumber(dashboard.today.intercepted)"
note="Requests blocked for CIDR mismatch or banned keys."
accent="amber"
/>
<MetricTile
eyebrow="Bindings"
:value="formatCompactNumber(dashboard.bindings.active)"
:note="`Active bindings, with ${formatCompactNumber(dashboard.bindings.banned)} banned keys in reserve.`"
accent="slate"
/>
</section>
<section class="content-grid">
<article class="chart-card panel">
<div class="toolbar">
<div>
<p class="eyebrow">7-day trend</p>
<h3 class="section-title">Allowed vs intercepted flow</h3>
</div>
<div class="inline-meta">
<el-tag round effect="plain" type="success">30s auto refresh</el-tag>
<span class="muted">Redis metrics with PostgreSQL intercept backfill.</span>
</div>
</div>
<div ref="chartElement" class="chart-surface" />
</article>
<article class="table-card panel">
<div class="table-toolbar-block">
<p class="eyebrow">Recent blocks</p>
<h3 class="section-title">Latest intercepted requests</h3>
<p class="muted">Operators can triage repeated misuse and verify whether alert escalation has already fired.</p>
</div>
<div v-if="!dashboard.recent_intercepts.length" class="empty-state">No intercepts recorded yet.</div>
<div v-else class="table-stack" style="margin-top: 18px;">
<div
v-for="item in dashboard.recent_intercepts"
:key="item.id"
class="insight-card"
style="padding: 16px; border-radius: 20px;"
>
<div class="toolbar">
<strong>{{ item.token_display }}</strong>
<el-tag :type="item.alerted ? 'danger' : 'warning'" round>
{{ item.alerted ? 'Alerted' : 'Pending' }}
</el-tag>
</div>
<p class="insight-note">Bound CIDR: {{ item.bound_ip }}</p>
<p class="insight-note">Attempt IP: {{ item.attempt_ip }}</p>
<p class="insight-note">{{ formatDateTime(item.intercepted_at) }}</p>
</div>
</div>
</article>
</section>
</div>
</template>

View File

@@ -0,0 +1,88 @@
<script setup>
import { reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { humanizeError, login, setAuthToken } from '../api'
const router = useRouter()
const loading = ref(false)
const form = reactive({
password: '',
})
async function submit() {
if (!form.password) {
ElMessage.warning('Enter the admin password first.')
return
}
loading.value = true
try {
const data = await login(form.password)
setAuthToken(data.access_token)
ElMessage.success('Authentication complete.')
await router.push({ name: 'dashboard' })
} catch (error) {
ElMessage.error(humanizeError(error, 'Login failed.'))
} finally {
loading.value = false
}
}
</script>
<template>
<div class="login-shell">
<section class="login-stage panel">
<div>
<p class="eyebrow">Edge enforcement</p>
<h1>Key-IP Sentinel</h1>
<p class="login-copy">
Lock every model API key to its first trusted origin. Monitor drift, inspect misuse, and react from one
hardened control surface.
</p>
</div>
<div class="stack">
<div class="status-chip">
<el-icon><Lock /></el-icon>
Zero-trust binding perimeter
</div>
<div class="status-chip">
<el-icon><Connection /></el-icon>
Live downstream relay with SSE passthrough
</div>
</div>
</section>
<section class="login-card">
<div class="login-card-inner">
<p class="eyebrow">Admin access</p>
<h2 class="section-title">Secure operator login</h2>
<p class="muted">Use the runtime password from your deployment environment to obtain an 8-hour admin token.</p>
<el-form label-position="top" @submit.prevent="submit">
<el-form-item label="Admin password">
<el-input
v-model="form.password"
show-password
size="large"
autocomplete="current-password"
@keyup.enter="submit"
/>
</el-form-item>
<el-button type="primary" size="large" :loading="loading" class="w-full" @click="submit">
Enter control plane
</el-button>
</el-form>
</div>
</section>
</div>
</template>
<style scoped>
.w-full {
width: 100%;
}
</style>

183
frontend/src/views/Logs.vue Normal file
View File

@@ -0,0 +1,183 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
import { exportLogs, fetchLogs, humanizeError } from '../api'
import { downloadBlob, formatCompactNumber, formatDateTime } from '../utils/formatters'
const loading = ref(false)
const exporting = ref(false)
const rows = ref([])
const total = ref(0)
const filters = reactive({
token: '',
attempt_ip: '',
time_range: [],
page: 1,
page_size: 20,
})
const alertedCount = computed(() => rows.value.filter((item) => item.alerted).length)
const uniqueAttempts = computed(() => new Set(rows.value.map((item) => item.attempt_ip)).size)
function requestParams() {
return {
page: filters.page,
page_size: filters.page_size,
token: filters.token || undefined,
attempt_ip: filters.attempt_ip || undefined,
start_time: filters.time_range?.[0] || undefined,
end_time: filters.time_range?.[1] || undefined,
}
}
async function loadLogs() {
loading.value = true
try {
const data = await fetchLogs(requestParams())
rows.value = data.items
total.value = data.total
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to load logs.'))
} finally {
loading.value = false
}
}
async function handleExport() {
exporting.value = true
try {
const blob = await exportLogs({
token: filters.token || undefined,
attempt_ip: filters.attempt_ip || undefined,
start_time: filters.time_range?.[0] || undefined,
end_time: filters.time_range?.[1] || undefined,
})
downloadBlob(blob, 'sentinel-logs.csv')
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to export logs.'))
} finally {
exporting.value = false
}
}
function resetFilters() {
filters.token = ''
filters.attempt_ip = ''
filters.time_range = []
filters.page = 1
loadLogs()
}
onMounted(() => {
loadLogs()
})
</script>
<template>
<div class="page-grid">
<PageHero
eyebrow="Audit trail"
title="Review blocked requests, escalation state, and repeated misuse patterns"
description="Intercept records stay in PostgreSQL even if Redis counters reset, so operators can reconstruct activity across the full retention window."
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Alerted on page</span>
<strong>{{ formatCompactNumber(alertedCount) }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Unique IPs</span>
<strong>{{ formatCompactNumber(uniqueAttempts) }}</strong>
</div>
</div>
</template>
<template #actions>
<el-button type="primary" plain :loading="exporting" @click="handleExport">Export CSV</el-button>
</template>
</PageHero>
<section class="metric-grid">
<MetricTile
eyebrow="Page rows"
:value="formatCompactNumber(rows.length)"
note="Intercept rows visible in the current result page."
accent="slate"
/>
<MetricTile
eyebrow="Matching total"
:value="formatCompactNumber(total)"
note="Persisted intercepts matching the active filters."
accent="amber"
/>
<MetricTile
eyebrow="Alerted"
:value="formatCompactNumber(alertedCount)"
note="Visible rows that already triggered webhook escalation."
accent="amber"
/>
<MetricTile
eyebrow="Attempt sources"
:value="formatCompactNumber(uniqueAttempts)"
note="Distinct attempt IPs visible on the current page."
accent="mint"
/>
</section>
<section class="table-card panel">
<div class="toolbar">
<div class="toolbar-left">
<el-input v-model="filters.token" placeholder="Masked token" clearable style="width: 180px;" />
<el-input v-model="filters.attempt_ip" placeholder="Attempt IP" clearable style="width: 180px;" />
<el-date-picker
v-model="filters.time_range"
type="datetimerange"
range-separator="to"
start-placeholder="Start time"
end-placeholder="End time"
value-format="YYYY-MM-DDTHH:mm:ssZ"
/>
</div>
<div class="toolbar-right">
<el-button @click="resetFilters">Reset</el-button>
<el-button type="primary" :loading="loading" @click="filters.page = 1; loadLogs()">Search</el-button>
</div>
</div>
<div class="data-table" style="margin-top: 20px;">
<el-table :data="rows" v-loading="loading">
<el-table-column prop="intercepted_at" label="Time" min-width="190">
<template #default="{ row }">{{ formatDateTime(row.intercepted_at) }}</template>
</el-table-column>
<el-table-column prop="token_display" label="Token" min-width="170" />
<el-table-column prop="bound_ip" label="Bound CIDR" min-width="170" />
<el-table-column prop="attempt_ip" label="Attempt IP" min-width="160" />
<el-table-column label="Alerted" width="120">
<template #default="{ row }">
<el-tag :type="row.alerted ? 'danger' : 'info'" round>
{{ row.alerted ? 'Yes' : 'No' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</div>
<div class="toolbar" style="margin-top: 18px;">
<span class="muted">Total matching logs: {{ total }}</span>
<el-pagination
background
layout="prev, pager, next"
:current-page="filters.page"
:page-size="filters.page_size"
:total="total"
@current-change="(value) => { filters.page = value; loadLogs() }"
/>
</div>
</section>
</div>
</template>

View File

@@ -0,0 +1,169 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus'
import MetricTile from '../components/MetricTile.vue'
import PageHero from '../components/PageHero.vue'
import { fetchSettings, humanizeError, updateSettings } from '../api'
import { formatCompactNumber } from '../utils/formatters'
const loading = ref(false)
const saving = ref(false)
const form = reactive({
alert_webhook_url: '',
alert_threshold_count: 5,
alert_threshold_seconds: 300,
archive_days: 90,
failsafe_mode: 'closed',
})
const thresholdMinutes = computed(() => Math.round(form.alert_threshold_seconds / 60))
const webhookState = computed(() => (form.alert_webhook_url ? 'Configured' : 'Disabled'))
async function loadSettings() {
loading.value = true
try {
const data = await fetchSettings()
form.alert_webhook_url = data.alert_webhook_url || ''
form.alert_threshold_count = data.alert_threshold_count
form.alert_threshold_seconds = data.alert_threshold_seconds
form.archive_days = data.archive_days
form.failsafe_mode = data.failsafe_mode
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to load runtime settings.'))
} finally {
loading.value = false
}
}
async function saveSettings() {
saving.value = true
try {
await updateSettings({
alert_webhook_url: form.alert_webhook_url || null,
alert_threshold_count: form.alert_threshold_count,
alert_threshold_seconds: form.alert_threshold_seconds,
archive_days: form.archive_days,
failsafe_mode: form.failsafe_mode,
})
ElMessage.success('Runtime settings updated.')
} catch (error) {
ElMessage.error(humanizeError(error, 'Failed to update runtime settings.'))
} finally {
saving.value = false
}
}
onMounted(() => {
loadSettings()
})
</script>
<template>
<div class="page-grid">
<PageHero
eyebrow="Runtime controls"
title="Adjust alerting and retention without redeploying the app"
description="These values are persisted in Redis and applied live by the proxy, alerting, and archive scheduler services."
>
<template #aside>
<div class="hero-stat-pair">
<div class="hero-stat">
<span class="eyebrow">Failsafe</span>
<strong>{{ form.failsafe_mode }}</strong>
</div>
<div class="hero-stat">
<span class="eyebrow">Webhook</span>
<strong>{{ webhookState }}</strong>
</div>
</div>
</template>
<template #actions>
<el-button type="primary" :loading="saving" @click="saveSettings">Apply runtime settings</el-button>
</template>
</PageHero>
<section class="metric-grid">
<MetricTile
eyebrow="Threshold count"
:value="formatCompactNumber(form.alert_threshold_count)"
note="Intercepts needed before alert escalation fires."
accent="amber"
/>
<MetricTile
eyebrow="Threshold window"
:value="`${formatCompactNumber(thresholdMinutes)}m`"
note="Rolling window used by the Redis alert counter."
accent="slate"
/>
<MetricTile
eyebrow="Archive after"
:value="`${formatCompactNumber(form.archive_days)}d`"
note="Bindings older than this are pruned from the active table."
accent="mint"
/>
<MetricTile
eyebrow="Delivery"
:value="webhookState"
note="Webhook POST is optional and can be disabled."
accent="slate"
/>
</section>
<section class="content-grid">
<article class="form-card panel">
<p class="eyebrow">Alert window</p>
<h3 class="section-title">Thresholds and webhook delivery</h3>
<el-form label-position="top" v-loading="loading">
<el-form-item label="Webhook URL">
<el-input v-model="form.alert_webhook_url" placeholder="https://hooks.example.internal/sentinel" />
</el-form-item>
<el-form-item label="Intercept count threshold">
<el-input-number v-model="form.alert_threshold_count" :min="1" :max="100" />
</el-form-item>
<el-form-item label="Threshold window (seconds)">
<el-slider v-model="form.alert_threshold_seconds" :min="60" :max="3600" :step="30" show-input />
</el-form-item>
<el-form-item label="Failsafe mode">
<el-radio-group v-model="form.failsafe_mode">
<el-radio-button value="closed">Closed</el-radio-button>
<el-radio-button value="open">Open</el-radio-button>
</el-radio-group>
</el-form-item>
</el-form>
</article>
<article class="form-card panel">
<p class="eyebrow">Retention</p>
<h3 class="section-title">Archive stale bindings</h3>
<el-form label-position="top" v-loading="loading">
<el-form-item label="Archive inactive bindings after N days">
<el-slider v-model="form.archive_days" :min="7" :max="365" :step="1" show-input />
</el-form-item>
</el-form>
<div class="stack">
<div class="panel" style="padding: 16px; border-radius: 20px;">
<p class="eyebrow">Closed mode</p>
<p class="muted" style="margin: 10px 0 0;">
Reject traffic if both Redis and PostgreSQL are unavailable. Use this for security-first deployments.
</p>
</div>
<div class="panel" style="padding: 16px; border-radius: 20px;">
<p class="eyebrow">Open mode</p>
<p class="muted" style="margin: 10px 0 0;">
Allow requests to keep business traffic flowing when the full binding backend is unavailable.
</p>
</div>
</div>
</article>
</section>
</div>
</template>

21
frontend/vite.config.js Normal file
View File

@@ -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,
},
},
},
})

6
main.py Normal file
View File

@@ -0,0 +1,6 @@
def main():
print("Hello from sentinel!")
if __name__ == "__main__":
main()

90
nginx/nginx.conf Normal file
View File

@@ -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 "";
}
}
}

20
pyproject.toml Normal file
View File

@@ -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 = []

9
requirements.txt Normal file
View File

@@ -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

751
uv.lock generated Normal file
View File

@@ -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" },
]