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.
@@ -17,21 +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
@@ -43,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
@@ -95,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:
@@ -175,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 7698a31..d948ddb 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -6,7 +6,18 @@ 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
+
+- 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.
---
@@ -135,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/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/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 410218b..4dca111 100644
--- a/docs/reference/engine.md
+++ b/docs/reference/engine.md
@@ -260,6 +260,83 @@ 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(...)` |
+| `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(...)` |
+
+---
+
## Scheduled maintenance
### `schedule_maintenance`
@@ -473,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/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/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/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/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
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/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 29b9567..dd8d73a 100644
--- a/shield/admin/app.py
+++ b/shield/admin/app.py
@@ -281,6 +281,10 @@ 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(
"/global-maintenance/enable",
@@ -295,6 +299,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),
@@ -309,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"]),
@@ -347,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 9f169f5..c16b1f6 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:
@@ -289,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 418ce3d..ba2ae4b 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 ...)
# ---------------------------------------------------------------------------
@@ -989,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 80cc5c7..e371dc8 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,254 @@
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)
+ )
+
+ 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
+ # ------------------------------------------------------------------
+
+ 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 +341,9 @@ 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)
# ------------------------------------------------------------------
# Async context manager โ calls backend lifecycle hooks
@@ -367,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(
@@ -376,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")
- 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
+ # 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*.
- request = (context or {}).get("request")
- custom_key_func = getattr(policy, "_custom_key_func", 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()
+
+ 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,
@@ -408,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*.
@@ -960,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,
@@ -982,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
@@ -1189,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 2450cfb..b1bd7b1 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.
@@ -454,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,
@@ -637,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 712f912..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-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-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 %}
+