From 50078f919fee702826e78c97e7928087157a8148 Mon Sep 17 00:00:00 2001 From: Quaicoe Richard Date: Wed, 18 Mar 2026 23:49:13 +0000 Subject: [PATCH 1/4] Feat: Added support for sync functions (#34) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **`engine.sync` β€” synchronous proxy for sync route handlers and background threads**: every async engine method (`enable`, `disable`, `set_maintenance`, `schedule_maintenance`, `set_env_only`, `enable_global_maintenance`, `disable_global_maintenance`, `set_rate_limit_policy`, `delete_rate_limit_policy`, `reset_rate_limit`, `get_state`, `list_states`, `get_audit_log`) is now mirrored on `engine.sync` using `anyio.from_thread.run()`, the same mechanism the shield decorators use internally. Use `engine.sync.*` from plain `def` FastAPI handlers (which FastAPI runs in a worker thread automatically) and background threads β€” no event-loop wiring required. --- README.md | 1 + docs/changelog.md | 4 + docs/reference/engine.md | 71 ++++++++++++++ package.json | 2 +- shield/core/engine.py | 204 ++++++++++++++++++++++++++++++++++++++- 5 files changed, 279 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index f60b17b..1a14290 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ |---|---| | 🎨 **Decorator-first DX** | Route state lives next to the route definition, not in a separate config file | | ⚑ **Zero-restart control** | State changes take effect immediately β€” no redeployment or server restart needed | +| πŸ”„ **Sync & async** | Full support for both `async def` and plain `def` route handlers β€” use `await engine.*` or `engine.sync.*` | | πŸ›‘οΈ **Fail-open by default** | If the backend is unreachable, requests pass through. Shield never takes down your API | | πŸ”Œ **Pluggable backends** | In-memory (default), file-based JSON, or Redis for multi-instance deployments | | πŸ–₯️ **Admin dashboard** | HTMX-powered UI with live SSE updates β€” no JS framework required | diff --git a/docs/changelog.md b/docs/changelog.md index 7698a31..da037f5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -8,6 +8,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ## [Unreleased] +### Added + +- **`engine.sync` β€” synchronous proxy for sync route handlers and background threads**: every async engine method (`enable`, `disable`, `set_maintenance`, `schedule_maintenance`, `set_env_only`, `enable_global_maintenance`, `disable_global_maintenance`, `set_rate_limit_policy`, `delete_rate_limit_policy`, `reset_rate_limit`, `get_state`, `list_states`, `get_audit_log`) is now mirrored on `engine.sync` using `anyio.from_thread.run()`, the same mechanism the shield decorators use internally. Use `engine.sync.*` from plain `def` FastAPI handlers (which FastAPI runs in a worker thread automatically) and background threads β€” no event-loop wiring required. + --- ## [0.6.0] diff --git a/docs/reference/engine.md b/docs/reference/engine.md index 410218b..050fcc6 100644 --- a/docs/reference/engine.md +++ b/docs/reference/engine.md @@ -260,6 +260,77 @@ for s in states: --- +## Sync API β€” `engine.sync` + +Every async engine method has a synchronous mirror available via `engine.sync`. This is useful for **sync FastAPI route handlers** (plain `def`, not `async def`) and background threads, where you cannot use `await`. + +FastAPI automatically runs sync handlers in a worker thread, which is exactly the context `engine.sync` requires. No additional setup is needed β€” just use `engine.sync.*` instead of `await engine.*`. + +!!! warning "Do not call from inside `async def`" + `engine.sync.*` uses `anyio.from_thread.run()` internally. Calling it from inside an async function (on the event loop thread) will deadlock. Use `await engine.*` there instead. + +=== "Async handler (normal)" + + ```python + @router.post("/admin/deploy") + @force_active + async def deploy(): + await engine.disable("GET:/payments", reason="deploy in progress") + await run_migration() + await engine.enable("GET:/payments") + return {"deployed": True} + ``` + +=== "Sync handler (engine.sync)" + + ```python + @router.post("/admin/deploy") + @force_active + def deploy(): # FastAPI runs this in a worker thread automatically + engine.sync.disable("GET:/payments", reason="deploy in progress") + run_migration() + engine.sync.enable("GET:/payments") + return {"deployed": True} + ``` + +=== "Background thread" + + ```python + import threading + + def nightly_job(): + engine.sync.set_maintenance("GET:/reports", reason="nightly rebuild") + rebuild_reports() + engine.sync.enable("GET:/reports") + + threading.Thread(target=nightly_job, daemon=True).start() + ``` + +### Available methods + +`engine.sync` exposes the same public API as the async engine: + +| `engine.sync.*` | Async equivalent | +|---|---| +| `enable(path, actor, reason)` | `await engine.enable(...)` | +| `disable(path, reason, actor)` | `await engine.disable(...)` | +| `set_maintenance(path, reason, window, actor)` | `await engine.set_maintenance(...)` | +| `schedule_maintenance(path, window, actor)` | `await engine.schedule_maintenance(...)` | +| `set_env_only(path, envs, actor)` | `await engine.set_env_only(...)` | +| `get_global_maintenance()` | `await engine.get_global_maintenance()` | +| `enable_global_maintenance(reason, ...)` | `await engine.enable_global_maintenance(...)` | +| `disable_global_maintenance(actor)` | `await engine.disable_global_maintenance(...)` | +| `set_global_exempt_paths(paths)` | `await engine.set_global_exempt_paths(...)` | +| `get_rate_limit_hits(path, limit)` | `await engine.get_rate_limit_hits(...)` | +| `set_rate_limit_policy(path, method, limit, ...)` | `await engine.set_rate_limit_policy(...)` | +| `delete_rate_limit_policy(path, method, actor)` | `await engine.delete_rate_limit_policy(...)` | +| `reset_rate_limit(path, method, actor)` | `await engine.reset_rate_limit(...)` | +| `get_state(path)` | `await engine.get_state(path)` | +| `list_states()` | `await engine.list_states()` | +| `get_audit_log(path, limit)` | `await engine.get_audit_log(...)` | + +--- + ## Scheduled maintenance ### `schedule_maintenance` diff --git a/package.json b/package.json index 7506903..67ef98b 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,6 @@ "watch:css": "tailwindcss -i input.css -o shield/dashboard/static/shield.min.css --watch" }, "devDependencies": { - "@tailwindcss/cli": "^4.0.0" + "@tailwindcss/cli": "4.2.1" } } diff --git a/shield/core/engine.py b/shield/core/engine.py index 80cc5c7..ccf4945 100644 --- a/shield/core/engine.py +++ b/shield/core/engine.py @@ -11,9 +11,9 @@ import hashlib import logging import uuid -from collections.abc import Callable +from collections.abc import Callable, Coroutine from datetime import UTC, datetime -from typing import Any +from typing import Any, TypeVar from shield.core.backends.base import ShieldBackend from shield.core.backends.memory import MemoryBackend @@ -37,10 +37,208 @@ logger = logging.getLogger(__name__) +_T = TypeVar("_T") + # Type alias for a webhook formatter callable. WebhookFormatter = Callable[[str, str, RouteState], dict[str, Any]] +class _SyncProxy: + """Synchronous faΓ§ade over :class:`ShieldEngine`. + + Access via ``engine.sync`` from any sync context that runs inside an + anyio worker thread β€” which is exactly what FastAPI does for every + ``def`` (non-async) route handler and dependency. + + Uses ``anyio.from_thread.run()`` internally, the same mechanism the + shield decorators use, so no event-loop wiring is needed. + + Do **not** call from inside an ``async def`` β€” use ``await engine.*`` + directly there. + + Examples + -------- + Sync route handler:: + + @router.post("/admin/deploy") + @force_active + def deploy(): # FastAPI runs sync handlers in a worker thread + engine.sync.disable("GET:/payments", reason="deploy in progress") + run_migration() + engine.sync.enable("GET:/payments") + return {"deployed": True} + + Background thread:: + + def nightly_job(): + engine.sync.set_maintenance("GET:/reports", reason="nightly rebuild") + rebuild_reports() + engine.sync.enable("GET:/reports") + """ + + __slots__ = ("_engine",) + + def __init__(self, engine: ShieldEngine) -> None: + self._engine = engine + + def _run(self, coro: Coroutine[Any, Any, _T]) -> _T: + import anyio.from_thread + + return anyio.from_thread.run(coro) # type: ignore[arg-type] + + # ------------------------------------------------------------------ + # Route lifecycle + # ------------------------------------------------------------------ + + def enable( + self, path: str, actor: str = "system", reason: str = "", platform: str = "system" + ) -> RouteState: + """Sync version of :meth:`ShieldEngine.enable`.""" + return self._run(self._engine.enable(path, actor=actor, reason=reason, platform=platform)) + + def disable( + self, path: str, reason: str = "", actor: str = "system", platform: str = "system" + ) -> RouteState: + """Sync version of :meth:`ShieldEngine.disable`.""" + return self._run(self._engine.disable(path, reason=reason, actor=actor, platform=platform)) + + def set_maintenance( + self, + path: str, + reason: str = "", + window: MaintenanceWindow | None = None, + actor: str = "system", + platform: str = "system", + ) -> RouteState: + """Sync version of :meth:`ShieldEngine.set_maintenance`.""" + return self._run( + self._engine.set_maintenance( + path, reason=reason, window=window, actor=actor, platform=platform + ) + ) + + def schedule_maintenance( + self, + path: str, + window: MaintenanceWindow, + actor: str = "system", + platform: str = "system", + ) -> None: + """Sync version of :meth:`ShieldEngine.schedule_maintenance`.""" + self._run( + self._engine.schedule_maintenance(path, window=window, actor=actor, platform=platform) + ) + + def set_env_only( + self, path: str, envs: list[str], actor: str = "system", platform: str = "system" + ) -> RouteState: + """Sync version of :meth:`ShieldEngine.set_env_only`.""" + return self._run(self._engine.set_env_only(path, envs=envs, actor=actor, platform=platform)) + + # ------------------------------------------------------------------ + # Global maintenance + # ------------------------------------------------------------------ + + def get_global_maintenance(self) -> GlobalMaintenanceConfig: + """Sync version of :meth:`ShieldEngine.get_global_maintenance`.""" + return self._run(self._engine.get_global_maintenance()) + + def enable_global_maintenance( + self, + reason: str = "", + exempt_paths: list[str] | None = None, + include_force_active: bool = False, + actor: str = "system", + platform: str = "system", + ) -> GlobalMaintenanceConfig: + """Sync version of :meth:`ShieldEngine.enable_global_maintenance`.""" + return self._run( + self._engine.enable_global_maintenance( + reason=reason, + exempt_paths=exempt_paths, + include_force_active=include_force_active, + actor=actor, + platform=platform, + ) + ) + + def disable_global_maintenance( + self, actor: str = "system", platform: str = "system" + ) -> GlobalMaintenanceConfig: + """Sync version of :meth:`ShieldEngine.disable_global_maintenance`.""" + return self._run(self._engine.disable_global_maintenance(actor=actor, platform=platform)) + + def set_global_exempt_paths(self, paths: list[str]) -> GlobalMaintenanceConfig: + """Sync version of :meth:`ShieldEngine.set_global_exempt_paths`.""" + return self._run(self._engine.set_global_exempt_paths(paths)) + + # ------------------------------------------------------------------ + # Rate limiting + # ------------------------------------------------------------------ + + def set_rate_limit_policy( + self, + path: str, + method: str, + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + burst: int = 0, + actor: str = "system", + platform: str = "system", + ) -> Any: + """Sync version of :meth:`ShieldEngine.set_rate_limit_policy`.""" + return self._run( + self._engine.set_rate_limit_policy( + path, + method, + limit, + algorithm=algorithm, + key_strategy=key_strategy, + burst=burst, + actor=actor, + platform=platform, + ) + ) + + def delete_rate_limit_policy( + self, path: str, method: str, *, actor: str = "system", platform: str = "system" + ) -> None: + """Sync version of :meth:`ShieldEngine.delete_rate_limit_policy`.""" + self._run( + self._engine.delete_rate_limit_policy(path, method, actor=actor, platform=platform) + ) + + def get_rate_limit_hits(self, path: str | None = None, limit: int = 100) -> list[Any]: + """Sync version of :meth:`ShieldEngine.get_rate_limit_hits`.""" + return self._run(self._engine.get_rate_limit_hits(path=path, limit=limit)) + + def reset_rate_limit( + self, path: str, method: str | None = None, actor: str = "system", platform: str = "system" + ) -> None: + """Sync version of :meth:`ShieldEngine.reset_rate_limit`.""" + self._run( + self._engine.reset_rate_limit(path, method=method, actor=actor, platform=platform) + ) + + # ------------------------------------------------------------------ + # Read-only queries + # ------------------------------------------------------------------ + + def get_state(self, path: str) -> RouteState: + """Sync version of :meth:`ShieldEngine.get_state`.""" + return self._run(self._engine.get_state(path)) + + def list_states(self) -> list[RouteState]: + """Sync version of :meth:`ShieldEngine.list_states`.""" + return self._run(self._engine.list_states()) + + def get_audit_log(self, path: str | None = None, limit: int = 100) -> list[AuditEntry]: + """Sync version of :meth:`ShieldEngine.get_audit_log`.""" + return self._run(self._engine.get_audit_log(path=path, limit=limit)) + + class ShieldEngine: """Central orchestrator β€” all route lifecycle logic flows through here. @@ -97,6 +295,8 @@ def __init__( self._default_rate_limit_algorithm: Any = default_rate_limit_algorithm self._rate_limiter: Any = None # ShieldRateLimiter | None self._rate_limit_policies: dict[str, Any] = {} # "METHOD:/path" β†’ RateLimitPolicy + # Sync proxy β€” created once, reused on every engine.sync access. + self.sync: _SyncProxy = _SyncProxy(self) # ------------------------------------------------------------------ # Async context manager β€” calls backend lifecycle hooks From 8bf657d7ece2540e634c03f9fb1946316cdd6e1b Mon Sep 17 00:00:00 2001 From: Quaicoe Richard Date: Thu, 19 Mar 2026 00:00:59 +0000 Subject: [PATCH 2/4] =?UTF-8?q?chore:=20Refined=20docs=20and=20readme=20to?= =?UTF-8?q?=20reflect=20the=20new=20architecture=20suppor=E2=80=A6=20(#35)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reframed all docs and README as ASGI-first; expanded framework support tables to include Litestar, Starlette, Quart, and Django (ASGI) as planned adapters; added a dedicated Adapters overview page with a clear explanation of why WSGI frameworks (Flask, Django WSGI, Bottle) are out of scope for this project and will be supported in a separate dedicated library. --- README.md | 54 +++++++++++++++++++++++++++++--- docs/adapters/fastapi.md | 5 ++- docs/adapters/index.md | 54 ++++++++++++++++++++++++++++++++ docs/changelog.md | 3 ++ docs/index.md | 48 +++++++++++++++++++++++++--- docs/reference/middleware.md | 10 +++--- docs/tutorial/first-decorator.md | 4 +-- docs/tutorial/installation.md | 8 ++--- docs/tutorial/middleware.md | 12 ++++--- mkdocs.yml | 3 +- 10 files changed, 174 insertions(+), 27 deletions(-) create mode 100644 docs/adapters/index.md diff --git a/README.md b/README.md index 1a14290..b2de328 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
API Shield -

Route(API) lifecycle management for Python web frameworks β€” maintenance mode, environment gating, deprecation, rate limiting, admin panels, and more. No restarts required.

+

Route(API) lifecycle management for ASGI Python web frameworks β€” maintenance mode, environment gating, deprecation, rate limiting, admin panels, and more. No restarts required.

PyPI Python versions @@ -17,22 +17,35 @@ ## Key features +### Core (`shield.core`) + +These features are framework-agnostic and available to any adapter. + | Feature | Description | |---|---| -| 🎨 **Decorator-first DX** | Route state lives next to the route definition, not in a separate config file | | ⚑ **Zero-restart control** | State changes take effect immediately β€” no redeployment or server restart needed | | πŸ”„ **Sync & async** | Full support for both `async def` and plain `def` route handlers β€” use `await engine.*` or `engine.sync.*` | | πŸ›‘οΈ **Fail-open by default** | If the backend is unreachable, requests pass through. Shield never takes down your API | | πŸ”Œ **Pluggable backends** | In-memory (default), file-based JSON, or Redis for multi-instance deployments | | πŸ–₯️ **Admin dashboard** | HTMX-powered UI with live SSE updates β€” no JS framework required | | πŸ–±οΈ **REST API + CLI** | Full programmatic control from the terminal or CI pipelines β€” works over HTTPS remotely | -| πŸ“„ **OpenAPI integration** | Disabled / env-gated routes hidden from `/docs`; deprecated routes flagged automatically | | πŸ“‹ **Audit log** | Every state change is recorded: who, when, what route, old status β†’ new status | | ⏰ **Scheduled windows** | `asyncio`-native scheduler β€” maintenance windows activate and deactivate automatically | | πŸ”” **Webhooks** | Fire HTTP POST on every state change β€” built-in Slack formatter and custom formatters supported | -| 🎨 **Custom responses** | Return HTML, redirects, or any response shape for blocked routes β€” per-route or app-wide default | | 🚦 **Rate limiting** | Per-IP, per-user, per-API-key, or global counters β€” tiered limits, burst allowance, runtime mutation | +### Framework adapters + +#### FastAPI (`shield.fastapi`) β€” βœ… supported + +| Feature | Description | +|---|---| +| 🎨 **Decorator-first DX** | `@maintenance`, `@disabled`, `@env_only`, `@force_active`, `@deprecated`, `@rate_limit` β€” state lives next to the route | +| πŸ“„ **OpenAPI integration** | Disabled / env-gated routes hidden from `/docs`; deprecated routes flagged; live maintenance banners in the Swagger UI | +| 🧩 **Dependency injection** | All decorators work as `Depends()` β€” enforce shield state per-handler without middleware | +| 🎨 **Custom responses** | Return HTML, redirects, or any response shape for blocked routes β€” per-route or app-wide default on the middleware | +| πŸ”€ **ShieldRouter** | Drop-in `APIRouter` replacement that auto-registers route metadata with the engine at startup | + --- ## Install @@ -44,6 +57,8 @@ uv add "api-shield[all]" ## Quickstart +> FastAPI is the currently supported adapter. Litestar, Starlette, Quart, and Django (ASGI) are on the roadmap. + ```python from fastapi import FastAPI from shield.core.config import make_engine @@ -96,7 +111,7 @@ shield global enable --reason "Deploying v2" --exempt /health | `@deprecated(sunset, use_instead)` | Still works, injects deprecation headers | 200 | | `@force_active` | Bypasses all shield checks | Always 200 | | `@rate_limit("100/minute")` | Cap requests per IP, user, API key, or globally | 429 | -### Custom responses +### Custom responses (FastAPI) By default, blocked routes return a structured JSON error body. You can replace it with anything β€” HTML, a redirect, plain text, or your own JSON β€” in two ways: @@ -176,6 +191,35 @@ Requires `api-shield[rate-limit]`. Powered by [limits](https://limits.readthedoc --- +## Framework support + +api-shield is built on the **ASGI** standard. The core (`shield.core`) is completely framework-agnostic and has zero framework imports. Any ASGI framework can be supported β€” either via a Starlette `BaseHTTPMiddleware` (for Starlette-based frameworks) or a raw ASGI callable for frameworks like Quart and Django that implement the ASGI spec independently. + +### ASGI frameworks + +| Framework | Status | Adapter | +|---|---|---| +| **FastAPI** | βœ… Supported | `shield.fastapi` | +| **Litestar** | πŸ”œ Planned | β€” | +| **Starlette** | πŸ”œ Planned | β€” | +| **Quart** | πŸ”œ Planned | β€” | +| **Django (ASGI)** | πŸ”œ Planned | β€” | + +> Want support for another ASGI framework? [Open an issue](https://github.com/Attakay78/api-shield/issues). + +### WSGI frameworks (Flask, Django, …) + +> [!IMPORTANT] +> **WSGI support is out of scope for this project.** +> +> `api-shield` is an ASGI-native library. Bolting WSGI support in through shims or patches would require a persistent background event loop, thread-bridging hacks, and a fundamentally different middleware model β€” complexity that would compromise the quality and reliability of both layers. +> +> WSGI framework support (Flask, Django, Bottle, …) will be delivered as a **separate, dedicated project** designed from the ground up for the synchronous request model. This keeps both projects clean, well-tested, and maintainable without trade-offs. +> +> Watch this repo or [open an issue](https://github.com/Attakay78/api-shield/issues) to be notified when the WSGI companion project launches. + +--- + ## Backends | Backend | Persistence | Multi-instance | diff --git a/docs/adapters/fastapi.md b/docs/adapters/fastapi.md index 8005cdb..a28a637 100644 --- a/docs/adapters/fastapi.md +++ b/docs/adapters/fastapi.md @@ -1,6 +1,9 @@ # FastAPI Adapter -The FastAPI adapter is the primary supported adapter. It provides middleware, decorators, a drop-in router, and OpenAPI integration. +The FastAPI adapter is the currently supported ASGI adapter. It provides middleware, decorators, a drop-in router, and OpenAPI integration β€” all built on top of the framework-agnostic `shield.core`. + +!!! info "Other ASGI frameworks" + api-shield's core and `ShieldMiddleware` are ASGI-native and framework-agnostic. FastAPI-specific features (ShieldRouter, OpenAPI integration, `Depends()` support) live in `shield.fastapi`. Adapters for **Litestar** and plain **Starlette** are on the roadmap. [Open an issue](https://github.com/Attakay78/api-shield/issues) if you need another framework prioritised. --- diff --git a/docs/adapters/index.md b/docs/adapters/index.md new file mode 100644 index 0000000..3b996a0 --- /dev/null +++ b/docs/adapters/index.md @@ -0,0 +1,54 @@ +# Framework Adapters + +api-shield separates concerns cleanly: + +- **`shield.core`** β€” the engine, backends, models, and exceptions. Zero framework imports. Works anywhere Python runs. +- **`shield.fastapi`** β€” the FastAPI adapter: ASGI middleware, route decorators, `ShieldRouter`, and OpenAPI integration. +- **`shield.`** β€” future adapters follow the same pattern. + +--- + +## ASGI frameworks + +api-shield is built on the **ASGI** standard with zero framework imports in `shield.core`. ASGI frameworks fall into two groups, each requiring a slightly different middleware approach: + +**Starlette-based** β€” use `ShieldMiddleware` (`BaseHTTPMiddleware`) directly. These frameworks share Starlette's request/response model and middleware protocol. + +**Pure ASGI** β€” frameworks like Quart and Django that implement the ASGI spec independently (no Starlette layer). Their adapters will use a raw ASGI callable (`async def __call__(scope, receive, send)`) so no Starlette dependency is introduced. + +| Framework | Status | Adapter module | Middleware approach | +|---|---|---|---| +| **FastAPI** | βœ… Supported now | `shield.fastapi` | Starlette `BaseHTTPMiddleware` + ShieldRouter + OpenAPI integration | +| **Litestar** | πŸ”œ Planned | `shield.litestar` | Starlette-compatible middleware | +| **Starlette** | πŸ”œ Planned | `shield.starlette` | Starlette `BaseHTTPMiddleware` | +| **Quart** | πŸ”œ Planned | `shield.quart` | Pure ASGI callable (no Starlette dependency) | +| **Django (ASGI)** | πŸ”œ Planned | `shield.django` | Pure ASGI callable (no Starlette dependency) | + +!!! note "Quart and Django ASGI" + [Quart](https://quart.palletsprojects.com/) is the ASGI reimplementation of Flask and is a natural fit. [Django's ASGI mode](https://docs.djangoproject.com/en/stable/howto/deployment/asgi/) (`django.core.asgi`) makes Django routes available over ASGI. Neither uses Starlette internally, so their adapters will wrap the shield engine in a pure ASGI middleware layer β€” keeping `shield.quart` and `shield.django` free of any Starlette dependency. + +See [**FastAPI Adapter**](fastapi.md) for the full guide on the currently supported adapter. + +--- + +## WSGI frameworks + +!!! warning "WSGI support is out of scope for this project" + `api-shield` is an ASGI-native library. Integrating WSGI frameworks (Flask, Django, Bottle, …) via thread-bridging shims or a persistent background event loop would require a fundamentally different request model, and would introduce architectural compromises that undermine the reliability of both layers. + + **WSGI support will be delivered as a separate, dedicated project.** Building it from scratch for the synchronous request model β€” rather than patching it onto an async core β€” means both projects stay clean, well-tested, and maintainable without trade-offs. + + This is a deliberate design decision, not a gap. [Open an issue](https://github.com/Attakay78/api-shield/issues) or watch this repo to be notified when the WSGI companion project launches. + +### Why not just add a sync bridge? + +The short answer: it works until it doesn't, and the failure modes are silent. + +A WSGI-to-ASGI bridge requires spawning a background asyncio event loop in a daemon thread and using `asyncio.run_coroutine_threadsafe()` to call into the async engine on every request. This creates several problems: + +- **Connection pool fragmentation** β€” `RedisBackend` opens a connection pool tied to one event loop. Each WSGI worker process creates its own daemon loop, fragmenting the pool across processes with no shared state. +- **Thread-safety surface** β€” asyncio primitives (`asyncio.Lock`, `asyncio.Queue`) are not thread-safe. Wrapping them correctly across the WSGI/ASGI boundary requires significant additional machinery. +- **Testing complexity** β€” unit tests for sync WSGI views that touch the async engine require careful loop management, making the test suite fragile. +- **Hidden failures** β€” when the bridge breaks (deadlock, loop death, queue overflow), requests fail silently or block indefinitely rather than failing fast. + +A purpose-built sync engine for WSGI avoids all of this. diff --git a/docs/changelog.md b/docs/changelog.md index da037f5..75417e4 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -11,6 +11,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - **`engine.sync` β€” synchronous proxy for sync route handlers and background threads**: every async engine method (`enable`, `disable`, `set_maintenance`, `schedule_maintenance`, `set_env_only`, `enable_global_maintenance`, `disable_global_maintenance`, `set_rate_limit_policy`, `delete_rate_limit_policy`, `reset_rate_limit`, `get_state`, `list_states`, `get_audit_log`) is now mirrored on `engine.sync` using `anyio.from_thread.run()`, the same mechanism the shield decorators use internally. Use `engine.sync.*` from plain `def` FastAPI handlers (which FastAPI runs in a worker thread automatically) and background threads β€” no event-loop wiring required. +### Documentation + +- Reframed all docs and README as ASGI-first; expanded framework support tables to include Litestar, Starlette, Quart, and Django (ASGI) as planned adapters; added a dedicated Adapters overview page with a clear explanation of why WSGI frameworks (Flask, Django WSGI, Bottle) are out of scope for this project and will be supported in a separate dedicated library. --- diff --git a/docs/index.md b/docs/index.md index c655b29..ddea47e 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,7 +13,7 @@ !!! warning "Early Access: your feedback shapes the roadmap" `api-shield` is fully functional and ready to use. We are actively building on a solid foundation and would love to hear from you. If you have feedback, feature ideas, or suggestions, **[open an issue on GitHub](https://github.com/Attakay78/api-shield/issues)**. Every voice helps make the library better for everyone. -**Route(API) lifecycle management for Python web frameworks: maintenance mode, environment gating, deprecation, rate limiting, admin panels, and more. No restarts required.** +**Route(API) lifecycle management for ASGI Python web frameworks: maintenance mode, environment gating, deprecation, rate limiting, admin panels, and more. No restarts required.** Most "route lifecycle management" tools are blunt instruments: shut everything down or nothing at all. `api-shield` treats each route as a first-class entity with its own lifecycle. State changes take effect immediately through middleware, with no redeployment and no server restart. @@ -21,6 +21,8 @@ Most "route lifecycle management" tools are blunt instruments: shut everything d ## 30-second quickstart +> **FastAPI** is the currently supported adapter. Litestar, Starlette, Quart, and Django (ASGI) are on the roadmap. See [Adapters](adapters/index.md) for details. + ```bash uv add "api-shield[all]" ``` @@ -87,21 +89,34 @@ shield enable GET:/payments ## Key features +### Core (`shield.core`) + +These features are framework-agnostic and available to every adapter. + | Feature | Description | |---|---| -| 🎨 **Decorator-first DX** | Route state lives next to the route definition, not in a separate config file | | ⚑ **Zero-restart control** | State changes are immediate, with no redeployment needed | | πŸ›‘οΈ **Fail-open by default** | If the backend is unreachable, requests pass through. Shield never takes down your API | | πŸ”Œ **Pluggable backends** | In-memory (default), file-based JSON, or Redis for multi-instance deployments | | πŸ–₯️ **Admin dashboard** | HTMX-powered UI with live SSE updates, no JS framework required | | πŸ–±οΈ **REST API + CLI** | Full programmatic control from the terminal or CI pipelines | -| πŸ“„ **OpenAPI integration** | Disabled and env-gated routes hidden from `/docs`; deprecated routes flagged automatically | | πŸ“‹ **Audit log** | Every state change is recorded: who, when, what route, old status, new status | | ⏰ **Scheduled windows** | `asyncio`-native scheduler that activates and deactivates maintenance windows automatically | | πŸ”” **Webhooks** | Fire HTTP POST on every state change, with a built-in Slack formatter and support for custom formatters | -| 🎨 **Custom responses** | Return HTML, redirects, or any response shape for blocked routes, per-route or as an app-wide default | | 🚦 **Rate limiting** | Per-IP, per-user, per-API-key, or global counters with tiered limits, burst allowance, and runtime policy mutation | +### Framework adapters + +#### FastAPI (`shield.fastapi`) β€” βœ… supported + +| Feature | Description | +|---|---| +| 🎨 **Decorator-first DX** | `@maintenance`, `@disabled`, `@env_only`, `@force_active`, `@deprecated`, `@rate_limit` β€” state lives next to the route | +| πŸ“„ **OpenAPI integration** | Disabled and env-gated routes hidden from `/docs`; deprecated routes flagged; live maintenance banners in the Swagger UI | +| 🧩 **Dependency injection** | All decorators work as `Depends()` β€” enforce shield state per-handler without middleware | +| 🎨 **Custom responses** | Return HTML, redirects, or any response shape for blocked routes, per-route or as an app-wide default on the middleware | +| πŸ”€ **ShieldRouter** | Drop-in `APIRouter` replacement that auto-registers route metadata with the engine at startup | + --- ## Decorators at a glance @@ -117,6 +132,31 @@ shield enable GET:/payments --- +## Framework support + +### ASGI frameworks + +api-shield is an **ASGI-native** library. The core (`shield.core`) is framework-agnostic with zero framework imports. Any ASGI framework can be supported β€” Starlette-based frameworks use `BaseHTTPMiddleware` directly; frameworks like Quart and Django that implement the ASGI spec independently use a raw ASGI callable adapter instead. + +| Framework | Status | Adapter | +|---|---|---| +| **FastAPI** | βœ… Supported | `shield.fastapi` | +| **Litestar** | πŸ”œ Planned | β€” | +| **Starlette** | πŸ”œ Planned | β€” | +| **Quart** | πŸ”œ Planned | β€” | +| **Django (ASGI)** | πŸ”œ Planned | β€” | + +### WSGI frameworks (Flask, Django, …) + +!!! warning "WSGI support is out of scope for this project" + `api-shield` is built on the ASGI standard. Adding WSGI support through shims or thread-bridging patches would require a persistent background event loop, a fundamentally different middleware model, and trade-offs that would compromise reliability for both ASGI and WSGI users. + + WSGI framework support (Flask, Django, Bottle, and others) will be delivered as a **separate, dedicated project** designed from the ground up for the synchronous request model. This keeps both projects clean, well-tested, and free of architectural compromises. + + [Open an issue](https://github.com/Attakay78/api-shield/issues) or watch this repo to be notified when the WSGI companion project launches. + +--- + ## Next steps - [**Tutorial: Installation**](tutorial/installation.md): get up and running in seconds diff --git a/docs/reference/middleware.md b/docs/reference/middleware.md index 4420690..4ec86e0 100644 --- a/docs/reference/middleware.md +++ b/docs/reference/middleware.md @@ -6,7 +6,7 @@ This page covers `ShieldMiddleware`, which enforces route state on every HTTP re ## ShieldMiddleware -`ShieldMiddleware` is a Starlette `BaseHTTPMiddleware`. Add it once to your FastAPI app and it automatically intercepts every request, calls `engine.check()`, and returns the appropriate error response when a route is blocked. +`ShieldMiddleware` is a standard ASGI middleware (Starlette `BaseHTTPMiddleware`). Add it once to your ASGI app and it automatically intercepts every request, calls `engine.check()`, and returns the appropriate error response when a route is blocked. It works with any Starlette-compatible ASGI framework, including FastAPI. ```python from shield.fastapi import ShieldMiddleware @@ -98,9 +98,9 @@ Link: ; rel="successor-version" --- -## `apply_shield_to_openapi` +## `apply_shield_to_openapi` (FastAPI only) -Keep your OpenAPI schema accurate by filtering it based on the current route states at runtime. Disabled and env-gated routes are hidden. Maintenance routes are annotated. Deprecated routes are flagged. +Keep your FastAPI OpenAPI schema accurate by filtering it based on the current route states at runtime. Disabled and env-gated routes are hidden. Maintenance routes are annotated. Deprecated routes are flagged. ```python from shield.fastapi.openapi import apply_shield_to_openapi @@ -138,9 +138,9 @@ The schema is re-computed on every request to `/openapi.json`, so runtime state --- -## `setup_shield_docs` +## `setup_shield_docs` (FastAPI only) -Enhance `/docs` and `/redoc` with live status indicators that update automatically as route states change. +Enhance FastAPI's `/docs` and `/redoc` with live status indicators that update automatically as route states change. ```python from shield.fastapi.openapi import apply_shield_to_openapi, setup_shield_docs diff --git a/docs/tutorial/first-decorator.md b/docs/tutorial/first-decorator.md index 7e9db2f..d47fc66 100644 --- a/docs/tutorial/first-decorator.md +++ b/docs/tutorial/first-decorator.md @@ -1,8 +1,8 @@ # Your first decorator -This tutorial shows you how to put a single route into maintenance mode and verify the behaviour. +This tutorial shows you how to put a single route into maintenance mode and verify the behaviour. The examples use **FastAPI** β€” the currently supported ASGI adapter. -## 1. Create a simple FastAPI app +## 1. Create a simple app ```python title="app.py" from fastapi import FastAPI diff --git a/docs/tutorial/installation.md b/docs/tutorial/installation.md index 05953bf..962b982 100644 --- a/docs/tutorial/installation.md +++ b/docs/tutorial/installation.md @@ -3,14 +3,14 @@ ## Requirements - Python **3.11** or higher -- A FastAPI app (other adapters coming soon) +- An ASGI web framework (FastAPI is currently supported; Starlette and other ASGI frameworks are on the roadmap) --- ## Install with uv (recommended) ```bash -# Minimal β€” core library only (no FastAPI, no CLI, no dashboard) +# Minimal β€” core library only (no framework adapter, no CLI, no dashboard) uv add api-shield # FastAPI adapter @@ -22,7 +22,7 @@ uv add "api-shield[fastapi,cli]" # FastAPI + rate limiting uv add "api-shield[fastapi,rate-limit]" -# Everything (FastAPI, Redis, dashboard, CLI, admin, rate limiting) +# Everything (FastAPI adapter, Redis, dashboard, CLI, admin, rate limiting) uv add "api-shield[all]" ``` @@ -38,7 +38,7 @@ pip install "api-shield[all]" | Extra | What it adds | When to use | |---|---|---| -| `fastapi` | FastAPI adapter (middleware, decorators, router, OpenAPI integration) | Any FastAPI app | +| `fastapi` | FastAPI adapter (middleware, decorators, router, OpenAPI integration) | FastAPI apps | | `redis` | `RedisBackend` for multi-instance deployments | Production with multiple replicas | | `dashboard` | Jinja2 + aiofiles for the HTMX dashboard | When mounting the admin UI | | `admin` | Unified `ShieldAdmin` (dashboard + REST API) | Recommended for CLI support | diff --git a/docs/tutorial/middleware.md b/docs/tutorial/middleware.md index 58f0d23..74581cb 100644 --- a/docs/tutorial/middleware.md +++ b/docs/tutorial/middleware.md @@ -1,6 +1,8 @@ # Adding middleware -`ShieldMiddleware` is the enforcement layer. It intercepts every HTTP request, calls `engine.check()`, and returns the appropriate error response when a route is blocked. Without it, decorators register state but nothing enforces it. +`ShieldMiddleware` is the enforcement layer. It is a standard ASGI middleware that intercepts every HTTP request, calls `engine.check()`, and returns the appropriate error response when a route is blocked. Without it, decorators register state but nothing enforces it. + +The examples below use **FastAPI**, but `ShieldMiddleware` works with any [Starlette](https://www.starlette.io/)-compatible ASGI framework. --- @@ -18,7 +20,7 @@ app.add_middleware(ShieldMiddleware, engine=engine) ``` !!! important - Add `ShieldMiddleware` **before** including any routers. Middleware is applied in reverse registration order in Starlette/FastAPI, so adding it first ensures it wraps all routes. + Add `ShieldMiddleware` **before** including any routers. Middleware is applied in reverse registration order in ASGI frameworks built on Starlette, so adding it first ensures it wraps all routes. --- @@ -57,7 +59,7 @@ ShieldMiddleware.dispatch() ## Route registration -The middleware auto-registers routes on first startup by scanning for `__shield_meta__` on route handlers. This works with any router type: plain `APIRouter`, `ShieldRouter`, or routes added directly to the `FastAPI` app. +The middleware auto-registers routes on first startup by scanning for `__shield_meta__` on route handlers. This works with any router type: plain `APIRouter`, `ShieldRouter`, or routes added directly to the app. If a route already has persisted state in the backend (for example, written by a previous CLI command), the decorator default is **ignored** and the persisted state wins. This means runtime changes survive restarts. @@ -120,9 +122,9 @@ All error responses from the middleware use a consistent JSON structure: --- -## OpenAPI integration +## OpenAPI integration (FastAPI only) -Add OpenAPI filtering to hide disabled and env-gated routes from `/docs` and `/redoc`: +FastAPI exposes a live OpenAPI schema at `/openapi.json`. Shield can filter it to hide disabled and env-gated routes and annotate maintained or deprecated ones: ```python from shield.fastapi.openapi import apply_shield_to_openapi diff --git a/mkdocs.yml b/mkdocs.yml index 435d5bb..c1ae9dd 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -1,5 +1,5 @@ site_name: api-shield -site_description: Route lifecycle management for FastAPI β€” maintenance mode, env gating, deprecation, and more +site_description: Route lifecycle management for ASGI Python frameworks β€” maintenance mode, env gating, deprecation, and more site_url: https://attakay78.github.io/api-shield repo_url: https://github.com/Attakay78/api-shield repo_name: Attakay78/api-shield @@ -113,6 +113,7 @@ nav: - Production Monitoring & Deployment: guides/production.md - Distributed Deployments: guides/distributed.md - Adapters: + - Overview: adapters/index.md - FastAPI: adapters/fastapi.md - Building Your Own: adapters/custom.md - Contributing: contributing.md From 9f410c1313243c5265fe888509453f4227cd8086 Mon Sep 17 00:00:00 2001 From: Quaicoe Richard Date: Thu, 19 Mar 2026 01:09:28 +0000 Subject: [PATCH 3/4] =?UTF-8?q?Feat:=20Added=20feature=20to=20support=20en?= =?UTF-8?q?v=5Fgated=20route=20mutation=20from=20dashboar=E2=80=A6=20(#36)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit **Env-gate management from dashboard and CLI**: routes can now have their environment gate set or cleared at runtime β€” without redeployment β€” via the dashboard "Env Gate" button (opens an inline modal) and the new `shield env set` / `shield env clear` CLI commands. --- docs/changelog.md | 2 + docs/tutorial/admin-dashboard.md | 2 + docs/tutorial/cli.md | 15 +++ shield/admin/app.py | 2 + shield/cli/client.py | 9 ++ shield/cli/main.py | 94 +++++++++++++++++++ shield/dashboard/routes.py | 48 ++++++++++ shield/dashboard/static/shield.min.css | 2 +- .../templates/partials/modal_env_gate.html | 71 ++++++++++++++ .../templates/partials/route_row.html | 11 +++ 10 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 shield/dashboard/templates/partials/modal_env_gate.html diff --git a/docs/changelog.md b/docs/changelog.md index 75417e4..9d70212 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), ### Added - **`engine.sync` β€” synchronous proxy for sync route handlers and background threads**: every async engine method (`enable`, `disable`, `set_maintenance`, `schedule_maintenance`, `set_env_only`, `enable_global_maintenance`, `disable_global_maintenance`, `set_rate_limit_policy`, `delete_rate_limit_policy`, `reset_rate_limit`, `get_state`, `list_states`, `get_audit_log`) is now mirrored on `engine.sync` using `anyio.from_thread.run()`, the same mechanism the shield decorators use internally. Use `engine.sync.*` from plain `def` FastAPI handlers (which FastAPI runs in a worker thread automatically) and background threads β€” no event-loop wiring required. +- **Env-gate management from dashboard and CLI**: routes can now have their environment gate set or cleared at runtime β€” without redeployment β€” via the dashboard "Env Gate" button (opens an inline modal) and the new `shield env set` / `shield env clear` CLI commands. + ### Documentation - Reframed all docs and README as ASGI-first; expanded framework support tables to include Litestar, Starlette, Quart, and Django (ASGI) as planned adapters; added a dedicated Adapters overview page with a clear explanation of why WSGI frameworks (Flask, Django WSGI, Bottle) are out of scope for this project and will be supported in a separate dedicated library. diff --git a/docs/tutorial/admin-dashboard.md b/docs/tutorial/admin-dashboard.md index 887f008..868332e 100644 --- a/docs/tutorial/admin-dashboard.md +++ b/docs/tutorial/admin-dashboard.md @@ -99,6 +99,7 @@ The dashboard renders all registered routes with live status badges: - **Enable**: restore route to `ACTIVE` - **Maintenance**: put in maintenance with optional reason + window - **Disable**: permanently disable with reason +- **Env Gate**: open a modal to set the allowed environments (comma-separated). The current `allowed_envs` list is pre-filled. Submitting updates the route to `ENV_GATED` immediately; leaving the field empty and submitting is the same as calling `enable` to clear the gate. ### Live updates (SSE) @@ -146,6 +147,7 @@ The same mount exposes a JSON API used by the `shield` CLI: | `POST` | `/api/routes/{key}/enable` | Enable a route | | `POST` | `/api/routes/{key}/disable` | Disable a route | | `POST` | `/api/routes/{key}/maintenance` | Put route in maintenance | +| `POST` | `/api/routes/{key}/env` | Set env gate (`{"envs": ["dev", "staging"]}`) | | `POST` | `/api/routes/{key}/schedule` | Schedule a maintenance window | | `DELETE` | `/api/routes/{key}/schedule` | Cancel a scheduled window | | `GET` | `/api/audit` | Audit log (`?route=` and `?limit=` params) | diff --git a/docs/tutorial/cli.md b/docs/tutorial/cli.md index 5ad7454..0139a55 100644 --- a/docs/tutorial/cli.md +++ b/docs/tutorial/cli.md @@ -96,6 +96,21 @@ shield global disable # restore normal operation --- +## Environment gating + +Restrict a route to specific environments at runtime without redeploying. + +```bash +shield env set /api/debug dev # allow only the "dev" environment +shield env set /api/internal dev staging # allow dev and staging +shield env clear /api/debug # remove the gate, restore to ACTIVE +``` + +!!! note + The engine's `current_env` is set at startup (`ShieldEngine(current_env="prod")`). Requests from an environment not in `allowed_envs` receive a `403 ENV_GATED` response. `shield env clear` is equivalent to calling `shield enable` β€” it transitions the route back to `ACTIVE`. + +--- + ## Rate limits Manage rate limit policies and view blocked requests. Requires `api-shield[rate-limit]` on the server. diff --git a/shield/admin/app.py b/shield/admin/app.py index 29b9567..c0a1c85 100644 --- a/shield/admin/app.py +++ b/shield/admin/app.py @@ -281,6 +281,7 @@ def ShieldAdmin( Route("/routes", _dash.routes_partial), Route("/modal/global/enable", _dash.modal_global_enable), Route("/modal/global/disable", _dash.modal_global_disable), + Route("/modal/env/{path_key}", _dash.modal_env_gate), Route("/modal/{action}/{path_key}", _dash.action_modal), Route( "/global-maintenance/enable", @@ -295,6 +296,7 @@ def ShieldAdmin( Route("/toggle/{path_key}", _dash.toggle, methods=["POST"]), Route("/disable/{path_key}", _dash.disable, methods=["POST"]), Route("/enable/{path_key}", _dash.enable, methods=["POST"]), + Route("/env/{path_key}", _dash.env_gate, methods=["POST"]), Route("/schedule", _dash.schedule, methods=["POST"]), Route("/schedule/{path_key}", _dash.cancel_schedule, methods=["DELETE"]), Route("/audit", _dash.audit_page), diff --git a/shield/cli/client.py b/shield/cli/client.py index 9f169f5..2aca095 100644 --- a/shield/cli/client.py +++ b/shield/cli/client.py @@ -181,6 +181,15 @@ async def schedule( ) return cast(dict[str, Any], self._check(resp)) + async def env_gate(self, path_key: str, envs: list[str]) -> dict[str, Any]: + """POST /api/routes/{path_key}/env β€” restrict a route to specific environments.""" + async with self._make_client() as c: + resp = await c.post( + f"/api/routes/{_encode_path(path_key)}/env", + json={"envs": envs}, + ) + return cast(dict[str, Any], self._check(resp)) + async def cancel_schedule(self, path_key: str) -> dict[str, Any]: """DELETE /api/routes/{path_key}/schedule β€” cancel a scheduled window.""" async with self._make_client() as c: diff --git a/shield/cli/main.py b/shield/cli/main.py index 418ce3d..a177679 100644 --- a/shield/cli/main.py +++ b/shield/cli/main.py @@ -760,6 +760,100 @@ async def _run_er() -> None: _run(_run_er) +# --------------------------------------------------------------------------- +# Env gate command group (shield env ...) +# --------------------------------------------------------------------------- + +env_app = typer.Typer( + name="env", + help="Manage environment-gating for routes.", + no_args_is_help=True, +) +cli.add_typer(env_app, name="env") + + +@env_app.command("set") +def env_set( + route: str = typer.Argument(..., help="Route: /path or METHOD:/path"), + envs: list[str] = typer.Argument( + ..., help="Environment names to allow (e.g. dev staging prod)." + ), + reason: str = typer.Option("", "--reason", "-r", help="Optional note for the audit log."), +) -> None: + """Restrict a route to specific environments. + + Only requests where the engine's current_env matches one of the given + environments will be allowed through. All other environments receive 403. + + Examples: + + \b + shield env set /api/debug dev + shield env set /api/internal dev staging + """ + + async def _run_env_set() -> None: + key = _parse_route(route) + client = make_client() + try: + keys_to_apply = [key] + state = await client.env_gate(key, list(envs)) + states = [state] + except ShieldClientError as exc: + if not exc.ambiguous_matches: + raise + keys_to_apply = _confirm_ambiguous(exc.ambiguous_matches, "env") + states = [await client.env_gate(k, list(envs)) for k in keys_to_apply] + + for k, state in zip(keys_to_apply, states): + allowed = ", ".join(state.get("allowed_envs") or []) + console.print( + f"[blue]πŸ”’[/blue] {k} β†’ [blue]{state['status'].upper()}[/blue]" + + (f" [dim]({allowed})[/dim]" if allowed else "") + ) + if reason: + console.print(f" Reason: {reason}") + + _run(_run_env_set) + + +@env_app.command("clear") +def env_clear( + route: str = typer.Argument(..., help="Route: /path or METHOD:/path"), + reason: str = typer.Option("", "--reason", "-r", help="Optional note for the audit log."), +) -> None: + """Remove env-gating from a route, restoring it to active status. + + Examples: + + \b + shield env clear /api/debug + """ + + async def _run_env_clear() -> None: + key = _parse_route(route) + client = make_client() + try: + keys_to_apply = [key] + state = await client.enable(key, reason=reason) + states = [state] + except ShieldClientError as exc: + if not exc.ambiguous_matches: + raise + keys_to_apply = _confirm_ambiguous(exc.ambiguous_matches, "env clear") + states = [await client.enable(k, reason=reason) for k in keys_to_apply] + + for k, state in zip(keys_to_apply, states): + console.print( + f"[green]βœ“[/green] {k} β†’ [green]{state['status'].upper()}[/green]" + " [dim](env gate removed)[/dim]" + ) + if reason: + console.print(f" Reason: {reason}") + + _run(_run_env_clear) + + # --------------------------------------------------------------------------- # Rate limits command group (shield rate-limits ...) # --------------------------------------------------------------------------- diff --git a/shield/dashboard/routes.py b/shield/dashboard/routes.py index 2450cfb..96a9a90 100644 --- a/shield/dashboard/routes.py +++ b/shield/dashboard/routes.py @@ -407,6 +407,54 @@ async def global_maintenance_disable(request: Request) -> HTMLResponse: return HTMLResponse(_render_global_widget(tpl, config, prefix)) +async def modal_env_gate(request: Request) -> HTMLResponse: + """Return the env-gate modal form pre-filled with the current allowed envs.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + path_key = request.path_params["path_key"] + route_path = _decode_path(path_key) + slug = path_slug(route_path) + + try: + state = await engine.get_state(route_path) + current_envs = ", ".join(state.allowed_envs or []) + except Exception: + current_envs = "" + + html = tpl.env.get_template("partials/modal_env_gate.html").render( + route_path=route_path, + path_slug=slug, + submit_path=f"{prefix}/env/{path_key}", + current_envs=current_envs, + ) + return HTMLResponse(html) + + +async def env_gate(request: Request) -> HTMLResponse: + """Apply env-gating from form data and return the updated route row. + + Expected form fields: ``envs`` β€” comma-separated environment names. + """ + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + route_path = _decode_path(request.path_params["path_key"]) + + form_data = await request.form() + raw = str(form_data.get("envs", "")) + envs = [e.strip() for e in raw.replace(",", " ").split() if e.strip()] + + try: + new_state = await engine.set_env_only( + route_path, envs, actor=_actor(request), platform=_platform(request) + ) + except RouteProtectedException: + new_state = await engine.get_state(route_path) + + return HTMLResponse(_render_route_row(tpl, new_state, prefix)) + + async def action_modal(request: Request) -> HTMLResponse: """Return the styled action confirmation modal content. diff --git a/shield/dashboard/static/shield.min.css b/shield/dashboard/static/shield.min.css index 712f912..5dc8f7d 100644 --- a/shield/dashboard/static/shield.min.css +++ b/shield/dashboard/static/shield.min.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-blue-50:oklch(97% .014 254.604);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--drop-shadow-md:0 3px 3px #0000001f;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-20{bottom:calc(var(--spacing) * 20)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:480px){.container{max-width:480px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-\[4\.5rem\]{height:4.5rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[18rem\]{max-width:18rem}.max-w-\[200px\]{max-width:200px}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[600px\]{min-width:600px}.min-w-\[640px\]{min-width:640px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.list-none{list-style-type:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-amber-200{border-color:var(--color-amber-200)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-violet-600{border-color:var(--color-violet-600)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-600{background-color:var(--color-red-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/60{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/60{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-white{background-color:var(--color-white)}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-500{color:var(--color-blue-500)}.text-blue-700{color:var(--color-blue-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-700{color:var(--color-indigo-700)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-slate-400::placeholder{color:var(--color-slate-400)}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_3px_0_rgb\(0\,0\,0\,0\.04\)\]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-600\/20{--tw-ring-color:#dd740033}@supports (color:color-mix(in lab, red, red)){.ring-amber-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-amber-600) 20%, transparent)}}.ring-blue-600\/20{--tw-ring-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.ring-blue-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-600) 20%, transparent)}}.ring-emerald-600\/20{--tw-ring-color:#00976733}@supports (color:color-mix(in lab, red, red)){.ring-emerald-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-emerald-600) 20%, transparent)}}.ring-red-300\/40{--tw-ring-color:#ffa3a366}@supports (color:color-mix(in lab, red, red)){.ring-red-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-red-300) 40%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-sky-600\/20{--tw-ring-color:#0084cc33}@supports (color:color-mix(in lab, red, red)){.ring-sky-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-sky-600) 20%, transparent)}}.ring-slate-400\/20{--tw-ring-color:#90a1b933}@supports (color:color-mix(in lab, red, red)){.ring-slate-400\/20{--tw-ring-color:color-mix(in oklab, var(--color-slate-400) 20%, transparent)}}.ring-violet-600\/20{--tw-ring-color:#7f22fe33}@supports (color:color-mix(in lab, red, red)){.ring-violet-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-violet-600) 20%, transparent)}}.drop-shadow-md{--tw-drop-shadow-size:drop-shadow(0 3px 3px var(--tw-drop-shadow-color,#0000001f));--tw-drop-shadow:drop-shadow(var(--drop-shadow-md));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:bg-slate-50:is(:where(.group):hover *){background-color:var(--color-slate-50)}}.placeholder\:font-sans::placeholder{font-family:var(--font-sans)}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}@media (hover:hover){.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-emerald-300:hover{border-color:var(--color-emerald-300)}.hover\:border-indigo-300:hover{border-color:var(--color-indigo-300)}.hover\:border-red-200:hover{border-color:var(--color-red-200)}.hover\:border-red-300:hover{border-color:var(--color-red-300)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-slate-300:hover{border-color:var(--color-slate-300)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-50:hover{background-color:var(--color-sky-50)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/70:hover{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/70:hover{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-violet-50:hover{background-color:var(--color-violet-50)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-sky-600:hover{color:var(--color-sky-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-400:focus{--tw-ring-color:var(--color-amber-400)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-indigo-500\/20:focus{--tw-ring-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.focus\:ring-indigo-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 20%, transparent)}}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:ring-violet-500:focus{--tw-ring-color:var(--color-violet-500)}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:bg-indigo-800:active{background-color:var(--color-indigo-800)}@media (min-width:480px){.xs\:hidden{display:none}.xs\:inline{display:inline}.xs\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:40rem){.sm\:col-span-1{grid-column:span 1/span 1}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:h-14{height:calc(var(--spacing) * 14)}.sm\:w-72{width:calc(var(--spacing) * 72)}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-5{gap:calc(var(--spacing) * 5)}.sm\:self-auto{align-self:auto}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:pb-3{padding-bottom:calc(var(--spacing) * 3)}}@media (min-width:48rem){.md\:table-cell{display:table-cell}}@media (min-width:64rem){.lg\:table-cell{display:table-cell}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--drop-shadow-md:0 3px 3px #0000001f;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-20{bottom:calc(var(--spacing) * 20)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:480px){.container{max-width:480px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-\[4\.5rem\]{height:4.5rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[18rem\]{max-width:18rem}.max-w-\[200px\]{max-width:200px}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[600px\]{min-width:600px}.min-w-\[640px\]{min-width:640px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.list-none{list-style-type:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-violet-600{border-color:var(--color-violet-600)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-600{background-color:var(--color-red-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/60{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/60{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-white{background-color:var(--color-white)}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-700{color:var(--color-indigo-700)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-slate-400::placeholder{color:var(--color-slate-400)}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_3px_0_rgb\(0\,0\,0\,0\.04\)\]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-600\/20{--tw-ring-color:#dd740033}@supports (color:color-mix(in lab, red, red)){.ring-amber-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-amber-600) 20%, transparent)}}.ring-blue-600\/20{--tw-ring-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.ring-blue-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-600) 20%, transparent)}}.ring-emerald-600\/20{--tw-ring-color:#00976733}@supports (color:color-mix(in lab, red, red)){.ring-emerald-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-emerald-600) 20%, transparent)}}.ring-red-300\/40{--tw-ring-color:#ffa3a366}@supports (color:color-mix(in lab, red, red)){.ring-red-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-red-300) 40%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-sky-600\/20{--tw-ring-color:#0084cc33}@supports (color:color-mix(in lab, red, red)){.ring-sky-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-sky-600) 20%, transparent)}}.ring-slate-400\/20{--tw-ring-color:#90a1b933}@supports (color:color-mix(in lab, red, red)){.ring-slate-400\/20{--tw-ring-color:color-mix(in oklab, var(--color-slate-400) 20%, transparent)}}.ring-violet-600\/20{--tw-ring-color:#7f22fe33}@supports (color:color-mix(in lab, red, red)){.ring-violet-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-violet-600) 20%, transparent)}}.drop-shadow-md{--tw-drop-shadow-size:drop-shadow(0 3px 3px var(--tw-drop-shadow-color,#0000001f));--tw-drop-shadow:drop-shadow(var(--drop-shadow-md));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:bg-slate-50:is(:where(.group):hover *){background-color:var(--color-slate-50)}}.placeholder\:font-sans::placeholder{font-family:var(--font-sans)}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}@media (hover:hover){.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-emerald-300:hover{border-color:var(--color-emerald-300)}.hover\:border-indigo-300:hover{border-color:var(--color-indigo-300)}.hover\:border-red-200:hover{border-color:var(--color-red-200)}.hover\:border-red-300:hover{border-color:var(--color-red-300)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-slate-300:hover{border-color:var(--color-slate-300)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-50:hover{background-color:var(--color-sky-50)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/70:hover{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/70:hover{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-violet-50:hover{background-color:var(--color-violet-50)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-sky-600:hover{color:var(--color-sky-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-400:focus{--tw-ring-color:var(--color-amber-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-indigo-500\/20:focus{--tw-ring-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.focus\:ring-indigo-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 20%, transparent)}}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:ring-violet-500:focus{--tw-ring-color:var(--color-violet-500)}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:bg-indigo-800:active{background-color:var(--color-indigo-800)}@media (min-width:480px){.xs\:hidden{display:none}.xs\:inline{display:inline}.xs\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:40rem){.sm\:col-span-1{grid-column:span 1/span 1}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:h-14{height:calc(var(--spacing) * 14)}.sm\:w-72{width:calc(var(--spacing) * 72)}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-5{gap:calc(var(--spacing) * 5)}.sm\:self-auto{align-self:auto}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:pb-3{padding-bottom:calc(var(--spacing) * 3)}}@media (min-width:48rem){.md\:table-cell{display:table-cell}}@media (min-width:64rem){.lg\:table-cell{display:table-cell}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/shield/dashboard/templates/partials/modal_env_gate.html b/shield/dashboard/templates/partials/modal_env_gate.html new file mode 100644 index 0000000..786d1ed --- /dev/null +++ b/shield/dashboard/templates/partials/modal_env_gate.html @@ -0,0 +1,71 @@ +
+ + {# ── Header ───────────────────────────────────────────────── #} +
+
+ + + +
+
+

Set Environment Gate

+

+ Restrict this route to specific environments only. Requests from other environments will receive a 403 response. +

+
+ + + + + {{ route_path }} + +
+
+
+ + {# ── Form ──────────────────────────────────────────────────── #} +
+ +
+ + +

+ Comma-separated list of environment names (e.g. dev, staging). +

+
+ +
+ + +
+
+
diff --git a/shield/dashboard/templates/partials/route_row.html b/shield/dashboard/templates/partials/route_row.html index 8f48b16..9fcd8aa 100644 --- a/shield/dashboard/templates/partials/route_row.html +++ b/shield/dashboard/templates/partials/route_row.html @@ -115,6 +115,17 @@ {% endif %} +
{# Schedule window β€” collapsible form #} From 5531abaee712f359b73e0bfa2559ca6f8a7b8331 Mon Sep 17 00:00:00 2001 From: Quaicoe Richard Date: Thu, 19 Mar 2026 03:43:02 +0000 Subject: [PATCH 4/4] feat: Added support for global rate limiting (#37) **Global rate limit** (`engine.set_global_rate_limit`): a single rate limit policy applied across all routes with higher precedence than per-route limits checked first, so a request blocked globally never touches a per-route counter. Supports all key strategies, burst allowance, and per-route exemptions (`exempt_routes`). Configurable from the dashboard Rate Limits page and the new `shield grl` CLI command group (`get`, `set`, `delete`, `reset`, `enable`, `disable`). **Global rate limit pause / resume** (`engine.disable_global_rate_limit` / `engine.enable_global_rate_limit`): suspend enforcement without removing the policy, then resume it later. Per-route policies are always unaffected. --- docs/changelog.md | 7 +- docs/reference/cli.md | 84 +++- docs/reference/engine.md | 80 ++++ docs/reference/rate-limiting.md | 220 +++++++++- pyproject.toml | 2 +- shield/admin/api.py | 79 ++++ shield/admin/app.py | 34 ++ shield/cli/client.py | 57 +++ shield/cli/main.py | 162 ++++++++ shield/core/backends/base.py | 39 ++ shield/core/engine.py | 390 ++++++++++++++++-- shield/core/rate_limit/models.py | 40 ++ shield/dashboard/routes.py | 102 +++++ shield/dashboard/static/shield.min.css | 2 +- .../templates/partials/audit_row.html | 16 +- .../templates/partials/global_rl_card.html | 133 ++++++ .../templates/partials/modal_global_rl.html | 129 ++++++ .../partials/modal_global_rl_delete.html | 39 ++ .../partials/modal_global_rl_reset.html | 39 ++ .../templates/partials/rate_limit_hits.html | 2 +- shield/dashboard/templates/rate_limits.html | 5 + uv.lock | 8 +- 22 files changed, 1608 insertions(+), 61 deletions(-) create mode 100644 shield/dashboard/templates/partials/global_rl_card.html create mode 100644 shield/dashboard/templates/partials/modal_global_rl.html create mode 100644 shield/dashboard/templates/partials/modal_global_rl_delete.html create mode 100644 shield/dashboard/templates/partials/modal_global_rl_reset.html diff --git a/docs/changelog.md b/docs/changelog.md index 9d70212..d948ddb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -6,12 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), --- -## [Unreleased] +## [0.7.0] ### Added - **`engine.sync` β€” synchronous proxy for sync route handlers and background threads**: every async engine method (`enable`, `disable`, `set_maintenance`, `schedule_maintenance`, `set_env_only`, `enable_global_maintenance`, `disable_global_maintenance`, `set_rate_limit_policy`, `delete_rate_limit_policy`, `reset_rate_limit`, `get_state`, `list_states`, `get_audit_log`) is now mirrored on `engine.sync` using `anyio.from_thread.run()`, the same mechanism the shield decorators use internally. Use `engine.sync.*` from plain `def` FastAPI handlers (which FastAPI runs in a worker thread automatically) and background threads β€” no event-loop wiring required. - **Env-gate management from dashboard and CLI**: routes can now have their environment gate set or cleared at runtime β€” without redeployment β€” via the dashboard "Env Gate" button (opens an inline modal) and the new `shield env set` / `shield env clear` CLI commands. +- **Global rate limit** (`engine.set_global_rate_limit`): a single rate limit policy applied across all routes with higher precedence than per-route limits β€” checked first, so a request blocked globally never touches a per-route counter. Supports all key strategies, burst allowance, and per-route exemptions (`exempt_routes`). Configurable from the dashboard Rate Limits page and the new `shield grl` CLI command group (`get`, `set`, `delete`, `reset`, `enable`, `disable`). +- **Global rate limit pause / resume** (`engine.disable_global_rate_limit` / `engine.enable_global_rate_limit`): suspend enforcement without removing the policy, then resume it later. Per-route policies are always unaffected. ### Documentation @@ -144,7 +146,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), - `shield` CLI with direct backend access - `shield status`, `shield enable`, `shield disable`, `shield maintenance`, `shield schedule`, `shield log` -[Unreleased]: https://github.com/Attakay78/api-shield/compare/v0.5.0...HEAD +[0.7.0]: https://github.com/Attakay78/api-shield/compare/v0.6.0...v0.7.0 +[0.6.0]: https://github.com/Attakay78/api-shield/compare/v0.5.0...v0.6.0 [0.5.0]: https://github.com/Attakay78/api-shield/compare/v0.4.0...v0.5.0 [0.4.0]: https://github.com/Attakay78/api-shield/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/Attakay78/api-shield/compare/v0.2.0...v0.3.0 diff --git a/docs/reference/cli.md b/docs/reference/cli.md index c205d79..6712a2f 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -309,11 +309,93 @@ shield rl hits --route /api/pay # filter to one route --- +## Global rate limit commands + +`shield grl` and `shield global-rate-limit` are aliases for the same command group. Requires `api-shield[rate-limit]` on the server. + +```bash +shield grl get +shield global-rate-limit get # identical +``` + +### `shield grl get` + +Show the current global rate limit policy, including limit, algorithm, key strategy, burst, exempt routes, and enabled state. + +```bash +shield grl get +``` + +--- + +### `shield grl set` + +Configure the global rate limit. Creates a new policy or replaces the existing one. + +```bash +shield grl set +``` + +```bash +shield grl set 1000/minute +shield grl set 500/minute --algorithm sliding_window --key ip +shield grl set 2000/hour --burst 50 --exempt /health --exempt GET:/metrics +``` + +| Option | Description | +|---|---| +| `--algorithm TEXT` | Counting algorithm: `fixed_window`, `sliding_window`, `moving_window`, `token_bucket` | +| `--key TEXT` | Key strategy: `ip`, `user`, `api_key`, `global` | +| `--burst INT` | Extra requests above the base limit | +| `--exempt TEXT` | Exempt route (repeatable). Bare path (`/health`) or method-prefixed (`GET:/metrics`) | + +--- + +### `shield grl delete` + +Remove the global rate limit policy entirely. + +```bash +shield grl delete +``` + +--- + +### `shield grl reset` + +Clear all global rate limit counters. The policy is kept; clients get their full quota back on the next request. + +```bash +shield grl reset +``` + +--- + +### `shield grl enable` + +Resume a paused global rate limit policy. + +```bash +shield grl enable +``` + +--- + +### `shield grl disable` + +Pause the global rate limit without removing it. Per-route policies continue to enforce normally. + +```bash +shield grl disable +``` + +--- + ## Audit log ### `shield log` -Display the audit log, newest entries first. The `Status` column shows `old > new` for route state changes and a coloured action label (`set`, `update`, `reset`, `delete`) for rate limit policy changes. +Display the audit log, newest entries first. The `Status` column shows `old > new` for route state changes and a coloured action label for rate limit policy changes (including global RL actions such as `global set`, `global reset`, `global enabled`, `global disabled`). ```bash shield log # page 1, 20 rows diff --git a/docs/reference/engine.md b/docs/reference/engine.md index 050fcc6..4dca111 100644 --- a/docs/reference/engine.md +++ b/docs/reference/engine.md @@ -325,6 +325,12 @@ FastAPI automatically runs sync handlers in a worker thread, which is exactly th | `set_rate_limit_policy(path, method, limit, ...)` | `await engine.set_rate_limit_policy(...)` | | `delete_rate_limit_policy(path, method, actor)` | `await engine.delete_rate_limit_policy(...)` | | `reset_rate_limit(path, method, actor)` | `await engine.reset_rate_limit(...)` | +| `set_global_rate_limit(limit, ...)` | `await engine.set_global_rate_limit(...)` | +| `get_global_rate_limit()` | `await engine.get_global_rate_limit()` | +| `delete_global_rate_limit(actor)` | `await engine.delete_global_rate_limit(...)` | +| `reset_global_rate_limit(actor)` | `await engine.reset_global_rate_limit(...)` | +| `enable_global_rate_limit(actor)` | `await engine.enable_global_rate_limit(...)` | +| `disable_global_rate_limit(actor)` | `await engine.disable_global_rate_limit(...)` | | `get_state(path)` | `await engine.get_state(path)` | | `list_states()` | `await engine.list_states()` | | `get_audit_log(path, limit)` | `await engine.get_audit_log(...)` | @@ -544,6 +550,80 @@ Return all registered rate limit policies. --- +## Global rate limit + +A single policy applied across all routes with higher precedence than per-route limits. Checked first on every request β€” a request blocked by the global limit never touches the per-route counter. Per-route checks only run after the global limit passes (or the route is exempt, or no global limit is configured). + +### `set_global_rate_limit` + +```python +async def set_global_rate_limit( + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + on_missing_key: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + actor: str = "system", + platform: str = "", +) -> GlobalRateLimitPolicy +``` + +Create or replace the global rate limit policy. Logged as `global_rl_set` or `global_rl_updated`. + +--- + +### `get_global_rate_limit` + +```python +async def get_global_rate_limit() -> GlobalRateLimitPolicy | None +``` + +Return the current policy, or `None` if not configured. + +--- + +### `delete_global_rate_limit` + +```python +async def delete_global_rate_limit(*, actor: str = "system") -> None +``` + +Remove the global policy. Logged as `global_rl_deleted`. + +--- + +### `reset_global_rate_limit` + +```python +async def reset_global_rate_limit(*, actor: str = "system") -> None +``` + +Clear all global counters. Policy is kept. Logged as `global_rl_reset`. + +--- + +### `enable_global_rate_limit` + +```python +async def enable_global_rate_limit(*, actor: str = "system") -> None +``` + +Resume a paused global policy. No-op if already enabled. Logged as `global_rl_enabled`. + +--- + +### `disable_global_rate_limit` + +```python +async def disable_global_rate_limit(*, actor: str = "system") -> None +``` + +Pause the global policy without removing it. Per-route policies are unaffected. Logged as `global_rl_disabled`. + +--- + ## Webhooks ### `add_webhook` diff --git a/docs/reference/rate-limiting.md b/docs/reference/rate-limiting.md index dc5b720..e9df8f4 100644 --- a/docs/reference/rate-limiting.md +++ b/docs/reference/rate-limiting.md @@ -349,6 +349,117 @@ for p in policies: --- +## Global rate limit + +A global rate limit applies a single policy across **all routes** with higher precedence than per-route limits. It is checked **first** on every request β€” if the global limit is exceeded the request is rejected immediately and the per-route counter is never touched. Per-route policies only run after the global limit passes (or when the route is exempt, or no global limit is configured). + +### `GlobalRateLimitPolicy` + +```python +from shield.core.rate_limit.models import GlobalRateLimitPolicy +``` + +| Field | Type | Default | Description | +|---|---|---|---| +| `limit` | `str` | required | Limit string, e.g. `"1000/minute"` | +| `algorithm` | `str` | `"fixed_window"` | Counting algorithm | +| `key_strategy` | `str` | `"ip"` | Key strategy: `ip`, `user`, `api_key`, `global` | +| `on_missing_key` | `str \| None` | strategy default | Behaviour when the key extractor returns `None` | +| `burst` | `int` | `0` | Extra requests allowed above `limit` | +| `exempt_routes` | `list[str]` | `[]` | Routes skipped by the global limit. Bare path (`"/health"`) exempts all methods; method-prefixed (`"GET:/metrics"`) exempts that method only | +| `enabled` | `bool` | `True` | Whether the policy is currently enforced. `False` = paused (policy kept, counters not incremented) | + +### Engine methods + +#### `set_global_rate_limit` + +```python +async def set_global_rate_limit( + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + on_missing_key: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + actor: str = "system", + platform: str = "", +) -> GlobalRateLimitPolicy +``` + +Create or replace the global rate limit policy. Persists to the backend. Logged as `global_rl_set` (new) or `global_rl_updated` (replacement). + +```python +await engine.set_global_rate_limit( + "1000/minute", + key_strategy="ip", + exempt_routes=["/health", "GET:/metrics"], + actor="alice", +) +``` + +--- + +#### `get_global_rate_limit` + +```python +async def get_global_rate_limit() -> GlobalRateLimitPolicy | None +``` + +Return the current policy, or `None` if not configured. + +--- + +#### `delete_global_rate_limit` + +```python +async def delete_global_rate_limit(*, actor: str = "system") -> None +``` + +Remove the global rate limit policy entirely. Logged as `global_rl_deleted`. + +--- + +#### `reset_global_rate_limit` + +```python +async def reset_global_rate_limit(*, actor: str = "system") -> None +``` + +Clear all global counters so the limit starts fresh. The policy itself is not removed. Logged as `global_rl_reset`. + +--- + +#### `enable_global_rate_limit` + +```python +async def enable_global_rate_limit(*, actor: str = "system") -> None +``` + +Resume a paused global rate limit policy. No-op if already enabled or not configured. Logged as `global_rl_enabled`. + +--- + +#### `disable_global_rate_limit` + +```python +async def disable_global_rate_limit(*, actor: str = "system") -> None +``` + +Pause the global rate limit without removing it. Requests are no longer counted or blocked by the global policy; per-route policies are unaffected. Logged as `global_rl_disabled`. + +--- + +### Dashboard + +The **Rate Limits** page includes a Global Rate Limit card above the policies table. + +- **Not configured** β€” compact bar with a "Set Global Limit" button. +- **Active** β€” info card showing limit, algorithm, key strategy, burst, and exempt routes. Action buttons: Pause, Edit, Reset, Remove. +- **Paused** β€” same card with a "Paused" badge (grey) and a Resume button instead of Pause. The limit string is dimmed to indicate it is not being enforced. + +--- + ## CLI commands `shield rl` and `shield rate-limits` are aliases for the same command group β€” use whichever you prefer. @@ -432,16 +543,115 @@ shield rl hits --limit 50 # show more --- +Now add the global rate limit CLI commands section after `shield rl hits`: + +--- + +### `shield grl` / `shield global-rate-limit` + +`shield grl` and `shield global-rate-limit` are aliases for the global rate limit command group. + +```bash +shield grl get # show current policy +shield global-rate-limit get # identical +``` + +#### `shield grl get` + +Show the current global rate limit policy (limit, algorithm, key strategy, burst, exempt routes, enabled state). + +```bash +shield grl get +``` + +--- + +#### `shield grl set` + +Configure the global rate limit. Creates a new policy or replaces the existing one. + +```bash +shield grl set +``` + +```bash +shield grl set 1000/minute +shield grl set 500/minute --algorithm sliding_window --key ip +shield grl set 2000/hour --burst 50 --exempt /health --exempt GET:/metrics +``` + +| Option | Description | +|---|---| +| `--algorithm TEXT` | Counting algorithm: `fixed_window`, `sliding_window`, `moving_window`, `token_bucket` | +| `--key TEXT` | Key strategy: `ip`, `user`, `api_key`, `global` | +| `--burst INT` | Extra requests above the base limit | +| `--exempt TEXT` | Exempt route (repeatable). Bare path or `METHOD:/path` | + +--- + +#### `shield grl delete` + +Remove the global rate limit policy entirely. + +```bash +shield grl delete +``` + +--- + +#### `shield grl reset` + +Clear all global rate limit counters. The policy is kept; clients get their full quota back on the next request. + +```bash +shield grl reset +``` + +--- + +#### `shield grl enable` + +Resume a paused global rate limit policy. + +```bash +shield grl enable +``` + +--- + +#### `shield grl disable` + +Pause the global rate limit without removing it. Per-route policies continue to enforce normally. + +```bash +shield grl disable +``` + +--- + ## Audit log integration -Rate limit policy changes are recorded in the same audit log as route state changes. The `action` field uses one of four values: +Rate limit policy changes are recorded in the same audit log as route state changes. The `action` field uses the following values: + +**Per-route:** + +| Action | Badge | When | +|---|---|---| +| `rl_policy_set` | set | New per-route policy registered | +| `rl_policy_updated` | update | Existing per-route policy replaced | +| `rl_reset` | reset | Per-route counters cleared | +| `rl_policy_deleted` | delete | Per-route policy removed | + +**Global:** | Action | Badge | When | |---|---|---| -| `rl_policy_set` | set | New policy registered | -| `rl_policy_updated` | update | Existing policy replaced | -| `rl_reset` | reset | Counters cleared | -| `rl_policy_deleted` | delete | Policy removed | +| `global_rl_set` | global set | Global policy created | +| `global_rl_updated` | global update | Global policy replaced | +| `global_rl_reset` | global reset | Global counters cleared | +| `global_rl_deleted` | global delete | Global policy removed | +| `global_rl_enabled` | global enabled | Policy resumed after pause | +| `global_rl_disabled` | global disabled | Policy paused | View in the dashboard at `/shield/audit` or via `shield log`. diff --git a/pyproject.toml b/pyproject.toml index dda0014..83e6a74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "api-shield" -version = "0.6.0" +version = "0.7.0" description = "Route lifecycle management for APIs β€” maintenance mode, env gating, deprecation, and more" readme = "README.md" license = { text = "MIT" } diff --git a/shield/admin/api.py b/shield/admin/api.py index 3896905..daa561b 100644 --- a/shield/admin/api.py +++ b/shield/admin/api.py @@ -430,3 +430,82 @@ async def delete_rate_limit_policy_api(request: Request) -> JSONResponse: method, path = raw_key.split(":", 1) await engine.delete_rate_limit_policy(path=path, method=method, actor=actor) return JSONResponse({"ok": True, "path": path, "method": method}) + + +# --------------------------------------------------------------------------- +# Global rate limit +# --------------------------------------------------------------------------- + + +async def get_global_rate_limit(request: Request) -> JSONResponse: + """GET /api/global-rate-limit β€” current global rate limit policy.""" + policy = await _engine(request).get_global_rate_limit() + if policy is None: + return JSONResponse({"enabled": False, "policy": None}) + return JSONResponse({"enabled": policy.enabled, "policy": policy.model_dump(mode="json")}) + + +async def set_global_rate_limit_api(request: Request) -> JSONResponse: + """POST /api/global-rate-limit β€” set or update the global rate limit policy.""" + engine = _engine(request) + actor = _actor(request) + try: + body = await request.json() + except Exception: + return JSONResponse({"error": "Invalid JSON body"}, status_code=400) + + limit = body.get("limit") + if not limit: + return JSONResponse({"error": "limit is required"}, status_code=400) + + exempt = body.get("exempt_routes", []) + if not isinstance(exempt, list): + return JSONResponse({"error": "exempt_routes must be a list"}, status_code=400) + + try: + policy = await engine.set_global_rate_limit( + limit=limit, + algorithm=body.get("algorithm"), + key_strategy=body.get("key_strategy"), + on_missing_key=body.get("on_missing_key"), + burst=int(body.get("burst", 0)), + exempt_routes=exempt, + actor=actor, + platform=_platform(request), + ) + except Exception as exc: + return JSONResponse({"error": str(exc)}, status_code=400) + + return JSONResponse(policy.model_dump(mode="json"), status_code=201) + + +async def delete_global_rate_limit_api(request: Request) -> JSONResponse: + """DELETE /api/global-rate-limit β€” remove the global rate limit policy.""" + engine = _engine(request) + actor = _actor(request) + await engine.delete_global_rate_limit(actor=actor, platform=_platform(request)) + return JSONResponse({"ok": True}) + + +async def reset_global_rate_limit_api(request: Request) -> JSONResponse: + """DELETE /api/global-rate-limit/reset β€” reset global rate limit counters.""" + engine = _engine(request) + actor = _actor(request) + await engine.reset_global_rate_limit(actor=actor, platform=_platform(request)) + return JSONResponse({"ok": True}) + + +async def enable_global_rate_limit_api(request: Request) -> JSONResponse: + """POST /api/global-rate-limit/enable β€” resume a paused global rate limit.""" + engine = _engine(request) + actor = _actor(request) + await engine.enable_global_rate_limit(actor=actor, platform=_platform(request)) + return JSONResponse({"ok": True}) + + +async def disable_global_rate_limit_api(request: Request) -> JSONResponse: + """POST /api/global-rate-limit/disable β€” pause the global rate limit.""" + engine = _engine(request) + actor = _actor(request) + await engine.disable_global_rate_limit(actor=actor, platform=_platform(request)) + return JSONResponse({"ok": True}) diff --git a/shield/admin/app.py b/shield/admin/app.py index c0a1c85..dd8d73a 100644 --- a/shield/admin/app.py +++ b/shield/admin/app.py @@ -281,6 +281,9 @@ def ShieldAdmin( Route("/routes", _dash.routes_partial), Route("/modal/global/enable", _dash.modal_global_enable), Route("/modal/global/disable", _dash.modal_global_disable), + Route("/modal/global-rl", _dash.modal_global_rl), + Route("/modal/global-rl/delete", _dash.modal_global_rl_delete), + Route("/modal/global-rl/reset", _dash.modal_global_rl_reset), Route("/modal/env/{path_key}", _dash.modal_env_gate), Route("/modal/{action}/{path_key}", _dash.action_modal), Route( @@ -311,6 +314,11 @@ def ShieldAdmin( Route("/rl/reset/{path_key}", _dash.rl_reset, methods=["POST"]), Route("/rl/edit/{path_key}", _dash.rl_edit, methods=["POST"]), Route("/rl/delete/{path_key}", _dash.rl_delete, methods=["POST"]), + Route("/global-rl/set", _dash.global_rl_set, methods=["POST"]), + Route("/global-rl/delete", _dash.global_rl_delete, methods=["POST"]), + Route("/global-rl/reset", _dash.global_rl_reset, methods=["POST"]), + Route("/global-rl/enable", _dash.global_rl_enable, methods=["POST"]), + Route("/global-rl/disable", _dash.global_rl_disable, methods=["POST"]), Route("/events", _dash.events), # ── REST API (CLI) ──────────────────────────────────────────── Route("/api/auth/login", _api.auth_login, methods=["POST"]), @@ -349,6 +357,32 @@ def ShieldAdmin( _api.delete_rate_limit_policy_api, methods=["DELETE"], ), + Route("/api/global-rate-limit", _api.get_global_rate_limit, methods=["GET"]), + Route( + "/api/global-rate-limit", + _api.set_global_rate_limit_api, + methods=["POST"], + ), + Route( + "/api/global-rate-limit", + _api.delete_global_rate_limit_api, + methods=["DELETE"], + ), + Route( + "/api/global-rate-limit/reset", + _api.reset_global_rate_limit_api, + methods=["DELETE"], + ), + Route( + "/api/global-rate-limit/enable", + _api.enable_global_rate_limit_api, + methods=["POST"], + ), + Route( + "/api/global-rate-limit/disable", + _api.disable_global_rate_limit_api, + methods=["POST"], + ), ], ) diff --git a/shield/cli/client.py b/shield/cli/client.py index 2aca095..c16b1f6 100644 --- a/shield/cli/client.py +++ b/shield/cli/client.py @@ -298,6 +298,63 @@ async def delete_rate_limit_policy(self, path: str, method: str) -> dict[str, An resp = await c.delete(f"/api/rate-limits/{path_key}") return cast(dict[str, Any], self._check(resp)) + # ------------------------------------------------------------------ + # Global rate limit + # ------------------------------------------------------------------ + + async def get_global_rate_limit(self) -> dict[str, Any]: + """GET /api/global-rate-limit β€” current global rate limit policy.""" + async with self._make_client() as c: + resp = await c.get("/api/global-rate-limit") + return cast(dict[str, Any], self._check(resp)) + + async def set_global_rate_limit( + self, + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + ) -> dict[str, Any]: + """POST /api/global-rate-limit β€” set or update the global rate limit policy.""" + payload: dict[str, Any] = { + "limit": limit, + "burst": burst, + "exempt_routes": exempt_routes or [], + } + if algorithm: + payload["algorithm"] = algorithm + if key_strategy: + payload["key_strategy"] = key_strategy + async with self._make_client() as c: + resp = await c.post("/api/global-rate-limit", json=payload) + return cast(dict[str, Any], self._check(resp)) + + async def delete_global_rate_limit(self) -> dict[str, Any]: + """DELETE /api/global-rate-limit β€” remove the global rate limit policy.""" + async with self._make_client() as c: + resp = await c.delete("/api/global-rate-limit") + return cast(dict[str, Any], self._check(resp)) + + async def reset_global_rate_limit(self) -> dict[str, Any]: + """DELETE /api/global-rate-limit/reset β€” reset global rate limit counters.""" + async with self._make_client() as c: + resp = await c.delete("/api/global-rate-limit/reset") + return cast(dict[str, Any], self._check(resp)) + + async def enable_global_rate_limit(self) -> dict[str, Any]: + """POST /api/global-rate-limit/enable β€” resume a paused global rate limit.""" + async with self._make_client() as c: + resp = await c.post("/api/global-rate-limit/enable") + return cast(dict[str, Any], self._check(resp)) + + async def disable_global_rate_limit(self) -> dict[str, Any]: + """POST /api/global-rate-limit/disable β€” pause the global rate limit.""" + async with self._make_client() as c: + resp = await c.post("/api/global-rate-limit/disable") + return cast(dict[str, Any], self._check(resp)) + def make_client( transport: httpx.AsyncBaseTransport | None = None, diff --git a/shield/cli/main.py b/shield/cli/main.py index a177679..ba2ae4b 100644 --- a/shield/cli/main.py +++ b/shield/cli/main.py @@ -1083,5 +1083,167 @@ async def _run_rl_delete() -> None: _run(_run_rl_delete) +# --------------------------------------------------------------------------- +# Global rate limit command group (shield global-rate-limit ...) +# --------------------------------------------------------------------------- + +grl_app = typer.Typer( + name="global-rate-limit", + help="Manage the global rate limit policy applied to all routes.", + no_args_is_help=True, +) +cli.add_typer(grl_app, name="global-rate-limit") +cli.add_typer(grl_app, name="grl") + + +@grl_app.command("get") +def grl_get() -> None: + """Show the current global rate limit policy.""" + + async def _run_grl_get() -> None: + result = await make_client().get_global_rate_limit() + policy = result.get("policy") + if not policy: + console.print("[dim]No global rate limit policy configured.[/dim]") + return + + table = Table(box=box.ROUNDED, show_header=True, header_style="bold") + table.add_column("Field", style="dim") + table.add_column("Value") + + table.add_row("Limit", f"[magenta]{policy.get('limit', 'β€”')}[/magenta]") + table.add_row("Algorithm", policy.get("algorithm", "β€”")) + table.add_row("Key Strategy", policy.get("key_strategy", "β€”")) + table.add_row("Burst", str(policy.get("burst", 0))) + table.add_row("Enabled", "[green]yes[/green]" if policy.get("enabled") else "[red]no[/red]") + exempt = policy.get("exempt_routes") or [] + table.add_row("Exempt Routes", "\n".join(exempt) if exempt else "[dim](none)[/dim]") + console.print(table) + + _run(_run_grl_get) + + +@grl_app.command("set") +def grl_set( + limit: str = typer.Argument(..., help="Rate limit string, e.g. 1000/minute"), + algorithm: str | None = typer.Option( + None, + "--algorithm", + "-a", + help="Algorithm: fixed_window, sliding_window, moving_window, token_bucket.", + ), + key_strategy: str | None = typer.Option( + None, + "--key", + "-k", + help="Key strategy: ip, user, api_key, global, custom.", + ), + burst: int = typer.Option(0, "--burst", "-b", help="Burst allowance (extra requests)."), + exempt: list[str] | None = typer.Option( + None, + "--exempt", + "-e", + help=( + "Route to exempt from the global limit. Repeat for multiple routes. " + "Use /path to exempt all methods, or METHOD:/path for a specific method." + ), + ), +) -> None: + """Set or update the global rate limit policy. + + The policy applies to every route that is not explicitly exempted. + Persisted to the backend so it survives restarts. Examples: + + \b + shield grl set 1000/minute + shield grl set 500/minute --key ip --exempt /health --exempt GET:/api/internal + shield grl set 200/hour --algorithm sliding_window --burst 20 + """ + + async def _run_grl_set() -> None: + result = await make_client().set_global_rate_limit( + limit=limit, + algorithm=algorithm, + key_strategy=key_strategy, + burst=burst, + exempt_routes=list(exempt) if exempt else [], + ) + key_strat = result.get("key_strategy", "ip") + algo = result.get("algorithm", "") + console.print( + f"[green]βœ“[/green] Global rate limit set: " + f"[bold]{result.get('limit')}[/bold] ({algo}, key={key_strat})" + ) + exempt_list = result.get("exempt_routes") or [] + if exempt_list: + console.print(f" Exempt routes: [dim]{', '.join(exempt_list)}[/dim]") + + _run(_run_grl_set) + + +@grl_app.command("delete") +def grl_delete() -> None: + """Remove the global rate limit policy. + + Clears the policy from the backend. In-process counters are not + affected β€” use ``grl reset`` to clear them too. + """ + + async def _run_grl_delete() -> None: + result = await make_client().delete_global_rate_limit() + if result.get("ok"): + console.print("[green]βœ“[/green] Global rate limit policy removed.") + else: + console.print(f"[yellow]?[/yellow] {result}") + + _run(_run_grl_delete) + + +@grl_app.command("reset") +def grl_reset() -> None: + """Reset global rate limit counters. + + Clears all counters so the limit starts fresh. The policy itself + is not removed β€” use ``grl delete`` for that. + """ + + async def _run_grl_reset() -> None: + result = await make_client().reset_global_rate_limit() + if result.get("ok"): + console.print("[green]βœ“[/green] Global rate limit counters reset.") + else: + console.print(f"[yellow]?[/yellow] {result}") + + _run(_run_grl_reset) + + +@grl_app.command("enable") +def grl_enable() -> None: + """Resume a paused global rate limit policy.""" + + async def _run_grl_enable() -> None: + result = await make_client().enable_global_rate_limit() + if result.get("ok"): + console.print("[green]βœ“[/green] Global rate limit resumed.") + else: + console.print(f"[yellow]?[/yellow] {result}") + + _run(_run_grl_enable) + + +@grl_app.command("disable") +def grl_disable() -> None: + """Pause the global rate limit policy without removing it.""" + + async def _run_grl_disable() -> None: + result = await make_client().disable_global_rate_limit() + if result.get("ok"): + console.print("[green]βœ“[/green] Global rate limit paused.") + else: + console.print(f"[yellow]?[/yellow] {result}") + + _run(_run_grl_disable) + + if __name__ == "__main__": cli() diff --git a/shield/core/backends/base.py b/shield/core/backends/base.py index b486c34..9b3db76 100644 --- a/shield/core/backends/base.py +++ b/shield/core/backends/base.py @@ -224,6 +224,45 @@ async def delete_rate_limit_policy(self, path: str, method: str) -> None: Default implementation is a no-op. """ + # ------------------------------------------------------------------ + # Global rate limit policy persistence β€” concrete default implementations + # + # Stored as a sentinel RouteState with path ``_GLOBAL_RL_KEY``. + # The ``GlobalRateLimitPolicy`` is JSON-serialised into the ``reason`` + # field. Same pattern as global maintenance config β€” no subclass changes + # required for existing backends. + # ------------------------------------------------------------------ + + async def get_global_rate_limit_policy(self) -> dict[str, Any] | None: + """Return the persisted global rate limit policy dict, or ``None``.""" + _GLOBAL_RL_KEY = "__shield:global_rl__" + try: + state = await self.get_state(_GLOBAL_RL_KEY) + import json + + return dict(json.loads(state.reason)) + except (KeyError, Exception): + return None + + async def set_global_rate_limit_policy(self, policy_data: dict[str, Any]) -> None: + """Persist *policy_data* as the global rate limit policy.""" + import json + + _GLOBAL_RL_KEY = "__shield:global_rl__" + from shield.core.models import RouteStatus + + sentinel = RouteState( + path=_GLOBAL_RL_KEY, + status=RouteStatus.ACTIVE, + reason=json.dumps(policy_data), + ) + await self.set_state(_GLOBAL_RL_KEY, sentinel) + + async def delete_global_rate_limit_policy(self) -> None: + """Remove the persisted global rate limit policy.""" + _GLOBAL_RL_KEY = "__shield:global_rl__" + await self.delete_state(_GLOBAL_RL_KEY) + async def subscribe_rate_limit_policy(self) -> AsyncIterator[dict[str, Any]]: """Stream rate limit policy changes as they occur. diff --git a/shield/core/engine.py b/shield/core/engine.py index ccf4945..e371dc8 100644 --- a/shield/core/engine.py +++ b/shield/core/engine.py @@ -222,6 +222,52 @@ def reset_rate_limit( self._engine.reset_rate_limit(path, method=method, actor=actor, platform=platform) ) + def set_global_rate_limit( + self, + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + on_missing_key: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + actor: str = "system", + platform: str = "system", + ) -> Any: + """Sync version of :meth:`ShieldEngine.set_global_rate_limit`.""" + return self._run( + self._engine.set_global_rate_limit( + limit, + algorithm=algorithm, + key_strategy=key_strategy, + on_missing_key=on_missing_key, + burst=burst, + exempt_routes=exempt_routes, + actor=actor, + platform=platform, + ) + ) + + def get_global_rate_limit(self) -> Any: + """Sync version of :meth:`ShieldEngine.get_global_rate_limit`.""" + return self._run(self._engine.get_global_rate_limit()) + + def delete_global_rate_limit(self, *, actor: str = "system", platform: str = "system") -> None: + """Sync version of :meth:`ShieldEngine.delete_global_rate_limit`.""" + self._run(self._engine.delete_global_rate_limit(actor=actor, platform=platform)) + + def reset_global_rate_limit(self, *, actor: str = "system", platform: str = "system") -> None: + """Sync version of :meth:`ShieldEngine.reset_global_rate_limit`.""" + self._run(self._engine.reset_global_rate_limit(actor=actor, platform=platform)) + + def enable_global_rate_limit(self, *, actor: str = "system", platform: str = "system") -> None: + """Sync version of :meth:`ShieldEngine.enable_global_rate_limit`.""" + self._run(self._engine.enable_global_rate_limit(actor=actor, platform=platform)) + + def disable_global_rate_limit(self, *, actor: str = "system", platform: str = "system") -> None: + """Sync version of :meth:`ShieldEngine.disable_global_rate_limit`.""" + self._run(self._engine.disable_global_rate_limit(actor=actor, platform=platform)) + # ------------------------------------------------------------------ # Read-only queries # ------------------------------------------------------------------ @@ -295,6 +341,7 @@ def __init__( self._default_rate_limit_algorithm: Any = default_rate_limit_algorithm self._rate_limiter: Any = None # ShieldRateLimiter | None self._rate_limit_policies: dict[str, Any] = {} # "METHOD:/path" β†’ RateLimitPolicy + self._global_rate_limit_policy: Any = None # GlobalRateLimitPolicy | None # Sync proxy β€” created once, reused on every engine.sync access. self.sync: _SyncProxy = _SyncProxy(self) @@ -567,8 +614,9 @@ async def check( # Rate limit check still runs for deprecated routes. return await self._run_rate_limit_check(path, method or "", context) - # Rate limiting runs after all lifecycle checks so that maintenance / - # disabled routes short-circuit before touching counters. + # Rate limiting runs after all lifecycle checks (maintenance / disabled + # routes short-circuit before touching any counters). Within the rate + # limit check, the global limit is evaluated first. await self._run_rate_limit_check(path, method or "", context) async def _run_rate_limit_check( @@ -576,30 +624,108 @@ async def _run_rate_limit_check( ) -> Any: """Run the rate limit check for *path*/*method* if a policy is registered. + Applies the global rate limit first (higher precedence), then the + per-route policy. A request blocked by the global limit never + touches the per-route counter. + Returns the ``RateLimitResult`` (or ``None`` when no policy applies). Raises ``RateLimitExceededException`` when the limit is exceeded. """ - if self._rate_limiter is None: - return None + request = (context or {}).get("request") + + # Global rate limit takes precedence β€” checked first, same model as + # global maintenance. If the global limit is exceeded the per-route + # check never runs and the per-route counter is not touched. + grl = self._global_rate_limit_policy + if grl is not None and grl.enabled: + if not self._is_globally_exempt(path, method, grl.exempt_routes): + await self._run_global_rate_limit_check(path, method, request, grl) + + # Per-route check β€” only reached when the global limit passed (or the + # route is exempt from the global limit, or no global limit is set). + route_result: Any = None + if self._rate_limiter is not None: + policy_key = f"{method.upper()}:{path}" if method else f"ALL:{path}" + policy = self._rate_limit_policies.get(policy_key) + if policy is not None: + custom_key_func = getattr(policy, "_custom_key_func", None) + result = await self._rate_limiter.check( + path=path, + method=method or "GET", + request=request, + policy=policy, + custom_key_func=custom_key_func, + ) + if not result.allowed: + await self._record_rate_limit_hit(path, method or "GET", policy, result) + raise RateLimitExceededException( + limit=result.limit, + retry_after_seconds=result.retry_after_seconds, + reset_at=result.reset_at, + remaining=0, + key=result.key, + ) + route_result = result + + return route_result + + def _is_globally_exempt(self, path: str, method: str, exempt_routes: list[str]) -> bool: + """Return ``True`` if *path*/*method* is in the global exempt list. + + Each entry in *exempt_routes* is either: + * a bare path (``"/health"``) β€” exempts all methods, or + * a method-prefixed path (``"GET:/api/internal"``) β€” exempts that + specific method only. + """ + upper_method = method.upper() if method else "" + for entry in exempt_routes: + if ":" in entry and not entry.startswith("/"): + em, _, ep = entry.partition(":") + if em.upper() == upper_method and ep == path: + return True + else: + if entry == path: + return True + return False + + async def _run_global_rate_limit_check( + self, + path: str, + method: str, + request: Any, + grl_policy: Any, + ) -> None: + """Check the global rate limit for *path*/*method*. - policy_key = f"{method.upper()}:{path}" if method else f"ALL:{path}" - policy = self._rate_limit_policies.get(policy_key) - if policy is None: - return None + Uses ``__global__`` as the virtual path so all routes share the + same counter namespace. Raises ``RateLimitExceededException`` + when the limit is exceeded. + """ + # Ensure the rate limiter is initialised without polluting + # _rate_limit_policies with a fake "ALL:__global__" route entry. + await self._ensure_rate_limiter() - request = (context or {}).get("request") - custom_key_func = getattr(policy, "_custom_key_func", None) + from shield.core.rate_limit.models import RateLimitPolicy + + grl_as_policy = RateLimitPolicy( + path="__global__", + method="ALL", + limit=grl_policy.limit, + algorithm=grl_policy.algorithm, + key_strategy=grl_policy.key_strategy, + on_missing_key=grl_policy.on_missing_key, + burst=grl_policy.burst, + ) result = await self._rate_limiter.check( - path=path, - method=method or "GET", + path="__global__", + method="ALL", request=request, - policy=policy, - custom_key_func=custom_key_func, + policy=grl_as_policy, ) if not result.allowed: - await self._record_rate_limit_hit(path, method or "GET", policy, result) + await self._record_rate_limit_hit(path, method or "ALL", grl_as_policy, result) raise RateLimitExceededException( limit=result.limit, retry_after_seconds=result.retry_after_seconds, @@ -608,8 +734,6 @@ async def _run_rate_limit_check( key=result.key, ) - return result - async def _resolve_state(self, path: str, method: str | None) -> RouteState | None: """Return the applicable ``RouteState`` for *path* / *method*. @@ -1160,6 +1284,39 @@ async def _post_webhook(url: str, payload: dict[str, Any]) -> None: # Rate limiting # ------------------------------------------------------------------ + async def _ensure_rate_limiter(self) -> None: + """Lazily initialise the rate limiter without registering any policy. + + Safe to call multiple times β€” no-op once the limiter exists. + """ + if self._rate_limiter is not None: + return + try: + from shield.core.rate_limit.limiter import ShieldRateLimiter + from shield.core.rate_limit.storage import create_rate_limit_storage + + storage = create_rate_limit_storage( + self.backend, + self._rate_limit_backend, + snapshot_interval_seconds=self._rate_limit_snapshot_interval, + ) + from shield.core.rate_limit.models import RateLimitAlgorithm + + algo = self._default_rate_limit_algorithm or RateLimitAlgorithm.FIXED_WINDOW + self._rate_limiter = ShieldRateLimiter( + storage=storage, + default_algorithm=algo, + ) + # Kick off storage background tasks (e.g. FileRateLimitStorage + # snapshot writer) now, in async context, so they are running + # before the first request rather than lazily per-increment. + await self._rate_limiter.startup() + except ImportError: + raise ImportError( + "Rate limiting requires the 'limits' library. " + "Install it with: pip install api-shield[rate-limit]" + ) from None + async def register_rate_limit( self, path: str, @@ -1182,33 +1339,7 @@ async def register_rate_limit( policy: A ``RateLimitPolicy`` instance describing the limit. """ - if self._rate_limiter is None: - try: - from shield.core.rate_limit.limiter import ShieldRateLimiter - from shield.core.rate_limit.storage import create_rate_limit_storage - - storage = create_rate_limit_storage( - self.backend, - self._rate_limit_backend, - snapshot_interval_seconds=self._rate_limit_snapshot_interval, - ) - from shield.core.rate_limit.models import RateLimitAlgorithm - - algo = self._default_rate_limit_algorithm or RateLimitAlgorithm.FIXED_WINDOW - self._rate_limiter = ShieldRateLimiter( - storage=storage, - default_algorithm=algo, - ) - # Kick off storage background tasks (e.g. FileRateLimitStorage - # snapshot writer) now, in async context, so they are running - # before the first request rather than lazily per-increment. - await self._rate_limiter.startup() - except ImportError: - raise ImportError( - "Rate limiting requires the 'limits' library. " - "Install it with: pip install api-shield[rate-limit]" - ) from None - + await self._ensure_rate_limiter() key = f"{method.upper()}:{path}" self._rate_limit_policies[key] = policy @@ -1389,6 +1520,177 @@ async def restore_rate_limit_policies(self) -> None: except Exception: logger.exception("shield: failed to restore rate limit policy %r", policy_data) + # Also restore the global rate limit policy if one was persisted. + await self._restore_global_rate_limit_policy() + + async def _restore_global_rate_limit_policy(self) -> None: + """Load the persisted global rate limit policy from the backend.""" + try: + policy_data = await self.backend.get_global_rate_limit_policy() + except Exception: + logger.exception("shield: failed to restore global rate limit policy from backend") + return + + if not policy_data: + return + + try: + from shield.core.rate_limit.models import GlobalRateLimitPolicy + + self._global_rate_limit_policy = GlobalRateLimitPolicy.model_validate(policy_data) + logger.info( + "shield: restored global rate limit policy (%s)", + self._global_rate_limit_policy.limit, + ) + except Exception: + logger.exception("shield: failed to parse persisted global rate limit policy") + + async def set_global_rate_limit( + self, + limit: str, + *, + algorithm: str | None = None, + key_strategy: str | None = None, + on_missing_key: str | None = None, + burst: int = 0, + exempt_routes: list[str] | None = None, + actor: str = "system", + platform: str = "system", + ) -> Any: + """Set or update the global rate limit policy. + + The global policy applies to every route that is not listed in + *exempt_routes*. Persisted so the policy survives restarts. + + Returns the ``GlobalRateLimitPolicy`` instance. + """ + from shield.core.rate_limit.models import ( + GlobalRateLimitPolicy, + OnMissingKey, + RateLimitAlgorithm, + RateLimitKeyStrategy, + ) + + algo = RateLimitAlgorithm(algorithm) if algorithm else RateLimitAlgorithm.FIXED_WINDOW + key_strat = RateLimitKeyStrategy(key_strategy) if key_strategy else RateLimitKeyStrategy.IP + omk = OnMissingKey(on_missing_key) if on_missing_key else None + + is_update = self._global_rate_limit_policy is not None + policy = GlobalRateLimitPolicy( + limit=limit, + algorithm=algo, + key_strategy=key_strat, + on_missing_key=omk, + burst=burst, + exempt_routes=exempt_routes or [], + enabled=True, + ) + self._global_rate_limit_policy = policy + await self.backend.set_global_rate_limit_policy(policy.model_dump(mode="json")) + logger.info( + "shield: global rate limit policy %s (%s) by %s", + "updated" if is_update else "set", + limit, + actor, + ) + await self._audit_rl( + path="__global_rl__", + action="global_rl_updated" if is_update else "global_rl_set", + actor=actor, + reason=f"{limit} Β· {algo} Β· {key_strat}", + platform=platform, + ) + return policy + + async def get_global_rate_limit(self) -> Any: + """Return the current ``GlobalRateLimitPolicy``, or ``None``.""" + return self._global_rate_limit_policy + + async def delete_global_rate_limit( + self, + *, + actor: str = "system", + platform: str = "system", + ) -> None: + """Remove the global rate limit policy. + + Clears the in-process policy and removes the persisted entry. + """ + self._global_rate_limit_policy = None + await self.backend.delete_global_rate_limit_policy() + logger.info("shield: global rate limit policy deleted by %s", actor) + await self._audit_rl( + path="__global_rl__", + action="global_rl_deleted", + actor=actor, + platform=platform, + ) + + async def reset_global_rate_limit( + self, + *, + actor: str = "system", + platform: str = "system", + ) -> None: + """Reset global rate limit counters. + + Clears the ``__global__`` counter namespace so the rate limit + starts fresh. The policy itself is not removed. + """ + if self._rate_limiter is None: + return + await self._rate_limiter.reset(path="__global__", method="ALL") + await self._audit_rl( + path="__global_rl__", + action="global_rl_reset", + actor=actor, + platform=platform, + ) + + async def enable_global_rate_limit( + self, + *, + actor: str = "system", + platform: str = "system", + ) -> None: + """Re-enable a paused global rate limit policy.""" + if self._global_rate_limit_policy is None or self._global_rate_limit_policy.enabled: + return + self._global_rate_limit_policy = self._global_rate_limit_policy.model_copy( + update={"enabled": True} + ) + await self.backend.set_global_rate_limit_policy( + self._global_rate_limit_policy.model_dump(mode="json") + ) + await self._audit_rl( + path="__global_rl__", + action="global_rl_enabled", + actor=actor, + platform=platform, + ) + + async def disable_global_rate_limit( + self, + *, + actor: str = "system", + platform: str = "system", + ) -> None: + """Pause (disable) the global rate limit policy without removing it.""" + if self._global_rate_limit_policy is None or not self._global_rate_limit_policy.enabled: + return + self._global_rate_limit_policy = self._global_rate_limit_policy.model_copy( + update={"enabled": False} + ) + await self.backend.set_global_rate_limit_policy( + self._global_rate_limit_policy.model_dump(mode="json") + ) + await self._audit_rl( + path="__global_rl__", + action="global_rl_disabled", + actor=actor, + platform=platform, + ) + # ------------------------------------------------------------------ # Read methods # ------------------------------------------------------------------ diff --git a/shield/core/rate_limit/models.py b/shield/core/rate_limit/models.py index d7b1bc1..cb890a7 100644 --- a/shield/core/rate_limit/models.py +++ b/shield/core/rate_limit/models.py @@ -262,6 +262,46 @@ class RateLimitResult(BaseModel): """Which ``OnMissingKey`` behaviour fired, if any.""" +class GlobalRateLimitPolicy(BaseModel): + """Global rate limiting policy applied to all routes unless exempted. + + When set, every request that is not in ``exempt_routes`` is checked + against this policy **in addition to** any per-route policy. + + The global check uses a dedicated storage namespace (``__global__``) so + counters are independent of per-route counters. + + Parameters + ---------- + limit: + Rate limit in ``limits`` format, e.g. ``"1000/minute"``. + algorithm: + Algorithm to use for counting. Defaults to FIXED_WINDOW. + key_strategy: + How to derive the per-request bucket key. Defaults to IP. + on_missing_key: + What to do when the key strategy cannot produce a key. + When ``None``, the per-strategy default from ``STRATEGY_DEFAULTS`` is used. + burst: + Extra requests allowed above the base limit (additive). + exempt_routes: + Routes that bypass the global limit. Each entry is either a bare + path (``"/health"``, all methods) or a method-prefixed path + (``"GET:/api/internal"``). + enabled: + Whether the global limit is actively enforced. Set to ``False`` + to temporarily disable without deleting the policy. + """ + + limit: str + algorithm: RateLimitAlgorithm = RateLimitAlgorithm.FIXED_WINDOW + key_strategy: RateLimitKeyStrategy = RateLimitKeyStrategy.IP + on_missing_key: OnMissingKey | None = None + burst: int = 0 + exempt_routes: list[str] = Field(default_factory=list) + enabled: bool = True + + class RateLimitHit(BaseModel): """Record of a single blocked request. diff --git a/shield/dashboard/routes.py b/shield/dashboard/routes.py index 96a9a90..b1bd7b1 100644 --- a/shield/dashboard/routes.py +++ b/shield/dashboard/routes.py @@ -502,12 +502,14 @@ async def rate_limits_page(request: Request) -> Response: page = int(request.query_params.get("page", 1)) policies = list(engine._rate_limit_policies.values()) paged = _paginate(policies, page) + global_rl = await engine.get_global_rate_limit() return tpl.TemplateResponse( request, "rate_limits.html", { "policies": paged["items"], "pagination": paged, + "global_rl": global_rl, "prefix": prefix, "active_tab": "rate_limits", "version": request.app.state.version, @@ -685,6 +687,106 @@ async def rl_delete(request: Request) -> HTMLResponse: return HTMLResponse("") +async def modal_global_rl(request: Request) -> HTMLResponse: + """Return the global rate limit set/edit modal form.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + grl = await engine.get_global_rate_limit() + html = tpl.env.get_template("partials/modal_global_rl.html").render( + grl=grl, + prefix=prefix, + ) + return HTMLResponse(html) + + +async def modal_global_rl_delete(request: Request) -> HTMLResponse: + """Return the global rate limit delete confirmation modal.""" + tpl = _templates(request) + prefix = _prefix(request) + html = tpl.env.get_template("partials/modal_global_rl_delete.html").render(prefix=prefix) + return HTMLResponse(html) + + +async def modal_global_rl_reset(request: Request) -> HTMLResponse: + """Return the global rate limit reset confirmation modal.""" + tpl = _templates(request) + prefix = _prefix(request) + html = tpl.env.get_template("partials/modal_global_rl_reset.html").render(prefix=prefix) + return HTMLResponse(html) + + +async def global_rl_set(request: Request) -> HTMLResponse: + """Save global rate limit policy from form data and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + form = await request.form() + limit = str(form.get("limit", "")).strip() + algorithm = str(form.get("algorithm", "fixed_window")).strip() or None + key_strategy = str(form.get("key_strategy", "ip")).strip() or None + burst = int(str(form.get("burst", 0) or 0)) + exempt_raw = str(form.get("exempt_routes", "")).strip() + exempt_routes = [r.strip() for r in exempt_raw.splitlines() if r.strip()] + if limit: + await engine.set_global_rate_limit( + limit=limit, + algorithm=algorithm, + key_strategy=key_strategy, + burst=burst, + exempt_routes=exempt_routes, + actor=_actor(request), + platform=_platform(request), + ) + grl = await engine.get_global_rate_limit() + html = tpl.env.get_template("partials/global_rl_card.html").render(grl=grl, prefix=prefix) + return HTMLResponse(html) + + +async def global_rl_delete(request: Request) -> HTMLResponse: + """Delete global rate limit policy and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + await engine.delete_global_rate_limit(actor=_actor(request), platform=_platform(request)) + grl = await engine.get_global_rate_limit() + html = tpl.env.get_template("partials/global_rl_card.html").render(grl=grl, prefix=prefix) + return HTMLResponse(html) + + +async def global_rl_reset(request: Request) -> HTMLResponse: + """Reset global rate limit counters and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + await engine.reset_global_rate_limit(actor=_actor(request), platform=_platform(request)) + grl = await engine.get_global_rate_limit() + html = tpl.env.get_template("partials/global_rl_card.html").render(grl=grl, prefix=prefix) + return HTMLResponse(html) + + +async def global_rl_enable(request: Request) -> HTMLResponse: + """Enable (resume) the global rate limit policy and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + await engine.enable_global_rate_limit(actor=_actor(request), platform=_platform(request)) + grl = await engine.get_global_rate_limit() + html = tpl.env.get_template("partials/global_rl_card.html").render(grl=grl, prefix=prefix) + return HTMLResponse(html) + + +async def global_rl_disable(request: Request) -> HTMLResponse: + """Disable (pause) the global rate limit policy and refresh the card.""" + engine = _engine(request) + tpl = _templates(request) + prefix = _prefix(request) + await engine.disable_global_rate_limit(actor=_actor(request), platform=_platform(request)) + grl = await engine.get_global_rate_limit() + html = tpl.env.get_template("partials/global_rl_card.html").render(grl=grl, prefix=prefix) + return HTMLResponse(html) + + async def rate_limits_hits_partial(request: Request) -> Response: """Return only the recent blocked requests table rows (HTMX auto-refresh).""" engine = _engine(request) diff --git a/shield/dashboard/static/shield.min.css b/shield/dashboard/static/shield.min.css index 5dc8f7d..d2f527b 100644 --- a/shield/dashboard/static/shield.min.css +++ b/shield/dashboard/static/shield.min.css @@ -1,2 +1,2 @@ /*! tailwindcss v4.2.1 | MIT License | https://tailwindcss.com */ -@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--drop-shadow-md:0 3px 3px #0000001f;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-20{bottom:calc(var(--spacing) * 20)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:480px){.container{max-width:480px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-\[4\.5rem\]{height:4.5rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[18rem\]{max-width:18rem}.max-w-\[200px\]{max-width:200px}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[600px\]{min-width:600px}.min-w-\[640px\]{min-width:640px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.list-none{list-style-type:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-violet-600{border-color:var(--color-violet-600)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-600{background-color:var(--color-red-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/60{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/60{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-white{background-color:var(--color-white)}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-700{color:var(--color-indigo-700)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-slate-400::placeholder{color:var(--color-slate-400)}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_3px_0_rgb\(0\,0\,0\,0\.04\)\]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-600\/20{--tw-ring-color:#dd740033}@supports (color:color-mix(in lab, red, red)){.ring-amber-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-amber-600) 20%, transparent)}}.ring-blue-600\/20{--tw-ring-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.ring-blue-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-600) 20%, transparent)}}.ring-emerald-600\/20{--tw-ring-color:#00976733}@supports (color:color-mix(in lab, red, red)){.ring-emerald-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-emerald-600) 20%, transparent)}}.ring-red-300\/40{--tw-ring-color:#ffa3a366}@supports (color:color-mix(in lab, red, red)){.ring-red-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-red-300) 40%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-sky-600\/20{--tw-ring-color:#0084cc33}@supports (color:color-mix(in lab, red, red)){.ring-sky-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-sky-600) 20%, transparent)}}.ring-slate-400\/20{--tw-ring-color:#90a1b933}@supports (color:color-mix(in lab, red, red)){.ring-slate-400\/20{--tw-ring-color:color-mix(in oklab, var(--color-slate-400) 20%, transparent)}}.ring-violet-600\/20{--tw-ring-color:#7f22fe33}@supports (color:color-mix(in lab, red, red)){.ring-violet-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-violet-600) 20%, transparent)}}.drop-shadow-md{--tw-drop-shadow-size:drop-shadow(0 3px 3px var(--tw-drop-shadow-color,#0000001f));--tw-drop-shadow:drop-shadow(var(--drop-shadow-md));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:bg-slate-50:is(:where(.group):hover *){background-color:var(--color-slate-50)}}.placeholder\:font-sans::placeholder{font-family:var(--font-sans)}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}@media (hover:hover){.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-emerald-300:hover{border-color:var(--color-emerald-300)}.hover\:border-indigo-300:hover{border-color:var(--color-indigo-300)}.hover\:border-red-200:hover{border-color:var(--color-red-200)}.hover\:border-red-300:hover{border-color:var(--color-red-300)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-slate-300:hover{border-color:var(--color-slate-300)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-50:hover{background-color:var(--color-sky-50)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/70:hover{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/70:hover{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-violet-50:hover{background-color:var(--color-violet-50)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-sky-600:hover{color:var(--color-sky-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-400:focus{--tw-ring-color:var(--color-amber-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-indigo-500\/20:focus{--tw-ring-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.focus\:ring-indigo-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 20%, transparent)}}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:ring-violet-500:focus{--tw-ring-color:var(--color-violet-500)}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:bg-indigo-800:active{background-color:var(--color-indigo-800)}@media (min-width:480px){.xs\:hidden{display:none}.xs\:inline{display:inline}.xs\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:40rem){.sm\:col-span-1{grid-column:span 1/span 1}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:h-14{height:calc(var(--spacing) * 14)}.sm\:w-72{width:calc(var(--spacing) * 72)}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-5{gap:calc(var(--spacing) * 5)}.sm\:self-auto{align-self:auto}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:pb-3{padding-bottom:calc(var(--spacing) * 3)}}@media (min-width:48rem){.md\:table-cell{display:table-cell}}@media (min-width:64rem){.lg\:table-cell{display:table-cell}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file +@layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-rotate-x:initial;--tw-rotate-y:initial;--tw-rotate-z:initial;--tw-skew-x:initial;--tw-skew-y:initial;--tw-space-y-reverse:0;--tw-divide-y-reverse:0;--tw-border-style:solid;--tw-leading:initial;--tw-font-weight:initial;--tw-tracking:initial;--tw-ordinal:initial;--tw-slashed-zero:initial;--tw-numeric-figure:initial;--tw-numeric-spacing:initial;--tw-numeric-fraction:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000;--tw-blur:initial;--tw-brightness:initial;--tw-contrast:initial;--tw-grayscale:initial;--tw-hue-rotate:initial;--tw-invert:initial;--tw-opacity:initial;--tw-saturate:initial;--tw-sepia:initial;--tw-drop-shadow:initial;--tw-drop-shadow-color:initial;--tw-drop-shadow-alpha:100%;--tw-drop-shadow-size:initial;--tw-backdrop-blur:initial;--tw-backdrop-brightness:initial;--tw-backdrop-contrast:initial;--tw-backdrop-grayscale:initial;--tw-backdrop-hue-rotate:initial;--tw-backdrop-invert:initial;--tw-backdrop-opacity:initial;--tw-backdrop-saturate:initial;--tw-backdrop-sepia:initial;--tw-duration:initial;--tw-ease:initial;--tw-scale-x:1;--tw-scale-y:1;--tw-scale-z:1}}}@layer theme{:root,:host{--font-sans:-apple-system, BlinkMacSystemFont, Inter, "Segoe UI", "Helvetica Neue", Arial, sans-serif;--font-mono:"JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, Menlo, monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-100:oklch(93.6% .032 17.717);--color-red-200:oklch(88.5% .062 18.334);--color-red-300:oklch(80.8% .114 19.571);--color-red-400:oklch(70.4% .191 22.216);--color-red-500:oklch(63.7% .237 25.331);--color-red-600:oklch(57.7% .245 27.325);--color-red-700:oklch(50.5% .213 27.518);--color-red-800:oklch(44.4% .177 26.899);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-200:oklch(92.4% .12 95.746);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-400:oklch(82.8% .189 84.429);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-600:oklch(66.6% .179 58.318);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-emerald-50:oklch(97.9% .021 166.113);--color-emerald-100:oklch(95% .052 163.051);--color-emerald-200:oklch(90.5% .093 164.15);--color-emerald-300:oklch(84.5% .143 164.978);--color-emerald-400:oklch(76.5% .177 163.223);--color-emerald-500:oklch(69.6% .17 162.48);--color-emerald-600:oklch(59.6% .145 163.225);--color-emerald-700:oklch(50.8% .118 165.612);--color-emerald-800:oklch(43.2% .095 166.913);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-100:oklch(95.1% .026 236.824);--color-sky-200:oklch(90.1% .058 230.902);--color-sky-300:oklch(82.8% .111 230.318);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-blue-50:oklch(97% .014 254.604);--color-blue-100:oklch(93.2% .032 255.585);--color-blue-200:oklch(88.2% .059 254.128);--color-blue-300:oklch(80.9% .105 251.813);--color-blue-500:oklch(62.3% .214 259.815);--color-blue-600:oklch(54.6% .245 262.881);--color-blue-700:oklch(48.8% .243 264.376);--color-indigo-50:oklch(96.2% .018 272.314);--color-indigo-200:oklch(87% .065 274.039);--color-indigo-300:oklch(78.5% .115 274.713);--color-indigo-500:oklch(58.5% .233 277.117);--color-indigo-600:oklch(51.1% .262 276.966);--color-indigo-700:oklch(45.7% .24 277.023);--color-indigo-800:oklch(39.8% .195 277.366);--color-violet-50:oklch(96.9% .016 293.756);--color-violet-100:oklch(94.3% .029 294.588);--color-violet-500:oklch(60.6% .25 292.717);--color-violet-600:oklch(54.1% .281 293.009);--color-violet-700:oklch(49.1% .27 292.581);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-100:oklch(96.8% .007 247.896);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-800:oklch(27.9% .041 260.031);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-xs:20rem;--container-sm:24rem;--container-7xl:80rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-xl:1.25rem;--text-xl--line-height:calc(1.75 / 1.25);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-normal:400;--font-weight-medium:500;--font-weight-semibold:600;--font-weight-bold:700;--tracking-tight:-.025em;--tracking-normal:0em;--tracking-wide:.025em;--tracking-widest:.1em;--leading-relaxed:1.625;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--radius-2xl:1rem;--drop-shadow-md:0 3px 3px #0000001f;--ease-in-out:cubic-bezier(.4, 0, .2, 1);--animate-spin:spin 1s linear infinite;--animate-ping:ping 1s cubic-bezier(0, 0, .2, 1) infinite;--animate-pulse:pulse 2s cubic-bezier(.4, 0, .6, 1) infinite;--blur-sm:8px;--default-transition-duration:.15s;--default-transition-timing-function:cubic-bezier(.4, 0, .2, 1);--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.collapse{visibility:collapse}.visible{visibility:visible}.absolute{position:absolute}.fixed{position:fixed}.relative{position:relative}.static{position:static}.sticky{position:sticky}.start{inset-inline-start:var(--spacing)}.end{inset-inline-end:var(--spacing)}.top-0{top:calc(var(--spacing) * 0)}.right-5{right:calc(var(--spacing) * 5)}.bottom-5{bottom:calc(var(--spacing) * 5)}.bottom-20{bottom:calc(var(--spacing) * 20)}.left-0{left:calc(var(--spacing) * 0)}.z-10{z-index:10}.z-30{z-index:30}.z-40{z-index:40}.z-50{z-index:50}.col-span-2{grid-column:span 2/span 2}.container{width:100%}@media (min-width:480px){.container{max-width:480px}}@media (min-width:40rem){.container{max-width:40rem}}@media (min-width:48rem){.container{max-width:48rem}}@media (min-width:64rem){.container{max-width:64rem}}@media (min-width:80rem){.container{max-width:80rem}}@media (min-width:96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-0\.5{margin-top:calc(var(--spacing) * .5)}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-1\.5{margin-top:calc(var(--spacing) * 1.5)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-1{margin-bottom:calc(var(--spacing) * 1)}.mb-1\.5{margin-bottom:calc(var(--spacing) * 1.5)}.mb-2{margin-bottom:calc(var(--spacing) * 2)}.mb-3{margin-bottom:calc(var(--spacing) * 3)}.mb-4{margin-bottom:calc(var(--spacing) * 4)}.mb-5{margin-bottom:calc(var(--spacing) * 5)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.mb-8{margin-bottom:calc(var(--spacing) * 8)}.ml-1{margin-left:calc(var(--spacing) * 1)}.ml-auto{margin-left:auto}.block{display:block}.contents{display:contents}.flex{display:flex}.grid{display:grid}.hidden{display:none}.inline{display:inline}.inline-flex{display:inline-flex}.table{display:table}.h-1\.5{height:calc(var(--spacing) * 1.5)}.h-2{height:calc(var(--spacing) * 2)}.h-2\.5{height:calc(var(--spacing) * 2.5)}.h-3{height:calc(var(--spacing) * 3)}.h-3\.5{height:calc(var(--spacing) * 3.5)}.h-4{height:calc(var(--spacing) * 4)}.h-5{height:calc(var(--spacing) * 5)}.h-6{height:calc(var(--spacing) * 6)}.h-7{height:calc(var(--spacing) * 7)}.h-8{height:calc(var(--spacing) * 8)}.h-9{height:calc(var(--spacing) * 9)}.h-10{height:calc(var(--spacing) * 10)}.h-11{height:calc(var(--spacing) * 11)}.h-12{height:calc(var(--spacing) * 12)}.h-\[4\.5rem\]{height:4.5rem}.h-full{height:100%}.min-h-screen{min-height:100vh}.w-1\.5{width:calc(var(--spacing) * 1.5)}.w-2{width:calc(var(--spacing) * 2)}.w-2\.5{width:calc(var(--spacing) * 2.5)}.w-3{width:calc(var(--spacing) * 3)}.w-3\.5{width:calc(var(--spacing) * 3.5)}.w-4{width:calc(var(--spacing) * 4)}.w-5{width:calc(var(--spacing) * 5)}.w-6{width:calc(var(--spacing) * 6)}.w-7{width:calc(var(--spacing) * 7)}.w-8{width:calc(var(--spacing) * 8)}.w-9{width:calc(var(--spacing) * 9)}.w-10{width:calc(var(--spacing) * 10)}.w-11{width:calc(var(--spacing) * 11)}.w-12{width:calc(var(--spacing) * 12)}.w-14{width:calc(var(--spacing) * 14)}.w-full{width:100%}.max-w-7xl{max-width:var(--container-7xl)}.max-w-\[18rem\]{max-width:18rem}.max-w-\[200px\]{max-width:200px}.max-w-sm{max-width:var(--container-sm)}.max-w-xs{max-width:var(--container-xs)}.min-w-0{min-width:calc(var(--spacing) * 0)}.min-w-\[600px\]{min-width:600px}.min-w-\[640px\]{min-width:640px}.flex-1{flex:1}.flex-shrink{flex-shrink:1}.shrink-0{flex-shrink:0}.grow{flex-grow:1}.border-collapse{border-collapse:collapse}.transform{transform:var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,)}.animate-ping{animation:var(--animate-ping)}.animate-pulse{animation:var(--animate-pulse)}.animate-spin{animation:var(--animate-spin)}.cursor-not-allowed{cursor:not-allowed}.cursor-pointer{cursor:pointer}.resize-none{resize:none}.list-none{list-style-type:none}.grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.items-end{align-items:flex-end}.items-start{align-items:flex-start}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.justify-end{justify-content:flex-end}.gap-0\.5{gap:calc(var(--spacing) * .5)}.gap-1{gap:calc(var(--spacing) * 1)}.gap-1\.5{gap:calc(var(--spacing) * 1.5)}.gap-2{gap:calc(var(--spacing) * 2)}.gap-2\.5{gap:calc(var(--spacing) * 2.5)}.gap-3{gap:calc(var(--spacing) * 3)}.gap-4{gap:calc(var(--spacing) * 4)}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}.gap-x-4{column-gap:calc(var(--spacing) * 4)}.gap-x-5{column-gap:calc(var(--spacing) * 5)}.gap-y-1{row-gap:calc(var(--spacing) * 1)}:where(.divide-y>:not(:last-child)){--tw-divide-y-reverse:0;border-bottom-style:var(--tw-border-style);border-top-style:var(--tw-border-style);border-top-width:calc(1px * var(--tw-divide-y-reverse));border-bottom-width:calc(1px * calc(1 - var(--tw-divide-y-reverse)))}:where(.divide-slate-100>:not(:last-child)){border-color:var(--color-slate-100)}.self-start{align-self:flex-start}.truncate{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.overflow-hidden{overflow:hidden}.rounded{border-radius:.25rem}.rounded-2xl{border-radius:var(--radius-2xl)}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-t{border-top-style:var(--tw-border-style);border-top-width:1px}.border-b{border-bottom-style:var(--tw-border-style);border-bottom-width:1px}.border-amber-200{border-color:var(--color-amber-200)}.border-blue-200{border-color:var(--color-blue-200)}.border-emerald-200{border-color:var(--color-emerald-200)}.border-indigo-200{border-color:var(--color-indigo-200)}.border-red-200{border-color:var(--color-red-200)}.border-red-300{border-color:var(--color-red-300)}.border-sky-200{border-color:var(--color-sky-200)}.border-slate-100{border-color:var(--color-slate-100)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.border-violet-600{border-color:var(--color-violet-600)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-400{background-color:var(--color-amber-400)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-blue-50{background-color:var(--color-blue-50)}.bg-blue-500{background-color:var(--color-blue-500)}.bg-emerald-50{background-color:var(--color-emerald-50)}.bg-emerald-100{background-color:var(--color-emerald-100)}.bg-emerald-400{background-color:var(--color-emerald-400)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-emerald-600{background-color:var(--color-emerald-600)}.bg-indigo-50{background-color:var(--color-indigo-50)}.bg-indigo-500{background-color:var(--color-indigo-500)}.bg-indigo-600{background-color:var(--color-indigo-600)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-100{background-color:var(--color-red-100)}.bg-red-500{background-color:var(--color-red-500)}.bg-red-600{background-color:var(--color-red-600)}.bg-sky-50{background-color:var(--color-sky-50)}.bg-sky-100{background-color:var(--color-sky-100)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-50\/60{background-color:#f8fafc99}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/60{background-color:color-mix(in oklab, var(--color-slate-50) 60%, transparent)}}.bg-slate-50\/80{background-color:#f8fafccc}@supports (color:color-mix(in lab, red, red)){.bg-slate-50\/80{background-color:color-mix(in oklab, var(--color-slate-50) 80%, transparent)}}.bg-slate-100{background-color:var(--color-slate-100)}.bg-slate-200{background-color:var(--color-slate-200)}.bg-slate-400{background-color:var(--color-slate-400)}.bg-slate-900{background-color:var(--color-slate-900)}.bg-violet-50{background-color:var(--color-violet-50)}.bg-violet-100{background-color:var(--color-violet-100)}.bg-violet-600{background-color:var(--color-violet-600)}.bg-white{background-color:var(--color-white)}.bg-white\/90{background-color:#ffffffe6}@supports (color:color-mix(in lab, red, red)){.bg-white\/90{background-color:color-mix(in oklab, var(--color-white) 90%, transparent)}}.p-0\.5{padding:calc(var(--spacing) * .5)}.p-1\.5{padding:calc(var(--spacing) * 1.5)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-6{padding:calc(var(--spacing) * 6)}.px-0\.5{padding-inline:calc(var(--spacing) * .5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-1\.5{padding-inline:calc(var(--spacing) * 1.5)}.px-2{padding-inline:calc(var(--spacing) * 2)}.px-2\.5{padding-inline:calc(var(--spacing) * 2.5)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.px-6{padding-inline:calc(var(--spacing) * 6)}.py-0{padding-block:calc(var(--spacing) * 0)}.py-0\.5{padding-block:calc(var(--spacing) * .5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-1\.5{padding-block:calc(var(--spacing) * 1.5)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-2\.5{padding-block:calc(var(--spacing) * 2.5)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-3\.5{padding-block:calc(var(--spacing) * 3.5)}.py-6{padding-block:calc(var(--spacing) * 6)}.py-16{padding-block:calc(var(--spacing) * 16)}.pt-0\.5{padding-top:calc(var(--spacing) * .5)}.pt-1{padding-top:calc(var(--spacing) * 1)}.pb-2{padding-bottom:calc(var(--spacing) * 2)}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xl{font-size:var(--text-xl);line-height:var(--tw-leading,var(--text-xl--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.text-\[10px\]{font-size:10px}.text-\[11px\]{font-size:11px}.leading-relaxed{--tw-leading:var(--leading-relaxed);line-height:var(--leading-relaxed)}.font-bold{--tw-font-weight:var(--font-weight-bold);font-weight:var(--font-weight-bold)}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-normal{--tw-font-weight:var(--font-weight-normal);font-weight:var(--font-weight-normal)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-normal{--tw-tracking:var(--tracking-normal);letter-spacing:var(--tracking-normal)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.tracking-wide{--tw-tracking:var(--tracking-wide);letter-spacing:var(--tracking-wide)}.tracking-widest{--tw-tracking:var(--tracking-widest);letter-spacing:var(--tracking-widest)}.whitespace-nowrap{white-space:nowrap}.text-amber-500{color:var(--color-amber-500)}.text-amber-600{color:var(--color-amber-600)}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-blue-500{color:var(--color-blue-500)}.text-blue-600{color:var(--color-blue-600)}.text-blue-700{color:var(--color-blue-700)}.text-emerald-300{color:var(--color-emerald-300)}.text-emerald-400{color:var(--color-emerald-400)}.text-emerald-600{color:var(--color-emerald-600)}.text-emerald-700{color:var(--color-emerald-700)}.text-emerald-800{color:var(--color-emerald-800)}.text-indigo-500{color:var(--color-indigo-500)}.text-indigo-600{color:var(--color-indigo-600)}.text-indigo-700{color:var(--color-indigo-700)}.text-red-500{color:var(--color-red-500)}.text-red-600{color:var(--color-red-600)}.text-red-700{color:var(--color-red-700)}.text-red-800{color:var(--color-red-800)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-slate-300{color:var(--color-slate-300)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-800{color:var(--color-slate-800)}.text-slate-900{color:var(--color-slate-900)}.text-violet-600{color:var(--color-violet-600)}.text-violet-700{color:var(--color-violet-700)}.text-white{color:var(--color-white)}.lowercase{text-transform:lowercase}.normal-case{text-transform:none}.uppercase{text-transform:uppercase}.italic{font-style:italic}.tabular-nums{--tw-numeric-spacing:tabular-nums;font-variant-numeric:var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,)}.antialiased{-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}.placeholder-slate-400::placeholder{color:var(--color-slate-400)}.opacity-25{opacity:.25}.opacity-75{opacity:.75}.shadow-2xl{--tw-shadow:0 25px 50px -12px var(--tw-shadow-color,#00000040);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-\[0_1px_3px_0_rgb\(0\,0\,0\,0\.04\)\]{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000000a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-md{--tw-shadow:0 4px 6px -1px var(--tw-shadow-color,#0000001a), 0 2px 4px -2px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-1{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.ring-amber-600\/20{--tw-ring-color:#dd740033}@supports (color:color-mix(in lab, red, red)){.ring-amber-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-amber-600) 20%, transparent)}}.ring-blue-600\/20{--tw-ring-color:#155dfc33}@supports (color:color-mix(in lab, red, red)){.ring-blue-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-blue-600) 20%, transparent)}}.ring-emerald-600\/20{--tw-ring-color:#00976733}@supports (color:color-mix(in lab, red, red)){.ring-emerald-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-emerald-600) 20%, transparent)}}.ring-indigo-300\/40{--tw-ring-color:#a4b3ff66}@supports (color:color-mix(in lab, red, red)){.ring-indigo-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-indigo-300) 40%, transparent)}}.ring-indigo-600\/20{--tw-ring-color:#4f39f633}@supports (color:color-mix(in lab, red, red)){.ring-indigo-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-indigo-600) 20%, transparent)}}.ring-red-300\/40{--tw-ring-color:#ffa3a366}@supports (color:color-mix(in lab, red, red)){.ring-red-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-red-300) 40%, transparent)}}.ring-red-600\/20{--tw-ring-color:#e4001433}@supports (color:color-mix(in lab, red, red)){.ring-red-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-red-600) 20%, transparent)}}.ring-sky-600\/20{--tw-ring-color:#0084cc33}@supports (color:color-mix(in lab, red, red)){.ring-sky-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-sky-600) 20%, transparent)}}.ring-slate-300\/40{--tw-ring-color:#cad5e266}@supports (color:color-mix(in lab, red, red)){.ring-slate-300\/40{--tw-ring-color:color-mix(in oklab, var(--color-slate-300) 40%, transparent)}}.ring-slate-400\/20{--tw-ring-color:#90a1b933}@supports (color:color-mix(in lab, red, red)){.ring-slate-400\/20{--tw-ring-color:color-mix(in oklab, var(--color-slate-400) 20%, transparent)}}.ring-violet-600\/20{--tw-ring-color:#7f22fe33}@supports (color:color-mix(in lab, red, red)){.ring-violet-600\/20{--tw-ring-color:color-mix(in oklab, var(--color-violet-600) 20%, transparent)}}.drop-shadow-md{--tw-drop-shadow-size:drop-shadow(0 3px 3px var(--tw-drop-shadow-color,#0000001f));--tw-drop-shadow:drop-shadow(var(--drop-shadow-md));filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.filter{filter:var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,)}.backdrop-blur-sm{--tw-backdrop-blur:blur(var(--blur-sm));-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.backdrop-filter{-webkit-backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,);backdrop-filter:var(--tw-backdrop-blur,) var(--tw-backdrop-brightness,) var(--tw-backdrop-contrast,) var(--tw-backdrop-grayscale,) var(--tw-backdrop-hue-rotate,) var(--tw-backdrop-invert,) var(--tw-backdrop-opacity,) var(--tw-backdrop-saturate,) var(--tw-backdrop-sepia,)}.transition{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to,opacity,box-shadow,transform,translate,scale,rotate,filter,-webkit-backdrop-filter,backdrop-filter,display,content-visibility,overlay,pointer-events;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-all{transition-property:all;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-colors{transition-property:color,background-color,border-color,outline-color,text-decoration-color,fill,stroke,--tw-gradient-from,--tw-gradient-via,--tw-gradient-to;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.transition-shadow{transition-property:box-shadow;transition-timing-function:var(--tw-ease,var(--default-transition-timing-function));transition-duration:var(--tw-duration,var(--default-transition-duration))}.duration-150{--tw-duration:.15s;transition-duration:.15s}.ease-in-out{--tw-ease:var(--ease-in-out);transition-timing-function:var(--ease-in-out)}.select-none{-webkit-user-select:none;user-select:none}.ring-inset{--tw-ring-inset:inset}@media (hover:hover){.group-hover\:bg-slate-50:is(:where(.group):hover *){background-color:var(--color-slate-50)}}.placeholder\:font-sans::placeholder{font-family:var(--font-sans)}.placeholder\:text-slate-400::placeholder{color:var(--color-slate-400)}@media (hover:hover){.hover\:border-amber-300:hover{border-color:var(--color-amber-300)}.hover\:border-blue-300:hover{border-color:var(--color-blue-300)}.hover\:border-emerald-300:hover{border-color:var(--color-emerald-300)}.hover\:border-indigo-200:hover{border-color:var(--color-indigo-200)}.hover\:border-indigo-300:hover{border-color:var(--color-indigo-300)}.hover\:border-red-200:hover{border-color:var(--color-red-200)}.hover\:border-red-300:hover{border-color:var(--color-red-300)}.hover\:border-red-400:hover{border-color:var(--color-red-400)}.hover\:border-sky-300:hover{border-color:var(--color-sky-300)}.hover\:border-slate-300:hover{border-color:var(--color-slate-300)}.hover\:bg-amber-100:hover{background-color:var(--color-amber-100)}.hover\:bg-amber-600:hover{background-color:var(--color-amber-600)}.hover\:bg-blue-100:hover{background-color:var(--color-blue-100)}.hover\:bg-emerald-100:hover{background-color:var(--color-emerald-100)}.hover\:bg-emerald-700:hover{background-color:var(--color-emerald-700)}.hover\:bg-indigo-50:hover{background-color:var(--color-indigo-50)}.hover\:bg-indigo-700:hover{background-color:var(--color-indigo-700)}.hover\:bg-red-50:hover{background-color:var(--color-red-50)}.hover\:bg-red-100:hover{background-color:var(--color-red-100)}.hover\:bg-red-700:hover{background-color:var(--color-red-700)}.hover\:bg-sky-50:hover{background-color:var(--color-sky-50)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}.hover\:bg-slate-50\/70:hover{background-color:#f8fafcb3}@supports (color:color-mix(in lab, red, red)){.hover\:bg-slate-50\/70:hover{background-color:color-mix(in oklab, var(--color-slate-50) 70%, transparent)}}.hover\:bg-slate-100:hover{background-color:var(--color-slate-100)}.hover\:bg-violet-50:hover{background-color:var(--color-violet-50)}.hover\:bg-violet-700:hover{background-color:var(--color-violet-700)}.hover\:text-indigo-600:hover{color:var(--color-indigo-600)}.hover\:text-indigo-700:hover{color:var(--color-indigo-700)}.hover\:text-red-600:hover{color:var(--color-red-600)}.hover\:text-red-700:hover{color:var(--color-red-700)}.hover\:text-sky-600:hover{color:var(--color-sky-600)}.hover\:text-slate-700:hover{color:var(--color-slate-700)}.hover\:text-slate-800:hover{color:var(--color-slate-800)}.hover\:text-violet-600:hover{color:var(--color-violet-600)}.hover\:shadow-lg:hover{--tw-shadow:0 10px 15px -3px var(--tw-shadow-color,#0000001a), 0 4px 6px -4px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}}.focus\:border-blue-500:focus{border-color:var(--color-blue-500)}.focus\:border-indigo-500:focus{border-color:var(--color-indigo-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-amber-400:focus{--tw-ring-color:var(--color-amber-400)}.focus\:ring-blue-500:focus{--tw-ring-color:var(--color-blue-500)}.focus\:ring-emerald-500:focus{--tw-ring-color:var(--color-emerald-500)}.focus\:ring-indigo-500:focus{--tw-ring-color:var(--color-indigo-500)}.focus\:ring-indigo-500\/20:focus{--tw-ring-color:#625fff33}@supports (color:color-mix(in lab, red, red)){.focus\:ring-indigo-500\/20:focus{--tw-ring-color:color-mix(in oklab, var(--color-indigo-500) 20%, transparent)}}.focus\:ring-red-500:focus{--tw-ring-color:var(--color-red-500)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-slate-400:focus{--tw-ring-color:var(--color-slate-400)}.focus\:ring-violet-500:focus{--tw-ring-color:var(--color-violet-500)}.focus\:ring-offset-1:focus{--tw-ring-offset-width:1px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.active\:scale-95:active{--tw-scale-x:95%;--tw-scale-y:95%;--tw-scale-z:95%;scale:var(--tw-scale-x) var(--tw-scale-y)}.active\:bg-indigo-800:active{background-color:var(--color-indigo-800)}@media (min-width:480px){.xs\:hidden{display:none}.xs\:inline{display:inline}.xs\:grid-cols-3{grid-template-columns:repeat(3,minmax(0,1fr))}}@media (min-width:40rem){.sm\:col-span-1{grid-column:span 1/span 1}.sm\:block{display:block}.sm\:flex{display:flex}.sm\:hidden{display:none}.sm\:inline{display:inline}.sm\:table-cell{display:table-cell}.sm\:h-14{height:calc(var(--spacing) * 14)}.sm\:w-72{width:calc(var(--spacing) * 72)}.sm\:grid-cols-5{grid-template-columns:repeat(5,minmax(0,1fr))}.sm\:flex-row{flex-direction:row}.sm\:items-center{align-items:center}.sm\:items-start{align-items:flex-start}.sm\:justify-between{justify-content:space-between}.sm\:gap-3{gap:calc(var(--spacing) * 3)}.sm\:gap-5{gap:calc(var(--spacing) * 5)}.sm\:self-auto{align-self:auto}.sm\:p-5{padding:calc(var(--spacing) * 5)}.sm\:p-7{padding:calc(var(--spacing) * 7)}.sm\:px-6{padding-inline:calc(var(--spacing) * 6)}.sm\:py-8{padding-block:calc(var(--spacing) * 8)}.sm\:pb-3{padding-bottom:calc(var(--spacing) * 3)}}@media (min-width:48rem){.md\:table-cell{display:table-cell}}@media (min-width:64rem){.lg\:table-cell{display:table-cell}.lg\:px-8{padding-inline:calc(var(--spacing) * 8)}}}@property --tw-rotate-x{syntax:"*";inherits:false}@property --tw-rotate-y{syntax:"*";inherits:false}@property --tw-rotate-z{syntax:"*";inherits:false}@property --tw-skew-x{syntax:"*";inherits:false}@property --tw-skew-y{syntax:"*";inherits:false}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-divide-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-leading{syntax:"*";inherits:false}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-ordinal{syntax:"*";inherits:false}@property --tw-slashed-zero{syntax:"*";inherits:false}@property --tw-numeric-figure{syntax:"*";inherits:false}@property --tw-numeric-spacing{syntax:"*";inherits:false}@property --tw-numeric-fraction{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-blur{syntax:"*";inherits:false}@property --tw-brightness{syntax:"*";inherits:false}@property --tw-contrast{syntax:"*";inherits:false}@property --tw-grayscale{syntax:"*";inherits:false}@property --tw-hue-rotate{syntax:"*";inherits:false}@property --tw-invert{syntax:"*";inherits:false}@property --tw-opacity{syntax:"*";inherits:false}@property --tw-saturate{syntax:"*";inherits:false}@property --tw-sepia{syntax:"*";inherits:false}@property --tw-drop-shadow{syntax:"*";inherits:false}@property --tw-drop-shadow-color{syntax:"*";inherits:false}@property --tw-drop-shadow-alpha{syntax:"";inherits:false;initial-value:100%}@property --tw-drop-shadow-size{syntax:"*";inherits:false}@property --tw-backdrop-blur{syntax:"*";inherits:false}@property --tw-backdrop-brightness{syntax:"*";inherits:false}@property --tw-backdrop-contrast{syntax:"*";inherits:false}@property --tw-backdrop-grayscale{syntax:"*";inherits:false}@property --tw-backdrop-hue-rotate{syntax:"*";inherits:false}@property --tw-backdrop-invert{syntax:"*";inherits:false}@property --tw-backdrop-opacity{syntax:"*";inherits:false}@property --tw-backdrop-saturate{syntax:"*";inherits:false}@property --tw-backdrop-sepia{syntax:"*";inherits:false}@property --tw-duration{syntax:"*";inherits:false}@property --tw-ease{syntax:"*";inherits:false}@property --tw-scale-x{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-y{syntax:"*";inherits:false;initial-value:1}@property --tw-scale-z{syntax:"*";inherits:false;initial-value:1}@keyframes spin{to{transform:rotate(360deg)}}@keyframes ping{75%,to{opacity:0;transform:scale(2)}}@keyframes pulse{50%{opacity:.5}} \ No newline at end of file diff --git a/shield/dashboard/templates/partials/audit_row.html b/shield/dashboard/templates/partials/audit_row.html index 8bb449b..5d0567d 100644 --- a/shield/dashboard/templates/partials/audit_row.html +++ b/shield/dashboard/templates/partials/audit_row.html @@ -41,13 +41,25 @@ "rl_policy_set": "bg-emerald-50 text-emerald-700 ring-emerald-600/20", "rl_policy_updated": "bg-amber-50 text-amber-700 ring-amber-600/20", "rl_reset": "bg-sky-50 text-sky-700 ring-sky-600/20", - "rl_policy_deleted": "bg-red-50 text-red-700 ring-red-600/20" + "rl_policy_deleted": "bg-red-50 text-red-700 ring-red-600/20", + "global_rl_set": "bg-indigo-50 text-indigo-700 ring-indigo-600/20", + "global_rl_updated": "bg-amber-50 text-amber-700 ring-amber-600/20", + "global_rl_reset": "bg-sky-50 text-sky-700 ring-sky-600/20", + "global_rl_deleted": "bg-red-50 text-red-700 ring-red-600/20", + "global_rl_enabled": "bg-emerald-50 text-emerald-700 ring-emerald-600/20", + "global_rl_disabled":"bg-slate-100 text-slate-500 ring-slate-400/20" } %} {% set rl_action_labels = { "rl_policy_set": "set", "rl_policy_updated": "update", "rl_reset": "reset", - "rl_policy_deleted": "delete" + "rl_policy_deleted": "delete", + "global_rl_set": "global set", + "global_rl_updated": "global update", + "global_rl_reset": "global reset", + "global_rl_deleted": "global delete", + "global_rl_enabled": "global enabled", + "global_rl_disabled":"global disabled" } %} {% if entry.action in rl_action_labels %} diff --git a/shield/dashboard/templates/partials/global_rl_card.html b/shield/dashboard/templates/partials/global_rl_card.html new file mode 100644 index 0000000..c179af5 --- /dev/null +++ b/shield/dashboard/templates/partials/global_rl_card.html @@ -0,0 +1,133 @@ +
+ +{% if grl %} + + {# ── Configured: info bar with details (active or paused) ─────── #} + {% set is_active = grl.enabled %} +
+
+ + + +
+
+
+ Global Rate Limit + {% if is_active %} + + + Active + + {% else %} + + + Paused + + {% endif %} +
+
+

{{ grl.limit }}

+

{{ grl.algorithm }} Β· {{ grl.key_strategy }}{% if grl.burst %} Β· +{{ grl.burst }} burst{% endif %}

+
+ {% if grl.exempt_routes %} +
+ Exempt: + {% for route in grl.exempt_routes %} + {{ route }} + {% endfor %} +
+ {% endif %} +
+
+ {# Enable / Disable toggle #} + {% if is_active %} + + {% else %} + + {% endif %} + + + +
+
+ +{% else %} + + {# ── Not configured: subtle control bar ───────────────────────── #} +
+
+

Global rate limit is not configured

+ +
+ +
+ +{% endif %} +
diff --git a/shield/dashboard/templates/partials/modal_global_rl.html b/shield/dashboard/templates/partials/modal_global_rl.html new file mode 100644 index 0000000..30bca4c --- /dev/null +++ b/shield/dashboard/templates/partials/modal_global_rl.html @@ -0,0 +1,129 @@ +
+ + {# ── Header ─────────────────────────────────────────────────── #} +
+
+ + + +
+
+

{% if grl %}Edit{% else %}Configure{% endif %} Global Rate Limit

+

Applies to all routes except those listed as exempt.

+
+
+ + {# ── Edit form ───────────────────────────────────────────────── #} +
+ + {# Limit #} +
+ + +
+ + {# Algorithm + Key Strategy side by side #} +
+
+ + +
+
+ + +
+
+ + {# Burst #} +
+ + +

Extra requests allowed above the base limit.

+
+ + {# Exempt Routes #} +
+ + +

One route per line. Use /path for all methods or GET:/path for a specific method.

+
+ +
+ + +
+
+
diff --git a/shield/dashboard/templates/partials/modal_global_rl_delete.html b/shield/dashboard/templates/partials/modal_global_rl_delete.html new file mode 100644 index 0000000..148c466 --- /dev/null +++ b/shield/dashboard/templates/partials/modal_global_rl_delete.html @@ -0,0 +1,39 @@ +
+ + {# ── Header ─────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Remove Global Rate Limit

+

All routes will become unrestricted by the global limit. Per-route policies are unaffected.

+
+
+ + {# ── Confirmation form ───────────────────────────────────────── #} +
+ +
+ + +
+
+
diff --git a/shield/dashboard/templates/partials/modal_global_rl_reset.html b/shield/dashboard/templates/partials/modal_global_rl_reset.html new file mode 100644 index 0000000..2851061 --- /dev/null +++ b/shield/dashboard/templates/partials/modal_global_rl_reset.html @@ -0,0 +1,39 @@ +
+ + {# ── Header ─────────────────────────────────────────────────── #} +
+
+ + + +
+
+

Reset Global Rate Limit Counters

+

Clears all accumulated counts. The policy is kept β€” only the counters are cleared.

+
+
+ + {# ── Confirmation form ───────────────────────────────────────── #} +
+ +
+ + +
+
+
diff --git a/shield/dashboard/templates/partials/rate_limit_hits.html b/shield/dashboard/templates/partials/rate_limit_hits.html index 36ac7b3..8a8098c 100644 --- a/shield/dashboard/templates/partials/rate_limit_hits.html +++ b/shield/dashboard/templates/partials/rate_limit_hits.html @@ -1,7 +1,7 @@ {% for hit in hits %} - {{ hit.timestamp.strftime('%H:%M:%S') if hit.timestamp else 'β€”' }} + {{ hit.timestamp.strftime('%Y-%m-%d %H:%M:%S') if hit.timestamp else 'β€”' }}
diff --git a/shield/dashboard/templates/rate_limits.html b/shield/dashboard/templates/rate_limits.html index 730df64..0323575 100644 --- a/shield/dashboard/templates/rate_limits.html +++ b/shield/dashboard/templates/rate_limits.html @@ -15,6 +15,11 @@

Rate Limits

+{# ── Global rate limit card ──────────────────────────────────────── #} +{% with grl=global_rl %} +{% include "partials/global_rl_card.html" %} +{% endwith %} + {# ── Policies table ─────────────────────────────────────────────── #}
diff --git a/uv.lock b/uv.lock index c71b368..69e51ce 100644 --- a/uv.lock +++ b/uv.lock @@ -48,7 +48,7 @@ trio = [ [[package]] name = "api-shield" -version = "0.6.0" +version = "0.7.0" source = { editable = "." } dependencies = [ { name = "anyio" }, @@ -1190,15 +1190,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.1.3" +version = "1.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d7/7e/9f3b0dd3a074a6c3e1e79f35e465b1f2ee4b262d619de00cfce523cc9b24/python_discovery-1.1.3.tar.gz", hash = "sha256:7acca36e818cd88e9b2ba03e045ad7e93e1713e29c6bbfba5d90202310b7baa5", size = 56945 } +sdist = { url = "https://files.pythonhosted.org/packages/9c/90/bcce6b46823c9bec1757c964dc37ed332579be512e17a30e9698095dcae4/python_discovery-1.2.0.tar.gz", hash = "sha256:7d33e350704818b09e3da2bd419d37e21e7c30db6e0977bb438916e06b41b5b1", size = 58055 } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/80/73211fc5bfbfc562369b4aa61dc1e4bf07dc7b34df7b317e4539316b809c/python_discovery-1.1.3-py3-none-any.whl", hash = "sha256:90e795f0121bc84572e737c9aa9966311b9fde44ffb88a5953b3ec9b31c6945e", size = 31485 }, + { url = "https://files.pythonhosted.org/packages/c2/3c/2005227cb951df502412de2fa781f800663cccbef8d90ec6f1b371ac2c0d/python_discovery-1.2.0-py3-none-any.whl", hash = "sha256:1e108f1bbe2ed0ef089823d28805d5ad32be8e734b86a5f212bf89b71c266e4a", size = 31524 }, ] [[package]]