Add opt-in OIDC sign-in with session cookie and roles

Auth is enabled only when issuer, client id and client secret are all set;
otherwise the dashboard stays an open LAN dashboard. Provider endpoints are
discovered from <issuer>/.well-known/openid-configuration via Authlib, so no
provider-specific code is needed.

- auth.py: /auth/login, /auth/authorize, /auth/callback, /auth/logout (RP-initiated
  logout when the issuer advertises end_session_endpoint)
- users.py: atomic, lock-guarded data/users.json keyed by the OIDC sub
- role resolution: admin claim wins, else first user bootstraps as admin, else viewer
- api.py/app.py: session middleware and require_login/require_admin gating on every
  route except /health, /auth/* and /static
- config.py/main.py: UWUDASH_OIDC_* / UWUDASH_SESSION_* env vars plus --oidc-issuer
  and --public-url flags
- templates/login.html, static app.js/styles.css: sign-in page and settings UI
This commit is contained in:
Archibold committed 2026-10-31 10:36:39 +11:00
1 parent 2e78143812
commit 3187b8cf05
15 files changed
+1070 -41

No files matched your search

+103 -9
View File
@@ -31,6 +31,8 @@ uv run uwudash-mk3 --reload
| `--port` | `8000` | bind port |
| `--data-dir` | `data` | directory holding `services.json` |
| `--services-file` | `<data-dir>/services.json` | explicit store path |
| `--oidc-issuer` | unset | OIDC issuer; enables sign-in together with the client env vars |
| `--public-url` | request origin | external base URL used for the OIDC `redirect_uri` |
| `--reload` | off | uvicorn reload on source changes |
### Environment variables
@@ -43,9 +45,76 @@ uv run uwudash-mk3 --reload
| `UWUDASH_SERVICES_FILE` | `data/services.json` | services store path |
| `UWUDASH_PROXY_TIMEOUT` | `30.0` | upstream request timeout (seconds) |
| `UWUDASH_STRIP_FRAME_OPTIONS` | `true` | strip `X-Frame-Options` and CSP `frame-ancestors` from proxied responses |
| `UWUDASH_OIDC_ISSUER` | unset | OIDC issuer, e.g. `https://id.example.com` (auth stays off unless issuer, client id and secret are all set) |
| `UWUDASH_OIDC_CLIENT_ID` | unset | OIDC client id |
| `UWUDASH_OIDC_CLIENT_SECRET` | unset | OIDC client secret |
| `UWUDASH_OIDC_SCOPE` | `openid email profile` | requested scope |
| `UWUDASH_OIDC_ADMIN_CLAIM` | unset | claim to inspect for admin group membership, e.g. `groups` |
| `UWUDASH_OIDC_ADMIN_VALUES` | `admin` | comma-separated claim values that grant `admin` |
| `UWUDASH_SESSION_SECRET` | random per start | signed-session cookie secret (set it, or sessions die on restart) |
| `UWUDASH_SESSION_TTL` | `3600` | session lifetime in seconds |
| `UWUDASH_USERS_FILE` | `<data-dir>/users.json` | user store path |
| `UWUDASH_PUBLIC_URL` | request origin | external base URL for the OIDC `redirect_uri` |
CLI flags override environment variables.
## Authentication (opt-in)
With no issuer set the dashboard is open, exactly as before: no `/auth/*` routes
are registered and every API route is reachable from the LAN.
Setting `UWUDASH_OIDC_ISSUER` + `UWUDASH_OIDC_CLIENT_ID` + `UWUDASH_OIDC_CLIENT_SECRET`
turns sign-in on. The provider is discovered from
`<issuer>/.well-known/openid-configuration` (Authlib reads the authorize, token,
JWKS and `end_session_endpoint` URLs), so nothing here is provider-specific -
Keycloak, Authentik, Auth0 and the like all work.
```bash
export UWUDASH_OIDC_ISSUER=https://id.example.com
export UWUDASH_OIDC_CLIENT_ID=uwudash
export UWUDASH_OIDC_CLIENT_SECRET=...
export UWUDASH_OIDC_ADMIN_CLAIM=groups
export UWUDASH_OIDC_ADMIN_VALUES=dash-admin
export UWUDASH_SESSION_SECRET=$(openssl rand -hex 32)
uv run uwudash-mk3 --public-url https://dash.example.com
```
Flow: `/` with no session redirects to `/auth/login` → `Sign in` →
`/auth/authorize` → the provider → `/auth/callback` validates the `id_token`
against the issuer's JWKS → a signed `session` cookie (`HttpOnly`, `SameSite=lax`,
`max_age=UWUDASH_SESSION_TTL`) holds the user id → `/`.
Once auth is on, every route except `/health`, `/auth/*`, `/static` and FastAPI's
own `/docs` / `/openapi.json` requires a session: unauthenticated requests get
`401` (API) or a redirect to `/auth/login` (the HTML page).
### Roles
| Role | Can |
| --- | --- |
| `admin` | everything a viewer can, plus create/update/delete services and list or change user roles |
| `viewer` | read the service list, open services through the proxy, see their own profile |
The role is decided at **first** login and never changed by logging in again -
promotions and demotions go through `PATCH /api/users/{id}` (or the Settings page):
1. if `UWUDASH_OIDC_ADMIN_CLAIM` is set and the token's claim (scalar or list)
contains one of `UWUDASH_OIDC_ADMIN_VALUES` → `admin`;
2. otherwise, if the user store is empty → `admin` (the first person to log in
bootstraps the dashboard);
3. otherwise → `viewer`.
Users are stored in `data/users.json` (same atomic-write format as
`data/services.json`), keyed by the OIDC `sub`; `email` and `name` are refreshed
on each login.
### Signing out
`/auth/logout` (GET or POST) always clears the local session. If the issuer
advertises an `end_session_endpoint`, it then does RP-initiated logout with the
saved `id_token` and returns to `/auth/login` afterwards; otherwise it returns to
`/auth/login` directly.
## Services
Services live in `data/services.json`:
@@ -88,14 +157,23 @@ Only the upstream **origin** (`scheme://host:port`) is mapped onto the dashboard
So a service with `url = http://internal-host:8080/sub/page` is served at
`/proxy/<id>/sub/page`, and any link the upstream page emits to
`http://internal-host:8080/...` or `//internal-host:8080/...` is rewritten to
`/proxy/<id>/...`. Relative links need no rewriting.
`/proxy/<id>/...`. Root-relative links (`/foo/bar`) are pulled back onto the
proxy prefix too - they would otherwise resolve against the dashboard and 404.
Links relative to the current page (`foo/bar`) need no rewriting.
Details:
- `Location` headers on `3xx` responses are rewritten back onto the proxy prefix;
redirects are **not** followed, so the browser stays on the proxy.
- `text/html` responses are buffered and rewritten; everything else (JSON, CSS,
images, downloads) is streamed through unchanged.
- `text/html` responses are buffered and rewritten. `application/json` is
rewritten as well, but only values that start with the service's own base path:
single-page apps read their base path from an API config (Sonarr's
`initialize.json` returns `urlBase`/`apiRoot`), while plain data fields such as
filesystem paths are left alone. Everything else (CSS, images, downloads) is
streamed through unchanged.
- Upstream compression (`gzip`, `deflate`, `br`) is decoded by httpx, so
`Content-Encoding` and `Content-Length` are never forwarded - the body the
browser receives is always uncompressed and header-consistent.
- `X-Frame-Options` and CSP `frame-ancestors` are stripped from proxied responses
so the iframe can render (disable with `UWUDASH_STRIP_FRAME_OPTIONS=false`).
- Hop-by-hop request/response headers are dropped; per-service `headers` are merged
@@ -113,7 +191,14 @@ Details:
| `PATCH` / `PUT` | `/api/services/{id}` | update |
| `DELETE` | `/api/services/{id}` | delete |
| `GET` | `/api/services/{id}/health` | upstream reachability check |
| `GET` | `/api/config` | effective config |
| `GET` | `/api/config` | effective config (never includes the OIDC secret) |
| `GET` | `/api/me` | current user (only when auth is enabled) |
| `GET` | `/api/users` | list users - admin only |
| `PATCH` | `/api/users/{id}` | change a role - admin only |
| `GET` | `/auth/login` | sign-in page |
| `GET` | `/auth/authorize` | start the OIDC flow |
| `GET` | `/auth/callback` | provider redirect target |
| `GET` / `POST` | `/auth/logout` | clear the session, then RP-initiated logout |
| `GET` | `/health` | dashboard health |
| `ANY` | `/proxy/{id}` and `/proxy/{id}/{path}` | reverse proxy |
@@ -122,19 +207,28 @@ Details:
```
src/uwudash_mk3/
config.py env-driven Config dataclass
models.py Service / ServiceInput / ServicePatch + URL helpers
models.py Service / ServiceInput / ServicePatch / User / UserPatch + URL helpers
store.py JSON file store (atomic writes, lock)
users.py JSON user store (same pattern as store.py)
auth.py OIDC client, login/callback/logout, role dependencies
proxy.py reverse proxy: header filtering, HTML/redirect rewriting
api.py /api routes
app.py FastAPI factory, static mount, proxy routes
app.py FastAPI factory, session middleware, static mount, proxy routes
main.py CLI entrypoint
templates/ index.html
templates/ index.html, login.html
static/ styles.css, app.js
```
## Limitations
- No authentication on the dashboard itself - it is meant for a trusted LAN.
- No WebSocket proxying.
- Authentication is opt-in OIDC (see above). Without an issuer the dashboard is
still open by design - it is meant for a trusted LAN. Sign-in gates the
dashboard, not the upstream services: the proxy does not forward the dashboard
session, so each service still needs its own login or a per-service `headers`
token.
- Roles are decided at first login and only change through the admin API; there
is no group sync after that, and no per-service permissions.
- No WebSocket proxying. Apps that use WebSockets for live updates (Sonarr's
SignalR) fall back to SSE/long-polling through the proxy.
- Upstream cookies work, but they are scoped to the dashboard origin, so
cross-service cookie sharing is possible if two services share a proxy host.
+2
View File
@@ -5,8 +5,10 @@ description = "Add your description here"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"Authlib>=1.9.1",
"fastapi>=0.162.0",
"httpx>=0.28.1",
"itsdangerous>=2.2",
"jinja2>=3.1.6",
"uvicorn[standard]>=0.54.0",
]
+63 -10
View File
@@ -1,19 +1,35 @@
"""JSON API used by the dashboard UI to manage services."""
"""JSON API used by the dashboard UI to manage services and users."""
from __future__ import annotations
from typing import List
from typing import List, Optional
from fastapi import APIRouter, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from .config import Config
from .models import Service, ServiceInput, ServicePatch
from .models import Service, ServiceInput, ServicePatch, User, UserPatch
from .proxy import Proxy
from .store import ServiceStore
from .users import UserStore
def create_api_router(store: ServiceStore, proxy: Proxy, config: Config) -> APIRouter:
router = APIRouter(prefix="/api")
def create_api_router(
store: ServiceStore,
proxy: Proxy,
config: Config,
users: Optional[UserStore] = None,
require_login=None,
require_admin=None,
) -> APIRouter:
"""Build the ``/api`` router.
``users``/``require_login``/``require_admin`` are only supplied when OIDC
is configured; without them the router is exactly the unauthenticated one.
"""
router = APIRouter(
prefix="/api",
dependencies=[Depends(require_login)] if require_login else [],
)
@router.get("/config")
def get_config() -> dict:
@@ -23,6 +39,7 @@ def create_api_router(store: ServiceStore, proxy: Proxy, config: Config) -> APIR
"services_file": str(config.services_file),
"strip_frame_options": config.strip_frame_options,
"proxy_timeout": config.proxy_timeout,
"auth_enabled": config.auth_enabled,
}
@router.get("/services")
@@ -36,18 +53,28 @@ def create_api_router(store: ServiceStore, proxy: Proxy, config: Config) -> APIR
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
return service
@router.post("/services", status_code=201)
@router.post(
"/services",
status_code=201,
dependencies=[Depends(require_admin)] if require_admin else [],
)
def create_service(payload: ServiceInput) -> Service:
return store.create(payload)
@router.patch("/services/{service_id}")
@router.patch(
"/services/{service_id}",
dependencies=[Depends(require_admin)] if require_admin else [],
)
def update_service(service_id: str, payload: ServicePatch) -> Service:
service = store.update(service_id, payload)
if service is None:
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
return service
@router.put("/services/{service_id}")
@router.put(
"/services/{service_id}",
dependencies=[Depends(require_admin)] if require_admin else [],
)
def replace_service(service_id: str, payload: ServiceInput) -> Service:
patch = ServicePatch(**payload.model_dump())
service = store.update(service_id, patch)
@@ -55,7 +82,11 @@ def create_api_router(store: ServiceStore, proxy: Proxy, config: Config) -> APIR
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
return service
@router.delete("/services/{service_id}", status_code=204)
@router.delete(
"/services/{service_id}",
status_code=204,
dependencies=[Depends(require_admin)] if require_admin else [],
)
def delete_service(service_id: str) -> None:
if not store.delete(service_id):
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
@@ -67,4 +98,26 @@ def create_api_router(store: ServiceStore, proxy: Proxy, config: Config) -> APIR
raise HTTPException(status_code=404, detail=f"unknown service: {service_id}")
return await proxy.check(service)
if users is not None:
@router.get("/me")
def me(user: User = Depends(require_login)) -> User:
return user
@router.get(
"/users",
dependencies=[Depends(require_admin)] if require_admin else [],
)
def list_users() -> List[User]:
return users.list()
@router.patch(
"/users/{user_id}",
dependencies=[Depends(require_admin)] if require_admin else [],
)
def update_user(user_id: str, payload: UserPatch) -> User:
user = users.set_role(user_id, payload.role)
if user is None:
raise HTTPException(status_code=404, detail=f"unknown user: {user_id}")
return user
return router
+79 -13
View File
@@ -1,55 +1,121 @@
"""FastAPI application factory: static frontend + API + reverse proxy."""
"""FastAPI application factory.
The app is a single process that serves the dashboard UI, a JSON API for
managing services, and a same-origin proxy that lets the UI embed internal
services that would otherwise refuse to be framed.
Authentication is opt-in: with an OIDC issuer, client id and client secret the
dashboard requires a signed session cookie on every route except ``/health``,
``/auth/*`` and ``/static``. Without them it behaves exactly as before - an
open LAN dashboard.
"""
from __future__ import annotations
import secrets
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from starlette.middleware.sessions import SessionMiddleware
from .api import create_api_router
from .auth import auth_dependencies, create_auth_router, create_oauth, get_current_user
from .config import Config
from .proxy import Proxy
from .store import ServiceStore
from .users import UserStore
PACKAGE_DIR = Path(__file__).parent
PACKAGE_DIR = Path(__file__).resolve().parent
ALL_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]
def create_app(config: Optional[Config] = None) -> FastAPI:
def create_app(config: Config | None = None) -> FastAPI:
config = config or Config.from_env()
store = ServiceStore(config.services_file)
proxy = Proxy(store, config)
users = UserStore(config.users_file) if config.auth_enabled else None
oauth = create_oauth(config) if config.auth_enabled else None
@asynccontextmanager
async def lifespan(app: FastAPI):
if config.auth_enabled and not config.session_secret:
print(
"warning: UWUDASH_SESSION_SECRET is not set - a random secret is "
"generated at startup, so sessions do not survive a restart."
)
yield
proxy.close()
await proxy.close()
app = FastAPI(
title="UwuDash MK3",
description="Self-hosted internal service dashboard.",
version="0.1.0",
lifespan=lifespan,
)
if config.auth_enabled:
# httponly is always set by SessionMiddleware; the cookie is signed with
# itsdangerous and expires after UWUDASH_SESSION_TTL.
app.add_middleware(
SessionMiddleware,
secret_key=config.session_secret or secrets.token_hex(32),
session_cookie="session",
max_age=config.session_ttl_seconds,
same_site="lax",
)
app = FastAPI(title="UwuDash MK3", version="0.1.0", lifespan=lifespan)
app.state.config = config
app.state.store = store
app.state.proxy = proxy
app.state.users = users
app.state.oauth = oauth
app.mount("/static", StaticFiles(directory=PACKAGE_DIR / "static"), name="static")
templates = Jinja2Templates(directory=PACKAGE_DIR / "templates")
app.include_router(create_api_router(store, proxy, config))
if config.auth_enabled:
require_login, require_admin = auth_dependencies(users)
app.include_router(create_auth_router(oauth, users, config, templates))
else:
require_login = require_admin = None
@app.get("/", response_class=None)
app.include_router(
create_api_router(store, proxy, config, users, require_login, require_admin)
)
@app.get("/")
def index(request: Request):
return templates.TemplateResponse(request, "index.html", {"services": store.list()})
if config.auth_enabled and get_current_user(request, users) is None:
return RedirectResponse("/auth/login")
return templates.TemplateResponse(
request, "index.html", {"services": store.list()}
)
@app.get("/health")
def health() -> dict:
return {"status": "ok", "services": len(store.list())}
# Reverse proxy: the only route that can reach an upstream service.
@app.api_route("/proxy/{service_id}", methods=ALL_METHODS)
@app.api_route("/proxy/{service_id}/{path:path}", methods=ALL_METHODS)
auth_gate = [Depends(require_login)] if require_login else []
# The proxy is a catch-all passthrough, not a documented API: keeping it out
# of the schema avoids a duplicate-operation-id warning per method.
@app.api_route(
"/proxy/{service_id}",
methods=ALL_METHODS,
dependencies=auth_gate,
include_in_schema=False,
)
@app.api_route(
"/proxy/{service_id}/{path:path}",
methods=ALL_METHODS,
dependencies=auth_gate,
include_in_schema=False,
)
async def proxy_route(service_id: str, request: Request, path: str = ""):
return await proxy.handle(service_id, path, request)
+157
View File
@@ -0,0 +1,157 @@
"""OIDC sign-in, session handling and role checks.
The provider is discovered from ``<issuer>/.well-known/openid-configuration``
(authlib reads ``authorize_url``, ``access_token_url``, ``jwks_uri`` and, when
present, ``end_session_endpoint``), so nothing here is provider-specific.
Identity lives in the signed session cookie installed by
``starlette.middleware.sessions.SessionMiddleware`` - there is no second cookie
and no hand-rolled token.
"""
from __future__ import annotations
from typing import Optional, Tuple
from urllib.parse import quote
from authlib.integrations.starlette_client import OAuth, OAuthError
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from fastapi.templating import Jinja2Templates
from .config import Config
from .models import User
from .users import UserStore
def create_oauth(config: Config) -> OAuth:
oauth = OAuth()
oauth.register(
name="oidc",
client_id=config.oidc_client_id,
client_secret=config.oidc_client_secret,
server_metadata_url=config.oidc_issuer + "/.well-known/openid-configuration",
client_kwargs={"scope": config.oidc_scope},
)
return oauth
def base_url(request: Request, config: Config) -> str:
"""External base URL of the dashboard (``--public-url`` wins)."""
return (config.public_url or str(request.base_url)).rstrip("/")
def get_current_user(request: Request, users: UserStore) -> Optional[User]:
user_id = request.session.get("user_id")
if not user_id:
return None
return users.get(user_id)
def auth_dependencies(users: UserStore) -> Tuple:
"""Return ``(require_login, require_admin)`` bound to ``users``."""
def require_login(request: Request) -> User:
user = get_current_user(request, users)
if user is None:
raise HTTPException(status_code=401, detail="not authenticated")
return user
def require_admin(request: Request) -> User:
user = require_login(request)
if user.role != "admin":
raise HTTPException(status_code=403, detail="admin role required")
return user
return require_login, require_admin
def resolve_role(claims: dict, config: Config, users: UserStore) -> str:
"""Decide the role for a freshly authenticated subject.
1. an explicit admin claim (``UWUDASH_OIDC_ADMIN_CLAIM``) wins;
2. otherwise the very first user to log in is bootstrapped as admin;
3. everyone else is a viewer.
"""
if config.oidc_admin_claim:
value = claims.get(config.oidc_admin_claim)
if value is not None:
values = value if isinstance(value, (list, tuple, set)) else [value]
if any(str(item) in config.oidc_admin_values for item in values):
return "admin"
if users.count() == 0:
return "admin"
return "viewer"
def create_auth_router(
oauth: OAuth, users: UserStore, config: Config, templates: Jinja2Templates
) -> APIRouter:
router = APIRouter()
@router.get("/auth/login")
def login_page(request: Request):
return templates.TemplateResponse(
request, "login.html", {"error": request.query_params.get("error")}
)
@router.get("/auth/authorize")
async def authorize(request: Request):
redirect_uri = base_url(request, config) + "/auth/callback"
return await oauth.oidc.authorize_redirect(request, redirect_uri)
@router.get("/auth/callback")
async def callback(request: Request):
try:
token = await oauth.oidc.authorize_access_token(request)
except OAuthError as exc:
# Redirect back to the login page so the failure is readable in the
# browser instead of a bare JSON 400.
return RedirectResponse(f"/auth/login?error={quote(str(exc))}")
claims = token.get("userinfo") or {}
subject = claims.get("sub") or token.get("sub")
if not subject:
raise HTTPException(
status_code=400,
detail="provider did not return a subject (sub) claim",
)
role = resolve_role(claims, config, users)
user = users.upsert(
subject=str(subject),
email=claims.get("email"),
name=claims.get("name") or claims.get("preferred_username"),
role=role,
)
request.session["user_id"] = user.id
# RP-initiated logout needs the id_token; only keep it when the issuer
# advertises an end_session_endpoint.
if token.get("id_token") and "end_session_endpoint" in (
oauth.oidc.server_metadata or {}
):
request.session["id_token"] = token["id_token"]
return RedirectResponse("/")
@router.get("/auth/logout", operation_id="auth_logout_get")
@router.post("/auth/logout", operation_id="auth_logout_post")
async def logout(request: Request):
# The local session is always dropped; the provider round-trip is only
# RP-initiated logout on top of that.
id_token = request.session.get("id_token")
request.session.clear()
if id_token:
try:
return await oauth.oidc.logout_redirect(
request,
post_logout_redirect_uri=base_url(request, config) + "/auth/login",
id_token_hint=id_token,
)
except RuntimeError:
# Issuer has no end_session_endpoint: fall back to local logout.
pass
return RedirectResponse("/auth/login")
return router
+54
View File
@@ -5,6 +5,14 @@ dashboard can be deployed without editing code:
UWUDASH_HOST, UWUDASH_PORT, UWUDASH_DATA_DIR, UWUDASH_SERVICES_FILE,
UWUDASH_PROXY_TIMEOUT, UWUDASH_STRIP_FRAME_OPTIONS
Authentication is opt-in: the OIDC routes and the session middleware only take
effect when an issuer, client id and client secret are all present.
UWUDASH_OIDC_ISSUER, UWUDASH_OIDC_CLIENT_ID, UWUDASH_OIDC_CLIENT_SECRET,
UWUDASH_OIDC_SCOPE, UWUDASH_OIDC_ADMIN_CLAIM, UWUDASH_OIDC_ADMIN_VALUES,
UWUDASH_SESSION_SECRET, UWUDASH_SESSION_TTL, UWUDASH_USERS_FILE,
UWUDASH_PUBLIC_URL
"""
from __future__ import annotations
@@ -12,11 +20,15 @@ from __future__ import annotations
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
DEFAULT_HOST = "127.0.0.1"
DEFAULT_PORT = 8000
DEFAULT_DATA_DIR = Path("data")
DEFAULT_PROXY_TIMEOUT = 30.0
DEFAULT_OIDC_SCOPE = "openid email profile"
DEFAULT_SESSION_TTL = 3600
DEFAULT_ADMIN_VALUES = ("admin",)
def _int(name: str, default: int) -> int:
@@ -46,6 +58,13 @@ def _bool(name: str, default: bool) -> bool:
return raw.strip().lower() in {"1", "true", "yes", "on"}
def _csv(name: str, default: tuple[str, ...]) -> tuple[str, ...]:
raw = os.environ.get(name)
if raw is None or raw == "":
return default
return tuple(part.strip() for part in raw.split(",") if part.strip())
@dataclass(frozen=True)
class Config:
host: str
@@ -56,6 +75,29 @@ class Config:
# Internal apps often send X-Frame-Options, which would block the iframe.
strip_frame_options: bool
# OIDC / session settings. All optional: without issuer + client id +
# secret the dashboard stays in unauthenticated LAN mode.
oidc_issuer: Optional[str] = None
oidc_client_id: Optional[str] = None
oidc_client_secret: Optional[str] = None
oidc_scope: str = DEFAULT_OIDC_SCOPE
oidc_admin_claim: Optional[str] = None
oidc_admin_values: tuple[str, ...] = DEFAULT_ADMIN_VALUES
session_secret: Optional[str] = None
session_ttl_seconds: int = DEFAULT_SESSION_TTL
users_file: Optional[Path] = None
public_url: Optional[str] = None
def __post_init__(self) -> None:
if self.users_file is None:
object.__setattr__(self, "users_file", self.data_dir / "users.json")
if self.oidc_issuer:
object.__setattr__(self, "oidc_issuer", self.oidc_issuer.rstrip("/"))
@property
def auth_enabled(self) -> bool:
return bool(self.oidc_issuer and self.oidc_client_id and self.oidc_client_secret)
@classmethod
def from_env(cls) -> "Config":
data_dir = Path(os.environ.get("UWUDASH_DATA_DIR", DEFAULT_DATA_DIR))
@@ -69,4 +111,16 @@ class Config:
services_file=services_file,
proxy_timeout=_float("UWUDASH_PROXY_TIMEOUT", DEFAULT_PROXY_TIMEOUT),
strip_frame_options=_bool("UWUDASH_STRIP_FRAME_OPTIONS", True),
oidc_issuer=os.environ.get("UWUDASH_OIDC_ISSUER") or None,
oidc_client_id=os.environ.get("UWUDASH_OIDC_CLIENT_ID") or None,
oidc_client_secret=os.environ.get("UWUDASH_OIDC_CLIENT_SECRET") or None,
oidc_scope=os.environ.get("UWUDASH_OIDC_SCOPE", DEFAULT_OIDC_SCOPE),
oidc_admin_claim=os.environ.get("UWUDASH_OIDC_ADMIN_CLAIM") or None,
oidc_admin_values=_csv("UWUDASH_OIDC_ADMIN_VALUES", DEFAULT_ADMIN_VALUES),
session_secret=os.environ.get("UWUDASH_SESSION_SECRET") or None,
session_ttl_seconds=_int("UWUDASH_SESSION_TTL", DEFAULT_SESSION_TTL),
users_file=Path(
os.environ.get("UWUDASH_USERS_FILE", data_dir / "users.json")
),
public_url=os.environ.get("UWUDASH_PUBLIC_URL") or None,
)
+19
View File
@@ -19,6 +19,14 @@ def parse_args(argv=None) -> argparse.Namespace:
parser.add_argument("--data-dir", help="directory holding services.json")
parser.add_argument("--services-file", help="path to the services JSON file")
parser.add_argument("--reload", action="store_true", help="restart on source changes")
parser.add_argument(
"--oidc-issuer",
help="OIDC issuer URL (client id/secret come from UWUDASH_OIDC_CLIENT_* env vars)",
)
parser.add_argument(
"--public-url",
help="External base URL of the dashboard, used for the OIDC redirect_uri",
)
return parser.parse_args(argv)
@@ -34,6 +42,10 @@ def build_config(args: argparse.Namespace) -> Config:
config = replace(config, services_file=Path(args.services_file))
elif args.data_dir:
config = replace(config, services_file=Path(args.data_dir) / "services.json")
if args.oidc_issuer:
config = replace(config, oidc_issuer=args.oidc_issuer)
if args.public_url:
config = replace(config, public_url=args.public_url)
return config
@@ -41,4 +53,11 @@ def main(argv=None) -> None:
args = parse_args(argv)
config = build_config(args)
print(f"UwuDash MK3 listening on http://{config.host}:{config.port}")
if config.auth_enabled:
print(f"OIDC sign-in enabled (issuer: {config.oidc_issuer})")
else:
print(
"Authentication disabled - set UWUDASH_OIDC_ISSUER, "
"UWUDASH_OIDC_CLIENT_ID and UWUDASH_OIDC_CLIENT_SECRET to enable it"
)
uvicorn.run(create_app(config), host=config.host, port=config.port, reload=args.reload)
+33
View File
@@ -117,3 +117,36 @@ class ServicePatch(BaseModel):
if value not in OPEN_MODES:
raise ValueError(f"open_in must be one of {OPEN_MODES}")
return value
ROLES = ("admin", "viewer")
class User(BaseModel):
"""A dashboard user, provisioned locally on first OIDC login."""
id: str
subject: str
email: Optional[str] = None
name: Optional[str] = None
role: str = "viewer"
created_at: str
last_login_at: str
@field_validator("role")
def _validate_role(cls, value: str) -> str:
if value not in ROLES:
raise ValueError(f"role must be one of {ROLES}")
return value
class UserPatch(BaseModel):
"""Role change payload for ``PATCH /api/users/{user_id}``."""
role: str
@field_validator("role")
def _validate_role(cls, value: str) -> str:
if value not in ROLES:
raise ValueError(f"role must be one of {ROLES}")
return value
+46 -7
View File
@@ -40,12 +40,26 @@ RESPONSE_BLOCKED = {
"transfer-encoding",
"upgrade",
"content-length", # recomputed by the response class
# httpx transparently decodes gzip/deflate/br before we see the body, so
# forwarding the upstream Content-Encoding would describe a body that is
# already decompressed (browsers then fail with a decoding error).
"content-encoding",
}
MAX_BODY_BYTES = 10 * 1024 * 1024
_FRAME_ANCESTORS = re.compile(r"frame-ancestors\s*=?[^;]*;?")
# Root-relative URLs in upstream HTML resolve against the *dashboard* origin,
# not the upstream host, so every quoted "/..." value (attributes and inline
# app config such as Sonarr's `urlBase: '/sonarr'`) has to be pulled back onto
# the proxy prefix or the framed app 404s its own assets.
#
# The value class excludes `<`, `>` and whitespace: without them a closing
# quote followed by a self-closing slash (`href="x"/><meta name="`) matches as
# a "path" and the rewrite splices the prefix into the markup.
_ROOT_RELATIVE = re.compile(r'(["\'])(/[^"\'<>\s]*)\1')
class Proxy:
def __init__(self, store: ServiceStore, config: Config) -> None:
@@ -57,8 +71,8 @@ class Proxy:
headers={"user-agent": "uwudash-mk3/0.1"},
)
def close(self) -> None:
self.client.close()
async def close(self) -> None:
await self.client.aclose()
# --------------------------------------------------------------- helpers
@staticmethod
@@ -109,12 +123,32 @@ class Proxy:
return prefix + location
return location
def _rewrite_html(self, text: str, service: Service) -> str:
def _rewrite_text(
self, text: str, service: Service, *, base_only: bool = False
) -> str:
"""Rewrite absolute origin URLs and root-relative ``"/..."`` values
onto the proxy prefix.
``base_only`` (used for JSON) limits the rewrite to values under the
service's own base path: JSON also carries plain data such as
filesystem paths, which must not be touched.
"""
prefix = self._prefix(service)
return text.replace(service.origin, prefix).replace(
base = f"/{service.base_path.strip('/')}"
text = text.replace(service.origin, prefix).replace(
f"//{self._netloc(service)}", prefix
)
def pull_back(match: re.Match) -> str:
quote, value = match.groups()
if value.startswith(prefix): # already rewritten by the origin pass
return match.group(0)
if base_only and not value.startswith(base):
return match.group(0)
return f"{quote}{prefix}{value}{quote}"
return _ROOT_RELATIVE.sub(pull_back, text)
# --------------------------------------------------------------- handler
async def handle(self, service_id: str, path: str, request: Request) -> Response:
service = self.store.get(service_id)
@@ -150,13 +184,18 @@ class Proxy:
content_type = upstream.headers.get("content-type", "")
headers = self._response_headers(upstream, service)
if content_type.startswith("text/html"):
html = content_type.startswith("text/html")
json_data = content_type.startswith("application/json")
if html or json_data:
await upstream.aread()
rewritten = self._rewrite_text(
upstream.text, service, base_only=not html
)
return Response(
content=self._rewrite_html(upstream.text, service),
content=rewritten,
status_code=upstream.status_code,
headers=headers,
media_type="text/html; charset=utf-8",
media_type=content_type,
)
async def stream():
+83 -2
View File
@@ -25,9 +25,13 @@ const el = {
enabled: document.getElementById("f-enabled"),
formCancel: document.getElementById("form-cancel"),
toast: document.getElementById("toast"),
userChip: document.getElementById("user-chip"),
userName: document.getElementById("user-name"),
userRole: document.getElementById("user-role"),
userTable: document.getElementById("user-table"),
};
const state = { services: [], selected: null, editing: null };
const state = { services: [], selected: null, editing: null, user: null };
/* ---------------------------------------------------------------- helpers */
const ESCAPES = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
@@ -124,6 +128,58 @@ function findService(id) {
return state.services.find((s) => s.id === id);
}
/* ---------------------------------------------------------------- auth */
async function loadUser() {
const response = await fetch("/api/me");
if (response.status === 401) {
window.location.href = "/auth/login";
return null;
}
if (!response.ok) return null; // auth disabled: /api/me is not mounted
return response.json();
}
function renderUser() {
const user = state.user;
if (!user) return;
el.userChip.hidden = false;
el.userName.textContent = user.name || user.email || user.subject;
el.userRole.textContent = user.role;
el.userRole.className = `role-badge ${user.role}`;
if (user.role !== "admin") el.settingsToggle.hidden = true;
}
async function renderUsers() {
if (!state.user || state.user.role !== "admin") return;
let users;
try {
users = await api("/api/users");
} catch (error) {
toast(error.message, "error");
return;
}
el.userTable.hidden = false;
el.userTable.innerHTML =
'<h2>Users</h2>' +
(users.length
? users
.map((user) => {
const label = esc(user.name || user.email || user.subject);
return (
`<div class="user-row">` +
`<div class="meta"><strong>${label}</strong>` +
`<code>${esc(user.subject)}</code></div>` +
`<select data-user-role="${esc(user.id)}">` +
`<option value="admin"${user.role === "admin" ? " selected" : ""}>admin</option>` +
`<option value="viewer"${user.role === "viewer" ? " selected" : ""}>viewer</option>` +
`</select>` +
`</div>`
);
})
.join("")
: '<p class="status">No users yet.</p>');
}
/* ---------------------------------------------------------------- actions */
function openService(service) {
state.selected = service.id;
@@ -160,6 +216,7 @@ function showSettings() {
el.viewer.hidden = true;
el.viewerFrame.src = "";
renderTable();
renderUsers();
}
function resetForm() {
@@ -269,4 +326,28 @@ el.reloadBtn.addEventListener("click", () => {
if (el.viewerFrame.src) el.viewerFrame.src = el.viewerFrame.src;
});
refresh().catch((error) => toast(error.message, "error"));
el.userTable.addEventListener("change", async (event) => {
const select = event.target.closest("select[data-user-role]");
if (!select) return;
try {
const updated = await api(`/api/users/${select.dataset.userRole}`, {
method: "PATCH",
body: JSON.stringify({ role: select.value }),
});
toast(`${updated.name || updated.subject} is now ${updated.role}`);
if (updated.id === state.user.id) {
state.user = updated;
renderUser();
}
} catch (error) {
toast(error.message, "error");
}
});
loadUser()
.then((user) => {
state.user = user;
renderUser();
return refresh();
})
.catch((error) => toast(error.message, "error"));
+63
View File
@@ -75,6 +75,28 @@ body {
.sidebar-foot { margin-top: 12px; }
.user-chip {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding: 8px 10px;
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 8px;
font-size: 13px;
}
.user-name { font-weight: 600; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.role-badge {
font-size: 11px;
padding: 3px 8px;
border-radius: 999px;
border: 1px solid var(--border);
color: var(--muted);
}
.role-badge.admin { color: var(--success); border-color: var(--success); }
.user-chip .btn { margin-left: auto; }
/* ------------------------------------------------------------------- main */
.main { flex: 1; display: flex; flex-direction: column; min-width: 0; }
@@ -156,6 +178,47 @@ body {
.status.ok { color: var(--success); }
.status.bad { color: var(--error); }
/* ------------------------------------------------------------------ auth */
.login {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 40px;
}
.login-card { max-width: 380px; display: flex; flex-direction: column; gap: 12px; }
.login-card p { margin: 0; color: var(--muted); font-size: 13px; }
.login-error {
color: var(--error);
border: 1px solid var(--error);
border-radius: 8px;
padding: 8px 10px;
font-size: 13px;
}
.user-table { display: flex; flex-direction: column; gap: 10px; }
.user-table h2 { margin: 0; font-size: 16px; }
.user-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 12px;
align-items: center;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 10px;
padding: 12px 14px;
}
.user-row .meta strong { display: block; }
.user-row .meta code { color: var(--muted); font-size: 12px; }
.user-row select {
font: inherit;
color: var(--fg);
background: var(--panel-2);
border: 1px solid var(--border);
border-radius: 6px;
padding: 6px 8px;
}
/* ------------------------------------------------------------------ btns */
.btn {
font: inherit;
+6
View File
@@ -16,6 +16,11 @@
<div class="service-list" id="service-list"></div>
<div class="sidebar-foot">
<div class="user-chip" id="user-chip" hidden>
<span class="user-name" id="user-name"></span>
<span class="role-badge" id="user-role"></span>
<a class="btn ghost" href="/auth/logout">Sign out</a>
</div>
<button class="btn ghost" id="settings-toggle">Settings</button>
</div>
</nav>
@@ -79,6 +84,7 @@
</form>
<div class="service-table" id="service-table"></div>
<div class="user-table" id="user-table" hidden></div>
</div>
</section>
</main>
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>UwuDash MK3 - sign in</title>
<link rel="stylesheet" href="/static/styles.css" />
</head>
<body>
<div class="login">
<div class="login-card card">
<div class="brand">
<span class="brand-name">UwuDash</span>
<span class="brand-sub">mk3</span>
</div>
<p>Sign in with your organisation account to open the dashboard.</p>
{% if error %}
<p class="login-error">{{ error }}</p>
{% endif %}
<a class="btn" href="/auth/authorize">Sign in</a>
</div>
</div>
</body>
</html>
+124
View File
@@ -0,0 +1,124 @@
"""Persistence for dashboard users.
Same shape as :mod:`store`: a plain JSON file, atomic writes (temp file +
``os.replace``) and a lock so concurrent logins cannot interleave. Users are
created on first OIDC login and refreshed on every subsequent one.
"""
from __future__ import annotations
import json
import os
import threading
from datetime import datetime, timezone
from pathlib import Path
from typing import List, Optional
from .models import User, slugify
def _now() -> str:
return datetime.now(timezone.utc).isoformat(timespec="seconds")
class UserStore:
def __init__(self, path: Path) -> None:
self.path = Path(path)
self._lock = threading.Lock()
self.path.parent.mkdir(parents=True, exist_ok=True)
if not self.path.exists():
self._write([])
# ------------------------------------------------------------------ read
def _read(self) -> List[User]:
try:
raw = json.loads(self.path.read_text(encoding="utf-8"))
except FileNotFoundError:
return []
except json.JSONDecodeError as exc: # pragma: no cover - corrupt file is loud
raise ValueError(f"{self.path} is not valid JSON: {exc}") from exc
items = raw.get("users", []) if isinstance(raw, dict) else raw
return [User.model_validate(item) for item in items]
def _write(self, users: List[User]) -> None:
payload = {"version": 1, "users": [u.model_dump() for u in users]}
tmp = self.path.with_suffix(".tmp")
tmp.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
os.replace(tmp, self.path)
def list(self) -> List[User]:
with self._lock:
return self._read()
def get(self, user_id: str) -> Optional[User]:
with self._lock:
for user in self._read():
if user.id == user_id:
return user
return None
def get_by_subject(self, subject: str) -> Optional[User]:
with self._lock:
for user in self._read():
if user.subject == subject:
return user
return None
def count(self) -> int:
with self._lock:
return len(self._read())
# ----------------------------------------------------------------- write
def upsert(
self,
subject: str,
email: Optional[str] = None,
name: Optional[str] = None,
role: str = "viewer",
) -> User:
"""Create a user, or refresh an existing one on every login.
``role`` only applies to brand-new users: an existing admin is never
demoted by a login, promotion/demotion goes through ``set_role``.
"""
with self._lock:
users = self._read()
now = _now()
for user in users:
if user.subject == subject:
user.email = email or user.email
user.name = name or user.name
user.last_login_at = now
self._write(users)
return user
base = slugify(subject)
candidate = base
suffix = 1
existing = {u.id for u in users}
while candidate in existing:
suffix += 1
candidate = f"{base}-{suffix}"
user = User(
id=candidate,
subject=subject,
email=email,
name=name,
role=role,
created_at=now,
last_login_at=now,
)
users.append(user)
self._write(users)
return user
def set_role(self, user_id: str, role: str) -> Optional[User]:
with self._lock:
users = self._read()
for user in users:
if user.id == user_id:
user.role = role
self._write(users)
return user
return None
Generated
+214
View File
@@ -33,6 +33,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "authlib"
version = "1.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "joserfc" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/dc/087c08741a98d234c9b98beef8a28d3c309f814d14b6156422c5815bf6d6/authlib-1.9.1.tar.gz", hash = "sha256:5c7d9848f47cac340f060f76ae0bc09b59f5c8e89cc1bb032d4370a0c55b5974", size = 182700, upload-time = "2026-10-14T14:17:52.307Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/39/44/a46089266de32a0d809023b6bf31b80a1f26a46954446a7ce695a565cbea/authlib-1.9.1-py2.py3-none-any.whl", hash = "sha256:8b9be8b1e5174dcbf5520e984f1a22dc2edae5b6e40a61d4cedbf86b97e9e69b", size = 266042, upload-time = "2026-10-14T14:17:50.695Z" },
]
[[package]]
name = "certifi"
version = "2026.10.23"
@@ -42,6 +55,104 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/32/d1/a3652fe2c19134d0d15922177821d863cbd7e842d53a21bbe7e8e3a1b5ac/certifi-2026.10.23-py3-none-any.whl", hash = "sha256:e9b54de76e530bb6cf0ccd036787103acb58fd42ba930ea8bf948ddbaa2f61d1", size = 135082, upload-time = "2026-10-23T11:36:14.763Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" },
{ url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" },
{ url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" },
{ url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" },
{ url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" },
{ url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" },
{ url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" },
{ url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" },
{ url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" },
{ url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" },
{ url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" },
{ url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" },
{ url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" },
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" },
]
[[package]]
name = "click"
version = "8.5.0"
@@ -51,6 +162,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "cryptography"
version = "50.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
{ url = "https://files.pythonhosted.org/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
{ url = "https://files.pythonhosted.org/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
{ url = "https://files.pythonhosted.org/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
{ url = "https://files.pythonhosted.org/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
{ url = "https://files.pythonhosted.org/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
{ url = "https://files.pythonhosted.org/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
{ url = "https://files.pythonhosted.org/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
{ url = "https://files.pythonhosted.org/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
{ url = "https://files.pythonhosted.org/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
{ url = "https://files.pythonhosted.org/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
{ url = "https://files.pythonhosted.org/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
{ url = "https://files.pythonhosted.org/packages/ce/cb/52eb3770c0d0be2702a98c6e96065ddc0a2877cf0845aa9c23397c142cd4/cryptography-50.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f785f6161f202ab04d8ca194158968798e480ca058943907972da5f12e2881e8", size = 3892731, upload-time = "2026-09-30T14:44:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/19/8e/aa1fc533d4546b127b45de8aa024eb5933d23eff9debfe25931e56861095/cryptography-50.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0ecbc5652bdb6fc9eaf89a7d196e20941adfe812f43bc4ca05d9150496821047", size = 4710431, upload-time = "2026-09-30T14:44:15.427Z" },
{ url = "https://files.pythonhosted.org/packages/6a/64/72bc3f75176e7e406b748a3e3830432b8c51297b38368713df04dc04898a/cryptography-50.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ab50ee449bf968271e820086f10a33d101dd060370abc10bcd22279be2656539", size = 4694824, upload-time = "2026-09-30T14:44:17.69Z" },
{ url = "https://files.pythonhosted.org/packages/4e/c6/62c77550edfa5ca3f14bf44a1e6739b9fa09d6e998a11d97ed8213bccc98/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a9f7355e6fab51f6c369b86fb7571cffa05edee2c2121e0380a37fb9ac1cd5c1", size = 4716967, upload-time = "2026-09-30T14:44:19.661Z" },
{ url = "https://files.pythonhosted.org/packages/f4/37/cce70f150c432914460157a6ecc161752e053aa5ec0ef3b3f7dc6e31039a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:94e5e9f108ee10471288214d3d233fbfbb492840a8457eb85178d643ddeb32c7", size = 5328676, upload-time = "2026-09-30T14:44:21.744Z" },
{ url = "https://files.pythonhosted.org/packages/aa/9a/6f2f0304d634ceafdeaf23e84537336664ac419b5d07611675c2ad3f6b7a/cryptography-50.0.2-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:241449bf940a5d27309bd317e6f9a2af6932113818bb2b8f5c59ddc7ef16da18", size = 4727698, upload-time = "2026-09-30T14:44:24.178Z" },
{ url = "https://files.pythonhosted.org/packages/1d/de/66bcf9244d118663b2e1aaded8990f4640e3d7b7411870a5765f252074d2/cryptography-50.0.2-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d8947001be83df1394050758ce0e745dd74fb134eef0a4b5124208dfc3a68c37", size = 4354821, upload-time = "2026-09-30T14:44:26.263Z" },
{ url = "https://files.pythonhosted.org/packages/bd/e6/db28a28c7b6c676addce89136de3d8db49ea825a8c863472e36e42ead4ad/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4a20ce1e5cb4284a86692fdcba7cb8754185c6b2e5c56fcef3751cf451d3cdc2", size = 4716748, upload-time = "2026-09-30T14:44:28.447Z" },
{ url = "https://files.pythonhosted.org/packages/30/96/01546c7f69ea0e2ab790a2e4f0934a4052fb9b388147fbf83c2fd72f1e57/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:84f964e537f916e2cc85199e5a88742e964939b575ac8598b3f9d6cc416cdaf1", size = 5285085, upload-time = "2026-09-30T14:44:30.704Z" },
{ url = "https://files.pythonhosted.org/packages/6c/01/03263395f74d50b071e9e66daace3f8bef80493e5d410726f2ba8554736b/cryptography-50.0.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:828d49b0ff5a0e3975865571c5d91dbbdd0d38d8289b249a163e9425413a5e05", size = 4727268, upload-time = "2026-09-30T14:44:32.92Z" },
{ url = "https://files.pythonhosted.org/packages/eb/94/2bfe8f29ec0cc9c0d99359c4161adf32858e4934b72c6d100d2ac0bbe962/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:deb9fde5c60e437ee4821bc9bc39ff31b42135c27e1dc61ef0a629389c1de62e", size = 4849503, upload-time = "2026-09-30T14:44:34.969Z" },
{ url = "https://files.pythonhosted.org/packages/54/44/e80651ecbf0e42b62e2bb5f5768916e07eea72e1297338956a61df361f88/cryptography-50.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8c71ba2cd31fc93748c38e1b613200ff1c2665cbfd5341fe3a61cfde35a1430e", size = 5004057, upload-time = "2026-09-30T14:44:37.064Z" },
{ url = "https://files.pythonhosted.org/packages/f8/cc/1d33befb3cd7ea7e77d2d73f43f2066471da1b21f24a6156efcaabf6d2e8/cryptography-50.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:78198641e5be9521beea5aa782bb551a58068d10e6eb04c9c680c1b69f2e7d45", size = 3795868, upload-time = "2026-09-30T14:44:39.71Z" },
{ url = "https://files.pythonhosted.org/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
{ url = "https://files.pythonhosted.org/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
{ url = "https://files.pythonhosted.org/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
{ url = "https://files.pythonhosted.org/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
{ url = "https://files.pythonhosted.org/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
{ url = "https://files.pythonhosted.org/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
{ url = "https://files.pythonhosted.org/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
{ url = "https://files.pythonhosted.org/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
{ url = "https://files.pythonhosted.org/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
{ url = "https://files.pythonhosted.org/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
{ url = "https://files.pythonhosted.org/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
{ url = "https://files.pythonhosted.org/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
{ url = "https://files.pythonhosted.org/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
{ url = "https://files.pythonhosted.org/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
{ url = "https://files.pythonhosted.org/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
{ url = "https://files.pythonhosted.org/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
{ url = "https://files.pythonhosted.org/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
{ url = "https://files.pythonhosted.org/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
{ url = "https://files.pythonhosted.org/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
{ url = "https://files.pythonhosted.org/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
{ url = "https://files.pythonhosted.org/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
{ url = "https://files.pythonhosted.org/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
{ url = "https://files.pythonhosted.org/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
{ url = "https://files.pythonhosted.org/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
{ url = "https://files.pythonhosted.org/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
{ url = "https://files.pythonhosted.org/packages/1d/7a/f08d34ce09d60f89ebd391e2ebc6ba2b995e6dd7552f41820f8085f94e53/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:92e665960f25fcdc73725b9cec7a3824f279ba97a98653afe9ffac2e43668f67", size = 4716035, upload-time = "2026-09-30T15:29:48.681Z" },
{ url = "https://files.pythonhosted.org/packages/45/67/e18fb65592451a2acb76e9f2fbe14e0f47a8318b4c5430f1633851d03daa/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:eef4c2f3423810b3070ab391f85436d2f8bbfcb286ac15cbc73190b3563b1f1a", size = 4726917, upload-time = "2026-09-30T15:29:50.608Z" },
{ url = "https://files.pythonhosted.org/packages/83/28/38fdce17e60f6b825e69fc3b7f75e70a6612759980704697e1de4cbfaf6e/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:7c6d0330c472d96f6a6afe24d80dfdf15176c33096f0a4397ae4c60f3dd3be48", size = 4715341, upload-time = "2026-09-30T15:29:52.522Z" },
{ url = "https://files.pythonhosted.org/packages/b6/b1/d9121a717e0f893c64bd6ca7702614778d7df2a5c309128a002421788516/cryptography-50.0.2-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:1ba34f04897fcdaa73f74145c25f3ec146fbd56593853e88adc2e811303c5f42", size = 4726322, upload-time = "2026-09-30T15:29:54.263Z" },
{ url = "https://files.pythonhosted.org/packages/36/8b/e6d153808bf353e152abd2fd4d8f09670d956ac78379ac46e60d7efbf04c/cryptography-50.0.2-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:3dc4fd8058cea1644971207d530e1a03a184a805ffc8ebdddf0599d78a331b81", size = 3873097, upload-time = "2026-09-30T15:29:56.097Z" },
{ url = "https://files.pythonhosted.org/packages/ca/1d/1271f287ff7170ddafc2aad36260c4eec20ccd2fea70f38455e9d56d427b/cryptography-50.0.2-pp311-pypy311_pp80-win_amd64.whl", hash = "sha256:7b75de3c8b3be1cdb1052747c929440c3eea46c1bc2cb8a6e3a48388e9b7b452", size = 3805376, upload-time = "2026-09-30T15:29:58.729Z" },
]
[[package]]
name = "fastapi"
version = "0.162.0"
@@ -213,6 +393,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "itsdangerous"
version = "2.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
@@ -225,6 +414,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "joserfc"
version = "1.7.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bd/15/48ff246732232d35ee234228def22b52455fcc55577f06146602399a29b0/joserfc-1.7.7.tar.gz", hash = "sha256:471d0fd12a5a0d1c0942de280f39efe6f597878315033270b504d4d006d25510", size = 237446, upload-time = "2026-10-19T00:30:29.974Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/9f/934db284aa22f37af59708e382903860d4b81329c137b1b0ed2745daeb65/joserfc-1.7.7-py3-none-any.whl", hash = "sha256:06979cfbab78bc802f14c0358f32969826cc26720e13cb38926ffdc86bc0cb2c", size = 71758, upload-time = "2026-10-19T00:30:28.498Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.4"
@@ -365,6 +566,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
]
[[package]]
name = "pycparser"
version = "3.12"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/cb/27/eeb9f307bf07060f3d9f9aa0fb8c15b01bdfded0a941e4b2719c7f94cacd/pycparser-3.12.tar.gz", hash = "sha256:f03387877eab82ce01bbdfd0ba7252ee55a90b1c708d5129e2ce915475cf1db6", size = 158785, upload-time = "2026-10-22T13:40:03.56Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600, upload-time = "2026-10-22T13:40:02.021Z" },
]
[[package]]
name = "pydantic"
version = "2.14.1"
@@ -689,16 +899,20 @@ name = "uwudash-mk3"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "authlib" },
{ name = "fastapi" },
{ name = "httpx" },
{ name = "itsdangerous" },
{ name = "jinja2" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.metadata]
requires-dist = [
{ name = "authlib", specifier = ">=1.9.1" },
{ name = "fastapi", specifier = ">=0.162.0" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "itsdangerous", specifier = ">=2.2" },
{ name = "jinja2", specifier = ">=3.1.6" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.54.0" },
]