diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6d1260f..248e070 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,12 +11,13 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: pnpm/action-setup@v6 + name: Install pnpm with: - node-version: 20 - cache: npm - - run: npm ci - - run: npm run build + version: 11 + cache: true + - run: pnpm ci + - run: pnpm run build - uses: actions/upload-artifact@v4 with: name: docs-dist diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index 02056b8..3d023b6 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -50,6 +50,9 @@ export default defineConfig({ { text: 'Architecture', link: '/advanced/architecture' }, { text: 'Dynamic Throttle & Sliding Window', link: '/advanced/dynamic-throttle' }, { text: 'Request Context', link: '/advanced/request-context' }, + { text: 'Portable Config', link: '/advanced/portable-config' }, + { text: 'Config Composition', link: '/advanced/config-composition' }, + { text: 'Presets', link: '/advanced/presets' }, { text: 'Track & Notifications', link: '/advanced/track-notifications' }, { text: 'Observability', link: '/advanced/observability' }, { text: 'Infrastructure Adapters', link: '/advanced/infrastructure' }, diff --git a/docs/advanced/architecture.md b/docs/advanced/architecture.md index d5a1e08..1faf816 100644 --- a/docs/advanced/architecture.md +++ b/docs/advanced/architecture.md @@ -4,7 +4,7 @@ outline: deep # Architecture -Phirewall's core decision engine uses an **evaluator pipeline** -- a sequential chain of single-responsibility evaluator classes, each handling one type of firewall rule. The pipeline processes every request and short-circuits on the first decisive result. +Phirewall's core decision engine uses an **evaluator pipeline**, a sequential chain of single-responsibility evaluator classes, each handling one type of firewall rule. The pipeline processes every request and short-circuits on the first decisive result. ## Evaluator Pipeline @@ -14,7 +14,7 @@ When `Firewall::decide()` is called, it creates an `EvaluationContext` and passe Request | v -TrackEvaluator (passive counting -- always continues) +TrackEvaluator (passive counting, always continues) | v SafelistEvaluator (match? --> allow, skip remaining) @@ -74,8 +74,8 @@ The `EvaluationContext` is a mutable transport object that carries shared config The context also provides helper methods: -- `dispatch(object $event): void` -- dispatches a PSR-14 event if a dispatcher is configured -- `responseHeaders(string $type, string $rule): array` -- builds `X-Phirewall` response headers when enabled +- `dispatch(object $event): void` - dispatches a PSR-14 event if a dispatcher is configured +- `responseHeaders(string $type, string $rule): array` - builds `X-Phirewall` response headers when enabled ## Evaluators @@ -95,7 +95,7 @@ Checks blocklist rules. On the first match, dispatches `BlocklistMatched`, sets For each fail2ban rule: -1. Checks if the key is already banned -- if so, returns a blocked result immediately +1. Checks if the key is already banned - if so, returns a blocked result immediately 2. If the filter matches, increments the failure counter and bans if the threshold is reached Both the pre-handler path (during `decide()`) and the post-handler path (via `processRecordedSignal()`) use the same `count >= threshold` comparison: the Nth matching request triggers the ban and is itself blocked. This matches rack-attack's `maxretry` semantics and is consistent with Allow2Ban. @@ -136,13 +136,13 @@ The order is optimized so cheap checks run before expensive ones, and passive tr ## Performance -The evaluator pipeline adds no measurable overhead compared to the previous monolithic implementation. Each evaluator is a lightweight, stateless object (except `Fail2BanEvaluator`, which is retained for post-handler failure processing). The pipeline iterates a fixed-size array with early exit on the first decisive result. +The evaluator pipeline adds negligible overhead. Each evaluator is a lightweight object; the `Fail2BanEvaluator` and `Allow2BanEvaluator` are additionally retained on the firewall so post-handler signal processing can reuse them. The pipeline iterates a fixed-size array with early exit on the first decisive result. Performance timing for every `decide()` call is captured in the `PerformanceMeasured` event, which includes the `DecisionPath` and `durationMicros`. See [Observability](/advanced/observability#performancemeasured) for details. ## Related Pages -- [Observability](/advanced/observability) -- PSR-14 events, diagnostics counters, performance monitoring -- [Request Context](/advanced/request-context) -- post-handler failure signaling for Fail2Ban -- [Rate Limiting](/features/rate-limiting) -- throttle rules, sliding windows, and multi-throttle -- [Fail2Ban & Allow2Ban](/features/fail2ban) -- automatic banning configuration +- [Observability](/advanced/observability) - PSR-14 events, diagnostics counters, performance monitoring +- [Request Context](/advanced/request-context) - post-handler failure signaling for Fail2Ban +- [Rate Limiting](/features/rate-limiting) - throttle rules, sliding windows, and multi-throttle +- [Fail2Ban & Allow2Ban](/features/fail2ban) - automatic banning configuration diff --git a/docs/advanced/config-composition.md b/docs/advanced/config-composition.md new file mode 100644 index 0000000..2421f3d --- /dev/null +++ b/docs/advanced/config-composition.md @@ -0,0 +1,90 @@ +--- +outline: deep +--- + +# Config Composition + +Real deployments rarely have a single source of firewall rules. A vendor ships a baseline, an environment (staging vs. production) adds its own rules, a tenant overrides a few, and a single deployment applies a last-minute tweak. `Config::compose()` (and the fluent `Config::mergedWith()`) merges these layers into one effective `Config` (**without mutating any input**) so each layer can be owned, versioned, and shipped independently, often as a [`PortableConfig`](/advanced/portable-config). + +## Usage + +```php +use Flowd\Phirewall\Config; + +// Each layer is owned and versioned independently, usually as a PortableConfig. +// Materialize them onto your cache with Config::combine(); later layers win. +// The cache lives only on Config; the portable layers never carry one. +$effective = (new Config($cache))->combine( + $vendorPortable, // shared product defaults + $environmentPortable, // staging vs. production + $tenantPortable, // per-customer policy +); + +// Already holding Config instances? compose() / mergedWith() layer those directly +// (same precedence; later layers win): +$effective = $vendorConfig->mergedWith($environmentConfig, $tenantConfig); +$effective = Config::compose($vendorConfig, $environmentConfig, $tenantConfig); +``` + +`compose()` is static and reads as "base first, overlays after"; `mergedWith()` is the instance form for when you already hold the base. Both return a fresh `Config`; the base and every overlay are left untouched. + +| Form | Signature | Reads as | +|------|-----------|----------| +| `Config::compose(...$configs)` | static, variadic | base first, overlays after | +| `$base->mergedWith(...$overlays)` | instance, variadic | overlays applied onto `$base` | + +## Merge semantics + +Overlays are applied left to right, so **later sources win**. + +| Aspect | Rule | +|--------|------| +| **Rules** (safelists, blocklists, throttles, fail2ban, allow2ban, tracks) | Merged **by name** within each section. A later same-named rule **replaces** the earlier one in place (base ordering preserved); genuinely new names are appended. A union, never duplicates. | +| **Pattern backends** | Merged by name with the same later-wins rule. | +| **`enabled`** | **Last layer wins (fail-safe)**: the composed value is the `enabled` state of the highest-priority (last) layer. An explicit `enable()` / `disable()` / `setEnabled()` on the winning layer always takes effect, so an ambiguous composition is never left silently disabled. | +| **Other scalar / object options** (`keyPrefix`, `failOpen`, the response-header toggles, the IP resolver, the discriminator normalizer, the response factories) | **Last explicit value wins**: the value comes from the last layer whose value differs from the field default. A layer that left an option at its default never clobbers an explicit choice from an earlier layer. | +| **Infrastructure** (PSR-16 cache, PSR-14 event dispatcher, clock) | Inherited from the **base** layer; overlays do not override it. | + +### Why "last explicit value wins"? + +A `Config` does not track which options were *set* versus *left at their default*. Composition therefore treats "still at the field default" as "no opinion": only a value that differs from the default counts as an explicit choice that can override an earlier layer. This is what lets a thin overlay add a single rule without silently resetting the baseline's `keyPrefix` or `failOpen` policy back to the defaults. + +### Limitation: an overlay cannot reset a toggle to its default + +Because "default-valued" is read as "no opinion", an overlay **cannot turn a toggle back off** once an earlier layer turned it on. If the vendor baseline calls `enableResponseHeaders()` (changing the toggle from its `false` default to `true`), a tenant overlay that leaves the toggle at `false` will *not* switch it back off; its `false` is indistinguishable from "unspecified", so the baseline's explicit `true` wins. The same applies to `failOpen` and the other boolean toggles. (`enabled` is the deliberate exception: as its row above notes, it uses last-layer-wins, so a later layer *can* re-assert it.) + +If you need a later layer to *force* a non-default option back to the default, do not rely on composition: build the final `Config` and set the option explicitly after composing, e.g. `Config::compose(...)->setFailOpen(true)`. + +### Limitation: composing the IP resolver does not rewrite IP-aware matchers + +IP-aware matchers (`IpMatcher`, the file/snapshot IP blocklists, `TrustedBotMatcher`) capture their IP resolver **when the rule is constructed**. Because composition copies already-built rule objects, composing a layer with a different IP resolver only affects rules added *after* it; it does **not** retroactively change how earlier layers' IP rules resolve the client IP. Set the resolver on each source `Config` (via `setIpResolver()`) **before** adding its IP rules, rather than expecting a later layer to override it. + +This limitation does **not** apply to counter rules (throttle, fail2ban, allow2ban, track) added **without** an explicit `key`. Their default IP key is resolved per request against the `Config` they run under, so a composed `Config` correctly applies its own merged IP resolver to such rules no matter which layer defined them. + +## Example + +```php +use Flowd\Phirewall\Config; +use Flowd\Phirewall\Http\Firewall; + +$effective = $vendorBaseline->mergedWith($environmentOverlay, $tenantOverlay, $deploymentTweak); + +// Rules unioned by name, base ordering preserved: +$effective->blocklists->rules(); // ['scanners' (tenant wins), 'bad-net', 'admin-probe', ...] +$effective->allow2ban->rules(); // ['volume-cap'] contributed by the tenant overlay + +// Last-explicit-wins options: +$effective->getKeyPrefix(); // 'deploy-eu-1' (last layer that set it) +$effective->isFailOpen(); // false (only the deployment layer set it) +$effective->responseHeadersEnabled(); // true (set by the environment overlay) + +$firewall = new Firewall($effective); +``` + +See [`examples/30-config-composition.php`](https://github.com/flowd/phirewall/blob/main/examples/30-config-composition.php) for a full vendor → environment → tenant → deployment walkthrough that prints an overridden-by-name rule, the unioned rule sets, and the last-wins options, then proves the composed firewall enforces every layer while leaving the inputs unchanged. + +## Related pages + +- [Portable Config](/advanced/portable-config) - ship each layer as serializable data. +- [Presets](/advanced/presets) - bundled `Config`s designed to be composed under your own rules. +- [Getting Started](/getting-started) - the base `Config` and its options. diff --git a/docs/advanced/discriminator-normalizer.md b/docs/advanced/discriminator-normalizer.md index f22cca5..fe1966d 100644 --- a/docs/advanced/discriminator-normalizer.md +++ b/docs/advanced/discriminator-normalizer.md @@ -10,17 +10,17 @@ Phirewall provides two layers of key normalization to ensure consistent counting When a request is evaluated, the key goes through two normalization stages: -1. **Discriminator Normalizer** (optional, user-configured) -- transforms the raw key before it reaches the cache key generator. Use this for domain-specific normalization like case-insensitive matching. -2. **Cache Key Generator** (automatic) -- rule names are sanitized to safe characters; user-extracted keys are SHA-256 hashed for collision-free, fixed-length cache keys. +1. **Discriminator Normalizer** (optional, user-configured): transforms the raw key before it reaches the cache key generator. Use this for domain-specific normalization like case-insensitive matching. +2. **Cache Key Generator** (automatic): rule names are sanitized to safe characters; user-extracted keys are SHA-256 hashed for collision-free, fixed-length cache keys. ## The Bypass Problem Without normalization, attackers can bypass rate limiting by manipulating the key used for counting: ``` -phirewall:throttle:api: ← Real IP -phirewall:throttle:api: ← Trailing space -phirewall:throttle:api: ← Leading space +phirewall.throttle.api. ← Real IP +phirewall.throttle.api. ← Trailing space +phirewall.throttle.api. ← Leading space ``` Each of these would produce a different SHA-256 hash and create a separate counter, effectively multiplying the attacker's rate limit. The discriminator normalizer prevents this by transforming keys before hashing. @@ -53,18 +53,18 @@ The normalizer is a `Closure` that receives a string and returns a string. It is Phirewall's `CacheKeyGenerator` produces cache keys in this format: ``` -{prefix}:{type}:{normalized_rule_name}:{hashed_key} +{prefix}.{type}.{normalized_rule_name}.{hashed_key} ``` ### Rule Name Normalization Rule names are sanitized for safe use in cache keys: -1. **Trimmed** -- leading and trailing whitespace removed -2. **Sanitized** -- only `A-Za-z0-9._:-` characters are kept; all others replaced with `_` -3. **Deduplicated** -- consecutive underscores collapsed to one -4. **Truncated** -- names longer than 120 characters are shortened with a SHA-1 suffix -5. **Empty-safe** -- empty strings are replaced with `empty` +1. **Trimmed** - leading and trailing whitespace removed +2. **Sanitized** - only `A-Za-z0-9._-` characters are kept; all others replaced with `_` +3. **Deduplicated** - consecutive underscores collapsed to one +4. **Truncated** - names longer than 120 characters are shortened with a SHA-1 suffix +5. **Empty-safe** - empty strings are replaced with `empty` Rule names are memoized internally for performance. @@ -77,10 +77,10 @@ User-extracted keys (IP addresses, usernames, API keys, etc.) are hashed with SH ``` This ensures: -- **Fixed length** -- regardless of input length, the cache key is always the same size -- **No special characters** -- hex output is always safe for any cache backend -- **Collision-free** -- SHA-256 has negligible collision probability -- **No memory leak** -- unlike memoized normalization, hashing is stateless and safe for long-running processes +- **Fixed length** - regardless of input length, the cache key is always the same size +- **No special characters** - hex output is always safe for any cache backend +- **Collision-free** - SHA-256 has negligible collision probability +- **No memory leak** - unlike memoized normalization, hashing is stateless and safe for long-running processes ### Examples @@ -171,15 +171,15 @@ Different cache backends have different key constraints: | File-based | OS path limit (~260 chars) | `/`, `\`, `NUL` | | Memcached | 250 bytes | Spaces, control characters | -SHA-256 hashing ensures user keys are always exactly 64 hex characters, safe across all backends. +SHA-256 hashing ensures user keys are always exactly 64 hex characters, safe across all backends. The Memcached and File-based rows are general PSR-16 examples; they are not bundled backends (Phirewall ships `InMemoryCache`, `ApcuCache`, `RedisCache`, and `PdoCache`). ### Security Without normalization, user-supplied values (like User-Agent strings, API keys, or email addresses) could: -- **Enable bypass attacks** -- padding, encoding, or case variations create distinct keys for the same identity -- **Exhaust cache memory** -- long user-agents or query strings create bloated keys -- **Leak sensitive data** -- raw keys may appear in cache monitoring tools; hashed keys are opaque +- **Enable bypass attacks** - padding, encoding, or case variations create distinct keys for the same identity +- **Exhaust cache memory** - long user-agents or query strings create bloated keys +- **Leak sensitive data** - raw keys may appear in cache monitoring tools; hashed keys are opaque ## Discriminator Normalizer vs. Key Extractor Normalization @@ -199,10 +199,10 @@ Use `$config->setKeyPrefix()` to change the prefix and avoid collisions when sha ```php $config->setKeyPrefix('myapp'); -// Keys become: myapp:throttle:..., myapp:fail2ban:..., etc. +// Keys become: myapp.throttle..., myapp.fail2ban..., etc. ``` -The prefix itself is validated -- it cannot be empty. +The prefix is validated: it is trimmed (whitespace and a trailing `:` stripped), must be non-empty, and may not contain a PSR-16 reserved character (`{}()/\@:`) or any control/whitespace character; otherwise `setKeyPrefix()` throws `InvalidArgumentException` at the call site. ## Best Practices diff --git a/docs/advanced/dynamic-throttle.md b/docs/advanced/dynamic-throttle.md index 8bfb86b..907fad9 100644 --- a/docs/advanced/dynamic-throttle.md +++ b/docs/advanced/dynamic-throttle.md @@ -25,12 +25,14 @@ use Psr\Http\Message\ServerRequestInterface; $config = new Config(new InMemoryCache()); $config->enableRateLimitHeaders(); +// `role` is a PSR-7 request attribute your auth middleware sets before Phirewall +// runs (e.g. $request->withAttribute('role', $user->role)). Attributes are +// server-side only, so a client cannot forge them the way it could a header. // Admins get 1000 req/min, regular users get 100 req/min $config->throttles->add('role-based', limit: fn(ServerRequestInterface $request): int => - $request->getHeaderLine('X-Role') === 'admin' ? 1000 : 100, + $request->getAttribute('role') === 'admin' ? 1000 : 100, period: 60, - key: KeyExtractors::ip(), ); ``` @@ -46,7 +48,6 @@ $config->throttles->add('endpoint-adaptive', limit: 100, period: fn(ServerRequestInterface $request): int => str_starts_with($request->getUri()->getPath(), '/api/export') ? 3600 : 60, - key: KeyExtractors::ip(), ); ``` @@ -62,10 +63,9 @@ You can make both the limit and the period dynamic: // Enterprise users: 10,000 req/hour. Everyone else: 100 req/min. $config->throttles->add('fully-dynamic', limit: fn(ServerRequestInterface $request): int => - $request->getHeaderLine('X-Plan') === 'enterprise' ? 10000 : 100, + $request->getAttribute('plan') === 'enterprise' ? 10000 : 100, period: fn(ServerRequestInterface $request): int => - $request->getHeaderLine('X-Plan') === 'enterprise' ? 3600 : 60, - key: KeyExtractors::ip(), + $request->getAttribute('plan') === 'enterprise' ? 3600 : 60, ); ``` @@ -76,7 +76,7 @@ $config->throttles->add( string $name, int|Closure $limit, // Static int or Closure(ServerRequestInterface): int int|Closure $period, // Static int or Closure(ServerRequestInterface): int - Closure $key, // Closure(ServerRequestInterface): ?string + ?Closure $key = null, // Closure(ServerRequestInterface): ?string ): ThrottleSection ``` @@ -85,7 +85,7 @@ $config->throttles->add( | `$name` | `string` | Rule name (appears in headers and events) | | `$limit` | `int\|Closure` | Maximum requests in the period. Closure receives the request. | | `$period` | `int\|Closure` | Time window in seconds. Closure receives the request. | -| `$key` | `Closure` | Key extractor. Return `null` to skip this rule for the request. | +| `$key` | `?Closure` | Key extractor; return `null` to skip this rule. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | ## Sliding Window @@ -130,11 +130,10 @@ As time progresses within the current window, the previous window's contribution $config->throttles->sliding('api-sliding', limit: 100, period: 60, - key: KeyExtractors::ip(), ); ``` -The method signature is identical to `add()` -- the only difference is the internal algorithm. Sliding windows also support dynamic `limit` and `period` closures. +The method signature is identical to `add()`; the only difference is the internal algorithm. Sliding windows also support dynamic `limit` and `period` closures. ### Fixed vs. Sliding Comparison @@ -146,7 +145,7 @@ The method signature is identical to `add()` -- the only difference is the inter | Best for | Simple rate limiting, internal APIs | Public APIs, strict limit enforcement | ::: tip -The sliding window algorithm is not atomic under high concurrency -- a small number of requests may slip through at the exact moment the threshold is crossed. This is acceptable for rate limiting, which is a fairness mechanism, not a security boundary. For hard security limits, use [Fail2Ban](/features/fail2ban) or [Allow2Ban](/features/fail2ban#allow2ban). +The sliding window algorithm is not atomic under high concurrency; a small number of requests may slip through at the exact moment the threshold is crossed. This is acceptable for rate limiting, which is a fairness mechanism, not a security boundary. For hard security limits, use [Fail2Ban](/features/fail2ban) or [Allow2Ban](/features/fail2ban#allow2ban). ::: ## Multi-Window Throttling @@ -160,7 +159,7 @@ Register multiple time windows under a single logical name with `multi()`. This $config->throttles->multi('api', [ 1 => 3, // 3 requests per second (burst protection) 60 => 100, // 100 requests per minute (sustained limit) -], KeyExtractors::ip()); +]); ``` A request is blocked if it exceeds **any** window's limit. Windows are evaluated from shortest to longest period. @@ -171,7 +170,7 @@ A request is blocked if it exceeds **any** window's limit. Windows are evaluated $config->throttles->multi( string $name, array $windowLimits, // array - Closure $key, + ?Closure $key = null, ): ThrottleSection ``` @@ -179,14 +178,14 @@ $config->throttles->multi( |-----------|------|-------------| | `$name` | `string` | Base name. Sub-rules are named `{name}:{period}s`. | | `$windowLimits` | `array` | Map of period (seconds) to limit (max requests). Must not be empty. | -| `$key` | `Closure` | Key extractor, shared across all sub-rules. | +| `$key` | `?Closure` | Key extractor, shared across all sub-rules. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | ### Naming Convention Sub-rules follow the pattern `{name}:{period}s`: ```php -$config->throttles->multi('api', [1 => 5, 60 => 100, 3600 => 2000], KeyExtractors::ip()); +$config->throttles->multi('api', [1 => 5, 60 => 100, 3600 => 2000]); // Creates three rules: // - "api:1s" -> 5 req/s @@ -205,7 +204,7 @@ $config->throttles->multi('public-api', [ 1 => 10, // 10 req/s burst cap 60 => 300, // 300 req/min sustained 3600 => 5000, // 5000 req/hour daily budget -], KeyExtractors::ip()); +]); ``` ## Per-User Tier Limits @@ -218,15 +217,14 @@ Use a single rule with a dynamic limit. This is simpler and requires less config ```php $config->throttles->add('api', - limit: fn(ServerRequestInterface $request): int => match ($request->getHeaderLine('X-Plan')) { + limit: fn(ServerRequestInterface $request): int => match ($request->getAttribute('plan')) { 'enterprise' => 10000, 'pro' => 1000, 'free' => 100, default => 50, }, period: 60, - key: fn($request): ?string => $request->getHeaderLine('X-User-Id') - ?: $request->getServerParams()['REMOTE_ADDR'] ?? null, + key: fn($request): ?string => $request->getServerParams()['REMOTE_ADDR'] ?? null, ); ``` @@ -239,8 +237,8 @@ Create separate rules and use the key closure returning `null` to skip: $config->throttles->add('free-tier', limit: 100, period: 60, key: function ($request): ?string { - if ($request->getHeaderLine('X-Plan') !== 'free') return null; - return $request->getHeaderLine('X-User-Id') ?: null; + if ($request->getAttribute('plan') !== 'free') return null; + return $request->getAttribute('userId'); }, ); @@ -248,8 +246,8 @@ $config->throttles->add('free-tier', $config->throttles->add('pro-tier', limit: 1000, period: 60, key: function ($request): ?string { - if ($request->getHeaderLine('X-Plan') !== 'pro') return null; - return $request->getHeaderLine('X-User-Id') ?: null; + if ($request->getAttribute('plan') !== 'pro') return null; + return $request->getAttribute('userId'); }, ); @@ -257,14 +255,14 @@ $config->throttles->add('pro-tier', $config->throttles->add('anonymous', limit: 50, period: 60, key: function ($request): ?string { - if ($request->getHeaderLine('X-User-Id') !== '') return null; + if ($request->getAttribute('userId') !== null) return null; return $request->getServerParams()['REMOTE_ADDR'] ?? null; }, ); ``` -::: tip -Your authentication middleware should set `X-User-Id` and `X-Plan` headers on the PSR-7 request before it reaches the Phirewall middleware. This keeps rate limiting configuration clean and decoupled from authentication logic. +::: tip Read tier and identity from request attributes, not headers +`plan` and `userId` here are PSR-7 request **attributes**, set by your authentication middleware after it verifies the principal: `$request = $request->withAttribute('plan', $user->plan)`. Attributes live only on the server-side request object and are never part of the incoming HTTP message, so a client cannot forge them the way it could an `X-Plan` header. Place that middleware before Phirewall in the pipeline (Phirewall still runs after your error handler). Only fall back to a header if a separate upstream service sets it, and then strip or overwrite any inbound copy at the trusted edge. ::: ## Per-Endpoint Cost @@ -295,8 +293,8 @@ $config->throttles->add('export-endpoints', limit: 10, period: 3600, key: function ($request): ?string { if (!str_starts_with($request->getUri()->getPath(), '/api/export')) return null; - return $request->getHeaderLine('X-User-Id') - ?: $request->getServerParams()['REMOTE_ADDR'] ?? null; + return $request->getAttribute('userId') + ?? ($request->getServerParams()['REMOTE_ADDR'] ?? null); }, ); ``` @@ -314,7 +312,7 @@ $config->throttles->add('api-limit', if (str_starts_with($ip, '10.')) return null; // Skip for admin users - if ($request->getHeaderLine('X-Role') === 'admin') return null; + if ($request->getAttribute('role') === 'admin') return null; // Skip for webhooks if (str_starts_with($request->getUri()->getPath(), '/webhooks/')) return null; @@ -339,15 +337,14 @@ $tierMap = array_column($userTiers, 'plan', 'user_id'); $config->throttles->add('db-tiered', limit: fn(ServerRequestInterface $request) use ($tierMap): int => - match ($tierMap[$request->getHeaderLine('X-User-Id')] ?? 'anonymous') { + match ($tierMap[$request->getAttribute('userId') ?? ''] ?? 'anonymous') { 'enterprise' => 10000, 'pro' => 1000, 'free' => 100, default => 50, }, period: 60, - key: fn($request): ?string => $request->getHeaderLine('X-User-Id') - ?: $request->getServerParams()['REMOTE_ADDR'] ?? null, + key: fn($request): ?string => $request->getServerParams()['REMOTE_ADDR'] ?? null, ); ``` @@ -396,7 +393,7 @@ $firewall->resetAll(); ## Related Pages -- [Rate Limiting](/features/rate-limiting) -- basic throttle setup and rate limit headers -- [Observability](/advanced/observability) -- `ThrottleExceeded` events and metrics -- [Safelists & Blocklists](/features/safelists-blocklists) -- bypass all rules for trusted traffic -- [Track & Notifications](/advanced/track-notifications) -- passive counting for monitoring +- [Rate Limiting](/features/rate-limiting) - basic throttle setup and rate limit headers +- [Observability](/advanced/observability) - `ThrottleExceeded` events and metrics +- [Safelists & Blocklists](/features/safelists-blocklists) - bypass all rules for trusted traffic +- [Track & Notifications](/advanced/track-notifications) - passive counting for monitoring diff --git a/docs/advanced/infrastructure.md b/docs/advanced/infrastructure.md index 9ddebfa..dddc566 100644 --- a/docs/advanced/infrastructure.md +++ b/docs/advanced/infrastructure.md @@ -8,7 +8,7 @@ Phirewall can mirror application-level blocks to web server infrastructure, prov ## Why Infrastructure-Level Blocking? -Application-level firewalls run inside PHP, which means every blocked request still consumes PHP-FPM resources. By mirroring bans to the web server layer (Apache, Nginx), subsequent requests from banned IPs are rejected before reaching PHP -- saving CPU, memory, and reducing attack surface. +Application-level firewalls run inside PHP, which means every blocked request still consumes PHP-FPM resources. By mirroring bans to the web server layer (Apache, Nginx), subsequent requests from banned IPs are rejected before reaching PHP, saving CPU, memory, and reducing attack surface. ```text Request --> Web Server (.htaccess) --> PHP-FPM --> Phirewall Middleware @@ -104,7 +104,7 @@ RewriteRule ^(.*)$ index.php [L] # BEGIN Phirewall Require not ip 192.168.1.101 Require not ip 10.0.0.50 -Require not ip 2001:db8::1 +Require not ip 2001:db8::5 # END Phirewall # More custom rules (preserved) @@ -113,11 +113,11 @@ Options -Indexes ### Safety Features -- **Atomic writes** -- writes to a temporary file first, then renames it (POSIX atomic operation), preserving permissions -- **IP validation** -- all IPs are validated with `filter_var()` before writing; IPv6 addresses are normalized to canonical form to prevent duplicates -- **Content preservation** -- only the managed section between markers is modified; all other `.htaccess` content is untouched -- **Idempotent operations** -- blocking an already-blocked IP is a no-op; duplicates in batch operations are deduplicated -- **All-or-nothing semantics** -- in `blockMany()`/`unblockMany()`, all IPs are validated before any file modification; if one IP is invalid, the entire operation is rejected +- **Atomic writes** - writes to a temporary file first, then renames it (POSIX atomic operation), preserving permissions +- **IP validation** - all IPs are validated with `filter_var()` before writing; IPv6 addresses are normalized to canonical form to prevent duplicates +- **Content preservation** - only the managed section between markers is modified; all other `.htaccess` content is untouched +- **Idempotent operations** - blocking an already-blocked IP is a no-op; duplicates in batch operations are deduplicated +- **All-or-nothing semantics** - in `blockMany()`/`unblockMany()`, all IPs are validated before any file modification; if one IP is invalid, the entire operation is rejected ## Automatic Event Integration @@ -138,8 +138,8 @@ new InfrastructureBanListener( | Parameter | Type | Default | Description | |-----------|------|---------|-------------| -| `$infrastructureBlocker` | `InfrastructureBlockerInterface` | -- | The adapter to push blocks to | -| `$nonBlockingRunner` | `NonBlockingRunnerInterface` | -- | How to execute the adapter call | +| `$infrastructureBlocker` | `InfrastructureBlockerInterface` | - | The adapter to push blocks to | +| `$nonBlockingRunner` | `NonBlockingRunnerInterface` | - | How to execute the adapter call | | `$blockOnFail2Ban` | `bool` | `true` | Mirror Fail2Ban bans | | `$blockOnBlocklist` | `bool` | `false` | Mirror blocklist hits (request IP) | | `$keyToIp` | `?callable` | identity | Map a Fail2Ban key to an IP (default: assumes key is an IP) | @@ -424,13 +424,11 @@ $config->fail2ban->add('login-abuse', threshold: 5, period: 300, ban: 3600, filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Standard rate limiting $config->throttles->add('global', limit: 100, period: 60, - key: KeyExtractors::ip() ); $middleware = new Middleware($config); diff --git a/docs/advanced/observability.md b/docs/advanced/observability.md index 781bbe6..d4c9e62 100644 --- a/docs/advanced/observability.md +++ b/docs/advanced/observability.md @@ -19,7 +19,7 @@ $dispatcher = /* your PSR-14 event dispatcher */; $config = new Config(new InMemoryCache(), $dispatcher); ``` -If no dispatcher is provided, events are silently skipped with zero overhead. This means observability is entirely opt-in -- your firewall runs at full speed with no event overhead until you plug in a dispatcher. +If no dispatcher is provided, events are silently skipped with zero overhead. This means observability is entirely opt-in: your firewall runs at full speed with no event overhead until you plug in a dispatcher. ## PSR-14 Events @@ -112,7 +112,7 @@ $event->serverRequest; // ServerRequestInterface ### TrackHit -Track events fire on **every** matching request -- they never block. When a `limit` is configured on the track rule, the `thresholdReached` flag becomes `true` once the counter reaches the threshold. This makes track rules ideal for alerting without blocking. +Track events fire on **every** matching request; they never block. When a `limit` is configured on the track rule, the `thresholdReached` flag becomes `true` once the counter reaches the threshold. This makes track rules ideal for alerting without blocking. ```php use Flowd\Phirewall\Events\TrackHit; @@ -177,7 +177,7 @@ Since this event fires on every request, expensive operations in its handler dir ## Diagnostics Counters -Phirewall provides a built-in `DiagnosticsCounters` class that collects lightweight, in-memory counters for every decision category — perfect for health endpoints, dashboards, and quick debugging. +Phirewall provides a built-in `DiagnosticsCounters` class that collects lightweight, in-memory counters for every decision category, perfect for health endpoints, dashboards, and quick debugging. `DiagnosticsCounters` is an observer, not a dispatcher. To use it, wrap it with `DiagnosticsDispatcher`: @@ -667,7 +667,7 @@ class FirewallEventsTest extends TestCase ``` ::: tip Use `Firewall` directly in tests -The `Firewall` class returns a `Decision` object with `isPass()` and `isBlock()` methods. This is faster than running through the full PSR-15 middleware pipeline and does not require a PSR-17 response factory. +The `Firewall` class returns a `FirewallResult` object with `isPass()` and `isBlocked()` methods. This is faster than running through the full PSR-15 middleware pipeline and does not require a PSR-17 response factory. ::: ## Performance Considerations @@ -702,7 +702,7 @@ public function dispatch(object $event): object ``` ::: tip -Always process blocking events (`BlocklistMatched`, `Fail2BanBanned`, `ThrottleExceeded`, `Allow2BanBanned`, `FirewallError`) at full volume -- these are low-frequency, high-signal events that you do not want to miss. +Always process blocking events (`BlocklistMatched`, `Fail2BanBanned`, `ThrottleExceeded`, `Allow2BanBanned`, `FirewallError`) at full volume; these are low-frequency, high-signal events that you do not want to miss. ::: ### Protect Sensitive Data @@ -714,9 +714,13 @@ $maskedIp = preg_replace('/\.\d+$/', '.xxx', $event->key); $this->logger->info('Event', ['key' => $maskedIp]); ``` +::: warning Keys can be secrets +The discriminator in event payloads (`$event->key`) and in the ban-registry cache entry is the **raw** value the rule keyed on. When a rule keys on a credential-bearing header, that is a live secret; never log it verbatim and never expose the ban registry. Key such rules with `KeyExtractors::hashedHeader()` so only a sha256 fingerprint is stored and emitted. +::: + ## Related Pages -- [Track & Notifications](/advanced/track-notifications) -- track rules, thresholds, and notification patterns -- [Request Context](/advanced/request-context) -- post-handler failure signaling for Fail2Ban -- [Rate Limiting](/features/rate-limiting) -- throttle rules and rate limit headers -- [Fail2Ban & Allow2Ban](/features/fail2ban) -- automatic banning +- [Track & Notifications](/advanced/track-notifications) - track rules, thresholds, and notification patterns +- [Request Context](/advanced/request-context) - post-handler failure signaling for Fail2Ban +- [Rate Limiting](/features/rate-limiting) - throttle rules and rate limit headers +- [Fail2Ban & Allow2Ban](/features/fail2ban) - automatic banning diff --git a/docs/advanced/portable-config.md b/docs/advanced/portable-config.md new file mode 100644 index 0000000..910ae9d --- /dev/null +++ b/docs/advanced/portable-config.md @@ -0,0 +1,208 @@ +--- +outline: deep +--- + +# Portable Config + +`PortableConfig` expresses a firewall ruleset as plain, JSON-serializable data instead of PHP closures. Because a ruleset is just data, you can: + +- **store it in a database** and pick up rule changes on the next request, +- **ship it through a config service** (etcd, Consul, S3, a settings table), +- **diff and review it in git**, or +- **share one ruleset across many apps, processes, or languages** + +…and then materialize a live [`Config`](/getting-started) from it with [`Config::combine()`](/advanced/config-composition); the schema is pure data and never carries a cache. Closures are never serialized, so the surface is intentionally a safe, declarative subset (see [Not portable by design](#not-portable-by-design)). + +## Building and round-tripping + +Build a ruleset fluently, export it with `toArray()` (or `json_encode()` the result), and rebuild it with `fromArray()`, then materialize it onto a `Config` with `Config::combine()`: + +```php +use Flowd\Phirewall\Config; +use Flowd\Phirewall\Http\Firewall; +use Flowd\Phirewall\Pattern\PatternKind; +use Flowd\Phirewall\Portable\PortableConfig; + +$portable = PortableConfig::create() + ->setKeyPrefix('shop') + ->enableRateLimitHeaders() + ->enableResponseHeaders() + ->safelist('health', PortableConfig::filterPathEquals('/health')) + ->blocklist('admin-probe', PortableConfig::filterPathPrefix('/wp-admin')) + ->blocklist('scanners', PortableConfig::filterKnownScanners()) + ->blocklist('bad-net', PortableConfig::filterIp(['203.0.113.0/24'])) + ->throttle('api', limit: 100, period: 60, key: PortableConfig::keyHashedHeader('X-Api-Key'), sliding: true) + ->allow2ban('volume-cap', threshold: 1000, period: 60, ban: 300, key: PortableConfig::keyIp()) + ->fail2ban('wp-login-probe', threshold: 5, period: 60, ban: 900, filter: PortableConfig::filterPathEquals('/wp-login.php'), key: PortableConfig::keyIp()) + ->patternBlocklist('threats', [ + PortableConfig::patternEntry(PatternKind::CIDR, '10.66.0.0/16'), + PortableConfig::patternEntry(PatternKind::PATH_REGEX, '#/\.git(/|$)#'), + ]); + +// Export as data … +$json = json_encode($portable->toArray(), JSON_THROW_ON_ERROR); + +// … and rebuild a live Config somewhere else. +$config = (new Config($cache))->combine(PortableConfig::fromArray(json_decode($json, true, 512, JSON_THROW_ON_ERROR))); +$firewall = new Firewall($config); +``` + +`fromArray()` validates the *shape* of the data (rule/filter/key types, regex patterns compile, pattern-entry fields) and throws `InvalidArgumentException` on anything malformed. It does **not** verify *authenticity*; for that, see [Signed transport](#signed-transport). + +## The catalogue + +Everything `PortableConfig` can express today. + +### Rules + +| Builder | Notes | +|---------|-------| +| `safelist(name, filter)` | Bypass all checks when the filter matches | +| `blocklist(name, filter)` | Deny (403) when the filter matches | +| `throttle(name, limit, period, key, sliding = false, scope = null)` | Fixed or sliding-window rate limit (429); the optional `scope` filter restricts which requests the throttle counts (e.g. only `/api`) | +| `fail2ban(name, threshold, period, ban, filter, key)` | Auto-ban after repeated matching ("bad") requests | +| `allow2ban(name, threshold, period, ban, key)` | Hard volume cap: ban after too many *total* requests for a key | +| `track(name, period, filter, key, limit = null)` | Passive counting with optional alert threshold | +| `addPatternBackend(name, entries)` | Register a reusable catalogue of block patterns | +| `blocklistFromBackend(name, backendName)` | Add a blocklist that matches against a registered backend | +| `patternBlocklist(name, entries)` | Convenience: register a backend and a blocklist under one name | + +### Filters (request predicates) + +| Factory | Matches when … | +|---------|----------------| +| `filterAll()` | always | +| `filterNone()` | never: a filter that never matches; use it for a rule that must not be assertable from any request property (e.g. a fail2ban driven solely by `RequestContext::recordFailure`) | +| `filterPathEquals(path)` | the path equals `path` | +| `filterPathPrefix(prefix)` | the path starts with `prefix` | +| `filterPathRegex(pattern)` | the path matches the PCRE `pattern` (delimiters included) | +| `filterMethodEquals(method)` | the HTTP method equals `method` (case-insensitive) | +| `filterMethodIn(methods)` | the HTTP method is one of `methods` | +| `filterHeaderEquals(name, value)` | header `name` equals `value` | +| `filterHeaderPresent(name)` | header `name` is present with any non-empty value | +| `filterHeaderRegex(name, pattern)` | header `name` matches the PCRE `pattern` | +| `filterIp(ipsOrCidrs)` | the client IP is in the list (CIDR-aware, IPv4/IPv6), backed by `IpMatcher` | +| `filterKnownScanners(patterns = null)` | the User-Agent matches a known scanner; `null` uses the curated default list, backed by `KnownScannerMatcher` | +| `filterSuspiciousHeaders(requiredHeaders = null)` | a required browser header is missing; `null` uses the default set, backed by `SuspiciousHeadersMatcher` | + +`filterIp`, `filterKnownScanners`, and `filterSuspiciousHeaders` compile to the dedicated matcher classes (so you get their diagnostics and CIDR handling); the remaining filters compile to a request-predicate closure. + +::: warning +`filterHeaderEquals`, `filterHeaderPresent`, and `filterHeaderRegex` are rejected on `safelist()` (and on `fromArray()` deserialize): a client-controlled header value would be a forgeable bypass token (anyone presenting it skips every downstream rule). They remain valid on blocklists, throttles, fail2ban, and track rules. +::: + +### Key extractors + +| Factory | Keys on | +|---------|---------| +| `keyIp()` | client IP (`REMOTE_ADDR`) | +| `keyMethod()` | HTTP method | +| `keyPath()` | request path | +| `keyHeader(name)` | raw value of header `name` | +| `keyHashedHeader(name)` | sha256 fingerprint of header `name`, preferred for credential-bearing headers (`Authorization`, `Cookie`, `X-Api-Key`) so the raw value never reaches the cache/ban registry | + +::: tip +`keyIp()` keys on `REMOTE_ADDR`, which behind a CDN or load balancer is the proxy's address, not the client's. The IP resolver is a closure and therefore not portable; set it on the rebuilt `Config` with `setIpResolver(KeyExtractors::clientIp(new TrustedProxyResolver([...])))`. See [Client IP behind proxies](/getting-started#client-ip-behind-proxies). +::: + +### Pattern kinds (`PortableConfig::patternEntry()`) + +Pattern backends carry a list of entries; each entry has a `PatternKind`: + +| Kind | Matches | +|------|---------| +| `PatternKind::IP` | exact client IP | +| `PatternKind::CIDR` | client IP within a CIDR range | +| `PatternKind::PATH_EXACT` | exact path | +| `PatternKind::PATH_PREFIX` | path prefix | +| `PatternKind::PATH_REGEX` | path PCRE pattern | +| `PatternKind::HEADER_EXACT` | named header equals value (entry `target` = header name) | +| `PatternKind::HEADER_REGEX` | named header matches PCRE pattern (entry `target` = header name) | +| `PatternKind::REQUEST_REGEX` | pattern over path + query + headers | + +`patternEntry()` also accepts optional `target`, `expiresAt`, `addedAt`, and a scalar `metadata` map, all of which round-trip as data, so an entry can carry its own expiry and provenance (handy when the catalogue lives in a database). + +### Options + +| Builder | Effect on the built `Config` | +|---------|------------------------------| +| `enableRateLimitHeaders()` | emit `X-RateLimit-*` headers | +| `enableResponseHeaders()` | emit `X-Phirewall-*` headers | +| `enableOwaspDiagnosticsHeader()` | emit the OWASP diagnostics header | +| `setFailOpen(bool)` | fail-open (default) vs fail-closed on backend errors | +| `setKeyPrefix(prefix)` | cache-key prefix | + +## Rules in a database + +Pattern backends and the portable schema are the natural fit for a block catalogue you maintain *outside* code, e.g. a `blocked_patterns` table or a threat feed. Store the (ideally [signed](#signed-transport)) ruleset in your store, and on each request load it and build the `Config`. Changing the stored rules then takes effect on the next request, with no deploy: + +```php +use Flowd\Phirewall\Http\Firewall; +use Flowd\Phirewall\Portable\PortableConfig; + +// $store->load() returns the signed blob from your DB / cache / config service. +$portable = PortableConfig::loadSigned($store->load(), $secret); +$firewall = new Firewall((new Config($cache))->combine($portable)); +``` + +Under classic PHP-FPM each request is a fresh process, so this runs once per request and always reflects the current rules. To avoid querying the database on every request, put a shared cache (APCu, for example) in front of the store. + +### Long-running workers + +Under a long-running worker runtime (Swoole, RoadRunner, FrankenPHP worker mode, Octane) the process handles many requests, so keep the built `Firewall` in memory and rebuild it only when the stored ruleset version changes: + +```php +// $store->load() returns ['version' => int, 'blob' => string]. +$row = $store->load(); +if ($loadedVersion !== $row['version']) { + $portable = PortableConfig::loadSigned($row['blob'], $secret); + $firewall = new Firewall((new Config($cache))->combine($portable)); + $loadedVersion = $row['version']; +} +``` + +See [`examples/29-portable-config.php`](https://github.com/flowd/phirewall/blob/main/examples/29-portable-config.php) for a runnable version with the database simulated in memory. + +## Signed transport + +When the serialized config is read back from storage you do **not** fully control (a shared filesystem, an S3 bucket, etcd, a config service, a git repo that accepts external contributions), an attacker who can write the blob could inject an allow-all safelist and disable the firewall. `fromArray()` validates shape only, not authenticity. + +`toSignedJson()` / `loadSigned()` close that gap with an HMAC-SHA256 envelope: + +```php +$signed = $portable->toSignedJson($secretKey); //
.. +$restored = PortableConfig::loadSigned($signed, $secretKey); // verifies before returning +``` + +- The envelope is JWS-compact-style: `
..`, where the signature is HMAC-SHA256 over `
.`. +- Verification uses a constant-time `hash_equals()` compare. Any tampering (payload edit, key substitution, or an `alg=none` downgrade attempt) is rejected with a `RuntimeException` *before* the rules are applied. +- Signing keys must be at least 16 bytes; **32 random bytes is recommended** (`random_bytes(32)`), stored in your secrets manager. + +::: warning Threat model +Signing protects **integrity and authenticity**, not confidentiality: the payload is base64url-encoded, not encrypted, so anyone who can read the envelope can read the ruleset. Distribute the secret only to the producer and the consumers, rotate it like any other credential, and keep it out of the serialized blob. Signing also does not make a ruleset *safe to run* if you do not trust its author; it only proves the bytes were not altered after signing. +::: + +See [`examples/28-portable-config-signing.php`](https://github.com/flowd/phirewall/blob/main/examples/28-portable-config-signing.php) for a signing + tamper-rejection walkthrough. + +## Not portable by design + +A few capabilities cannot be represented as pure data and are intentionally **excluded** from the schema. Configure these directly on the `Config` you build before (or after) combining the portable rules in: + +| Excluded | Why | +|----------|-----| +| Trusted-bot reverse-DNS safelisting (`TrustedBotMatcher`) | needs live DNS resolution and an optional cache at request time | +| OWASP Core Rule Set (`blocklists->owasp()`) | a ruleset is parsed `SecRule` objects / rule files, not a small data blob | +| File-backed lists (`fileIp`, `filePatternBackend`) | filesystem paths are environment-specific; the in-memory pattern backend is the portable equivalent | +| Closure-driven dynamic throttle limits/periods, `$config->throttles->multi()` | limits/periods can be arbitrary PHP closures and cannot be serialized (express the multi-window case as several `throttles->add()` entries; `sliding` is supported) | +| Response factories, `ipResolver`, `discriminatorNormalizer` | these are closures / objects, not declarative data | + +## Examples + +- [`examples/28-portable-config-signing.php`](https://github.com/flowd/phirewall/blob/main/examples/28-portable-config-signing.php) - signed transport and tamper rejection. +- [`examples/29-portable-config.php`](https://github.com/flowd/phirewall/blob/main/examples/29-portable-config.php) - round-trip, signing, and a database-backed, per-request loading scenario. + +## Related pages + +- [Config Composition](/advanced/config-composition) - layer a portable ruleset under environment and tenant overlays. +- [Presets](/advanced/presets) - ready-made rule bundles, each defined as a `PortableConfig`. +- [Storage Backends](/features/storage) - the PSR-16 cache a `Config` needs. diff --git a/docs/advanced/presets.md b/docs/advanced/presets.md new file mode 100644 index 0000000..9a590e5 --- /dev/null +++ b/docs/advanced/presets.md @@ -0,0 +1,74 @@ +--- +outline: deep +--- + +# Presets + +Presets are ready-to-use rule bundles for recurring scenarios, so you don't have to hand-write the same rules each time. Each preset is a [`PortableConfig`](/advanced/portable-config) returned by an accessor (e.g. `Presets::scannerBlocking()`): plain, inspectable, serializable data you can diff, sign, or layer. + +Materialize one or several onto your own cache with [`Config::combine()`](/advanced/config-composition); presets are pure data and never receive a cache. Every rule is namespaced `preset..*`, so a later layer that redefines it by name overrides predictably. + +## Usage + +```php +use Flowd\Phirewall\Config; +use Flowd\Phirewall\Preset\Presets; + +// A preset on its own; combine it onto a Config you build with your cache: +$config = (new Config($cache))->combine(Presets::scannerBlocking()); + +// Inspect / serialize the underlying portable schema: +$schema = Presets::scannerBlocking()->toArray(); + +// Stack several presets, then your own rules last (later layers win by name): +$config = (new Config($cache))->combine( + Presets::scannerBlocking(), + Presets::sensitivePathBlocking(), + $myPortable, +); +``` + +Preset rules emit the same [observability events](/advanced/observability) as hand-written ones; wire your PSR-14 dispatcher into the `Config` you combine onto (`new Config($cache, $dispatcher)`). + +## Shipped presets + +| Preset | Rules (namespaced `preset..*`) | +|--------|--------------------------------------| +| `scannerBlocking()` | `preset.scanner.known-tools` (known scanner/exploit User-Agents) and `preset.scanner.suspicious-headers` (requests missing the standard browser `Accept` / `Accept-Language` / `Accept-Encoding` headers). | +| `sensitivePathBlocking()` | `preset.sensitive-path.probes`: pattern blocklist for `/.git`, `/.svn`, `/.hg`, `/.env*`, `/.aws/credentials`, `/.htpasswd`, `/.htaccess`, `/.DS_Store`. | + +Resolve any preset by name with `Presets::get($name)` (a `PortableConfig`), passing one of the `Presets::names()` constants. + +## Conventions and overrides + +- The shipped presets target signals that are universal across applications (scanner User-Agents, missing browser headers, well-known sensitive paths), so they assume nothing about your routing. A preset you build yourself is just a `PortableConfig`, so it can key on whatever fits your environment, including routes your own apps standardize. +- Override any rule by combining the preset with your own portable rules that redefine the rule by the same name (later layer wins), or by rebuilding the preset's schema. + +> **Note:** `scannerBlocking()`'s `suspicious-headers` rule is the more aggressive of the two: some legitimate API clients, privacy tools, and embedded browsers also omit `Accept-*` headers. Drop or override it by name if your traffic includes non-browser clients. + +## Versioning and update checks + +`Presets::VERSION` identifies the bundled rule catalogue and is bumped whenever a preset's rule set changes in a way integrators should review. `Presets::version()` is a convenience accessor for the same value. + +Phirewall ships **no** update-check mechanism and performs **no** network I/O. To surface "a newer ruleset is available", compare `Presets::VERSION` against a release feed you trust (Packagist, an internal config service, a versioned JSON document behind HTTPS, and so on) with `version_compare()`: + +```php +use Flowd\Phirewall\Preset\Presets; + +// $latestFromYourFeed comes from a source YOU control and trust. +if (version_compare(Presets::VERSION, $latestFromYourFeed, '<')) { + // A newer preset catalogue is available; review and upgrade phirewall. +} +``` + +Fetching `$latestFromYourFeed` is the integrator's job; phirewall hardcodes no remote endpoint. + +## Example + +See [`examples/31-presets.php`](https://github.com/flowd/phirewall/blob/main/examples/31-presets.php) for standalone use, inspecting a preset as portable data, composing a preset with a user `Config` (overriding a rule by name), and comparing `Presets::VERSION` against your own release feed with `version_compare()`. + +## Related pages + +- [Config Composition](/advanced/config-composition) - how presets layer with your own rules. +- [Portable Config](/advanced/portable-config) - the data format every preset is built on. +- [Fail2Ban & Allow2Ban](/features/fail2ban) - the brute-force ban mechanism. diff --git a/docs/advanced/psr17.md b/docs/advanced/psr17.md index 0b7a91c..0d877b1 100644 --- a/docs/advanced/psr17.md +++ b/docs/advanced/psr17.md @@ -8,8 +8,8 @@ Phirewall generates HTTP responses (`403 Forbidden`, `429 Too Many Requests`) wh There are two layers of response customization: -1. **Base response factory** -- the `ResponseFactoryInterface` used by the `Middleware` to create bare responses (status code + headers). Auto-detected or injected explicitly. -2. **Custom response factories** -- optional `BlocklistedResponseFactoryInterface` and `ThrottledResponseFactoryInterface` on `Config` that produce complete responses with body text, content negotiation, etc. +1. **Base response factory** - the `ResponseFactoryInterface` used by the `Middleware` to create bare responses (status code + headers). Auto-detected or injected explicitly. +2. **Custom response factories** - optional `BlocklistedResponseFactoryInterface` and `ThrottledResponseFactoryInterface` on `Config` that produce complete responses with body text, content negotiation, etc. ## Auto-Detection @@ -328,7 +328,7 @@ $config->usePsr17Responses($psr17, $psr17); $config->blocklistedResponseFactory = new Psr17BlocklistedResponseFactory( $psr17, $psr17, - 'Access Denied -- your request has been blocked.', + 'Access Denied - your request has been blocked.', ); $config->throttledResponseFactory = new Psr17ThrottledResponseFactory( $psr17, @@ -340,7 +340,7 @@ $config->throttledResponseFactory = new Psr17ThrottledResponseFactory( $config->blocklists->add('admin', fn($req) => str_starts_with($req->getUri()->getPath(), '/admin') ); -$config->throttles->add('ip', 100, 60, KeyExtractors::ip()); +$config->throttles->add('ip', 100, 60); // Create middleware (also pass PSR-17 factory for base responses) $middleware = new Middleware($config, $psr17); @@ -438,7 +438,6 @@ $this->app->singleton(Middleware::class, function ($app) { ); $config->throttles->add('global', limit: 100, period: 60, - key: KeyExtractors::ip() ); return new Middleware($config, $psr17); @@ -475,7 +474,7 @@ $psr17 = new Psr17Factory(); $config->usePsr17Responses($psr17, $psr17); // ... configure rules ... -// Mezzio uses PSR-15 natively -- pipe first +// Mezzio uses PSR-15 natively - pipe first $app->pipe(new Middleware($config, $psr17)); ``` diff --git a/docs/advanced/request-context.md b/docs/advanced/request-context.md index 5d0f1f0..5926bee 100644 --- a/docs/advanced/request-context.md +++ b/docs/advanced/request-context.md @@ -4,7 +4,7 @@ outline: deep # Request Context -The `RequestContext` API lets your application signal **fail2ban failures** and **allow2ban hits** from inside the request handler -- after the firewall has already passed the request through. This solves a fundamental limitation: standard pre-handler filters cannot see whether credentials were valid, whether a payment failed, or whether an API key was revoked. +The `RequestContext` API lets your application signal post-handler events (fail2ban **failures** via `recordFailure()` and allow2ban **hits** via `recordHit()`) **from inside the request handler**, after the firewall has already passed the request through. This solves a fundamental limitation: standard fail2ban and allow2ban filters run _before_ your handler, so they cannot see whether credentials were valid, whether a payment failed, or whether an API key was revoked. ## The Problem @@ -16,7 +16,6 @@ $config->fail2ban->add('login', threshold: 5, period: 300, ban: 3600, filter: fn($request) => $request->getMethod() === 'POST' && $request->getUri()->getPath() === '/login', - key: KeyExtractors::ip(), ); ``` @@ -42,13 +41,13 @@ Here is what happens step by step: 1. The middleware calls the firewall's `decide()` method on the incoming request 2. If the request passes (is not blocked), the middleware creates a `RequestContext` and attaches it to the request as a PSR-7 attribute named `phirewall.context` 3. Your handler receives the request with the attached context -4. If your handler determines that the request represents a failure, it calls `$context->recordFailure('rule-name')`. For an allow2ban hit, it calls `$context->recordHit('rule-name')` instead. The key is derived from the matching rule's `keyExtractor`; pass an explicit second argument only when the handler knows a value the firewall cannot derive (e.g. a user id from a session). -5. After your handler returns a response, the middleware processes each recorded signal through the matching counter engine +4. If your handler determines that the request represents a failure (wrong password, invalid API key, etc.), it calls `$context->recordFailure('rule-name')`. For an allow2ban hit, it calls `$context->recordHit('rule-name')` instead. The key is derived from the matching rule's `keyExtractor`; pass an explicit second argument (`$key`) only when the handler knows a value the firewall cannot derive (e.g. a user id from a session). +5. After your handler returns a response, the middleware processes each recorded signal through the matching counter engine (fail2ban or allow2ban) 6. If the count crosses the threshold, the key is banned for future requests ## Setup -Configure a fail2ban rule with a filter that **always returns `false`**. This means the firewall never counts failures automatically -- your handler does it instead: +Configure a fail2ban rule with a filter that **always returns `false`**. This means the firewall never counts failures automatically; your handler does it instead: ```php use Flowd\Phirewall\Config; @@ -59,26 +58,25 @@ use Psr\Http\Message\ServerRequestInterface; $config = new Config(new InMemoryCache()); -// The filter returns false -- no request is counted automatically. +// The filter returns false; no request is counted automatically. // Failures are recorded programmatically via RequestContext in your handler. $config->fail2ban->add('login-failures', threshold: 3, period: 300, ban: 3600, filter: fn(ServerRequestInterface $request): bool => false, - key: KeyExtractors::ip(), ); $middleware = new Middleware($config); ``` ::: tip Why `filter: fn() => false`? -The filter still exists because the fail2ban rule requires one. Setting it to always return `false` means the pre-handler phase never counts any request as a failure -- all failure counting is deferred to your handler via `RequestContext`. +The filter still exists because the fail2ban rule requires one. Setting it to always return `false` means the pre-handler phase never counts any request as a failure; all failure counting is deferred to your handler via `RequestContext`. ::: ## Recording Failures in Your Handler -Retrieve the `RequestContext` from the request attribute and call `recordFailure()`. The second argument is optional -- when omitted, the firewall reuses the rule's own `keyExtractor` against this request, so the handler doesn't need to know whether the rule keys on IP, header, or anything else: +Retrieve the `RequestContext` from the request attribute and call `recordFailure()`. The second argument is optional: when omitted, the firewall reuses the rule's own `keyExtractor` against this request, so the handler doesn't need to know whether the rule keys on IP, header, or anything else: ```php use Flowd\Phirewall\Context\RequestContext; @@ -98,7 +96,7 @@ class LoginHandler implements RequestHandlerInterface /** @var RequestContext|null $context */ $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); - // Signal the failure -- the firewall derives the key from the + // Signal the failure; the firewall derives the key from the // rule's own keyExtractor. Use the null-safe operator for safety. $context?->recordFailure('login-failures'); @@ -117,20 +115,18 @@ $context?->recordFailure('login-failures', $userIdFromSession); ``` ::: warning Rule name must match -The first parameter to `recordFailure()` must **exactly** match the `name` you used in `$config->fail2ban->add()`. If no matching rule is found, the signal is silently ignored. +The first parameter to `recordFailure()` must **exactly** match the `name` you used in `$config->fail2ban->add()` (and likewise `recordHit()` must match a `$config->allow2ban->add()` rule). If no matching rule is found, the signal is silently ignored. ::: -## Recording Hits for Allow2Ban +## Recording allow2ban Hits -`recordHit()` is the allow2ban counterpart of `recordFailure()`. It signals that something countable happened during the handler (e.g. an expensive operation completed, a webhook delivered a duplicate payload, a third-party API quota was charged) so the count can drive an allow2ban threshold ban: +`recordHit()` is the allow2ban counterpart of `recordFailure()`. The same context records **allow2ban** hits: use it to count handler-observable events the pre-handler path cannot see (an expensive operation completed, a webhook delivered a duplicate payload, a third-party API quota was charged) so the count can drive an allow2ban threshold ban. It mirrors `recordFailure()`, and `$key` is likewise optional: omit it to reuse the matching rule's key extractor on the current request. + +First, configure an allow2ban rule. To make the rule count *only* the events recorded by the handler (not every request), have the rule's `keyExtractor` return `null` pre-handler; the firewall then skips counting until the handler signals an explicit key via `recordHit()`: ```php use Flowd\Phirewall\KeyExtractors; -// Configure an allow2ban rule. To make the rule count *only* the events -// recorded by the handler (not every request), have the rule's keyExtractor -// return null pre-handler -- the firewall then skips counting until the -// handler signals an explicit key via recordHit(). $config->allow2ban->add( 'expensive-endpoint', threshold: 5, @@ -151,7 +147,16 @@ if ($context !== null && $this->operationWasExpensive($request)) { } ``` -If the rule's `keyExtractor` returns a value pre-handler (the common case), the second argument to `recordHit()` can be omitted -- the firewall derives the key the same way it does for `recordFailure()`. Note that in that case **both** the pre-handler counter and the handler's `recordHit()` increment the counter, so the threshold should account for the doubled count. +If the rule's `keyExtractor` returns a value pre-handler (the common case), the second argument to `recordHit()` can be omitted; the firewall derives the key the same way it does for `recordFailure()`: + +```php +// Omitting $key reuses the rule's own key extractor on this request. +$context?->recordHit('expensive-endpoint'); +``` + +Note that when the rule's `keyExtractor` returns a value pre-handler, **both** the pre-handler counter and the handler's `recordHit()` increment the counter, so the threshold should account for the doubled count. + +Recorded failures and hits are processed together after your handler returns; retrieve them all with `getRecordedSignals()`. ## API Reference @@ -161,10 +166,10 @@ The `RequestContext` class is a mutable recorder that the middleware attaches to | Method | Signature | Description | |--------|-----------|-------------| -| `recordFailure()` | `(string $ruleName, ?string $key = null): void` | Record a fail2ban failure signal | -| `recordHit()` | `(string $ruleName, ?string $key = null): void` | Record an allow2ban hit signal | +| `recordFailure()` | `(string $ruleName, ?string $key = null): void` | Record a fail2ban **failure** signal | +| `recordHit()` | `(string $ruleName, ?string $key = null): void` | Record an allow2ban **hit** signal | | `getResult()` | `(): FirewallResult` | Access the pre-handler firewall decision | -| `getRecordedSignals()` | `(): list` | Get all recorded signals (fail2ban + allow2ban) | +| `getRecordedSignals()` | `(): list` | Get all recorded signals (failures and hits) | | `hasRecordedSignals()` | `(): bool` | Whether any signals have been recorded | **Constants:** @@ -175,20 +180,22 @@ The `RequestContext` class is a mutable recorder that the middleware attaches to ### recordFailure() / recordHit() Parameters +Both methods take the same parameters: + | Parameter | Type | Description | |-----------|------|-------------| -| `$ruleName` | `string` | Must match the `name` of a configured `fail2ban->add()` (for `recordFailure`) or `allow2ban->add()` (for `recordHit`) rule | -| `$key` | `?string` | Optional discriminator override. When `null` (the default), the firewall extracts the key from the rule's own `keyExtractor` against the current request. | +| `$ruleName` | `string` | Must match the `name` of a configured `fail2ban->add()` rule (for `recordFailure()`) or `allow2ban->add()` rule (for `recordHit()`) | +| `$key` | `?string` | The discriminator key to count against (e.g., IP address, username). **Optional**: when omitted (`null`), the firewall applies the matching rule's own key extractor to the current request, so your handler does not need to repeat the rule's keying logic. | ### RecordedSignal -An immutable value object representing a single recorded signal. +An immutable value object representing a single recorded signal (the elements returned by `getRecordedSignals()`). | Property | Type | Description | |----------|------|-------------| | `$ruleName` | `string` | The fail2ban or allow2ban rule this signal is recorded against | | `$banType` | `BanType` | `BanType::Fail2Ban` (from `recordFailure()`) or `BanType::Allow2Ban` (from `recordHit()`) | -| `$key` | `?string` | The discriminator override, or `null` to defer to the rule's `keyExtractor` | +| `$key` | `?string` | The discriminator key override, or `null` to defer to the matching rule's key extractor | ## Accessing the Firewall Decision @@ -203,7 +210,7 @@ $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); if ($context !== null) { $result = $context->getResult(); - $result->outcome->value; // 'passed', 'safelisted', etc. + $result->outcome->value; // 'pass', 'safelisted', etc. $result->isPass(); // true if the request was allowed through $result->rule; // Name of the matching rule (null if simply passed) } @@ -224,7 +231,7 @@ $context?->recordFailure('login-failures'); $context?->recordHit('expensive-endpoint'); ``` -If the middleware is not present, `$context` is `null` and the calls are silently skipped -- no errors, no side effects. This makes your handler safe to use with or without Phirewall. +If the middleware is not present, `$context` is `null` and the calls are silently skipped: no errors, no side effects. This makes your handler safe to use with or without Phirewall. ## Complete Example @@ -256,7 +263,6 @@ $config->fail2ban->add('login-failures', period: 300, ban: 3600, filter: fn(ServerRequestInterface $request): bool => false, - key: KeyExtractors::ip(), ); $middleware = new Middleware($config, new Psr17Factory()); @@ -349,6 +355,7 @@ Verify that failures recorded via `RequestContext` trigger bans: ```php use PHPUnit\Framework\TestCase; +use Flowd\Phirewall\BanType; use Flowd\Phirewall\Config; use Flowd\Phirewall\Context\RequestContext; use Flowd\Phirewall\Http\Firewall; @@ -369,7 +376,6 @@ class RequestContextTest extends TestCase $config->fail2ban->add('test-rule', threshold: 2, period: 300, ban: 3600, filter: fn($request): bool => false, - key: KeyExtractors::ip(), ); $middleware = new Middleware($config, new Psr17Factory()); @@ -394,14 +400,14 @@ class RequestContextTest extends TestCase } // Verify the IP is now banned - $this->assertTrue($firewall->isBanned('test-rule', $ip)); + $this->assertTrue($firewall->isBanned('test-rule', $ip, BanType::Fail2Ban)); } } ``` ## Related Pages -- [Fail2Ban & Allow2Ban](/features/fail2ban) -- fail2ban rule configuration and filter predicates -- [Track & Notifications](/advanced/track-notifications) -- passive counting without blocking -- [Observability](/advanced/observability) -- events and diagnostics -- [Getting Started](/getting-started) -- full setup walkthrough +- [Fail2Ban & Allow2Ban](/features/fail2ban) - fail2ban rule configuration and filter predicates +- [Track & Notifications](/advanced/track-notifications) - passive counting without blocking +- [Observability](/advanced/observability) - events and diagnostics +- [Getting Started](/getting-started) - full setup walkthrough diff --git a/docs/advanced/track-notifications.md b/docs/advanced/track-notifications.md index d2e80b8..223e2dc 100644 --- a/docs/advanced/track-notifications.md +++ b/docs/advanced/track-notifications.md @@ -4,11 +4,11 @@ outline: deep # Track & Notifications -Track rules provide **passive counting without blocking**. They are ideal for observability, alerting thresholds, and feeding data into dashboards -- all without affecting request processing. +Track rules provide **passive counting without blocking**. They are ideal for observability, alerting thresholds, and feeding data into dashboards, all without affecting request processing. ## How Tracking Works -Track rules are evaluated **first** in the pipeline, before safelists and blocklists. They always run -- even for requests that will be safelisted. This makes them reliable for comprehensive monitoring. +Track rules are evaluated **first** in the pipeline, before safelists and blocklists. They always run, even for requests that will be safelisted. This makes them reliable for comprehensive monitoring. ```text Request --> Track (passive) --> Safelist --> Blocklist --> Fail2Ban --> Throttle --> Allow2Ban --> Pass @@ -24,7 +24,7 @@ Here is what happens step by step: 3. If the filter returns `true`, the **key** closure extracts a grouping key (for example, the client IP) 4. The counter for that key is incremented in the cache, scoped to the rule's **period** (time window) 5. A `TrackHit` event is dispatched via the PSR-14 (PHP Standard Recommendation for Event Dispatching) event dispatcher -6. The request continues to the remaining pipeline stages -- track rules **never** block +6. The request continues to the remaining pipeline stages; track rules **never** block ## API Reference @@ -33,7 +33,7 @@ $config->tracks->add( string $name, int $period, Closure $filter, - Closure $key, + ?Closure $key = null, ?int $limit = null // optional threshold ): TrackSection ``` @@ -42,8 +42,8 @@ $config->tracks->add( |-----------|------|-------------| | `$name` | `string` | Unique rule identifier (must not be empty) | | `$period` | `int` | Time window for counting in seconds (must be >= 1) | -| `$filter` | `Closure` | `fn(ServerRequestInterface): bool` -- return `true` to count this request | -| `$key` | `Closure` | `fn(ServerRequestInterface): ?string` -- return the grouping key, or `null` to skip counting | +| `$filter` | `Closure` | `fn(ServerRequestInterface): bool` - return `true` to count this request | +| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string` - return the grouping key, or `null` to skip counting. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | | `$limit` | `?int` | Optional threshold. When set, the `TrackHit` event includes a `thresholdReached` flag that becomes `true` once the counter reaches this value | ::: tip Return type @@ -68,21 +68,17 @@ $config->tracks->add('login-attempts', period: 3600, filter: fn($request) => $request->getMethod() === 'POST' && $request->getUri()->getPath() === '/login', - key: KeyExtractors::ip(), ); ``` ### Track API Usage by User -Monitor per-user API consumption using a custom header: +Monitor API consumption per client (keyed on the client IP by default): ```php -use Flowd\Phirewall\KeyExtractors; - $config->tracks->add('api-usage', period: 3600, filter: fn($request) => str_starts_with($request->getUri()->getPath(), '/api/'), - key: KeyExtractors::header('X-User-Id'), ); ``` @@ -96,7 +92,6 @@ use Flowd\Phirewall\KeyExtractors; $config->tracks->add('admin-access', period: 600, filter: fn($request) => str_starts_with($request->getUri()->getPath(), '/admin/'), - key: KeyExtractors::ip(), ); ``` @@ -355,7 +350,7 @@ The returned array is organized by category, each with a total and a breakdown b ] ``` -Categories tracked: `safelisted`, `blocklisted`, `throttle_exceeded`, `fail2ban_banned`, `track_hit`, `passed`, `fail2ban_blocked`. +Categories tracked: `safelisted`, `blocklisted`, `throttle_exceeded`, `fail2ban_banned`, `allow2ban_banned`, `track_hit`, `passed`, `fail2ban_blocked`. ### Exposing as a Prometheus-Style Metrics Endpoint @@ -411,7 +406,7 @@ Use track rules during an initial monitoring phase. Once you are confident in yo 3. **Set appropriate periods.** The period determines how long counters accumulate. Use shorter periods (60-300s) for real-time alerting and longer periods (3600s) for trend analysis. -4. **Use the `$limit` parameter for alert thresholds.** Instead of checking `$event->count >= N` in your event handler, configure `limit: N` on the track rule and check `$event->thresholdReached` -- it is more readable and keeps the threshold visible in your configuration. +4. **Use the `$limit` parameter for alert thresholds.** Instead of checking `$event->count >= N` in your event handler, configure `limit: N` on the track rule and check `$event->thresholdReached`, which is more readable and keeps the threshold visible in your configuration. 5. **Alert on specific counts, not ranges.** When sending notifications, compare `$event->count === $event->limit` (exact match) rather than `$event->count >= $event->limit` to avoid flooding your notification channel with duplicate alerts. @@ -421,7 +416,7 @@ Use track rules during an initial monitoring phase. Once you are confident in yo ## Related Pages -- [Observability](/advanced/observability) -- logging, OpenTelemetry, and monitoring integration -- [Fail2Ban & Allow2Ban](/features/fail2ban) -- automatic banning based on thresholds -- [Request Context](/advanced/request-context) -- post-handler failure signaling for Fail2Ban -- [Rate Limiting](/features/rate-limiting) -- throttling and rate limit headers +- [Observability](/advanced/observability) - logging, OpenTelemetry, and monitoring integration +- [Fail2Ban & Allow2Ban](/features/fail2ban) - automatic banning based on thresholds +- [Request Context](/advanced/request-context) - post-handler failure signaling for Fail2Ban +- [Rate Limiting](/features/rate-limiting) - throttling and rate limit headers diff --git a/docs/common-attacks.md b/docs/common-attacks.md index caf305f..4f1b775 100644 --- a/docs/common-attacks.md +++ b/docs/common-attacks.md @@ -4,61 +4,42 @@ outline: deep # Common Attacks -Ready-to-use Phirewall configurations for defending against common web application attacks. Each recipe is self-contained -- copy what you need and adapt it to your application. +Ready-to-use Phirewall configurations for defending against common web application attacks. Each recipe is self-contained: copy what you need and adapt it to your application. ## Brute Force Login Protect login endpoints with layered rate limiting and fail2ban. -### Fail2Ban on Login Failures +### Post-Handler Failure Signaling (recommended) -Ban IPs after repeated failed login attempts. The `filter` predicate determines what counts as a failure: +The accurate way to ban on *real* failed logins is to record the failure **after** your handler has verified the credentials, using [RequestContext](/features/fail2ban#post-handler-signaling-with-requestcontext). The fail2ban rule's filter never matches on its own (`fn() => false`); your handler decides what counts as a failure and records it, and the middleware processes the recorded signal once the handler returns. This pattern is demonstrated in [`examples/02-brute-force-protection.php`](https://github.com/flowd/phirewall/blob/main/examples/02-brute-force-protection.php). ```php use Flowd\Phirewall\Config; -use Flowd\Phirewall\KeyExtractors; +use Flowd\Phirewall\Context\RequestContext; use Flowd\Phirewall\Store\RedisCache; use Psr\Http\Message\ServerRequestInterface; $config = new Config(new RedisCache($redis)); -// Ban after 5 failed logins in 5 minutes for 1 hour -$config->fail2ban->add('login-brute-force', - threshold: 5, - period: 300, - ban: 3600, - filter: fn(ServerRequestInterface $req): bool => - $req->getMethod() === 'POST' - && $req->getUri()->getPath() === '/login' - && $req->getHeaderLine('X-Login-Failed') === '1', - key: KeyExtractors::ip(), -); -``` - -Your login handler sets the `X-Login-Failed` header on failed attempts before the response is returned. - -### Post-Handler Failure Signaling - -For more precise control, use [RequestContext](/features/fail2ban#post-handler-signaling-with-requestcontext) to signal failures only after verifying credentials: - -```php -use Flowd\Phirewall\Context\RequestContext; - +// Ban after 3 verified failures in 5 minutes for 1 hour. +// The filter never matches; failures are signaled by the handler. $config->fail2ban->add('login-failures', threshold: 3, period: 300, ban: 3600, - filter: fn($req): bool => false, // Never counts automatically - key: KeyExtractors::ip(), + filter: fn(ServerRequestInterface $req): bool => false, ); -// In your login handler: +// In your login handler, AFTER checking credentials: if (!$this->authenticate($username, $password)) { $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); - // No second argument needed -- the firewall extracts the key from the - // rule's own keyExtractor against this request. + // recordFailure's key is optional; with none, the rule resolves the + // discriminator itself (here the client IP). $context?->recordFailure('login-failures'); } ``` +Only genuine failures are counted, so a user who logs in correctly on the first try is never one attempt closer to a ban. + ### Login Endpoint Throttle Add a rate limit specifically on the login path to slow down attackers: @@ -85,11 +66,15 @@ $config->throttles->add('account-throttle', limit: 5, period: 60, key: function (ServerRequestInterface $req): ?string { - if ($req->getUri()->getPath() === '/login' && $req->getMethod() === 'POST') { - $username = $req->getHeaderLine('X-Username'); - return $username !== '' ? $username : null; + if ($req->getMethod() !== 'POST' || $req->getUri()->getPath() !== '/login') { + return null; } - return null; + // Key on the submitted credential read from the request body, not a + // client-settable header: an attacker could rotate or omit X-Username + // to dodge the per-account limit entirely. + $body = (array) $req->getParsedBody(); + $username = $body['username'] ?? $body['email'] ?? null; + return $username !== null ? 'user:' . strtolower(trim((string) $username)) : null; }, ); ``` @@ -239,7 +224,7 @@ Block known attack tools (sqlmap, nikto, nuclei, etc.) with a single call: $config->blocklists->knownScanners(); ``` -The default list covers ~25 tools. Extend or replace it: +The default list covers 24 tools (26 substring patterns). Extend or replace it: ```php use Flowd\Phirewall\Matchers\KnownScannerMatcher; @@ -303,7 +288,7 @@ Catch both bursts and sustained abuse with multiple time windows: $config->throttles->multi('api', [ 1 => 3, // Burst protection 60 => 100, // Sustained limit -], KeyExtractors::ip()); +]); ``` ### Sliding Window @@ -314,7 +299,6 @@ Prevent the "double burst" problem at fixed-window boundaries: $config->throttles->sliding('api', limit: 100, period: 60, - key: KeyExtractors::ip(), ); ``` @@ -324,7 +308,7 @@ Apply different limits based on subscription tier: ```php $config->throttles->add('api', - limit: fn(ServerRequestInterface $req): int => match ($req->getHeaderLine('X-Plan')) { + limit: fn(ServerRequestInterface $req): int => match ($req->getAttribute('plan')) { 'enterprise' => 10000, 'pro' => 1000, 'free' => 100, @@ -332,10 +316,14 @@ $config->throttles->add('api', }, period: 60, key: fn($req): ?string => - $req->getHeaderLine('X-User-Id') ?: $req->getServerParams()['REMOTE_ADDR'] ?? null, + $req->getAttribute('userId') ?? ($req->getServerParams()['REMOTE_ADDR'] ?? null), ); ``` +::: tip Read tier and identity from request attributes, not headers +`plan` and `userId` are PSR-7 request **attributes** that your authentication layer sets after verifying the principal: `$request = $request->withAttribute('plan', $user->plan)`. Attributes are server-side only and never part of the incoming request, so a client cannot forge them the way it could an `X-Plan` header. Place the middleware that sets them before Phirewall in the pipeline. +::: + ### Write Operation Limits Apply stricter limits to mutating operations: @@ -361,24 +349,26 @@ $config->allow2ban->add('volume-ban', threshold: 500, period: 60, banSeconds: 3600, - key: KeyExtractors::ip(), ); ``` ## API Abuse -### API Key Throttling +### API Endpoint Throttling -Rate-limit by API key for authenticated endpoints. `hashedHeader()` stores a sha256 fingerprint of the key in the cache backend rather than the raw value: +Rate-limit API traffic per client IP, the value a caller cannot forge (behind a proxy, resolve it with `KeyExtractors::clientIp()` and a `TrustedProxyResolver`): ```php -$config->throttles->add('api-key', +$config->throttles->add('api', limit: 1000, period: 60, - key: KeyExtractors::hashedHeader('X-Api-Key'), ); ``` +::: warning Header keys are client-controlled +A throttle, fail2ban, or allow2ban rule keyed on a request header (`X-Api-Key`, `X-User-Id`, …) is only as trustworthy as that header. A client can rotate or drop the header to land in a fresh counter on every request and never reach the threshold (a trivial bypass). Key such rules on a value the client cannot freely change: the client IP (via `KeyExtractors::clientIp()` with a `TrustedProxyResolver`), the authenticated principal your auth layer sets *after* verifying it, or a composite of both. When you must key on a credential-bearing header, use `KeyExtractors::hashedHeader('X-Api-Key')`: the raw value otherwise reaches the ban registry and event payloads (and your logs) in cleartext. +::: + ### Expensive Endpoint Protection Apply stricter limits to resource-intensive endpoints: @@ -389,8 +379,8 @@ $config->throttles->add('export', period: 3600, key: function (ServerRequestInterface $req): ?string { if (str_starts_with($req->getUri()->getPath(), '/api/export')) { - return $req->getHeaderLine('X-User-Id') - ?: $req->getServerParams()['REMOTE_ADDR'] ?? null; + return $req->getAttribute('userId') + ?? ($req->getServerParams()['REMOTE_ADDR'] ?? null); } return null; }, @@ -405,7 +395,6 @@ Monitor request patterns without blocking, alerting when thresholds are exceeded $config->tracks->add('sensitive-endpoints', period: 300, filter: fn($req): bool => str_starts_with($req->getUri()->getPath(), '/api/admin'), - key: KeyExtractors::ip(), limit: 50, ); ``` @@ -464,14 +453,16 @@ CRS); $config->blocklists->owasp('owasp', $rules); // ── Layer 4: Fail2Ban ───────────────────────────────────────────────── +// The filter never matches pre-handler; your login handler signals each +// verified failure with $context->recordFailure('login-brute-force'). +// See Brute Force Login above. $config->fail2ban->add('login-brute-force', threshold: 5, period: 300, ban: 3600, - filter: fn($req): bool => $req->getHeaderLine('X-Login-Failed') === '1', - key: KeyExtractors::ip(), + filter: fn($req): bool => false, ); // ── Layer 5: Throttling ─────────────────────────────────────────────── -$config->throttles->multi('api', [1 => 5, 60 => 200], KeyExtractors::ip()); +$config->throttles->multi('api', [1 => 5, 60 => 200]); $config->throttles->add('login', limit: 10, period: 60, key: function ($req): ?string { return $req->getUri()->getPath() === '/login' ? ($req->getServerParams()['REMOTE_ADDR'] ?? null) @@ -481,7 +472,6 @@ $config->throttles->add('login', limit: 10, period: 60, key: function ($req): ?s // ── Layer 6: Allow2Ban ──────────────────────────────────────────────── $config->allow2ban->add('volume-ban', threshold: 500, period: 60, banSeconds: 3600, - key: KeyExtractors::ip(), ); // ── PSR-17 Responses ────────────────────────────────────────────────── @@ -501,7 +491,7 @@ Track → Safelist → Blocklist → Fail2Ban → Throttle → Allow2Ban → Pas | Layer | Purpose | Response | |-------|---------|----------| -| Track | Observe and count (never blocks) | -- | +| Track | Observe and count (never blocks) | - | | Safelist | Bypass all remaining checks | 200 (pass-through) | | Blocklist | IP lists, OWASP rules, patterns | 403 | | Fail2Ban | Ban after repeated filtered failures | 403 | @@ -515,7 +505,7 @@ Track → Safelist → Blocklist → Fail2Ban → Throttle → Allow2Ban → Pas 2. **Safelist your health checks.** Internal monitoring endpoints should bypass all firewall rules to avoid false alerts. -3. **Use `clientIp()` behind proxies.** If your application runs behind a load balancer or CDN, configure a `TrustedProxyResolver` so rate limits and bans apply to the real client IP. +3. **Use `clientIp()` behind proxies.** If your application runs behind a load balancer or CDN, configure a `TrustedProxyResolver` so rate limits and bans apply to the real client IP; raw `KeyExtractors::ip()` would collapse every client onto the proxy's address. See [Client IP Behind Proxies](/getting-started#client-ip-behind-proxies). 4. **Start with logging, then enforce.** Use [Track rules](/advanced/track-notifications) to observe traffic patterns before enabling blocking rules. diff --git a/docs/examples.md b/docs/examples.md index 4741c8a..956375c 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -8,7 +8,7 @@ Complete, copy-pasteable configurations for common scenarios. Each example is se ## Running the Built-in Examples -The Phirewall repository includes 27 runnable examples: +The Phirewall repository includes 31 runnable examples: ```bash git clone https://github.com/flowd/phirewall @@ -46,16 +46,20 @@ php examples/01-basic-setup.php | 25 | [track-threshold](https://github.com/flowd/phirewall/blob/main/examples/25-track-threshold.php) | Track with threshold for alerting | | 26 | [psr17-factories](https://github.com/flowd/phirewall/blob/main/examples/26-psr17-factories.php) | PSR-17 response factory integration | | 27 | [request-context](https://github.com/flowd/phirewall/blob/main/examples/27-request-context.php) | Post-handler fail2ban signaling | +| 28 | [portable-config-signing](https://github.com/flowd/phirewall/blob/main/examples/28-portable-config-signing.php) | Signed PortableConfig transport (HMAC-SHA256) | +| 29 | [portable-config](https://github.com/flowd/phirewall/blob/main/examples/29-portable-config.php) | PortableConfig as a first-class transport with database-backed rules | +| 30 | [config-composition](https://github.com/flowd/phirewall/blob/main/examples/30-config-composition.php) | Layering configs (vendor → environment → tenant → deployment) | +| 31 | [presets](https://github.com/flowd/phirewall/blob/main/examples/31-presets.php) | Ready-to-use rule presets and version comparison (compare `Presets::VERSION` against your own release feed) | --- ## Framework Integration -Production-ready integration examples for popular PHP frameworks. Each example includes storage, safelists, blocklists, rate limiting, brute-force protection, OWASP rules, and observability -- copy, paste, adapt. +Production-ready integration examples for popular PHP frameworks. Each example includes storage, safelists, blocklists, rate limiting, brute-force protection, OWASP rules, and observability. Copy, paste, adapt. ### PSR-15 (Generic / Plain PHP) -Works with any PSR-15 compatible stack (Mezzio, custom dispatchers, etc.). Requires `nyholm/psr7`. +Works with any PSR-15 compatible stack (custom dispatchers, runtimes, etc.; Mezzio has its own section below). Requires `nyholm/psr7`. ```php getStatusCode() . "\n"; ### Symfony -Requires `symfony/psr-http-message-bridge` and `nyholm/psr7`. Phirewall runs as a PSR-15 middleware wrapped by Symfony's PSR bridge. +Requires `symfony/psr-http-message-bridge` and `nyholm/psr7`. Phirewall runs as a PSR-15 middleware wrapped by Symfony's PSR bridge; the bridge factories (`HttpMessageFactoryInterface`, `HttpFoundationFactoryInterface`) and the `nyholm/psr7` PSR-17 factory then autowire into the listener below. + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +::: warning +This bridge runs Phirewall with a pass-through handler, so the `RequestContext` attribute it attaches for app-recorded fail2ban/allow2ban signals lives on the throwaway PSR request and is not visible to your Symfony controllers. Use the pre-handler rule filters for blocking; post-handler `recordFailure()`/`recordHit()` from a controller is not propagated by this basic bridge. +::: **`src/Factory/PhirewallFactory.php`** @@ -220,8 +232,11 @@ class PhirewallFactory $config->setFailOpen(true); // ── Trusted Proxies ────────────────────────────────────── - if ($this->trustedProxies !== []) { - $proxyResolver = new TrustedProxyResolver($this->trustedProxies); + // Drop empty entries so an unset/blank env var disables the + // resolver instead of registering an empty proxy list. + $trustedProxies = array_values(array_filter($this->trustedProxies)); + if ($trustedProxies !== []) { + $proxyResolver = new TrustedProxyResolver($trustedProxies); $config->setIpResolver( KeyExtractors::clientIp($proxyResolver) ); @@ -251,8 +266,8 @@ class PhirewallFactory $config->blocklists->owasp('owasp', $owaspRules); // ── Fail2Ban ───────────────────────────────────────────── - // KeyExtractors::ip() uses the global resolver set by - // setIpResolver() above — no need to repeat clientIp(). + // No key: these rules default to the client IP from the + // global resolver set above. $config->fail2ban->add('login-abuse', threshold: 5, period: 300, @@ -260,23 +275,19 @@ class PhirewallFactory filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // ── Rate Limiting ──────────────────────────────────────── $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); // ── Allow2Ban ──────────────────────────────────────────── $config->allow2ban->add('flood-protection', threshold: 500, period: 60, banSeconds: 3600, - key: KeyExtractors::ip() ); // ── PSR-17 Response Bodies ─────────────────────────────── @@ -291,79 +302,105 @@ class PhirewallFactory **`config/services.yaml`** ```yaml +# Add these under the `services:` key in Symfony's default config/services.yaml. +# Keep the stock `_defaults: { autowire: true, autoconfigure: true }` and `App\:` +# resource block: `autoconfigure` is what turns the #[AsEventListener] below into a +# registered listener, and the `App\` loader registers the factory and listener as +# services. Drop them and the listener never runs, so Phirewall silently does nothing. services: App\Factory\PhirewallFactory: arguments: - $trustedProxies: ['10.0.0.0/8', '172.16.0.0/12'] + $trustedProxies: '%env(csv:PHIREWALL_TRUSTED_PROXIES)%' Flowd\Phirewall\Middleware: factory: ['@App\Factory\PhirewallFactory', 'create'] ``` -**`src/EventSubscriber/PhirewallSubscriber.php`** +Define `PHIREWALL_TRUSTED_PROXIES` in your environment even if empty: an undefined `%env()%` reference fails container compilation, whereas an empty value cleanly disables proxy resolution (the factory's `array_filter` drops it). + +```dotenv +# .env (empty disables Phirewall's proxy resolution) +PHIREWALL_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12 +``` + +The listener below auto-registers via `#[AsEventListener]` + autoconfigure; no manual `tags:` entry needed. + +**`src/EventListener/PhirewallListener.php`** + +A two-phase listener: it runs Phirewall on `kernel.request` (blocking early when a rule fires) and re-attaches the `X-RateLimit-*` headers Phirewall adds on the allowed path during `kernel.response`. A single-phase subscriber that only acts when the status is non-200 would silently drop those headers. ```php ['onKernelRequest', 256]]; - } - + #[AsEventListener(event: KernelEvents::REQUEST, priority: 256)] public function onKernelRequest(RequestEvent $event): void { if (!$event->isMainRequest()) { return; } - - $psr17 = new Psr17Factory(); - $psrFactory = new PsrHttpFactory($psr17, $psr17, $psr17, $psr17); - $httpFoundationFactory = new HttpFoundationFactory(); - - // Convert Symfony request to PSR-7 - $psrRequest = $psrFactory->createRequest($event->getRequest()); - - // Run Phirewall as a pass-through handler - $psrResponse = $this->middleware->process( - $psrRequest, - new class ($psr17) implements \Psr\Http\Server\RequestHandlerInterface { - public function __construct(private readonly Psr17Factory $factory) {} - - public function handle( - \Psr\Http\Message\ServerRequestInterface $request, - ): \Psr\Http\Message\ResponseInterface { - // Return 200 -- Symfony continues processing - return $this->factory->createResponse(200); - } + $psrRequest = $this->psrHttpFactory->createRequest($event->getRequest()); + $probe = new class ($this->responseFactory) implements RequestHandlerInterface { + private bool $invoked = false; + public function __construct(private readonly ResponseFactoryInterface $responseFactory) {} + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->invoked = true; + return $this->responseFactory->createResponse(200); } - ); + public function wasInvoked(): bool { return $this->invoked; } + }; + $psrResponse = $this->middleware->process($psrRequest, $probe); + if (!$probe->wasInvoked()) { + // The handler never ran: Phirewall produced a block/throttle response. + $event->setResponse($this->httpFoundationFactory->createResponse($psrResponse)); + return; + } + // Allowed: carry Phirewall's rate-limit headers onto the real response. + if ($psrResponse->getHeaders() !== []) { + $event->getRequest()->attributes->set(self::HEADERS_ATTRIBUTE, $psrResponse->getHeaders()); + } + } - // If Phirewall blocked the request, short-circuit - if ($psrResponse->getStatusCode() !== 200) { - $event->setResponse( - $httpFoundationFactory->createResponse($psrResponse) - ); + #[AsEventListener(event: KernelEvents::RESPONSE)] + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + /** @var array> $headers */ + $headers = $event->getRequest()->attributes->get(self::HEADERS_ATTRIBUTE, []); + foreach ($headers as $name => $values) { + $event->getResponse()->headers->set($name, $values); } } + } ``` @@ -371,7 +408,15 @@ class PhirewallSubscriber implements EventSubscriberInterface ### Laravel -Requires `nyholm/psr7`. Register the service provider and add the middleware to your HTTP kernel. +`Flowd\Phirewall\Middleware` is a PSR-15 middleware (`process(...)`), **not** a Laravel middleware (`handle($request, $next)`); registering the class directly throws. A thin bridge middleware adapts it. Install the bridge: + +```bash +composer require symfony/psr-http-message-bridge nyholm/psr7 +``` + +::: warning +This bridge runs Phirewall with a probe handler, so the `RequestContext` attribute it attaches for app-recorded fail2ban/allow2ban signals lives on the throwaway PSR request and is not visible to your Laravel controllers. Use the pre-handler rule filters for blocking; post-handler `recordFailure()`/`recordHit()` from a controller is not propagated by this basic bridge. +::: **`app/Providers/PhirewallServiceProvider.php`** @@ -391,11 +436,21 @@ use Flowd\Phirewall\Store\ApcuCache; use Illuminate\Support\ServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ServerRequestInterface; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; class PhirewallServiceProvider extends ServiceProvider { public function register(): void { + // PSR-7/PSR-17 bridge factories used by the Phirewall middleware. + // HttpFoundationFactory has a no-arg constructor, so Laravel + // autowires it without an explicit binding. + $this->app->singleton(Psr17Factory::class); + $this->app->singleton(PsrHttpFactory::class, fn ($app) => new PsrHttpFactory( + $app->make(Psr17Factory::class), $app->make(Psr17Factory::class), + $app->make(Psr17Factory::class), $app->make(Psr17Factory::class), + )); + $this->app->singleton(PhirewallMiddleware::class, function () { // ── Storage ────────────────────────────────────────── // ApcuCache requires ext-apcu (zero config, single-server) @@ -409,8 +464,12 @@ class PhirewallServiceProvider extends ServiceProvider $config->setFailOpen(true); // ── Trusted Proxies ────────────────────────────────── - $trustedProxies = config('trustedproxy.proxies', []); - if (is_array($trustedProxies) && $trustedProxies !== []) { + // Phirewall resolves the client IP from its OWN trusted-proxy list, + // independent of Laravel's TrustProxies middleware. List your load + // balancer / CDN ranges here (e.g. via a TRUSTED_PROXIES env var). + // Leave empty only for a direct-to-PHP deployment. + $trustedProxies = array_filter(explode(',', (string) env('TRUSTED_PROXIES', ''))); + if ($trustedProxies !== []) { $proxyResolver = new TrustedProxyResolver($trustedProxies); $config->setIpResolver( KeyExtractors::clientIp($proxyResolver) @@ -445,8 +504,8 @@ class PhirewallServiceProvider extends ServiceProvider $config->blocklists->owasp('owasp', $owaspRules); // ── Fail2Ban ───────────────────────────────────────── - // KeyExtractors::ip() uses the global resolver set by - // setIpResolver() above — no need to repeat clientIp(). + // No key: these rules default to the client IP from the + // global resolver set above. $config->fail2ban->add('login-abuse', threshold: 5, period: 300, @@ -454,32 +513,31 @@ class PhirewallServiceProvider extends ServiceProvider filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // ── Rate Limiting ──────────────────────────────────── $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); + // `role` is a request attribute; set it on the PSR request in the bridge + // from Laravel's authenticated user (e.g. ->withAttribute('role', $request->user()?->role)). $config->throttles->add('api', limit: fn(ServerRequestInterface $req): int => - $req->getHeaderLine('X-Role') === 'admin' ? 5000 : 200, + $req->getAttribute('role') === 'admin' ? 5000 : 200, period: 60, - key: KeyExtractors::ip() ); // ── Allow2Ban ──────────────────────────────────────── $config->allow2ban->add('flood-protection', threshold: 500, period: 60, banSeconds: 3600, - key: KeyExtractors::ip() ); // ── PSR-17 Response Bodies ─────────────────────────── + // usePsr17Responses() sets the block/throttle response bodies; the + // constructor argument is only the fallback ResponseFactory. $psr17 = new Psr17Factory(); $config->usePsr17Responses($psr17, $psr17); @@ -489,24 +547,102 @@ class PhirewallServiceProvider extends ServiceProvider } ``` -**`bootstrap/app.php`** (Laravel 11+) +**`app/Http/Middleware/Phirewall.php`** + +The bridge adapts the PSR-15 engine to Laravel's middleware contract. It uses a probe handler so the real Laravel response is never round-tripped through PSR-7; `StreamedResponse`/`BinaryFileResponse` and other special responses are preserved. On the allowed path it copies Phirewall's `X-RateLimit-*` headers onto the real response. ```php -use Flowd\Phirewall\Middleware as PhirewallMiddleware; +psrHttpFactory->createRequest($request); + $probe = new class($this->psr17) implements RequestHandlerInterface { + private bool $invoked = false; + public function __construct(private readonly Psr17Factory $responseFactory) {} + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->invoked = true; + return $this->responseFactory->createResponse(); + } + public function wasInvoked(): bool { return $this->invoked; } + }; + $psrResponse = $this->firewall->process($psrRequest, $probe); + if (! $probe->wasInvoked()) { + return $this->httpFoundationFactory->createResponse($psrResponse); + } + $response = $next($request); + foreach ($psrResponse->getHeaders() as $name => $values) { + $response->headers->set($name, $values); + } + return $response; + } +} +``` + +Register the service provider in `bootstrap/providers.php` (Laravel 11+) or the `providers` array in `config/app.php` (Laravel 10 and earlier): + +```php +// bootstrap/providers.php (Laravel 11+) +return [ + App\Providers\AppServiceProvider::class, + App\Providers\PhirewallServiceProvider::class, +]; +``` + +**`bootstrap/app.php`** (Laravel 11/12) + +```php +withMiddleware(function (Middleware $middleware) { - // Run Phirewall as the outermost middleware - $middleware->prepend(PhirewallMiddleware::class); + ->withRouting( + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', + health: '/up', + ) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->prepend(Phirewall::class); }) - ->create(); + ->withExceptions(function (Exceptions $exceptions): void { + // + })->create(); ``` **`app/Http/Kernel.php`** (Laravel 10 and earlier) ```php protected $middleware = [ - \Flowd\Phirewall\Middleware::class, // outermost -- before everything + \App\Http\Middleware\Phirewall::class, // outermost: runs before everything // ... other global middleware ]; ``` @@ -515,7 +651,7 @@ protected $middleware = [ ### Slim -Native PSR-15 support. No external dependencies beyond `ext-apcu`. +Native PSR-15 support. The middleware auto-detects a PSR-17 `ResponseFactory` from installed packages (`slim/psr7`, which ships with the Slim skeleton, or `nyholm/psr7`) and throws if none is found, so install one (plus `ext-apcu` for `ApcuCache`). ```php setKeyPrefix('mezzio'); $config->enableRateLimitHeaders(); - $config->setFailOpen(true); + // failOpen defaults to true; call setFailOpen(false) to fail closed. // ── Trusted Proxies ────────────────────────────────────── $proxyResolver = new TrustedProxyResolver([ @@ -681,8 +817,8 @@ class PhirewallMiddlewareFactory $config->blocklists->owasp('owasp', $owaspRules); // ── Fail2Ban ───────────────────────────────────────────── - // KeyExtractors::ip() uses the global resolver set by - // setIpResolver() above — no need to repeat clientIp(). + // No key: these rules default to the client IP from the + // global resolver set above. $config->fail2ban->add('login-abuse', threshold: 5, period: 300, @@ -690,23 +826,19 @@ class PhirewallMiddlewareFactory filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // ── Rate Limiting ──────────────────────────────────────── $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); // ── Allow2Ban ──────────────────────────────────────────── $config->allow2ban->add('flood-protection', threshold: 500, period: 60, banSeconds: 3600, - key: KeyExtractors::ip() ); // ── PSR-17 Response Bodies ─────────────────────────────── @@ -738,7 +870,15 @@ return [ **`config/pipeline.php`** ```php -// Phirewall must be the outermost middleware (piped first) +// ErrorHandler must be piped FIRST so it can catch exceptions thrown by +// downstream middleware and route handlers. Phirewall does not wrap the +// downstream handler in a try/catch, so a handler exception propagates +// through it; if Phirewall were piped above ErrorHandler, that exception +// would escape the error boundary and reach the emitter unhandled. +$app->pipe(\Laminas\Stratigility\Middleware\ErrorHandler::class); + +// Phirewall runs as early as possible AFTER the error boundary, so it +// blocks before routing/dispatch while still being covered by ErrorHandler. $app->pipe(\Flowd\Phirewall\Middleware::class); // ... other middleware @@ -748,6 +888,37 @@ $app->pipe(\Mezzio\Router\Middleware\DispatchMiddleware::class); --- +### TYPO3 + +**Use the official extension.** For TYPO3, install [`flowd/typo3-firewall`](https://extensions.typo3.org/extension/firewall) rather than wiring Phirewall by hand. It integrates Phirewall into TYPO3, registers the PSR-15 middleware in the frontend stack for you, and adds a backend module for managing block patterns. + +```bash +composer require flowd/typo3-firewall +``` + +Phirewall is then configured in TYPO3's core configuration file `config/system/phirewall.php`. That file must **return a closure** that receives the PSR-14 `EventDispatcherInterface` and returns a built `Config` (the cache is the first constructor argument, the dispatcher the second). The full [Phirewall configuration](/getting-started) applies inside the closure; use one of Phirewall's bundled PSR-16 stores (`RedisCache`, `ApcuCache`, `PdoCache`), since TYPO3's own caches are not PSR-16. + +```php +blocklists->knownScanners(); + $config->throttles->add('burst', limit: 30, period: 5); + + return $config; +}; +``` + +Block patterns created in the backend module are stored in `config/system/phirewall.patterns.json` and take effect immediately. See the [extension documentation](https://docs.typo3.org/p/flowd/typo3-firewall/main/en-us/) for details. + +--- + ## Basic: Minimal Setup The smallest useful configuration. Protects against common scanners and rate-limits all traffic. @@ -776,7 +947,6 @@ $config->blocklists->knownScanners(); // Rate limit: 100 requests per minute per IP $config->throttles->add('api', limit: 100, period: 60, - key: KeyExtractors::ip() ); $middleware = new Middleware($config); @@ -786,7 +956,7 @@ $middleware = new Middleware($config); ## Basic: API Rate Limiting -Tiered rate limits for an API with authenticated and anonymous users. +Tiered per-client-IP rate limits for an API, with a tighter cap on an expensive endpoint. ```php use Flowd\Phirewall\Config; @@ -813,21 +983,6 @@ $config->throttles->add('global', key: KeyExtractors::clientIp($proxyResolver) ); -// Authenticated user limits (higher) -$config->throttles->add('user', - limit: 5000, period: 60, - key: KeyExtractors::header('X-User-Id') -); - -// Anonymous limits (lower, skip if authenticated) -$config->throttles->add('anon', - limit: 100, period: 60, - key: function ($req) use ($proxyResolver): ?string { - if ($req->getHeaderLine('X-User-Id') !== '') return null; - return $proxyResolver->resolve($req); - } -); - // Expensive endpoint limit $config->throttles->add('search', limit: 20, period: 60, @@ -857,7 +1012,6 @@ $config = new Config(new InMemoryCache()); $config->throttles->sliding('api-sliding', limit: 100, period: 60, - key: KeyExtractors::ip() ); ``` @@ -881,7 +1035,7 @@ $config = new Config(new InMemoryCache()); $config->throttles->multi('api', [ 1 => 3, // 3 req/s burst limit 60 => 60, // 60 req/min sustained limit -], KeyExtractors::ip()); +]); ``` --- @@ -899,12 +1053,13 @@ use Psr\Http\Message\ServerRequestInterface; $config = new Config(new InMemoryCache()); $config->enableRateLimitHeaders(); +// `role` is a request attribute set by an upstream auth middleware +// ($req->withAttribute('role', ...)), not a forgeable client header. // Dynamic limit: admins get 1000 req/min, regular users get 100 req/min $config->throttles->add('role-based', limit: fn(ServerRequestInterface $req): int => - $req->getHeaderLine('X-Role') === 'admin' ? 1000 : 100, + $req->getAttribute('role') === 'admin' ? 1000 : 100, period: 60, - key: KeyExtractors::ip() ); ``` @@ -930,7 +1085,6 @@ $config->tracks->add('login-attempts', period: 3600, filter: fn($req) => $req->getUri()->getPath() === '/login' && $req->getMethod() === 'POST', - key: KeyExtractors::ip() ); // Track login attempts by username for alerting @@ -978,7 +1132,6 @@ $config->fail2ban->add('login-brute-force', ban: 3600, filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Per-username throttle: 5 attempts per 5 minutes per username @@ -1008,20 +1161,19 @@ use Psr\Http\Message\ServerRequestInterface; $config = new Config(new InMemoryCache()); -// The filter returns false -- failures are signaled programmatically +// The filter returns false; failures are signaled programmatically $config->fail2ban->add('login-failures', threshold: 3, period: 300, ban: 3600, filter: fn(ServerRequestInterface $req): bool => false, - key: KeyExtractors::ip() ); // In your login handler: // $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); // if ($loginFailed) { // // The firewall derives the key from the rule's own keyExtractor -// // -- no need to repeat the IP/header/etc. extraction here. +// // No need to repeat the IP/header/etc. extraction here. // $context?->recordFailure('login-failures'); // } ``` @@ -1036,7 +1188,6 @@ Allow2Ban is the inverse of Fail2Ban: it counts every request for a key and bans ```php use Flowd\Phirewall\Config; -use Flowd\Phirewall\KeyExtractors; use Flowd\Phirewall\Store\InMemoryCache; $config = new Config(new InMemoryCache()); @@ -1046,16 +1197,6 @@ $config->allow2ban->add('high-volume-ban', threshold: 100, period: 60, banSeconds: 3600, - key: KeyExtractors::ip() -); - -// Ban by API key for authenticated routes. hashedHeader() stores a sha256 -// fingerprint of the key in the ban registry instead of the raw credential. -$config->allow2ban->add('api-key-ban', - threshold: 1000, - period: 60, - banSeconds: 300, - key: KeyExtractors::hashedHeader('X-Api-Key') ); ``` @@ -1172,14 +1313,12 @@ $config = new Config(new InMemoryCache()); $config->tracks->add('every-login-attempt', period: 60, filter: fn($req) => $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Track with threshold: thresholdReached=true at 5+ hits $config->tracks->add('suspicious-login-burst', period: 60, filter: fn($req) => $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip(), limit: 5, ); ``` @@ -1203,7 +1342,7 @@ $pdo->exec('PRAGMA journal_mode=WAL'); $cache = new PdoCache($pdo); $config = new Config($cache); -$config->throttles->add('api', limit: 100, period: 60, key: KeyExtractors::ip()); +$config->throttles->add('api', limit: 100, period: 60); // MySQL (shared across multiple app servers) // $pdo = new PDO('mysql:host=db.example.com;dbname=myapp', getenv('DB_USER'), getenv('DB_PASSWORD')); @@ -1252,7 +1391,7 @@ $psr17Factory = new Psr17Factory(); $config->blocklistedResponseFactory = new Psr17BlocklistedResponseFactory( $psr17Factory, $psr17Factory, - 'Access Denied -- your request has been blocked.', + 'Access Denied. Your request has been blocked.', ); $config->throttledResponseFactory = new Psr17ThrottledResponseFactory( @@ -1527,7 +1666,7 @@ $dispatcher = new class ($logger) implements EventDispatcherInterface { $event instanceof BlocklistMatched => $this->logger->warning('Request blocklisted', $context), $event instanceof ThrottleExceeded => $this->logger->notice('Rate limited', $context), $event instanceof SafelistMatched => $this->logger->debug('Safelisted', $context), - $event instanceof FirewallError => $this->logger->error('Firewall error', ['error' => $event->throwable->getMessage()]), + $event instanceof FirewallError => $this->logger->error('Firewall error', ['error' => $event->exception->getMessage()]), default => null, }; @@ -1536,11 +1675,10 @@ $dispatcher = new class ($logger) implements EventDispatcherInterface { }; $config = new Config(new RedisCache($redis), $dispatcher); -$config->throttles->add('api', limit: 100, period: 60, key: KeyExtractors::ip()); +$config->throttles->add('api', limit: 100, period: 60); $config->fail2ban->add('login', threshold: 5, period: 300, ban: 3600, filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); ``` @@ -1582,12 +1720,10 @@ $config->blocklists->ip('known-bad', ['198.51.100.0/24', '203.0.113.0/24']); $config->fail2ban->add('persistent-scanner', threshold: 10, period: 60, ban: 86400, filter: fn($req) => true, - key: KeyExtractors::ip() ); // Global rate limit as backstop $config->throttles->add('global', limit: 100, period: 60, - key: KeyExtractors::ip() ); ``` diff --git a/docs/faq.md b/docs/faq.md index 0ec7bb1..4ed2095 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -16,7 +16,8 @@ Phirewall works with any PSR-15 (PHP Standard Recommendation for HTTP Server Mid - **Slim** (4.x+) - **Mezzio** (Laminas) -- **Laravel** (via PSR-15 bridge or `nyholm/psr7`) +- **TYPO3** (via an extension middleware) +- **Laravel** (via `symfony/psr-http-message-bridge` + `nyholm/psr7`) - **Symfony** (via `symfony/psr-http-message-bridge`) - **Spiral** - **Any custom PSR-15 middleware stack** @@ -62,13 +63,13 @@ Phirewall is dual licensed under LGPL-3.0-or-later and a proprietary license. Se Phirewall evaluates rules in a strict, deterministic order. The first match wins: -1. **Track** -- passive counting, never blocks -2. **Safelist** -- if matched, bypass all other checks (returns 200) -3. **Blocklist** -- if matched, returns 403 Forbidden -4. **Fail2Ban** -- if already banned, 403; if filter matches, increment failure counter -5. **Throttle** -- if counter exceeds limit, returns 429 Too Many Requests -6. **Allow2Ban** -- if threshold exceeded, returns 403 -7. **Pass** -- request reaches your application +1. **Track**: passive counting, never blocks +2. **Safelist**: if matched, bypass all other checks (returns 200) +3. **Blocklist**: if matched, returns 403 Forbidden +4. **Fail2Ban**: if already banned, 403; if filter matches, increment the failure counter +5. **Throttle**: if counter exceeds limit, returns 429 Too Many Requests +6. **Allow2Ban**: if threshold exceeded, returns 403 +7. **Pass**: request reaches your application ### How do I handle trusted proxies? @@ -84,7 +85,7 @@ $proxy = new TrustedProxyResolver([ '192.168.0.0/16', // Private ranges ]); -// Apply globally to all rules that use KeyExtractors::ip() +// Default client-IP resolution for every rule added without an explicit key $config->setIpResolver(KeyExtractors::clientIp($proxy)); ``` @@ -96,8 +97,16 @@ $config->throttles->add('api', limit: 100, period: 60, ); ``` +A few details worth knowing: + +- **`allowedHeaders` defaults to `['X-Forwarded-For']`** (a single header). If your stack emits the RFC 7239 `Forwarded` header, pass it explicitly: `new TrustedProxyResolver([...], ['Forwarded'])`. +- **All `X-Forwarded-For` / `Forwarded` instances are folded into one chain**, which the resolver walks right to left, returning the first hop not in your trusted-proxy list. The protection is this trusted-hop walk (and reading proxy headers only when the direct peer is itself trusted), not discarding duplicate lines. +- **IPv6 is canonicalized**: IPv4-mapped peers (`::ffff:1.2.3.4`) match IPv4 rules, and alternate IPv6 spellings are treated as one identity by `ip()` / CIDR list matching (rate-limit and ban keys use the spelling the resolver returns). + +See [Client IP Behind Proxies](/getting-started#client-ip-behind-proxies) for the full behavior. + ::: danger -Never trust `X-Forwarded-For` without configuring trusted proxies. An attacker can spoof this header to bypass rate limiting. +`KeyExtractors::ip()` reads `REMOTE_ADDR`, which behind a CDN or load balancer is the *proxy's* address, so every client collapses onto one key. Always install a client-IP resolver in that case. And never trust `X-Forwarded-For` without configuring trusted proxies: an attacker can otherwise spoof this header to bypass rate limiting. ::: ### What happens when the cache backend is unavailable? @@ -176,27 +185,9 @@ $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); $context?->recordFailure('login-failures'); ``` -The second argument to `recordFailure()` is optional -- when omitted, the firewall extracts the discriminator key from the rule's own `keyExtractor`. The matching Fail2Ban rule should use `filter: fn($request): bool => false` so it only counts failures signaled programmatically. - -For allow2ban rules, use `$context->recordHit('rule-name')` -- same shape, routed through the allow2ban evaluator instead. - -### The old fluent API methods are gone — what do I use now? - -The deprecated convenience methods (`$config->safelist()`, `$config->throttle()`, etc.) have been removed. Use the section API instead: - -```php -// Old (removed) -$config->safelist('health', fn($request) => ...); -$config->throttle('ip', 100, 60, fn($request) => ...); -$config->fail2ban('login', 5, 300, 3600, filter: ..., key: ...); - -// New (section API) -$config->safelists->add('health', fn($request) => ...); -$config->throttles->add('ip', 100, 60, fn($request) => ...); -$config->fail2ban->add('login', threshold: 5, period: 300, banSeconds: 3600, filter: ..., key: ...); -``` +The second argument to `recordFailure()` is optional; when omitted, the firewall extracts the discriminator key from the rule's own `keyExtractor`. The matching Fail2Ban rule should use `filter: fn($request): bool => false` so it only counts failures signaled programmatically. -See the [Getting Started](/getting-started) guide for the full section API reference. +For allow2ban rules, use `$context->recordHit('rule-name')`, same shape, routed through the allow2ban evaluator instead. ## Rate Limiting @@ -204,9 +195,9 @@ See the [Getting Started](/getting-started) guide for the full section API refer Phirewall supports three throttling strategies: -- **Fixed window** (`add()`) -- time is divided into fixed intervals. Simple and fast, but allows double bursts at period boundaries. -- **Sliding window** (`sliding()`) -- uses a weighted average of current and previous window to provide smooth rate enforcement. Prevents the "double burst" problem. -- **Multi-window** (`multi()`) -- registers multiple time windows in a single call. Useful for setting both burst limits (short window) and sustained limits (long window). +- **Fixed window** (`add()`): time is divided into fixed intervals. Simple and fast, but allows double bursts at period boundaries. +- **Sliding window** (`sliding()`): uses a weighted average of current and previous window to provide smooth rate enforcement. Prevents the "double burst" problem. +- **Multi-window** (`multi()`): registers multiple time windows in a single call. Useful for setting both burst limits (short window) and sustained limits (long window). See [Dynamic Throttle](/advanced/dynamic-throttle) for details. @@ -215,7 +206,7 @@ See [Dynamic Throttle](/advanced/dynamic-throttle) for details. Use sliding window throttling: ```php -$config->throttles->sliding('api', limit: 100, period: 60, key: KeyExtractors::ip()); +$config->throttles->sliding('api', limit: 100, period: 60); ``` Or combine multiple fixed windows with `multi()`: @@ -224,12 +215,12 @@ Or combine multiple fixed windows with `multi()`: $config->throttles->multi('api', [ 1 => 3, // 3 req/s burst protection 60 => 100, // 100 req/min sustained limit -], KeyExtractors::ip()); +]); ``` ### What happens when a throttle key returns `null`? -The rule is **skipped entirely** for that request -- as if the rule did not exist. This is the primary mechanism for conditional rate limits. For example, return `null` for admin users to exempt them from rate limiting. +The rule is **skipped entirely** for that request, as if the rule did not exist. This is the primary mechanism for conditional rate limits. For example, return `null` for admin users to exempt them from rate limiting. ### Are rate limit counters atomic? @@ -247,17 +238,18 @@ Yes. Use a dynamic `limit` closure: ```php $config->throttles->add('api', - limit: fn($request): int => match ($request->getHeaderLine('X-Plan')) { + limit: fn($request): int => match ($request->getAttribute('plan')) { 'enterprise' => 10000, 'pro' => 1000, default => 100, }, period: 60, - key: fn($request): ?string => $request->getHeaderLine('X-User-Id') - ?: $request->getServerParams()['REMOTE_ADDR'] ?? null, + key: fn($request): ?string => $request->getServerParams()['REMOTE_ADDR'] ?? null, ); ``` +`plan` is a PSR-7 request **attribute** your authentication middleware sets after verifying the principal (`$request->withAttribute('plan', ...)`), not a client header a caller could forge. Place that middleware before Phirewall in the pipeline. + See [Dynamic Throttle: Per-User Tier Limits](/advanced/dynamic-throttle#per-user-tier-limits) for more patterns. ## Storage @@ -269,6 +261,7 @@ See [Dynamic Throttle: Per-User Tier Limits](/advanced/dynamic-throttle#per-user | Testing / Development | `InMemoryCache` | No dependencies, resets each request | | Single server | `ApcuCache` | Sub-microsecond, shared across PHP-FPM workers | | Multiple servers | `RedisCache` | Shared state, atomic operations | +| Long-running workers (Swoole, RoadRunner, FrankenPHP, Octane) | `RedisCache` (or `PdoCache`) | Each worker is a separate process; avoid `InMemoryCache` and `ApcuCache`, whose state fragments across workers | | Kubernetes / Docker | `RedisCache` | Containers are ephemeral, need external state | | Serverless | `RedisCache` (external) | Function instances are short-lived | | No Redis available | `PdoCache` | MySQL, PostgreSQL, or SQLite | @@ -277,15 +270,18 @@ See [Storage Backends](/features/storage) for a detailed comparison. ### Can I use Symfony Cache or Laravel Cache? -Yes. Phirewall accepts any PSR-16 (PHP Standard Recommendation for Simple Caching) compatible implementation. However, generic PSR-16 caches may have non-atomic counter increments. For production, prefer the bundled `RedisCache` or `ApcuCache` for accuracy. +Yes, Phirewall accepts any PSR-16 (PHP Standard Recommendation for Simple Caching) compatible implementation, but note that most host-framework caches are **not** PSR-16 out of the box: Laravel's `Cache` repository (`Illuminate\Contracts\Cache\Repository`) and TYPO3's caching framework (`FrontendInterface`) are not `Psr\SimpleCache\CacheInterface`, so passing one directly to `Config` is a type error. Symfony Cache exposes a PSR-16 adapter (`Symfony\Component\Cache\Psr16Cache`) you can wrap a pool in. For production, prefer the bundled `RedisCache` or `ApcuCache`: they implement `CounterStoreInterface` for atomic increments, whereas a generic PSR-16 cache may have non-atomic counter increments. ### Why does InMemoryCache not work in production? In PHP-FPM (FastCGI Process Manager), each request starts a new process (or reuses one from the pool). The in-memory cache is empty at the start of each request, so counters always reset to zero. This means rate limits and ban counters never accumulate. +Under long-running worker runtimes (Swoole, RoadRunner, FrankenPHP worker mode, Octane) the failure is the opposite and easy to miss: the array survives across requests within a worker, so a single-worker demo looks like it "works", but each worker is a separate process with its own array. State is never shared across workers (counters and bans fragment, so the effective rate limit is roughly N times the configured value) and the array grows for the worker's lifetime. + Solutions: - **Single server**: use `ApcuCache` (shared memory across the FPM pool) - **Multiple servers**: use `RedisCache` (shared across all servers) +- **Long-running workers**: use `RedisCache` or `PdoCache` (not `ApcuCache`, whose memory is per process) - **Any server**: use `PdoCache` with a database ## OWASP Rules @@ -355,7 +351,6 @@ The optional `limit` parameter adds a threshold to your track rule. When set, th $config->tracks->add('suspicious-burst', period: 60, filter: fn($request) => $request->getUri()->getPath() === '/login', - key: KeyExtractors::ip(), limit: 10, // thresholdReached=true at 10+ hits ); ``` @@ -433,11 +428,21 @@ if ($result->isBlocked()) { Use the `Firewall` class: ```php +use Flowd\Phirewall\BanType; + $firewall = new Firewall($config); // Reset a specific throttle counter $firewall->resetThrottle('api', '192.168.1.100'); +// Lift a specific fail2ban ban (also clears its fail counter) +$firewall->resetFail2Ban('login-failures', '192.168.1.100'); + +// Check whether a key is currently banned (BanType is required) +$banned = $firewall->isBanned('login-failures', '192.168.1.100', BanType::Fail2Ban); + // Reset all counters and bans $firewall->resetAll(); ``` + +`BanType` is `enum BanType: string { case Allow2Ban = 'allow2ban'; case Fail2Ban = 'fail2ban'; }`. diff --git a/docs/features/bot-detection.md b/docs/features/bot-detection.md index 355155b..b331683 100644 --- a/docs/features/bot-detection.md +++ b/docs/features/bot-detection.md @@ -8,7 +8,7 @@ Phirewall provides three specialized matchers for bot and scanner detection: **K ## Known Scanner Blocking -The `knownScanners()` method blocks requests whose User-Agent matches known attack tools and vulnerability scanners. It ships with a curated default list covering 25+ well-known tools. +The `knownScanners()` method blocks requests whose User-Agent matches known attack tools and vulnerability scanners. It ships with a curated default list covering 24 tools (26 substring patterns, since Burp Suite and Metasploit each have two spellings). ### Quick Setup @@ -109,22 +109,22 @@ $config->blocklists->suspiciousHeaders(); ```php $config->blocklists->suspiciousHeaders( string $name = 'suspicious-headers', - array $requiredHeaders = [] + ?array $requiredHeaders = null ): BlocklistSection ``` | Parameter | Type | Description | |-----------|------|-------------| | `$name` | `string` | Unique rule identifier (default: `'suspicious-headers'`) | -| `$requiredHeaders` | `list` | Headers that must be present. Empty array uses defaults. | +| `$requiredHeaders` | `?list` | Headers that must be present (matched case-insensitively per PSR-7). `null` uses the default set; a non-null list replaces the defaults. Passing `[]` requires nothing and never matches; do not use it for defaults. | ### Default Required Headers When no custom headers are specified, the following are required: -- `Accept` -- specifies acceptable response content types -- `Accept-Language` -- specifies acceptable languages -- `Accept-Encoding` -- specifies acceptable compression +- `Accept` - specifies acceptable response content types +- `Accept-Language` - specifies acceptable languages +- `Accept-Encoding` - specifies acceptable compression Every modern browser sends all three. Their absence strongly suggests an automated tool. @@ -156,7 +156,7 @@ Some legitimate clients may not send all standard headers: API clients, embedded ## Trusted Bot Verification (rDNS) -The `trustedBots()` method safelists verified search engine bots using **reverse DNS (rDNS) verification**. This prevents fake bots -- anyone can send `Googlebot` as a User-Agent, but only Google's real crawlers have IPs that resolve to `*.googlebot.com`. +The `trustedBots()` method safelists verified search engine bots using **reverse DNS (rDNS) verification**. This prevents fake bots: anyone can send `Googlebot` as a User-Agent, but only Google's real crawlers have IPs that resolve to `*.googlebot.com`. ### Quick Setup @@ -233,7 +233,7 @@ $config->safelists->trustedBots('custom-bots', [ ``` ::: danger -The hostname suffix **must** start with a dot (e.g., `.googlebot.com`, not `googlebot.com`). This prevents subdomain spoofing -- without the leading dot, an attacker controlling `evil-googlebot.com` could pass verification. +The hostname suffix **must** start with a dot (e.g., `.googlebot.com`, not `googlebot.com`). This prevents subdomain spoofing: without the leading dot, an attacker controlling `evil-googlebot.com` could pass verification. ::: ### Caching DNS Results @@ -322,13 +322,11 @@ $config->blocklists->add('scanner-paths', function ($req): bool { $config->fail2ban->add('persistent-scanner', threshold: 5, period: 60, ban: 86400, filter: fn($req) => true, - key: KeyExtractors::ip() ); // 6. Rate limit everything else $config->throttles->add('global', limit: 60, period: 60, - key: KeyExtractors::ip() ); ``` @@ -356,7 +354,7 @@ Blocklists (knownScanners, suspiciousHeaders) --> match? --> 403 BLOCK No match | v -Fail2Ban / Allow2Ban --> banned? --> 403 BLOCK +Fail2Ban --> banned? --> 403 BLOCK | Not banned | @@ -366,6 +364,11 @@ Throttles --> over limit? --> 429 TOO MANY REQUESTS Under limit | v +Allow2Ban --> over volume cap? --> 403 BLOCK + | + Under cap + | + v ALLOW (pass to handler) ``` @@ -383,6 +386,6 @@ ALLOW (pass to handler) 6. **Safelist your API clients.** If your application serves both browser and API traffic, safelist API paths or known client IPs before applying `suspiciousHeaders()`, since API clients typically don't send browser headers. -7. **Monitor false positives.** Use [events and logging](/advanced/observability) to track which rules are triggering and watch for false positives -- especially with `suspiciousHeaders()`, which may catch some legitimate clients. +7. **Monitor false positives.** Use [events and logging](/advanced/observability) to track which rules are triggering and watch for false positives, especially with `suspiciousHeaders()`, which may catch some legitimate clients. 8. **Combine with OWASP CRS.** For deep packet inspection beyond User-Agent matching, enable the [OWASP Core Rule Set](/features/owasp-crs) to detect SQL injection, XSS, and other attacks in request payloads. diff --git a/docs/features/fail2ban.md b/docs/features/fail2ban.md index cdf81ac..f269483 100644 --- a/docs/features/fail2ban.md +++ b/docs/features/fail2ban.md @@ -4,7 +4,7 @@ outline: deep # Fail2Ban & Allow2Ban -Fail2Ban and Allow2Ban are Phirewall's automatic banning mechanisms. They monitor request patterns and temporarily ban clients that exceed configurable thresholds -- the primary defense against brute force attacks, credential stuffing, and persistent scanners. +Fail2Ban and Allow2Ban are Phirewall's automatic banning mechanisms. They monitor request patterns and temporarily ban clients that exceed configurable thresholds, the primary defense against brute force attacks, credential stuffing, and persistent scanners. ## Fail2Ban @@ -36,7 +36,7 @@ Request --> Is key already banned? --> Yes --> 403 Forbidden 1. A **filter** closure checks each incoming request for a condition (e.g., a POST to `/login`) 2. Matches are counted per **key** (e.g., IP address) within a time **period** -3. When the count **reaches** the **threshold**, the key is **banned** for a configurable duration (e.g., `threshold: 5` bans on the 5th matching request — the same request that brings the counter to 5 is itself blocked) +3. When the count **reaches** the **threshold**, the key is **banned** for a configurable duration (e.g., `threshold: 5` bans on the 5th matching request; the same request that brings the counter to 5 is itself blocked) 4. Banned keys receive `403 Forbidden` immediately, without further rule evaluation ### Configuration @@ -48,7 +48,7 @@ $config->fail2ban->add( int $period, int $ban, Closure $filter, - Closure $key + ?Closure $key = null ): Fail2BanSection ``` @@ -58,8 +58,8 @@ $config->fail2ban->add( | `$threshold` | `int` | Number of filter matches that triggers the ban (must be >= 1). The Nth matching request is itself banned (matching rack-attack `maxretry` semantics). | | `$period` | `int` | Time window for counting matches in seconds (must be >= 1) | | `$ban` | `int` | Ban duration in seconds (must be >= 1) | -| `$filter` | `Closure` | `fn(ServerRequestInterface): bool` -- return `true` to count as a match | -| `$key` | `Closure` | `fn(ServerRequestInterface): ?string` -- return key to track, or `null` to skip | +| `$filter` | `Closure` | `fn(ServerRequestInterface): bool`, return `true` to count as a match | +| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string`, return key to track, or `null` to skip. When the whole argument is omitted, defaults to the client IP from the Config's IP resolver (`Config::setIpResolver()`, typically `KeyExtractors::clientIp($proxy)`), falling back to `KeyExtractors::ip()` (REMOTE_ADDR). The resolver is read per request, so it can be set before or after the rule. | ::: warning Fail2Ban filters evaluate the **incoming request** before the handler runs. The filter can only inspect request data (path, method, headers, query parameters). It cannot see the application's response. To ban based on application outcomes (like actual failed logins), use the [Request Context API](#post-handler-signaling-with-requestcontext) instead. @@ -79,7 +79,6 @@ $config->fail2ban->add('login-brute-force', ban: 3600, // 1 hour ban filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); ``` @@ -101,7 +100,6 @@ $config->fail2ban->add('credential-stuffing-ip', ban: 7200, // 2 hour ban filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Per-username throttle: 5 attempts per 5 minutes per username @@ -138,19 +136,19 @@ This three-layer strategy defends against different attack speeds: ### API Signature Abuse -Ban clients sending invalid API signatures. A middleware running before Phirewall validates signatures and marks the request: +Ban clients sending invalid API signatures. A middleware running before Phirewall validates the signature and records the outcome and the verified client id on the request as attributes: ```php -// The Fail2Ban rule reads the header set by the prior middleware +// The Fail2Ban rule reads the attributes the prior middleware set $config->fail2ban->add('api-abuse', threshold: 3, period: 120, // 2 minute window ban: 900, // 15 minute ban - filter: fn($req) => $req->getHeaderLine('X-Signature-Invalid') === '1', - key: function ($req): ?string { - return $req->getHeaderLine('X-API-Key') - ?: $req->getServerParams()['REMOTE_ADDR']; - } + filter: fn($req) => $req->getAttribute('apiSignatureValid') === false, + // Key on the verified client id (an internal identifier, not the raw API + // secret), falling back to the client IP when the request is unauthenticated. + key: fn($req): ?string => + $req->getAttribute('apiClientId') ?? ($req->getServerParams()['REMOTE_ADDR'] ?? null), ); ``` @@ -166,17 +164,16 @@ $config->fail2ban->add('persistent-scanner', period: 60, // in 1 minute ban: 86400, // 24 hour ban filter: fn($req) => true, - key: KeyExtractors::ip() ); ``` ::: warning -The filter `fn($req) => true` counts every request that reaches the Fail2Ban layer. Because safelisted and blocklisted requests never reach Fail2Ban, this effectively counts requests that passed safelists and blocklists but are still suspicious. Use with care -- this is a broad filter. +The filter `fn($req) => true` counts every request that reaches the Fail2Ban layer. Because safelisted and blocklisted requests never reach Fail2Ban, this effectively counts requests that passed safelists and blocklists but are still suspicious. Use with care: this is a broad filter. ::: ## Post-Handler Signaling with RequestContext {#post-handler-signaling-with-requestcontext} -Standard Fail2Ban filters run **before** your application handler, so they can only inspect the incoming request. The **RequestContext API** solves this by letting your handler signal failures **after** it has processed the request -- for example, after verifying credentials against a database. +Standard Fail2Ban filters run **before** your application handler, so they can only inspect the incoming request. The **RequestContext API** solves this by letting your handler signal failures **after** it has processed the request, for example after verifying credentials against a database. ### How It Works @@ -207,7 +204,7 @@ Response ### Setup -Configure a fail2ban rule with a filter that always returns `false`. The filter will never match pre-handler -- all counting happens via `recordFailure()`: +Configure a fail2ban rule with a filter that always returns `false`. The filter will never match pre-handler; all counting happens via `recordFailure()`: ```php use Flowd\Phirewall\Config; @@ -223,13 +220,12 @@ $config->fail2ban->add( period: 300, // 5 minute window ban: 3600, // 1 hour ban filter: fn(ServerRequestInterface $req): bool => false, - key: KeyExtractors::ip(), ); ``` ### Recording Failures in Your Handler -Inside your request handler, retrieve the `RequestContext` from the request attribute and call `recordFailure()`. The second argument is optional -- when omitted, the firewall reuses the rule's own `keyExtractor` against this request, so the handler doesn't need to repeat the IP/header/etc. extraction: +Inside your request handler, retrieve the `RequestContext` from the request attribute and call `recordFailure()`. The second argument is optional; when omitted, the firewall reuses the rule's own `keyExtractor` against this request, so the handler doesn't need to repeat the IP/header/etc. extraction: ```php use Flowd\Phirewall\Context\RequestContext; @@ -242,7 +238,7 @@ class LoginController $password = $request->getParsedBody()['password'] ?? ''; if (!$this->auth->verify($username, $password)) { - // Signal the failure -- the firewall extracts the key from + // Signal the failure; the firewall extracts the key from // the rule's own keyExtractor against this request. $context = $request->getAttribute(RequestContext::ATTRIBUTE_NAME); $context?->recordFailure('login-failures'); @@ -263,14 +259,14 @@ $context?->recordFailure('login-failures', $userIdFromSession); | Method | Description | |--------|-------------| -| `$context->recordFailure(string $ruleName, ?string $key = null)` | Record a fail2ban failure signal. `$ruleName` must match a configured fail2ban rule. When `$key` is `null` the firewall derives it from the rule's `keyExtractor`. | -| `$context->recordHit(string $ruleName, ?string $key = null)` | Counterpart for allow2ban rules -- same shape, routed through the allow2ban evaluator. See [Request Context](/advanced/request-context#recording-hits-for-allow2ban). | +| `$context->recordFailure(string $ruleName, ?string $key = null)` | Record a fail2ban failure signal. `$ruleName` must match a configured fail2ban rule name. `$key` is **optional**; when omitted, the rule's own key extractor resolves the discriminator from the current request, so the handler does not need to know whether the rule keys on IP, header, or anything else. | +| `$context->recordHit(string $ruleName, ?string $key = null)` | Counterpart for allow2ban rules; same shape, routed through the allow2ban evaluator. See [Request Context](/advanced/request-context#recording-allow2ban-hits). | | `$context->getResult()` | Returns the `FirewallResult` from the pre-handler evaluation | | `$context->hasRecordedSignals()` | Whether any signals have been recorded | | `$context->getRecordedSignals()` | Returns all recorded `RecordedSignal` objects | ::: tip -Use the null-safe operator (`$context?->recordFailure(...)`) so your handler works safely both with and without the middleware in the stack -- useful in unit tests where the middleware may not be present. +Use the null-safe operator (`$context?->recordFailure(...)`) so your handler works safely both with and without the middleware in the stack, useful in unit tests where the middleware may not be present. ::: ### Why Use RequestContext? @@ -285,7 +281,7 @@ RequestContext is the most accurate approach because it only increments the fail ## Allow2Ban {#allow2ban} -Allow2Ban is a **dedicated section** (`$config->allow2ban`) with its own API. It is the inverse of Fail2Ban: instead of counting only filtered "bad" requests, it counts **every request** for a given key and bans once the count reaches the threshold. Think of it as "n requests allowed, then you're out" — with `threshold: n`, the nth request itself is the one that triggers and is blocked. +Allow2Ban is a **dedicated section** (`$config->allow2ban`) with its own API. It is the inverse of Fail2Ban: instead of counting only filtered "bad" requests, it counts **every request** for a given key and bans once the count reaches the threshold. Think of it as "n requests allowed, then you're out": with `threshold: n`, the nth request itself is the one that triggers and is blocked. ### How It Works @@ -298,7 +294,7 @@ Request --> Is key already banned? --> Yes --> 403 Forbidden Increment request counter | v - Counter >= threshold? --> No --> Continue to throttle rules + Counter >= threshold? --> No --> Allow (pass to handler) | Yes | @@ -306,7 +302,7 @@ Request --> Is key already banned? --> Yes --> 403 Forbidden BAN key for configured duration --> 403 Forbidden ``` -There is no filter -- every request matching the key extractor is counted. +There is no filter; every request matching the key extractor is counted. ### Configuration @@ -316,7 +312,7 @@ $config->allow2ban->add( int $threshold, int $period, int $banSeconds, - Closure $key + ?Closure $key = null ): Allow2BanSection ``` @@ -326,7 +322,7 @@ $config->allow2ban->add( | `$threshold` | `int` | Number of requests that triggers the ban (must be >= 1). The Nth request is itself banned (matching rack-attack `maxretry` semantics). | | `$period` | `int` | Time window for counting requests in seconds (must be >= 1) | | `$banSeconds` | `int` | Ban duration in seconds (must be >= 1) | -| `$key` | `Closure` | `fn(ServerRequestInterface): ?string` -- return key to track, or `null` to skip | +| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string`, return key to track, or `null` to skip. When omitted, defaults to the client IP from the Config's IP resolver (see Fail2Ban's `$key` above). | ::: tip Note the parameter name difference: Fail2Ban uses `$ban`, Allow2Ban uses `$banSeconds`. Both accept duration in seconds. @@ -345,29 +341,27 @@ $config->allow2ban->add( threshold: 100, period: 60, banSeconds: 3600, - key: KeyExtractors::ip(), ); ``` ### API Key Abuse Protection -Ban API keys that exceed expected usage. Unlike rate limiting (which returns 429 and lets the client retry), Allow2Ban **bans** the key entirely -- a stronger response for abuse: +Ban API keys that exceed expected usage. Unlike rate limiting (which returns 429 and lets the client retry), Allow2Ban **bans** the key entirely, a stronger response for abuse: ```php -use Flowd\Phirewall\KeyExtractors; - -// Ban any API key that makes more than 1000 requests in 60 seconds. -// hashedHeader() stores the sha256 fingerprint instead of the raw key so the -// ban registry doesn't carry the credential verbatim. +// Ban any client IP that makes more than 1000 requests in 60 seconds. $config->allow2ban->add( - name: 'api-key-abuse', + name: 'api-volume-abuse', threshold: 1000, period: 60, banSeconds: 300, // 5 minute ban - key: KeyExtractors::hashedHeader('X-Api-Key'), ); ``` +::: warning Header keys are client-controlled +A throttle, fail2ban, or allow2ban rule keyed on a request header (`X-Api-Key`, `X-User-Id`, …) is only as trustworthy as that header. A client can rotate or drop the header to land in a fresh counter on every request and never reach the threshold (a trivial bypass). Key such rules on a value the client cannot freely change: the client IP (via `KeyExtractors::clientIp()` with a `TrustedProxyResolver`), the authenticated principal your auth layer sets *after* verifying it, or a composite of both. When you must key on a credential-bearing header, use `KeyExtractors::hashedHeader('X-Api-Key')`: the raw value otherwise reaches the ban registry and event payloads (and your logs) in cleartext. +::: + ### Unauthenticated Endpoint Abuse Ban clients that repeatedly access authenticated endpoints without credentials: @@ -397,12 +391,52 @@ $config->allow2ban->add( | Aspect | Fail2Ban | Allow2Ban | |--------|----------|-----------| | **Section** | `$config->fail2ban` | `$config->allow2ban` | -| **Filter** | Required -- only matching requests are counted | No filter -- all requests for the key are counted | +| **Filter** | Required: only matching requests are counted | No filter: all requests for the key are counted | | **Trigger** | Repeated "bad" requests matching the filter | Exceeding a total request volume | | **Use case** | Brute force, credential stuffing, scanner blocking | Volume abuse, DDoS mitigation, API abuse | | **Event** | `Fail2BanBanned` | `Allow2BanBanned` | | **Ban parameter** | `$ban` | `$banSeconds` | +## Managing Bans + +`Flowd\Phirewall\Http\Firewall` is the supported runtime-management entry point. Construct it with the same `Config` your middleware uses; all state lives in the `Config` cache, so every `Firewall` over the same `Config` shares bans and counters. + +```php +use Flowd\Phirewall\BanType; +use Flowd\Phirewall\Http\Firewall; + +$firewall = new Firewall($config); + +// Is a key currently banned? BanType is REQUIRED (no default). +$firewall->isBanned('login-failures', $ip, BanType::Fail2Ban); +$firewall->isBanned('high-volume-ban', $ip, BanType::Allow2Ban); + +// Lift a specific fail2ban ban (also clears its fail counter). +$firewall->resetFail2Ban('login-failures', $ip); + +// Clear a throttle counter. +$firewall->resetThrottle('api', $ip); + +// Clear the whole cache instance (counters, bans, tracking). +$firewall->resetAll(); +``` + +`isBanned()` requires an explicit `BanType` because allow2ban and fail2ban store their bans under distinct cache keys, so an implicit default would silently answer for the wrong category: + +```php +enum BanType: string +{ + case Allow2Ban = 'allow2ban'; + case Fail2Ban = 'fail2ban'; +} +``` + +Notes: + +- For `multi()` throttle sub-rules, reset each window individually (for example `'api:1s'` and `'api:60s'`); for dynamic-period rules, pass the `:p{period}` suffix. +- `resetAll()` calls `cache->clear()` and wipes the entire cache instance, so give phirewall a dedicated cache (or key-prefixed namespace) if you share Redis/APCu with your application. +- All keys are normalized through the discriminator normalizer, so lookups match regardless of input casing. + ## Events When a key is banned, an event is dispatched through your PSR-14 event dispatcher. Fail2Ban and Allow2Ban each dispatch their own event type. @@ -499,19 +533,16 @@ $config->fail2ban->add('login', threshold: 5, period: 300, ban: 3600, filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Layer 4: Allow2Ban for volume abuse $config->allow2ban->add('volume-abuse', threshold: 200, period: 60, banSeconds: 1800, - key: KeyExtractors::ip() ); // Layer 5: Rate limiting as backstop $config->throttles->add('global', limit: 100, period: 60, - key: KeyExtractors::ip() ); ``` diff --git a/docs/features/owasp-crs.md b/docs/features/owasp-crs.md index 77280a0..4e9862a 100644 --- a/docs/features/owasp-crs.md +++ b/docs/features/owasp-crs.md @@ -87,11 +87,27 @@ $skipped = $report['skipped']; // int - Lines that were skipped | Method | Parameters | Description | |--------|-----------|-------------| -| `fromString()` | `string $rulesText, ?string $contextFolder` | Parse rules from a string | -| `fromFile()` | `string $filePath` | Load rules from a single file | -| `fromFiles()` | `list $paths` | Load and merge multiple files | -| `fromDirectory()` | `string $dir, ?callable $filter` | Load all files in a directory | -| `fromStringWithReport()` | `string $rulesText` | Parse with statistics | +| `fromString()` | `string $rulesText, ?string $contextFolder = null, ?int $maxValuesPerCrsVariable = null` | Parse rules from a string | +| `fromFile()` | `string $filePath, ?int $maxValuesPerCrsVariable = null` | Load rules from a single file | +| `fromFiles()` | `list $paths, ?int $maxValuesPerCrsVariable = null` | Load and merge multiple files | +| `fromDirectory()` | `string $dir, ?callable $filter = null, ?int $maxValuesPerCrsVariable = null` | Load all files in a directory | +| `fromStringWithReport()` | `string $rulesText, ?int $maxValuesPerCrsVariable = null` | Parse with statistics | + +## Per-Variable Value Cap (CPU-DoS Guard) + +Every `SecRuleLoader` factory accepts an optional trailing `?int $maxValuesPerCrsVariable`. It bounds how many collected values a single CRS variable (such as `ARGS`) may contribute to evaluation, so an attacker cannot drive up per-request WAF cost by submitting thousands of parameters, headers, or cookies. The cap is **per variable**, not aggregate. + +```php +use Flowd\Phirewall\Owasp\SecRuleLoader; + +// Cap each CRS variable at 5000 collected values. +$rules = SecRuleLoader::fromFile('/etc/phirewall/owasp.conf', maxValuesPerCrsVariable: 5000); +$rules = SecRuleLoader::fromString($rulesText, maxValuesPerCrsVariable: 5000); +``` + +- **Default (`null`):** twice PHP's `max_input_vars`, falling back to `2000` (`RequestVariableValues::DEFAULT_MAX_VALUES_PER_CRS_VARIABLE`) when `max_input_vars` is unset or non-positive. Doubling `max_input_vars` sizes the budget to the parameter count the runtime actually accepts (variables such as `ARGS` emit both a name and a value per parameter), so a request PHP can fully parse is never falsely truncated. +- **Fail-closed:** when a variable's values are truncated at the cap, the affected deny rules fail **closed** (the request is blocked) rather than evaluating a partial value set. An attacker therefore cannot pad a payload past the cap to slip past a deny rule. +- **Explicit non-positive value throws:** passing an explicit cap below `1` raises `\InvalidArgumentException`, because a non-positive cap would fail every deny rule closed and block all traffic. ## Supported SecRule Syntax @@ -106,7 +122,7 @@ Phirewall supports a subset of the ModSecurity SecRule language: | `REQUEST_URI` | Full request URI including query string | | `REQUEST_METHOD` | HTTP method (GET, POST, etc.) | | `QUERY_STRING` | Raw query string | -| `REQUEST_FILENAME` | Request path without query string | +| `REQUEST_FILENAME` | Basename (final path segment), without query string | | `REQUEST_HEADERS` | All request header values | | `REQUEST_HEADERS_NAMES` | Names of all request headers | | `REQUEST_COOKIES` | All cookie values | @@ -132,7 +148,7 @@ Phirewall supports a subset of the ModSecurity SecRule language: | `id:N` | Rule ID (required, must be unique) | | `phase:N` | Processing phase (currently informational) | | `deny` | Block the request (required for the rule to trigger blocking) | -| `block` | Alias for `deny` -- both trigger blocking | +| `block` | Alias for `deny`, both trigger blocking | | `msg:'text'` | Human-readable description for logging | ### Line Continuation @@ -342,7 +358,7 @@ insert into ``` ::: warning -`@pmFromFile` includes path traversal protection. Paths containing `..` are rejected to prevent loading files outside the rules directory. +`@pmFromFile` paths are resolved relative to the rule file's directory, and `..` traversal segments are rejected. Treat SecRule files as trusted operator configuration; never build rule text from untrusted input, since the operand selects which file is read. ::: ## Architecture @@ -373,7 +389,7 @@ Each CRS variable maps to a `VariableCollectorInterface` implementation: | `REQUEST_URI` | `RequestUriCollector` | Full URI including query string | | `REQUEST_METHOD` | `RequestMethodCollector` | HTTP method | | `QUERY_STRING` | `QueryStringCollector` | Raw query string | -| `REQUEST_FILENAME` | `RequestFilenameCollector` | URI path without query string | +| `REQUEST_FILENAME` | `RequestFilenameCollector` | Basename (final path segment), without query string | | `REQUEST_HEADERS` | `RequestHeadersCollector` | All header values | | `REQUEST_HEADERS_NAMES` | `RequestHeadersNamesCollector` | Header names | | `REQUEST_COOKIES` | `RequestCookiesCollector` | All cookie values | @@ -396,7 +412,7 @@ Each CRS operator maps to an `OperatorEvaluatorInterface` implementation: Unsupported operators resolve to `UnsupportedOperatorEvaluator`, which never matches (safe no-op). ::: warning ReDoS protection: 8 KiB length guard on `@rx` -`RegexEvaluator` skips any value whose byte length exceeds 8,192 bytes — the value is treated as non-matching. This is an intentional trade-off: running PCRE on unbounded attacker-controlled input risks catastrophic backtracking that can freeze the PHP process (ReDoS). Skipping overlength values mirrors the behavior of standard WAFs such as ModSecurity's `SecRequestBodyLimit`. +`RegexEvaluator` skips any value whose byte length exceeds 8,192 bytes; the value is treated as non-matching. This is an intentional trade-off: running PCRE on unbounded attacker-controlled input risks catastrophic backtracking that can freeze the PHP process (ReDoS). Skipping overlength values mirrors the behavior of standard WAFs such as ModSecurity's `SecRequestBodyLimit`. In practice, legitimate request values (query parameters, header values, cookie values) are rarely larger than a few kilobytes. If you are matching multi-megabyte request bodies via `@rx`, consider pre-processing them before passing to the firewall. ::: @@ -485,7 +501,6 @@ Use `@pm` for simple keyword matching and `@rx` for complex patterns. `@pm` is s $config->fail2ban->add('persistent-attacker', threshold: 5, period: 60, ban: 86400, filter: fn($req) => true, - key: KeyExtractors::ip() ); ``` diff --git a/docs/features/rate-limiting.md b/docs/features/rate-limiting.md index 4815b4b..27129e6 100644 --- a/docs/features/rate-limiting.md +++ b/docs/features/rate-limiting.md @@ -4,7 +4,7 @@ outline: deep # Rate Limiting -Phirewall provides rate limiting (throttling) that returns `429 Too Many Requests` when limits are exceeded. Throttle rules are evaluated after safelists, blocklists, and Fail2Ban -- making them the last check before a request reaches your application. +Phirewall provides rate limiting (throttling) that returns `429 Too Many Requests` when limits are exceeded. Throttle rules are evaluated after safelists, blocklists, and Fail2Ban, and before Allow2Ban. Three throttle strategies are available: @@ -14,6 +14,10 @@ Three throttle strategies are available: | **Sliding window** | `sliding()` | Smooth rate limits without double-burst | | **Multi-window** | `multi()` | Combined burst + sustained limits | +::: tip Default key +The `key` argument on `add()`, `sliding()`, and `multi()` is optional. When omitted, the throttle keys on the client IP resolved by the Config's IP resolver (set via `Config::setIpResolver(KeyExtractors::clientIp($trustedProxyResolver))` behind a proxy), falling back to `KeyExtractors::ip()` (REMOTE_ADDR) when none is set. The resolver is read per request, so it can be set before or after adding rules. The examples below omit `key:` to use this default; pass an explicit `key:` only to key on something other than the client IP (a header, a username, and so on). +::: + ## Fixed Window Throttle The default strategy. Time is divided into fixed windows (e.g., 60-second intervals aligned to clock time) and each unique key gets a counter that resets at the end of the window. @@ -23,7 +27,7 @@ $config->throttles->add( string $name, int|Closure $limit, int|Closure $period, - Closure $key + ?Closure $key = null ): ThrottleSection ``` @@ -32,16 +36,16 @@ $config->throttles->add( | `$name` | `string` | Unique rule identifier | | `$limit` | `int\|Closure` | Max requests per window, or a [dynamic closure](#dynamic-limits) | | `$period` | `int\|Closure` | Window size in seconds, or a [dynamic closure](#dynamic-limits) | -| `$key` | `Closure` | `fn(ServerRequestInterface): ?string` -- return a key to group by, or `null` to skip | +| `$key` | `?Closure` | `fn(ServerRequestInterface): ?string`, return a key to group by, or `null` to skip. Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | ```php use Flowd\Phirewall\KeyExtractors; // 100 requests per minute per IP -$config->throttles->add('ip-limit', limit: 100, period: 60, key: KeyExtractors::ip()); +$config->throttles->add('ip-limit', limit: 100, period: 60); ``` -When the key closure returns `null`, the rule is skipped for that request. This lets you apply throttles conditionally -- only to certain paths, methods, or user types. +When the key closure returns `null`, the rule is skipped for that request. This lets you apply throttles conditionally, only to certain paths, methods, or user types. ```text Window 1 (00:00-00:59) Window 2 (01:00-01:59) Window 3 (02:00-02:59) @@ -61,7 +65,7 @@ $config->throttles->sliding( string $name, int|Closure $limit, int|Closure $period, - Closure $key + ?Closure $key = null ): ThrottleSection ``` @@ -73,7 +77,6 @@ $config->throttles->sliding( name: 'api-sliding', limit: 10, period: 60, - key: KeyExtractors::ip(), ); ``` @@ -104,7 +107,7 @@ The `multi()` method registers multiple throttle windows under a single logical $config->throttles->multi( string $name, array $windowLimits, - Closure $key + ?Closure $key = null ): ThrottleSection ``` @@ -112,16 +115,16 @@ $config->throttles->multi( |-----------|------|-------------| | `$name` | `string` | Logical name prefix | | `$windowLimits` | `array` | Map of period (seconds) => limit (max requests) | -| `$key` | `Closure` | Key extractor closure | +| `$key` | `?Closure` | Key extractor closure (shared across all windows). Omit to default to the client IP (Config IP resolver, else REMOTE_ADDR). | Each entry creates a sub-rule named `{$name}:{$period}s`. Windows are evaluated shortest-first (burst before sustained). ```php // 3 req/s burst + 60 req/min sustained $config->throttles->multi('api', [ - 1 => 3, // "api:1s" -- burst protection - 60 => 60, // "api:60s" -- sustained throughput -], KeyExtractors::ip()); + 1 => 3, // "api:1s" - burst protection + 60 => 60, // "api:60s" - sustained throughput +]); ``` A request is blocked if it exceeds **any** of the windows. This catches both rapid-fire bursts and slow-and-steady abuse. @@ -134,7 +137,7 @@ $config->throttles->multi('public-api', [ 1 => 5, // 5 req/s burst 60 => 200, // 200 req/min sustained 3600 => 5000, // 5000 req/hour daily budget -], KeyExtractors::ip()); +]); // Login endpoint with tight controls $config->throttles->multi('login', [ @@ -155,7 +158,7 @@ $config->throttles->add( string $name, int|Closure(ServerRequestInterface): int $limit, int|Closure(ServerRequestInterface): int $period, - Closure $key + ?Closure $key = null ): ThrottleSection ``` @@ -164,16 +167,18 @@ $config->throttles->add( ```php use Psr\Http\Message\ServerRequestInterface; -// Single rule handles all plans -- no need for separate rules per tier +// `plan` and `userId` are request attributes set by your auth middleware +// (e.g. $req->withAttribute('plan', ...)), not client headers a caller could forge. +// A single rule handles all plans; no need for separate rules per tier. $config->throttles->add( 'api', - fn(ServerRequestInterface $req): int => match ($req->getHeaderLine('X-Plan')) { + fn(ServerRequestInterface $req): int => match ($req->getAttribute('plan')) { 'enterprise' => 10000, 'pro' => 1000, default => 100, }, 60, - fn(ServerRequestInterface $req): ?string => $req->getHeaderLine('X-User-Id') ?: null + fn(ServerRequestInterface $req): ?string => $req->getAttribute('userId') ); ``` @@ -184,7 +189,7 @@ $config->throttles->add( $config->throttles->add( 'role-based', fn(ServerRequestInterface $req): int => - $req->getHeaderLine('X-Role') === 'admin' ? 100 : 5, + $req->getAttribute('role') === 'admin' ? 100 : 5, 60, fn(ServerRequestInterface $req): string => $req->getServerParams()['REMOTE_ADDR'] ?? '127.0.0.1' @@ -200,7 +205,6 @@ $config->throttles->add( 100, fn(ServerRequestInterface $req): int => (int) date('G') >= 9 && (int) date('G') < 17 ? 30 : 60, - KeyExtractors::ip() ); ``` @@ -217,7 +221,7 @@ Phirewall ships with common key extractors for typical rate limiting scenarios: | `KeyExtractors::ip()` | Client IP from `REMOTE_ADDR` | `?string` | | `KeyExtractors::clientIp($resolver)` | Client IP via trusted proxy resolver | `?string` | | `KeyExtractors::header('X-User-Id')` | Raw value of a specific header | `?string` | -| `KeyExtractors::hashedHeader('X-Api-Key')` | sha256 fingerprint of a header value | `?string` | +| `KeyExtractors::hashedHeader('X-Api-Key')` | sha256 fingerprint of a header value; preferred for credential-bearing headers (raw value never stored/emitted) | `?string` | | `KeyExtractors::method()` | HTTP method (uppercase) | `?string` | | `KeyExtractors::path()` | Request path (always returns a value, never skips) | `string` | | `KeyExtractors::userAgent()` | User-Agent header value | `?string` | @@ -252,7 +256,7 @@ $config->throttles->add('per-endpoint', limit: 50, period: 60, ## Tiered Rate Limits -Define multiple throttle rules with different limits for different use cases. All rules are evaluated independently -- a request must satisfy all of them. +Define multiple throttle rules with different limits for different use cases. All rules are evaluated independently; a request must satisfy all of them. ```php use Flowd\Phirewall\Http\TrustedProxyResolver; @@ -295,29 +299,10 @@ $config->throttles->add('search-endpoint', ## Per-User Limits -Differentiate between authenticated and anonymous traffic: +Enforce rate limits at the firewall on the client IP, which a caller cannot forge (behind a proxy, resolve it with `KeyExtractors::clientIp()` and a `TrustedProxyResolver`). Do not key a limit on a client-supplied header such as `X-User-Id` or `X-Api-Key`: a caller can rotate or drop it to land in a fresh counter on every request and never reach the limit. For genuine per-authenticated-user limits, enforce them behind your application's auth layer, where the user identity has been verified, rather than on a raw request header at the edge. -```php -// Authenticated user limits (higher) -$config->throttles->add('api-user', - limit: 1000, period: 3600, - key: KeyExtractors::header('X-User-Id') -); - -// Anonymous limits (lower, keyed by IP) -$config->throttles->add('api-anon', - limit: 100, period: 3600, - key: function ($req) use ($proxyResolver): ?string { - if ($req->getHeaderLine('X-User-Id') !== '') { - return null; // Skip authenticated requests - } - return $proxyResolver->resolve($req); - } -); -``` - -::: tip -Your application's authentication middleware should set headers like `X-User-Id` and `X-Plan` on the request before it reaches the Phirewall middleware. This allows clean separation of concerns. +::: warning Header keys are client-controlled +A throttle, fail2ban, or allow2ban rule keyed on a request header (`X-Api-Key`, `X-User-Id`, …) is only as trustworthy as that header. A client can rotate or drop the header to land in a fresh counter on every request and never reach the threshold (a trivial bypass). Key such rules on a value the client cannot freely change: the client IP (via `KeyExtractors::clientIp()` with a `TrustedProxyResolver`), the authenticated principal your auth layer sets *after* verifying it, or a composite of both. When you must key on a credential-bearing header, use `KeyExtractors::hashedHeader('X-Api-Key')`: the raw value otherwise reaches the ban registry and event payloads (and your logs) in cleartext. ::: ## Rate Limit Headers @@ -404,8 +389,10 @@ You can also set a global IP resolver so all IP-aware matchers use it automatica $config->setIpResolver(KeyExtractors::clientIp($resolver)); ``` +The resolver's `allowedHeaders` argument defaults to `['X-Forwarded-For']` (a single header); pass `['Forwarded']` explicitly if your stack emits the RFC 7239 header. All forwarded-header instances are folded into one chain and walked right to left, returning the first hop not in your trusted-proxy list (so the trusted-proxy ranges, not the number of header lines, are what prevent spoofing), and IPv6 addresses are canonicalized (IPv4-mapped peers match IPv4 rules). See [Client IP Behind Proxies](/getting-started#client-ip-behind-proxies) for the full behavior. + ::: danger -Never trust `X-Forwarded-For` without configuring trusted proxies. An attacker can spoof this header to bypass rate limiting entirely. +`KeyExtractors::ip()` keys on raw `REMOTE_ADDR`; behind a load balancer or CDN that is the proxy IP, so every client shares one throttle key and your limits stop working. Configure a `TrustedProxyResolver` so rate limits apply to the real client. And never trust `X-Forwarded-For` without configuring trusted proxies: an attacker can otherwise spoof this header to bypass rate limiting entirely. ::: ## Events diff --git a/docs/features/safelists-blocklists.md b/docs/features/safelists-blocklists.md index ed659dd..3ee9c3d 100644 --- a/docs/features/safelists-blocklists.md +++ b/docs/features/safelists-blocklists.md @@ -31,7 +31,7 @@ $config->safelists->add(string $name, Closure $callback): SafelistSection | Parameter | Type | Description | |-----------|------|-------------| | `$name` | `string` | Unique rule identifier | -| `$callback` | `Closure` | `fn(ServerRequestInterface): bool` -- return `true` to safelist | +| `$callback` | `Closure` | `fn(ServerRequestInterface): bool` - return `true` to safelist | ```php // Health check endpoint @@ -107,7 +107,7 @@ $config->safelists->trustedBots( ``` ```php -// Safelist Google, Bing, Baidu, DuckDuckGo, Yandex, and Apple bots +// Safelist Google, Bing, Yahoo, Baidu, DuckDuckGo, Yandex, and Apple bots $config->safelists->trustedBots(); // Add custom bots on top of the built-in list @@ -151,7 +151,7 @@ $config->blocklists->add(string $name, Closure $callback): BlocklistSection | Parameter | Type | Description | |-----------|------|-------------| | `$name` | `string` | Unique rule identifier | -| `$callback` | `Closure` | `fn(ServerRequestInterface): bool` -- return `true` to block | +| `$callback` | `Closure` | `fn(ServerRequestInterface): bool` - return `true` to block | ```php // Block admin panel probes @@ -222,10 +222,10 @@ $config->blocklists->knownScanners( | `$name` | `string` | Rule identifier (default: `'known-scanners'`) | | `$patterns` | `?list` | UA substrings to block. `null` uses the built-in list | -The built-in list covers: sqlmap, nikto, nmap, masscan, zmeu, havij, acunetix, nessus, openvas, w3af, dirbuster, gobuster, wfuzz, hydra, medusa, burpsuite, skipfish, whatweb, metasploit, nuclei, ffuf, feroxbuster, joomscan, and wpscan. +The built-in list covers: sqlmap, nikto, nmap, masscan, zmeu, havij, acunetix, nessus, openvas, w3af, dirbuster, gobuster, wfuzz, hydra, medusa, burpsuite, skipfish, whatweb, metasploit, nuclei, ffuf, feroxbuster, joomscan, and wpscan (26 substring patterns in total; Burp Suite and Metasploit are each matched under two spellings). ```php -// Use defaults -- blocks 24+ known attack tools +// Use defaults: blocks 24 known attack tools $config->blocklists->knownScanners(); // Add custom patterns on top of defaults @@ -248,14 +248,14 @@ Block requests missing standard HTTP headers that real browsers typically send. ```php $config->blocklists->suspiciousHeaders( string $name = 'suspicious-headers', - array $requiredHeaders = [] + ?array $requiredHeaders = null ): BlocklistSection ``` | Parameter | Type | Description | |-----------|------|-------------| | `$name` | `string` | Rule identifier (default: `'suspicious-headers'`) | -| `$requiredHeaders` | `list` | Headers that must be present. Empty uses defaults | +| `$requiredHeaders` | `?list` | Headers that must be present. `null` uses the default set (`Accept`, `Accept-Language`, `Accept-Encoding`). A non-null list replaces the defaults entirely; do NOT pass `[]` expecting defaults (it requires zero headers and never matches) | Default required headers: `Accept`, `Accept-Language`, `Accept-Encoding`. @@ -316,6 +316,10 @@ The file format is one entry per line, with optional expiry and timestamp fields 203.0.113.50|1711929600|1711843200 ``` +::: warning Protect the blocklist file +This file holds live security state. Store it **outside your web document root** and restrict access to the application user (e.g. `0750` directory, `0640` file). A world-readable copy leaks every banned/blocked address; a writable one lets a local attacker edit the list, including removing their own ban. +::: + ### OWASP Core Rule Set Register OWASP CRS rules as a blocklist to detect SQL injection, XSS, and other attacks: @@ -335,7 +339,7 @@ See [OWASP CRS](/features/owasp-crs) for full details on loading rules from file ## Pattern Backends -For dynamic, data-driven blocklists, use pattern backends instead of hardcoded closures. Pattern backends support IP addresses, CIDR ranges, path patterns, header patterns, and regex matching -- all with optional expiration. +For dynamic, data-driven blocklists, use pattern backends instead of hardcoded closures. Pattern backends support IP addresses, CIDR ranges, path patterns, header patterns, and regex matching, all with optional expiration. ### In-Memory Pattern Blocklist @@ -377,6 +381,10 @@ $backend->append(new PatternEntry( )); ``` +::: warning Protect the blocklist file +This file holds live security state. Store it **outside your web document root** and restrict access to the application user (e.g. `0750` directory, `0640` file). A world-readable copy leaks every banned/blocked address; a writable one lets a local attacker edit the list, including removing their own ban. +::: + ### Two-Step Registration When you need to share a backend between multiple rules or keep a reference for later modification, use the two-step approach: @@ -464,6 +472,10 @@ $entries = array_map( $config->blocklists->patternBlocklist('threat-intel', $entries); ``` +::: tip +Pattern backends are also the serializable, database-friendly equivalent of file-backed lists. To keep a block catalogue outside code (in a settings table or config service) and hot-reload it on change, express it as a [Portable Config](/advanced/portable-config). +::: + ## IP Resolution {#ip-resolution} Both `safelists->ip()` and `blocklists->ip()` respect the global IP resolver set on the Config object. This is important when your application runs behind a reverse proxy or load balancer. @@ -489,8 +501,15 @@ $customResolver = fn($req) => $req->getHeaderLine('CF-Connecting-IP') ?: null; $config->safelists->ip('cloudflare-office', '203.0.113.10', ipResolver: $customResolver); ``` -::: warning -Never trust `X-Forwarded-For` without configuring trusted proxies. An attacker can spoof this header to bypass IP-based rules. +### IPv6 canonicalization + +`IpMatcher` (which backs `safelists->ip()` and `blocklists->ip()`) canonicalizes addresses before matching, so you write each rule once: + +- An **IPv4-mapped IPv6** peer such as `::ffff:203.0.113.7` (the form dual-stack PHP-FPM pools often surface for IPv4 clients) collapses to its embedded IPv4 form. A rule written as `203.0.113.7` (or a CIDR like `203.0.113.0/24`) matches it, and an attacker cannot slip past an IPv4 blocklist entry by presenting the mapped form. +- **Alternate IPv6 spellings** (expanded `2001:0db8:0:0:0:0:0:1` vs compressed `2001:db8::1`, upper vs lower case) all resolve to one canonical identity, so a rule in any spelling matches all of them. + +::: danger +`KeyExtractors::ip()` reads raw `REMOTE_ADDR`; behind a proxy or CDN that is the proxy's address, so IP rules match the proxy rather than the client. Set a client-IP resolver (above) in that case. And never trust `X-Forwarded-For` without configuring trusted proxies; an attacker can otherwise spoof this header to bypass IP-based rules. See [Client IP Behind Proxies](/getting-started#client-ip-behind-proxies). ::: ## Evaluation Order @@ -500,11 +519,12 @@ The complete evaluation order within Phirewall is: | Order | Layer | Action on Match | |-------|-------|-----------------| | 1 | Track | Count (passive, never blocks) | -| 2 | **Safelist** | **Allow** -- bypass all remaining checks | -| 3 | **Blocklist** | **Block** -- 403 Forbidden | -| 4 | Fail2Ban | Block -- 403 Forbidden | -| 5 | Throttle | Block -- 429 Too Many Requests | -| 6 | Pass | Request reaches your application | +| 2 | **Safelist** | **Allow** - bypass all remaining checks | +| 3 | **Blocklist** | **Block** - 403 Forbidden | +| 4 | Fail2Ban | Block - 403 Forbidden | +| 5 | Throttle | Block - 429 Too Many Requests | +| 6 | Allow2Ban | Block - 403 Forbidden | +| 7 | Pass | Request reaches your application | ::: warning Rules within each layer are evaluated in the order they were added. Place more specific rules before general ones if ordering matters. diff --git a/docs/features/storage.md b/docs/features/storage.md index d4f4b24..c19826d 100644 --- a/docs/features/storage.md +++ b/docs/features/storage.md @@ -34,7 +34,7 @@ $config = new Config($cache); - Zero external dependencies - Data resets on every request in PHP-FPM (each request is a new process) -- Data persists for the lifetime of a long-running process (CLI, Swoole, RoadRunner) +- Under long-running worker runtimes (Swoole, RoadRunner, FrankenPHP worker mode, Octane) the array lives for the worker's lifetime: state is **not** shared across workers (each worker is a separate process, so counters and bans fragment) and the array only evicts already-expired entries, so it is not a memory cap. This makes it unsafe as a firewall store there; see the warning below. - Implements both `CacheInterface` (PSR-16) and `CounterStoreInterface` - Automatic expired entry purging every 1000 operations @@ -67,15 +67,20 @@ $clock->advance(60); // Move forward 60 seconds - Unit tests and integration tests - Development and prototyping - Single-script CLI tools -- Long-running processes where per-process state is acceptable ### When NOT to Use - PHP-FPM production (counters reset each request) - Multi-server deployments (no shared state) +- Long-running worker runtimes (Swoole, RoadRunner, FrankenPHP worker mode, Octane): state fragments across workers and grows unbounded within a worker ::: warning -In coroutine-based servers (Swoole), `InMemoryCache` may experience race conditions under high concurrency because it uses plain PHP arrays with no locking. Use `RedisCache` or `ApcuCache` for production Swoole deployments. +Two distinct problems make `InMemoryCache` unsuitable for the firewall under long-running worker runtimes (Swoole, RoadRunner, FrankenPHP worker mode, Octane): + +1. **No shared state across workers.** Each worker is a separate OS process with its own array, so a counter or ban set in one worker is invisible to the others. The effective rate limit becomes roughly N times the configured value (N workers), and a client banned on one worker is not banned on the rest. +2. **Coroutine races within a worker.** In coroutine servers like Swoole, the plain PHP arrays have no locking, so concurrent coroutines in the same worker can race. + +Use a shared store under these runtimes: `RedisCache` (preferred) or `PdoCache`. Note that `ApcuCache` does **not** solve problem 1: APCu memory is per process, so counters and bans still fragment across workers. ::: ## ApcuCache @@ -213,9 +218,7 @@ $redis = new PredisClient([ ### Fail-Open Behavior -RedisCache is designed to fail open. If Redis is unavailable, `increment()` returns `0`, which means no throttle or Fail2Ban rule will trigger. This prevents Redis outages from blocking all traffic to your application. - -When `increment()` catches a Redis error, it emits an `E_USER_WARNING` via `trigger_error()` before returning `0`. This gives you visibility into cache failures without breaking the request flow. You can capture these warnings in your application's error handler or logging setup: +On a Redis error, `increment()` emits a diagnostic `E_USER_WARNING` via `trigger_error()` and then re-throws the underlying exception. Fail-open is applied one layer up: the middleware catches the error and, because `failOpen` is on by default (toggle it with `Config::setFailOpen()`), lets the request through and dispatches a `FirewallError` event. So a Redis outage does not block traffic under the default policy; with `setFailOpen(false)` the same outage fails closed and the exception surfaces as a 500. The warning message begins with `RedisCache::increment()`, so you can capture it in your application's error handler or logging setup: ```php set_error_handler(function (int $errno, string $message): bool { @@ -334,7 +337,7 @@ CREATE INDEX idx_phirewall_expires ON phirewall_cache (expires_at); - Prepared statement caching for optimal performance - Full persistence across application and server restarts - Implements both `CacheInterface` and `CounterStoreInterface` -- No additional dependencies -- uses PHP's built-in PDO extension +- No additional dependencies - uses PHP's built-in PDO extension ### When to Use @@ -422,42 +425,77 @@ Need multi-server support? ## Cache Key Structure -Keys follow the format `{prefix}:{type}:{rule}:{normalized_key}`: +Keys follow the format `{prefix}.{type}.{rule}.{hashed_key}`. The final segment is the SHA-256 hex of the discriminator (IP, header value, …), so it is fixed-length and you cannot derive it from the plaintext value: ``` -phirewall:throttle:ip-limit:192.168.1.100 -phirewall:fail2ban:fail:login:192.168.1.100 -phirewall:fail2ban:ban:login:192.168.1.100 -phirewall:allow2ban:hit:high-volume:192.168.1.100 -phirewall:allow2ban:ban:high-volume:192.168.1.100 -phirewall:track:api-calls:user-123 +phirewall.throttle.ip-limit. +phirewall.fail2ban.fail.login. +phirewall.fail2ban.ban.login. +phirewall.allow2ban.hit.high-volume. +phirewall.allow2ban.ban.high-volume. +phirewall.track.api-calls. ``` Use `$config->setKeyPrefix('myapp')` to change the prefix and avoid collisions when sharing a cache instance. +::: tip Key segments join with `.` +`CacheKeyGenerator` (and the trusted-bot rDNS cache) join key segments with `.` rather than `:`. PSR-16 reserves `:` for the *cache implementation*, not its callers, so joining with `.` keeps Phirewall's own keys spec-compliant. `RedisCache`'s own namespace prefix (default `Phirewall:`) is the backend's keyspace, applied *after* the public key, and is unaffected. +::: + See [Discriminator Normalizer](/advanced/discriminator-normalizer) for details on how keys are sanitized. +## Cache Key Validation (PSR-16) + +All four bundled backends (`InMemoryCache`, `ApcuCache`, `RedisCache`, and `PdoCache`) validate every key passed to their PSR-16 surface (`get`, `set`, `has`, `delete`, `getMultiple`, `setMultiple`, `deleteMultiple`). An invalid key throws `Flowd\Phirewall\Store\InvalidCacheKeyException`, which implements `Psr\SimpleCache\InvalidArgumentException` so it can be caught through the standard PSR-16 interface. + +A key is rejected when it is: + +- an **empty string**; +- a string containing a **reserved character**: any of `{}()/\@:` (the set PSR-16 reserves for cache implementations); +- a string containing a **control or whitespace character**; or +- for the multi-key methods (`getMultiple` / `setMultiple` / `deleteMultiple`), a **non-string key**. + +```php +use Flowd\Phirewall\Store\InMemoryCache; +use Psr\SimpleCache\InvalidArgumentException; + +$cache = new InMemoryCache(); + +try { + $cache->get('user:42'); // ':' is reserved +} catch (InvalidArgumentException $exception) { + // Flowd\Phirewall\Store\InvalidCacheKeyException +} +``` + +Per PSR-16 there is **no upper length limit**: keys longer than the mandated 64-character minimum remain valid. The rules live in a shared `KeyValidationTrait`, so they are identical across every bundled backend. + +::: tip +Phirewall's own keys never trip this validation: the [key structure](#cache-key-structure) above uses only safe characters, and the [discriminator normalizer](/advanced/discriminator-normalizer) sanitizes the variable part of each key. Validation matters mainly when you reuse a bundled backend as a general-purpose PSR-16 cache in your own code. +::: + ## Monitoring ### Redis -Redis keys have two layers of prefixing: the RedisCache namespace (default `Phirewall:`) and the firewall key prefix (default `phirewall`). For example, a throttle counter key looks like `Phirewall:phirewall:throttle:ip-limit:192.168.1.100`. You can change the Redis namespace via `new RedisCache($redis, 'custom:')` and the key prefix via `$config->setKeyPrefix('custom')`. +Redis keys have two layers of prefixing: the RedisCache namespace (default `Phirewall:`) and the firewall key prefix (default `phirewall`). For example, a throttle counter key looks like `Phirewall:phirewall.throttle.ip-limit.`: the `Phirewall:` namespace (note the reserved `:`, which only the backend may use) followed by the `.`-joined public key, whose final segment is the SHA-256 hex of the discriminator. You can change the Redis namespace via `new RedisCache($redis, 'custom:')` and the key prefix via `$config->setKeyPrefix('custom')`. ```bash # Watch Phirewall keys in real-time redis-cli monitor | grep Phirewall -# Count Phirewall keys (use SCAN in production -- see warning below) +# Count Phirewall keys (use SCAN in production, see warning below) redis-cli keys "Phirewall:*" | wc -l # Check memory usage redis-cli info memory -# Check a specific counter -redis-cli get "Phirewall:phirewall:throttle:ip-limit:192.168.1.100" -redis-cli ttl "Phirewall:phirewall:throttle:ip-limit:192.168.1.100" +# List all counters for a rule (use SCAN, not KEYS) +redis-cli --scan --pattern "Phirewall:phirewall.throttle.ip-limit.*" ``` +You cannot look up a specific client by plaintext IP; the discriminator segment is hashed. + ::: danger The `KEYS` command scans every key in Redis and blocks the server during execution. **Never use `KEYS` in production.** Use `SCAN` with a cursor instead: ```bash @@ -468,7 +506,7 @@ redis-cli --scan --pattern "Phirewall:*" | wc -l ### APCu ```php -$iterator = new APCuIterator('/^phirewall:/'); +$iterator = new APCuIterator('/^phirewall\./'); foreach ($iterator as $item) { printf("%s = %s (TTL: %ds)\n", $item['key'], diff --git a/docs/getting-started.md b/docs/getting-started.md index e6b5985..4833abe 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -37,7 +37,7 @@ composer require monolog/monolog ## Step 1: Choose a Storage Backend -Phirewall needs a PSR-16 (PHP Standard Recommendation for Simple Caching) cache for storing counters and ban states. Pick the backend that fits your deployment. +Phirewall needs a PSR-16 (PHP Standard Recommendation for Simple Caching) cache for storing counters and ban states. Pick the backend that fits your deployment. If you are just trying Phirewall locally, start with `InMemoryCache`; choose a shared backend (Redis or PDO) before production. ::: code-group @@ -100,11 +100,7 @@ The `Config` constructor accepts: ## Step 3: Define Rules -All imports needed for the examples below: - -```php -use Flowd\Phirewall\KeyExtractors; -``` +Every safelist, blocklist, throttle, fail2ban, and track callback receives the incoming PSR-7 `ServerRequestInterface`, so you can branch on the path, method, headers, and so on. The snippets below use the `$config` from Step 2; the `$req` parameter is that request. ### Safelists (Allow Trusted Traffic) @@ -117,7 +113,8 @@ $config->safelists->add('metrics', fn($req) => $req->getUri()->getPath() === '/m // Safelist specific IPs or CIDR ranges $config->safelists->ip('office', ['10.0.0.0/8', '192.168.1.0/24']); -// Safelist verified search engine bots (Googlebot, Bingbot, etc.) +// Safelist verified search engine bots (Googlebot, Bingbot, etc.). +// Verified via reverse DNS; pass a cache to skip repeat lookups (see Bot Detection). $config->safelists->trustedBots(); ``` @@ -144,26 +141,26 @@ $config->blocklists->suspiciousHeaders(); ### Throttling (Rate Limiting) -Throttled requests receive `429 Too Many Requests` with a `Retry-After` header. +Throttled requests receive `429 Too Many Requests` with a `Retry-After` header. Counting rules (throttle, fail2ban, allow2ban, track) count requests against a *key*, an identity that defaults to the client IP (`KeyExtractors::ip()`, which reads `REMOTE_ADDR`). Pass a `key:` with a `KeyExtractors::*` callable to count against something else; behind a proxy, set the real client IP with a resolver (see [Client IP Behind Proxies](#client-ip-behind-proxies)). ```php // 100 requests per minute per IP -$config->throttles->add('ip-minute', limit: 100, period: 60, key: KeyExtractors::ip()); +$config->throttles->add('ip-minute', limit: 100, period: 60); // Sliding window (prevents double-burst at window boundaries) -$config->throttles->sliding('api-sliding', limit: 100, period: 60, key: KeyExtractors::ip()); +$config->throttles->sliding('api-sliding', limit: 100, period: 60); // Multi-window (burst + sustained limits in a single call) $config->throttles->multi('api', [ 1 => 5, // 5 req/s burst limit 60 => 100, // 100 req/min sustained limit -], KeyExtractors::ip()); +]); -// Dynamic limits based on request properties +// Dynamic limit from a request attribute your auth middleware sets server-side +// ($req->withAttribute('role', ...)), never a forgeable header $config->throttles->add('role-based', - limit: fn($req) => $req->getHeaderLine('X-Role') === 'admin' ? 1000 : 100, + limit: fn($req) => $req->getAttribute('role') === 'admin' ? 1000 : 100, period: 60, - key: KeyExtractors::ip() ); // Enable standard rate limit headers @@ -174,7 +171,7 @@ See [Rate Limiting](/features/rate-limiting) and [Dynamic Throttle](/advanced/dy ### Fail2Ban (Brute Force Protection) -Automatically ban clients after repeated failures. The filter evaluates each incoming request; matching requests increment a failure counter, and the client is banned as soon as the count reaches the threshold (e.g., `threshold: 5` bans on the 5th matching request — that request is itself blocked). +Automatically ban clients after repeated failures. The filter evaluates each incoming request; matching requests increment a failure counter, and the client is banned as soon as the count reaches the threshold (e.g., `threshold: 5` bans on the 5th matching request; that request is itself blocked). ```php // Ban IPs that POST to /login more than 5 times in 5 minutes @@ -184,7 +181,6 @@ $config->fail2ban->add('login-abuse', ban: 3600, filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); ``` @@ -200,7 +196,6 @@ $config->allow2ban->add('high-volume', threshold: 1000, period: 60, banSeconds: 3600, - key: KeyExtractors::ip() ); ``` @@ -214,14 +209,12 @@ Track rules count requests passively without blocking. Use them for dashboards, $config->tracks->add('login-attempts', period: 3600, filter: fn($req) => $req->getUri()->getPath() === '/login' && $req->getMethod() === 'POST', - key: KeyExtractors::ip() ); // Track with a threshold for alerting $config->tracks->add('suspicious-burst', period: 60, filter: fn($req) => $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip(), limit: 10, // TrackHit event includes thresholdReached flag at 10+ hits ); ``` @@ -257,12 +250,47 @@ See [PSR-17 Factories](/advanced/psr17) for custom response configuration. ## Step 5: Add to Your Application +::: warning +The framework tabs below use `ApcuCache`, which needs the `ext-apcu` extension and throws without it. On a machine without APCu, swap in `new InMemoryCache()` to try it out, or `RedisCache` / `PdoCache` for shared production storage (see [Step 1](#step-1-choose-a-storage-backend)). +::: + ::: code-group -```php [PSR-15] -// Any PSR-15 compatible stack (Mezzio, custom dispatchers, etc.) -// The middleware from Step 4 plugs directly into your pipeline. -$app->pipe($middleware); +```php [PSR-15 / Plain PHP] +// In a PSR-15 pipeline (Mezzio, custom dispatchers): $app->pipe($middleware); +// +// With no framework, a minimal public/index.php front controller. Serve with: +// php -S localhost:8080 public/index.php +// Requires: composer require nyholm/psr7 nyholm/psr7-server +use Nyholm\Psr7\Factory\Psr17Factory; +use Nyholm\Psr7Server\ServerRequestCreator; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; + +// $middleware is the Phirewall middleware from Step 4. +$psr17 = new Psr17Factory(); +$request = (new ServerRequestCreator($psr17, $psr17, $psr17, $psr17))->fromGlobals(); + +// Your application handler; it runs only if Phirewall lets the request through. +$appHandler = new class ($psr17) implements RequestHandlerInterface { + public function __construct(private Psr17Factory $psr17) {} + public function handle(ServerRequestInterface $request): ResponseInterface + { + return $this->psr17->createResponse(200); + } +}; + +$response = $middleware->process($request, $appHandler); + +// Emit the PSR-7 response. +http_response_code($response->getStatusCode()); +foreach ($response->getHeaders() as $name => $values) { + foreach ($values as $value) { + header("$name: $value", false); + } +} +echo $response->getBody(); ``` ```php [Symfony] @@ -311,17 +339,14 @@ class PhirewallFactory filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Rate limiting $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); $psr17 = new Psr17Factory(); @@ -331,67 +356,98 @@ class PhirewallFactory } } -// 3. Register in config/services.yaml: +// 3. Register the middleware factory in config/services.yaml: // services: // Flowd\Phirewall\Middleware: // factory: ['@App\Factory\PhirewallFactory', 'create'] +// The listener below auto-registers via #[AsEventListener] + +// autoconfigure; the bridge factory interfaces autowire from the +// symfony/psr-http-message-bridge + nyholm/psr7 packages. // -// 4. Create src/EventSubscriber/PhirewallSubscriber.php: +// 4. Create src/EventListener/PhirewallListener.php +// A two-phase listener: it blocks on kernel.request, and re-attaches +// the X-RateLimit-* headers on kernel.response (a status-only +// subscriber would silently drop them on the allowed 200 path). +// NOTE: the bridge runs Phirewall with a pass-through handler, so the +// RequestContext attribute for app-recorded fail2ban/allow2ban signals +// is NOT visible to your controllers. Use pre-handler rule filters. -namespace App\EventSubscriber; +namespace App\EventListener; use Flowd\Phirewall\Middleware as PhirewallMiddleware; -use Nyholm\Psr7\Factory\Psr17Factory; -use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; -use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; -use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Psr\Http\Message\ResponseFactoryInterface; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Symfony\Bridge\PsrHttpMessage\HttpFoundationFactoryInterface; +use Symfony\Bridge\PsrHttpMessage\HttpMessageFactoryInterface; +use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\RequestEvent; +use Symfony\Component\HttpKernel\Event\ResponseEvent; use Symfony\Component\HttpKernel\KernelEvents; -class PhirewallSubscriber implements EventSubscriberInterface +final class PhirewallListener { + private const HEADERS_ATTRIBUTE = '_phirewall_headers'; + public function __construct( private readonly PhirewallMiddleware $middleware, + private readonly HttpMessageFactoryInterface $psrHttpFactory, + private readonly HttpFoundationFactoryInterface $httpFoundationFactory, + private readonly ResponseFactoryInterface $responseFactory, ) {} - public static function getSubscribedEvents(): array - { - return [KernelEvents::REQUEST => ['onKernelRequest', 256]]; - } - + #[AsEventListener(event: KernelEvents::REQUEST, priority: 256)] public function onKernelRequest(RequestEvent $event): void { if (!$event->isMainRequest()) { return; } - - $psr17 = new Psr17Factory(); - $psrFactory = new PsrHttpFactory($psr17, $psr17, $psr17, $psr17); - $httpFoundationFactory = new HttpFoundationFactory(); - - $psrRequest = $psrFactory->createRequest($event->getRequest()); - $psrResponse = $this->middleware->process( - $psrRequest, - new class ($psr17) implements \Psr\Http\Server\RequestHandlerInterface { - public function __construct(private readonly Psr17Factory $responseFactory) {} - public function handle( - \Psr\Http\Message\ServerRequestInterface $request, - ): \Psr\Http\Message\ResponseInterface { - return $this->responseFactory->createResponse(200); - } + $psrRequest = $this->psrHttpFactory->createRequest($event->getRequest()); + $probe = new class ($this->responseFactory) implements RequestHandlerInterface { + private bool $invoked = false; + public function __construct(private readonly ResponseFactoryInterface $responseFactory) {} + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->invoked = true; + return $this->responseFactory->createResponse(200); } - ); + public function wasInvoked(): bool { return $this->invoked; } + }; + $psrResponse = $this->middleware->process($psrRequest, $probe); + if (!$probe->wasInvoked()) { + // The handler never ran: Phirewall produced a block/throttle response. + $event->setResponse($this->httpFoundationFactory->createResponse($psrResponse)); + return; + } + // Allowed: carry Phirewall's rate-limit headers onto the real response. + if ($psrResponse->getHeaders() !== []) { + $event->getRequest()->attributes->set(self::HEADERS_ATTRIBUTE, $psrResponse->getHeaders()); + } + } - if ($psrResponse->getStatusCode() !== 200) { - $event->setResponse( - $httpFoundationFactory->createResponse($psrResponse) - ); + #[AsEventListener(event: KernelEvents::RESPONSE)] + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + /** @var array> $headers */ + $headers = $event->getRequest()->attributes->get(self::HEADERS_ATTRIBUTE, []); + foreach ($headers as $name => $values) { + $event->getResponse()->headers->set($name, $values); } } + } ``` ```php [Laravel] +// Flowd\Phirewall\Middleware is a PSR-15 middleware (process(...)), NOT a +// Laravel middleware (handle($request, $next)); registering the class +// directly throws. A thin bridge middleware (step 3) adapts it. +// Install the bridge: composer require symfony/psr-http-message-bridge nyholm/psr7 +// // 1. Create app/Providers/PhirewallServiceProvider.php: namespace App\Providers; @@ -403,11 +459,20 @@ use Flowd\Phirewall\Store\ApcuCache; use Illuminate\Support\ServiceProvider; use Nyholm\Psr7\Factory\Psr17Factory; use Psr\Http\Message\ServerRequestInterface; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; class PhirewallServiceProvider extends ServiceProvider { public function register(): void { + // PSR-7/PSR-17 bridge factories used by the bridge middleware. + // HttpFoundationFactory autowires (no-arg constructor). + $this->app->singleton(Psr17Factory::class); + $this->app->singleton(PsrHttpFactory::class, fn ($app) => new PsrHttpFactory( + $app->make(Psr17Factory::class), $app->make(Psr17Factory::class), + $app->make(Psr17Factory::class), $app->make(Psr17Factory::class), + )); + $this->app->singleton(PhirewallMiddleware::class, function () { // ApcuCache requires ext-apcu (zero config, single-server) // For multi-server: use RedisCache with predis/predis @@ -434,17 +499,14 @@ class PhirewallServiceProvider extends ServiceProvider filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Rate limiting $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); $psr17 = new Psr17Factory(); @@ -455,14 +517,74 @@ class PhirewallServiceProvider extends ServiceProvider } } -// 2. Register in bootstrap/app.php (Laravel 11+): -// ->withMiddleware(function (Middleware $middleware) { -// $middleware->prepend(\Flowd\Phirewall\Middleware::class); +// 2. Register the provider in bootstrap/providers.php (Laravel 11+) +// or the providers array in config/app.php (Laravel 10). +// +// 3. Create app/Http/Middleware/Phirewall.php, the bridge. +// Uses a probe handler so the real Laravel response is never +// round-tripped through PSR-7 (preserves StreamedResponse / +// BinaryFileResponse) and copies Phirewall's X-RateLimit-* headers +// onto the allowed response. +// NOTE: the bridge runs Phirewall with a probe handler, so the +// RequestContext attribute for app-recorded fail2ban/allow2ban signals +// is NOT visible to your controllers. Use pre-handler rule filters. + +namespace App\Http\Middleware; + +use Closure; +use Flowd\Phirewall\Middleware as PhirewallEngine; +use Illuminate\Http\Request; +use Nyholm\Psr7\Factory\Psr17Factory; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory; +use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory; +use Symfony\Component\HttpFoundation\Response; + +final readonly class Phirewall +{ + public function __construct( + private PhirewallEngine $firewall, + private PsrHttpFactory $psrHttpFactory, + private HttpFoundationFactory $httpFoundationFactory, + private Psr17Factory $psr17, + ) {} + + public function handle(Request $request, Closure $next): Response + { + $psrRequest = $this->psrHttpFactory->createRequest($request); + $probe = new class($this->psr17) implements RequestHandlerInterface { + private bool $invoked = false; + public function __construct(private readonly Psr17Factory $responseFactory) {} + public function handle(ServerRequestInterface $request): ResponseInterface + { + $this->invoked = true; + return $this->responseFactory->createResponse(); + } + public function wasInvoked(): bool { return $this->invoked; } + }; + $psrResponse = $this->firewall->process($psrRequest, $probe); + if (! $probe->wasInvoked()) { + return $this->httpFoundationFactory->createResponse($psrResponse); + } + $response = $next($request); + foreach ($psrResponse->getHeaders() as $name => $values) { + $response->headers->set($name, $values); + } + return $response; + } +} + +// 4. Register the bridge middleware (outermost): +// bootstrap/app.php (Laravel 11/12): +// ->withMiddleware(function (Middleware $middleware): void { +// $middleware->prepend(\App\Http\Middleware\Phirewall::class); // }) // -// Or in app/Http/Kernel.php (Laravel 10): +// Or app/Http/Kernel.php (Laravel 10 and earlier): // protected $middleware = [ -// \Flowd\Phirewall\Middleware::class, +// \App\Http\Middleware\Phirewall::class, // // ... // ]; ``` @@ -503,17 +625,14 @@ $config->fail2ban->add('login-abuse', filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Rate limiting $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); // Add Phirewall LAST (Slim LIFO = executes first) @@ -524,7 +643,7 @@ $app->run(); ``` ```php [Mezzio] -// Mezzio uses PSR-15 natively -- pipe Phirewall first. +// Mezzio uses PSR-15 natively. Pipe Phirewall right after the ErrorHandler. // In config/autoload/phirewall.global.php: // return [ @@ -576,17 +695,14 @@ class PhirewallMiddlewareFactory filter: fn(ServerRequestInterface $req): bool => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // Rate limiting $config->throttles->add('burst', limit: 30, period: 5, - key: KeyExtractors::ip() ); $config->throttles->add('global', limit: 1000, period: 60, - key: KeyExtractors::ip() ); $psr17 = new Psr17Factory(); @@ -596,15 +712,16 @@ class PhirewallMiddlewareFactory } } -// In config/pipeline.php: -// $app->pipe(\Flowd\Phirewall\Middleware::class); // outermost +// In config/pipeline.php (pipe the ErrorHandler first, then Phirewall): +// $app->pipe(\Laminas\Stratigility\Middleware\ErrorHandler::class); +// $app->pipe(\Flowd\Phirewall\Middleware::class); // $app->pipe(\Mezzio\Router\Middleware\RouteMiddleware::class); // $app->pipe(\Mezzio\Router\Middleware\DispatchMiddleware::class); ``` ::: -> **Middleware ordering:** Ensure Phirewall runs as the outermost middleware so it executes before your application handles the request. See the [Examples](/examples#framework-integration) page for more detailed, production-ready integrations. +> **Middleware ordering:** Pipe Phirewall as early as possible, but after your error-handling middleware. Phirewall does not wrap the downstream handler, so a handler exception must be able to reach the error handler. See the [Examples](/examples#framework-integration) page for more detailed, production-ready integrations. ## Complete Example @@ -618,7 +735,6 @@ declare(strict_types=1); require __DIR__ . '/vendor/autoload.php'; use Flowd\Phirewall\Config; -use Flowd\Phirewall\KeyExtractors; use Flowd\Phirewall\Middleware; use Flowd\Phirewall\Store\InMemoryCache; use Nyholm\Psr7\Factory\Psr17Factory; @@ -645,7 +761,7 @@ $config->blocklists->add('scanner-probe', fn($req) => str_starts_with($req->getU $config->blocklists->knownScanners(); // Rate limit: 10 requests per minute per IP -$config->throttles->add('ip-limit', limit: 10, period: 60, key: KeyExtractors::ip()); +$config->throttles->add('ip-limit', limit: 10, period: 60); // Fail2Ban: Ban IPs that POST to /login more than 3 times in 2 minutes $config->fail2ban->add('login', @@ -654,7 +770,6 @@ $config->fail2ban->add('login', ban: 300, filter: fn($req) => $req->getMethod() === 'POST' && $req->getUri()->getPath() === '/login', - key: KeyExtractors::ip() ); // 3. Create middleware @@ -690,12 +805,12 @@ Request --> Track (passive) --> Safelist --> Blocklist --> Fail2Ban --> Throttle The evaluation order is: 1. **Track** rules are always evaluated first (passive counting, never blocks) -2. **Safelist** -- if matched, the request bypasses all remaining checks -3. **Blocklist** -- if matched, the request is rejected with `403` -4. **Fail2Ban** -- if the client is already banned, `403`; if the filter matches, increment failure counter -5. **Throttle** -- if the counter exceeds the limit, `429` with `Retry-After` -6. **Allow2Ban** -- if the client has exceeded the request threshold, `403` with `Retry-After` -7. **Pass** -- the request reaches your application +2. **Safelist**: if matched, the request bypasses all remaining checks +3. **Blocklist**: if matched, the request is rejected with `403` +4. **Fail2Ban**: if the client is already banned, `403`; if the filter matches, increment the failure counter +5. **Throttle**: if the counter exceeds the limit, `429` with `Retry-After` +6. **Allow2Ban**: if the client has exceeded the request threshold, `403` with `Retry-After` +7. **Pass**: the request reaches your application ## Fail-Open / Fail-Closed @@ -760,13 +875,31 @@ $config->throttles->add('api', limit: 100, period: 60, $config->setIpResolver(KeyExtractors::clientIp($resolver)); ``` -::: warning -Never trust `X-Forwarded-For` without configuring trusted proxies. An attacker can spoof this header to bypass rate limiting. +::: danger Set a resolver behind a proxy, or every client shares one key +`KeyExtractors::ip()` reads `REMOTE_ADDR` verbatim. Behind a CDN or load balancer that value is the *proxy's* address, so every client collapses onto a single throttle/ban key and your rate limits and bans become useless (or ban everyone at once). The same default applies to file-backed IP blocklists and infrastructure ban listeners. Whenever Phirewall runs behind a proxy, install a client-IP resolver, `$config->setIpResolver(KeyExtractors::clientIp(new TrustedProxyResolver([...])))`, so rules key on the originating client. And never trust `X-Forwarded-For` *without* configuring the trusted proxies: an attacker can otherwise spoof the header to forge any client IP. ::: +### Resolver behavior + +`TrustedProxyResolver` walks the forwarded chain from right to left, skipping hops whose address is in your trusted-proxy list, and returns the first untrusted address as the client IP (falling back to `REMOTE_ADDR` when the chain yields nothing valid). A few details are worth knowing: + +```php +// Constructor: trusted proxies first, then the header(s) to consult, then a chain cap. +new TrustedProxyResolver( + trustedProxies: ['10.0.0.0/8', '172.16.0.0/12'], + allowedHeaders: ['X-Forwarded-For'], // default + maxChainEntries: 50, // default +); +``` + +- **The default header is a single header.** `allowedHeaders` defaults to `['X-Forwarded-For']` only. If your stack emits the RFC 7239 `Forwarded` header instead, pass it explicitly: `new TrustedProxyResolver([...], ['Forwarded'])`, or `['Forwarded', 'X-Forwarded-For']` for both, so the header the resolver trusts is visible at the call site rather than inferred. +- **Proxy headers are read only when the direct peer is trusted.** The resolver consults `X-Forwarded-For` (or `Forwarded`) only when `REMOTE_ADDR`, the address that actually connected, is itself in your trusted-proxy list. A request arriving directly from an untrusted client has its forwarded headers ignored and is keyed on `REMOTE_ADDR`. +- **All header instances are folded into one chain.** Whether intermediaries keep `X-Forwarded-For` as separate lines or fold them into one comma-separated value (the nginx default), the resolver flattens them and walks the chain right to left, returning the first hop that is not in your trusted-proxy list. The protection is this trusted-hop walk, not the number or order of header instances: a client-prepended value sits to the left of the addresses your proxies append, so it is returned only if every hop to its right is trusted. Correct trusted ranges are therefore essential, and stripping or overwriting the inbound header at the edge prevents spoofing outright. +- **IPv6 is canonicalized.** An IPv4-mapped IPv6 peer (`::ffff:203.0.113.7`) collapses to its embedded IPv4 form, so a plain IPv4 rule or CIDR matches it and an attacker cannot bypass an IPv4 rule by presenting the mapped form. Alternate *genuine*-IPv6 spellings (expanded `2001:0db8::1` vs compressed `2001:db8::1`, mixed case) are also treated as one identity by `ip()` / CIDR **list** matching, which compares the raw binary address. When keys are derived through `KeyExtractors::clientIp()`, the resolver canonicalizes the address it returns, so per-client keys stay stable regardless of the spelling the client presents; the consistent-spelling caveat applies only to raw `KeyExtractors::ip()` (`REMOTE_ADDR`) or a custom resolver that does not canonicalize. + ## First Test -Verify your setup works by sending requests: +With your application served (for the plain-PHP front controller in Step 5, run `php -S localhost:8080 public/index.php`; otherwise use your framework's own server), verify the firewall by sending requests: ```bash # Should pass (200) diff --git a/docs/index.md b/docs/index.md index fd91d7c..7737ef8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -20,7 +20,7 @@ features: details: Fixed-window, sliding-window, and multi-window throttling with dynamic per-request limits. - icon: "\uD83D\uDEE1\uFE0F" title: Bot Detection - details: Block known scanners, verify search engine bots via rDNS, and detect suspicious headers — each with a single method call. + details: Block known scanners, verify search engine bots via rDNS, and detect suspicious headers, each with a single method call. - icon: "\uD83D\uDEAB" title: IP Blocking & Safelisting details: Safelist and blocklist IPs and CIDR ranges. Pattern backends with file-backed persistence and automatic expiration. @@ -30,17 +30,23 @@ features: - icon: "\uD83D\uDD04" title: Fail2Ban & Allow2Ban details: Ban clients after threshold violations. Signal failures from your application handler via RequestContext. + - icon: "\uD83E\uDDE9" + title: Presets & Portable Config + details: Ready-made, serializable rule bundles you can layer, compose, and override by name. - icon: "\uD83D\uDCE6" title: PSR-15 Compatible - details: Works with any PSR-15 framework — Laravel, Symfony, Slim, TYPO3, Mezzio, and more. + details: "Works with any PSR-15 framework: Laravel, Symfony, Slim, TYPO3, Mezzio, and more." + - icon: "💾" + title: Flexible Storage + details: "PSR-16 cache backends included: in-memory, APCu, Redis, and PDO." ---
-

Built by Flowd GmbH — Available for Your Next Project

+

Built by Flowd GmbH - Available for Your Next Project

Flowd GmbH (opens in new tab) is a highly specialized partner for custom application solutions. - Since 2018, our team of experienced software engineers and IT consultants builds tailored software for mid-market companies and enterprises — efficient, stable, and with long-term support. + Since 2018, our team of experienced software engineers and IT consultants builds tailored software for mid-market companies and enterprises: efficient, stable, and with long-term support.

diff --git a/docs/services.md b/docs/services.md index 211b782..fc1de3f 100644 --- a/docs/services.md +++ b/docs/services.md @@ -6,15 +6,15 @@ outline: deep ## About Flowd GmbH -**Flowd GmbH** is not a generic full-service agency — we are a highly specialized partner for custom application solutions in the B2B space. Since our founding in 2018, we have been building tailored software for mid-market companies and enterprises — efficient, stable, and with long-term support. +**Flowd GmbH** is not a generic full-service agency; we are a highly specialized partner for custom application solutions in the B2B space. Since our founding in 2018, we have been building tailored software for mid-market companies and enterprises: efficient, stable, and with long-term support. Founded by **Sascha Egerer** and **Felix Tscheulin**, our team consists of six experienced software engineers and IT consultants who work closely together with minimal organizational overhead. ### What sets us apart -- **Management = Developers** — Decisions are made by those who write the code. Our leadership team are trained computer scientists who are responsible for projects both technically and organizationally. -- **Lean structures, high quality** — No layers of project managers between you and the engineers. -- **Long-term partnerships** — We support our clients over many years and take responsibility for the solutions we build. +- **Management = Developers**: Decisions are made by those who write the code. Our leadership team are trained computer scientists who are responsible for projects both technically and organizationally. +- **Lean structures, high quality**: No layers of project managers between you and the engineers. +- **Long-term partnerships**: We support our clients over many years and take responsibility for the solutions we build. ## Our Services @@ -74,7 +74,7 @@ Unified digital platform for multi-brand, multi-country website operations. Cust Development of the Paperless API with Ruby on Rails. Architecture, security consulting, eIDAS 2.0 certification support. -### Mankido — Marketing Website +### Mankido - Marketing Website *TYPO3 CMS* Design and development of the Mankido marketing website on TYPO3, providing a flexible content management platform for brand communication. @@ -82,8 +82,8 @@ Design and development of the Mankido marketing website on TYPO3, providing a fl ## How We Work - **Long-term partnerships** over one-off projects -- **Direct, technical communication** — no classic project managers, just senior engineers -- **Transparency & responsibility** — open error culture, clear budgets, accountability in operations +- **Direct, technical communication**: no classic project managers, just senior engineers +- **Transparency & responsibility**: open error culture, clear budgets, accountability in operations ## Get in Touch diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index cfdc79a..0000000 --- a/package-lock.json +++ /dev/null @@ -1,2511 +0,0 @@ -{ - "name": "phirewall-docs", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "phirewall-docs", - "devDependencies": { - "vitepress": "^1.6.0" - } - }, - "node_modules/@algolia/abtesting": { - "version": "1.15.2", - "resolved": "https://registry.npmjs.org/@algolia/abtesting/-/abtesting-1.15.2.tgz", - "integrity": "sha512-rF7vRVE61E0QORw8e2NNdnttcl3jmFMWS9B4hhdga12COe+lMa26bQLfcBn/Nbp9/AF/8gXdaRCPsVns3CnjsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/autocomplete-core": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz", - "integrity": "sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-plugin-algolia-insights": "1.17.7", - "@algolia/autocomplete-shared": "1.17.7" - } - }, - "node_modules/@algolia/autocomplete-plugin-algolia-insights": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz", - "integrity": "sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.7" - }, - "peerDependencies": { - "search-insights": ">= 1 < 3" - } - }, - "node_modules/@algolia/autocomplete-preset-algolia": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz", - "integrity": "sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-shared": "1.17.7" - }, - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/autocomplete-shared": { - "version": "1.17.7", - "resolved": "https://registry.npmjs.org/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz", - "integrity": "sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@algolia/client-search": ">= 4.9.1 < 6", - "algoliasearch": ">= 4.9.1 < 6" - } - }, - "node_modules/@algolia/client-abtesting": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-abtesting/-/client-abtesting-5.49.2.tgz", - "integrity": "sha512-XyvKCm0RRmovMI/ChaAVjTwpZhXdbgt3iZofK914HeEHLqD1MUFFVLz7M0+Ou7F56UkHXwRbpHwb9xBDNopprQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-analytics": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-analytics/-/client-analytics-5.49.2.tgz", - "integrity": "sha512-jq/3qvtmj3NijZlhq7A1B0Cl41GfaBpjJxcwukGsYds6aMSCWrEAJ9pUqw/C9B3hAmILYKl7Ljz3N9SFvekD3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-common": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-common/-/client-common-5.49.2.tgz", - "integrity": "sha512-bn0biLequn3epobCfjUqCxlIlurLr4RHu7RaE4trgN+RDcUq6HCVC3/yqq1hwbNYpVtulnTOJzcaxYlSr1fnuw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-insights": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-insights/-/client-insights-5.49.2.tgz", - "integrity": "sha512-z14wfFs1T3eeYbCArC8pvntAWsPo9f6hnUGoj8IoRUJTwgJiiySECkm8bmmV47/x0oGHfsVn3kBdjMX0yq0sNA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-personalization": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-personalization/-/client-personalization-5.49.2.tgz", - "integrity": "sha512-GpRf7yuuAX93+Qt0JGEJZwgtL0MFdjFO9n7dn8s2pA9mTjzl0Sc5+uTk1VPbIAuf7xhCP9Mve+URGb6J+EYxgA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-query-suggestions": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-query-suggestions/-/client-query-suggestions-5.49.2.tgz", - "integrity": "sha512-HZwApmNkp0DiAjZcLYdQLddcG4Agb88OkojiAHGgcm5DVXobT5uSZ9lmyrbw/tmQBJwgu2CNw4zTyXoIB7YbPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/client-search": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/client-search/-/client-search-5.49.2.tgz", - "integrity": "sha512-y1IOpG6OSmTpGg/CT0YBb/EAhR2nsC18QWp9Jy8HO9iGySpcwaTvs5kHa17daP3BMTwWyaX9/1tDTDQshZzXdg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/ingestion": { - "version": "1.49.2", - "resolved": "https://registry.npmjs.org/@algolia/ingestion/-/ingestion-1.49.2.tgz", - "integrity": "sha512-YYJRjaZ2bqk923HxE4um7j/Cm3/xoSkF2HC2ZweOF8cXL3sqnlndSUYmCaxHFjNPWLaSHk2IfssX6J/tdKTULw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/monitoring": { - "version": "1.49.2", - "resolved": "https://registry.npmjs.org/@algolia/monitoring/-/monitoring-1.49.2.tgz", - "integrity": "sha512-9WgH+Dha39EQQyGKCHlGYnxW/7W19DIrEbCEbnzwAMpGAv1yTWCHMPXHxYa+LcL3eCp2V/5idD1zHNlIKmHRHg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/recommend": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/recommend/-/recommend-5.49.2.tgz", - "integrity": "sha512-K7Gp5u+JtVYgaVpBxF5rGiM+Ia8SsMdcAJMTDV93rwh00DKNllC19o1g+PwrDjDvyXNrnTEbofzbTs2GLfFyKA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-browser-xhr": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.49.2.tgz", - "integrity": "sha512-3UhYCcWX6fbtN8ABcxZlhaQEwXFh3CsFtARyyadQShHMPe3mJV9Wel4FpJTa+seugRkbezFz0tt6aPTZSYTBuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-fetch": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-fetch/-/requester-fetch-5.49.2.tgz", - "integrity": "sha512-G94VKSGbsr+WjsDDOBe5QDQ82QYgxvpxRGJfCHZBnYKYsy/jv9qGIDb93biza+LJWizQBUtDj7bZzp3QZyzhPQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@algolia/requester-node-http": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/@algolia/requester-node-http/-/requester-node-http-5.49.2.tgz", - "integrity": "sha512-UuihBGHafG/ENsrcTGAn5rsOffrCIRuHMOsD85fZGLEY92ate+BMTUqxz60dv5zerh8ZumN4bRm8eW2z9L11jA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/client-common": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.0" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@docsearch/css": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.2.tgz", - "integrity": "sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@docsearch/js": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/js/-/js-3.8.2.tgz", - "integrity": "sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docsearch/react": "3.8.2", - "preact": "^10.0.0" - } - }, - "node_modules/@docsearch/react": { - "version": "3.8.2", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.2.tgz", - "integrity": "sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-core": "1.17.7", - "@algolia/autocomplete-preset-algolia": "1.17.7", - "@docsearch/css": "3.8.2", - "algoliasearch": "^5.14.2" - }, - "peerDependencies": { - "@types/react": ">= 16.8.0 < 19.0.0", - "react": ">= 16.8.0 < 19.0.0", - "react-dom": ">= 16.8.0 < 19.0.0", - "search-insights": ">= 1 < 3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "search-insights": { - "optional": true - } - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@iconify-json/simple-icons": { - "version": "1.2.74", - "resolved": "https://registry.npmjs.org/@iconify-json/simple-icons/-/simple-icons-1.2.74.tgz", - "integrity": "sha512-yqaohfY6jnYjTVpuTkaBQHrWbdUrQyWXhau0r/0EZiNWYXPX/P8WWwl1DoLH5CbvDjjcWQw5J0zADhgCUklOqA==", - "dev": true, - "license": "CC0-1.0", - "dependencies": { - "@iconify/types": "*" - } - }, - "node_modules/@iconify/types": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz", - "integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@shikijs/core": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-2.5.0.tgz", - "integrity": "sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-html": "^9.0.4" - } - }, - "node_modules/@shikijs/engine-javascript": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz", - "integrity": "sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "oniguruma-to-es": "^3.1.0" - } - }, - "node_modules/@shikijs/engine-oniguruma": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz", - "integrity": "sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2" - } - }, - "node_modules/@shikijs/langs": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-2.5.0.tgz", - "integrity": "sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/themes": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-2.5.0.tgz", - "integrity": "sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/transformers": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-2.5.0.tgz", - "integrity": "sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "2.5.0", - "@shikijs/types": "2.5.0" - } - }, - "node_modules/@shikijs/types": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-2.5.0.tgz", - "integrity": "sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/@shikijs/vscode-textmate": { - "version": "10.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz", - "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/hast": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", - "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/linkify-it": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz", - "integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/markdown-it": { - "version": "14.1.2", - "resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz", - "integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/linkify-it": "^5", - "@types/mdurl": "^2" - } - }, - "node_modules/@types/mdast": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", - "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "*" - } - }, - "node_modules/@types/mdurl": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/unist": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", - "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/web-bluetooth": { - "version": "0.0.21", - "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz", - "integrity": "sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==", - "dev": true, - "license": "MIT" - }, - "node_modules/@ungap/structured-clone": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", - "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", - "dev": true, - "license": "ISC" - }, - "node_modules/@vitejs/plugin-vue": { - "version": "5.2.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", - "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "vite": "^5.0.0 || ^6.0.0", - "vue": "^3.2.25" - } - }, - "node_modules/@vue/compiler-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", - "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/shared": "3.5.30", - "entities": "^7.0.1", - "estree-walker": "^2.0.2", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", - "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-core": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/compiler-sfc": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", - "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.0", - "@vue/compiler-core": "3.5.30", - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30", - "estree-walker": "^2.0.2", - "magic-string": "^0.30.21", - "postcss": "^8.5.8", - "source-map-js": "^1.2.1" - } - }, - "node_modules/@vue/compiler-ssr": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", - "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/devtools-api": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", - "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/devtools-kit": "^7.7.9" - } - }, - "node_modules/@vue/devtools-kit": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", - "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/devtools-shared": "^7.7.9", - "birpc": "^2.3.0", - "hookable": "^5.5.3", - "mitt": "^3.0.1", - "perfect-debounce": "^1.0.0", - "speakingurl": "^14.0.1", - "superjson": "^2.2.2" - } - }, - "node_modules/@vue/devtools-shared": { - "version": "7.7.9", - "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", - "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", - "dev": true, - "license": "MIT", - "dependencies": { - "rfdc": "^1.4.1" - } - }, - "node_modules/@vue/reactivity": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", - "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/runtime-core": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", - "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/shared": "3.5.30" - } - }, - "node_modules/@vue/runtime-dom": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", - "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/reactivity": "3.5.30", - "@vue/runtime-core": "3.5.30", - "@vue/shared": "3.5.30", - "csstype": "^3.2.3" - } - }, - "node_modules/@vue/server-renderer": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", - "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-ssr": "3.5.30", - "@vue/shared": "3.5.30" - }, - "peerDependencies": { - "vue": "3.5.30" - } - }, - "node_modules/@vue/shared": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", - "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@vueuse/core": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/core/-/core-12.8.2.tgz", - "integrity": "sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/web-bluetooth": "^0.0.21", - "@vueuse/metadata": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/integrations": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/integrations/-/integrations-12.8.2.tgz", - "integrity": "sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vueuse/core": "12.8.2", - "@vueuse/shared": "12.8.2", - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - }, - "peerDependencies": { - "async-validator": "^4", - "axios": "^1", - "change-case": "^5", - "drauu": "^0.4", - "focus-trap": "^7", - "fuse.js": "^7", - "idb-keyval": "^6", - "jwt-decode": "^4", - "nprogress": "^0.2", - "qrcode": "^1.5", - "sortablejs": "^1", - "universal-cookie": "^7" - }, - "peerDependenciesMeta": { - "async-validator": { - "optional": true - }, - "axios": { - "optional": true - }, - "change-case": { - "optional": true - }, - "drauu": { - "optional": true - }, - "focus-trap": { - "optional": true - }, - "fuse.js": { - "optional": true - }, - "idb-keyval": { - "optional": true - }, - "jwt-decode": { - "optional": true - }, - "nprogress": { - "optional": true - }, - "qrcode": { - "optional": true - }, - "sortablejs": { - "optional": true - }, - "universal-cookie": { - "optional": true - } - } - }, - "node_modules/@vueuse/metadata": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/metadata/-/metadata-12.8.2.tgz", - "integrity": "sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/@vueuse/shared": { - "version": "12.8.2", - "resolved": "https://registry.npmjs.org/@vueuse/shared/-/shared-12.8.2.tgz", - "integrity": "sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==", - "dev": true, - "license": "MIT", - "dependencies": { - "vue": "^3.5.13" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/algoliasearch": { - "version": "5.49.2", - "resolved": "https://registry.npmjs.org/algoliasearch/-/algoliasearch-5.49.2.tgz", - "integrity": "sha512-1K0wtDaRONwfhL4h8bbJ9qTjmY6rhGgRvvagXkMBsAOMNr+3Q2SffHECh9DIuNVrMA1JwA0zCwhyepgBZVakng==", - "dev": true, - "license": "MIT", - "dependencies": { - "@algolia/abtesting": "1.15.2", - "@algolia/client-abtesting": "5.49.2", - "@algolia/client-analytics": "5.49.2", - "@algolia/client-common": "5.49.2", - "@algolia/client-insights": "5.49.2", - "@algolia/client-personalization": "5.49.2", - "@algolia/client-query-suggestions": "5.49.2", - "@algolia/client-search": "5.49.2", - "@algolia/ingestion": "1.49.2", - "@algolia/monitoring": "1.49.2", - "@algolia/recommend": "5.49.2", - "@algolia/requester-browser-xhr": "5.49.2", - "@algolia/requester-fetch": "5.49.2", - "@algolia/requester-node-http": "5.49.2" - }, - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/birpc": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", - "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/ccount": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", - "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-html4": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", - "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/character-entities-legacy": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", - "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/comma-separated-tokens": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", - "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/copy-anything": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", - "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-what": "^5.2.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", - "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/dequal": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", - "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/devlop": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", - "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", - "dev": true, - "license": "MIT", - "dependencies": { - "dequal": "^2.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/emoji-regex-xs": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz", - "integrity": "sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", - "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/focus-trap": { - "version": "7.8.0", - "resolved": "https://registry.npmjs.org/focus-trap/-/focus-trap-7.8.0.tgz", - "integrity": "sha512-/yNdlIkpWbM0ptxno3ONTuf+2g318kh2ez3KSeZN5dZ8YC6AAmgeWz+GasYYiBJPFaYcSAPeu4GfhUaChzIJXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "tabbable": "^6.4.0" - } - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/hast-util-to-html": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", - "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "ccount": "^2.0.0", - "comma-separated-tokens": "^2.0.0", - "hast-util-whitespace": "^3.0.0", - "html-void-elements": "^3.0.0", - "mdast-util-to-hast": "^13.0.0", - "property-information": "^7.0.0", - "space-separated-tokens": "^2.0.0", - "stringify-entities": "^4.0.0", - "zwitch": "^2.0.4" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hast-util-whitespace": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", - "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/hookable": { - "version": "5.5.3", - "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", - "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/html-void-elements": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", - "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/is-what": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", - "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/mesqueeb" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", - "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/mark.js": { - "version": "8.11.1", - "resolved": "https://registry.npmjs.org/mark.js/-/mark.js-8.11.1.tgz", - "integrity": "sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/mdast-util-to-hast": { - "version": "13.2.1", - "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", - "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/mdast": "^4.0.0", - "@ungap/structured-clone": "^1.0.0", - "devlop": "^1.0.0", - "micromark-util-sanitize-uri": "^2.0.0", - "trim-lines": "^3.0.0", - "unist-util-position": "^5.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/micromark-util-character": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", - "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-symbol": "^2.0.0", - "micromark-util-types": "^2.0.0" - } - }, - "node_modules/micromark-util-encode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", - "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-sanitize-uri": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", - "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT", - "dependencies": { - "micromark-util-character": "^2.0.0", - "micromark-util-encode": "^2.0.0", - "micromark-util-symbol": "^2.0.0" - } - }, - "node_modules/micromark-util-symbol": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", - "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/micromark-util-types": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", - "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", - "dev": true, - "funding": [ - { - "type": "GitHub Sponsors", - "url": "https://github.com/sponsors/unifiedjs" - }, - { - "type": "OpenCollective", - "url": "https://opencollective.com/unified" - } - ], - "license": "MIT" - }, - "node_modules/minisearch": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/minisearch/-/minisearch-7.2.0.tgz", - "integrity": "sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==", - "dev": true, - "license": "MIT" - }, - "node_modules/mitt": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", - "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/oniguruma-to-es": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz", - "integrity": "sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex-xs": "^1.0.0", - "regex": "^6.0.1", - "regex-recursion": "^6.0.2" - } - }, - "node_modules/perfect-debounce": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", - "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/preact": { - "version": "10.29.0", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.0.tgz", - "integrity": "sha512-wSAGyk2bYR1c7t3SZ3jHcM6xy0lcBcDel6lODcs9ME6Th++Dx2KU+6D3HD8wMMKGA8Wpw7OMd3/4RGzYRpzwRg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/property-information": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", - "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/regex": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz", - "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-recursion": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz", - "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "regex-utilities": "^2.3.0" - } - }, - "node_modules/regex-utilities": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz", - "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==", - "dev": true, - "license": "MIT" - }, - "node_modules/rfdc": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", - "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", - "dev": true, - "license": "MIT" - }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" - } - }, - "node_modules/search-insights": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/search-insights/-/search-insights-2.17.3.tgz", - "integrity": "sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/shiki": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/shiki/-/shiki-2.5.0.tgz", - "integrity": "sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@shikijs/core": "2.5.0", - "@shikijs/engine-javascript": "2.5.0", - "@shikijs/engine-oniguruma": "2.5.0", - "@shikijs/langs": "2.5.0", - "@shikijs/themes": "2.5.0", - "@shikijs/types": "2.5.0", - "@shikijs/vscode-textmate": "^10.0.2", - "@types/hast": "^3.0.4" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/space-separated-tokens": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", - "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/speakingurl": { - "version": "14.0.1", - "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", - "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stringify-entities": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", - "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", - "dev": true, - "license": "MIT", - "dependencies": { - "character-entities-html4": "^2.0.0", - "character-entities-legacy": "^3.0.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/superjson": { - "version": "2.2.6", - "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", - "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "copy-anything": "^4" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tabbable": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", - "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", - "dev": true, - "license": "MIT" - }, - "node_modules/trim-lines": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", - "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/unist-util-is": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", - "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-position": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", - "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-stringify-position": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", - "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", - "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0", - "unist-util-visit-parents": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/unist-util-visit-parents": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", - "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", - "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "vfile-message": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vfile-message": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", - "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-stringify-position": "^4.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, - "node_modules/vite": { - "version": "5.4.21", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", - "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/vitepress": { - "version": "1.6.4", - "resolved": "https://registry.npmjs.org/vitepress/-/vitepress-1.6.4.tgz", - "integrity": "sha512-+2ym1/+0VVrbhNyRoFFesVvBvHAVMZMK0rw60E3X/5349M1GuVdKeazuksqopEdvkKwKGs21Q729jX81/bkBJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@docsearch/css": "3.8.2", - "@docsearch/js": "3.8.2", - "@iconify-json/simple-icons": "^1.2.21", - "@shikijs/core": "^2.1.0", - "@shikijs/transformers": "^2.1.0", - "@shikijs/types": "^2.1.0", - "@types/markdown-it": "^14.1.2", - "@vitejs/plugin-vue": "^5.2.1", - "@vue/devtools-api": "^7.7.0", - "@vue/shared": "^3.5.13", - "@vueuse/core": "^12.4.0", - "@vueuse/integrations": "^12.4.0", - "focus-trap": "^7.6.4", - "mark.js": "8.11.1", - "minisearch": "^7.1.1", - "shiki": "^2.1.0", - "vite": "^5.4.14", - "vue": "^3.5.13" - }, - "bin": { - "vitepress": "bin/vitepress.js" - }, - "peerDependencies": { - "markdown-it-mathjax3": "^4", - "postcss": "^8" - }, - "peerDependenciesMeta": { - "markdown-it-mathjax3": { - "optional": true - }, - "postcss": { - "optional": true - } - } - }, - "node_modules/vue": { - "version": "3.5.30", - "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", - "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vue/compiler-dom": "3.5.30", - "@vue/compiler-sfc": "3.5.30", - "@vue/runtime-dom": "3.5.30", - "@vue/server-renderer": "3.5.30", - "@vue/shared": "3.5.30" - }, - "peerDependencies": { - "typescript": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/zwitch": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", - "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", - "dev": true, - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - } - } -} diff --git a/package.json b/package.json index 06420b9..6c3c759 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,6 @@ "preview": "vitepress preview docs" }, "devDependencies": { - "vitepress": "^1.6.0" + "vitepress": "2.0.0-alpha.17" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..6ac89d9 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1401 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + vitepress: + specifier: 2.0.0-alpha.17 + version: 2.0.0-alpha.17(postcss@8.5.15) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@docsearch/css@4.6.3': + resolution: {integrity: sha512-nlOwcXcsNAptQl4vlL4MA78qNJKO0Qlds5GuBjCoePgkebTXLSf8Qt1oyZ3YBshYupKXG9VRGEsk1zr23d+bzQ==} + + '@docsearch/js@4.6.3': + resolution: {integrity: sha512-qUIX2b4Apew3tv4F0qhmgShsl/Lfw4m6mqv/5/5dWNxwTcDdLMp2s3YwZ+NMGh3IKCg0pBaXm7Q5VdyU5Rj+cQ==} + + '@docsearch/sidepanel-js@4.6.3': + resolution: {integrity: sha512-grGSmvXzG0if+mrzdIKykvpIAuEQ9u0sEJ2eLRRCaQfJvsWqh2C2/aY04bIzWvDh7myi5rvl8D+tUNsVrjYQ3A==} + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@iconify-json/simple-icons@1.2.85': + resolution: {integrity: sha512-Hp5LXvd3LRk+e+1558wtonA7c1Z0/Phmi7xCqpgtb8bs8cuyGnP34GDbt5uhhUXxKlzacnnhAcXgcDxe9bUa1w==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@rollup/rollup-android-arm-eabi@4.61.1': + resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.61.1': + resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.61.1': + resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.61.1': + resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.61.1': + resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.61.1': + resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.61.1': + resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.61.1': + resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.61.1': + resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.61.1': + resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.61.1': + resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.61.1': + resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.61.1': + resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.61.1': + resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} + cpu: [x64] + os: [win32] + + '@shikijs/core@3.23.0': + resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==} + + '@shikijs/engine-javascript@3.23.0': + resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==} + + '@shikijs/engine-oniguruma@3.23.0': + resolution: {integrity: sha512-1nWINwKXxKKLqPibT5f4pAFLej9oZzQTsby8942OTlsJzOBZ0MWKiwzMsd+jhzu8YPCHAswGnnN1YtQfirL35g==} + + '@shikijs/langs@3.23.0': + resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==} + + '@shikijs/themes@3.23.0': + resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==} + + '@shikijs/transformers@3.23.0': + resolution: {integrity: sha512-F9msZVxdF+krQNSdQ4V+Ja5QemeAoTQ2jxt7nJCwhDsdF1JWS3KxIQXA3lQbyKwS3J61oHRUSv4jYWv3CkaKTQ==} + + '@shikijs/types@3.23.0': + resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@ungap/structured-clone@1.3.1': + resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} + + '@vitejs/plugin-vue@6.0.7': + resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.35': + resolution: {integrity: sha512-BUmHaR1J+O+CKZ9uJucdVTEr1LHsdyvv7vG3eNRhK3CczEHeMd/LtsHAuD7PbrxvI2envCY2v7HI1vC1aBRzKw==} + + '@vue/compiler-dom@3.5.35': + resolution: {integrity: sha512-k+bprkXxuqhVajgTx5mUHuir7TwQzUKOWR40ng1ncAqQRPnrLngGGgqVEEhOnTMlc8btHYVKmrP8s5Qyg0hvYA==} + + '@vue/compiler-sfc@3.5.35': + resolution: {integrity: sha512-G5VPMcXTSywXBgtFOZOnHKBxKSrwXUcvY1iaF5/hRcy7t0J6CH/d8ha9F4nzi00Fax1eLV0QHM7v4mQu68jydw==} + + '@vue/compiler-ssr@3.5.35': + resolution: {integrity: sha512-rGhAeXgdM7/ffTJGXT69rCCdTmjDewnFuUZfBQQHTdcEBeWdT5HCGY60y2ytLJr9/Dsu7IntUi5z/w0h6Rjnzw==} + + '@vue/devtools-api@8.1.2': + resolution: {integrity: sha512-vA0O112YqyDuNA1s7Yb2gCgToQ/OxOWiFDO5ThLCcDy0ldHnSd1dUTaSYhOldbqoNgumE4dxtGAoAaSUKUD1Zg==} + + '@vue/devtools-kit@8.1.2': + resolution: {integrity: sha512-f75/upc+GCyjXErpgPGz4582ujS0L/adAltGy+tqXMGUJpgAcfGr6CxnnhpZY8BHuMYt6KpbF8uaFrrQG66rGQ==} + + '@vue/devtools-shared@8.1.2': + resolution: {integrity: sha512-X9RyVFYAdkBe4IUf5v48TxBF/6QPmF8CmWrDAjXzfUHrgQ/HGfTC1A6TqgXqZ03ye66l3AD51BAGD69IvKM9sw==} + + '@vue/reactivity@3.5.35': + resolution: {integrity: sha512-tVc+SsHConvh/Lz64qq1pP3rYArBmK42xonovEcxY74SQtvctZodG/zhq54P5dr38cVuw25d27cPNRdlMidpGQ==} + + '@vue/runtime-core@3.5.35': + resolution: {integrity: sha512-A/xFNX9loIcWDygeQuNCfKuh0CoYBzxhqEMNah5TSFg9Z53DrFYEN2qi5CU9necjM1OWYegYREUTHmXTmhfXtg==} + + '@vue/runtime-dom@3.5.35': + resolution: {integrity: sha512-odrJ1C391dbGnyDRh8U+rnP7J2amIEzfmRk5vXy7xi3aZhEXofTvpi0T4HJb6jlNqQZTNPR5MPHSB3RHNkIORA==} + + '@vue/server-renderer@3.5.35': + resolution: {integrity: sha512-NkebSOYdB97wi8OQcO3HqzZSlymJi/aWsN/7h74OSVhRTm6qGs3Jp3e0rCXynmWwSlKeRrnlIug+ilYoHBmQDA==} + peerDependencies: + vue: 3.5.35 + + '@vue/shared@3.5.35': + resolution: {integrity: sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA==} + + '@vueuse/core@14.3.0': + resolution: {integrity: sha512-aHfz47g0ZhMtTVHmIzMVpJy8ePhhOy68GY5bv110+5DVtZ+W7BsOx+m61UNQqfrWyPztIHIanWa3E2tib3NFIw==} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/integrations@14.3.0': + resolution: {integrity: sha512-76I5FT2ESvCmCaSwapI+a/u/CFtNXmzl9f9lNp1hRtx8vKB8hfiokJr8IvQqcQG5ckGXElyXK516b54ozV3MvA==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 || ^8 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 || ^8 + vue: ^3.5.0 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@14.3.0': + resolution: {integrity: sha512-BwxmbAzwAVF50+MW57GXOUEV61nFBGnlBvrTqj49PqWJu3uw7hdu72ztXeZ33RdZtDY6kO+bfCAE1PCn88Tktw==} + + '@vueuse/shared@14.3.0': + resolution: {integrity: sha512-bZpge9eSXwa4ToSiqJ7j6KRwhAsneMFoSz3LMWKQDkqimm3D/tbFlrklrs/IOqC8tEcYmXQZJ6N0UrjhBirVCg==} + peerDependencies: + vue: ^3.5.0 + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + focus-trap@8.2.1: + resolution: {integrity: sha512-6CxwrrFRquH7pDXb1mWxudkU9LSfYBMRZutpgddb2o6iwCk7cIRrBhyY3c8SGKcmIKdeMTrGSNg4Bedh2RSF/w==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + minisearch@7.2.0: + resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + oniguruma-parser@0.12.2: + resolution: {integrity: sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==} + + oniguruma-to-es@4.3.6: + resolution: {integrity: sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + property-information@7.2.0: + resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.1.0: + resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==} + + rollup@4.61.1: + resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + shiki@3.23.0: + resolution: {integrity: sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + unist-util-is@6.0.1: + resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.2: + resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} + + unist-util-visit@5.1.0: + resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + + vfile-message@4.0.3: + resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@7.3.5: + resolution: {integrity: sha512-KuOaNhcnGFN2zIPGA7wRmzF+lJA1sea7rHq17aiJ++9lzY1WWG6Jpwqwe1KNbRVPIqHmr8GLYx7jbrQcN/7/ww==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitepress@2.0.0-alpha.17: + resolution: {integrity: sha512-Z3VPUpwk/bHYqt1uMVOOK1/4xFiWQov1GNc2FvMdz6kvje4JRXEOngVI9C+bi5jeedMSHiA4dwKkff1NCvbZ9Q==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + oxc-minify: '*' + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + oxc-minify: + optional: true + postcss: + optional: true + + vue@3.5.35: + resolution: {integrity: sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@docsearch/css@4.6.3': {} + + '@docsearch/js@4.6.3': {} + + '@docsearch/sidepanel-js@4.6.3': {} + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@iconify-json/simple-icons@1.2.85': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@rolldown/pluginutils@1.0.1': {} + + '@rollup/rollup-android-arm-eabi@4.61.1': + optional: true + + '@rollup/rollup-android-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-arm64@4.61.1': + optional: true + + '@rollup/rollup-darwin-x64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-arm64@4.61.1': + optional: true + + '@rollup/rollup-freebsd-x64@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.61.1': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-linux-x64-musl@4.61.1': + optional: true + + '@rollup/rollup-openbsd-x64@4.61.1': + optional: true + + '@rollup/rollup-openharmony-arm64@4.61.1': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.61.1': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.61.1': + optional: true + + '@shikijs/core@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 4.3.6 + + '@shikijs/engine-oniguruma@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/themes@3.23.0': + dependencies: + '@shikijs/types': 3.23.0 + + '@shikijs/transformers@3.23.0': + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/types': 3.23.0 + + '@shikijs/types@3.23.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.21': {} + + '@ungap/structured-clone@1.3.1': {} + + '@vitejs/plugin-vue@6.0.7(vite@7.3.5)(vue@3.5.35)': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 7.3.5 + vue: 3.5.35 + + '@vue/compiler-core@3.5.35': + dependencies: + '@babel/parser': 7.29.7 + '@vue/shared': 3.5.35 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.35': + dependencies: + '@vue/compiler-core': 3.5.35 + '@vue/shared': 3.5.35 + + '@vue/compiler-sfc@3.5.35': + dependencies: + '@babel/parser': 7.29.7 + '@vue/compiler-core': 3.5.35 + '@vue/compiler-dom': 3.5.35 + '@vue/compiler-ssr': 3.5.35 + '@vue/shared': 3.5.35 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.35': + dependencies: + '@vue/compiler-dom': 3.5.35 + '@vue/shared': 3.5.35 + + '@vue/devtools-api@8.1.2': + dependencies: + '@vue/devtools-kit': 8.1.2 + + '@vue/devtools-kit@8.1.2': + dependencies: + '@vue/devtools-shared': 8.1.2 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.1.2': {} + + '@vue/reactivity@3.5.35': + dependencies: + '@vue/shared': 3.5.35 + + '@vue/runtime-core@3.5.35': + dependencies: + '@vue/reactivity': 3.5.35 + '@vue/shared': 3.5.35 + + '@vue/runtime-dom@3.5.35': + dependencies: + '@vue/reactivity': 3.5.35 + '@vue/runtime-core': 3.5.35 + '@vue/shared': 3.5.35 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.35(vue@3.5.35)': + dependencies: + '@vue/compiler-ssr': 3.5.35 + '@vue/shared': 3.5.35 + vue: 3.5.35 + + '@vue/shared@3.5.35': {} + + '@vueuse/core@14.3.0(vue@3.5.35)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 14.3.0 + '@vueuse/shared': 14.3.0(vue@3.5.35) + vue: 3.5.35 + + '@vueuse/integrations@14.3.0(focus-trap@8.2.1)(vue@3.5.35)': + dependencies: + '@vueuse/core': 14.3.0(vue@3.5.35) + '@vueuse/shared': 14.3.0(vue@3.5.35) + vue: 3.5.35 + optionalDependencies: + focus-trap: 8.2.1 + + '@vueuse/metadata@14.3.0': {} + + '@vueuse/shared@14.3.0(vue@3.5.35)': + dependencies: + vue: 3.5.35 + + birpc@2.9.0: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + comma-separated-tokens@2.0.3: {} + + csstype@3.2.3: {} + + dequal@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + entities@7.0.1: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + estree-walker@2.0.2: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + focus-trap@8.2.1: + dependencies: + tabbable: 6.4.0 + + fsevents@2.3.3: + optional: true + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.1 + property-information: 7.2.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hookable@5.5.3: {} + + html-void-elements@3.0.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mark.js@8.11.1: {} + + mdast-util-to-hast@13.2.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.1 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.1.0 + vfile: 6.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + minisearch@7.2.0: {} + + nanoid@3.3.12: {} + + oniguruma-parser@0.12.2: {} + + oniguruma-to-es@4.3.6: + dependencies: + oniguruma-parser: 0.12.2 + regex: 6.1.0 + regex-recursion: 6.0.2 + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + property-information@7.2.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.1.0: + dependencies: + regex-utilities: 2.3.0 + + rollup@4.61.1: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.61.1 + '@rollup/rollup-android-arm64': 4.61.1 + '@rollup/rollup-darwin-arm64': 4.61.1 + '@rollup/rollup-darwin-x64': 4.61.1 + '@rollup/rollup-freebsd-arm64': 4.61.1 + '@rollup/rollup-freebsd-x64': 4.61.1 + '@rollup/rollup-linux-arm-gnueabihf': 4.61.1 + '@rollup/rollup-linux-arm-musleabihf': 4.61.1 + '@rollup/rollup-linux-arm64-gnu': 4.61.1 + '@rollup/rollup-linux-arm64-musl': 4.61.1 + '@rollup/rollup-linux-loong64-gnu': 4.61.1 + '@rollup/rollup-linux-loong64-musl': 4.61.1 + '@rollup/rollup-linux-ppc64-gnu': 4.61.1 + '@rollup/rollup-linux-ppc64-musl': 4.61.1 + '@rollup/rollup-linux-riscv64-gnu': 4.61.1 + '@rollup/rollup-linux-riscv64-musl': 4.61.1 + '@rollup/rollup-linux-s390x-gnu': 4.61.1 + '@rollup/rollup-linux-x64-gnu': 4.61.1 + '@rollup/rollup-linux-x64-musl': 4.61.1 + '@rollup/rollup-openbsd-x64': 4.61.1 + '@rollup/rollup-openharmony-arm64': 4.61.1 + '@rollup/rollup-win32-arm64-msvc': 4.61.1 + '@rollup/rollup-win32-ia32-msvc': 4.61.1 + '@rollup/rollup-win32-x64-gnu': 4.61.1 + '@rollup/rollup-win32-x64-msvc': 4.61.1 + fsevents: 2.3.3 + + shiki@3.23.0: + dependencies: + '@shikijs/core': 3.23.0 + '@shikijs/engine-javascript': 3.23.0 + '@shikijs/engine-oniguruma': 3.23.0 + '@shikijs/langs': 3.23.0 + '@shikijs/themes': 3.23.0 + '@shikijs/types': 3.23.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + tabbable@6.4.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + trim-lines@3.0.1: {} + + unist-util-is@6.0.1: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + + unist-util-visit@5.1.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.1 + unist-util-visit-parents: 6.0.2 + + vfile-message@4.0.3: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.3 + + vite@7.3.5: + dependencies: + esbuild: 0.27.7 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.15 + rollup: 4.61.1 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitepress@2.0.0-alpha.17(postcss@8.5.15): + dependencies: + '@docsearch/css': 4.6.3 + '@docsearch/js': 4.6.3 + '@docsearch/sidepanel-js': 4.6.3 + '@iconify-json/simple-icons': 1.2.85 + '@shikijs/core': 3.23.0 + '@shikijs/transformers': 3.23.0 + '@shikijs/types': 3.23.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 6.0.7(vite@7.3.5)(vue@3.5.35) + '@vue/devtools-api': 8.1.2 + '@vue/shared': 3.5.35 + '@vueuse/core': 14.3.0(vue@3.5.35) + '@vueuse/integrations': 14.3.0(focus-trap@8.2.1)(vue@3.5.35) + focus-trap: 8.2.1 + mark.js: 8.11.1 + minisearch: 7.2.0 + shiki: 3.23.0 + vite: 7.3.5 + vue: 3.5.35 + optionalDependencies: + postcss: 8.5.15 + transitivePeerDependencies: + - '@types/node' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jiti + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - sass + - sass-embedded + - sortablejs + - stylus + - sugarss + - terser + - tsx + - typescript + - universal-cookie + - yaml + + vue@3.5.35: + dependencies: + '@vue/compiler-dom': 3.5.35 + '@vue/compiler-sfc': 3.5.35 + '@vue/runtime-dom': 3.5.35 + '@vue/server-renderer': 3.5.35(vue@3.5.35) + '@vue/shared': 3.5.35 + + zwitch@2.0.4: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..da5823f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: true +minimumReleaseAgeExclude: + - pnpm@11.5.0