فهرست منبع

feat(M01): monorepo skeleton, toolchain, docker compose builds clean

- api/ and ui/ subprojects with composer + slim 4
- frankenphp dockerfiles, multi-stage builds
- phpunit, phpstan level 8, php-cs-fixer wired into composer scripts
- tailwind build pipeline in ui/
- empty phinx migrations directory
- scripts/ci.sh runs the full test/lint matrix locally (no GitHub Actions)
- composer.json platform.php=8.3 in both subprojects so deps resolve against the FrankenPHP runtime

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 هفته پیش
والد
کامیت
d330d80b8a
75فایلهای تغییر یافته به همراه19426 افزوده شده و 0 حذف شده
  1. 78 0
      .env.example
  2. 33 0
      .gitignore
  3. 14 0
      PROGRESS.md
  4. 18 0
      README.md
  5. 1072 0
      SPEC.md
  6. 24 0
      api/.php-cs-fixer.dist.php
  7. 43 0
      api/Dockerfile
  8. 30 0
      api/bin/console
  9. 47 0
      api/composer.json
  10. 6601 0
      api/composer.lock
  11. 43 0
      api/config/phinx.php
  12. 42 0
      api/config/settings.php
  13. 0 0
      api/db/migrations/.gitkeep
  14. 0 0
      api/db/seeds/.gitkeep
  15. 32 0
      api/docker/Caddyfile
  16. 26 0
      api/docker/entrypoint.sh
  17. 5 0
      api/phpstan.neon
  18. 23 0
      api/phpunit.xml
  19. 45 0
      api/public/index.php
  20. 0 0
      api/src/App/.gitkeep
  21. 0 0
      api/src/Application/.gitkeep
  22. 0 0
      api/src/Domain/.gitkeep
  23. 0 0
      api/src/Infrastructure/.gitkeep
  24. 0 0
      api/src/Support/.gitkeep
  25. 0 0
      api/tests/Fixtures/.gitkeep
  26. 15 0
      api/tests/Unit/SmokeTest.php
  27. 16 0
      compose.scheduler.yml
  28. 0 0
      doc/.gitkeep
  29. 64 0
      docker-compose.yml
  30. 0 0
      examples/consumers/.gitkeep
  31. 0 0
      examples/reporters/.gitkeep
  32. 0 0
      examples/reverse-proxy/.gitkeep
  33. 4 0
      examples/scheduler/host.crontab
  34. 179 0
      files/M01-monorepo-skeleton.md
  35. 180 0
      files/M02-database-migrations.md
  36. 232 0
      files/M03-api-auth-foundations.md
  37. 211 0
      files/M04-token-system-and-ingest.md
  38. 213 0
      files/M05-reputation-engine-and-jobs.md
  39. 199 0
      files/M06-manual-blocks-allowlist.md
  40. 206 0
      files/M07-policies-and-distribution.md
  41. 262 0
      files/M08-ui-scaffold-and-auth.md
  42. 229 0
      files/M09-ui-ips-history-dashboard.md
  43. 197 0
      files/M10-ui-admin-crud-pages.md
  44. 198 0
      files/M11-enrichment.md
  45. 212 0
      files/M12-audit-and-settings.md
  46. 205 0
      files/M13-polish-openapi-docs.md
  47. 243 0
      files/M14-hardening.md
  48. 81 0
      files/README.md
  49. 126 0
      scripts/ci.sh
  50. 23 0
      ui/.php-cs-fixer.dist.php
  51. 46 0
      ui/Dockerfile
  52. 48 0
      ui/composer.json
  53. 6402 0
      ui/composer.lock
  54. 14 0
      ui/docker/Caddyfile
  55. 15 0
      ui/docker/entrypoint.sh
  56. 1238 0
      ui/package-lock.json
  57. 19 0
      ui/package.json
  58. 5 0
      ui/phpstan.neon
  59. 23 0
      ui/phpunit.xml
  60. 6 0
      ui/postcss.config.js
  61. 0 0
      ui/public/assets/.gitkeep
  62. 70 0
      ui/public/index.php
  63. 3 0
      ui/resources/css/app.css
  64. 5 0
      ui/resources/js/app.js
  65. 24 0
      ui/resources/views/layout.twig
  66. 10 0
      ui/resources/views/pages/hello.twig
  67. 0 0
      ui/resources/views/partials/.gitkeep
  68. 0 0
      ui/src/ApiClient/DTOs/.gitkeep
  69. 0 0
      ui/src/App/.gitkeep
  70. 0 0
      ui/src/Auth/.gitkeep
  71. 0 0
      ui/src/Controllers/.gitkeep
  72. 0 0
      ui/src/Http/.gitkeep
  73. 0 0
      ui/src/Support/.gitkeep
  74. 12 0
      ui/tailwind.config.js
  75. 15 0
      ui/tests/Unit/SmokeTest.php

+ 78 - 0
.env.example

@@ -0,0 +1,78 @@
+# =============================================================================
+# IRDB — IP Reputation Database — environment configuration
+# =============================================================================
+# Copy this file to `.env` and fill in the blanks.
+# Generate 32-byte hex secrets with: openssl rand -hex 32
+# =============================================================================
+
+# -----------------------------------------------------------------------------
+# Shared (consumed by both api and ui containers)
+# -----------------------------------------------------------------------------
+# 32-byte hex string. The api uses this to authenticate the ui's calls;
+# the ui presents it on every API request together with X-Acting-User-Id.
+UI_SERVICE_TOKEN=
+
+# -----------------------------------------------------------------------------
+# api container
+# -----------------------------------------------------------------------------
+APP_ENV=production           # development | production
+LOG_LEVEL=info
+APP_SECRET=                  # 32-byte hex; used internally for signing things like ETags
+
+# Database
+DB_DRIVER=sqlite             # sqlite | mysql
+DB_SQLITE_PATH=/data/irdb.sqlite
+DB_MYSQL_HOST=
+DB_MYSQL_PORT=3306
+DB_MYSQL_DATABASE=
+DB_MYSQL_USERNAME=
+DB_MYSQL_PASSWORD=
+
+# OIDC role mapping (defaults applied if no group mapping matches)
+OIDC_DEFAULT_ROLE=viewer     # viewer | none
+
+# Reputation engine
+SCORE_RECOMPUTE_INTERVAL_SECONDS=300
+SCORE_REPORT_HARD_CUTOFF_DAYS=365
+
+# Internal jobs
+INTERNAL_JOB_TOKEN=                       # 32-byte hex
+JOB_RECOMPUTE_MAX_RUNTIME_SECONDS=240
+JOB_RECOMPUTE_MAX_ROWS_PER_TICK=5000
+JOB_AUDIT_RETENTION_DAYS=180
+JOB_GEOIP_REFRESH_INTERVAL_DAYS=7
+
+# GeoIP
+GEOIP_ENABLED=true
+GEOIP_COUNTRY_DB=/data/geoip/GeoLite2-Country.mmdb
+GEOIP_ASN_DB=/data/geoip/GeoLite2-ASN.mmdb
+MAXMIND_LICENSE_KEY=
+
+# CORS — origin of the ui container (or future SPA frontend)
+UI_ORIGIN=http://localhost:8080
+
+# Rate limiting (public API)
+API_RATE_LIMIT_PER_SECOND=60
+
+# -----------------------------------------------------------------------------
+# ui container
+# -----------------------------------------------------------------------------
+# (APP_ENV / LOG_LEVEL above are reused; the ui reads its own copies of those.)
+UI_SECRET=                   # 32-byte hex; signs session cookies
+PUBLIC_URL=http://localhost:8080
+
+# Where the ui finds the api (internal docker network DNS)
+API_BASE_URL=http://api:8081
+
+# OIDC (Entra ID) — lives in ui only
+OIDC_ENABLED=true
+OIDC_ISSUER=https://login.microsoftonline.com/<tenant>/v2.0
+OIDC_CLIENT_ID=
+OIDC_CLIENT_SECRET=
+OIDC_REDIRECT_URI=https://reputation.example.com/oidc/callback
+
+# Local admin — lives in ui only
+LOCAL_ADMIN_ENABLED=true
+LOCAL_ADMIN_USERNAME=admin
+# Generate with: php -r "echo password_hash('s3cret', PASSWORD_ARGON2ID);"
+LOCAL_ADMIN_PASSWORD_HASH=

+ 33 - 0
.gitignore

@@ -0,0 +1,33 @@
+# Dependencies
+vendor/
+node_modules/
+
+# Built assets
+public/assets/
+api/public/assets/
+ui/public/assets/*
+!ui/public/assets/.gitkeep
+
+# Environment
+.env
+.env.local
+
+# PHPUnit
+.phpunit.cache/
+.phpunit.result.cache
+
+# PHP-CS-Fixer
+.php-cs-fixer.cache
+
+# PHPStan
+.phpstan.cache/
+
+# Local data
+data/
+
+# IDE / editor
+.idea/
+.vscode/
+*.swp
+*~
+.DS_Store

+ 14 - 0
PROGRESS.md

@@ -0,0 +1,14 @@
+## M01 — Monorepo skeleton (done)
+
+**Built:** repo layout per SPEC §11, both Dockerfiles, compose stack, toolchain.
+
+**Notes for next milestone:**
+- DB schema empty; M02 owns all tables and seeds.
+- `entrypoint.sh` for api supports `migrate` mode and calls `vendor/bin/phinx`.
+- Healthcheck payloads are stubs; later milestones extend them.
+- Service-token bootstrap deferred to M03 (needs `api_tokens` table first).
+- CI runs locally via `./scripts/ci.sh` (Docker-based, no host PHP/Node needed). No GitHub Actions workflow per project decision.
+- `composer.json` config pins `platform.php` to 8.3 in both subprojects so dependency resolution matches the FrankenPHP runtime image even when the build host's `composer:2` image ships a newer PHP.
+
+**Deviations from SPEC:** none.
+**Added dependencies beyond SPEC §2:** none.

+ 18 - 0
README.md

@@ -0,0 +1,18 @@
+# IRDB — IP Reputation Database
+
+A self-hosted IP reputation service that ingests abuse reports and distributes
+tailored block lists, shipped as a Docker Compose stack (`api` + `ui` +
+optional `mysql` / `scheduler`).
+
+See [`SPEC.md`](./SPEC.md) for the full build specification and
+[`files/`](./files/) for the milestone-by-milestone implementation prompts.
+Per-milestone progress is tracked in [`PROGRESS.md`](./PROGRESS.md).
+
+## Local CI
+
+```bash
+./scripts/ci.sh
+```
+
+Runs lint/static-analysis/tests for both subprojects and verifies docker
+compose builds. Requires Docker; no PHP/Node toolchain needed on the host.

+ 1072 - 0
SPEC.md

@@ -0,0 +1,1072 @@
+# IP Reputation Database — Build Specification
+
+You are building a self-hosted **IP Reputation Database** that ships as a Docker Compose stack:
+
+- **`api`** — pure JSON REST backend. Owns the database, business logic, scoring, RBAC, and all auth decisions. Does not render HTML.
+- **`ui`** — thin PHP+Twig+Tailwind frontend (BFF). Owns the OIDC redirect flow, browser sessions, login forms, and server-rendered templates. Calls `api` for all data.
+- **`migrate`** — one-shot, runs Phinx migrations and seeds, then exits.
+- **`scheduler`** (optional sidecar) — busybox crond that pokes `api`'s internal job endpoints.
+- **`mysql`** (optional) — replaces the default SQLite.
+
+The `ui` container is **deliberately replaceable**. The current PHP+Twig implementation is one of several possible frontends; future rewrites in Vue, Svelte, native desktop, or mobile are explicitly anticipated (and out of scope for this build). The API contract and auth model must remain stable across such rewrites. Documentation for future frontend authors lives in `doc/` and is a first-class deliverable, not an afterthought.
+
+Read this entire spec before writing any code, then execute the milestones in order. Do not skip ahead. Commit after each milestone.
+
+---
+
+## 1. Project Goals
+
+A central service that:
+1. **Ingests** abuse reports from many sources (web servers, IDS, fail2ban-like agents) via an authenticated REST API.
+2. **Distributes** tailored block lists to firewalls/proxies via an authenticated REST API, where each consumer gets a list shaped by a named policy.
+3. **Lets humans manage** IPs, subnets, allowlists, tokens, policies, and inspect full per-IP history through a modern web UI.
+4. Computes IP reputation as a **decaying, weighted, per-category score**.
+
+---
+
+## 2. Tech Stack (non-negotiable)
+
+### Shared
+- **Language**: PHP 8.3
+- **Framework**: Slim 4 (used in both containers, in different roles)
+- **Web server / runtime**: [FrankenPHP](https://frankenphp.dev/) (Caddy with embedded PHP) — single binary, single process per container, auto HTTPS in production, HTTP/2 + HTTP/3
+- **Container base**: `dunglas/frankenphp:1-php8.3-alpine` (or `-bookworm` if Alpine causes pain with extensions)
+- **Build**: Composer per app, npm for Tailwind. Multi-stage Dockerfile per container.
+- **Tests**: PHPUnit 11
+- **Logging**: Monolog → stdout in JSON. Both containers write structured logs.
+
+### `api` container (backend)
+- **Database**: SQLite 3 (default) or MySQL 8 / MariaDB 10.6+ (selected via env var). Use a thin DBAL — `doctrine/dbal` — so the same SQL works on both.
+- **Migrations**: Phinx
+- **GeoIP/ASN enrichment**: MaxMind GeoLite2-Country + GeoLite2-ASN, downloaded at container build time using a license key passed as build-arg, or refreshed at runtime via the `refresh-geoip` job
+- **Output**: JSON responses only (plus `text/plain` for blocklists). No HTML, no Twig.
+
+### `ui` container (frontend BFF)
+- **Templating**: Twig 3
+- **Frontend**: Tailwind CSS 3 (compiled at build time, no CDN), vanilla JS + Alpine.js for interactivity, htmx where it simplifies forms
+- **OIDC**: `jumbojett/openid-connect-php` for Microsoft Entra ID
+- **HTTP client**: `guzzlehttp/guzzle` for calls to the `api` container
+- **Sessions**: PHP native sessions, file-based on the container's writable layer (no shared volume needed; sessions are tied to a single ui replica)
+- **No database access.** The `ui` container holds zero persistent data of its own. Everything goes through the API.
+
+### Process model
+- One process per container.
+- Periodic batch work (score recompute, GeoIP refresh, audit cleanup) is exposed as authenticated internal HTTP endpoints inside the `api` container.
+- Job scheduling is external — host cron, systemd timer, or Kubernetes CronJob. Optional `scheduler` sidecar (busybox crond) provided as a compose overlay for users who don't want to touch the host.
+
+Do **not** introduce additional frameworks (no Laravel, no Symfony full-stack). Keep dependencies minimal in both containers.
+
+---
+
+## 3. High-Level Architecture
+
+```
+                                                          ┌──────────────────────────────┐
+                                                          │   api container (:8081)      │
+                                                          │   FrankenPHP + Slim          │
+   reporters     ──HTTPS POST /api/v1/report─────────────▶│                              │
+   (webservers, IDS, fail2ban)                             │   Public API                 │
+                                                          │   Bearer token auth          │
+   consumers     ──HTTPS GET  /api/v1/blocklist──────────▶│                              │
+   (firewalls, proxies)                                    │   Internal Jobs API          │
+                                                          │   /internal/jobs/*           │
+                                                          │   (loopback / RFC1918 only)  │
+   scheduler     ──HTTPS POST /internal/jobs/tick────────▶│                              │
+   (host cron / sidecar)                                   │   Reputation Engine          │
+                                                          │   GeoIP enrichment           │
+                                                          │   RBAC                       │
+                                                          └──────┬───────────────────────┘
+                                                                 │
+                                                          ┌──────▼───────────────────────┐
+                                                          │     SQLite or MySQL           │
+                                                          └──────▲───────────────────────┘
+                                                                 │
+   admins        ──browser HTTPS───┐                             │
+                                   ▼                             │
+                       ┌──────────────────────────────┐          │
+                       │   ui container (:8080)        │          │
+                       │   FrankenPHP + Slim + Twig    │          │
+                       │                               │          │
+                       │   OIDC redirect flow          │  service-token + impersonation
+                       │   Browser sessions            │  ────────▶
+                       │   Login UI / local admin      │          │
+                       │   Server-rendered templates   │          │
+                       │   (Tailwind, Alpine, htmx)    │          │
+                       │                               │          │
+                       │   No database, no business    │          │
+                       │   logic — pure BFF            │          │
+                       └───────────────────────────────┘          │
+                                                                  │
+   future Vue/native/mobile clients ────(direct API, future)──────┘
+   (out of scope; documented in doc/frontend-development.md)
+
+Containers:
+  • api       — backend, exposed on :8081; serves machine clients directly and ui as a server-side caller
+  • ui        — frontend BFF, exposed on :8080; the only thing humans hit in their browser
+  • migrate   — one-shot Phinx migrations + seed against the api's database, exits on success
+  • mysql     — optional; SQLite via shared volume by default
+  • scheduler — optional sidecar (busybox crond); disabled by default
+```
+
+The `api` and `ui` are independently deployable. In production, users typically front both with a reverse proxy / TLS terminator and route by hostname (`reputation.example.com` → ui, `reputation-api.example.com` → api). For the default compose deployment, both containers expose plain HTTP on different ports and FrankenPHP's auto-HTTPS handles TLS when a public hostname is configured.
+
+---
+
+## 4. Data Model
+
+All timestamps UTC, stored as ISO 8601 strings on SQLite, DATETIME on MySQL. All IPs stored in two columns: `ip_text` (canonical string form) and `ip_bin` (16-byte binary, IPv4 mapped into IPv6 `::ffff:0:0/96`). Indexes on `ip_bin`. Subnets stored as `network_bin` (16 bytes) + `prefix_length` (smallint).
+
+### Tables
+
+**`reporters`** — one row per ingest source
+- `id`, `name` (unique, e.g. `web-prod-01`), `description`, `trust_weight` (decimal 0.0–2.0, default 1.0), `is_active`, `created_at`, `created_by_user_id`
+
+**`consumers`** — one row per distribution consumer (firewall/proxy)
+- `id`, `name` (unique), `description`, `policy_id` (FK), `is_active`, `created_at`, `created_by_user_id`, `last_pulled_at`
+
+**`api_tokens`** — Bearer tokens
+- `id`, `token_hash` (SHA-256 of token; raw token shown once at creation), `token_prefix` (first 8 chars, for UI display), `kind` (`reporter` or `consumer`), `reporter_id` (nullable FK), `consumer_id` (nullable FK), `expires_at` (nullable), `revoked_at` (nullable), `last_used_at`, `created_at`
+- Constraint: exactly one of `reporter_id` / `consumer_id` is set, matching `kind`.
+
+**`categories`** — abuse categories
+- `id`, `slug` (e.g. `brute_force`, `spam`, `scanner`, `malware_c2`, `web_attack`), `name`, `description`, `decay_function` (`linear` | `exponential`), `decay_param` (for linear: days-to-zero; for exponential: half-life in days), `is_active`
+- Seed defaults on first run; admin can add/edit.
+
+**`reports`** — append-only event log of incoming reports
+- `id`, `ip_bin`, `ip_text`, `category_id`, `reporter_id`, `weight_at_report` (snapshot of reporter trust_weight), `received_at`, `metadata_json` (free-form: URL, user-agent, etc., max 4 KB)
+- Index `(ip_bin, category_id, received_at DESC)`
+
+**`ip_scores`** — denormalized current score per (ip, category). Touched synchronously on report ingest for the affected `(ip, category)` pair, and refreshed in bulk by the `recompute-scores` job to reapply decay.
+- `ip_bin`, `ip_text`, `category_id`, `score`, `last_report_at`, `report_count_30d`, `recomputed_at`
+- PK `(ip_bin, category_id)`
+
+**`job_locks`** — mutual exclusion for periodic jobs
+- `job_name` (PK, e.g. `recompute-scores`, `refresh-geoip`, `cleanup-audit`, `enrich-pending`)
+- `acquired_at`, `acquired_by` (string identifier of the request, e.g. hostname + pid)
+- `expires_at` — hard deadline; jobs failing to release the lock by this time are considered crashed and the lock is reclaimable.
+- Implementation: SQLite uses `INSERT OR FAIL` with a delete-if-expired pre-step in a transaction; MySQL uses the same pattern (no `GET_LOCK` — it doesn't survive failover well). Service exposes `tryAcquire($jobName, $maxRuntimeSeconds)` and `release($jobName)`.
+
+**`job_runs`** — per-job execution history and freshness state
+- `id`, `job_name`, `started_at`, `finished_at` (nullable), `status` (`running` | `success` | `failure` | `skipped_locked`), `items_processed` (int), `error_message` (nullable), `triggered_by` (`schedule` | `manual` | `api`)
+- Index `(job_name, started_at DESC)`. The latest row per `job_name` is the "freshness" answer.
+
+**`ip_enrichment`** — GeoIP/ASN cache per IP
+- `ip_bin`, `country_code`, `asn`, `as_org`, `enriched_at`
+
+**`manual_blocks`** — admin-defined blocks (overrides scoring)
+- `id`, `kind` (`ip` | `subnet`), `ip_bin` (if ip), `network_bin` + `prefix_length` (if subnet), `reason`, `expires_at` (nullable), `created_at`, `created_by_user_id`
+
+**`allowlist`** — never-block entries
+- `id`, `kind` (`ip` | `subnet`), `ip_bin` / `network_bin` + `prefix_length`, `reason`, `created_at`, `created_by_user_id`
+
+**`policies`** — distribution profiles
+- `id`, `name` (unique, e.g. `strict`, `moderate`, `paranoid`), `description`, `include_manual_blocks` (bool, default true), `created_at`
+- Each policy has many `policy_category_thresholds`:
+  - `policy_id`, `category_id`, `threshold` (decimal; IP included if its score in this category ≥ threshold)
+  - Absent row = category not considered.
+- Output rule: an IP appears in a policy's blocklist if **any** included category meets its threshold AND the IP is not on the allowlist. Plus all manual blocks if `include_manual_blocks` is true. Subnet manual blocks emit as CIDR.
+
+**`users`** — UI users (identity records only; no credentials stored here)
+- `id`, `subject` (OIDC `sub`, nullable), `email`, `display_name`, `role` (`admin` | `operator` | `viewer`), `is_local` (bool, marks the local admin record), `last_login_at`, `created_at`
+- The local admin's `password_hash` lives in the **`ui` container's** environment, not in this table. The `ui` container validates the password and then calls `POST /api/v1/auth/users/upsert` to ensure a corresponding record exists with `is_local=true` and `role=admin`.
+
+**`oidc_role_mappings`** — map Entra group object IDs to roles
+- `id`, `group_id`, `role`, `created_at`
+- On login, user's role = highest role granted by any matching group; default `viewer` if none match (configurable to "deny" instead).
+
+**`audit_log`** — every write action in the system
+- `id`, `actor_kind` (`user` | `token` | `system`), `actor_id`, `action`, `target_type`, `target_id`, `details_json`, `ip_address`, `created_at`
+
+**`ip_history_view`** — not a table, but a query: union of `reports`, `manual_blocks` events, allowlist events, and audit entries filtered by IP, ordered by time. Implemented as a service method.
+
+---
+
+## 5. Reputation Engine
+
+### Scoring formula
+
+For an IP `X` and category `C`, score is the sum over all reports `r` where `r.ip == X` and `r.category == C`:
+
+```
+score(X, C) = Σ ( r.weight_at_report × decay(now − r.received_at, C) )
+```
+
+`decay(age_days, C)`:
+- **Linear**: `max(0, 1 − age_days / decay_param)` where `decay_param` is days to zero (default 30).
+- **Exponential**: `0.5 ^ (age_days / decay_param)` where `decay_param` is the half-life in days (default 14).
+
+Reports older than 365 days are excluded from the sum (hard cutoff for performance). Configurable in env.
+
+### Recomputation
+
+The `recompute-scores` job (invoked on a schedule, default every 5 minutes — configurable):
+1. Acquires the `recompute-scores` lock with a max runtime of e.g. 4 minutes. If the lock is held, returns `409 Conflict` with status `skipped_locked` and exits immediately.
+2. Finds all `(ip_bin, category_id)` pairs touched by reports in the last interval **plus** all rows whose `recomputed_at` is older than a "freshness" window (default 1 hour) — capped at N rows per cycle to bound execution time.
+3. Recomputes and upserts into `ip_scores`.
+4. Drops rows where score < 0.01 and last report > 90 days ago.
+5. Records a `job_runs` entry, then releases the lock.
+
+A full-table recompute is also runnable on demand from the UI ("Rebuild scores"), which calls the same job with a `full=true` flag and a longer max runtime.
+
+### Manual override semantics
+
+`manual_blocks` and `allowlist` are evaluated at distribution time, not folded into scores. Allowlist always wins over everything (including manual blocks — log a warning if both match).
+
+---
+
+## 6. API Contracts
+
+The `api` container exposes four logical groups of endpoints, distinguished by audience and authentication:
+
+| Group        | Path prefix                | Audience              | Auth                                                           |
+|--------------|----------------------------|-----------------------|----------------------------------------------------------------|
+| Public       | `/api/v1/report`, `/api/v1/blocklist` | Machine clients (reporters, consumers) | Bearer (`reporter` or `consumer` token kind) |
+| Admin        | `/api/v1/admin/*`          | UI BFF, admin Bearer tokens | Service token + `X-Acting-User-Id`, OR Bearer (`admin` kind) |
+| Auth         | `/api/v1/auth/*`           | UI BFF only           | Service token                                                  |
+| Internal     | `/internal/jobs/*`         | Scheduler             | `INTERNAL_JOB_TOKEN`, network-restricted                       |
+
+All responses JSON unless stated. All endpoints require `Authorization: Bearer <token>` unless explicitly public. Rate limit: 60 req/s per token (token-bucket), configurable. Return `429` with `Retry-After`.
+
+### Authentication tokens — three kinds
+
+The `api_tokens` table's `kind` column gains two new values beyond `reporter` and `consumer`:
+
+- `reporter` — may call `POST /api/v1/report`. Bound to a reporter record.
+- `consumer` — may call `GET /api/v1/blocklist`. Bound to a consumer record.
+- `admin` — may call any `/api/v1/admin/*` endpoint as itself. For administrators or automation that doesn't go through the UI. Not bound to a reporter or consumer.
+- `service` — special class. Held by the `ui` container. Calls to `/api/v1/admin/*` and `/api/v1/auth/*` MUST include `X-Acting-User-Id: <user_id>`; the API verifies the user exists and applies RBAC for that user. There is exactly one `service` token at a time, set via `UI_SERVICE_TOKEN` env var on both containers; if it doesn't exist on startup the `api` container creates it. Service tokens are never returned in admin token-list endpoints.
+
+### Public API — machine clients
+
+**`POST /api/v1/report`** — token must be `kind=reporter`
+```json
+{
+  "ip": "203.0.113.42",
+  "category": "brute_force",
+  "metadata": { "url": "/wp-login.php", "ua": "..." }
+}
+```
+Response `202`:
+```json
+{ "report_id": 12345, "ip": "203.0.113.42", "received_at": "2026-04-27T10:11:12Z" }
+```
+Errors: `400` invalid IP/category, `401` bad token, `403` token revoked, `429` rate limited.
+
+**`GET /api/v1/blocklist`** — token must be `kind=consumer`. Returns `text/plain`, one entry per line: bare IP or CIDR. No comments by default. Cached internally for 30 seconds per consumer.
+
+Headers:
+- `ETag`: hash of body. Honor `If-None-Match` → `304`.
+- `X-Blocklist-Generated-At`, `X-Blocklist-Entries`, `X-Blocklist-Policy`.
+
+`GET /api/v1/blocklist?format=json` — convenience: array of `{ip_or_cidr, categories, score, reason}`.
+
+### Admin API — used by UI BFF and admin tokens
+
+All endpoints accept either:
+- `Authorization: Bearer <admin-kind-token>` — RBAC role determined by the token's configured role, OR
+- `Authorization: Bearer <UI_SERVICE_TOKEN>` + `X-Acting-User-Id: <user_id>` — RBAC role determined by the user record.
+
+Endpoints (representative, not exhaustive — see OpenAPI):
+- `GET /api/v1/admin/me` — current acting identity: `{user_id, email, display_name, role, source: "oidc"|"local"|"admin-token"}`
+- `GET /api/v1/admin/ips/{ip}` — full detail: scores per category, recent reports, manual block status, allowlist status, enrichment, history.
+- `GET /api/v1/admin/ips?q=&category=&min_score=&country=&asn=&page=` — search.
+- `POST/DELETE /api/v1/admin/manual-blocks`, `POST/DELETE /api/v1/admin/allowlist`
+- `GET/POST/PATCH/DELETE /api/v1/admin/policies`, `/policies/{id}/thresholds`
+- `GET/POST/DELETE /api/v1/admin/reporters`, `/consumers`, `/tokens`, `/categories`
+- `GET/POST/PATCH/DELETE /api/v1/admin/users`, `/oidc-role-mappings`
+- `GET /api/v1/admin/audit-log`
+- `POST /api/v1/admin/jobs/trigger/{job_name}` — admin-only thin wrapper that calls `/internal/jobs/<name>` server-side. The UI uses this to trigger manual jobs without needing the internal token.
+
+### Auth API — exclusively for UI BFF
+
+These are how the `ui` container resolves a browser-authenticated user (OIDC or local) into a stable user record. Always called with the service token.
+
+**`POST /api/v1/auth/users/upsert-oidc`**
+```json
+{
+  "subject": "...",
+  "email": "...",
+  "display_name": "...",
+  "groups": ["group-id-1", "group-id-2"]
+}
+```
+Returns `{user_id, role, email, display_name, is_local: false}`. API derives role from `oidc_role_mappings`; default applies if no match.
+
+**`POST /api/v1/auth/users/upsert-local`**
+```json
+{ "username": "admin" }
+```
+The UI calls this only after validating the local admin password against its own env config. Returns `{user_id, role: "admin", email: null, display_name: "Local Admin", is_local: true}`.
+
+**`GET /api/v1/auth/users/{id}`** — used by UI to refresh user info during a session.
+
+### Internal Jobs API
+
+Used by the scheduler (host cron / systemd / sidecar) to drive periodic batch work. Bound only to loopback and the Docker bridge network in the Caddyfile — never reachable from outside, even with a token. Bearer token: `INTERNAL_JOB_TOKEN` env var.
+
+All endpoints share the same response envelope:
+```json
+{ "job": "recompute-scores", "status": "success", "items_processed": 1284, "duration_ms": 8421, "run_id": 42 }
+```
+
+- `POST /internal/jobs/recompute-scores` — body optional: `{"full": true, "max_rows": 5000}`. Returns `202` on success, `409` if lock held (`status: "skipped_locked"`), `500` on failure.
+- `POST /internal/jobs/refresh-geoip` — downloads fresh GeoLite2 DBs if `MAXMIND_LICENSE_KEY` is set; otherwise returns `412 Precondition Failed`.
+- `POST /internal/jobs/cleanup-audit` — prunes audit log older than retention window.
+- `POST /internal/jobs/enrich-pending` — runs GeoIP/ASN enrichment for IPs missing it.
+- `POST /internal/jobs/tick` — convenience: examines `job_runs` and invokes any job whose interval has elapsed.
+- `GET  /internal/jobs/status` — JSON: latest `job_runs` row per job, lock state, "is overdue" flag.
+
+Each endpoint always writes a `job_runs` row, even on lock-skip and failure.
+
+Bound endpoints in `Caddyfile`:
+```caddy
+@internal {
+    path /internal/*
+    remote_ip 127.0.0.1/32 ::1/128 172.16.0.0/12 10.0.0.0/8 192.168.0.0/16
+}
+handle @internal {
+    php
+}
+@external_internal_blocked {
+    path /internal/*
+    not remote_ip 127.0.0.1/32 ::1/128 172.16.0.0/12 10.0.0.0/8 192.168.0.0/16
+}
+respond @external_internal_blocked 404
+```
+
+The OpenAPI document includes Public and Admin groups. Auth and Internal endpoints are documented separately in `doc/auth-flows.md` (they are not part of the public contract — frontends call Admin endpoints).
+
+### CORS
+
+The `api` container sets CORS headers permitting the configured `UI_ORIGIN` only, with `Access-Control-Allow-Credentials: true` and the `X-Acting-User-Id` header on the allow-list. This matters for any future browser-direct frontend; the current PHP UI calls server-to-server and doesn't trigger CORS.
+
+### OpenAPI
+
+Generate `openapi.yaml` at `/api/v1/openapi.yaml`. Serve a Stoplight Elements or RapiDoc viewer at `/api/docs`. Document all Public and Admin endpoints with full request/response schemas and auth requirements.
+
+---
+
+## 7. UI Container (PHP+Twig BFF)
+
+The `ui` container is a thin Backend-for-Frontend. It owns the browser-facing experience; it does not own any data. Every screen is rendered by fetching from the `api` container with the service token plus the acting user's ID, and every form action is forwarded as a corresponding `api` call.
+
+### Responsibilities
+
+The `ui` container owns:
+- Browser sessions (PHP native, file-backed inside the container)
+- The OIDC redirect/callback flow (it holds the OIDC client config)
+- The local admin login form and password validation against env config
+- All HTML rendering (Twig + Tailwind + Alpine + htmx)
+- Static asset serving
+- Anti-CSRF for its own forms
+- Light/dark mode preference (in the user's browser localStorage)
+
+The `ui` container does **not**:
+- Connect to the database
+- Implement business logic
+- Compute reputation scores
+- Hold any persistent data
+- Decide RBAC outcomes (it asks the API; the API decides)
+
+### Public routes
+
+- `GET  /` — redirect to `/login` if not signed in, else `/dashboard`
+- `GET  /login` — login page
+- `POST /login/local` — local admin form submission
+- `GET  /login/oidc` — initiate OIDC flow
+- `GET  /oidc/callback` — OIDC callback
+- `POST /logout`
+- `GET  /healthz` — UI's own health: `{status, api_reachable: bool, last_api_check_at}`. Does not depend on the API being up to return 200.
+
+### Authenticated routes
+
+All under `/app/*`. Top nav (logo, search box, dark-mode toggle, user menu) + sidebar (Dashboard, IPs, Subnets, Allowlist, Policies, Reporters, Consumers, Tokens, Categories, Audit, Settings).
+
+### Pages (data sources annotated)
+
+- **Dashboard** — `GET /api/v1/admin/stats/dashboard` (UI controllers should never assemble dashboards from multiple admin calls; the API exposes purpose-built endpoints).
+- **IPs** — `GET /api/v1/admin/ips?...` paginated list. **IP Detail** — `GET /api/v1/admin/ips/{ip}`.
+- **Subnets / Allowlist** — `GET/POST/DELETE /api/v1/admin/{manual-blocks,allowlist}`.
+- **Policies** — `GET/POST/PATCH /api/v1/admin/policies` and `/policies/{id}/thresholds`. Threshold matrix editor; preview of resulting blocklist count via `GET /api/v1/admin/policies/{id}/preview`.
+- **Reporters / Consumers / Tokens** — CRUD via admin endpoints. Token creation shows the raw token **once** in a modal with a copy button.
+- **Categories** — CRUD with decay function picker (linear / exponential) and decay parameter input with live preview chart (preview is local-only JS, no API call needed).
+- **Audit** — `GET /api/v1/admin/audit-log`, filterable.
+- **Settings** — `GET /api/v1/admin/jobs/status` and `GET /api/v1/admin/config` (returns effective config with secrets masked). Admin-only manual job triggers via `POST /api/v1/admin/jobs/trigger/{name}`.
+
+### Identity resolution flow (login)
+
+**Local admin**:
+1. User submits `/login/local` form.
+2. UI verifies password against `LOCAL_ADMIN_PASSWORD_HASH` (Argon2id, in UI env).
+3. UI calls `POST /api/v1/auth/users/upsert-local` with the username.
+4. UI stores the returned `user_id` in the session.
+
+**OIDC**:
+1. User clicks "Sign in with Microsoft".
+2. UI starts authorization-code-with-PKCE flow against Entra.
+3. Callback arrives; UI exchanges code, validates ID token, extracts `sub`, `email`, `name`, `groups`.
+4. UI calls `POST /api/v1/auth/users/upsert-oidc`.
+5. UI stores the returned `user_id` in the session.
+
+The session contains: `user_id`, `display_name`, `role` (cached from the upsert response), `expires_at`. On every request the UI sets `Authorization: Bearer <UI_SERVICE_TOKEN>` and `X-Acting-User-Id: <user_id>` when calling the API.
+
+### UX requirements
+
+- Light/dark mode toggle in top nav, persisted in `localStorage`, defaults to system preference. Tailwind `dark:` variant; CSS variables for accent.
+- Modern look: generous whitespace, rounded-xl, subtle shadows, monospace for IPs/tokens, color-coded category chips.
+- All destructive actions confirm via modal.
+- Mobile-responsive (sidebar collapses to drawer below `md`).
+- All forms server-validated by surfacing the API's validation errors; show inline.
+- No client-side framework heavier than Alpine.js.
+- API errors render as toast notifications, never raw JSON.
+
+### RBAC matrix
+
+Identical to before. The UI does **not** enforce RBAC by hiding buttons alone — the API is the source of truth. The UI does hide UI elements the user can't use, but treats this as cosmetic; security comes from the API rejecting unauthorized calls.
+
+| Action                             | viewer | operator | admin |
+|------------------------------------|:------:|:--------:|:-----:|
+| View IPs / scores / history        |   ✓    |    ✓     |   ✓   |
+| Create / remove manual blocks      |        |    ✓     |   ✓   |
+| Manage allowlist                   |        |    ✓     |   ✓   |
+| Manage policies / categories       |        |          |   ✓   |
+| Manage reporters / consumers       |        |          |   ✓   |
+| Manage tokens                      |        |          |   ✓   |
+| Manage users / role mappings       |        |          |   ✓   |
+| Trigger manual jobs                |        |          |   ✓   |
+| View audit log                     |   ✓    |    ✓     |   ✓   |
+
+---
+
+## 8. Authentication & Authorization
+
+Authentication is split between the two containers along clean lines:
+
+- **`api`** owns: validation of all token kinds (reporter, consumer, admin, service); the `users`, `oidc_role_mappings`, and `api_tokens` tables; RBAC enforcement on every admin endpoint.
+- **`ui`** owns: browser sessions; the OIDC redirect/callback flow; local admin password validation; rendering of login forms.
+
+### Token kinds (all stored in `api_tokens`, hashed at rest)
+
+- **`reporter`** — calls `POST /api/v1/report`. Bound to a reporter record.
+- **`consumer`** — calls `GET /api/v1/blocklist`. Bound to a consumer record.
+- **`admin`** — calls `/api/v1/admin/*` directly. Bound to a configured role (`viewer` / `operator` / `admin`). For automation that doesn't go through the UI.
+- **`service`** — calls `/api/v1/admin/*` and `/api/v1/auth/*` with `X-Acting-User-Id`. Held by the `ui` container and never exposed to humans.
+
+Token format: `irdb_<kind>_<32-char-base32>` (e.g. `irdb_rep_ABCD…`, `irdb_con_…`, `irdb_adm_…`, `irdb_svc_…`). The kind prefix aids ops/log triage; auth still validates against the hashed full token. Service tokens never appear in the UI's token list.
+
+### How the UI authenticates the browser user (BFF flow)
+
+#### Microsoft Entra ID (OIDC) — handled in `ui`
+- Standard authorization-code flow with PKCE.
+- Required scopes: `openid profile email`. Plus the `groups` claim (preferred via Entra app config, not the Graph API).
+- The UI validates the ID token (signature, issuer, audience, expiry, nonce) using the JWKS endpoint.
+- On successful validation, the UI calls `POST /api/v1/auth/users/upsert-oidc`. The API resolves the role from `oidc_role_mappings` (or `OIDC_DEFAULT_ROLE` if no group matches; set to `none` to deny login). The UI stores the returned `user_id` in the session.
+
+#### Local admin — handled in `ui`
+- `LOCAL_ADMIN_ENABLED`, `LOCAL_ADMIN_USERNAME`, `LOCAL_ADMIN_PASSWORD_HASH` are env vars on the `ui` container only.
+- UI validates the password against the Argon2id hash, then calls `POST /api/v1/auth/users/upsert-local`.
+- Local admin always has `admin` role.
+- Login form at `/login` shows two options: "Sign in with Microsoft" (primary) and "Local sign-in" (collapsed by default; hidden entirely if `LOCAL_ADMIN_ENABLED=false`).
+- Sessions: PHP native, file-backed inside the container, `SameSite=Lax`, `Secure` when `APP_ENV=production`. Sessions are tied to a specific UI replica — sticky sessions required when scaling UI horizontally (which is unusual; UI is typically single-replica).
+
+### How the API authenticates calls from the UI
+
+Every UI-originated API call carries:
+```
+Authorization: Bearer <UI_SERVICE_TOKEN>
+X-Acting-User-Id: <user_id>
+```
+
+The API:
+1. Validates the service token.
+2. Looks up the user by id; rejects `404` if not found, `403` if the user is disabled.
+3. Applies RBAC for that user's role.
+4. Logs the resulting action in `audit_log` with `actor_kind=user`, `actor_id=<user_id>` (NOT the service token).
+
+`X-Acting-User-Id` is **only** trusted in combination with the service token. It's ignored on calls authenticated with other token kinds.
+
+### RBAC enforcement
+
+The API has a single `RbacMiddleware` that runs after authentication. Each admin endpoint declares the required role. The middleware checks the resolved role (from the user record or admin token) against the requirement and returns `403` on mismatch.
+
+The UI also conditionally renders elements based on the cached role in the session, but this is purely cosmetic. Anything important is enforced server-side.
+
+### CSRF
+
+- UI forms: per-session CSRF token on every state-changing form. Validated by UI middleware before forwarding to the API.
+- API: stateless, Bearer-authenticated, CSRF-exempt.
+
+---
+
+## 9. Configuration
+
+Single `.env` file at the repo root, consumed by docker-compose. Each container reads only the variables it needs.
+
+### Shared (both containers)
+```dotenv
+# A 32-byte hex string. Used by api to authenticate the ui's calls.
+# Generate with: openssl rand -hex 32
+UI_SERVICE_TOKEN=
+```
+
+### `api` container
+```dotenv
+APP_ENV=production           # development | production
+LOG_LEVEL=info
+APP_SECRET=                  # 32-byte hex; used internally for signing things like ETags
+
+# Database
+DB_DRIVER=sqlite             # sqlite | mysql
+DB_SQLITE_PATH=/data/irdb.sqlite
+DB_MYSQL_HOST=
+DB_MYSQL_PORT=3306
+DB_MYSQL_DATABASE=
+DB_MYSQL_USERNAME=
+DB_MYSQL_PASSWORD=
+
+# OIDC role mapping (defaults applied if no group mapping matches)
+OIDC_DEFAULT_ROLE=viewer     # viewer | none
+
+# Reputation engine
+SCORE_RECOMPUTE_INTERVAL_SECONDS=300
+SCORE_REPORT_HARD_CUTOFF_DAYS=365
+
+# Internal jobs
+INTERNAL_JOB_TOKEN=                       # 32-byte hex
+JOB_RECOMPUTE_MAX_RUNTIME_SECONDS=240
+JOB_RECOMPUTE_MAX_ROWS_PER_TICK=5000
+JOB_AUDIT_RETENTION_DAYS=180
+JOB_GEOIP_REFRESH_INTERVAL_DAYS=7
+
+# GeoIP
+GEOIP_ENABLED=true
+GEOIP_COUNTRY_DB=/data/geoip/GeoLite2-Country.mmdb
+GEOIP_ASN_DB=/data/geoip/GeoLite2-ASN.mmdb
+MAXMIND_LICENSE_KEY=
+
+# CORS — origin of the ui container (or future SPA frontend)
+UI_ORIGIN=http://localhost:8080
+
+# Rate limiting (public API)
+API_RATE_LIMIT_PER_SECOND=60
+```
+
+### `ui` container
+```dotenv
+APP_ENV=production
+LOG_LEVEL=info
+UI_SECRET=                   # 32-byte hex; signs session cookies
+PUBLIC_URL=http://localhost:8080
+
+# Where the ui finds the api (internal docker network DNS)
+API_BASE_URL=http://api:8081
+
+# OIDC (Entra ID) — lives in ui only
+OIDC_ENABLED=true
+OIDC_ISSUER=https://login.microsoftonline.com/<tenant>/v2.0
+OIDC_CLIENT_ID=
+OIDC_CLIENT_SECRET=
+OIDC_REDIRECT_URI=https://reputation.example.com/oidc/callback
+
+# Local admin — lives in ui only
+LOCAL_ADMIN_ENABLED=true
+LOCAL_ADMIN_USERNAME=admin
+LOCAL_ADMIN_PASSWORD_HASH=
+```
+
+A complete `.env.example` documents every variable with comments. The README walks through generating the secrets.
+
+---
+
+## 10. Docker
+
+**Two images, one repo.** The `api` and `ui` are built independently from `api/Dockerfile` and `ui/Dockerfile`. They run as separate compose services. Periodic batch work in the `api` is triggered by an external scheduler hitting `/internal/jobs/*`. An optional sidecar overlay provides scheduling for users who don't want host cron.
+
+### Images
+
+**`api/Dockerfile`** (multi-stage):
+1. `composer:2` stage — `composer install --no-dev --optimize-autoloader`.
+2. Final `dunglas/frankenphp:1-php8.3-alpine` — install required PHP extensions (`pdo_sqlite`, `pdo_mysql`, `mbstring`, `intl`, `opcache`, `bcmath`), copy app + vendor, configure FrankenPHP via `api/docker/Caddyfile`. GeoLite2 download happens at build time if `MAXMIND_LICENSE_KEY` build-arg is provided.
+- `ENTRYPOINT` is `api/docker/entrypoint.sh` — dispatcher with modes `api` (default), `migrate`.
+
+**`ui/Dockerfile`** (multi-stage):
+1. `node:20-alpine` stage — `npm ci && npm run build` produces `public/assets/app.css` and `public/assets/app.js`.
+2. `composer:2` stage — `composer install --no-dev --optimize-autoloader`.
+3. Final `dunglas/frankenphp:1-php8.3-alpine` — install `mbstring`, `intl`, `opcache`, copy app + assets + vendor, configure via `ui/docker/Caddyfile`.
+- `ENTRYPOINT` is `ui/docker/entrypoint.sh` — single mode (`ui`), no migrations.
+
+### Containers
+
+**`api`** — JSON backend on `:8081`
+- Healthcheck: `GET /healthz` returns 200 with `{status, db, jobs: {...}}`.
+- Stateless when using MySQL; can be scaled to N replicas.
+
+**`ui`** — BFF on `:8080`
+- Healthcheck: `GET /healthz` returns 200 with `{status, api_reachable, last_api_check_at}`. Returns 200 even if the api is briefly unreachable (the UI renders degraded states).
+- Single replica is recommended (sticky sessions otherwise).
+
+**`migrate`** — one-shot, runs Phinx migrations against the api's database, seeds defaults, ensures the service token exists, then exits 0.
+- Built from `api/Dockerfile`, command `migrate`.
+- `restart: "no"` in compose.
+
+### docker-compose.yml (canonical, host-driven scheduler)
+
+```yaml
+services:
+  migrate:
+    image: irdb-api:latest
+    build: { context: ./api }
+    command: migrate
+    env_file: .env
+    volumes:
+      - irdb-data:/data
+    restart: "no"
+
+  api:
+    image: irdb-api:latest
+    command: api
+    env_file: .env
+    ports:
+      - "8081:8081"
+    volumes:
+      - irdb-data:/data
+    depends_on:
+      migrate:
+        condition: service_completed_successfully
+    healthcheck:
+      test: ["CMD", "wget", "-qO-", "http://localhost:8081/healthz"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+    restart: unless-stopped
+
+  ui:
+    image: irdb-ui:latest
+    build: { context: ./ui }
+    env_file: .env
+    ports:
+      - "8080:8080"
+    depends_on:
+      api:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+    restart: unless-stopped
+
+  # Uncomment to use MySQL. Also set DB_DRIVER=mysql in .env.
+  # mysql:
+  #   image: mysql:8
+  #   environment:
+  #     MYSQL_DATABASE: ${DB_MYSQL_DATABASE}
+  #     MYSQL_USER: ${DB_MYSQL_USERNAME}
+  #     MYSQL_PASSWORD: ${DB_MYSQL_PASSWORD}
+  #     MYSQL_ROOT_PASSWORD: ${DB_MYSQL_ROOT_PASSWORD}
+  #   volumes:
+  #     - mysql-data:/var/lib/mysql
+  #   healthcheck:
+  #     test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
+  #     interval: 10s
+  #     timeout: 5s
+  #     retries: 10
+  #   restart: unless-stopped
+
+volumes:
+  irdb-data:
+  # mysql-data:
+```
+
+### Scheduling — three documented options
+
+**Option A: Host cron (recommended for VM deployments)**
+```cron
+* * * * * curl -sf -m 280 -X POST -H "Authorization: Bearer $INTERNAL_JOB_TOKEN" http://localhost:8081/internal/jobs/tick > /dev/null
+```
+
+**Option B: systemd timer**
+
+Provide `examples/scheduler/irdb-tick.service` and `examples/scheduler/irdb-tick.timer`. Documented in README.
+
+**Option C: Sidecar overlay (`compose.scheduler.yml`)**
+```yaml
+services:
+  scheduler:
+    image: alpine:3
+    command: >
+      sh -c "
+      apk add --no-cache curl tini &&
+      exec tini -- crond -f -L /dev/stdout
+      "
+    volumes:
+      - ./docker/scheduler.crontab:/etc/crontabs/root:ro
+    environment:
+      INTERNAL_JOB_TOKEN: ${INTERNAL_JOB_TOKEN}
+    depends_on:
+      api:
+        condition: service_healthy
+    restart: unless-stopped
+```
+
+`docker/scheduler.crontab`:
+```cron
+* * * * * curl -sf -m 280 -X POST -H "Authorization: Bearer $INTERNAL_JOB_TOKEN" http://api:8081/internal/jobs/tick > /dev/null
+```
+
+Started with: `docker compose -f docker-compose.yml -f compose.scheduler.yml up -d`.
+
+### SQLite on a shared volume — known constraint
+
+The `api` container writes to `/data/irdb.sqlite` through the `irdb-data` volume. SQLite's WAL mode handles this correctly on a **local** Docker volume. It does **not** work reliably on networked storage (NFS, SMB, EFS). The README must call this out and recommend MySQL for any deployment using networked storage or multiple hosts.
+
+On every connection startup the `api` sets:
+```sql
+PRAGMA journal_mode = WAL;
+PRAGMA synchronous = NORMAL;
+PRAGMA busy_timeout = 5000;
+PRAGMA foreign_keys = ON;
+```
+
+The `ui` container does not touch the volume; the volume is exclusively the api's.
+
+### Scaling notes
+
+- With **MySQL**, `api` is stateless and can be replicated (`docker compose up --scale api=3` behind a load balancer). The scheduler fires against the LB; `job_locks` ensures only one replica actually runs each job.
+- With **SQLite**, do not scale `api` beyond 1; vertical scaling only.
+- `ui` is typically single-replica due to local-file sessions. To scale, either: (a) use sticky sessions at the LB, or (b) replace the UI with a future stateless frontend (out of scope).
+
+### Reverse proxy in production
+
+For production, users typically front both containers with nginx/Caddy/Traefik on the host. A representative Caddy config is provided in `examples/reverse-proxy/`:
+```
+reputation.example.com        → ui:8080
+reputation-api.example.com    → api:8081
+```
+
+Single-hostname routing (e.g. everything under `reputation.example.com` with `/api/*` → api, `/*` → ui) also works and is documented as an alternative. The browser must reach the UI; firewalls/reporters reach the API.
+
+---
+
+## 11. Project Structure
+
+Monorepo. Each container has its own subdirectory with its own `composer.json`, tests, and Dockerfile. Documentation lives at the root in `doc/`. Examples and shared compose files at the root.
+
+```
+.
+├── README.md                        # quickstart, links to doc/
+├── PLAN.md                          # written first, before coding
+├── PROGRESS.md                      # updated after each milestone
+├── docker-compose.yml
+├── compose.scheduler.yml
+├── .env.example
+│
+├── doc/                             # ★ first-class documentation, see §17
+│   ├── architecture.md
+│   ├── api-overview.md
+│   ├── auth-flows.md
+│   ├── frontend-development.md
+│   └── api-reference.md
+│
+├── examples/
+│   ├── reporters/                   # curl, python, bash sample report scripts
+│   ├── consumers/                   # iptables-restore, nginx include, HAProxy ACL
+│   ├── scheduler/
+│   │   ├── host.crontab
+│   │   ├── irdb-tick.service
+│   │   └── irdb-tick.timer
+│   └── reverse-proxy/
+│       └── Caddyfile
+│
+├── api/                             # ─────── api container ───────
+│   ├── Dockerfile
+│   ├── composer.json
+│   ├── phpunit.xml
+│   ├── phpstan.neon
+│   ├── bin/
+│   │   └── console                  # CLI: migrate, seed, scores:rebuild, jobs:run <name>, tokens:create
+│   ├── config/
+│   │   ├── settings.php
+│   │   └── phinx.php
+│   ├── db/
+│   │   ├── migrations/
+│   │   └── seeds/
+│   ├── docker/
+│   │   ├── Caddyfile                # /api/v1/* public, /internal/* network-restricted
+│   │   └── entrypoint.sh            # modes: api | migrate
+│   ├── public/
+│   │   └── index.php                # Slim entry
+│   ├── src/
+│   │   ├── App/
+│   │   │   ├── Bootstrap.php
+│   │   │   ├── Container.php
+│   │   │   └── Routes.php
+│   │   ├── Domain/
+│   │   │   ├── Reputation/          # scoring, decay, policy evaluator
+│   │   │   ├── Ip/                  # parsing, normalization, CIDR ops
+│   │   │   ├── Enrichment/          # MaxMind wrapper
+│   │   │   └── Audit/
+│   │   ├── Infrastructure/
+│   │   │   ├── Db/                  # DBAL, repositories
+│   │   │   ├── Auth/                # token resolver, role mapper, RBAC middleware
+│   │   │   ├── Http/                # middlewares (auth, rate limit, CORS, internal-only, error handler)
+│   │   │   └── Jobs/                # job runner, locks, tick dispatcher, individual jobs
+│   │   ├── Application/
+│   │   │   ├── Public/              # /api/v1/{report,blocklist}
+│   │   │   ├── Admin/               # /api/v1/admin/*
+│   │   │   ├── Auth/                # /api/v1/auth/* (UI BFF only)
+│   │   │   └── Internal/            # /internal/jobs/*
+│   │   └── Support/
+│   └── tests/
+│       ├── Unit/
+│       ├── Integration/             # spins up Slim app with in-memory SQLite
+│       └── Fixtures/
+│
+└── ui/                              # ─────── ui container ───────
+    ├── Dockerfile
+    ├── composer.json
+    ├── package.json
+    ├── tailwind.config.js
+    ├── postcss.config.js
+    ├── phpunit.xml
+    ├── phpstan.neon
+    ├── docker/
+    │   ├── Caddyfile
+    │   └── entrypoint.sh
+    ├── public/
+    │   ├── index.php                # Slim entry
+    │   └── assets/                  # built CSS/JS
+    ├── resources/
+    │   ├── css/app.css
+    │   ├── js/app.js
+    │   └── views/                   # Twig templates
+    │       ├── layout.twig
+    │       ├── pages/
+    │       │   ├── login.twig
+    │       │   ├── dashboard.twig
+    │       │   ├── ips/
+    │       │   ├── policies/
+    │       │   ├── tokens/
+    │       │   ├── audit.twig
+    │       │   └── settings.twig
+    │       └── partials/
+    ├── src/
+    │   ├── App/
+    │   │   ├── Bootstrap.php
+    │   │   ├── Container.php
+    │   │   └── Routes.php
+    │   ├── ApiClient/                # Guzzle-based; one method per endpoint group
+    │   │   ├── ApiClient.php
+    │   │   ├── AdminClient.php
+    │   │   ├── AuthClient.php
+    │   │   └── DTOs/
+    │   ├── Auth/
+    │   │   ├── OidcController.php
+    │   │   ├── LocalLoginController.php
+    │   │   ├── SessionManager.php
+    │   │   └── ImpersonationHeaderMiddleware.php
+    │   ├── Controllers/              # one per UI section; thin, calls ApiClient
+    │   ├── Http/                     # CSRF, error handler, flash messages
+    │   └── Support/
+    └── tests/
+        ├── Unit/
+        └── Integration/              # spins up ui Slim app with mocked ApiClient
+```
+
+---
+
+## 12. Implementation Milestones
+
+Execute in order. After each milestone: run tests, run linter, commit with a clear message, and update `PROGRESS.md` with a one-paragraph summary. Do not start the next milestone until the current one passes its acceptance criteria.
+
+### M1 — Monorepo skeleton & toolchain
+- Repo layout from §11. Both `api/composer.json` and `ui/composer.json` boot a Slim app. `ui/package.json` builds Tailwind. Both PHPUnit suites run (empty). `api/Dockerfile`, `ui/Dockerfile`, `docker-compose.yml`, root `.env.example`.
+- All three services build and start under compose. `api` returns a placeholder `/healthz`. `ui` returns a placeholder `/healthz` and shows a "hello" page. `migrate` runs an empty Phinx set and exits 0.
+- **Done when**: `docker compose build` succeeds; `docker compose up` brings api and ui to healthy and migrate exited 0; both `phpunit` runs pass; CI runs `phpstan` and `php-cs-fixer --dry-run` on both subprojects.
+
+### M2 — Database & migrations (api)
+- DBAL configured for SQLite + MySQL. Phinx migrations for every table in §4 (including `job_locks`, `job_runs`). Seeds for default categories and policies (`strict`, `moderate`, `paranoid`).
+- IP normalization helper with full unit tests covering IPv4, IPv6, IPv4-in-IPv6, invalid inputs, CIDR parsing, subnet containment.
+- **Done when**: migrations run cleanly on both drivers (test both in CI); `php api/bin/console db:seed` populates defaults; IP helper has ≥95% coverage.
+
+### M3 — API auth foundations
+- Token kinds (`reporter`, `consumer`, `admin`, `service`); creation, hashing, validation. `UI_SERVICE_TOKEN` ensured on container startup. Token-resolver middleware extracts the active principal.
+- `RbacMiddleware` enforces required role per route. `ImpersonationMiddleware` reads `X-Acting-User-Id` only when the resolved token is a service token.
+- `POST /api/v1/auth/users/upsert-oidc` and `upsert-local`. `GET /api/v1/admin/me`.
+- **Done when**: integration tests cover every combination — bad token, wrong-kind token, valid admin token, service token without impersonation header, service token with non-existent user, service token with valid user, role enforcement (viewer denied write, admin allowed). Tests against both SQLite and MySQL.
+
+### M4 — Token system & ingest API
+- Reporter and consumer token CRUD via admin endpoints (raw token shown once in response). Rate limiter (token-bucket; in-process per replica — acceptable for single-replica deployments).
+- `POST /api/v1/report` end-to-end. Append to `reports`. Update `ip_scores` synchronously for the touched (ip, category) pair.
+- **Done when**: integration test posts 100 reports across categories and reads back correct denormalized scores; bad tokens rejected; rate limit returns 429; admin-token kind cannot post reports (wrong kind).
+
+### M5 — Reputation engine + internal job endpoints
+- Decay functions (linear + exponential), score recomputation service, `Clock` interface.
+- `job_locks` and `job_runs` repositories. Job runner abstraction: each job class declares its name, default interval, max runtime; runner handles lock acquire/release, `job_runs` write, error capture.
+- Internal jobs: `recompute-scores`, `cleanup-audit`, `enrich-pending` (skeleton — full enrichment lands in M9), and the `tick` dispatcher.
+- HTTP routes `/internal/jobs/*` behind `InternalNetworkMiddleware` (loopback + RFC1918 only) and `InternalTokenMiddleware`.
+- CLI `php api/bin/console jobs:run <name>` for local invocation.
+- **Done when**: unit tests verify decay math against hand-computed values; integration test ages reports via fixed clock and confirms `recompute-scores` updates `ip_scores` correctly; concurrent calls produce one `success` and one `skipped_locked` row in `job_runs`; calls from outside the allowed network return 404; missing/wrong token returns 401; `tick` invokes only jobs whose interval has elapsed.
+
+### M6 — Manual blocks, allowlist, subnets (api)
+- Repositories + services. Admin endpoints for IP and CIDR (v4/v6) entries. CIDR containment evaluator (in-memory, refresh on change).
+- **Done when**: an IP inside an allowlisted /24 is excluded from any blocklist regardless of score; a manually blocked /16 emits as a single CIDR line; admin tests cover both v4 and v6.
+
+### M7 — Policies & distribution API
+- Policy CRUD endpoints. `GET /api/v1/blocklist` with caching, ETag, plain-text and JSON formats.
+- `GET /api/v1/admin/policies/{id}/preview` returns count + sample of resulting blocklist (used by UI).
+- **Done when**: three seeded policies produce different blocklists from the same data; ETag round-trip returns 304; performance test: 50k scored IPs render blocklist in <500 ms.
+
+### M8 — UI scaffold + auth flows
+- `ui` container: Slim app, base layout (Twig + Tailwind + dark mode toggle), session manager, CSRF middleware, ApiClient with retry and error mapping.
+- Login page, OIDC redirect/callback (PKCE), local admin form. Both call the api's auth endpoints and store `user_id` in the session.
+- `ImpersonationHeaderMiddleware` adds `Authorization: Bearer <UI_SERVICE_TOKEN>` and `X-Acting-User-Id` to every outgoing API call.
+- Logout clears the session.
+- **Done when**: a fresh user can log in via OIDC against a test tenant (document setup in `doc/oidc.md`) and via local admin; `/app/me` page renders showing the user; logout works; CSRF is enforced; an api-down scenario shows a friendly degraded page rather than an exception.
+
+### M9 — UI: IPs, history, dashboard
+- IP search/filter table, IP detail page, timeline component, dashboard with Chart.js.
+- **Done when**: every page renders for all three roles with correct visibility (cosmetic) AND the api enforces correct access (security); dark mode persists; Lighthouse accessibility ≥90.
+
+### M10 — UI: subnets, allowlist, policies, tokens, categories
+- CRUD pages for every admin domain. Token creation modal shows raw token once with copy-to-clipboard.
+- Policy editor with category × threshold matrix.
+- Category editor with decay-curve preview.
+- **Done when**: every admin endpoint reachable from the UI by an admin role; operator can do operator-allowed actions; viewer is read-only; all destructive actions show confirmation modals.
+
+### M11 — Enrichment (api)
+- MaxMind wrapper. `enrich-pending` job processes IPs missing enrichment in batches; `refresh-geoip` job downloads fresh DBs when `MAXMIND_LICENSE_KEY` is set. UI shows country flag + ASN on IP detail.
+- **Done when**: known IPs show country + ASN within one tick after first sighting; missing-DB scenario logs a warning, the enrichment job no-ops cleanly, the rest of the system keeps working.
+
+### M12 — Audit log + Settings page
+- Every write through admin or auth endpoints produces an audit entry attributed to the acting user (NOT the service token). Filterable audit page in UI.
+- Settings page: effective config (secrets masked), per-job status with overdue badges, admin-only manual-trigger buttons that POST to `/api/v1/admin/jobs/trigger/{name}`.
+- **Done when**: every action in the RBAC matrix produces a correctly attributed audit entry; manual job triggers from UI succeed and resulting `job_runs` rows carry `triggered_by = manual`; non-admin users cannot see or invoke manual triggers (UI hides them, api rejects them).
+
+### M13 — Polish, OpenAPI, docs
+- Generate and serve `openapi.yaml`, `/api/docs` viewer.
+- README walks through quickstart (compose with sidecar scheduler), MySQL setup, OIDC setup, reverse-proxy setup.
+- **All `doc/*.md` files written** per §17 — this is a hard requirement, not a nice-to-have.
+- Sample reporter scripts (`curl`, Python, Bash) and sample firewall configs (iptables ipset refresh, nginx allow/deny include, HAProxy ACL) in `examples/`.
+- **Done when**: a fresh clone → `docker compose -f docker-compose.yml -f compose.scheduler.yml up` → admin login → token created → curl example reports an IP → second curl pulls a blocklist containing it → after one minute, a `recompute-scores` row appears in `job_runs`. Steps documented and executed verbatim in CI. All `doc/` files reviewed for accuracy against the as-built code.
+
+### M14 — Hardening
+- Security headers on both containers (CSP, HSTS, X-Frame-Options, Referrer-Policy). UI login throttling and brute-force lockout on local admin. Token entropy verified. Logs scrubbed of secrets. Backup guidance for `/data` and MySQL in README.
+
+---
+
+## 13. Testing Strategy
+
+- **Unit tests**: IP helpers, decay functions, policy evaluator, RBAC checks. Aim ≥80% line coverage in `Domain/`.
+- **Integration tests**: spin up Slim app with in-memory SQLite per test class. Cover every API endpoint and every UI route's status + RBAC.
+- **Matrix CI**: run the full suite against SQLite and MySQL.
+- **Static analysis**: PHPStan level 8 on `src/`. PHP-CS-Fixer for style.
+- **Security**: `composer audit` in CI.
+
+---
+
+## 14. Coding Conventions
+
+- Strict types (`declare(strict_types=1);`) in every PHP file.
+- PSR-12.
+- DI via PHP-DI.
+- No business logic in controllers — controllers parse input, call a service, render a response.
+- Repositories return domain objects, not arrays.
+- All DB writes inside transactions. All time via an injectable `Clock` interface (production: system clock; tests: fixed clock).
+- Exceptions: domain layer throws typed exceptions; HTTP error middleware maps them to status codes.
+- Logging via Monolog to stdout in JSON.
+
+---
+
+## 15. Out of Scope (do not build)
+
+- Multi-tenancy.
+- Federation between IRDB instances.
+- Email/Slack alerting.
+- A public reputation lookup page.
+- Automatic subnet aggregation (manual only, per spec).
+- **Future frontends** (Vue/React/Svelte SPA, native desktop, mobile apps). The architecture deliberately enables them; do not build them. Do not add hooks or speculative endpoints for them. The contract for future frontend authors is documented in `doc/frontend-development.md` (§17), and that document is the deliverable that anticipates this work — not code.
+- Direct user-token issuance (OAuth flows where the API issues tokens to end users for SPA/native/mobile use). Sketched in `doc/auth-flows.md` as future work.
+
+These can be future work; do not introduce hooks for them now beyond what naturally falls out of the design.
+
+---
+
+## 16. Documentation Requirements (`doc/`)
+
+Documentation in `doc/` is a **deliverable**, not a postscript. Future engineers building Vue/native/mobile frontends will read these files first, before touching code. They must be accurate against the as-built system. M13's acceptance criteria gate the milestone on these being complete.
+
+Each file below has a required outline. Claude Code may add subsections but must not omit the required ones. Markdown only; diagrams in Mermaid (rendered by GitHub) where helpful.
+
+### `doc/architecture.md` — Overall architecture
+
+Required sections:
+1. **System overview** — what IRDB does, in two paragraphs.
+2. **Container topology** — Mermaid diagram of api / ui / migrate / scheduler / mysql; what each owns.
+3. **Where state lives** — explicit list: `api` owns the database; `ui` owns browser sessions only.
+4. **Stable surfaces vs replaceable parts** — table identifying what new frontends can rely on (the API contract, token kinds, RBAC roles, `users` shape, OIDC role mapping semantics) versus what may change without notice (Twig templates, UI route paths under `/app/*`, internal class names).
+5. **Why this split** — short rationale: BFF pattern, why ui has no DB, why the api doesn't render HTML.
+
+### `doc/api-overview.md` — Public API surface
+
+Audience: machine-client integrators (firewalls, fail2ban-style agents, monitoring) and frontend authors.
+
+Required sections:
+1. **Base URL & versioning** — single `v1` major version, additive-only changes within `v1`.
+2. **Authentication** — short summary of token kinds (table), with link to `auth-flows.md` for detail.
+3. **Endpoint groups** — Public, Admin, Auth, Internal — what each is for and who calls it.
+4. **Common conventions** — JSON shape, error envelope, pagination, ETag, rate limiting, IP normalization, CIDR notation.
+5. **Worked examples** — `curl` snippets for: posting a report, pulling a blocklist, an admin search with service-token impersonation, an admin search with an admin-kind token.
+6. **Pointer to OpenAPI** — `/api/v1/openapi.yaml` is the source of truth for endpoint schemas; this document is for context the spec doesn't capture.
+
+### `doc/auth-flows.md` — All authentication flows
+
+Required sections:
+1. **Overview** — table of who calls what with which token kind.
+2. **Reporter / consumer flow** (machine clients) — sequence diagram (Mermaid): client → api with Bearer.
+3. **Admin token flow** — for automation that doesn't go through the UI.
+4. **UI BFF flow (current)** — full sequence diagram showing browser → ui → api with service token + impersonation header. Cover both OIDC and local admin paths.
+5. **OIDC details** — Entra app registration steps (app type, redirect URI, claims config for `groups`, optional API permissions). Include screenshots-by-description so a reader can replicate without seeing real screenshots.
+6. **Local admin** — when to use it, why it's UI-side, how to generate the password hash (`php -r "echo password_hash('s3cret', PASSWORD_ARGON2ID);"`), why it's discouraged in production.
+7. **Future: direct user tokens (out of scope, sketched)** — a section describing how a future native/mobile/SPA flow would work without the BFF: a new `/api/v1/auth/oauth/*` endpoint group issuing user-bound bearer tokens after some flow (OIDC pass-through, device code, etc.). Explicitly marked "NOT IMPLEMENTED" with a note that this is the recommended extension point.
+8. **CSRF, sessions, CORS** — what's where and why.
+
+### `doc/frontend-development.md` — Building a new frontend
+
+The headline document for future UI authors. Audience: someone tasked with rewriting the UI in Vue, building a Tauri desktop app, or a mobile app.
+
+Required sections:
+1. **Read this first** — the contract: API + auth model is stable; UI can be replaced wholesale. Link to `architecture.md`'s "stable surfaces" table.
+2. **Three integration patterns**:
+   - **(a) BFF replacement** (drop-in for the current PHP UI) — same pattern, different language. New container holds the service token, manages sessions, calls api with impersonation header. Easiest path; works for SSR-style frontends (Next.js, Nuxt, Rails, Django). Worked example: pseudocode of a Node/Express BFF showing the three critical bits (OIDC handling, session storage, outgoing API call with impersonation header).
+   - **(b) SPA + thin BFF** — Vue/React/Svelte SPA in browser, talks to a thin BFF for auth only. The BFF mints short-lived signed cookies the SPA presents on each request to the api. Pros, cons, when to choose this.
+   - **(c) Direct API access (native / mobile / SPA without BFF)** — requires the user-token flow that's out of scope today. Reader is told: "this needs api work first; see `auth-flows.md` §7".
+3. **Minimum API surface a frontend needs** — checklist of admin endpoints a fully featured UI calls. Links to OpenAPI.
+4. **CORS** — how to configure `UI_ORIGIN`. For BFF pattern (server-to-server) CORS doesn't apply; for SPA pattern it does.
+5. **Local development** — how to run only the api container and point a non-PHP frontend at it (`docker compose up api migrate` then your frontend dev server with `API_BASE_URL=http://localhost:8081`).
+6. **Migration path** — how to swap the current UI with a new one without downtime: stand the new container next to the old at a different hostname, switch DNS, retire the old.
+7. **What NOT to do** — don't replicate business logic (scoring, RBAC, decay) in the frontend; don't store user data in the frontend's storage; don't bypass the service-token pattern by giving the SPA the service token directly.
+
+### `doc/api-reference.md` — Pointer + extras
+
+Short. Tells readers the OpenAPI document is at `/api/v1/openapi.yaml` and is canonical. Documents the small set of things OpenAPI doesn't cleanly express: rate-limit headers, ETag semantics, the impersonation header convention, the response envelope for batched future endpoints.
+
+### Quality bar
+
+- Every code snippet in `doc/` must be runnable as-is against a default `docker compose up` deployment, modulo tokens and hostnames the reader needs to fill in.
+- Every claim about the API or auth flow must match what the code does. CI step: a test that grep-checks for stale endpoint paths and token kinds.
+- No "TODO" or "coming soon" sections. If something isn't built, it's marked clearly under "Out of scope / future" with the rationale, not as an empty placeholder.
+- No screenshots (they go stale fast); use descriptive prose and Mermaid diagrams.
+- Each `doc/*.md` file ≤ 500 lines. If it grows beyond that, split it.
+
+---
+
+## 17. How to Work
+
+1. Start by creating a `PLAN.md` in the repo summarizing how you'll tackle M1–M3 in concrete tasks. Stop and wait for me to confirm before coding.
+2. Maintain a TODO list in your scratchpad. Tick items as you go.
+3. After each milestone, update `PROGRESS.md` and run the full test suite + linters.
+4. If a requirement here turns out to be ambiguous or wrong once you're in the code, **stop and ask** — don't paper over it.
+5. Prefer fewer, well-tested modules over many half-finished ones. It is better to ship M1–M5 solidly than M1–M11 shakily.
+
+Begin with the `PLAN.md`.

+ 24 - 0
api/.php-cs-fixer.dist.php

@@ -0,0 +1,24 @@
+<?php
+
+declare(strict_types=1);
+
+$finder = PhpCsFixer\Finder::create()
+    ->in(__DIR__ . '/src')
+    ->in(__DIR__ . '/tests')
+    ->in(__DIR__ . '/config')
+    ->in(__DIR__ . '/public');
+
+return (new PhpCsFixer\Config())
+    ->setRiskyAllowed(true)
+    ->setRules([
+        '@PSR12' => true,
+        '@PSR12:risky' => true,
+        'declare_strict_types' => true,
+        'array_syntax' => ['syntax' => 'short'],
+        'no_unused_imports' => true,
+        'ordered_imports' => ['sort_algorithm' => 'alpha'],
+        'single_quote' => true,
+        'trailing_comma_in_multiline' => true,
+    ])
+    ->setFinder($finder)
+    ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache');

+ 43 - 0
api/Dockerfile

@@ -0,0 +1,43 @@
+# syntax=docker/dockerfile:1.7
+
+# ---------- composer stage ----------
+FROM composer:2 AS deps
+WORKDIR /app
+COPY composer.json composer.lock* ./
+RUN composer install --no-dev --no-interaction --no-scripts --no-progress --optimize-autoloader
+
+# ---------- runtime ----------
+FROM dunglas/frankenphp:1-php8.3-alpine
+
+ENV PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d
+
+# System deps for PHP extensions
+RUN apk add --no-cache \
+        icu-dev \
+        oniguruma-dev \
+        sqlite-dev \
+        bash \
+    && install-php-extensions \
+        pdo_sqlite \
+        pdo_mysql \
+        mbstring \
+        intl \
+        opcache \
+        bcmath
+
+WORKDIR /app
+
+COPY --from=deps /app/vendor ./vendor
+COPY . ./
+
+# Caddyfile and entrypoint
+COPY docker/Caddyfile /etc/Caddyfile
+COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
+RUN chmod +x /usr/local/bin/entrypoint.sh \
+    && chmod +x bin/console \
+    && mkdir -p /data
+
+EXPOSE 8081
+
+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+CMD ["api"]

+ 30 - 0
api/bin/console

@@ -0,0 +1,30 @@
+#!/usr/bin/env php
+<?php
+
+declare(strict_types=1);
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$argv = $_SERVER['argv'] ?? [];
+$command = $argv[1] ?? null;
+
+switch ($command) {
+    case 'db:migrate':
+        $cmd = sprintf(
+            '%s --configuration=%s',
+            escapeshellarg(__DIR__ . '/../vendor/bin/phinx') . ' migrate',
+            escapeshellarg(__DIR__ . '/../config/phinx.php')
+        );
+        passthru($cmd, $exitCode);
+        exit($exitCode);
+
+    case null:
+    case '--help':
+    case '-h':
+        fwrite(STDOUT, "Usage: console <command>\n\nCommands:\n  db:migrate   Run Phinx migrations\n");
+        exit(0);
+
+    default:
+        fwrite(STDERR, "Unknown command: {$command}\n");
+        exit(1);
+}

+ 47 - 0
api/composer.json

@@ -0,0 +1,47 @@
+{
+    "name": "irdb/api",
+    "description": "IRDB — JSON API backend (Slim 4 on FrankenPHP)",
+    "type": "project",
+    "license": "proprietary",
+    "require": {
+        "php": "^8.3",
+        "slim/slim": "^4.12",
+        "slim/psr7": "^1.6",
+        "doctrine/dbal": "^4.0",
+        "robmorgan/phinx": "^0.16",
+        "monolog/monolog": "^3.5",
+        "php-di/php-di": "^7.0",
+        "guzzlehttp/psr7": "^2.6"
+    },
+    "require-dev": {
+        "phpunit/phpunit": "^11.0",
+        "phpstan/phpstan": "^1.10",
+        "friendsofphp/php-cs-fixer": "^3.0",
+        "vlucas/phpdotenv": "^5.6"
+    },
+    "autoload": {
+        "psr-4": {
+            "App\\": "src/"
+        }
+    },
+    "autoload-dev": {
+        "psr-4": {
+            "App\\Tests\\": "tests/"
+        }
+    },
+    "config": {
+        "sort-packages": true,
+        "platform": {
+            "php": "8.3"
+        },
+        "allow-plugins": {
+            "php-http/discovery": true
+        }
+    },
+    "scripts": {
+        "test": "phpunit",
+        "stan": "phpstan analyse --memory-limit=512M",
+        "cs": "php-cs-fixer fix --dry-run --diff",
+        "cs-fix": "php-cs-fixer fix"
+    }
+}

+ 6601 - 0
api/composer.lock

@@ -0,0 +1,6601 @@
+{
+    "_readme": [
+        "This file locks the dependencies of your project to a known state",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+        "This file is @generated automatically"
+    ],
+    "content-hash": "1707572c0f900c2df05fe3a3b81d0153",
+    "packages": [
+        {
+            "name": "cakephp/chronos",
+            "version": "3.5.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/cakephp/chronos.git",
+                "reference": "e6e777b534244911566face8a5dbdbd7f7bda5a6"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/cakephp/chronos/zipball/e6e777b534244911566face8a5dbdbd7f7bda5a6",
+                "reference": "e6e777b534244911566face8a5dbdbd7f7bda5a6",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "psr/clock": "^1.0"
+            },
+            "provide": {
+                "psr/clock-implementation": "1.0"
+            },
+            "require-dev": {
+                "cakephp/cakephp-codesniffer": "^5.0",
+                "phpunit/phpunit": "^10.5.58 || ^11.5.3 || ^12.1.3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Cake\\Chronos\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Brian Nesbitt",
+                    "email": "brian@nesbot.com",
+                    "homepage": "http://nesbot.com"
+                },
+                {
+                    "name": "The CakePHP Team",
+                    "homepage": "https://cakephp.org"
+                }
+            ],
+            "description": "A simple API extension for DateTime.",
+            "homepage": "https://cakephp.org",
+            "keywords": [
+                "date",
+                "datetime",
+                "time"
+            ],
+            "support": {
+                "issues": "https://github.com/cakephp/chronos/issues",
+                "source": "https://github.com/cakephp/chronos"
+            },
+            "time": "2026-04-10T02:50:39+00:00"
+        },
+        {
+            "name": "cakephp/core",
+            "version": "5.3.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/cakephp/core.git",
+                "reference": "eb012517900ed288f580aa3487e9a09f28ea85f9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/cakephp/core/zipball/eb012517900ed288f580aa3487e9a09f28ea85f9",
+                "reference": "eb012517900ed288f580aa3487e9a09f28ea85f9",
+                "shasum": ""
+            },
+            "require": {
+                "cakephp/utility": "^5.3.0",
+                "league/container": "^5.1",
+                "php": ">=8.2",
+                "psr/container": "^1.1 || ^2.0"
+            },
+            "provide": {
+                "psr/container-implementation": "^2.0"
+            },
+            "suggest": {
+                "cakephp/cache": "To use Configure::store() and restore().",
+                "cakephp/event": "To use PluginApplicationInterface or plugin applications.",
+                "league/container": "To use Container and ServiceProvider classes"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-5.next": "5.4.x-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "functions.php"
+                ],
+                "psr-4": {
+                    "Cake\\Core\\": "."
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "CakePHP Community",
+                    "homepage": "https://github.com/cakephp/core/graphs/contributors"
+                }
+            ],
+            "description": "CakePHP Framework Core classes",
+            "homepage": "https://cakephp.org",
+            "keywords": [
+                "cakephp",
+                "core",
+                "framework"
+            ],
+            "support": {
+                "forum": "https://stackoverflow.com/tags/cakephp",
+                "irc": "irc://irc.freenode.org/cakephp",
+                "issues": "https://github.com/cakephp/cakephp/issues",
+                "source": "https://github.com/cakephp/core"
+            },
+            "time": "2026-03-31T06:25:23+00:00"
+        },
+        {
+            "name": "cakephp/database",
+            "version": "5.3.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/cakephp/database.git",
+                "reference": "cf94dcb57c54a1a308fd866b038cd6995910e36e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/cakephp/database/zipball/cf94dcb57c54a1a308fd866b038cd6995910e36e",
+                "reference": "cf94dcb57c54a1a308fd866b038cd6995910e36e",
+                "shasum": ""
+            },
+            "require": {
+                "cakephp/chronos": "^3.3",
+                "cakephp/core": "^5.3.0",
+                "cakephp/datasource": "^5.3.0",
+                "php": ">=8.2",
+                "psr/log": "^3.0"
+            },
+            "require-dev": {
+                "cakephp/i18n": "^5.3.0",
+                "cakephp/log": "^5.3.0"
+            },
+            "suggest": {
+                "cakephp/i18n": "If you are using locale-aware datetime formats.",
+                "cakephp/log": "If you want to use query logging without providing a logger yourself."
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-5.next": "5.4.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Cake\\Database\\": "."
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "CakePHP Community",
+                    "homepage": "https://github.com/cakephp/database/graphs/contributors"
+                }
+            ],
+            "description": "Flexible and powerful Database abstraction library with a familiar PDO-like API",
+            "homepage": "https://cakephp.org",
+            "keywords": [
+                "abstraction",
+                "cakephp",
+                "database",
+                "database abstraction",
+                "pdo"
+            ],
+            "support": {
+                "forum": "https://stackoverflow.com/tags/cakephp",
+                "irc": "irc://irc.freenode.org/cakephp",
+                "issues": "https://github.com/cakephp/cakephp/issues",
+                "source": "https://github.com/cakephp/database"
+            },
+            "time": "2026-03-31T06:25:23+00:00"
+        },
+        {
+            "name": "cakephp/datasource",
+            "version": "5.3.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/cakephp/datasource.git",
+                "reference": "512464eb27b19316b515ec338089b83822c9ab5a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/cakephp/datasource/zipball/512464eb27b19316b515ec338089b83822c9ab5a",
+                "reference": "512464eb27b19316b515ec338089b83822c9ab5a",
+                "shasum": ""
+            },
+            "require": {
+                "cakephp/core": "^5.3.0",
+                "php": ">=8.2",
+                "psr/simple-cache": "^2.0 || ^3.0"
+            },
+            "require-dev": {
+                "cakephp/cache": "^5.3.0",
+                "cakephp/collection": "^5.3.0",
+                "cakephp/utility": "^5.3.0"
+            },
+            "suggest": {
+                "cakephp/cache": "If you decide to use Query caching.",
+                "cakephp/collection": "If you decide to use ResultSetInterface.",
+                "cakephp/utility": "If you decide to use EntityTrait."
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-5.next": "5.4.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Cake\\Datasource\\": "."
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "CakePHP Community",
+                    "homepage": "https://github.com/cakephp/datasource/graphs/contributors"
+                }
+            ],
+            "description": "Provides connection managing and traits for Entities and Queries that can be reused for different datastores",
+            "homepage": "https://cakephp.org",
+            "keywords": [
+                "cakephp",
+                "connection management",
+                "datasource",
+                "entity",
+                "query"
+            ],
+            "support": {
+                "forum": "https://stackoverflow.com/tags/cakephp",
+                "irc": "irc://irc.freenode.org/cakephp",
+                "issues": "https://github.com/cakephp/cakephp/issues",
+                "source": "https://github.com/cakephp/datasource"
+            },
+            "time": "2026-04-04T08:08:42+00:00"
+        },
+        {
+            "name": "cakephp/utility",
+            "version": "5.3.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/cakephp/utility.git",
+                "reference": "4ac9826fe5faa1505ec5aa3c171d6b58b6ab4e99"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/cakephp/utility/zipball/4ac9826fe5faa1505ec5aa3c171d6b58b6ab4e99",
+                "reference": "4ac9826fe5faa1505ec5aa3c171d6b58b6ab4e99",
+                "shasum": ""
+            },
+            "require": {
+                "cakephp/core": "^5.3.0",
+                "php": ">=8.2"
+            },
+            "suggest": {
+                "ext-intl": "To use Text::transliterate() or Text::slug()",
+                "lib-ICU": "To use Text::transliterate() or Text::slug()"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-5.next": "5.4.x-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Cake\\Utility\\": "."
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "CakePHP Community",
+                    "homepage": "https://github.com/cakephp/utility/graphs/contributors"
+                }
+            ],
+            "description": "CakePHP Utility classes such as Inflector, String, Hash, and Security",
+            "homepage": "https://cakephp.org",
+            "keywords": [
+                "cakephp",
+                "hash",
+                "inflector",
+                "security",
+                "string",
+                "utility"
+            ],
+            "support": {
+                "forum": "https://stackoverflow.com/tags/cakephp",
+                "irc": "irc://irc.freenode.org/cakephp",
+                "issues": "https://github.com/cakephp/cakephp/issues",
+                "source": "https://github.com/cakephp/utility"
+            },
+            "time": "2026-03-09T09:38:36+00:00"
+        },
+        {
+            "name": "doctrine/dbal",
+            "version": "4.4.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/dbal.git",
+                "reference": "61e730f1658814821a85f2402c945f3883407dec"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/dbal/zipball/61e730f1658814821a85f2402c945f3883407dec",
+                "reference": "61e730f1658814821a85f2402c945f3883407dec",
+                "shasum": ""
+            },
+            "require": {
+                "doctrine/deprecations": "^1.1.5",
+                "php": "^8.2",
+                "psr/cache": "^1|^2|^3",
+                "psr/log": "^1|^2|^3"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "14.0.0",
+                "fig/log-test": "^1",
+                "jetbrains/phpstorm-stubs": "2023.2",
+                "phpstan/phpstan": "2.1.30",
+                "phpstan/phpstan-phpunit": "2.0.7",
+                "phpstan/phpstan-strict-rules": "^2",
+                "phpunit/phpunit": "11.5.50",
+                "slevomat/coding-standard": "8.27.1",
+                "squizlabs/php_codesniffer": "4.0.1",
+                "symfony/cache": "^6.3.8|^7.0|^8.0",
+                "symfony/console": "^5.4|^6.3|^7.0|^8.0"
+            },
+            "suggest": {
+                "symfony/console": "For helpful console commands such as SQL execution and import of files."
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\DBAL\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Guilherme Blanco",
+                    "email": "guilhermeblanco@gmail.com"
+                },
+                {
+                    "name": "Roman Borschel",
+                    "email": "roman@code-factory.org"
+                },
+                {
+                    "name": "Benjamin Eberlei",
+                    "email": "kontakt@beberlei.de"
+                },
+                {
+                    "name": "Jonathan Wage",
+                    "email": "jonwage@gmail.com"
+                }
+            ],
+            "description": "Powerful PHP database abstraction layer (DBAL) with many features for database schema introspection and management.",
+            "homepage": "https://www.doctrine-project.org/projects/dbal.html",
+            "keywords": [
+                "abstraction",
+                "database",
+                "db2",
+                "dbal",
+                "mariadb",
+                "mssql",
+                "mysql",
+                "oci8",
+                "oracle",
+                "pdo",
+                "pgsql",
+                "postgresql",
+                "queryobject",
+                "sasql",
+                "sql",
+                "sqlite",
+                "sqlserver",
+                "sqlsrv"
+            ],
+            "support": {
+                "issues": "https://github.com/doctrine/dbal/issues",
+                "source": "https://github.com/doctrine/dbal/tree/4.4.3"
+            },
+            "funding": [
+                {
+                    "url": "https://www.doctrine-project.org/sponsorship.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://www.patreon.com/phpdoctrine",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/doctrine%2Fdbal",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-20T08:52:12+00:00"
+        },
+        {
+            "name": "doctrine/deprecations",
+            "version": "1.1.6",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/doctrine/deprecations.git",
+                "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+                "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "conflict": {
+                "phpunit/phpunit": "<=7.5 || >=14"
+            },
+            "require-dev": {
+                "doctrine/coding-standard": "^9 || ^12 || ^14",
+                "phpstan/phpstan": "1.4.10 || 2.1.30",
+                "phpstan/phpstan-phpunit": "^1.0 || ^2",
+                "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0",
+                "psr/log": "^1 || ^2 || ^3"
+            },
+            "suggest": {
+                "psr/log": "Allows logging deprecations via PSR-3 logger implementation"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Doctrine\\Deprecations\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "A small layer on top of trigger_error(E_USER_DEPRECATED) or PSR-3 logging with options to disable all deprecations or selectively for packages.",
+            "homepage": "https://www.doctrine-project.org/",
+            "support": {
+                "issues": "https://github.com/doctrine/deprecations/issues",
+                "source": "https://github.com/doctrine/deprecations/tree/1.1.6"
+            },
+            "time": "2026-02-07T07:09:04+00:00"
+        },
+        {
+            "name": "fig/http-message-util",
+            "version": "1.1.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-message-util.git",
+                "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765",
+                "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.3 || ^7.0 || ^8.0"
+            },
+            "suggest": {
+                "psr/http-message": "The package containing the PSR-7 interfaces"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Fig\\Http\\Message\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Utility classes and constants for use with PSR-7 (psr/http-message)",
+            "keywords": [
+                "http",
+                "http-message",
+                "psr",
+                "psr-7",
+                "request",
+                "response"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/http-message-util/issues",
+                "source": "https://github.com/php-fig/http-message-util/tree/1.1.5"
+            },
+            "time": "2020-11-24T22:02:12+00:00"
+        },
+        {
+            "name": "guzzlehttp/psr7",
+            "version": "2.9.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/guzzle/psr7.git",
+                "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884",
+                "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0",
+                "psr/http-factory": "^1.0",
+                "psr/http-message": "^1.1 || ^2.0",
+                "ralouphie/getallheaders": "^3.0"
+            },
+            "provide": {
+                "psr/http-factory-implementation": "1.0",
+                "psr/http-message-implementation": "1.0"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "http-interop/http-factory-tests": "0.9.0",
+                "jshttp/mime-db": "1.54.0.1",
+                "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+            },
+            "suggest": {
+                "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "GuzzleHttp\\Psr7\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
+                {
+                    "name": "Michael Dowling",
+                    "email": "mtdowling@gmail.com",
+                    "homepage": "https://github.com/mtdowling"
+                },
+                {
+                    "name": "George Mponos",
+                    "email": "gmponos@gmail.com",
+                    "homepage": "https://github.com/gmponos"
+                },
+                {
+                    "name": "Tobias Nyholm",
+                    "email": "tobias.nyholm@gmail.com",
+                    "homepage": "https://github.com/Nyholm"
+                },
+                {
+                    "name": "Márk Sági-Kazár",
+                    "email": "mark.sagikazar@gmail.com",
+                    "homepage": "https://github.com/sagikazarmark"
+                },
+                {
+                    "name": "Tobias Schultze",
+                    "email": "webmaster@tubo-world.de",
+                    "homepage": "https://github.com/Tobion"
+                },
+                {
+                    "name": "Márk Sági-Kazár",
+                    "email": "mark.sagikazar@gmail.com",
+                    "homepage": "https://sagikazarmark.hu"
+                }
+            ],
+            "description": "PSR-7 message implementation that also provides common utility methods",
+            "keywords": [
+                "http",
+                "message",
+                "psr-7",
+                "request",
+                "response",
+                "stream",
+                "uri",
+                "url"
+            ],
+            "support": {
+                "issues": "https://github.com/guzzle/psr7/issues",
+                "source": "https://github.com/guzzle/psr7/tree/2.9.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/Nyholm",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-10T16:41:02+00:00"
+        },
+        {
+            "name": "laravel/serializable-closure",
+            "version": "v2.0.12",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/laravel/serializable-closure.git",
+                "reference": "a6abb4e54f6fcd3138120b9ad497f0bd146f9919"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/a6abb4e54f6fcd3138120b9ad497f0bd146f9919",
+                "reference": "a6abb4e54f6fcd3138120b9ad497f0bd146f9919",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^8.1"
+            },
+            "require-dev": {
+                "illuminate/support": "^10.0|^11.0|^12.0|^13.0",
+                "nesbot/carbon": "^2.67|^3.0",
+                "pestphp/pest": "^2.36|^3.0|^4.0",
+                "phpstan/phpstan": "^2.0",
+                "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Laravel\\SerializableClosure\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Taylor Otwell",
+                    "email": "taylor@laravel.com"
+                },
+                {
+                    "name": "Nuno Maduro",
+                    "email": "nuno@laravel.com"
+                }
+            ],
+            "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.",
+            "keywords": [
+                "closure",
+                "laravel",
+                "serializable"
+            ],
+            "support": {
+                "issues": "https://github.com/laravel/serializable-closure/issues",
+                "source": "https://github.com/laravel/serializable-closure"
+            },
+            "time": "2026-04-14T13:33:34+00:00"
+        },
+        {
+            "name": "league/container",
+            "version": "5.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/thephpleague/container.git",
+                "reference": "58accbc032f0090a9bd08326f93062c5a658b2c5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/thephpleague/container/zipball/58accbc032f0090a9bd08326f93062c5a658b2c5",
+                "reference": "58accbc032f0090a9bd08326f93062c5a658b2c5",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^8.1",
+                "psr/container": "^2.0.2",
+                "psr/event-dispatcher": "^1.0"
+            },
+            "provide": {
+                "psr/container-implementation": "^1.0"
+            },
+            "replace": {
+                "orno/di": "~2.0"
+            },
+            "require-dev": {
+                "nette/php-generator": "^4.1",
+                "nikic/php-parser": "^5.0",
+                "phpstan/phpstan": "^2.1.11",
+                "phpunit/phpunit": "^10.5.45|^11.5.15|^12.0",
+                "roave/security-advisories": "dev-latest",
+                "scrutinizer/ocular": "^1.9",
+                "squizlabs/php_codesniffer": "^3.9"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-1.x": "1.x-dev",
+                    "dev-2.x": "2.x-dev",
+                    "dev-3.x": "3.x-dev",
+                    "dev-4.x": "4.x-dev",
+                    "dev-5.x": "5.x-dev",
+                    "dev-master": "5.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "League\\Container\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Phil Bennett",
+                    "email": "mail@philbennett.co.uk",
+                    "role": "Developer"
+                }
+            ],
+            "description": "A fast and intuitive dependency injection container.",
+            "homepage": "https://github.com/thephpleague/container",
+            "keywords": [
+                "container",
+                "dependency",
+                "di",
+                "injection",
+                "league",
+                "provider",
+                "service"
+            ],
+            "support": {
+                "issues": "https://github.com/thephpleague/container/issues",
+                "source": "https://github.com/thephpleague/container/tree/5.2.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/philipobenito",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-03-19T18:52:39+00:00"
+        },
+        {
+            "name": "monolog/monolog",
+            "version": "3.10.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/Seldaek/monolog.git",
+                "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
+                "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "psr/log": "^2.0 || ^3.0"
+            },
+            "provide": {
+                "psr/log-implementation": "3.0.0"
+            },
+            "require-dev": {
+                "aws/aws-sdk-php": "^3.0",
+                "doctrine/couchdb": "~1.0@dev",
+                "elasticsearch/elasticsearch": "^7 || ^8",
+                "ext-json": "*",
+                "graylog2/gelf-php": "^1.4.2 || ^2.0",
+                "guzzlehttp/guzzle": "^7.4.5",
+                "guzzlehttp/psr7": "^2.2",
+                "mongodb/mongodb": "^1.8 || ^2.0",
+                "php-amqplib/php-amqplib": "~2.4 || ^3",
+                "php-console/php-console": "^3.1.8",
+                "phpstan/phpstan": "^2",
+                "phpstan/phpstan-deprecation-rules": "^2",
+                "phpstan/phpstan-strict-rules": "^2",
+                "phpunit/phpunit": "^10.5.17 || ^11.0.7",
+                "predis/predis": "^1.1 || ^2",
+                "rollbar/rollbar": "^4.0",
+                "ruflin/elastica": "^7 || ^8",
+                "symfony/mailer": "^5.4 || ^6",
+                "symfony/mime": "^5.4 || ^6"
+            },
+            "suggest": {
+                "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
+                "doctrine/couchdb": "Allow sending log messages to a CouchDB server",
+                "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
+                "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
+                "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
+                "ext-mbstring": "Allow to work properly with unicode symbols",
+                "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
+                "ext-openssl": "Required to send log messages using SSL",
+                "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
+                "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
+                "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
+                "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
+                "rollbar/rollbar": "Allow sending log messages to Rollbar",
+                "ruflin/elastica": "Allow sending log messages to an Elastic Search server"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Monolog\\": "src/Monolog"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jordi Boggiano",
+                    "email": "j.boggiano@seld.be",
+                    "homepage": "https://seld.be"
+                }
+            ],
+            "description": "Sends your logs to files, sockets, inboxes, databases and various web services",
+            "homepage": "https://github.com/Seldaek/monolog",
+            "keywords": [
+                "log",
+                "logging",
+                "psr-3"
+            ],
+            "support": {
+                "issues": "https://github.com/Seldaek/monolog/issues",
+                "source": "https://github.com/Seldaek/monolog/tree/3.10.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/Seldaek",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-01-02T08:56:05+00:00"
+        },
+        {
+            "name": "nikic/fast-route",
+            "version": "v1.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nikic/FastRoute.git",
+                "reference": "181d480e08d9476e61381e04a71b34dc0432e812"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812",
+                "reference": "181d480e08d9476e61381e04a71b34dc0432e812",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.8.35|~5.7"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/functions.php"
+                ],
+                "psr-4": {
+                    "FastRoute\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Nikita Popov",
+                    "email": "nikic@php.net"
+                }
+            ],
+            "description": "Fast request router for PHP",
+            "keywords": [
+                "router",
+                "routing"
+            ],
+            "support": {
+                "issues": "https://github.com/nikic/FastRoute/issues",
+                "source": "https://github.com/nikic/FastRoute/tree/master"
+            },
+            "time": "2018-02-13T20:26:39+00:00"
+        },
+        {
+            "name": "php-di/invoker",
+            "version": "2.3.7",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHP-DI/Invoker.git",
+                "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1",
+                "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "psr/container": "^1.0|^2.0"
+            },
+            "require-dev": {
+                "athletic/athletic": "~0.1.8",
+                "mnapoli/hard-mode": "~0.3.0",
+                "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Invoker\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Generic and extensible callable invoker",
+            "homepage": "https://github.com/PHP-DI/Invoker",
+            "keywords": [
+                "callable",
+                "dependency",
+                "dependency-injection",
+                "injection",
+                "invoke",
+                "invoker"
+            ],
+            "support": {
+                "issues": "https://github.com/PHP-DI/Invoker/issues",
+                "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/mnapoli",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-08-30T10:22:22+00:00"
+        },
+        {
+            "name": "php-di/php-di",
+            "version": "7.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHP-DI/PHP-DI.git",
+                "reference": "f88054cc052e40dbe7b383c8817c19442d480352"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352",
+                "reference": "f88054cc052e40dbe7b383c8817c19442d480352",
+                "shasum": ""
+            },
+            "require": {
+                "laravel/serializable-closure": "^1.0 || ^2.0",
+                "php": ">=8.0",
+                "php-di/invoker": "^2.0",
+                "psr/container": "^1.1 || ^2.0"
+            },
+            "provide": {
+                "psr/container-implementation": "^1.0"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "^3",
+                "friendsofphp/proxy-manager-lts": "^1",
+                "mnapoli/phpunit-easymock": "^1.3",
+                "phpunit/phpunit": "^9.6 || ^10 || ^11",
+                "vimeo/psalm": "^5|^6"
+            },
+            "suggest": {
+                "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/functions.php"
+                ],
+                "psr-4": {
+                    "DI\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "The dependency injection container for humans",
+            "homepage": "https://php-di.org/",
+            "keywords": [
+                "PSR-11",
+                "container",
+                "container-interop",
+                "dependency injection",
+                "di",
+                "ioc",
+                "psr11"
+            ],
+            "support": {
+                "issues": "https://github.com/PHP-DI/PHP-DI/issues",
+                "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/mnapoli",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/php-di/php-di",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-16T11:10:48+00:00"
+        },
+        {
+            "name": "psr/cache",
+            "version": "3.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/cache.git",
+                "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/cache/zipball/aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
+                "reference": "aa5030cfa5405eccfdcb1083ce040c2cb8d253bf",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Cache\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for caching libraries",
+            "keywords": [
+                "cache",
+                "psr",
+                "psr-6"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/cache/tree/3.0.0"
+            },
+            "time": "2021-02-03T23:26:27+00:00"
+        },
+        {
+            "name": "psr/clock",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/clock.git",
+                "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/clock/zipball/e41a24703d4560fd0acb709162f73b8adfc3aa0d",
+                "reference": "e41a24703d4560fd0acb709162f73b8adfc3aa0d",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.0 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Clock\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for reading the clock.",
+            "homepage": "https://github.com/php-fig/clock",
+            "keywords": [
+                "clock",
+                "now",
+                "psr",
+                "psr-20",
+                "time"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/clock/issues",
+                "source": "https://github.com/php-fig/clock/tree/1.0.0"
+            },
+            "time": "2022-11-25T14:36:26+00:00"
+        },
+        {
+            "name": "psr/container",
+            "version": "2.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/container.git",
+                "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+                "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.4.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Container\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common Container Interface (PHP FIG PSR-11)",
+            "homepage": "https://github.com/php-fig/container",
+            "keywords": [
+                "PSR-11",
+                "container",
+                "container-interface",
+                "container-interop",
+                "psr"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/container/issues",
+                "source": "https://github.com/php-fig/container/tree/2.0.2"
+            },
+            "time": "2021-11-05T16:47:00+00:00"
+        },
+        {
+            "name": "psr/event-dispatcher",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/event-dispatcher.git",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\EventDispatcher\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Standard interfaces for event handling.",
+            "keywords": [
+                "events",
+                "psr",
+                "psr-14"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/event-dispatcher/issues",
+                "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+            },
+            "time": "2019-01-08T18:20:26+00:00"
+        },
+        {
+            "name": "psr/http-factory",
+            "version": "1.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-factory.git",
+                "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+                "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1",
+                "psr/http-message": "^1.0 || ^2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Message\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+            "keywords": [
+                "factory",
+                "http",
+                "message",
+                "psr",
+                "psr-17",
+                "psr-7",
+                "request",
+                "response"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/http-factory"
+            },
+            "time": "2024-04-15T12:06:14+00:00"
+        },
+        {
+            "name": "psr/http-message",
+            "version": "2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-message.git",
+                "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+                "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Message\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for HTTP messages",
+            "homepage": "https://github.com/php-fig/http-message",
+            "keywords": [
+                "http",
+                "http-message",
+                "psr",
+                "psr-7",
+                "request",
+                "response"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/http-message/tree/2.0"
+            },
+            "time": "2023-04-04T09:54:51+00:00"
+        },
+        {
+            "name": "psr/http-server-handler",
+            "version": "1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-server-handler.git",
+                "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4",
+                "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.0",
+                "psr/http-message": "^1.0 || ^2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Server\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for HTTP server-side request handler",
+            "keywords": [
+                "handler",
+                "http",
+                "http-interop",
+                "psr",
+                "psr-15",
+                "psr-7",
+                "request",
+                "response",
+                "server"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2"
+            },
+            "time": "2023-04-10T20:06:20+00:00"
+        },
+        {
+            "name": "psr/http-server-middleware",
+            "version": "1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-server-middleware.git",
+                "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
+                "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.0",
+                "psr/http-message": "^1.0 || ^2.0",
+                "psr/http-server-handler": "^1.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Server\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for HTTP server-side middleware",
+            "keywords": [
+                "http",
+                "http-interop",
+                "middleware",
+                "psr",
+                "psr-15",
+                "psr-7",
+                "request",
+                "response"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/http-server-middleware/issues",
+                "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2"
+            },
+            "time": "2023-04-11T06:14:47+00:00"
+        },
+        {
+            "name": "psr/log",
+            "version": "3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/log.git",
+                "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+                "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Log\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for logging libraries",
+            "homepage": "https://github.com/php-fig/log",
+            "keywords": [
+                "log",
+                "psr",
+                "psr-3"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/log/tree/3.0.2"
+            },
+            "time": "2024-09-11T13:17:53+00:00"
+        },
+        {
+            "name": "psr/simple-cache",
+            "version": "3.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/simple-cache.git",
+                "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/simple-cache/zipball/764e0b3939f5ca87cb904f570ef9be2d78a07865",
+                "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\SimpleCache\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interfaces for simple caching",
+            "keywords": [
+                "cache",
+                "caching",
+                "psr",
+                "psr-16",
+                "simple-cache"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/simple-cache/tree/3.0.0"
+            },
+            "time": "2021-10-29T13:26:27+00:00"
+        },
+        {
+            "name": "ralouphie/getallheaders",
+            "version": "3.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ralouphie/getallheaders.git",
+                "reference": "120b605dfeb996808c31b6477290a714d356e822"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
+                "reference": "120b605dfeb996808c31b6477290a714d356e822",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.6"
+            },
+            "require-dev": {
+                "php-coveralls/php-coveralls": "^2.1",
+                "phpunit/phpunit": "^5 || ^6.5"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/getallheaders.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ralph Khattar",
+                    "email": "ralph.khattar@gmail.com"
+                }
+            ],
+            "description": "A polyfill for getallheaders.",
+            "support": {
+                "issues": "https://github.com/ralouphie/getallheaders/issues",
+                "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+            },
+            "time": "2019-03-08T08:55:37+00:00"
+        },
+        {
+            "name": "robmorgan/phinx",
+            "version": "0.16.11",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/cakephp/phinx.git",
+                "reference": "a03014fea316ba021fc0776982e5bed2d10228d4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/cakephp/phinx/zipball/a03014fea316ba021fc0776982e5bed2d10228d4",
+                "reference": "a03014fea316ba021fc0776982e5bed2d10228d4",
+                "shasum": ""
+            },
+            "require": {
+                "cakephp/database": "^5.0.2",
+                "composer-runtime-api": "^2.0",
+                "php-64bit": ">=8.1",
+                "psr/container": "^1.1|^2.0",
+                "symfony/config": "^4.0|^5.0|^6.0|^7.0|^8.0",
+                "symfony/console": "^6.0|^7.0|^8.0"
+            },
+            "require-dev": {
+                "cakephp/cakephp-codesniffer": "^5.0",
+                "cakephp/i18n": "^5.0",
+                "ext-json": "*",
+                "ext-pdo": "*",
+                "phpunit/phpunit": "^10.5",
+                "symfony/yaml": "^4.0|^5.0|^6.0|^7.0|^8.0"
+            },
+            "suggest": {
+                "ext-json": "Install if using JSON configuration format",
+                "ext-pdo": "PDO extension is needed",
+                "symfony/yaml": "Install if using YAML configuration format"
+            },
+            "bin": [
+                "bin/phinx"
+            ],
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Phinx\\": "src/Phinx/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Rob Morgan",
+                    "email": "robbym@gmail.com",
+                    "homepage": "https://robmorgan.id.au",
+                    "role": "Lead Developer"
+                },
+                {
+                    "name": "Woody Gilk",
+                    "email": "woody.gilk@gmail.com",
+                    "homepage": "https://shadowhand.me",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Richard Quadling",
+                    "email": "rquadling@gmail.com",
+                    "role": "Developer"
+                },
+                {
+                    "name": "CakePHP Community",
+                    "homepage": "https://github.com/cakephp/phinx/graphs/contributors",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Phinx makes it ridiculously easy to manage the database migrations for your PHP app.",
+            "homepage": "https://phinx.org",
+            "keywords": [
+                "database",
+                "database migrations",
+                "db",
+                "migrations",
+                "phinx"
+            ],
+            "support": {
+                "issues": "https://github.com/cakephp/phinx/issues",
+                "source": "https://github.com/cakephp/phinx/tree/0.16.11"
+            },
+            "time": "2026-03-15T00:04:32+00:00"
+        },
+        {
+            "name": "slim/psr7",
+            "version": "1.8.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/slimphp/Slim-Psr7.git",
+                "reference": "76e7e3b1cdfd583e9035c4c966c08e01e45ce959"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/slimphp/Slim-Psr7/zipball/76e7e3b1cdfd583e9035c4c966c08e01e45ce959",
+                "reference": "76e7e3b1cdfd583e9035c4c966c08e01e45ce959",
+                "shasum": ""
+            },
+            "require": {
+                "fig/http-message-util": "^1.1.5",
+                "php": "^8.0",
+                "psr/http-factory": "^1.1",
+                "psr/http-message": "^1.0 || ^2.0",
+                "ralouphie/getallheaders": "^3.0"
+            },
+            "provide": {
+                "psr/http-factory-implementation": "^1.0",
+                "psr/http-message-implementation": "^1.0 || ^2.0"
+            },
+            "require-dev": {
+                "adriansuter/php-autoload-override": "^1.5|| ^2.0",
+                "ext-json": "*",
+                "http-interop/http-factory-tests": "^1.0 || ^2.0",
+                "php-http/psr7-integration-tests": "^1.5",
+                "phpstan/phpstan": "^2.1",
+                "phpunit/phpunit": "^9.6 || ^10",
+                "squizlabs/php_codesniffer": "^3.13"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Slim\\Psr7\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Josh Lockhart",
+                    "email": "hello@joshlockhart.com",
+                    "homepage": "https://joshlockhart.com"
+                },
+                {
+                    "name": "Andrew Smith",
+                    "email": "a.smith@silentworks.co.uk",
+                    "homepage": "https://silentworks.co.uk"
+                },
+                {
+                    "name": "Rob Allen",
+                    "email": "rob@akrabat.com",
+                    "homepage": "https://akrabat.com"
+                },
+                {
+                    "name": "Pierre Berube",
+                    "email": "pierre@lgse.com",
+                    "homepage": "https://www.lgse.com"
+                }
+            ],
+            "description": "Strict PSR-7 implementation",
+            "homepage": "https://www.slimframework.com",
+            "keywords": [
+                "http",
+                "psr-7",
+                "psr7"
+            ],
+            "support": {
+                "issues": "https://github.com/slimphp/Slim-Psr7/issues",
+                "source": "https://github.com/slimphp/Slim-Psr7/tree/1.8.0"
+            },
+            "time": "2025-11-02T17:51:19+00:00"
+        },
+        {
+            "name": "slim/slim",
+            "version": "4.15.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/slimphp/Slim.git",
+                "reference": "887893516557506f254d950425ce7f5387a26970"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/slimphp/Slim/zipball/887893516557506f254d950425ce7f5387a26970",
+                "reference": "887893516557506f254d950425ce7f5387a26970",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "nikic/fast-route": "^1.3",
+                "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+                "psr/container": "^1.0 || ^2.0",
+                "psr/http-factory": "^1.1",
+                "psr/http-message": "^1.1 || ^2.0",
+                "psr/http-server-handler": "^1.0",
+                "psr/http-server-middleware": "^1.0",
+                "psr/log": "^1.1 || ^2.0 || ^3.0"
+            },
+            "require-dev": {
+                "adriansuter/php-autoload-override": "^1.4 || ^2",
+                "ext-simplexml": "*",
+                "guzzlehttp/psr7": "^2.6",
+                "httpsoft/http-message": "^1.1",
+                "httpsoft/http-server-request": "^1.1",
+                "laminas/laminas-diactoros": "^2.17 || ^3",
+                "nyholm/psr7": "^1.8",
+                "nyholm/psr7-server": "^1.1",
+                "phpspec/prophecy": "^1.19",
+                "phpspec/prophecy-phpunit": "^2.1",
+                "phpstan/phpstan": "^1 || ^2",
+                "phpunit/phpunit": "^9.6 || ^10 || ^11 || ^12",
+                "slim/http": "^1.3",
+                "slim/psr7": "^1.6",
+                "squizlabs/php_codesniffer": "^3.10",
+                "vimeo/psalm": "^5 || ^6"
+            },
+            "suggest": {
+                "ext-simplexml": "Needed to support XML format in BodyParsingMiddleware",
+                "ext-xml": "Needed to support XML format in BodyParsingMiddleware",
+                "php-di/php-di": "PHP-DI is the recommended container library to be used with Slim",
+                "slim/psr7": "Slim PSR-7 implementation. See https://www.slimframework.com/docs/v4/start/installation.html for more information."
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Slim\\": "Slim"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Josh Lockhart",
+                    "email": "hello@joshlockhart.com",
+                    "homepage": "https://joshlockhart.com"
+                },
+                {
+                    "name": "Andrew Smith",
+                    "email": "a.smith@silentworks.co.uk",
+                    "homepage": "https://silentworks.co.uk"
+                },
+                {
+                    "name": "Rob Allen",
+                    "email": "rob@akrabat.com",
+                    "homepage": "https://akrabat.com"
+                },
+                {
+                    "name": "Pierre Berube",
+                    "email": "pierre@lgse.com",
+                    "homepage": "https://www.lgse.com"
+                },
+                {
+                    "name": "Gabriel Manricks",
+                    "email": "gmanricks@me.com",
+                    "homepage": "http://gabrielmanricks.com"
+                }
+            ],
+            "description": "Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs",
+            "homepage": "https://www.slimframework.com",
+            "keywords": [
+                "api",
+                "framework",
+                "micro",
+                "router"
+            ],
+            "support": {
+                "docs": "https://www.slimframework.com/docs/v4/",
+                "forum": "https://discourse.slimframework.com/",
+                "irc": "irc://irc.freenode.net:6667/slimphp",
+                "issues": "https://github.com/slimphp/Slim/issues",
+                "rss": "https://www.slimframework.com/blog/feed.rss",
+                "slack": "https://slimphp.slack.com/",
+                "source": "https://github.com/slimphp/Slim",
+                "wiki": "https://github.com/slimphp/Slim/wiki"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/slimphp",
+                    "type": "open_collective"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/slim/slim",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-11-21T12:23:44+00:00"
+        },
+        {
+            "name": "symfony/config",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/config.git",
+                "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/config/zipball/2d19dde43fa2ff720b9a40763ace7226594f503b",
+                "reference": "2d19dde43fa2ff720b9a40763ace7226594f503b",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/deprecation-contracts": "^2.5|^3",
+                "symfony/filesystem": "^7.1|^8.0",
+                "symfony/polyfill-ctype": "~1.8"
+            },
+            "conflict": {
+                "symfony/finder": "<6.4",
+                "symfony/service-contracts": "<2.5"
+            },
+            "require-dev": {
+                "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+                "symfony/finder": "^6.4|^7.0|^8.0",
+                "symfony/messenger": "^6.4|^7.0|^8.0",
+                "symfony/service-contracts": "^2.5|^3",
+                "symfony/yaml": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Config\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/config/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        },
+        {
+            "name": "symfony/console",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/console.git",
+                "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707",
+                "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/deprecation-contracts": "^2.5|^3",
+                "symfony/polyfill-mbstring": "~1.0",
+                "symfony/service-contracts": "^2.5|^3",
+                "symfony/string": "^7.2|^8.0"
+            },
+            "conflict": {
+                "symfony/dependency-injection": "<6.4",
+                "symfony/dotenv": "<6.4",
+                "symfony/event-dispatcher": "<6.4",
+                "symfony/lock": "<6.4",
+                "symfony/process": "<6.4"
+            },
+            "provide": {
+                "psr/log-implementation": "1.0|2.0|3.0"
+            },
+            "require-dev": {
+                "psr/log": "^1|^2|^3",
+                "symfony/config": "^6.4|^7.0|^8.0",
+                "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+                "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+                "symfony/http-foundation": "^6.4|^7.0|^8.0",
+                "symfony/http-kernel": "^6.4|^7.0|^8.0",
+                "symfony/lock": "^6.4|^7.0|^8.0",
+                "symfony/messenger": "^6.4|^7.0|^8.0",
+                "symfony/process": "^6.4|^7.0|^8.0",
+                "symfony/stopwatch": "^6.4|^7.0|^8.0",
+                "symfony/var-dumper": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Console\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Eases the creation of beautiful and testable command line interfaces",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "cli",
+                "command-line",
+                "console",
+                "terminal"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/console/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-30T13:54:39+00:00"
+        },
+        {
+            "name": "symfony/deprecation-contracts",
+            "version": "v3.6.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/deprecation-contracts.git",
+                "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
+                "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "3.6-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "function.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "A generic function and convention to trigger deprecation notices",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-25T14:21:43+00:00"
+        },
+        {
+            "name": "symfony/filesystem",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/filesystem.git",
+                "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5",
+                "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/polyfill-ctype": "~1.8",
+                "symfony/polyfill-mbstring": "~1.8"
+            },
+            "require-dev": {
+                "symfony/process": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Filesystem\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides basic utilities for the filesystem",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/filesystem/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        },
+        {
+            "name": "symfony/polyfill-ctype",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-ctype.git",
+                "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+                "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "provide": {
+                "ext-ctype": "*"
+            },
+            "suggest": {
+                "ext-ctype": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Ctype\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Gert de Pagter",
+                    "email": "BackEndTea@gmail.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for ctype functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "ctype",
+                "polyfill",
+                "portable"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-10T16:19:22+00:00"
+        },
+        {
+            "name": "symfony/polyfill-intl-grapheme",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+                "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
+                "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "suggest": {
+                "ext-intl": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for intl's grapheme_* functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "grapheme",
+                "intl",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-26T13:13:48+00:00"
+        },
+        {
+            "name": "symfony/polyfill-intl-normalizer",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+                "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
+                "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "suggest": {
+                "ext-intl": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for intl's Normalizer class and related functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "intl",
+                "normalizer",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-09T11:45:10+00:00"
+        },
+        {
+            "name": "symfony/polyfill-mbstring",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-mbstring.git",
+                "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
+                "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
+                "shasum": ""
+            },
+            "require": {
+                "ext-iconv": "*",
+                "php": ">=7.2"
+            },
+            "provide": {
+                "ext-mbstring": "*"
+            },
+            "suggest": {
+                "ext-mbstring": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Mbstring\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for the Mbstring extension",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "mbstring",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-10T17:25:58+00:00"
+        },
+        {
+            "name": "symfony/service-contracts",
+            "version": "v3.6.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/service-contracts.git",
+                "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
+                "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "psr/container": "^1.1|^2.0",
+                "symfony/deprecation-contracts": "^2.5|^3"
+            },
+            "conflict": {
+                "ext-psr": "<1.1|>=2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "3.6-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Service\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Test/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to writing services",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-07-15T11:30:57+00:00"
+        },
+        {
+            "name": "symfony/string",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/string.git",
+                "reference": "114ac57257d75df748eda23dd003878080b8e688"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688",
+                "reference": "114ac57257d75df748eda23dd003878080b8e688",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/deprecation-contracts": "^2.5|^3.0",
+                "symfony/polyfill-ctype": "~1.8",
+                "symfony/polyfill-intl-grapheme": "~1.33",
+                "symfony/polyfill-intl-normalizer": "~1.0",
+                "symfony/polyfill-mbstring": "~1.0"
+            },
+            "conflict": {
+                "symfony/translation-contracts": "<2.5"
+            },
+            "require-dev": {
+                "symfony/emoji": "^7.1|^8.0",
+                "symfony/http-client": "^6.4|^7.0|^8.0",
+                "symfony/intl": "^6.4|^7.0|^8.0",
+                "symfony/translation-contracts": "^2.5|^3.0",
+                "symfony/var-exporter": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "Resources/functions.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Component\\String\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "grapheme",
+                "i18n",
+                "string",
+                "unicode",
+                "utf-8",
+                "utf8"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/string/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        }
+    ],
+    "packages-dev": [
+        {
+            "name": "clue/ndjson-react",
+            "version": "v1.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/clue/reactphp-ndjson.git",
+                "reference": "392dc165fce93b5bb5c637b67e59619223c931b0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0",
+                "reference": "392dc165fce93b5bb5c637b67e59619223c931b0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3",
+                "react/stream": "^1.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35",
+                "react/event-loop": "^1.2"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Clue\\React\\NDJson\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering"
+                }
+            ],
+            "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.",
+            "homepage": "https://github.com/clue/reactphp-ndjson",
+            "keywords": [
+                "NDJSON",
+                "json",
+                "jsonlines",
+                "newline",
+                "reactphp",
+                "streaming"
+            ],
+            "support": {
+                "issues": "https://github.com/clue/reactphp-ndjson/issues",
+                "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://clue.engineering/support",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/clue",
+                    "type": "github"
+                }
+            ],
+            "time": "2022-12-23T10:58:28+00:00"
+        },
+        {
+            "name": "composer/pcre",
+            "version": "3.3.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/pcre.git",
+                "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+                "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.4 || ^8.0"
+            },
+            "conflict": {
+                "phpstan/phpstan": "<1.11.10"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^1.12 || ^2",
+                "phpstan/phpstan-strict-rules": "^1 || ^2",
+                "phpunit/phpunit": "^8 || ^9"
+            },
+            "type": "library",
+            "extra": {
+                "phpstan": {
+                    "includes": [
+                        "extension.neon"
+                    ]
+                },
+                "branch-alias": {
+                    "dev-main": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Composer\\Pcre\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jordi Boggiano",
+                    "email": "j.boggiano@seld.be",
+                    "homepage": "http://seld.be"
+                }
+            ],
+            "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+            "keywords": [
+                "PCRE",
+                "preg",
+                "regex",
+                "regular expression"
+            ],
+            "support": {
+                "issues": "https://github.com/composer/pcre/issues",
+                "source": "https://github.com/composer/pcre/tree/3.3.2"
+            },
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/composer",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-11-12T16:29:46+00:00"
+        },
+        {
+            "name": "composer/semver",
+            "version": "3.4.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/semver.git",
+                "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+                "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.3.2 || ^7.0 || ^8.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^1.11",
+                "symfony/phpunit-bridge": "^3 || ^7"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Composer\\Semver\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nils Adermann",
+                    "email": "naderman@naderman.de",
+                    "homepage": "http://www.naderman.de"
+                },
+                {
+                    "name": "Jordi Boggiano",
+                    "email": "j.boggiano@seld.be",
+                    "homepage": "http://seld.be"
+                },
+                {
+                    "name": "Rob Bast",
+                    "email": "rob.bast@gmail.com",
+                    "homepage": "http://robbast.nl"
+                }
+            ],
+            "description": "Semver library that offers utilities, version constraint parsing and validation.",
+            "keywords": [
+                "semantic",
+                "semver",
+                "validation",
+                "versioning"
+            ],
+            "support": {
+                "irc": "ircs://irc.libera.chat:6697/composer",
+                "issues": "https://github.com/composer/semver/issues",
+                "source": "https://github.com/composer/semver/tree/3.4.4"
+            },
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/composer",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-08-20T19:15:30+00:00"
+        },
+        {
+            "name": "composer/xdebug-handler",
+            "version": "3.0.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/xdebug-handler.git",
+                "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef",
+                "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef",
+                "shasum": ""
+            },
+            "require": {
+                "composer/pcre": "^1 || ^2 || ^3",
+                "php": "^7.2.5 || ^8.0",
+                "psr/log": "^1 || ^2 || ^3"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^1.0",
+                "phpstan/phpstan-strict-rules": "^1.1",
+                "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Composer\\XdebugHandler\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "John Stevenson",
+                    "email": "john-stevenson@blueyonder.co.uk"
+                }
+            ],
+            "description": "Restarts a process without Xdebug.",
+            "keywords": [
+                "Xdebug",
+                "performance"
+            ],
+            "support": {
+                "irc": "ircs://irc.libera.chat:6697/composer",
+                "issues": "https://github.com/composer/xdebug-handler/issues",
+                "source": "https://github.com/composer/xdebug-handler/tree/3.0.5"
+            },
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/composer",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-05-06T16:37:16+00:00"
+        },
+        {
+            "name": "ergebnis/agent-detector",
+            "version": "1.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ergebnis/agent-detector.git",
+                "reference": "5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64",
+                "reference": "5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64",
+                "shasum": ""
+            },
+            "require": {
+                "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0"
+            },
+            "require-dev": {
+                "ergebnis/composer-normalize": "^2.50.0",
+                "ergebnis/license": "^2.7.0",
+                "ergebnis/php-cs-fixer-config": "^6.60.2",
+                "ergebnis/phpstan-rules": "^2.13.1",
+                "ergebnis/phpunit-slow-test-detector": "^2.24.0",
+                "ergebnis/rector-rules": "^1.16.0",
+                "fakerphp/faker": "^1.24.1",
+                "infection/infection": "^0.26.6",
+                "phpstan/extension-installer": "^1.4.3",
+                "phpstan/phpstan": "^2.1.46",
+                "phpstan/phpstan-deprecation-rules": "^2.0.4",
+                "phpstan/phpstan-phpunit": "^2.0.16",
+                "phpstan/phpstan-strict-rules": "^2.0.10",
+                "phpunit/phpunit": "^9.6.34",
+                "rector/rector": "^2.4.1"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.0-dev"
+                },
+                "composer-normalize": {
+                    "indent-size": 2,
+                    "indent-style": "space"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Ergebnis\\AgentDetector\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Andreas Möller",
+                    "email": "am@localheinz.com",
+                    "homepage": "https://localheinz.com"
+                }
+            ],
+            "description": "Provides a detector for detecting the presence of an agent.",
+            "homepage": "https://github.com/ergebnis/agent-detector",
+            "support": {
+                "issues": "https://github.com/ergebnis/agent-detector/issues",
+                "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md",
+                "source": "https://github.com/ergebnis/agent-detector"
+            },
+            "time": "2026-04-10T13:45:13+00:00"
+        },
+        {
+            "name": "evenement/evenement",
+            "version": "v3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/igorw/evenement.git",
+                "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc",
+                "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9 || ^6"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Evenement\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Igor Wiedler",
+                    "email": "igor@wiedler.ch"
+                }
+            ],
+            "description": "Événement is a very simple event dispatching library for PHP",
+            "keywords": [
+                "event-dispatcher",
+                "event-emitter"
+            ],
+            "support": {
+                "issues": "https://github.com/igorw/evenement/issues",
+                "source": "https://github.com/igorw/evenement/tree/v3.0.2"
+            },
+            "time": "2023-08-08T05:53:35+00:00"
+        },
+        {
+            "name": "fidry/cpu-core-counter",
+            "version": "1.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/theofidry/cpu-core-counter.git",
+                "reference": "db9508f7b1474469d9d3c53b86f817e344732678"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
+                "reference": "db9508f7b1474469d9d3c53b86f817e344732678",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "require-dev": {
+                "fidry/makefile": "^0.2.0",
+                "fidry/php-cs-fixer-config": "^1.1.2",
+                "phpstan/extension-installer": "^1.2.0",
+                "phpstan/phpstan": "^2.0",
+                "phpstan/phpstan-deprecation-rules": "^2.0.0",
+                "phpstan/phpstan-phpunit": "^2.0",
+                "phpstan/phpstan-strict-rules": "^2.0",
+                "phpunit/phpunit": "^8.5.31 || ^9.5.26",
+                "webmozarts/strict-phpunit": "^7.5"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Fidry\\CpuCoreCounter\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Théo FIDRY",
+                    "email": "theo.fidry@gmail.com"
+                }
+            ],
+            "description": "Tiny utility to get the number of CPU cores.",
+            "keywords": [
+                "CPU",
+                "core"
+            ],
+            "support": {
+                "issues": "https://github.com/theofidry/cpu-core-counter/issues",
+                "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theofidry",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-08-14T07:29:31+00:00"
+        },
+        {
+            "name": "friendsofphp/php-cs-fixer",
+            "version": "v3.95.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
+                "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a9727678fbd12997f1d9de8f4a37824ed9df1065",
+                "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065",
+                "shasum": ""
+            },
+            "require": {
+                "clue/ndjson-react": "^1.3",
+                "composer/semver": "^3.4",
+                "composer/xdebug-handler": "^3.0.5",
+                "ergebnis/agent-detector": "^1.1.1",
+                "ext-filter": "*",
+                "ext-hash": "*",
+                "ext-json": "*",
+                "ext-tokenizer": "*",
+                "fidry/cpu-core-counter": "^1.3",
+                "php": "^7.4 || ^8.0",
+                "react/child-process": "^0.6.6",
+                "react/event-loop": "^1.5",
+                "react/socket": "^1.16",
+                "react/stream": "^1.4",
+                "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0",
+                "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/polyfill-mbstring": "^1.33",
+                "symfony/polyfill-php80": "^1.33",
+                "symfony/polyfill-php81": "^1.33",
+                "symfony/polyfill-php84": "^1.33",
+                "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0",
+                "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0"
+            },
+            "require-dev": {
+                "facile-it/paraunit": "^1.3.1 || ^2.8.0",
+                "infection/infection": "^0.32.6",
+                "justinrainbow/json-schema": "^6.8.0",
+                "keradus/cli-executor": "^2.3",
+                "mikey179/vfsstream": "^1.6.12",
+                "php-coveralls/php-coveralls": "^2.9.1",
+                "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
+                "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
+                "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55",
+                "symfony/polyfill-php85": "^1.33",
+                "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8",
+                "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.8"
+            },
+            "suggest": {
+                "ext-dom": "For handling output formats in XML",
+                "ext-mbstring": "For handling non-UTF8 characters."
+            },
+            "bin": [
+                "php-cs-fixer"
+            ],
+            "type": "application",
+            "autoload": {
+                "psr-4": {
+                    "PhpCsFixer\\": "src/"
+                },
+                "exclude-from-classmap": [
+                    "src/**/Internal/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Dariusz Rumiński",
+                    "email": "dariusz.ruminski@gmail.com"
+                }
+            ],
+            "description": "A tool to automatically fix PHP code style",
+            "keywords": [
+                "Static code analysis",
+                "fixer",
+                "standards",
+                "static analysis"
+            ],
+            "support": {
+                "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
+                "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/keradus",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-04-12T17:00:09+00:00"
+        },
+        {
+            "name": "graham-campbell/result-type",
+            "version": "v1.1.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/GrahamCampbell/Result-Type.git",
+                "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b",
+                "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0",
+                "phpoption/phpoption": "^1.9.5"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "GrahamCampbell\\ResultType\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                }
+            ],
+            "description": "An Implementation Of The Result Type",
+            "keywords": [
+                "Graham Campbell",
+                "GrahamCampbell",
+                "Result Type",
+                "Result-Type",
+                "result"
+            ],
+            "support": {
+                "issues": "https://github.com/GrahamCampbell/Result-Type/issues",
+                "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-12-27T19:43:20+00:00"
+        },
+        {
+            "name": "myclabs/deep-copy",
+            "version": "1.13.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/myclabs/DeepCopy.git",
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "conflict": {
+                "doctrine/collections": "<1.6.8",
+                "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+            },
+            "require-dev": {
+                "doctrine/collections": "^1.6.8",
+                "doctrine/common": "^2.13.3 || ^3.2.2",
+                "phpspec/prophecy": "^1.10",
+                "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/DeepCopy/deep_copy.php"
+                ],
+                "psr-4": {
+                    "DeepCopy\\": "src/DeepCopy/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Create deep copies (clones) of your objects",
+            "keywords": [
+                "clone",
+                "copy",
+                "duplicate",
+                "object",
+                "object graph"
+            ],
+            "support": {
+                "issues": "https://github.com/myclabs/DeepCopy/issues",
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+            },
+            "funding": [
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-01T08:46:24+00:00"
+        },
+        {
+            "name": "nikic/php-parser",
+            "version": "v5.7.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nikic/PHP-Parser.git",
+                "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+                "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+                "shasum": ""
+            },
+            "require": {
+                "ext-ctype": "*",
+                "ext-json": "*",
+                "ext-tokenizer": "*",
+                "php": ">=7.4"
+            },
+            "require-dev": {
+                "ircmaxell/php-yacc": "^0.0.7",
+                "phpunit/phpunit": "^9.0"
+            },
+            "bin": [
+                "bin/php-parse"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "PhpParser\\": "lib/PhpParser"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Nikita Popov"
+                }
+            ],
+            "description": "A PHP parser written in PHP",
+            "keywords": [
+                "parser",
+                "php"
+            ],
+            "support": {
+                "issues": "https://github.com/nikic/PHP-Parser/issues",
+                "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+            },
+            "time": "2025-12-06T11:56:16+00:00"
+        },
+        {
+            "name": "phar-io/manifest",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/manifest.git",
+                "reference": "54750ef60c58e43759730615a392c31c80e23176"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+                "reference": "54750ef60c58e43759730615a392c31c80e23176",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "ext-phar": "*",
+                "ext-xmlwriter": "*",
+                "phar-io/version": "^3.0.1",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+            "support": {
+                "issues": "https://github.com/phar-io/manifest/issues",
+                "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-03T12:33:53+00:00"
+        },
+        {
+            "name": "phar-io/version",
+            "version": "3.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/version.git",
+                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Library for handling version information and constraints",
+            "support": {
+                "issues": "https://github.com/phar-io/version/issues",
+                "source": "https://github.com/phar-io/version/tree/3.2.1"
+            },
+            "time": "2022-02-21T01:04:05+00:00"
+        },
+        {
+            "name": "phpoption/phpoption",
+            "version": "1.9.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/schmittjoh/php-option.git",
+                "reference": "75365b91986c2405cf5e1e012c5595cd487a98be"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be",
+                "reference": "75365b91986c2405cf5e1e012c5595cd487a98be",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34"
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                },
+                "branch-alias": {
+                    "dev-master": "1.9-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "PhpOption\\": "src/PhpOption/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "authors": [
+                {
+                    "name": "Johannes M. Schmitt",
+                    "email": "schmittjoh@gmail.com",
+                    "homepage": "https://github.com/schmittjoh"
+                },
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                }
+            ],
+            "description": "Option Type for PHP",
+            "keywords": [
+                "language",
+                "option",
+                "php",
+                "type"
+            ],
+            "support": {
+                "issues": "https://github.com/schmittjoh/php-option/issues",
+                "source": "https://github.com/schmittjoh/php-option/tree/1.9.5"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-12-27T19:41:33+00:00"
+        },
+        {
+            "name": "phpstan/phpstan",
+            "version": "1.12.33",
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1",
+                "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2|^8.0"
+            },
+            "conflict": {
+                "phpstan/phpstan-shim": "*"
+            },
+            "bin": [
+                "phpstan",
+                "phpstan.phar"
+            ],
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "PHPStan - PHP Static Analysis Tool",
+            "keywords": [
+                "dev",
+                "static analysis"
+            ],
+            "support": {
+                "docs": "https://phpstan.org/user-guide/getting-started",
+                "forum": "https://github.com/phpstan/phpstan/discussions",
+                "issues": "https://github.com/phpstan/phpstan/issues",
+                "security": "https://github.com/phpstan/phpstan/security/policy",
+                "source": "https://github.com/phpstan/phpstan-src"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/ondrejmirtes",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/phpstan",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-02-28T20:30:03+00:00"
+        },
+        {
+            "name": "phpunit/php-code-coverage",
+            "version": "11.0.12",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+                "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56",
+                "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "ext-xmlwriter": "*",
+                "nikic/php-parser": "^5.7.0",
+                "php": ">=8.2",
+                "phpunit/php-file-iterator": "^5.1.0",
+                "phpunit/php-text-template": "^4.0.1",
+                "sebastian/code-unit-reverse-lookup": "^4.0.1",
+                "sebastian/complexity": "^4.0.1",
+                "sebastian/environment": "^7.2.1",
+                "sebastian/lines-of-code": "^3.0.1",
+                "sebastian/version": "^5.0.2",
+                "theseer/tokenizer": "^1.3.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.5.46"
+            },
+            "suggest": {
+                "ext-pcov": "PHP extension that provides line coverage",
+                "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "11.0.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+            "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+            "keywords": [
+                "coverage",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+                "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-12-24T07:01:01+00:00"
+        },
+        {
+            "name": "phpunit/php-file-iterator",
+            "version": "5.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+                "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903",
+                "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+            "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+            "keywords": [
+                "filesystem",
+                "iterator"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+                "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-02-02T13:52:54+00:00"
+        },
+        {
+            "name": "phpunit/php-invoker",
+            "version": "5.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-invoker.git",
+                "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2",
+                "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "ext-pcntl": "*",
+                "phpunit/phpunit": "^11.0"
+            },
+            "suggest": {
+                "ext-pcntl": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Invoke callables with a timeout",
+            "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+            "keywords": [
+                "process"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+                "security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T05:07:44+00:00"
+        },
+        {
+            "name": "phpunit/php-text-template",
+            "version": "4.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-text-template.git",
+                "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964",
+                "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Simple template engine.",
+            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+            "keywords": [
+                "template"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+                "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T05:08:43+00:00"
+        },
+        {
+            "name": "phpunit/php-timer",
+            "version": "7.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-timer.git",
+                "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3",
+                "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "7.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Utility class for timing",
+            "homepage": "https://github.com/sebastianbergmann/php-timer/",
+            "keywords": [
+                "timer"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+                "security": "https://github.com/sebastianbergmann/php-timer/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T05:09:35+00:00"
+        },
+        {
+            "name": "phpunit/phpunit",
+            "version": "11.5.55",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/phpunit.git",
+                "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00",
+                "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-json": "*",
+                "ext-libxml": "*",
+                "ext-mbstring": "*",
+                "ext-xml": "*",
+                "ext-xmlwriter": "*",
+                "myclabs/deep-copy": "^1.13.4",
+                "phar-io/manifest": "^2.0.4",
+                "phar-io/version": "^3.2.1",
+                "php": ">=8.2",
+                "phpunit/php-code-coverage": "^11.0.12",
+                "phpunit/php-file-iterator": "^5.1.1",
+                "phpunit/php-invoker": "^5.0.1",
+                "phpunit/php-text-template": "^4.0.1",
+                "phpunit/php-timer": "^7.0.1",
+                "sebastian/cli-parser": "^3.0.2",
+                "sebastian/code-unit": "^3.0.3",
+                "sebastian/comparator": "^6.3.3",
+                "sebastian/diff": "^6.0.2",
+                "sebastian/environment": "^7.2.1",
+                "sebastian/exporter": "^6.3.2",
+                "sebastian/global-state": "^7.0.2",
+                "sebastian/object-enumerator": "^6.0.1",
+                "sebastian/recursion-context": "^6.0.3",
+                "sebastian/type": "^5.1.3",
+                "sebastian/version": "^5.0.2",
+                "staabm/side-effects-detector": "^1.0.5"
+            },
+            "suggest": {
+                "ext-soap": "To be able to generate mocks based on WSDL files"
+            },
+            "bin": [
+                "phpunit"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "11.5-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/Framework/Assert/Functions.php"
+                ],
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "The PHP Unit Testing framework.",
+            "homepage": "https://phpunit.de/",
+            "keywords": [
+                "phpunit",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+                "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55"
+            },
+            "funding": [
+                {
+                    "url": "https://phpunit.de/sponsors.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-02-18T12:37:06+00:00"
+        },
+        {
+            "name": "react/cache",
+            "version": "v1.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/cache.git",
+                "reference": "d47c472b64aa5608225f47965a484b75c7817d5b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b",
+                "reference": "d47c472b64aa5608225f47965a484b75c7817d5b",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0",
+                "react/promise": "^3.0 || ^2.0 || ^1.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Cache\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Async, Promise-based cache interface for ReactPHP",
+            "keywords": [
+                "cache",
+                "caching",
+                "promise",
+                "reactphp"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/cache/issues",
+                "source": "https://github.com/reactphp/cache/tree/v1.2.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2022-11-30T15:59:55+00:00"
+        },
+        {
+            "name": "react/child-process",
+            "version": "v0.6.7",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/child-process.git",
+                "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3",
+                "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3",
+                "shasum": ""
+            },
+            "require": {
+                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+                "php": ">=5.3.0",
+                "react/event-loop": "^1.2",
+                "react/stream": "^1.4"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+                "react/socket": "^1.16",
+                "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\ChildProcess\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Event-driven library for executing child processes with ReactPHP.",
+            "keywords": [
+                "event-driven",
+                "process",
+                "reactphp"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/child-process/issues",
+                "source": "https://github.com/reactphp/child-process/tree/v0.6.7"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-12-23T15:25:20+00:00"
+        },
+        {
+            "name": "react/dns",
+            "version": "v1.14.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/dns.git",
+                "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3",
+                "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0",
+                "react/cache": "^1.0 || ^0.6 || ^0.5",
+                "react/event-loop": "^1.2",
+                "react/promise": "^3.2 || ^2.7 || ^1.2.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+                "react/async": "^4.3 || ^3 || ^2",
+                "react/promise-timer": "^1.11"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Dns\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Async DNS resolver for ReactPHP",
+            "keywords": [
+                "async",
+                "dns",
+                "dns-resolver",
+                "reactphp"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/dns/issues",
+                "source": "https://github.com/reactphp/dns/tree/v1.14.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-11-18T19:34:28+00:00"
+        },
+        {
+            "name": "react/event-loop",
+            "version": "v1.6.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/event-loop.git",
+                "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
+                "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+            },
+            "suggest": {
+                "ext-pcntl": "For signal handling support when using the StreamSelectLoop"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\EventLoop\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.",
+            "keywords": [
+                "asynchronous",
+                "event-loop"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/event-loop/issues",
+                "source": "https://github.com/reactphp/event-loop/tree/v1.6.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-11-17T20:46:25+00:00"
+        },
+        {
+            "name": "react/promise",
+            "version": "v3.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/promise.git",
+                "reference": "23444f53a813a3296c1368bb104793ce8d88f04a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a",
+                "reference": "23444f53a813a3296c1368bb104793ce8d88f04a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "1.12.28 || 1.4.10",
+                "phpunit/phpunit": "^9.6 || ^7.5"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/functions_include.php"
+                ],
+                "psr-4": {
+                    "React\\Promise\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "A lightweight implementation of CommonJS Promises/A for PHP",
+            "keywords": [
+                "promise",
+                "promises"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/promise/issues",
+                "source": "https://github.com/reactphp/promise/tree/v3.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-08-19T18:57:03+00:00"
+        },
+        {
+            "name": "react/socket",
+            "version": "v1.17.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/socket.git",
+                "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08",
+                "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08",
+                "shasum": ""
+            },
+            "require": {
+                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+                "php": ">=5.3.0",
+                "react/dns": "^1.13",
+                "react/event-loop": "^1.2",
+                "react/promise": "^3.2 || ^2.6 || ^1.2.1",
+                "react/stream": "^1.4"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+                "react/async": "^4.3 || ^3.3 || ^2",
+                "react/promise-stream": "^1.4",
+                "react/promise-timer": "^1.11"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Socket\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP",
+            "keywords": [
+                "Connection",
+                "Socket",
+                "async",
+                "reactphp",
+                "stream"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/socket/issues",
+                "source": "https://github.com/reactphp/socket/tree/v1.17.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-11-19T20:47:34+00:00"
+        },
+        {
+            "name": "react/stream",
+            "version": "v1.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/stream.git",
+                "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d",
+                "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d",
+                "shasum": ""
+            },
+            "require": {
+                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+                "php": ">=5.3.8",
+                "react/event-loop": "^1.2"
+            },
+            "require-dev": {
+                "clue/stream-filter": "~1.2",
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Stream\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP",
+            "keywords": [
+                "event-driven",
+                "io",
+                "non-blocking",
+                "pipe",
+                "reactphp",
+                "readable",
+                "stream",
+                "writable"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/stream/issues",
+                "source": "https://github.com/reactphp/stream/tree/v1.4.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2024-06-11T12:45:25+00:00"
+        },
+        {
+            "name": "sebastian/cli-parser",
+            "version": "3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/cli-parser.git",
+                "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180",
+                "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for parsing CLI options",
+            "homepage": "https://github.com/sebastianbergmann/cli-parser",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+                "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+                "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T04:41:36+00:00"
+        },
+        {
+            "name": "sebastian/code-unit",
+            "version": "3.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit.git",
+                "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64",
+                "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/code-unit",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+                "security": "https://github.com/sebastianbergmann/code-unit/security/policy",
+                "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-03-19T07:56:08+00:00"
+        },
+        {
+            "name": "sebastian/code-unit-reverse-lookup",
+            "version": "4.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+                "reference": "183a9b2632194febd219bb9246eee421dad8d45e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e",
+                "reference": "183a9b2632194febd219bb9246eee421dad8d45e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Looks up which function or method a line of code belongs to",
+            "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+                "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy",
+                "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T04:45:54+00:00"
+        },
+        {
+            "name": "sebastian/comparator",
+            "version": "6.3.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/comparator.git",
+                "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
+                "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-mbstring": "*",
+                "php": ">=8.2",
+                "sebastian/diff": "^6.0",
+                "sebastian/exporter": "^6.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.4"
+            },
+            "suggest": {
+                "ext-bcmath": "For comparing BcMath\\Number objects"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.3-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@2bepublished.at"
+                }
+            ],
+            "description": "Provides the functionality to compare PHP values for equality",
+            "homepage": "https://github.com/sebastianbergmann/comparator",
+            "keywords": [
+                "comparator",
+                "compare",
+                "equality"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/comparator/issues",
+                "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+                "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-01-24T09:26:40+00:00"
+        },
+        {
+            "name": "sebastian/complexity",
+            "version": "4.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/complexity.git",
+                "reference": "ee41d384ab1906c68852636b6de493846e13e5a0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0",
+                "reference": "ee41d384ab1906c68852636b6de493846e13e5a0",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^5.0",
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for calculating the complexity of PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/complexity",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/complexity/issues",
+                "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+                "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T04:49:50+00:00"
+        },
+        {
+            "name": "sebastian/diff",
+            "version": "6.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/diff.git",
+                "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544",
+                "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0",
+                "symfony/process": "^4.2 || ^5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Kore Nordmann",
+                    "email": "mail@kore-nordmann.de"
+                }
+            ],
+            "description": "Diff implementation",
+            "homepage": "https://github.com/sebastianbergmann/diff",
+            "keywords": [
+                "diff",
+                "udiff",
+                "unidiff",
+                "unified diff"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/diff/issues",
+                "security": "https://github.com/sebastianbergmann/diff/security/policy",
+                "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T04:53:05+00:00"
+        },
+        {
+            "name": "sebastian/environment",
+            "version": "7.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/environment.git",
+                "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4",
+                "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.3"
+            },
+            "suggest": {
+                "ext-posix": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "7.2-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Provides functionality to handle HHVM/PHP environments",
+            "homepage": "https://github.com/sebastianbergmann/environment",
+            "keywords": [
+                "Xdebug",
+                "environment",
+                "hhvm"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/environment/issues",
+                "security": "https://github.com/sebastianbergmann/environment/security/policy",
+                "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-05-21T11:55:47+00:00"
+        },
+        {
+            "name": "sebastian/exporter",
+            "version": "6.3.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/exporter.git",
+                "reference": "70a298763b40b213ec087c51c739efcaa90bcd74"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74",
+                "reference": "70a298763b40b213ec087c51c739efcaa90bcd74",
+                "shasum": ""
+            },
+            "require": {
+                "ext-mbstring": "*",
+                "php": ">=8.2",
+                "sebastian/recursion-context": "^6.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.3-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@gmail.com"
+                }
+            ],
+            "description": "Provides the functionality to export PHP variables for visualization",
+            "homepage": "https://www.github.com/sebastianbergmann/exporter",
+            "keywords": [
+                "export",
+                "exporter"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/exporter/issues",
+                "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+                "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-09-24T06:12:51+00:00"
+        },
+        {
+            "name": "sebastian/global-state",
+            "version": "7.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/global-state.git",
+                "reference": "3be331570a721f9a4b5917f4209773de17f747d7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7",
+                "reference": "3be331570a721f9a4b5917f4209773de17f747d7",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "sebastian/object-reflector": "^4.0",
+                "sebastian/recursion-context": "^6.0"
+            },
+            "require-dev": {
+                "ext-dom": "*",
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "7.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Snapshotting of global state",
+            "homepage": "https://www.github.com/sebastianbergmann/global-state",
+            "keywords": [
+                "global state"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/global-state/issues",
+                "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+                "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T04:57:36+00:00"
+        },
+        {
+            "name": "sebastian/lines-of-code",
+            "version": "3.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+                "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a",
+                "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^5.0",
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for counting the lines of code in PHP source code",
+            "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+                "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+                "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T04:58:38+00:00"
+        },
+        {
+            "name": "sebastian/object-enumerator",
+            "version": "6.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+                "reference": "f5b498e631a74204185071eb41f33f38d64608aa"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa",
+                "reference": "f5b498e631a74204185071eb41f33f38d64608aa",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "sebastian/object-reflector": "^4.0",
+                "sebastian/recursion-context": "^6.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+            "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+                "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
+                "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T05:00:13+00:00"
+        },
+        {
+            "name": "sebastian/object-reflector",
+            "version": "4.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-reflector.git",
+                "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9",
+                "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Allows reflection of object attributes, including inherited and non-public ones",
+            "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+                "security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
+                "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T05:01:32+00:00"
+        },
+        {
+            "name": "sebastian/recursion-context",
+            "version": "6.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/recursion-context.git",
+                "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc",
+                "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                }
+            ],
+            "description": "Provides functionality to recursively process PHP variables",
+            "homepage": "https://github.com/sebastianbergmann/recursion-context",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+                "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
+                "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-13T04:42:22+00:00"
+        },
+        {
+            "name": "sebastian/type",
+            "version": "5.1.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/type.git",
+                "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449",
+                "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the types of the PHP type system",
+            "homepage": "https://github.com/sebastianbergmann/type",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/type/issues",
+                "security": "https://github.com/sebastianbergmann/type/security/policy",
+                "source": "https://github.com/sebastianbergmann/type/tree/5.1.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-09T06:55:48+00:00"
+        },
+        {
+            "name": "sebastian/version",
+            "version": "5.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/version.git",
+                "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874",
+                "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+            "homepage": "https://github.com/sebastianbergmann/version",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/version/issues",
+                "security": "https://github.com/sebastianbergmann/version/security/policy",
+                "source": "https://github.com/sebastianbergmann/version/tree/5.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-10-09T05:16:32+00:00"
+        },
+        {
+            "name": "staabm/side-effects-detector",
+            "version": "1.0.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/staabm/side-effects-detector.git",
+                "reference": "d8334211a140ce329c13726d4a715adbddd0a163"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
+                "reference": "d8334211a140ce329c13726d4a715adbddd0a163",
+                "shasum": ""
+            },
+            "require": {
+                "ext-tokenizer": "*",
+                "php": "^7.4 || ^8.0"
+            },
+            "require-dev": {
+                "phpstan/extension-installer": "^1.4.3",
+                "phpstan/phpstan": "^1.12.6",
+                "phpunit/phpunit": "^9.6.21",
+                "symfony/var-dumper": "^5.4.43",
+                "tomasvotruba/type-coverage": "1.0.0",
+                "tomasvotruba/unused-public": "1.0.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "lib/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "A static analysis tool to detect side effects in PHP code",
+            "keywords": [
+                "static analysis"
+            ],
+            "support": {
+                "issues": "https://github.com/staabm/side-effects-detector/issues",
+                "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/staabm",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-10-20T05:08:20+00:00"
+        },
+        {
+            "name": "symfony/event-dispatcher",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/event-dispatcher.git",
+                "reference": "f57b899fa736fd71121168ef268f23c206083f0a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f57b899fa736fd71121168ef268f23c206083f0a",
+                "reference": "f57b899fa736fd71121168ef268f23c206083f0a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/event-dispatcher-contracts": "^2.5|^3"
+            },
+            "conflict": {
+                "symfony/dependency-injection": "<6.4",
+                "symfony/service-contracts": "<2.5"
+            },
+            "provide": {
+                "psr/event-dispatcher-implementation": "1.0",
+                "symfony/event-dispatcher-implementation": "2.0|3.0"
+            },
+            "require-dev": {
+                "psr/log": "^1|^2|^3",
+                "symfony/config": "^6.4|^7.0|^8.0",
+                "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+                "symfony/error-handler": "^6.4|^7.0|^8.0",
+                "symfony/expression-language": "^6.4|^7.0|^8.0",
+                "symfony/framework-bundle": "^6.4|^7.0|^8.0",
+                "symfony/http-foundation": "^6.4|^7.0|^8.0",
+                "symfony/service-contracts": "^2.5|^3",
+                "symfony/stopwatch": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\EventDispatcher\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-30T13:54:39+00:00"
+        },
+        {
+            "name": "symfony/event-dispatcher-contracts",
+            "version": "v3.6.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/event-dispatcher-contracts.git",
+                "reference": "59eb412e93815df44f05f342958efa9f46b1e586"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586",
+                "reference": "59eb412e93815df44f05f342958efa9f46b1e586",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "psr/event-dispatcher": "^1"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "3.6-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\EventDispatcher\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to dispatching event",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-25T14:21:43+00:00"
+        },
+        {
+            "name": "symfony/finder",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/finder.git",
+                "reference": "e0be088d22278583a82da281886e8c3592fbf149"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149",
+                "reference": "e0be088d22278583a82da281886e8c3592fbf149",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "symfony/filesystem": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Finder\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Finds files and directories via an intuitive fluent interface",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/finder/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        },
+        {
+            "name": "symfony/options-resolver",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/options-resolver.git",
+                "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4",
+                "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/deprecation-contracts": "^2.5|^3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\OptionsResolver\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides an improved replacement for the array_replace PHP function",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "config",
+                "configuration",
+                "options"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/options-resolver/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php80",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php80.git",
+                "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+                "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php80\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ion Bazan",
+                    "email": "ion.bazan@gmail.com"
+                },
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-10T16:19:22+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php81",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php81.git",
+                "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
+                "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php81\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-09T11:45:10+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php84",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php84.git",
+                "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06",
+                "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php84\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-10T18:47:49+00:00"
+        },
+        {
+            "name": "symfony/process",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/process.git",
+                "reference": "60f19cd3badc8de688421e21e4305eba50f8089a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a",
+                "reference": "60f19cd3badc8de688421e21e4305eba50f8089a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Process\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Executes commands in sub-processes",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/process/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        },
+        {
+            "name": "symfony/stopwatch",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/stopwatch.git",
+                "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89",
+                "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/service-contracts": "^2.5|^3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Stopwatch\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides a way to profile code",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/stopwatch/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        },
+        {
+            "name": "theseer/tokenizer",
+            "version": "1.3.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/theseer/tokenizer.git",
+                "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+                "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-tokenizer": "*",
+                "ext-xmlwriter": "*",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+            "support": {
+                "issues": "https://github.com/theseer/tokenizer/issues",
+                "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-11-17T20:03:58+00:00"
+        },
+        {
+            "name": "vlucas/phpdotenv",
+            "version": "v5.6.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/vlucas/phpdotenv.git",
+                "reference": "955e7815d677a3eaa7075231212f2110983adecc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc",
+                "reference": "955e7815d677a3eaa7075231212f2110983adecc",
+                "shasum": ""
+            },
+            "require": {
+                "ext-pcre": "*",
+                "graham-campbell/result-type": "^1.1.4",
+                "php": "^7.2.5 || ^8.0",
+                "phpoption/phpoption": "^1.9.5",
+                "symfony/polyfill-ctype": "^1.26",
+                "symfony/polyfill-mbstring": "^1.26",
+                "symfony/polyfill-php80": "^1.26"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "ext-filter": "*",
+                "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
+            },
+            "suggest": {
+                "ext-filter": "Required to use the boolean validator."
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                },
+                "branch-alias": {
+                    "dev-master": "5.6-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Dotenv\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
+                {
+                    "name": "Vance Lucas",
+                    "email": "vance@vancelucas.com",
+                    "homepage": "https://github.com/vlucas"
+                }
+            ],
+            "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.",
+            "keywords": [
+                "dotenv",
+                "env",
+                "environment"
+            ],
+            "support": {
+                "issues": "https://github.com/vlucas/phpdotenv/issues",
+                "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-12-27T19:49:13+00:00"
+        }
+    ],
+    "aliases": [],
+    "minimum-stability": "stable",
+    "stability-flags": {},
+    "prefer-stable": false,
+    "prefer-lowest": false,
+    "platform": {
+        "php": "^8.3"
+    },
+    "platform-dev": {},
+    "platform-overrides": {
+        "php": "8.3"
+    },
+    "plugin-api-version": "2.9.0"
+}

+ 43 - 0
api/config/phinx.php

@@ -0,0 +1,43 @@
+<?php
+
+declare(strict_types=1);
+
+$appEnv = getenv('APP_ENV') ?: 'production';
+
+if ($appEnv === 'development' && file_exists(__DIR__ . '/../.env')) {
+    require_once __DIR__ . '/../vendor/autoload.php';
+    $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
+    $dotenv->safeLoad();
+}
+
+$driver = getenv('DB_DRIVER') ?: 'sqlite';
+
+$envConfig = $driver === 'mysql'
+    ? [
+        'adapter' => 'mysql',
+        'host' => getenv('DB_MYSQL_HOST') ?: 'mysql',
+        'port' => (int) (getenv('DB_MYSQL_PORT') ?: 3306),
+        'name' => getenv('DB_MYSQL_DATABASE') ?: '',
+        'user' => getenv('DB_MYSQL_USERNAME') ?: '',
+        'pass' => getenv('DB_MYSQL_PASSWORD') ?: '',
+        'charset' => 'utf8mb4',
+        'collation' => 'utf8mb4_unicode_ci',
+    ]
+    : [
+        'adapter' => 'sqlite',
+        'name' => rtrim((string) (getenv('DB_SQLITE_PATH') ?: '/data/irdb.sqlite'), '.sqlite'),
+        'suffix' => '.sqlite',
+    ];
+
+return [
+    'paths' => [
+        'migrations' => __DIR__ . '/../db/migrations',
+        'seeds' => __DIR__ . '/../db/seeds',
+    ],
+    'environments' => [
+        'default_migration_table' => 'phinxlog',
+        'default_environment' => 'app',
+        'app' => $envConfig,
+    ],
+    'version_order' => 'creation',
+];

+ 42 - 0
api/config/settings.php

@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+use Monolog\Level;
+
+$appEnv = getenv('APP_ENV') ?: 'production';
+
+if ($appEnv === 'development' && file_exists(__DIR__ . '/../.env')) {
+    $dotenv = Dotenv\Dotenv::createImmutable(__DIR__ . '/..');
+    $dotenv->safeLoad();
+}
+
+$logLevelName = strtoupper((string) (getenv('LOG_LEVEL') ?: 'info'));
+$logLevel = match ($logLevelName) {
+    'DEBUG' => Level::Debug,
+    'NOTICE' => Level::Notice,
+    'WARNING' => Level::Warning,
+    'ERROR' => Level::Error,
+    'CRITICAL' => Level::Critical,
+    'ALERT' => Level::Alert,
+    'EMERGENCY' => Level::Emergency,
+    default => Level::Info,
+};
+
+return [
+    'app_env' => $appEnv,
+    'log_level' => $logLevel,
+    'app_secret' => getenv('APP_SECRET') ?: '',
+    'db' => [
+        'driver' => getenv('DB_DRIVER') ?: 'sqlite',
+        'sqlite_path' => getenv('DB_SQLITE_PATH') ?: '/data/irdb.sqlite',
+        'mysql_host' => getenv('DB_MYSQL_HOST') ?: '',
+        'mysql_port' => (int) (getenv('DB_MYSQL_PORT') ?: 3306),
+        'mysql_database' => getenv('DB_MYSQL_DATABASE') ?: '',
+        'mysql_username' => getenv('DB_MYSQL_USERNAME') ?: '',
+        'mysql_password' => getenv('DB_MYSQL_PASSWORD') ?: '',
+    ],
+    'ui_service_token' => getenv('UI_SERVICE_TOKEN') ?: '',
+    'internal_job_token' => getenv('INTERNAL_JOB_TOKEN') ?: '',
+    'ui_origin' => getenv('UI_ORIGIN') ?: 'http://localhost:8080',
+];

+ 0 - 0
api/db/migrations/.gitkeep


+ 0 - 0
api/db/seeds/.gitkeep


+ 32 - 0
api/docker/Caddyfile

@@ -0,0 +1,32 @@
+# FrankenPHP Caddyfile for the api container.
+# Serves Slim from public/ on :8081.
+{
+    frankenphp
+    order php_server before file_server
+    auto_https off
+    admin off
+}
+
+:8081 {
+    root * /app/public
+    encode zstd gzip
+
+    # Internal jobs API: only callable from loopback / RFC1918.
+    # No /internal/* routes are mounted yet; this matcher exists in M01 to
+    # exercise the protective pattern. Internal endpoints land in M05.
+    @internal {
+        path /internal/*
+        remote_ip 127.0.0.1/32 ::1/128 172.16.0.0/12 10.0.0.0/8 192.168.0.0/16
+    }
+    handle @internal {
+        php_server
+    }
+
+    @external_internal_blocked {
+        path /internal/*
+        not remote_ip 127.0.0.1/32 ::1/128 172.16.0.0/12 10.0.0.0/8 192.168.0.0/16
+    }
+    respond @external_internal_blocked 404
+
+    php_server
+}

+ 26 - 0
api/docker/entrypoint.sh

@@ -0,0 +1,26 @@
+#!/bin/sh
+set -eu
+
+mode="${1:-api}"
+
+# Ensure the SQLite data dir exists when DB_SQLITE_PATH is configured.
+if [ -n "${DB_SQLITE_PATH:-}" ]; then
+    sqlite_dir="$(dirname "$DB_SQLITE_PATH")"
+    mkdir -p "$sqlite_dir"
+fi
+
+case "$mode" in
+    api)
+        exec frankenphp run --config /etc/Caddyfile
+        ;;
+    migrate)
+        cd /app
+        # Empty migrations dir is fine — phinx exits 0 with "no migrations to run".
+        exec vendor/bin/phinx migrate --configuration=config/phinx.php
+        ;;
+    *)
+        echo "Unknown mode: $mode" >&2
+        echo "Usage: entrypoint.sh [api|migrate]" >&2
+        exit 1
+        ;;
+esac

+ 5 - 0
api/phpstan.neon

@@ -0,0 +1,5 @@
+parameters:
+    level: 8
+    paths:
+        - src
+    tmpDir: .phpstan.cache

+ 23 - 0
api/phpunit.xml

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
+         bootstrap="vendor/autoload.php"
+         colors="true"
+         cacheDirectory=".phpunit.cache"
+         executionOrder="depends,defects"
+         beStrictAboutOutputDuringTests="true"
+         failOnWarning="true">
+    <testsuites>
+        <testsuite name="Unit">
+            <directory>tests/Unit</directory>
+        </testsuite>
+        <testsuite name="Integration">
+            <directory>tests/Integration</directory>
+        </testsuite>
+    </testsuites>
+    <source>
+        <include>
+            <directory>src</directory>
+        </include>
+    </source>
+</phpunit>

+ 45 - 0
api/public/index.php

@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+use Monolog\Formatter\JsonFormatter;
+use Monolog\Handler\StreamHandler;
+use Monolog\Logger;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Slim\Factory\AppFactory;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$settings = require __DIR__ . '/../config/settings.php';
+
+$logger = new Logger('api');
+$handler = new StreamHandler('php://stdout', $settings['log_level']);
+$handler->setFormatter(new JsonFormatter());
+$logger->pushHandler($handler);
+
+$app = AppFactory::create();
+$app->addRoutingMiddleware();
+$app->addBodyParsingMiddleware();
+$app->addErrorMiddleware($settings['app_env'] === 'development', true, true, $logger);
+
+$app->get('/healthz', function (Request $request, Response $response): Response {
+    // Stub healthcheck. Later milestones extend this with `db` and `jobs` fields.
+    $response->getBody()->write((string) json_encode(['status' => 'ok']));
+
+    return $response->withHeader('Content-Type', 'application/json');
+});
+
+$app->map(
+    ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
+    '/{routes:.+}',
+    function (Request $request, Response $response): Response {
+        $response->getBody()->write((string) json_encode(['error' => 'not_found']));
+
+        return $response
+            ->withHeader('Content-Type', 'application/json')
+            ->withStatus(404);
+    }
+);
+
+$app->run();

+ 0 - 0
api/src/App/.gitkeep


+ 0 - 0
api/src/Application/.gitkeep


+ 0 - 0
api/src/Domain/.gitkeep


+ 0 - 0
api/src/Infrastructure/.gitkeep


+ 0 - 0
api/src/Support/.gitkeep


+ 0 - 0
api/tests/Fixtures/.gitkeep


+ 15 - 0
api/tests/Unit/SmokeTest.php

@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+
+final class SmokeTest extends TestCase
+{
+    public function testTrueIsTrue(): void
+    {
+        $this->assertTrue(true);
+    }
+}

+ 16 - 0
compose.scheduler.yml

@@ -0,0 +1,16 @@
+services:
+  scheduler:
+    image: alpine:3
+    command: >
+      sh -c "
+      apk add --no-cache curl tini &&
+      exec tini -- crond -f -L /dev/stdout
+      "
+    volumes:
+      - ./docker/scheduler.crontab:/etc/crontabs/root:ro
+    environment:
+      INTERNAL_JOB_TOKEN: ${INTERNAL_JOB_TOKEN}
+    depends_on:
+      api:
+        condition: service_healthy
+    restart: unless-stopped

+ 0 - 0
doc/.gitkeep


+ 64 - 0
docker-compose.yml

@@ -0,0 +1,64 @@
+services:
+  migrate:
+    image: irdb-api:latest
+    build: { context: ./api }
+    command: migrate
+    env_file: .env
+    volumes:
+      - irdb-data:/data
+    restart: "no"
+
+  api:
+    image: irdb-api:latest
+    command: api
+    env_file: .env
+    ports:
+      - "8081:8081"
+    volumes:
+      - irdb-data:/data
+    depends_on:
+      migrate:
+        condition: service_completed_successfully
+    healthcheck:
+      test: ["CMD", "wget", "-qO-", "http://localhost:8081/healthz"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+    restart: unless-stopped
+
+  ui:
+    image: irdb-ui:latest
+    build: { context: ./ui }
+    env_file: .env
+    ports:
+      - "8080:8080"
+    depends_on:
+      api:
+        condition: service_healthy
+    healthcheck:
+      test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
+      interval: 30s
+      timeout: 5s
+      retries: 3
+    restart: unless-stopped
+
+  # Uncomment to use MySQL. Also set DB_DRIVER=mysql in .env.
+  # mysql:
+  #   image: mysql:8
+  #   environment:
+  #     MYSQL_DATABASE: ${DB_MYSQL_DATABASE}
+  #     MYSQL_USER: ${DB_MYSQL_USERNAME}
+  #     MYSQL_PASSWORD: ${DB_MYSQL_PASSWORD}
+  #     MYSQL_ROOT_PASSWORD: ${DB_MYSQL_ROOT_PASSWORD}
+  #   volumes:
+  #     - mysql-data:/var/lib/mysql
+  #   healthcheck:
+  #     test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
+  #     interval: 10s
+  #     timeout: 5s
+  #     retries: 10
+  #   restart: unless-stopped
+
+volumes:
+  irdb-data:
+  # mysql-data:

+ 0 - 0
examples/consumers/.gitkeep


+ 0 - 0
examples/reporters/.gitkeep


+ 0 - 0
examples/reverse-proxy/.gitkeep


+ 4 - 0
examples/scheduler/host.crontab

@@ -0,0 +1,4 @@
+# Host crontab stub for the IRDB scheduler.
+# Real content lands in M13. The eventual entry will look like:
+#   * * * * * curl -sf -m 280 -X POST -H "Authorization: Bearer $INTERNAL_JOB_TOKEN" \
+#       http://localhost:8081/internal/jobs/tick > /dev/null

+ 179 - 0
files/M01-monorepo-skeleton.md

@@ -0,0 +1,179 @@
+# M01 — Monorepo Skeleton & Toolchain
+
+> Fresh Claude Code agent prompt. You are starting from an empty repo (or a repo with only `SPEC.md` and an empty `PROGRESS.md`).
+> Estimated effort: medium (mostly boilerplate; the goal is solid foundations).
+
+## Mission
+
+Create the monorepo layout, set up the toolchain (composer, npm, PHPUnit, PHPStan, php-cs-fixer), and produce buildable Docker images for both the `api` and `ui` containers. Both containers start under compose, return placeholder healthchecks, and `migrate` runs an empty Phinx migration set and exits 0. **No business logic in this milestone.**
+
+## Before you start
+
+1. Read `SPEC.md` end-to-end once. Even if it feels long, do it — every later milestone assumes you understand the architecture.
+2. Then re-read these sections carefully: §1 (Project Goals), §2 (Tech Stack), §3 (Architecture), §10 (Docker), §11 (Project Structure), §14 (Coding Conventions).
+3. Verify the working tree:
+   ```bash
+   ls -la                  # should see SPEC.md and PROGRESS.md
+   git status              # clean
+   ```
+4. Confirm tooling: `docker --version`, `docker compose version`, `php --version` (8.3+), `composer --version`, `node --version` (20+), `npm --version`.
+
+## Tasks
+
+Execute in this order. Commit nothing until acceptance passes.
+
+### 1. Repo skeleton
+
+Create the directory structure exactly as in SPEC.md §11. Empty placeholders are fine where files come later (e.g. `api/src/Domain/.gitkeep`).
+
+### 2. Root files
+
+- `.env.example` — every env var from SPEC.md §9, grouped into "Shared", "API container", "UI container" sections, with comments.
+- `.gitignore` — sensible defaults: `vendor/`, `node_modules/`, `public/assets/`, `.env`, `.phpunit.cache/`, `.phpunit.result.cache`, `data/`, IDE files.
+- `docker-compose.yml` — exactly as in SPEC.md §10.
+- `compose.scheduler.yml` — exactly as in SPEC.md §10. You don't need to make it work end-to-end yet; you only need it to be valid YAML.
+- `README.md` — minimal: project name, one-paragraph description, "see SPEC.md and milestones/ for details".
+
+### 3. `api/` subproject
+
+- `api/composer.json` — Slim 4, doctrine/dbal, robmorgan/phinx, monolog, php-di, vlucas/phpdotenv (dev), guzzlehttp/psr7. Dev: phpunit ^11, phpstan ^1.10, friendsofphp/php-cs-fixer ^3. Set `"type": "project"` and PSR-4 autoload mapping `App\\` → `src/`.
+- `api/public/index.php` — Slim app bootstrap. Two routes for now: `GET /healthz` returning `{"status": "ok"}` (JSON), and a 404 fallback. Wire structured JSON logging via Monolog to stdout.
+- `api/config/settings.php` — builds a config array from environment variables. Don't read `.env` in production; do read it in `development` via phpdotenv.
+- `api/config/phinx.php` — Phinx config that reads the same env vars. Migrations dir `db/migrations`, seeds dir `db/seeds`. Both SQLite and MySQL adapters configured.
+- `api/db/migrations/.gitkeep` and `api/db/seeds/.gitkeep`.
+- `api/bin/console` — minimal Symfony Console app with one command: `db:migrate` that delegates to `vendor/bin/phinx migrate`. Make it executable.
+- `api/docker/entrypoint.sh` — dispatcher script that switches on `$1` (`api` default → starts FrankenPHP serving `public/`; `migrate` → runs migrations and exits). Make it executable.
+- `api/docker/Caddyfile` — FrankenPHP/Caddy config serving on `:8081`. Configure `/internal/*` location with the `remote_ip` matcher from SPEC.md §6 (you don't need any internal routes yet, just the protective Caddy match).
+- `api/Dockerfile` — multi-stage as described in SPEC.md §10, `dunglas/frankenphp:1-php8.3-alpine` base. Install `pdo_sqlite`, `pdo_mysql`, `mbstring`, `intl`, `opcache`, `bcmath` extensions.
+- `api/phpunit.xml` — testsuite includes `tests/Unit` and `tests/Integration`.
+- `api/phpstan.neon` — level 8 on `src/`.
+- `api/.php-cs-fixer.dist.php` — PSR-12 + strict types.
+- `api/tests/Unit/SmokeTest.php` — one trivial assertion (`$this->assertTrue(true)`) so the suite runs.
+
+### 4. `ui/` subproject
+
+Same shape, but:
+- `ui/composer.json` — Slim 4, twig/twig, slim/twig-view, guzzlehttp/guzzle, jumbojett/openid-connect-php, monolog, php-di, vlucas/phpdotenv (dev). No DBAL, no Phinx. Same dev deps as api.
+- `ui/package.json` — `tailwindcss ^3`, `postcss`, `autoprefixer`, `alpinejs`, `htmx.org`. Build script: `tailwindcss -i resources/css/app.css -o public/assets/app.css --minify`.
+- `ui/tailwind.config.js` — content paths covering `resources/views/**/*.twig` and `resources/js/**/*.js`. `darkMode: 'class'`.
+- `ui/postcss.config.js` — autoprefixer + tailwindcss.
+- `ui/resources/css/app.css` — Tailwind directives only.
+- `ui/resources/js/app.js` — Alpine + htmx imports.
+- `ui/resources/views/layout.twig` — minimal HTML skeleton with Tailwind classes, `<html class="dark:bg-slate-900">`, dark-mode toggle button (no JS yet, just the markup).
+- `ui/resources/views/pages/hello.twig` — extends layout, says "IRDB UI — milestone 1".
+- `ui/public/index.php` — Slim with Twig. One route `GET /` renders `pages/hello.twig`. One route `GET /healthz` returns `{"status":"ok","api_reachable":null,"last_api_check_at":null}`.
+- `ui/docker/Caddyfile` — serves on `:8080`.
+- `ui/docker/entrypoint.sh` — single mode (`ui`).
+- `ui/Dockerfile` — multi-stage as in SPEC.md §10.
+- `ui/phpunit.xml`, `ui/phpstan.neon`, `ui/.php-cs-fixer.dist.php`, `ui/tests/Unit/SmokeTest.php`.
+
+### 5. `examples/` and `doc/` placeholders
+
+- `examples/scheduler/host.crontab` — a comment-only stub for now (real content lands in M13).
+- `doc/.gitkeep` — empty (docs land in M13).
+
+### 6. CI (local)
+
+Do **not** create any GitHub Actions workflow. CI runs locally on this server (which has Docker installed). Create `scripts/ci.sh` that an operator can invoke manually:
+
+- Make it executable (`chmod +x scripts/ci.sh`) and start with `#!/usr/bin/env bash` and `set -euo pipefail`.
+- Runs each stage in order, fails fast on the first non-zero exit, and prints a clear banner before each stage.
+- Stages:
+  1. `api/`: `composer install --no-interaction --prefer-dist`, then `composer stan`, `composer cs`, `composer test`.
+  2. `ui/`: `composer install --no-interaction --prefer-dist`, then `composer stan`, `composer cs`, `composer test`.
+  3. `ui/`: `npm ci` then `npm run build`; assert `ui/public/assets/app.css` exists.
+  4. `docker compose build` from the repo root to verify both images build.
+- DB driver matrix: accept an env var `DB_DRIVERS` (default `"sqlite mysql"`) and loop the api test stage once per driver, exporting `DB_DRIVER` so phpunit can pick it up. For `mysql`, skip gracefully with a warning if no local MySQL is reachable (this milestone has no DB-touching tests yet, so the loop is mostly scaffolding for later milestones).
+- At the end, print a green "CI OK" line. On failure, the failing command's exit code propagates.
+
+Also add a one-line invocation note in `README.md` under a "Local CI" heading: `./scripts/ci.sh`.
+
+## Implementation notes
+
+- **FrankenPHP entrypoints**: the entrypoint script must `exec` the FrankenPHP process for proper signal handling. `exec frankenphp run --config /etc/Caddyfile`.
+- **Caddy `:8081` for api, `:8080` for ui** — match SPEC.md §10. Don't expose the api on 8080.
+- **healthz format**: api returns `{"status":"ok"}` for now; full payload (`db`, `jobs`) lands in later milestones. Comment in the code where additional fields will go.
+- **Service token bootstrap**: do not implement yet. SPEC says the api ensures `UI_SERVICE_TOKEN` exists on startup — that lands in M03 when `api_tokens` table exists.
+- **Volume mount**: api's `entrypoint.sh` should `mkdir -p /data` before launching, in case the SQLite path is set but the dir wasn't created.
+- **Composer scripts**: add convenience scripts in each subproject's composer.json: `test` (phpunit), `stan` (phpstan), `cs` (cs-fixer), `cs-fix` (cs-fixer fix). Lets the agent in later milestones run `composer test` instead of remembering paths.
+
+## Out of scope (DO NOT)
+
+- Do **not** create any database tables or migrations beyond an empty migrations dir. Tables come in M02.
+- Do **not** implement any auth, tokens, RBAC, sessions. That's M03/M08.
+- Do **not** install any deps not listed above without strong reason. Note any addition in PROGRESS.md.
+- Do **not** write business logic. The api should respond only to `/healthz`. The ui should render only the hello page and `/healthz`.
+- Do **not** wire OIDC, MaxMind, or any external service.
+- Do **not** create files in `doc/` beyond `.gitkeep`.
+- Do **not** touch `SPEC.md`. It is read-only for milestone agents.
+
+## Acceptance
+
+Run all of these. Every one must pass. If any fails, fix and re-run; do not commit until all green.
+
+```bash
+# 1. Static analysis and style
+cd api && composer install && composer stan && composer cs && composer test && cd ..
+cd ui  && composer install && composer stan && composer cs && composer test && cd ..
+
+# 2. Frontend build
+cd ui && npm ci && npm run build && test -f public/assets/app.css && cd ..
+
+# 3. Compose build
+docker compose build
+
+# 4. Compose up — migrate exits 0, api and ui become healthy
+cp .env.example .env
+# Set required secrets in .env so containers start (use placeholder values for now):
+#   APP_SECRET, UI_SECRET, UI_SERVICE_TOKEN, INTERNAL_JOB_TOKEN — any 32 hex chars each.
+docker compose up -d
+sleep 15
+
+# Migrate must have exited 0:
+test "$(docker compose ps -a --format '{{.Service}} {{.State}} {{.ExitCode}}' | grep migrate | awk '{print $3}')" = "0"
+
+# Healthchecks (poll up to 60s):
+for i in {1..30}; do
+  curl -sf http://localhost:8081/healthz && curl -sf http://localhost:8080/healthz && break
+  sleep 2
+done
+curl -sf http://localhost:8081/healthz | grep -q '"status":"ok"'
+curl -sf http://localhost:8080/healthz | grep -q '"status":"ok"'
+
+# Hello page renders:
+curl -sf http://localhost:8080/ | grep -q "milestone 1"
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M01): monorepo skeleton, toolchain, docker compose builds clean
+
+   - api/ and ui/ subprojects with composer + slim 4
+   - frankenphp dockerfiles, multi-stage builds
+   - phpunit, phpstan level 8, php-cs-fixer wired into composer scripts
+   - tailwind build pipeline in ui/
+   - empty phinx migrations directory
+   - scripts/ci.sh runs the full test/lint matrix locally (no GitHub Actions)
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M01 — Monorepo skeleton (done)
+
+   **Built:** repo layout per SPEC §11, both Dockerfiles, compose stack, toolchain.
+
+   **Notes for next milestone:**
+   - DB schema empty; M02 owns all tables and seeds.
+   - `entrypoint.sh` for api supports `migrate` mode and calls `vendor/bin/phinx`.
+   - Healthcheck payloads are stubs; later milestones extend them.
+   - Service-token bootstrap deferred to M03 (needs `api_tokens` table first).
+
+   **Deviations from SPEC:** none.
+   **Added dependencies beyond SPEC §2:** none.
+   ```
+
+3. **Stop.** Do not start M02.

+ 180 - 0
files/M02-database-migrations.md

@@ -0,0 +1,180 @@
+# M02 — Database & Migrations (api)
+
+> Fresh Claude Code agent prompt. M01 must be complete and committed.
+> Estimated effort: medium.
+
+## Mission
+
+Define every database table from `SPEC.md §4` as Phinx migrations, write seeds for default categories and policies, build a robust IP/CIDR normalization helper with thorough tests, and verify migrations run cleanly on both SQLite and MySQL.
+
+## Before you start
+
+1. Verify M01 acceptance:
+   ```bash
+   git log --oneline -1            # last commit should reference M01
+   docker compose build            # must succeed
+   cat PROGRESS.md                 # M01 entry present
+   ```
+2. Read `SPEC.md` §2 (tech stack), §4 (Data Model — every table), §5 (Reputation Engine, for context on what `ip_scores` will store), §14 (Coding Conventions).
+3. Confirm working tree is clean: `git status`.
+
+## Tasks
+
+### 1. DBAL setup
+
+In `api/src/Infrastructure/Db/`:
+- `ConnectionFactory.php` — builds a `Doctrine\DBAL\Connection` from settings. Selects driver from `DB_DRIVER` env. For SQLite, executes the four `PRAGMA` statements from SPEC §10 on each connection.
+- `RepositoryBase.php` — abstract base with `Connection` injection and helpers for inserting + fetching binary IP columns.
+- Wire the connection into the DI container.
+
+### 2. Migrations
+
+One migration file per table. File names: `YYYYMMDDHHMMSS_create_<table>.php`. Order matters because of foreign keys.
+
+Required tables (all from SPEC §4 — read it carefully, do not omit columns):
+
+1. `users`
+2. `oidc_role_mappings`
+3. `reporters`
+4. `consumers`
+5. `policies`
+6. `policy_category_thresholds` — composite PK `(policy_id, category_id)`
+7. `categories` (seed comes later)
+8. `api_tokens` — note the constraint that exactly one of `reporter_id`/`consumer_id` is set, matching `kind`. On SQLite use a `CHECK` constraint; on MySQL the same. Phinx supports both.
+9. `reports` — index `(ip_bin, category_id, received_at DESC)`.
+10. `ip_scores` — composite PK `(ip_bin, category_id)`.
+11. `ip_enrichment` — PK `ip_bin`.
+12. `manual_blocks`
+13. `allowlist`
+14. `audit_log`
+15. `job_locks` — PK `job_name`.
+16. `job_runs` — index `(job_name, started_at DESC)`.
+
+Cross-cutting requirements:
+
+- Timestamps: SQLite uses `TEXT` (ISO 8601 strings, default `CURRENT_TIMESTAMP`); MySQL uses `DATETIME(6)`. Phinx supports both via `'timestamp'` / `'datetime'` types — use the right one per adapter, or use a custom helper that picks based on the adapter.
+- IP columns: `ip_bin` = `BINARY(16)` on MySQL, `BLOB` on SQLite. `ip_text` = `VARCHAR(45)`.
+- Subnets: `network_bin` = `BINARY(16)`/`BLOB`; `prefix_length` = `SMALLINT`.
+- Foreign keys: declare them; `ON DELETE` semantics per common sense (`api_tokens.reporter_id` → cascade on reporter delete; `reports.reporter_id` → SET NULL or RESTRICT — choose RESTRICT to preserve audit trail).
+- Indexes: at minimum, every FK column is indexed. Add `(ip_bin)` indexes on `reports`, `manual_blocks`, `allowlist`.
+
+### 3. Seeds
+
+In `api/db/seeds/`:
+
+- `DefaultCategoriesSeeder.php` — five categories: `brute_force`, `spam`, `scanner`, `malware_c2`, `web_attack`. Sensible names and descriptions. Default decay: exponential, half-life 14 days.
+- `DefaultPoliciesSeeder.php` — three policies: `strict`, `moderate`, `paranoid`. Each with thresholds across all five categories. Pick numbers that produce visibly different blocklists (e.g. `paranoid` thresholds at 0.3, `moderate` at 1.0, `strict` at 2.5).
+
+Seeders are idempotent: check if a row exists by slug/name before inserting.
+
+### 4. IP normalization helper
+
+In `api/src/Domain/Ip/`:
+
+- `IpAddress.php` — value object. Static `fromString(string): self`, throws `InvalidIpException` on garbage. Stores: canonical text form (lowercase, no leading zeros for v6), 16-byte binary (v4 mapped into `::ffff:0:0/96`), and a flag indicating whether the input was originally v4. Provides `binary(): string`, `text(): string`, `isIpv4(): bool`.
+- `Cidr.php` — value object. Static `fromString(string): self`. Stores network as 16-byte binary, prefix length 0–128. For v4 CIDRs the prefix is internally stored as `96 + originalPrefix` so containment math is uniform across families. Provides `contains(IpAddress): bool`, `network(): string`, `prefixLength(): int`, `text(): string`.
+- `InvalidIpException.php`, `InvalidCidrException.php`.
+
+Tests in `api/tests/Unit/Ip/`:
+
+- `IpAddressTest.php` — at least 30 cases: dotted-quad v4, full v6, zero-compressed v6, `::ffff:1.2.3.4`, leading zeros (rejected per RFC), garbage strings, empty string, integers, addresses with whitespace.
+- `CidrTest.php` — containment edge cases: v4 in v4, v4 in v4-mapped-v6 CIDR, v6 in v6, v6 not in v4, prefix 0 (matches all of family), prefix 32/128 (matches single).
+
+Coverage target ≥95% on `src/Domain/Ip/`.
+
+### 5. CLI commands
+
+Extend `api/bin/console`:
+- `db:migrate` (already exists) — runs Phinx migrations.
+- `db:seed` — runs all seeders idempotently.
+- `db:rollback` — Phinx rollback (for dev use).
+
+## Implementation notes
+
+- **DBAL vs Phinx**: Phinx owns schema; DBAL owns runtime queries. Don't query the DB inside migrations using DBAL — use Phinx's adapter API.
+- **Binary columns on SQLite**: PDO returns BLOB columns as PHP strings (octet sequences). Be explicit: when fetching `ip_bin`, treat the value as raw bytes; when binding, use `\PDO::PARAM_LOB` or pass-through string. Hide this in `RepositoryBase`.
+- **MySQL `STRICT_TRANS_TABLES`**: assume strict mode is on. Don't rely on lax type coercion.
+- **Migration testability**: write a `tests/Integration/MigrationsTest.php` that runs migrations against an in-memory SQLite, then introspects the schema (table names, key columns) to assert structure. Also run in CI against MySQL via a service container.
+- **Seeders in tests**: integration tests should call seeders to set up baseline state.
+- **Don't seed users or tokens.** That's M03's job — auth doesn't exist yet.
+
+## Out of scope (DO NOT)
+
+- No auth logic, no token model behavior, no RBAC. Migrations create the tables; population and behavior come in M03/M04.
+- No HTTP routes beyond what M01 already created.
+- No reputation calculation logic; `ip_scores` table exists but no service writes to it yet.
+- No GeoIP integration; `ip_enrichment` table exists but no enrichment service.
+- No ui changes whatsoever. UI is untouched in this milestone.
+- Do **not** add helpers, services, or domain classes outside `src/Domain/Ip/` and `src/Infrastructure/Db/`.
+
+## Acceptance
+
+```bash
+# Lint and unit tests
+cd api && composer cs && composer stan && composer test && cd ..
+
+# Migrations against SQLite (in-memory)
+cd api && DB_DRIVER=sqlite DB_SQLITE_PATH=:memory: ./bin/console db:migrate && cd ..
+
+# Migrations against ephemeral MySQL via docker
+docker run -d --rm --name irdb-mysql-test \
+  -e MYSQL_ROOT_PASSWORD=root \
+  -e MYSQL_DATABASE=irdb \
+  -p 33306:3306 \
+  mysql:8 --default-authentication-plugin=mysql_native_password
+# wait for mysql ready
+for i in {1..60}; do docker exec irdb-mysql-test mysqladmin -uroot -proot ping >/dev/null 2>&1 && break; sleep 1; done
+cd api && DB_DRIVER=mysql DB_MYSQL_HOST=127.0.0.1 DB_MYSQL_PORT=33306 \
+  DB_MYSQL_DATABASE=irdb DB_MYSQL_USERNAME=root DB_MYSQL_PASSWORD=root \
+  ./bin/console db:migrate && \
+  ./bin/console db:seed && cd ..
+docker stop irdb-mysql-test
+
+# Verify seed counts (rerun against a fresh sqlite file to avoid the in-memory db disappearing)
+rm -f /tmp/irdb-test.sqlite
+cd api && DB_DRIVER=sqlite DB_SQLITE_PATH=/tmp/irdb-test.sqlite ./bin/console db:migrate && \
+  DB_DRIVER=sqlite DB_SQLITE_PATH=/tmp/irdb-test.sqlite ./bin/console db:seed && cd ..
+sqlite3 /tmp/irdb-test.sqlite "SELECT COUNT(*) FROM categories;" | grep -q '^5$'
+sqlite3 /tmp/irdb-test.sqlite "SELECT COUNT(*) FROM policies;"   | grep -q '^3$'
+sqlite3 /tmp/irdb-test.sqlite "SELECT COUNT(*) FROM policy_category_thresholds;" | grep -q '^15$'
+
+# Idempotent re-seed produces no errors and same counts
+cd api && DB_DRIVER=sqlite DB_SQLITE_PATH=/tmp/irdb-test.sqlite ./bin/console db:seed && cd ..
+sqlite3 /tmp/irdb-test.sqlite "SELECT COUNT(*) FROM categories;" | grep -q '^5$'
+
+# Coverage check on Ip domain
+cd api && vendor/bin/phpunit --coverage-text --filter Ip tests/Unit/Ip 2>&1 | grep -E "Domain.Ip" | awk '{print $NF}' | grep -E "9[5-9]\.|100\.00"
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M02): database schema, migrations, seeds, IP/CIDR helpers
+
+   - phinx migrations for all SPEC §4 tables (sqlite + mysql)
+   - default seeds: 5 categories, 3 policies (strict/moderate/paranoid)
+   - IpAddress and Cidr value objects with ≥95% coverage
+   - DBAL connection factory with SQLite WAL pragmas
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M02 — Database & migrations (done)
+
+   **Built:** all SPEC §4 tables; idempotent seeds; IP/CIDR value objects.
+
+   **Schema notes for next milestone:**
+   - `users.password_hash` is NOT in the schema (per SPEC §4; UI owns local-admin credentials).
+   - `api_tokens.kind` enum values: `reporter`, `consumer`, `admin`, `service` (constraint enforced).
+   - All timestamps stored UTC. ISO 8601 strings on SQLite, DATETIME(6) on MySQL.
+   - `ip_bin` always 16 bytes; v4 mapped to `::ffff:0:0/96`. Use `IpAddress::fromString()` for normalization.
+
+   **Decisions made:**
+   - [Document any FK ON DELETE choices the agent had to make.]
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** none beyond SPEC §2.
+   ```
+
+3. **Stop.** Do not start M03.

+ 232 - 0
files/M03-api-auth-foundations.md

@@ -0,0 +1,232 @@
+# M03 — API Auth Foundations
+
+> Fresh Claude Code agent prompt. M02 must be complete and committed.
+> Estimated effort: large. This milestone is auth-critical; spend time on tests.
+
+## Mission
+
+Implement the api's authentication and authorization foundations: token kinds (`reporter`, `consumer`, `admin`, `service`), token resolution, RBAC middleware, the impersonation header pattern for the UI BFF, and the auth endpoints (`/api/v1/auth/users/upsert-oidc`, `/api/v1/auth/users/upsert-local`, `/api/v1/admin/me`). Bootstrap the service token on startup. **No business endpoints yet** — only the auth machinery.
+
+## Before you start
+
+1. Verify M02 acceptance:
+   ```bash
+   git log --oneline -2     # M01, M02 commits
+   cd api && composer test && composer stan && cd ..
+   ```
+2. Read `SPEC.md` §6 (API Contracts — Authentication tokens, Auth API), §7 only the "Identity resolution flow" and RBAC matrix, §8 (Authentication & Authorization, the api-side parts), §14 (Coding Conventions).
+3. Pay particular attention in SPEC §8 to **where each auth concern lives**: the api owns token validation, the `users` table, RBAC. The ui owns OIDC redirects, sessions, password validation. This milestone is api-only.
+
+## Tasks
+
+### 1. Token domain
+
+In `api/src/Domain/Auth/`:
+
+- `TokenKind.php` — enum: `Reporter`, `Consumer`, `Admin`, `Service`.
+- `Role.php` — enum: `Viewer`, `Operator`, `Admin`. Method `satisfies(Role $required): bool`.
+- `Token.php` — value object representing a parsed token (`kind`, `prefix`, `id` after lookup, `subject` (reporter/consumer/admin user id), `role` for admin tokens).
+- `TokenIssuer.php` — generates tokens. Format: `irdb_<kind3>_<32 base32 chars>` where `kind3` is `rep|con|adm|svc`. Uses `random_bytes(20)` (160 bits) → base32. Returns the raw token string.
+- `TokenHasher.php` — `hash(string $raw): string` using SHA-256 hex. Pure function, easy to test.
+
+In `api/src/Infrastructure/Auth/`:
+
+- `TokenRepository.php` — `findByHash(string $hash): ?TokenRecord`, `create(TokenRecord)`, `markUsed(int $id, DateTimeImmutable)`. `TokenRecord` carries: id, kind, hash, prefix, reporter_id, consumer_id, role (for admin kind), expires_at, revoked_at, last_used_at. Apply `WHERE revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now)` in lookups.
+
+### 2. User identity domain
+
+In `api/src/Domain/User/`:
+
+- `User.php` — value object: `id`, `subject` (OIDC sub, nullable), `email`, `displayName`, `role: Role`, `isLocal: bool`.
+
+In `api/src/Infrastructure/Auth/`:
+
+- `UserRepository.php` — methods:
+  - `findById(int): ?User`
+  - `findBySubject(string): ?User` (OIDC sub)
+  - `findLocalByUsername(string): ?User`
+  - `upsertOidc(string $sub, string $email, string $displayName, array $groupIds, Role $defaultRole): User` — looks up by sub; if not found, derives role from `oidc_role_mappings` (highest role granted by any matching group; default if none); inserts. If found, updates email/display_name and recomputes role from current group memberships.
+  - `upsertLocal(string $username): User` — finds-or-inserts the local admin record with `is_local=true`, `role=Admin`. Always returns role Admin.
+- `RoleMappingRepository.php` — `resolveRole(array $groupIds, Role $default): Role`.
+
+### 3. Middlewares
+
+In `api/src/Infrastructure/Http/Middleware/`:
+
+- `TokenAuthenticationMiddleware.php` — extracts `Authorization: Bearer ...`, parses the kind prefix, looks up by hash, attaches a resolved `AuthenticatedPrincipal` to the request attributes. On failure: `401`. Updates `last_used_at` async (write-behind is fine; for now, synchronous update is acceptable).
+- `ImpersonationMiddleware.php` — runs after `TokenAuthenticationMiddleware`. If the principal's token kind is `Service`:
+  - require `X-Acting-User-Id` header; missing → `400 Bad Request` with `{"error":"missing X-Acting-User-Id"}`.
+  - look up the user; not found → `403`.
+  - replace the principal with one carrying that user's id and role.
+  - For non-service tokens, `X-Acting-User-Id` is ignored (do not 400).
+- `RbacMiddleware.php` — route-attached middleware factory: `RbacMiddleware::require(Role::Operator)` returns a middleware that 403s if the principal doesn't have the role.
+- Extra: `JsonErrorHandler.php` — converts thrown exceptions to JSON error responses (`{"error":"...","details":...}`). Wire it as Slim's error handler.
+
+`AuthenticatedPrincipal` (in `api/src/Domain/Auth/`):
+```php
+final class AuthenticatedPrincipal {
+    public function __construct(
+        public readonly TokenKind $tokenKind,
+        public readonly ?int $userId,           // present when service-impersonating or admin-token bound to user
+        public readonly ?Role $role,            // null for reporter/consumer
+        public readonly ?int $reporterId,
+        public readonly ?int $consumerId,
+        public readonly int $tokenId,
+    ) {}
+}
+```
+
+### 4. Service token bootstrap
+
+In `api/src/Infrastructure/Auth/ServiceTokenBootstrap.php`:
+
+- Run on api container startup (call from `entrypoint.sh` via a `bin/console auth:bootstrap-service-token` command, or run inline at app boot before HTTP serving).
+- Reads `UI_SERVICE_TOKEN` env var. If empty: log a warning, skip.
+- If set: hash it, look up in `api_tokens`. If absent, insert a row with `kind=service`, hash, prefix (first 8 chars of raw), no FKs, no expiry. If present and hash matches an existing service-kind row, do nothing. If a different service-kind row exists, log a warning (rotation case) but do not auto-revoke — operator must clean up.
+
+Update `api/docker/entrypoint.sh` so the `api` mode runs `bin/console auth:bootstrap-service-token` before launching FrankenPHP.
+
+### 5. Routes
+
+In `api/src/Application/`:
+
+- `Auth/AuthController.php`:
+  - `POST /api/v1/auth/users/upsert-oidc` — service token only. Body `{subject, email, display_name, groups: []}`. Returns `{user_id, role, email, display_name, is_local: false}`.
+  - `POST /api/v1/auth/users/upsert-local` — service token only. Body `{username}`. Returns local admin user record.
+  - `GET  /api/v1/auth/users/{id}` — service token only. Returns user, 404 otherwise.
+- `Admin/MeController.php`:
+  - `GET /api/v1/admin/me` — admin token OR service+impersonation. Returns `{user_id, email, display_name, role, source: "oidc"|"local"|"admin-token"}`.
+
+Wire routes with the appropriate middleware stack: `TokenAuthenticationMiddleware` → `ImpersonationMiddleware` → `RbacMiddleware::require(...)`.
+
+### 6. CLI
+
+Extend `api/bin/console`:
+- `auth:bootstrap-service-token` (described above).
+- `auth:create-token --kind=admin --role=admin` — creates an admin token (used in dev/manual ops). Outputs the raw token to stdout exactly once. Refuse to create `service` kind via this command.
+
+### 7. Configuration
+
+Add to `api/config/settings.php`:
+- `UI_SERVICE_TOKEN` (required when `OIDC_DEFAULT_ROLE` ≠ `none` and the UI is in use; default behavior: warn if empty).
+- `UI_ORIGIN` for CORS (read but no enforcement yet — CORS middleware is a stretch task here; if you add it, be conservative).
+
+## Implementation notes
+
+- **Token format and lookup**: `irdb_<kind3>_<base32>` is parsed before DB lookup so a malformed token returns 401 without a DB hit. Hash the entire raw string (including prefix) for storage; the prefix is also stored separately for log readability.
+- **Constant-time comparison**: `hash_equals()` when comparing token hashes if you ever do an inline compare. For lookup-by-hash, the DB index is fine.
+- **Don't leak which token kind was wrong**. On any mismatch (bad kind for the route, expired, revoked), return a uniform 401 with body `{"error":"unauthorized"}`. Reserve 403 for "authenticated, wrong role."
+- **Service token rotation**: out of scope this milestone. The bootstrap just handles "set or not set." Document that rotating means: deploy with new value, restart api, manually revoke old hash via a future tool.
+- **`X-Acting-User-Id` header**: validate format (positive integer). Reject malformed with 400.
+- **Audit log**: not yet wired. Audit emitter lands in M12. Don't half-build it now.
+- **Tests**: this milestone lives or dies by test coverage. Aim for an integration test per cell of this matrix:
+
+| Token kind   | Has X-Acting-User-Id? | User exists? | Endpoint requires role | Expected outcome                |
+|--------------|-----------------------|--------------|------------------------|---------------------------------|
+| no token     | -                     | -            | viewer                 | 401                             |
+| bad token    | -                     | -            | viewer                 | 401                             |
+| reporter     | -                     | -            | viewer                 | 401 (wrong kind for admin route)|
+| admin/viewer | -                     | -            | viewer                 | 200                             |
+| admin/viewer | -                     | -            | operator               | 403                             |
+| admin/admin  | -                     | -            | admin                  | 200                             |
+| service      | no                    | -            | viewer                 | 400                             |
+| service      | yes                   | no           | viewer                 | 403                             |
+| service      | yes (viewer user)     | yes          | viewer                 | 200                             |
+| service      | yes (viewer user)     | yes          | operator               | 403                             |
+| service      | yes (admin user)      | yes          | admin                  | 200                             |
+
+## Out of scope (DO NOT)
+
+- No reporter/consumer endpoints (`/api/v1/report`, `/api/v1/blocklist`). M04.
+- No internal job endpoints. M05.
+- No rate limiting. M04.
+- No OIDC validation (the api doesn't validate OIDC tokens; the ui does, then calls `upsert-oidc`).
+- No password validation. The api never sees passwords.
+- No CORS enforcement (OK to add headers but don't gate on them yet).
+- No ui changes.
+- No audit log emission.
+- No new dependencies.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+
+# Service token bootstrap
+docker compose down -v
+cp .env.example .env
+# Fill in: UI_SERVICE_TOKEN=<32 hex>, INTERNAL_JOB_TOKEN=<32 hex>, etc.
+docker compose up -d
+sleep 15
+# Verify the token row exists
+docker compose exec -T api sqlite3 /data/irdb.sqlite \
+  "SELECT kind FROM api_tokens WHERE kind='service';" | grep -q "service"
+
+# /api/v1/admin/me with bad auth
+test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8081/api/v1/admin/me)" = "401"
+
+# /api/v1/admin/me with admin token (created via CLI)
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+RESP=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/me)
+echo "$RESP" | grep -q '"role":"admin"'
+echo "$RESP" | grep -q '"source":"admin-token"'
+
+# Upsert local user via service token (simulating the ui)
+SVC_TOKEN=$(grep ^UI_SERVICE_TOKEN= .env | cut -d= -f2)
+RESP=$(curl -s -X POST \
+  -H "Authorization: Bearer $SVC_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{"username":"admin"}' \
+  http://localhost:8081/api/v1/auth/users/upsert-local)
+USER_ID=$(echo "$RESP" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["user_id"];')
+[ -n "$USER_ID" ]
+
+# Service + impersonation: fetches /admin/me as that user
+RESP=$(curl -s -H "Authorization: Bearer $SVC_TOKEN" -H "X-Acting-User-Id: $USER_ID" \
+  http://localhost:8081/api/v1/admin/me)
+echo "$RESP" | grep -q '"is_local":true'
+echo "$RESP" | grep -q '"role":"admin"'
+
+# Service without impersonation header → 400
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -H "Authorization: Bearer $SVC_TOKEN" \
+  http://localhost:8081/api/v1/admin/me)" = "400"
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M03): api auth foundations — tokens, RBAC, BFF impersonation
+
+   - token kinds: reporter | consumer | admin | service (irdb_<kind>_<32b32> format)
+   - TokenAuthenticationMiddleware + ImpersonationMiddleware + RbacMiddleware
+   - /api/v1/auth/users/upsert-{oidc,local}, /api/v1/admin/me
+   - service token bootstrap on container startup
+   - integration tests cover the full auth matrix
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M03 — API auth foundations (done)
+
+   **Built:** token kinds, hashing, RBAC, impersonation pattern, auth endpoints, service token bootstrap.
+
+   **API contract decisions:**
+   - 401 = bad/expired/revoked/wrong-kind token (uniform body)
+   - 403 = authenticated but wrong role
+   - 400 = service token without X-Acting-User-Id header
+   - last_used_at updated synchronously (move to async in M14 if perf demands)
+
+   **Notes for next milestone:**
+   - Reporter and consumer tokens have no role column; their auth carries reporter_id / consumer_id only.
+   - M04's report endpoint reads `principal->reporterId` from request attrs.
+   - Admin endpoints in later milestones can use `RbacMiddleware::require(Role::Operator)` etc.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** none.
+   ```
+
+3. **Stop.** Do not start M04.

+ 211 - 0
files/M04-token-system-and-ingest.md

@@ -0,0 +1,211 @@
+# M04 — Token Management & Ingest API
+
+> Fresh Claude Code agent prompt. M03 must be complete and committed.
+> Estimated effort: medium.
+
+## Mission
+
+Implement reporter and consumer CRUD plus token issuance via admin endpoints, the public `POST /api/v1/report` endpoint with synchronous `ip_scores` updates, and a per-token rate limiter. After this milestone, machine clients can report IPs and rate limits actually bite.
+
+## Before you start
+
+1. Verify M03:
+   ```bash
+   git log --oneline -3
+   cd api && composer test && composer stan && cd ..
+   ```
+2. Read `SPEC.md` §4 (`reporters`, `consumers`, `api_tokens`, `reports`, `ip_scores` tables), §5 (Reputation Engine — scoring formula; you'll write the synchronous-update piece, but the bulk recompute is M05), §6 (API Contracts — Public API and the relevant Admin endpoints).
+3. Confirm clean tree.
+
+## Tasks
+
+### 1. Reporter & Consumer admin CRUD
+
+In `api/src/Application/Admin/`:
+
+- `ReportersController.php`:
+  - `GET    /api/v1/admin/reporters` — list, paginated.
+  - `GET    /api/v1/admin/reporters/{id}` — detail.
+  - `POST   /api/v1/admin/reporters` — `{name, description, trust_weight}`. Returns the created record.
+  - `PATCH  /api/v1/admin/reporters/{id}` — partial update.
+  - `DELETE /api/v1/admin/reporters/{id}` — soft delete (set `is_active=false`). Hard delete refused if reports exist (409).
+- `ConsumersController.php` — analogous, with `policy_id` instead of `trust_weight`. (Policy CRUD is M07; for now the FK is required and the UI will pass an existing policy id; in tests, you may seed a policy directly.)
+
+RBAC: all reporter/consumer endpoints require `Admin` role.
+
+### 2. Token issuance & management
+
+In `api/src/Application/Admin/`:
+
+- `TokensController.php`:
+  - `GET    /api/v1/admin/tokens` — list. **Never** include `service`-kind tokens. Return prefix and metadata; never the raw token (it's not stored).
+  - `POST   /api/v1/admin/tokens` — body `{kind: "reporter"|"consumer"|"admin", reporter_id?, consumer_id?, role?, expires_at?}`. Validate constraints:
+    - `kind=reporter` → `reporter_id` required, no `role`, no `consumer_id`.
+    - `kind=consumer` → `consumer_id` required, no `role`, no `reporter_id`.
+    - `kind=admin` → `role` required, no FKs.
+    - `kind=service` → 400 always (service tokens cannot be created via API).
+  - Returns `{id, kind, prefix, raw_token, ...}` — `raw_token` appears **only in this response**; document this in OpenAPI later.
+  - `DELETE /api/v1/admin/tokens/{id}` — sets `revoked_at = now()`. Refuse on service tokens.
+
+RBAC: `Admin` role. Audit emission deferred to M12.
+
+### 3. Public ingest: `POST /api/v1/report`
+
+In `api/src/Application/Public/ReportController.php`:
+
+- Auth: `TokenKind::Reporter` only. Reject all other kinds with 401 (wrong kind = generic unauthorized per M03 convention).
+- Body validation: `ip` (parse via `IpAddress::fromString`, 400 on failure), `category` (slug; lookup by `categories.slug`, 400 if unknown or `is_active=false`), `metadata` (optional, must be a JSON object ≤4 KB after re-encoding).
+- Insert a row into `reports`:
+  - `weight_at_report` = current `trust_weight` of the reporter (snapshot).
+  - `received_at` = current UTC time via injected `Clock`.
+- Update `ip_scores` for the affected `(ip_bin, category_id)` pair **synchronously**:
+  - Compute the new score by re-running the formula (Σ weight × decay over reports for this ip+category, hard cutoff 365 days). The bulk recompute service lands in M05 — but for the synchronous-on-ingest path you need a small helper now. Place it at `api/src/Domain/Reputation/PairScorer.php` so M05 can build on it.
+  - `UPSERT` the score row.
+- Return `202` with `{report_id, ip, received_at}`.
+
+### 4. Rate limiter
+
+In `api/src/Infrastructure/Http/Middleware/RateLimitMiddleware.php`:
+
+- Token-bucket per token id. In-process state (PHP array attached to a singleton service); good enough for single-replica deployments and dev.
+- Bucket: capacity = `API_RATE_LIMIT_PER_SECOND` × 2, refill rate = `API_RATE_LIMIT_PER_SECOND` per second. Configurable.
+- On exhaustion: return `429` with `Retry-After: 1` (seconds, integer).
+- Apply to public endpoints (`/api/v1/report`, future `/api/v1/blocklist`). Skip for admin endpoints (admins are humans/UI; not a DDoS vector).
+- Tests: 60 requests in <1s with limit=60 → all 200/202; 120 in <1s → some 429s.
+
+Note for self: in-process means each replica has its own bucket. Document this in PROGRESS.md as a known limitation; multi-replica rate limiting needs a shared store and is out of scope.
+
+### 5. Validation framework
+
+You'll need consistent request validation across this and future milestones. Two acceptable approaches:
+
+- **Hand-rolled** in each controller (acceptable for this scale).
+- **Lightweight library** like `respect/validation` (allowed; document in PROGRESS.md if added).
+
+Either way, validation errors must produce a uniform response:
+```json
+{"error":"validation_failed","details":{"field":"reason"}}
+```
+HTTP status `400` for malformed; `422` is also acceptable but be consistent.
+
+## Implementation notes
+
+- **PairScorer** signature: `score(string $ipBin, int $categoryId, DateTimeImmutable $now): float`. Reads from `reports`, applies category-specific decay (linear or exponential per `categories.decay_function` and `decay_param`). Hard cutoff at `SCORE_REPORT_HARD_CUTOFF_DAYS` (default 365). Returns the float score.
+- **Decay functions**: defined in SPEC §5. Linear: `max(0, 1 - age_days/decay_param)`. Exponential: `0.5 ^ (age_days/decay_param)`. Implement them in `api/src/Domain/Reputation/Decay.php` as pure functions with unit tests.
+- **`ip_scores` upsert**: use the DBAL adapter's UPSERT-equivalent. SQLite: `INSERT ... ON CONFLICT(ip_bin, category_id) DO UPDATE`. MySQL: `INSERT ... ON DUPLICATE KEY UPDATE`. Wrap in `RepositoryBase`.
+- **Report metadata size**: enforce ≤4 KB after `json_encode` of the parsed object. Reject larger with 400.
+- **IPv6 metadata**: just store the metadata as JSON — no special handling.
+- **Rate limit in tests**: inject the limiter so tests can either bypass it or fast-forward time via the `Clock`. Use a `ClockInterface` (you already created it for `received_at`).
+- **Reports are append-only**: never UPDATE or DELETE rows in `reports`. The ingest endpoint just inserts.
+
+## Out of scope (DO NOT)
+
+- Bulk recompute / decay of all stored scores. M05.
+- Internal job endpoints. M05.
+- Allowlist / manual blocks. M06.
+- Distribution endpoint (`/api/v1/blocklist`). M07.
+- Audit log emission. M12.
+- Any UI changes.
+- GeoIP / enrichment. M11.
+- New dependencies beyond what's already in api/composer.json (one optional: `respect/validation` if you go that route — record in PROGRESS.md).
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+
+docker compose down -v
+cp .env.example .env  # fill secrets if not already done
+docker compose up -d
+sleep 15
+
+# Create a reporter and a token
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+
+REPORTER=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"name":"web-prod-01","description":"prod webserver","trust_weight":1.0}' \
+  http://localhost:8081/api/v1/admin/reporters)
+REPORTER_ID=$(echo "$REPORTER" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["id"];')
+
+TOKEN_RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d "{\"kind\":\"reporter\",\"reporter_id\":$REPORTER_ID}" \
+  http://localhost:8081/api/v1/admin/tokens)
+RAW_TOKEN=$(echo "$TOKEN_RESP" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["raw_token"];')
+[ -n "$RAW_TOKEN" ]
+
+# Submit a report
+RESP=$(curl -s -X POST -H "Authorization: Bearer $RAW_TOKEN" -H "Content-Type: application/json" \
+  -d '{"ip":"203.0.113.42","category":"brute_force","metadata":{"url":"/wp-login"}}' \
+  http://localhost:8081/api/v1/report)
+echo "$RESP" | grep -q '"report_id"'
+echo "$RESP" | grep -q '"received_at"'
+
+# ip_scores updated synchronously
+docker compose exec -T api sqlite3 /data/irdb.sqlite \
+  "SELECT ROUND(score, 4) FROM ip_scores WHERE ip_text='203.0.113.42';" | grep -q '^[0-9]'
+
+# Wrong-kind token rejected (admin token can't report)
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"ip":"1.2.3.4","category":"spam"}' \
+  http://localhost:8081/api/v1/report)" = "401"
+
+# Bad IP rejected
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X POST -H "Authorization: Bearer $RAW_TOKEN" -H "Content-Type: application/json" \
+  -d '{"ip":"not-an-ip","category":"spam"}' \
+  http://localhost:8081/api/v1/report)" = "400"
+
+# Unknown category rejected
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X POST -H "Authorization: Bearer $RAW_TOKEN" -H "Content-Type: application/json" \
+  -d '{"ip":"1.2.3.4","category":"nonexistent"}' \
+  http://localhost:8081/api/v1/report)" = "400"
+
+# Rate limit kicks in (with low API_RATE_LIMIT_PER_SECOND)
+docker compose down
+echo "API_RATE_LIMIT_PER_SECOND=2" >> .env
+docker compose up -d
+sleep 10
+HITS_429=0
+for i in $(seq 1 20); do
+  CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST \
+    -H "Authorization: Bearer $RAW_TOKEN" -H "Content-Type: application/json" \
+    -d '{"ip":"1.2.3.4","category":"spam"}' \
+    http://localhost:8081/api/v1/report)
+  [ "$CODE" = "429" ] && HITS_429=$((HITS_429+1))
+done
+[ "$HITS_429" -gt 0 ]
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M04): reporter/consumer CRUD, token issuance, ingest API, rate limiter
+
+   - admin endpoints for reporters, consumers, tokens (raw token shown once)
+   - POST /api/v1/report with synchronous ip_scores update via PairScorer
+   - decay functions (linear + exponential) with unit tests
+   - per-token in-process rate limiter on public endpoints
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M04 — Token system & ingest (done)
+
+   **Built:** reporter/consumer/token CRUD; POST /api/v1/report end-to-end; rate limiter; decay functions.
+
+   **Notes for next milestone:**
+   - Synchronous score updates are correct but only touch the (ip, category) pair just reported. Bulk decay re-application is M05's recompute job.
+   - PairScorer is the authoritative single-pair scorer; the bulk recompute job in M05 should call into it (or a near-clone) so behavior stays consistent.
+   - Rate limiter is in-process; document this in README. Multi-replica deployments need a shared store.
+   - Service tokens cannot be created via the admin API; only the bootstrap path makes them.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** [list any, e.g. respect/validation, or "none"].
+   ```
+
+3. **Stop.** Do not start M05.

+ 213 - 0
files/M05-reputation-engine-and-jobs.md

@@ -0,0 +1,213 @@
+# M05 — Reputation Engine & Internal Job Endpoints
+
+> Fresh Claude Code agent prompt. M04 must be complete and committed.
+> Estimated effort: large.
+
+## Mission
+
+Build the reputation engine (full bulk recompute with decay reapplication) and the internal job framework: locks, run history, runner abstraction, the `/internal/jobs/*` endpoints, network and token middlewares, the `tick` dispatcher, and a CLI runner. Three job types are wired: `recompute-scores`, `cleanup-audit`, `enrich-pending` (skeleton — full enrichment is M11).
+
+## Before you start
+
+1. Verify M04:
+   ```bash
+   git log --oneline -4
+   cd api && composer test && composer stan && cd ..
+   ```
+2. Read `SPEC.md` §4 (`job_locks`, `job_runs`), §5 (Reputation Engine — recomputation rules), §6 (Internal Jobs API — endpoints, middlewares, response envelope), §10 (where the scheduler comes in).
+3. Confirm clean tree.
+
+## Tasks
+
+### 1. Clock & decay (extend M04)
+
+You already have `Decay.php` (linear + exponential) and `PairScorer.php` from M04. Verify they handle hard cutoff (365 days default) correctly. Add tests for:
+
+- An age beyond cutoff → decay returns 0.
+- Linear with `decay_param=30`, age=0 → 1.0; age=15 → 0.5; age=30 → 0.0.
+- Exponential with `decay_param=14` (half-life), age=14 → 0.5; age=28 → 0.25.
+
+### 2. Job framework
+
+In `api/src/Infrastructure/Jobs/`:
+
+- `Job.php` — interface: `name(): string`, `defaultIntervalSeconds(): int`, `maxRuntimeSeconds(): int`, `run(JobContext $ctx): JobResult`.
+- `JobContext.php` — carries the `Clock`, a logger, and any per-invocation params (`$ctx->param('full', false)`).
+- `JobResult.php` — `itemsProcessed: int`, `details: array`.
+- `JobLockRepository.php`:
+  - `tryAcquire(string $name, int $maxRuntimeSeconds, string $owner): bool` — atomic. Implementation:
+    1. Begin transaction.
+    2. Delete rows where `expires_at < now`.
+    3. `INSERT INTO job_locks (job_name, acquired_at, acquired_by, expires_at) VALUES (...)` — fails on PK conflict if held.
+    4. Commit. Return success/failure.
+  - `release(string $name, string $owner)` — `DELETE WHERE job_name = ? AND acquired_by = ?`.
+- `JobRunRepository.php` — append rows, query latest per job, query overdue.
+- `JobRunner.php`:
+  - `run(Job $job, array $params, string $triggeredBy): JobOutcome` — orchestrates: try-acquire → write `running` row → run → on success/failure write final row → release lock. Always writes a final row even on `skipped_locked`.
+  - Generates a unique `owner` per invocation (e.g. `getmypid() . '/' . random_bytes(4) hex`).
+- `JobRegistry.php` — registers job classes by name; resolves by name.
+
+### 3. Concrete jobs
+
+In `api/src/Application/Jobs/` (or `api/src/Infrastructure/Jobs/Tasks/` — pick one and stay consistent):
+
+- `RecomputeScoresJob.php`:
+  - Default interval: 300s. Max runtime: 240s.
+  - Runs in two modes: full (`full=true`) and incremental (default).
+  - Incremental: pairs `(ip_bin, category_id)` from `reports` with `received_at >= now - interval` UNION pairs from `ip_scores` where `recomputed_at < now - freshness_window` (default 1 hour). Cap at `JOB_RECOMPUTE_MAX_ROWS_PER_TICK`.
+  - Full: every pair in `ip_scores` plus every pair in `reports`. No cap (but bounded by `maxRuntimeSeconds`).
+  - For each pair: call `PairScorer::score()`, upsert `ip_scores`. Drop rows where score < 0.01 AND `last_report_at < now - 90 days`.
+- `CleanupAuditJob.php`:
+  - Default interval: 86400s (daily). Max runtime: 60s.
+  - Deletes `audit_log` rows older than `JOB_AUDIT_RETENTION_DAYS`. Audit table exists from M02 even though emitter doesn't yet — that's fine.
+- `EnrichPendingJob.php`:
+  - Skeleton only. Default interval: 300s. Max runtime: 60s. For now: no-op that returns `items_processed: 0` and logs a debug line. Full implementation in M11.
+
+### 4. Tick dispatcher
+
+`TickJob.php` (or `TickDispatcher.php` — kept in same dir):
+- Iterates the registry. For each job, reads the latest `job_runs` entry for that name. If `now - last_finished_at >= job.defaultInterval` (or no row exists), invokes `JobRunner::run()` for that job. Per-job exceptions are caught and recorded but don't abort the dispatcher.
+- Itself recorded in `job_runs` as `tick`. Default interval doesn't apply (it's invoked directly by the scheduler), but max runtime should be ~5 minutes total to avoid the cron piling up.
+
+### 5. HTTP endpoints
+
+In `api/src/Application/Internal/JobsController.php`:
+
+- `POST /internal/jobs/recompute-scores` — body `{full?: bool, max_rows?: int}`.
+- `POST /internal/jobs/cleanup-audit`
+- `POST /internal/jobs/enrich-pending`
+- `POST /internal/jobs/tick`
+- `POST /internal/jobs/refresh-geoip` — for now: returns `412 Precondition Failed` with `{"error":"not_implemented"}`. Real implementation in M11.
+- `GET  /internal/jobs/status` — returns latest `job_runs` per known job, lock state, `overdue: bool`, computed against `defaultIntervalSeconds`.
+
+Response envelope (POST endpoints):
+```json
+{"job":"recompute-scores","status":"success","items_processed":1284,"duration_ms":8421,"run_id":42}
+```
+Statuses: `success`, `failure`, `skipped_locked`. `failure` returns HTTP 500. `skipped_locked` returns HTTP 409. Both still write a `job_runs` row and return the envelope.
+
+### 6. Middlewares
+
+In `api/src/Infrastructure/Http/Middleware/`:
+
+- `InternalNetworkMiddleware.php` — checks `$_SERVER['REMOTE_ADDR']` against the CIDR list `127.0.0.1/32, ::1/128, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16`. Reject with `404` (NOT 403 — be opaque about the existence of these endpoints to outsiders). Use `IpAddress` and `Cidr` from M02 for parsing.
+- `InternalTokenMiddleware.php` — checks `Authorization: Bearer <INTERNAL_JOB_TOKEN>` (`hash_equals`). Reject with `401` if mismatch.
+
+Apply both to all `/internal/*` routes. Order: network → token. (If network fails, don't even acknowledge the auth attempt.)
+
+Also confirm the Caddyfile (from M01) actually applies the network restriction for defense in depth — Caddy returns 404 for non-RFC1918 sources. The PHP middleware is belt-and-suspenders.
+
+### 7. CLI
+
+Extend `api/bin/console`:
+- `jobs:run <name> [--full]` — invokes `JobRunner::run()` directly. Useful for dev/debugging without HTTP.
+- `jobs:status` — prints the same data as `GET /internal/jobs/status`.
+- `scores:rebuild` — convenience alias for `jobs:run recompute-scores --full`.
+
+## Implementation notes
+
+- **Concurrency**: lock acquire+release must survive process crash. The `expires_at` reclaim handles crashed processes; pick `expires_at = now + maxRuntimeSeconds + 30s buffer`.
+- **Long-running jobs in HTTP**: FrankenPHP's worker mode has a per-request timeout. Configure `max_execution_time` to be longer than your longest `maxRuntimeSeconds` for `/internal/jobs/*` routes. Keep public/admin routes at the default lower timeout.
+- **DB perf**: incremental recompute should batch by reading all touched pair-keys first, then iterating. Avoid N+1 queries — fetch all relevant `reports` for a batch of pairs in one IN-list query.
+- **Drop-stale rule**: be careful — score < 0.01 AND `last_report_at` ≥ 90 days old. Don't drop pairs with recent reports just because their score dropped temporarily (shouldn't happen with correct math, but defensive).
+- **Tests**: Three critical scenarios:
+  1. Decay over time. Inject `Clock` to advance; verify scores fall predictably.
+  2. Lock contention. Two concurrent `RecomputeScoresJob` runs (use a barrier in tests). Exactly one `success`, one `skipped_locked`.
+  3. Tick dispatcher invokes only what's due. Set up `job_runs` history; verify only the right jobs run.
+- **Network middleware tests**: integration tests bind to `127.0.0.1` so they should pass naturally; add a unit test that constructs a request with a public IP via `REMOTE_ADDR` mock and asserts 404.
+
+## Out of scope (DO NOT)
+
+- Audit log emission (M12). The cleanup job runs but the table will mostly be empty.
+- GeoIP enrichment logic (M11). The skeleton job no-ops.
+- Allowlist / manual block evaluation (M06). Recompute only updates `ip_scores`; final blocklist filtering is M07.
+- Distribution endpoint (M07).
+- UI changes.
+- Calling `/internal/jobs/*` from the UI directly (UI uses the admin job-trigger wrapper added in M12).
+- New dependencies.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 15
+
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+INTERNAL_TOKEN=$(grep ^INTERNAL_JOB_TOKEN= .env | cut -d= -f2)
+
+# Internal endpoint requires the internal token
+test "$(curl -s -o /dev/null -w '%{http_code}' -X POST http://localhost:8081/internal/jobs/tick)" = "401"
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -H "Authorization: Bearer wrong" \
+  -X POST http://localhost:8081/internal/jobs/tick)" = "401"
+
+# tick succeeds
+RESP=$(curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  http://localhost:8081/internal/jobs/tick)
+echo "$RESP" | grep -q '"job":"tick"'
+
+# recompute-scores runs
+RESP=$(curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  http://localhost:8081/internal/jobs/recompute-scores)
+echo "$RESP" | grep -q '"status":"success"'
+
+# Concurrent calls: exactly one success + one skipped_locked
+RESP1_FILE=$(mktemp); RESP2_FILE=$(mktemp)
+curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  -d '{"full":true}' http://localhost:8081/internal/jobs/recompute-scores > $RESP1_FILE &
+curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  -d '{"full":true}' http://localhost:8081/internal/jobs/recompute-scores > $RESP2_FILE &
+wait
+STATUSES=$(cat $RESP1_FILE $RESP2_FILE | grep -oE '"status":"[a-z_]+"' | sort)
+echo "$STATUSES" | grep -q '"status":"success"'
+echo "$STATUSES" | grep -q '"status":"skipped_locked"'
+
+# /internal/jobs/status returns per-job state
+curl -s -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  http://localhost:8081/internal/jobs/status | grep -q '"recompute-scores"'
+
+# Decay over time: insert old reports, recompute, expect lower scores than fresh
+# (use the CLI scores:rebuild and inspect ip_scores; this is the trickiest acceptance step)
+docker compose exec -T api php bin/console scores:rebuild
+docker compose exec -T api sqlite3 /data/irdb.sqlite "SELECT COUNT(*) FROM ip_scores;"
+
+docker compose down -v
+```
+
+Add a focused integration test in PHP that clocks-forward 30 days between reports and asserts a known score with an exponential half-life of 14 days.
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M05): reputation engine + internal jobs framework
+
+   - Job interface, JobLockRepository (atomic acquire), JobRunner, JobRegistry
+   - RecomputeScoresJob (full + incremental), CleanupAuditJob, EnrichPendingJob (skeleton)
+   - tick dispatcher; /internal/jobs/{recompute-scores,cleanup-audit,enrich-pending,tick,status}
+   - InternalNetworkMiddleware + InternalTokenMiddleware (network-bound + token)
+   - CLI: jobs:run, jobs:status, scores:rebuild
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M05 — Reputation engine & jobs (done)
+
+   **Built:** decay math, bulk recompute (incremental + full), job framework with locks, /internal/jobs/*.
+
+   **Notes for next milestone:**
+   - PairScorer (from M04) is reused by RecomputeScoresJob; both produce identical scores for the same pair.
+   - EnrichPendingJob is a skeleton — M11 fills it in.
+   - refresh-geoip endpoint returns 412 — M11 wires it up.
+   - Job results are returned synchronously; long jobs may exceed default request timeout. /internal/* routes have an extended timeout configured.
+   - Drop rule: score < 0.01 AND last_report_at older than 90 days.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** none.
+   ```
+
+3. **Stop.** Do not start M06.

+ 199 - 0
files/M06-manual-blocks-allowlist.md

@@ -0,0 +1,199 @@
+# M06 — Manual Blocks, Allowlist, Subnets
+
+> Fresh Claude Code agent prompt. M05 must be complete and committed.
+> Estimated effort: small to medium.
+
+## Mission
+
+Implement admin endpoints for manual blocks (single IPs and CIDR subnets) and the allowlist. Build the in-memory CIDR containment evaluator that the distribution endpoint will use in M07. **Allowlist always wins.**
+
+## Before you start
+
+1. Verify M05:
+   ```bash
+   git log --oneline -5
+   cd api && composer test && composer stan && cd ..
+   ```
+2. Read `SPEC.md` §4 (`manual_blocks`, `allowlist` tables), §5 ("Manual override semantics" — allowlist precedence, distribution-time evaluation), §6 (Admin API endpoints for these).
+3. Confirm clean tree.
+
+## Tasks
+
+### 1. Repositories
+
+In `api/src/Infrastructure/Db/`:
+
+- `ManualBlockRepository.php`:
+  - `list(?int $limit, ?int $offset, array $filters): array<ManualBlock>`
+  - `findById(int): ?ManualBlock`
+  - `create(ManualBlock): ManualBlock`
+  - `delete(int): void`
+  - `findExpired(DateTimeImmutable $now): array<int>` — returns ids of expired entries (used by a future cleanup job; skip the job itself for now, just have the query).
+- `AllowlistRepository.php` — same shape, no `expires_at`.
+
+`ManualBlock` value object: `id`, `kind` (`ip` | `subnet`), `ipBin?`, `networkBin?`, `prefixLength?`, `reason`, `expiresAt?`, `createdAt`, `createdByUserId?`.
+`AllowlistEntry` value object: like above, no `expiresAt`.
+
+Validation rules (enforced in service layer):
+- `kind=ip` requires `ip` and forbids `network`/`prefix_length`.
+- `kind=subnet` requires `network` (CIDR string) and computes `network_bin` + `prefix_length`. Reject if the address part doesn't match the prefix (i.e., reject `203.0.113.5/24` — accept only the canonical network address `203.0.113.0/24`). Or normalize automatically; pick one and document. **Recommended: normalize automatically and warn in the response if the input wasn't canonical.**
+
+### 2. Domain service: containment evaluator
+
+In `api/src/Domain/Reputation/CidrEvaluator.php`:
+
+- Loaded with the current set of `manual_blocks` (subnet kind only) and `allowlist` (subnet kind only) on construction.
+- Methods:
+  - `isAllowlisted(IpAddress $ip): bool` — checks both single-IP allowlist entries and subnet entries.
+  - `isManuallyBlocked(IpAddress $ip): bool` — same, for manual_blocks.
+  - `manualBlockedSubnets(): array<Cidr>` — for the distribution endpoint to emit as CIDR lines.
+  - `allowlistedSubnets(): array<Cidr>` — exposed for diagnostics.
+- Implementation: subnet entries are stored as `[networkBin, prefixLength]` pairs. Containment check is a bitwise prefix match — implement as a small helper using PHP's binary string ops. For up to ~10k entries this is fine in PHP; document the limit.
+
+In `api/src/Infrastructure/Reputation/CidrEvaluatorFactory.php`:
+
+- Builds the evaluator from the current DB state.
+- Caches in-process for `CIDR_EVALUATOR_TTL_SECONDS` (default 60s) — gives a near-realtime view without hammering the DB on every blocklist request.
+- Provides `invalidate()` — called from the manual-block / allowlist mutation endpoints so changes are visible immediately.
+
+### 3. Admin endpoints
+
+In `api/src/Application/Admin/`:
+
+- `ManualBlocksController.php`:
+  - `GET    /api/v1/admin/manual-blocks` — list, paginated, filterable by kind.
+  - `GET    /api/v1/admin/manual-blocks/{id}` — detail.
+  - `POST   /api/v1/admin/manual-blocks` — body for IP: `{kind:"ip", ip, reason, expires_at?}`. Body for subnet: `{kind:"subnet", cidr, reason, expires_at?}`.
+  - `DELETE /api/v1/admin/manual-blocks/{id}`
+  - RBAC: `Operator` for create/delete, `Viewer` for list/get.
+- `AllowlistController.php` — analogous, no `expires_at` field.
+
+After any successful POST or DELETE, call `CidrEvaluatorFactory::invalidate()`.
+
+Both v4 and v6 must work end-to-end. Test:
+- IP `203.0.113.42`, subnet `203.0.113.0/24`.
+- IP `2001:db8::1`, subnet `2001:db8::/32`.
+- IPv4-mapped-v6 quirks: `::ffff:203.0.113.42` should round-trip cleanly.
+
+### 4. Effective-status helper
+
+In `api/src/Domain/Reputation/EffectiveStatusService.php`:
+
+- `forIp(IpAddress $ip): EffectiveStatus` — returns one of: `allowlisted`, `manually_blocked`, `scored`, `clean`. Used by the upcoming admin "ip detail" endpoint and the distribution endpoint.
+- Resolution order: allowlisted (any match) → manually blocked (any match) → has scores above any policy threshold (M07 will use this) → clean.
+
+For now this milestone implements only the `allowlisted` and `manually_blocked` checks. Score-vs-policy comes in M07.
+
+## Implementation notes
+
+- **Allowlist precedence**: when an IP matches BOTH the allowlist AND a manual block, allowlist wins. Log a `WARNING` level entry: "IP X is on both allowlist and manual block list; allowlist takes precedence". Don't reject the configuration — admins are allowed to do this, it's just suspicious.
+- **CIDR canonicalization**: `203.0.113.5/24` and `203.0.113.0/24` should be treated as the same network. Pick one of: (a) reject non-canonical with 400, (b) silently canonicalize, (c) canonicalize and include a `normalized_from` field in the response. Recommended (c).
+- **Performance**: linear scan over subnet lists is fine for this milestone. If the user has 100k subnets we have bigger problems. Don't over-engineer with tries / radix trees.
+- **Cache invalidation**: the in-process cache is per-replica. With multi-replica deployments, invalidation in one replica doesn't hit others, so they may serve stale evaluator state for up to TTL seconds. Acceptable for this milestone; document.
+- **Tests**:
+  - Both v4 and v6 paths.
+  - An IP inside an allowlisted /24 with high reputation score (we can simulate a high score in the DB) is `allowlisted` not `manually_blocked` not `scored`.
+  - A /16 manual block produces a single CIDR entry in evaluator's `manualBlockedSubnets()`.
+  - Removing a manual block via DELETE actually drops it from the evaluator.
+
+## Out of scope (DO NOT)
+
+- Distribution endpoint (`/api/v1/blocklist`) — M07.
+- Policy-vs-score evaluation — M07.
+- UI changes.
+- Automatic subnet aggregation. Per SPEC §15, manual only. Don't infer subnets from many bad IPs.
+- Background job for expiring manual blocks. The data model has `expires_at` and the repository has the query, but the cleanup job itself is not required this milestone.
+- Audit emission — M12.
+- New dependencies.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 15
+
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+
+# Create a single-IP manual block
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"ip","ip":"198.51.100.5","reason":"manual block test"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks | grep -q '"id"'
+
+# Create a subnet manual block (canonical)
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"subnet","cidr":"198.51.100.0/24","reason":"subnet block"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks | grep -q '"prefix_length":24'
+
+# Create a subnet manual block (non-canonical → normalized in response)
+RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"subnet","cidr":"203.0.113.55/24","reason":"non canonical"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks)
+echo "$RESP" | grep -q '"normalized_from":"203.0.113.55/24"'
+
+# IPv6 subnet
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"subnet","cidr":"2001:db8::/32","reason":"v6 test"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks | grep -q '"prefix_length":32'
+
+# Allowlist for a known monitoring IP
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"ip","ip":"198.51.100.5","reason":"my monitor"}' \
+  http://localhost:8081/api/v1/admin/allowlist | grep -q '"id"'
+
+# Allowlist a subnet
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"subnet","cidr":"10.0.0.0/8","reason":"private space"}' \
+  http://localhost:8081/api/v1/admin/allowlist | grep -q '"id"'
+
+# Check log warns about overlap (allowlist + manual block on 198.51.100.5)
+docker compose logs api 2>&1 | grep -q "allowlist takes precedence"
+
+# Listing returns entries
+curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/admin/manual-blocks | grep -q '"items"'
+
+# Operator can mutate, Viewer cannot
+VIEWER_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=viewer --quiet)
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X POST -H "Authorization: Bearer $VIEWER_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"ip","ip":"1.2.3.4","reason":"x"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks)" = "403"
+
+docker compose down -v
+```
+
+Plus PHPUnit tests covering the CidrEvaluator (containment math) and the EffectiveStatusService (precedence rules).
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M06): manual blocks, allowlist, CIDR evaluator
+
+   - admin endpoints for manual_blocks and allowlist (IP and CIDR, v4 + v6)
+   - non-canonical CIDR input auto-normalized; response includes normalized_from
+   - in-process CidrEvaluator with 60s cache + invalidation on writes
+   - EffectiveStatusService skeleton (allowlist + manual; score+policy lands in M07)
+   - allowlist always wins; warning logged on overlap with manual blocks
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M06 — Manual blocks, allowlist (done)
+
+   **Built:** CRUD for manual_blocks and allowlist; CidrEvaluator with cache; EffectiveStatusService (partial).
+
+   **Notes for next milestone:**
+   - M07 wires CidrEvaluator into the distribution endpoint and finishes EffectiveStatusService with policy evaluation.
+   - Cache TTL is 60s; mutation endpoints invalidate explicitly. Multi-replica deployments will see up to 60s of staleness across replicas — documented.
+   - Manual block expiration cleanup job is NOT implemented; the data model supports it, the repository has findExpired, but no job runs. Add in M14 hardening if desired, or leave as known limitation.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** none.
+   ```
+
+3. **Stop.** Do not start M07.

+ 206 - 0
files/M07-policies-and-distribution.md

@@ -0,0 +1,206 @@
+# M07 — Policies & Distribution API
+
+> Fresh Claude Code agent prompt. M06 must be complete and committed.
+> Estimated effort: medium.
+
+## Mission
+
+Implement policy CRUD, the policy-vs-score evaluator, the public `GET /api/v1/blocklist` endpoint with caching/ETag/text-and-JSON formats, and a per-policy preview endpoint for the UI. By the end, three different policies produce three different blocklists from identical underlying data, and the endpoint serves 50k entries in <500 ms.
+
+## Before you start
+
+1. Verify M06:
+   ```bash
+   git log --oneline -6
+   cd api && composer test && composer stan && cd ..
+   ```
+2. Read `SPEC.md` §4 (`policies`, `policy_category_thresholds`), §5 (output rule for an IP appearing on a policy's blocklist), §6 (Public API: `/api/v1/blocklist`; Admin API: policies + preview).
+3. Confirm the seed policies from M02 exist with sensible thresholds.
+
+## Tasks
+
+### 1. Policy domain
+
+In `api/src/Domain/Policy/`:
+
+- `Policy.php` — value object: `id`, `name`, `description`, `includeManualBlocks`, `thresholds: array<int, float>` (categoryId => threshold).
+- `PolicyEvaluator.php`:
+  - Constructor takes a `Policy` and the current `CidrEvaluator` from M06.
+  - `evaluate(IpAddress $ip, array $scoresByCategory): EvaluationResult` — returns one of: `EXCLUDED_BY_ALLOWLIST`, `INCLUDED_BY_MANUAL_BLOCK`, `INCLUDED_BY_SCORE` (with the matching categories), or `EXCLUDED`.
+  - The score-side rule: an IP is included if **any** category in the policy meets its threshold. `policy_category_thresholds` rows define inclusion; absent rows mean "this category is ignored by this policy."
+
+In `api/src/Infrastructure/Db/PolicyRepository.php`:
+
+- CRUD over `policies` and `policy_category_thresholds` (the join is small; load thresholds eagerly with each policy).
+- `byName(string): ?Policy`, `byId(int): ?Policy`.
+- Concurrent threshold updates: replace all thresholds for a policy in a single transaction.
+
+### 2. Admin endpoints
+
+In `api/src/Application/Admin/PoliciesController.php`:
+
+- `GET    /api/v1/admin/policies`
+- `GET    /api/v1/admin/policies/{id}` — includes thresholds.
+- `POST   /api/v1/admin/policies` — body `{name, description, include_manual_blocks, thresholds: {<category_slug>: <number>}}`.
+- `PATCH  /api/v1/admin/policies/{id}` — same body shape; replaces thresholds wholesale.
+- `DELETE /api/v1/admin/policies/{id}` — refuse if any consumer references this policy (409 with `{"error":"policy_in_use","consumers":[...]}`); cascade is wrong here.
+- `GET    /api/v1/admin/policies/{id}/preview` — returns `{count: int, sample: [string], generated_at}`. Sample = first 50 entries. Same calculation as the distribution endpoint.
+
+RBAC: `Admin` for write, `Viewer` for read.
+
+### 3. Distribution endpoint
+
+In `api/src/Application/Public/BlocklistController.php`:
+
+- `GET /api/v1/blocklist` — token must be `kind=consumer`. Resolves the consumer's policy, evaluates, returns the blocklist.
+- Output formats:
+  - Default: `text/plain`. One entry per line. No comments. Lines are bare IPs (`203.0.113.42`, `2001:db8::1`) or CIDRs (`203.0.113.0/24`, `2001:db8::/32`).
+  - `?format=json`: JSON array of `{ip_or_cidr, categories: [string], score: number|null, reason: "scored"|"manual"}`. Allowlisted IPs never appear in either format.
+- Headers (both formats):
+  - `ETag`: SHA-256 hex of the response body. Honor `If-None-Match` → `304` with empty body.
+  - `X-Blocklist-Generated-At`: ISO 8601.
+  - `X-Blocklist-Entries`: count.
+  - `X-Blocklist-Policy`: policy name.
+- Caching: 30-second per-policy in-memory cache (key: `policyId`). Cache invalidation triggers: any mutation to `policies`, `policy_category_thresholds`, `manual_blocks`, `allowlist`, or a manual flag from M12's "rebuild scores" trigger. For simplicity now, just TTL — invalidation hooks into mutations come for free if you respect the same `CidrEvaluator` invalidation pattern from M06.
+
+### 4. Blocklist computation
+
+In `api/src/Domain/Reputation/BlocklistBuilder.php`:
+
+- `build(Policy $policy): Blocklist` — returns a list of entries with metadata.
+- Algorithm:
+  1. Read all `ip_scores` rows joined to categories where the score column meets at least one threshold for this policy. Single SQL query with a UNION across category thresholds, OR a simpler "select all, filter in PHP" if policy has few categories. Pick whichever is faster on a 50k-row dataset; benchmark.
+  2. Filter out IPs in the allowlist (`CidrEvaluator::isAllowlisted`).
+  3. If `include_manual_blocks`, append all manual block entries (single IPs and CIDRs), filtering allowlisted ones.
+  4. Deduplicate (an IP might be both scored and manually blocked).
+  5. Sort: IPv4 first, then IPv6; lexical within each. Stable order so the ETag is stable.
+- Returns entries with the exact representation needed for both formats.
+
+`Blocklist` value object: a list of `BlocklistEntry { ipOrCidr, isCidr, categories?, score?, reason }`.
+
+### 5. Performance
+
+Add a perf test in `api/tests/Integration/Perf/BlocklistPerfTest.php`:
+- Seed 50k `ip_scores` rows (mixed v4 and v6, varied scores) plus 100 manual subnet blocks.
+- Time the blocklist build for the `paranoid` policy.
+- Assert <500 ms wall-clock.
+- Skip in default test runs (mark `@group perf`); run in CI as a separate job.
+
+If you can't hit 500 ms, the bottleneck is almost certainly the SQL query. Options:
+- Add a covering index on `ip_scores(category_id, score DESC)` so threshold-filter scans are cheap.
+- Pre-aggregate per-IP "max score across all categories" into a derived column in `ip_scores` (mild denormalization). Out of scope unless 500ms is unreachable; document if you take this route.
+
+## Implementation notes
+
+- **Cache vs eviction**: per-policy 30s cache key by `policy_id`. Memory bound: if a deployment has 100 policies × 50k entries × ~50 bytes each, that's ~250 MB. Acceptable for default; flag in PROGRESS.md as a known footprint.
+- **JSON format**: keep it small. Don't include audit/timestamp fields per entry; that's what the admin API is for.
+- **Empty blocklist**: 200 with empty body in text mode, `[]` in JSON. Still emit ETag.
+- **ETag stability**: the ETag must depend only on the data, not on time. Don't include `generated_at` in the body.
+- **`If-None-Match`**: parse standard format including weak validators (`W/"..."`). Strict comparison on the strong hash is fine.
+- **Deduplication subtlety**: if an IP is in `ip_scores` AND inside a manually blocked /24, you have two ways to include it (single + subnet). Prefer the broader one (the /24 subnet entry covers the IP); drop the single entry to keep the list compact.
+- **Subnet expansion**: never expand a /16 to 65k entries. Emit as CIDR.
+
+## Out of scope (DO NOT)
+
+- UI changes — M08 onward.
+- Audit emission — M12.
+- Format generators for specific firewalls (iptables, nginx, HAProxy). The `text/plain` output is universal; per-firewall transformation is a client-side concern, with examples shipped in M13's `examples/consumers/`.
+- Compression (gzip) — let FrankenPHP/Caddy handle it via standard headers if needed; don't roll your own.
+- Streaming responses — buffered text response is fine at 50k entries.
+- New dependencies.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+cd api && vendor/bin/phpunit --group perf && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 15
+
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+
+# Create a consumer + token (requires a policy_id; use the seeded "moderate")
+POLICY_ID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/admin/policies \
+  | php -r '$j=json_decode(stream_get_contents(STDIN),true); foreach($j["items"] as $p){if($p["name"]==="moderate"){echo $p["id"];break;}}')
+CONSUMER=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d "{\"name\":\"firewall-1\",\"description\":\"edge\",\"policy_id\":$POLICY_ID}" \
+  http://localhost:8081/api/v1/admin/consumers)
+CONSUMER_ID=$(echo "$CONSUMER" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];')
+TOKEN_RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d "{\"kind\":\"consumer\",\"consumer_id\":$CONSUMER_ID}" \
+  http://localhost:8081/api/v1/admin/tokens)
+CONSUMER_TOKEN=$(echo "$TOKEN_RESP" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["raw_token"];')
+
+# Empty blocklist initially
+curl -s -H "Authorization: Bearer $CONSUMER_TOKEN" http://localhost:8081/api/v1/blocklist
+# -> empty body, 200
+
+# Insert a manual block; blocklist now contains it
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"subnet","cidr":"198.51.100.0/24","reason":"x"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks > /dev/null
+sleep 1
+curl -s -H "Authorization: Bearer $CONSUMER_TOKEN" http://localhost:8081/api/v1/blocklist | grep -q "198.51.100.0/24"
+
+# JSON format
+curl -s -H "Authorization: Bearer $CONSUMER_TOKEN" \
+  "http://localhost:8081/api/v1/blocklist?format=json" | grep -q '"reason":"manual"'
+
+# ETag round-trip
+ETAG=$(curl -s -D - -H "Authorization: Bearer $CONSUMER_TOKEN" \
+  http://localhost:8081/api/v1/blocklist -o /dev/null | grep -i '^etag:' | cut -d' ' -f2 | tr -d '\r')
+test "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $CONSUMER_TOKEN" \
+  -H "If-None-Match: $ETAG" http://localhost:8081/api/v1/blocklist)" = "304"
+
+# Three policies, three different counts after seeding scored data
+# (Seed at least one IP with a high enough score that paranoid catches it but strict doesn't.)
+# Detailed seeding handled by an integration test; here just verify the preview endpoint differs:
+for P in strict moderate paranoid; do
+  PID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/policies \
+    | php -r "\$j=json_decode(stream_get_contents(STDIN),true); foreach(\$j['items'] as \$p){if(\$p['name']==='$P'){echo \$p['id'];break;}}")
+  curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+    http://localhost:8081/api/v1/admin/policies/$PID/preview
+  echo
+done
+
+# Token wrong kind: admin can't pull blocklist
+test "$(curl -s -o /dev/null -w '%{http_code}' -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/blocklist)" = "401"
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M07): policies, blocklist distribution endpoint
+
+   - policy CRUD with thresholds (replaces wholesale on PATCH)
+   - GET /api/v1/blocklist (text + json), ETag with If-None-Match round-trip
+   - per-policy 30s cache, invalidated on relevant mutations
+   - BlocklistBuilder with allowlist filtering and manual-block dedup
+   - perf test: 50k entries < 500ms (sqlite)
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M07 — Policies & distribution (done)
+
+   **Built:** policy CRUD, blocklist endpoint, preview endpoint, ETag, perf-tested at 50k entries.
+
+   **Notes for next milestone:**
+   - Per-policy cache TTL = 30s. Mutation endpoints invalidate the cache for affected policies.
+   - The text/plain format is universal; firewall-specific consumers transform on their side. Examples land in M13.
+   - DELETE on a policy with consumers returns 409 with the consumer list.
+   - Performance: SQLite hits the 500ms target with [add measured number]. MySQL [add measured number].
+
+   **Deviations from SPEC:** [list any, e.g. additional index added]
+   **Added dependencies:** none.
+   ```
+
+3. **Stop.** Do not start M08.

+ 262 - 0
files/M08-ui-scaffold-and-auth.md

@@ -0,0 +1,262 @@
+# M08 — UI Scaffold & Auth Flows
+
+> Fresh Claude Code agent prompt. M07 must be complete and committed.
+> Estimated effort: large. This milestone establishes the entire UI baseline.
+
+## Mission
+
+Build the `ui` container's foundation: Slim app, base layout (Tailwind + dark mode + sidebar/topnav), session manager, CSRF middleware, ApiClient with retry, OIDC redirect/callback flow, local admin login form, logout. After this milestone, a user can sign in via OIDC against a test tenant or as local admin, and a `/app/me` page renders showing the user's identity. **Page content beyond `/app/me` lands in M09.**
+
+## Before you start
+
+1. Verify M07:
+   ```bash
+   git log --oneline -7
+   cd api && composer test && composer stan && cd ..
+   cd ui && composer test && composer stan && cd ..
+   ```
+2. Read `SPEC.md` §2 (UI tech stack), §6 (Auth API endpoints — `/api/v1/auth/users/upsert-{oidc,local}`, `/api/v1/admin/me`), §7 (UI Container — full section, especially "Identity resolution flow"), §8 (Authentication — UI-side parts).
+3. Set up an Entra test tenant or use a workforce/personal account if you can. Document the setup steps you used in `doc/oidc.md` (this is allowed even though M13 owns most docs — auth setup is a prerequisite for testing this milestone).
+
+## Tasks
+
+### 1. Base UI infrastructure
+
+In `ui/src/App/`:
+- `Bootstrap.php` — boots Slim, registers middlewares, registers routes.
+- `Container.php` — DI bindings. Bind a `GuzzleHttp\Client` configured with `API_BASE_URL` and 5s default timeout.
+- `Routes.php` — declared routes; auth-required routes mounted under `/app/*`.
+
+In `ui/src/Http/`:
+- `JsonExceptionHandler.php` — catches uncaught exceptions, renders a friendly Twig error page (not raw JSON; this is a UI), logs the full exception. Distinguish 4xx vs 5xx in the rendered template.
+- `CsrfMiddleware.php` — generates a per-session CSRF token, validates on POST/PUT/PATCH/DELETE, exposes the token to Twig via a global. Use a constant-time compare.
+- `FlashMessageMiddleware.php` — pulls flash messages from session and exposes to Twig.
+
+### 2. Session management
+
+In `ui/src/Auth/`:
+- `SessionManager.php` — wraps PHP native sessions. Methods: `startSession()`, `setUser(int $userId, string $displayName, string $role, ?string $email)`, `getUser(): ?UserContext`, `clear()`, `regenerateId()` (call after auth success).
+- `UserContext.php` — value object with the cached fields.
+- Sessions: file-based, inside the container. Cookie: name `irdb_session`, `HttpOnly`, `SameSite=Lax`, `Secure` when `APP_ENV=production`.
+- Session lifetime: 8 hours of inactivity; absolute max 24 hours.
+
+### 3. ApiClient
+
+In `ui/src/ApiClient/`:
+- `ApiClient.php` — wraps Guzzle. Auto-attaches:
+  - `Authorization: Bearer <UI_SERVICE_TOKEN>`.
+  - `X-Acting-User-Id: <session.user_id>` if a user is in the session.
+  - `Accept: application/json`.
+  - User agent: `irdb-ui/<version>`.
+- Automatic retry (1 retry on connection errors and 5xx; no retry on 4xx).
+- Maps non-2xx responses to typed exceptions: `ApiAuthException` (401/403), `ApiValidationException` (400/422 — carries field errors), `ApiNotFoundException` (404), `ApiServerException` (5xx), `ApiUnreachableException` (network/timeout).
+- Subclients per endpoint group:
+  - `AuthClient` — `upsertOidc(...)`, `upsertLocal(...)`.
+  - `AdminClient` — `getMe()`, plus stubs for endpoints used in M09–M12. **Don't implement subclient methods you don't yet need; M09+ adds them as needed.**
+
+DTOs in `ui/src/ApiClient/DTOs/` — small classes mirroring API response shapes (`UserDto`, `PolicyDto`, etc.). Strict types; no array soup leaking into controllers.
+
+### 4. OIDC flow
+
+In `ui/src/Auth/OidcController.php`:
+
+- `GET /login/oidc` — initiates flow. Generates state + code-verifier + nonce; stores in session; redirects to Entra authorize endpoint with PKCE.
+- `GET /oidc/callback`:
+  1. Validates state.
+  2. Exchanges code for tokens via the OIDC client.
+  3. Validates the ID token (signature, issuer, audience, expiry, nonce).
+  4. Extracts `sub`, `email` (or `preferred_username` if email absent), `name`, `groups` (array of group object IDs).
+  5. Calls `AuthClient::upsertOidc($sub, $email, $displayName, $groups)`.
+  6. If the API returns a user with role `none` (or however your `OIDC_DEFAULT_ROLE=none` case surfaces), redirect to a "no access" page rather than logging in.
+  7. Calls `SessionManager::regenerateId()`, then `setUser(...)`.
+  8. Redirects to `/app/me` (the session manager remembers a `next` URL if one was set pre-auth).
+
+Use `jumbojett/openid-connect-php`. Configure scopes: `openid profile email`. The `groups` claim must already be present in the ID token from Entra; document that in `doc/oidc.md`.
+
+### 5. Local admin login
+
+In `ui/src/Auth/LocalLoginController.php`:
+
+- `GET  /login` — renders the login form.
+- `POST /login/local`:
+  1. CSRF check (handled by middleware, but verify it's wired).
+  2. Validate username matches `LOCAL_ADMIN_USERNAME`.
+  3. `password_verify` against `LOCAL_ADMIN_PASSWORD_HASH`.
+  4. Throttle: track failed attempts in session + a small in-memory backoff. After 5 failures: 30-second lockout. (Full brute-force protection is M14; this is the basic version.)
+  5. On success: `AuthClient::upsertLocal($username)`, regenerate session, `setUser(...)`, redirect to `/app/me`.
+  6. On failure: flash error, redirect back to `/login`.
+- Hide local sign-in entirely if `LOCAL_ADMIN_ENABLED=false`.
+
+### 6. Logout
+
+- `POST /logout` — clears session, redirects to `/login`. CSRF-protected.
+
+### 7. Pages
+
+In `ui/resources/views/`:
+
+- `layout.twig` — full layout: top nav (logo, search-box stub, dark-mode toggle button, user menu with logout), sidebar (placeholder links to Dashboard, IPs, Subnets, Allowlist, Policies, Reporters, Consumers, Tokens, Categories, Audit, Settings — these aren't built yet, but the nav structure is).
+- `pages/login.twig` — clean centered card. "Sign in with Microsoft" primary button (only if `OIDC_ENABLED=true`), "Local sign-in" collapsed/hidden behind a link (only if `LOCAL_ADMIN_ENABLED=true`).
+- `pages/me.twig` — `/app/me`: shows user_id, email, display_name, role, source ("oidc" / "local"). Includes a "Logout" button (POSTs).
+- `pages/no-access.twig` — for OIDC users who land here without a role grant.
+- `pages/error.twig` — generic error template.
+- `partials/topnav.twig`, `partials/sidebar.twig`, `partials/flash.twig`, `partials/csrf.twig`.
+
+Tailwind: dark mode via `class="dark"` toggled by JS on `<html>`. Persist to `localStorage` (key `irdb-theme`). Default to system preference on first visit. Use `prefers-color-scheme` media query as fallback.
+
+Alpine for the dark-mode toggle and the user menu dropdown. htmx not strictly needed yet but the pattern should be established for M09's tables.
+
+### 8. Healthz update
+
+`GET /healthz` in the ui:
+- Returns `200` always (unless the ui itself is broken).
+- Body includes `api_reachable` (boolean, last status) and `last_api_check_at` (ISO 8601 of most recent successful HEAD on `<API_BASE_URL>/healthz`).
+- A background ticker is overkill — just remember the most recent ApiClient call's success/failure in a singleton service. If no API call has happened yet, both fields are null.
+
+### 9. Configuration
+
+Read these env vars (all UI-side, per SPEC §9):
+- `OIDC_ENABLED`, `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`, `OIDC_REDIRECT_URI`.
+- `LOCAL_ADMIN_ENABLED`, `LOCAL_ADMIN_USERNAME`, `LOCAL_ADMIN_PASSWORD_HASH`.
+- `UI_SERVICE_TOKEN`, `API_BASE_URL`.
+- `UI_SECRET`, `PUBLIC_URL`, `APP_ENV`, `LOG_LEVEL`.
+
+Validate at startup: log a clear error and exit non-zero if `UI_SERVICE_TOKEN` or `API_BASE_URL` are missing. If both `OIDC_ENABLED=false` and `LOCAL_ADMIN_ENABLED=false`, exit non-zero (no way to log in).
+
+### 10. OIDC documentation stub
+
+Create `doc/oidc.md` with the steps you actually used to set up Entra ID for testing:
+- App registration creation
+- Redirect URI
+- Client secret
+- API permissions (and consent)
+- Group claim configuration in token configuration / app manifest
+- Test user assignment
+
+Keep it factual; M13 will polish.
+
+## Implementation notes
+
+- **`upsertOidc` failure modes**: if the API returns 5xx, render an error page with retry. If it returns the user with role `viewer` and you're redirecting to `/app/me` (where they can see their identity but nothing else yet), that's correct behavior. The "no role" case (when `OIDC_DEFAULT_ROLE=none` and no group matches) needs a clear "no access — contact admin" page.
+- **Session fixation**: regenerate session ID on every auth-state change (login success, logout). Sessions before login should not carry over.
+- **CSRF on logout**: yes, even logout. Otherwise CSRF can log users out unexpectedly.
+- **htmx + CSRF**: htmx requests must include the CSRF token. Use the standard pattern of a meta tag in `layout.twig` and an htmx config that pulls it.
+- **Dark mode FOUC**: prevent flash by inlining a tiny script in `<head>` that reads `localStorage` and applies the class before the body renders.
+- **API unreachable**: when ApiClient throws `ApiUnreachableException`, the relevant page should render a friendly degraded state (sidebar still visible, content area shows "API unreachable; retrying in 30s"). Don't crash the whole UI.
+- **Tests**:
+  - Unit: SessionManager, ApiClient exception mapping, CSRF middleware.
+  - Integration: spin up the ui Slim app with a mocked ApiClient. Verify login flows, redirects, session set, CSRF rejection.
+  - You can mock OIDC; full OIDC integration tests against a real tenant are out of scope (manual verification suffices).
+
+## Out of scope (DO NOT)
+
+- Any UI page beyond `/login`, `/oidc/callback`, `/app/me`, `/no-access`, errors. M09 onward.
+- Calling admin endpoints other than `/api/v1/admin/me`. M09+.
+- Token entry list, IP search, dashboard charts, etc. M09+.
+- Audit display. M12.
+- Settings page with job triggers. M12.
+- Brute-force lockout beyond the basic 5-fail/30s. Full version M14.
+- Rate-limiting the UI. M14.
+- New api endpoints. (If you find you need one, stop and reconsider — likely M09+ work creeping in.)
+- Touching `api/` code at all. UI-only milestone.
+
+## Acceptance
+
+```bash
+cd ui && composer cs && composer stan && composer test && cd ..
+cd ui && npm ci && npm run build && cd ..
+
+docker compose down -v
+cp .env.example .env
+# Set: UI_SERVICE_TOKEN matches between containers
+# Set: LOCAL_ADMIN_ENABLED=true, LOCAL_ADMIN_USERNAME=admin
+# Generate hash: php -r 'echo password_hash("test1234", PASSWORD_ARGON2ID);'
+# Set: LOCAL_ADMIN_PASSWORD_HASH=<that hash>
+# OIDC: leave with placeholder issuer/client_id/secret unless you have a tenant ready
+docker compose up -d
+sleep 20
+
+# UI returns login page when not authenticated
+curl -s http://localhost:8080/ -L | grep -q "Sign in"
+
+# Healthz works on UI
+curl -sf http://localhost:8080/healthz | grep -q '"status":"ok"'
+
+# /app/me unauthenticated redirects to /login
+test "$(curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/app/me)" = "302"
+
+# Local admin login flow (CSRF-aware)
+COOKIE_JAR=$(mktemp)
+# 1. GET /login to obtain a session + CSRF token
+LOGIN_PAGE=$(curl -s -c $COOKIE_JAR http://localhost:8080/login)
+CSRF=$(echo "$LOGIN_PAGE" | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+[ -n "$CSRF" ]
+# 2. POST credentials
+curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&username=admin&password=test1234" \
+  http://localhost:8080/login/local -L -o /tmp/me_response.html
+grep -q "admin@local" /tmp/me_response.html || grep -q '"role":"admin"' /tmp/me_response.html || grep -qi "local admin" /tmp/me_response.html
+
+# Wrong password is rejected
+COOKIE_JAR2=$(mktemp)
+LOGIN_PAGE=$(curl -s -c $COOKIE_JAR2 http://localhost:8080/login)
+CSRF=$(echo "$LOGIN_PAGE" | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+RESP=$(curl -s -b $COOKIE_JAR2 -c $COOKIE_JAR2 -X POST \
+  -d "csrf_token=$CSRF&username=admin&password=WRONG" \
+  http://localhost:8080/login/local -L)
+echo "$RESP" | grep -qi "invalid\|incorrect\|failed"
+
+# CSRF without token is rejected
+test "$(curl -s -o /dev/null -w '%{http_code}' -X POST \
+  -d "username=admin&password=test1234" \
+  http://localhost:8080/login/local)" = "403"
+
+# Logout
+curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF" \
+  http://localhost:8080/logout -L > /dev/null
+# After logout, /app/me redirects to /login
+test "$(curl -s -b $COOKIE_JAR -o /dev/null -w '%{http_code}' http://localhost:8080/app/me)" = "302"
+
+docker compose down -v
+```
+
+OIDC flow against a real tenant: manual verification. Document the test in `PROGRESS.md`.
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M08): ui scaffold, OIDC + local admin auth, session, ApiClient
+
+   - Slim+Twig+Tailwind base with dark-mode toggle and sidebar/topnav layout
+   - ApiClient with auto Bearer + X-Acting-User-Id, retry, typed exceptions
+   - OIDC code-flow with PKCE; ID token validation; upsert-oidc → session
+   - Local admin login with Argon2id verify, basic 5-fail/30s throttle
+   - logout, CSRF-protected; CSRF middleware globally
+   - /app/me renders user identity, /no-access for unmapped OIDC users
+   - doc/oidc.md drafted with Entra setup steps
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M08 — UI scaffold & auth (done)
+
+   **Built:** UI base, both auth paths, sessions, ApiClient.
+
+   **Notes for next milestone:**
+   - AdminClient has only `getMe()`; M09 adds methods for IP search, IP detail, etc.
+   - The ApiClient's exception types are stable; M09+ catches them in controllers.
+   - Sidebar links exist but most lead to "not implemented" placeholders. M09–M12 fills them.
+   - dark mode persistence: `localStorage.irdb-theme = 'dark' | 'light'`.
+   - M14 will replace the basic 5/30 throttle with a full brute-force lockout.
+
+   **Manual verification:**
+   - OIDC flow against [tenant name / "test tenant configured at ..."]: succeeded for users in groups [...].
+   - Tested role mapping by [...].
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** [list, e.g. jumbojett/openid-connect-php was already in SPEC §2].
+   ```
+
+3. **Stop.** Do not start M09.

+ 229 - 0
files/M09-ui-ips-history-dashboard.md

@@ -0,0 +1,229 @@
+# M09 — UI: IPs, History, Dashboard
+
+> Fresh Claude Code agent prompt. M08 must be complete and committed.
+> Estimated effort: medium.
+
+## Mission
+
+Build the three core read-only UI pages: Dashboard, IPs list (search/filter/paginate), and IP Detail (enrichment placeholder, scores per category, history timeline). Add the corresponding API admin endpoints for the data: an IP search endpoint, an IP detail endpoint, and a dashboard stats endpoint. **Read-only this milestone** — no manual block buttons or tokens UI yet (M10).
+
+## Before you start
+
+1. Verify M08:
+   ```bash
+   git log --oneline -8
+   cd api && composer test && cd ..
+   cd ui  && composer test && cd ..
+   ```
+2. Read `SPEC.md` §6 (Admin API endpoints — `/api/v1/admin/ips/{ip}`, `/api/v1/admin/ips?...`, `/api/v1/admin/stats/dashboard`), §7 (Web UI Pages — Dashboard, IPs, IP Detail).
+3. Confirm clean tree.
+
+## Tasks
+
+### 1. API: Admin IP endpoints
+
+In `api/src/Application/Admin/IpsController.php`:
+
+- `GET /api/v1/admin/ips?q=&category=&min_score=&max_score=&country=&asn=&status=&page=&page_size=`
+  - `q`: substring match on `ip_text` (efficient with index — use `LIKE 'prefix%'` when the query looks like an IP prefix).
+  - `category`: filter to IPs with score in this category above 0.
+  - `min_score`, `max_score`: numeric range; applies to `MAX(score)` across all categories.
+  - `country` (2-letter), `asn` (integer): from `ip_enrichment` (table exists; data lands in M11 — return null/blank gracefully now).
+  - `status`: one of `scored | manual | allowlisted | clean` (uses `EffectiveStatusService` from M06 + this milestone's score check).
+  - Returns `{items: [...], page, page_size, total}`.
+- `GET /api/v1/admin/ips/{ip}` — `ip` is URL-encoded; parse via `IpAddress::fromString` (404 on bad).
+  - Returns:
+    ```json
+    {
+      "ip": "203.0.113.42",
+      "is_ipv4": true,
+      "scores": [{"category":"brute_force","score":2.34,"last_report_at":"...","report_count_30d":12}, ...],
+      "enrichment": {"country_code":null,"asn":null,"as_org":null,"enriched_at":null},
+      "status": "scored",
+      "manual_block": null,
+      "allowlist": null,
+      "history": [
+        {"type":"report","received_at":"...","category":"brute_force","reporter":"web-prod-01","weight":1.0,"metadata":{...}},
+        {"type":"manual_block_added","at":"...","actor":"admin@example","reason":"..."},
+        ...
+      ]
+    }
+    ```
+  - History combines `reports`, `manual_blocks` events, `allowlist` events, audit entries about this IP. Reports are the bulk; manual/allowlist events come from creation+deletion timestamps.
+  - Limit history to most recent 200 entries; include `has_more: bool`.
+
+- `GET /api/v1/admin/stats/dashboard` — returns:
+  ```json
+  {
+    "active_blocks": <count of IPs currently in any policy's blocklist using "moderate" as default reference>,
+    "manual_blocks_count": ...,
+    "allowlist_count": ...,
+    "reports_24h": ...,
+    "reports_24h_by_hour": [{"hour":"2026-04-27T15:00Z","count":42}, ...],
+    "top_reporters_24h": [{"name":"web-prod-01","count":120}, ...],
+    "top_categories_24h": [{"slug":"brute_force","count":300}, ...],
+    "jobs_status": [{"name":"recompute-scores","last_finished_at":"...","status":"success","overdue":false}, ...]
+  }
+  ```
+  - Cache for 30s in-memory.
+
+RBAC: `Viewer` for all three.
+
+### 2. UI: AdminClient extensions
+
+In `ui/src/ApiClient/AdminClient.php`:
+
+- `searchIps(array $filters, int $page, int $pageSize): IpListDto`
+- `getIp(string $ip): IpDetailDto`
+- `getDashboardStats(): DashboardStatsDto`
+
+DTOs match the API shapes. Use simple constructors.
+
+### 3. UI: Pages
+
+In `ui/src/Controllers/`:
+
+- `DashboardController.php` — `GET /app/dashboard`. Renders `pages/dashboard.twig` with stats. Server-side render; the chart uses Chart.js via CDN-vendored static asset (already npm-installable via `chart.js`).
+- `IpsController.php`:
+  - `GET /app/ips` — list page with filters (form fields with htmx for live filter optional).
+  - `GET /app/ips/{ip}` — detail page.
+
+Templates in `ui/resources/views/pages/`:
+- `dashboard.twig` — top counts in cards; line chart of reports/hour for last 24h; tables for top reporters and top categories; jobs status list (don't add manual triggers — those are M12).
+- `ips/index.twig` — filter form (q, category dropdown, score range, country, ASN, status), paginated table. Columns: IP (link to detail), country flag (placeholder if no enrichment), ASN, top category, total score, last report relative time, status pill.
+- `ips/detail.twig` — header with IP and status pill; enrichment panel (greys out gracefully when null); score-per-category bars (CSS-only, no JS); history timeline (server-rendered; show "Load older" button or pagination if history is large).
+
+### 4. Routes & nav
+
+Update `ui/src/App/Routes.php`:
+- After login, default redirect to `/app/dashboard` (was `/app/me` in M08).
+- All `/app/*` routes require an authenticated session; otherwise redirect to `/login` with a `next` parameter.
+
+Update `partials/sidebar.twig` to highlight the active section.
+
+### 5. Dark mode polish
+
+- Verify all three pages render cleanly in both modes.
+- Status pills: use Tailwind color tokens that work in dark mode (e.g. `bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-100`).
+
+### 6. Accessibility
+
+- All interactive elements keyboard-reachable.
+- Form labels properly associated.
+- Color contrast meets WCAG AA in both modes.
+- The agent should run a Lighthouse accessibility check on the IPs list and detail pages and fix any score below 90.
+
+### 7. RBAC visibility
+
+- Viewer: sees everything on these three pages.
+- Operator and Admin: same. (No write actions on these pages this milestone.)
+
+## Implementation notes
+
+- **Pagination**: use page+page_size, default `page_size=25`, cap at `200`. Cursor pagination would be better for huge tables but isn't worth it here.
+- **Searching by IP prefix**: index `(ip_text)` on `reports`, `ip_scores`, `ip_enrichment`, `manual_blocks`, `allowlist` — most exist already; verify and add if missing via a small migration. Document in PROGRESS.md.
+- **Dashboard chart**: render a 24-bucket bar/line chart via Chart.js. Server pre-buckets by hour to avoid 1000s of points.
+- **History query performance**: when an IP has thousands of reports, joining history across multiple sources can be slow. Materialize the union via a single query that selects from each source with `received_at`/`created_at` aliased uniformly, then ORDER+LIMIT.
+- **`country_code` flag**: render as a Unicode regional indicator pair if present (e.g. "US" → 🇺🇸). Fall back to a 2-char text pill if the font doesn't render emoji flags.
+- **Tests**:
+  - api: integration tests for each endpoint with seeded data.
+  - ui: integration tests with mocked AdminClient verifying the templates render expected text/structures. No need for browser-level tests.
+
+## Out of scope (DO NOT)
+
+- Manual block / allowlist creation buttons on the IP detail page. M10.
+- Token, reporter, consumer, policy, category management UI. M10.
+- Audit log UI. M12.
+- Settings page. M12.
+- Real GeoIP enrichment values. M11. The endpoint shape includes the fields; data is null until M11.
+- Charts beyond the dashboard line chart (no per-category trend page, no global trends, etc.).
+- New api endpoints beyond the three listed.
+- htmx live-search / infinite scroll. Pagination is fine.
+- New dependencies (Chart.js is allowed if not already present; record in PROGRESS.md).
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+cd ui  && composer cs && composer stan && composer test && cd ..
+cd ui  && npm ci && npm run build && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 20
+
+# Seed some data via the API to make pages non-empty
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+# Create a reporter + token; submit a few reports across categories; verify they show
+REPORTER=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"name":"test-reporter","trust_weight":1.0}' \
+  http://localhost:8081/api/v1/admin/reporters)
+RID=$(echo "$REPORTER" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];')
+RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d "{\"kind\":\"reporter\",\"reporter_id\":$RID}" http://localhost:8081/api/v1/admin/tokens)
+RT=$(echo "$RESP" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["raw_token"];')
+for ip in 203.0.113.10 203.0.113.11 2001:db8::1; do
+  curl -s -X POST -H "Authorization: Bearer $RT" -H "Content-Type: application/json" \
+    -d "{\"ip\":\"$ip\",\"category\":\"brute_force\"}" http://localhost:8081/api/v1/report > /dev/null
+done
+
+# Login as local admin (re-use the M08 acceptance flow), then:
+COOKIE_JAR=$(mktemp)
+CSRF=$(curl -s -c $COOKIE_JAR http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&username=admin&password=test1234" \
+  http://localhost:8080/login/local -L > /dev/null
+
+# Dashboard renders with non-zero counts
+curl -s -b $COOKIE_JAR http://localhost:8080/app/dashboard | grep -qi "reports"
+curl -s -b $COOKIE_JAR http://localhost:8080/app/dashboard | grep -q "203.0.113"   # at least one IP-related counter
+
+# IPs list shows our reported IPs
+curl -s -b $COOKIE_JAR http://localhost:8080/app/ips | grep -q "203.0.113.10"
+
+# Filter by IPv6
+curl -s -b $COOKIE_JAR "http://localhost:8080/app/ips?q=2001" | grep -q "2001:db8::1"
+
+# IP detail page
+curl -s -b $COOKIE_JAR http://localhost:8080/app/ips/203.0.113.10 | grep -q "brute_force"
+
+# Bad IP → 404 page
+test "$(curl -s -b $COOKIE_JAR -o /dev/null -w '%{http_code}' http://localhost:8080/app/ips/not-an-ip)" = "404"
+
+# Lighthouse-equivalent: at minimum, run htmx-side tests for accessibility attributes
+# (manual: open in a browser and run Lighthouse; aim ≥90)
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M09): UI dashboard, IPs list, IP detail; matching admin API endpoints
+
+   - GET /api/v1/admin/ips, /ips/{ip}, /stats/dashboard
+   - dashboard with Chart.js (24h reports), top reporters/categories, jobs status
+   - IP search with q/category/score/country/asn/status filters + pagination
+   - IP detail: scores per category, history timeline (reports + manual events)
+   - dark mode polished; Lighthouse a11y ≥90 on both pages
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M09 — UI: IPs, history, dashboard (done)
+
+   **Built:** read-only IP browsing UI; dashboard; matching admin endpoints.
+
+   **Notes for next milestone:**
+   - country flag and ASN show null/blank until M11 wires real GeoIP.
+   - "active_blocks" count on the dashboard uses the seeded "moderate" policy as the reference; document this default. M10/M12 may add a config knob for which policy is the dashboard reference.
+   - Manual block/allowlist buttons on IP detail are not present yet; the data is shown read-only. M10 adds the action buttons.
+   - Lighthouse score: [insert measured number].
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** chart.js (was in SPEC §2 React-libs note; here used directly via npm).
+   ```
+
+3. **Stop.** Do not start M10.

+ 197 - 0
files/M10-ui-admin-crud-pages.md

@@ -0,0 +1,197 @@
+# M10 — UI: Admin CRUD Pages
+
+> Fresh Claude Code agent prompt. M09 must be complete and committed.
+> Estimated effort: large (lots of CRUD).
+
+## Mission
+
+Add UI pages for every admin domain: Subnets (manual blocks), Allowlist, Policies (with the threshold matrix editor), Reporters, Consumers, Tokens (raw token shown once), Categories (with decay-curve preview). Wire manual block/allowlist actions into the IP detail page. **Audit log UI and Settings page are still M12.**
+
+## Before you start
+
+1. Verify M09:
+   ```bash
+   git log --oneline -9
+   cd api && composer test && cd ..
+   cd ui  && composer test && cd ..
+   ```
+2. Read `SPEC.md` §6 (every Admin endpoint), §7 (every Page in the Pages section), §8 (RBAC matrix — confirm which roles can do what).
+3. The api endpoints needed mostly exist already (M04 reporters/consumers/tokens, M06 manual_blocks/allowlist, M07 policies). The new ones this milestone adds are categories CRUD.
+
+## Tasks
+
+### 1. API: Categories admin endpoints
+
+In `api/src/Application/Admin/CategoriesController.php`:
+
+- `GET    /api/v1/admin/categories`
+- `POST   /api/v1/admin/categories` — body `{slug, name, description, decay_function: "linear"|"exponential", decay_param: number, is_active: bool}`. Slug must be unique, lowercase, kebab-case.
+- `PATCH  /api/v1/admin/categories/{id}`
+- `DELETE /api/v1/admin/categories/{id}` — refuse if `policy_category_thresholds` references it OR `reports.category_id` references it (409 with usage info). Soft-delete via `is_active=false` is preferred when in use.
+
+RBAC: `Admin` for write, `Viewer` for read.
+
+### 2. UI: AdminClient extensions
+
+Add methods for everything the new pages need: list/get/create/update/delete on policies, reporters, consumers, tokens, categories, manual_blocks, allowlist. Use the typed exceptions established in M08.
+
+### 3. UI Pages
+
+Build under `ui/resources/views/pages/`:
+
+- `subnets/index.twig` — list of manual blocks with kind=`subnet`. Create form: CIDR input, reason, optional expiry. Single-IP manual blocks are still managed (use the same controller, separate "single IPs" tab if it helps clarity).
+- `manual-blocks/index.twig` — combined list (or two tabs: IPs and Subnets). Recommended: one page with a `kind` filter pill at the top.
+- `allowlist/index.twig` — same shape as manual-blocks but for allowlist entries. No expiry field.
+- `policies/index.twig` — list view.
+- `policies/edit.twig` — the threshold matrix editor: rows = categories, columns = ["threshold"]. Numeric input per category; "remove from policy" button to delete the threshold row. Below the matrix: a live "preview" panel that calls `GET /api/v1/admin/policies/{id}/preview` and shows the resulting count + first 50 entries. Debounce the preview (e.g., htmx + 500ms hx-trigger).
+- `reporters/index.twig`, `reporters/edit.twig` — list and edit. Show `trust_weight` prominently; a small explainer ("0–2.0; 1.0 default; affects how heavily this reporter influences scores").
+- `consumers/index.twig`, `consumers/edit.twig` — list and edit. Show assigned policy with a dropdown.
+- `tokens/index.twig` — list of all non-service tokens. Columns: kind, prefix, target (reporter/consumer name or "admin role"), expires_at, revoked_at, last_used_at. Actions: revoke. Top of page: "+ New token" button → modal with kind selector and conditional fields. On creation: success modal showing the raw token in a monospace block, a copy-to-clipboard button, and a clear "this is the only time you'll see this token" warning. Modal must require explicit dismissal.
+- `categories/index.twig`, `categories/edit.twig` — list and edit. Edit page includes:
+  - decay_function radio (linear / exponential).
+  - decay_param numeric input with appropriate unit label ("days to zero" / "half-life days").
+  - Live preview chart: a small SVG (or Chart.js) showing the decay curve over 0–60 days. Pure client-side math; no API call. Must update reactively as the user changes inputs.
+
+### 4. IP Detail action buttons
+
+On `ui/resources/views/pages/ips/detail.twig`, add (visible per RBAC):
+- "Add to allowlist" button (Operator+) — opens a modal with a reason field, POSTs to `/api/v1/admin/allowlist`.
+- "Manually block" button (Operator+) — opens a modal with a reason field and an optional expiry, POSTs to `/api/v1/admin/manual-blocks`.
+- If the IP is already manually blocked: "Remove manual block" button.
+- If the IP is on the allowlist: "Remove from allowlist" button.
+
+After any mutation, re-render the page (or do an htmx swap) so status reflects immediately. The CidrEvaluator from M06 should already invalidate; just make sure the api round-trip retrieves fresh data.
+
+### 5. RBAC enforcement (UI side)
+
+- Hide buttons/links the user can't use.
+- Always treat UI hiding as cosmetic; the api enforces. Test that an Operator clicking through to a forbidden URL gets a friendly error page rather than an exception.
+
+### 6. Sidebar updates
+
+Wire each section as a working link. Active highlighting per current section.
+
+### 7. Confirmation modals
+
+All destructive actions (delete reporter, delete consumer, revoke token, delete category, delete policy, remove manual block, remove allowlist entry) require a confirmation modal. Modal pattern: small Twig partial reused everywhere, Alpine for show/hide, HTML `<form method="post" action="...">` inside.
+
+## Implementation notes
+
+- **Token creation modal**: render server-side after POST /admin/tokens succeeds. The page reloads with a `?just_created=<id>` param; the page reads it once and shows the modal. Don't pass the raw token in the URL — store it in the flash session and clear after display.
+- **Policy threshold editor**: there are at most ~20 categories typically. A simple HTML table is fine. For each row: category slug + name, current threshold input, "remove" button, "add category to policy" select+button at the bottom.
+- **Decay curve preview**: a small Alpine component computes 60 sample points and renders them in an SVG path. ~30 lines of JS. Avoid pulling in a charting lib for this single curve.
+- **htmx for inline updates**: the threshold editor's preview pane is the prime use case. Other CRUD pages can be plain forms with full page reload — that's simpler and less buggy.
+- **Validation feedback**: when the api returns 400/422 with field errors, surface them inline on the form. The DTO/error mapping in `ApiClient` from M08 should already give you a `ValidationException` with field-level details.
+- **Tests**:
+  - api: integration tests for the new categories endpoints.
+  - ui: integration tests with mocked AdminClient verifying each CRUD page renders and submits correctly. Cover one happy path and one validation-error path per resource.
+
+## Out of scope (DO NOT)
+
+- Audit log UI (M12).
+- Settings page (M12).
+- User management UI (`/admin/users`, role-mapping CRUD) — sketch in nav as a placeholder; the api endpoints exist but the UI pages are M12 alongside settings. Or: defer entirely. Pick one and document.
+- Bulk operations (multi-select delete, mass token revocation). Future work.
+- Inline editing on list pages (htmx cells). Edit pages are fine.
+- New api endpoints beyond categories CRUD.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+cd ui  && composer cs && composer stan && composer test && cd ..
+cd ui  && npm run build && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 20
+
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+
+# Login as local admin
+COOKIE_JAR=$(mktemp)
+CSRF=$(curl -s -c $COOKIE_JAR http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&username=admin&password=test1234" \
+  http://localhost:8080/login/local -L > /dev/null
+
+# Each list page renders
+for p in subnets allowlist policies reporters consumers tokens categories; do
+  curl -sf -b $COOKIE_JAR http://localhost:8080/app/$p > /dev/null
+done
+
+# Create a manual block via the UI
+CSRF=$(curl -s -b $COOKIE_JAR http://localhost:8080/app/subnets | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4 | head -1)
+curl -s -b $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&kind=subnet&cidr=192.0.2.0/24&reason=test" \
+  http://localhost:8080/app/manual-blocks -L > /dev/null
+# Verify it persisted in the api
+curl -sf -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/admin/manual-blocks | grep -q "192.0.2.0/24"
+
+# Operator role cannot delete a token (only admin can)
+docker compose exec -T api php bin/console auth:create-token --kind=admin --role=operator --quiet
+# (Manual: log out, log back in via OIDC as an operator-mapped user, verify token-delete button is absent
+#  and that direct POST is rejected. For automated check, hit the api directly with an operator token.)
+OP_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=operator --quiet)
+TOKEN_ID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/tokens \
+  | php -r '$j=json_decode(stream_get_contents(STDIN),true); echo $j["items"][0]["id"];')
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X DELETE -H "Authorization: Bearer $OP_TOKEN" \
+  http://localhost:8081/api/v1/admin/tokens/$TOKEN_ID)" = "403"
+
+# Token creation modal flow (via the UI's session)
+CSRF=$(curl -s -b $COOKIE_JAR http://localhost:8080/app/tokens | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4 | head -1)
+RESP=$(curl -s -b $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&kind=admin&role=viewer" \
+  http://localhost:8080/app/tokens -L)
+echo "$RESP" | grep -q "irdb_adm_"   # raw token displayed in the response
+
+# Categories: create, then refuse delete because in use
+RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"slug":"phishing","name":"Phishing","description":"...","decay_function":"exponential","decay_param":14,"is_active":true}' \
+  http://localhost:8081/api/v1/admin/categories)
+CID=$(echo "$RESP" | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];')
+# Add it to a policy
+PID=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/policies \
+  | php -r '$j=json_decode(stream_get_contents(STDIN),true); echo $j["items"][0]["id"];')
+curl -s -X PATCH -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d "{\"thresholds\":{\"phishing\":1.0}}" http://localhost:8081/api/v1/admin/policies/$PID > /dev/null
+# Now delete should 409
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X DELETE -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/admin/categories/$CID)" = "409"
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M10): UI admin CRUD; categories endpoints; IP detail actions
+
+   - manual blocks, allowlist, policies (matrix editor), reporters, consumers, tokens, categories
+   - token creation modal with one-time raw display + copy
+   - decay-curve preview (svg) on category edit
+   - manual-block / allowlist actions on IP detail page
+   - api: CRUD for categories with in-use protection
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M10 — UI admin CRUD (done)
+
+   **Built:** every admin CRUD UI; categories endpoints; IP detail action buttons.
+
+   **Notes for next milestone:**
+   - User management UI (admin/users, role-mapping editor) is [either: built / deferred to M12]. Decide and note here.
+   - Token list never includes service tokens (api enforces).
+   - Operator vs Admin: operator can manage manual blocks and allowlist but not tokens, policies, categories, reporters, consumers, role mappings.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** none.
+   ```
+
+3. **Stop.** Do not start M11.

+ 198 - 0
files/M11-enrichment.md

@@ -0,0 +1,198 @@
+# M11 — GeoIP / ASN Enrichment
+
+> Fresh Claude Code agent prompt. M07 must be complete (M08–M10 not strictly required, but recommended order).
+> Estimated effort: small to medium.
+
+## Mission
+
+Wire up MaxMind GeoLite2 enrichment: a wrapper service, a working `enrich-pending` job (replacing the M05 skeleton), the `refresh-geoip` job (replacing the M05 stub that returned 412), and UI display of country flag and ASN on the IP detail page.
+
+## Before you start
+
+1. Verify previous milestones (especially M05, M07, M09):
+   ```bash
+   git log --oneline -10
+   cd api && composer test && cd ..
+   ```
+2. Read `SPEC.md` §2 (GeoIP/ASN section), §4 (`ip_enrichment` table), §6 (`refresh-geoip` and `enrich-pending` job endpoints), §10 (where the DBs live; `/data/geoip/`), §15 (note out-of-scope items).
+3. Decide whether to test with a real MaxMind license. If not, the agent uses small fixture `.mmdb` files committed to the repo for tests. The `php-maxmind/MaxMind-DB-Reader-php` library can read fixtures.
+
+## Tasks
+
+### 1. MaxMind wrapper
+
+In `api/src/Domain/Enrichment/`:
+
+- `EnrichmentResult.php` — value object: `countryCode: ?string`, `asn: ?int`, `asOrg: ?string`, `enrichedAt: DateTimeImmutable`.
+- `EnrichmentService.php` interface: `enrich(IpAddress $ip): EnrichmentResult`.
+
+In `api/src/Infrastructure/Enrichment/`:
+
+- `MaxMindEnrichmentService.php` — implements the interface using `geoip2/geoip2`. Accepts paths to two `.mmdb` files (Country and ASN). Lazy-loads the readers; if a file is missing, log a warning once and return a result with all-null fields. Add `geoip2/geoip2` to `api/composer.json` if it isn't already (allowed; SPEC §2 names MaxMind).
+- `EnrichmentRepository.php`:
+  - `find(string $ipBin): ?EnrichmentRow`
+  - `upsert(string $ipBin, EnrichmentResult)`
+  - `findPending(int $limit): array<string>` — returns `ip_bin` values that exist in `reports` or `manual_blocks` but not in `ip_enrichment`. Order by `MIN(received_at)` so older entries get caught up first.
+  - Used by the job and by the admin endpoint `GET /api/v1/admin/ips/{ip}` (already returning the field, was null until now).
+
+### 2. `enrich-pending` job — full implementation
+
+Replace the skeleton in `api/src/Application/Jobs/EnrichPendingJob.php`:
+
+- Pulls a batch from `EnrichmentRepository::findPending(limit=200)`.
+- For each ip: calls `EnrichmentService::enrich`, upserts the result.
+- If the MaxMind DBs aren't present (e.g. `MAXMIND_LICENSE_KEY` never set, no fallback `.mmdb`s):
+  - The service returns all-null results. Don't store them — that would create poison rows. Instead, log a single warning per job run and exit cleanly with `items_processed=0`.
+- Default interval: 300s. Max runtime: 60s.
+- Idempotent: if an IP is already enriched, skip it (the `findPending` query already excludes them).
+
+### 3. `refresh-geoip` job — full implementation
+
+Replace the stub in `api/src/Application/Jobs/RefreshGeoipJob.php`:
+
+- If `MAXMIND_LICENSE_KEY` is empty: return `412 Precondition Failed` from the HTTP handler with `{"error":"no_license_key"}`. The job itself shouldn't be invoked — the controller short-circuits.
+- Otherwise:
+  - Download `GeoLite2-Country.tar.gz` and `GeoLite2-ASN.tar.gz` from MaxMind's permalink endpoint using HTTPS + license key.
+  - Verify the tarball's SHA-256 against the matching `.sha256` URL.
+  - Extract to a temp dir.
+  - Atomic-replace the existing `.mmdb` files at `GEOIP_COUNTRY_DB` and `GEOIP_ASN_DB`. Use rename within the same filesystem.
+  - Reload the in-process readers (clear any cached singleton).
+- Default interval: 7 days (`JOB_GEOIP_REFRESH_INTERVAL_DAYS`). Max runtime: 5 minutes.
+- On HTTP/network failure: write a failure run entry, log clearly, don't leave partial files.
+- Use Guzzle (already in api deps).
+
+### 4. UI: IP detail enrichment panel
+
+The endpoint `GET /api/v1/admin/ips/{ip}` already returns the `enrichment` block; from M09 the field is null. After this milestone the data fills in.
+
+Update `ui/resources/views/pages/ips/detail.twig`:
+- If `enrichment.country_code` is null, show "Unknown" greyed out.
+- Otherwise show the country flag (Unicode regional indicator) + country name (use a small mapping or a JSON lookup table).
+- ASN: show as `AS{asn} {as_org}`, link to bgp.he.net or similar (target=_blank, rel=noopener) — optional but nice.
+- Add `enriched_at` as a small timestamp footer ("Enriched 4 hours ago").
+
+### 5. Search filters
+
+The IPs list page already accepts `country` and `asn` filters from M09. They should now actually filter results — the api joins `ip_enrichment` on the search query. Add a simple country dropdown using the populated set of countries seen so far (one extra endpoint or just compute on the fly).
+
+### 6. Update healthz
+
+`/healthz` on api now reports GeoIP DB status:
+```json
+{
+  "status": "ok",
+  "db": {"connected": true, "driver": "sqlite"},
+  "geoip": {
+    "country_db_present": true,
+    "asn_db_present": true,
+    "country_db_modified": "2026-04-20T...",
+    "asn_db_modified": "2026-04-20T..."
+  }
+}
+```
+Missing DBs don't make `/healthz` unhealthy (the system still works without enrichment). Just report the state.
+
+## Implementation notes
+
+- **Build-time vs runtime DBs**: The Dockerfile may bake DBs in at build time if `MAXMIND_LICENSE_KEY` is set as a build arg; otherwise they're absent until `refresh-geoip` runs. Either way, the runtime path is `/data/geoip/`. The Dockerfile copies build-time DBs into `/data/geoip/` if present.
+- **License key handling**: never log it. Don't include it in error messages or `job_runs.details`. Mask in any echoed config.
+- **Atomic file replace**: `tempnam()` in `/data/geoip/`, write the new file, `rename()` to the target. Avoid leaving partials if the process crashes.
+- **MaxMind library**: use `geoip2/geoip2`. Don't roll your own `.mmdb` parser. Don't use a service that calls back to MaxMind on every lookup — the local DB is the point.
+- **IPv6**: the same DBs cover both families. Verify with a v6 lookup test.
+- **Large batches**: 200 per tick is a safe default. Each lookup is fast; 200 takes well under a second.
+- **Tests**: ship two small fixture `.mmdb` files (the `geoip2/geoip2` test fixtures are publicly licensed and small; you can vendor them in `api/tests/Fixtures/geoip/`). Use them in unit tests.
+
+## Out of scope (DO NOT)
+
+- Other enrichment sources (Spamhaus, IPInfo, AbuseIPDB). MaxMind only.
+- Per-request enrichment lookups in the report endpoint. Enrichment is a background concern.
+- Reverse-DNS / WHOIS enrichment.
+- Auditing the enrichment job (M12 owns audit emission generally; this job logs to its `job_runs` row).
+- New API endpoints beyond what's listed.
+- Mass re-enrichment of all IPs on every refresh-geoip run. New DB ⇒ existing rows stay. Add a `?reenrich=true` flag to refresh-geoip that, if true, also nulls the `enriched_at` so `findPending` re-picks them up — but only run that on explicit request.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+
+docker compose down -v
+cp .env.example .env
+# DO NOT set MAXMIND_LICENSE_KEY for the first part of the test
+docker compose up -d
+sleep 15
+
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+INTERNAL_TOKEN=$(grep ^INTERNAL_JOB_TOKEN= .env | cut -d= -f2)
+
+# Without DBs / license key: refresh-geoip returns 412
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  -X POST http://localhost:8081/internal/jobs/refresh-geoip)" = "412"
+
+# enrich-pending no-ops cleanly when DBs are missing
+RESP=$(curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  http://localhost:8081/internal/jobs/enrich-pending)
+echo "$RESP" | grep -q '"status":"success"'
+echo "$RESP" | grep -q '"items_processed":0'
+
+# /healthz reports geoip status
+curl -s http://localhost:8081/healthz | grep -q '"country_db_present":false'
+
+# With fixture DBs present (copy them into the volume)
+docker compose cp api/tests/Fixtures/geoip/. api:/data/geoip/
+# Submit a report for an IP that's in the fixture
+RID=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"name":"test","trust_weight":1.0}' \
+  http://localhost:8081/api/v1/admin/reporters | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];')
+RT=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d "{\"kind\":\"reporter\",\"reporter_id\":$RID}" \
+  http://localhost:8081/api/v1/admin/tokens | php -r 'echo json_decode(stream_get_contents(STDIN),true)["raw_token"];')
+curl -s -X POST -H "Authorization: Bearer $RT" -H "Content-Type: application/json" \
+  -d '{"ip":"81.2.69.142","category":"brute_force"}' \
+  http://localhost:8081/api/v1/report > /dev/null
+
+# Run enrichment
+curl -s -X POST -H "Authorization: Bearer $INTERNAL_TOKEN" \
+  http://localhost:8081/internal/jobs/enrich-pending | grep -q '"items_processed":1'
+
+# IP detail returns enrichment fields populated
+curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/admin/ips/81.2.69.142 | grep -qE '"country_code":"(GB|US)"'
+
+# /healthz reflects DB presence
+curl -s http://localhost:8081/healthz | grep -q '"country_db_present":true'
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M11): MaxMind GeoLite2 enrichment
+
+   - EnrichmentService backed by geoip2/geoip2
+   - enrich-pending job (replaces M05 skeleton): 200 per tick, no-ops cleanly without DBs
+   - refresh-geoip job: download + verify + atomic replace, 412 without license key
+   - IP detail UI shows country flag + ASN (graceful when null)
+   - /healthz reports geoip db status
+   - country/asn filters on IPs list now functional
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M11 — Enrichment (done)
+
+   **Built:** GeoIP wrapper, both jobs, UI display, healthz fields.
+
+   **Notes for next milestone:**
+   - DBs live at /data/geoip/. Without MAXMIND_LICENSE_KEY they must be present before the container starts (mount or copy in).
+   - License key never logged.
+   - Re-enrichment is opt-in via ?reenrich=true on refresh-geoip.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** geoip2/geoip2 (mentioned in SPEC §2 as the planned library).
+   ```
+
+3. **Stop.** Do not start M12.

+ 212 - 0
files/M12-audit-and-settings.md

@@ -0,0 +1,212 @@
+# M12 — Audit Log & Settings Page
+
+> Fresh Claude Code agent prompt. M10 and M11 must be complete and committed.
+> Estimated effort: medium.
+
+## Mission
+
+Wire the audit emitter into every write path on the api. Add a filterable Audit page on the UI. Build the Settings page (effective config with secrets masked, per-job status with overdue badges, manual job triggers for admins). Optionally, complete the user management UI if it was deferred from M10.
+
+## Before you start
+
+1. Verify M10 and M11:
+   ```bash
+   git log --oneline -11
+   cd api && composer test && cd ..
+   cd ui  && composer test && cd ..
+   ```
+2. Read `SPEC.md` §4 (`audit_log` table), §6 (`POST /api/v1/admin/jobs/trigger/{name}`, `GET /api/v1/admin/audit-log`, `GET /api/v1/admin/jobs/status`, `GET /api/v1/admin/config`), §7 (Audit page, Settings page).
+3. Confirm clean tree.
+
+## Tasks
+
+### 1. Audit emitter
+
+In `api/src/Domain/Audit/`:
+
+- `AuditEmitter.php` interface: `emit(string $action, string $entityType, ?int $entityId, array $payload, AuditContext $ctx): void`.
+- `AuditContext.php` — captured from the current request: `actorKind: "user"|"reporter"|"consumer"|"admin-token"|"system"`, `actorId: ?int`, `actorName: ?string`, `requestId`, `ip` (the source IP making the call).
+- `AuditAction.php` — enum or string constants: `reporter.created`, `reporter.updated`, `reporter.deleted`, `consumer.*`, `token.created`, `token.revoked`, `policy.*`, `category.*`, `manual_block.*`, `allowlist.*`, `user.role_changed`, `oidc_role_mapping.*`, `job.triggered`.
+
+In `api/src/Infrastructure/Audit/AuditRepository.php`:
+- Insert with all fields, including a stable JSON-encoded payload.
+
+Wire `AuditEmitter` into the DI container; resolve `AuditContext` from the current request's principal in a request-scoped middleware. **Service-token + impersonation calls record `actorKind="user"` and `actorId=user_id` — NOT the service token.**
+
+### 2. Wire audit calls
+
+Every state-changing admin endpoint emits exactly one audit entry on success. Patterns:
+
+- `ReportersController::create` → `reporter.created`, payload contains the reporter fields (no secrets — never the trust weight is fine; never write credentials/tokens).
+- `TokensController::create` → `token.created`, payload contains `kind`, `prefix`, `target` ids; **never** the raw token.
+- `TokensController::delete` → `token.revoked`.
+- All `manual_blocks`, `allowlist`, `policies`, `categories`, `consumers`, `users`, `oidc_role_mappings` endpoints — `<entity>.created/updated/deleted`.
+- `JobsController::trigger` (new this milestone, see below) → `job.triggered`.
+
+Failure paths (4xx/5xx) **do not** emit. Only successful state changes.
+
+### 3. Audit list endpoint and UI
+
+`GET /api/v1/admin/audit-log` (Viewer role):
+- Query params: `actor_kind`, `actor_id`, `action`, `entity_type`, `entity_id`, `from` (ISO datetime), `to`, `page`, `page_size` (default 50, max 200).
+- Returns `{items, page, page_size, total}`. Each item: id, occurred_at, actor (kind+name+id), action, entity_type, entity_id, payload (raw JSON), source_ip.
+
+UI page `pages/audit/index.twig`:
+- Sidebar link "Audit".
+- Filter bar at top (form with all the params).
+- Table: time (relative + absolute on hover), actor, action (color-coded by category), entity, "view payload" button → modal showing the payload JSON pretty-printed.
+- Pagination at bottom.
+- All roles can see audit (Viewer+).
+
+### 4. Manual job trigger endpoint
+
+`POST /api/v1/admin/jobs/trigger/{name}` (Admin role):
+- Accepts the same body shape as `/internal/jobs/{name}` (e.g. `{full: true}` for recompute-scores).
+- Server-side: emits `job.triggered` audit, then calls the corresponding internal handler (NOT via HTTP — call the same Job class directly). Pass `triggered_by="manual"` in the runner's context.
+- Returns the job's response envelope (same as the internal endpoint).
+- Errors: 404 if `name` isn't registered; 412 for refresh-geoip without license key.
+
+Do **not** expose `/internal/jobs/*` to the UI. The UI uses this admin endpoint as a thin wrapper. The internal endpoint remains scheduler-only.
+
+### 5. Jobs status endpoint and Settings page
+
+`GET /api/v1/admin/jobs/status` (Viewer role): proxies `/internal/jobs/status`'s data — but reachable without the internal token. Returns the same shape: latest run per job, overdue flag, lock state.
+
+`GET /api/v1/admin/config` (Admin role only):
+- Returns the effective config the api is using, **with secrets masked**:
+  - `***` for: `INTERNAL_JOB_TOKEN`, `MAXMIND_LICENSE_KEY`, `DB_MYSQL_PASSWORD`.
+  - First 8 chars + `...` for: `UI_SERVICE_TOKEN`.
+  - Plain values for: `DB_DRIVER`, `LOG_LEVEL`, `SCORE_RECOMPUTE_INTERVAL_SECONDS`, `JOB_AUDIT_RETENTION_DAYS`, `GEOIP_*` paths (not the license key), `API_RATE_LIMIT_PER_SECOND`, etc.
+- Returns config grouped by section.
+
+UI page `pages/settings/index.twig` (Admin role only — Operator and Viewer get a "no access" page):
+- Three sections: Configuration (read-only display from `/admin/config`), Jobs (status table from `/admin/jobs/status` with overdue red badges and manual-trigger buttons), GeoIP (DB presence, last refresh times — pull from healthz or extend `/admin/config`).
+- Manual-trigger buttons: confirm modal → POST to `/api/v1/admin/jobs/trigger/{name}` → flash success/failure with the run details.
+
+### 6. (Optional) User management UI
+
+If deferred from M10, build it now:
+
+- `pages/users/index.twig` — list users; columns: email, display_name, role, source (oidc/local), last_login_at. Edit role inline (admin only); cannot edit local admin's role (always admin).
+- `pages/oidc-mappings/index.twig` — list `oidc_role_mappings`. Create form: group object id (paste from Entra), role, optional description. Delete with confirmation.
+- API endpoints already exist (M03 declared the schema; this milestone wires the handlers if not already done):
+  - `GET/POST/PATCH/DELETE /api/v1/admin/users` (admin only; cannot delete the local admin)
+  - `GET/POST/DELETE /api/v1/admin/oidc-role-mappings`
+
+If those endpoints aren't yet implemented, add them this milestone.
+
+## Implementation notes
+
+- **Audit emitter must not block writes**: wrap the emit in a try/catch. If audit insertion fails, log loudly but don't fail the originating request. The audit row is observability, not a transactional invariant.
+- **Audit context resolution**: a small middleware that captures the principal + request_id + source ip into a request-scoped `AuditContext`. Inject the emitter where needed; controllers don't have to manually pass the context.
+- **Audit retention**: M05's `CleanupAuditJob` already deletes old entries based on `JOB_AUDIT_RETENTION_DAYS`. Verify it works with real data now that data exists.
+- **Audit indexes**: ensure indexes on `(occurred_at DESC)`, `(action)`, `(entity_type, entity_id)`, `(actor_kind, actor_id)` for the filter performance. Add a migration if needed.
+- **Manual trigger UX**: triggers can take seconds. Use htmx with a loading indicator, OR submit the form and redirect to a flash result. Either way: don't double-submit; disable the button immediately on click.
+- **Service token in audit**: when the api is called by the UI BFF, the service token is irrelevant for audit. Always record the impersonated user. When called by an admin token directly, record `actor_kind="admin-token"`, `actor_id=<token_id>`.
+- **`job.triggered` payload**: include `name`, `params`, `triggered_by="manual"`. Don't include the raw response (which can be large).
+
+## Out of scope (DO NOT)
+
+- Audit log to external SIEM. The DB row is the audit trail this milestone.
+- Audit rate limiting / sampling. All writes audit; volume is low.
+- Live-tail audit on the UI. Pagination is fine.
+- Triggering reporter or consumer endpoints from the admin UI.
+- Per-user audit dashboards. The filterable list is sufficient.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+cd ui  && composer cs && composer stan && composer test && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 20
+
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+
+# Create a manual block to generate an audit entry
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"ip","ip":"203.0.113.99","reason":"audit test"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks > /dev/null
+
+# Audit endpoint contains the entry; actor_kind=admin-token because we used a raw admin token
+curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  "http://localhost:8081/api/v1/admin/audit-log?action=manual_block.created" | grep -q '"actor_kind":"admin-token"'
+
+# Now via the UI (service token + impersonation): audit entry attributed to user
+COOKIE_JAR=$(mktemp)
+CSRF=$(curl -s -c $COOKIE_JAR http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+curl -s -b $COOKIE_JAR -c $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&username=admin&password=test1234" \
+  http://localhost:8080/login/local -L > /dev/null
+CSRF=$(curl -s -b $COOKIE_JAR http://localhost:8080/app/manual-blocks | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4 | head -1)
+curl -s -b $COOKIE_JAR -X POST \
+  -d "csrf_token=$CSRF&kind=ip&ip=203.0.113.100&reason=via-ui" \
+  http://localhost:8080/app/manual-blocks -L > /dev/null
+curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  "http://localhost:8081/api/v1/admin/audit-log?entity_type=manual_block&page_size=10" \
+  | grep -A2 "via-ui" | grep -q '"actor_kind":"user"'
+
+# Manual job trigger (admin endpoint, not internal)
+RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  http://localhost:8081/api/v1/admin/jobs/trigger/recompute-scores)
+echo "$RESP" | grep -q '"status":"success"'
+# Audit entry recorded
+curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  "http://localhost:8081/api/v1/admin/audit-log?action=job.triggered" | grep -q "recompute-scores"
+
+# Operator role cannot trigger jobs
+OP_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=operator --quiet)
+test "$(curl -s -o /dev/null -w '%{http_code}' \
+  -X POST -H "Authorization: Bearer $OP_TOKEN" \
+  http://localhost:8081/api/v1/admin/jobs/trigger/recompute-scores)" = "403"
+
+# /admin/config has secrets masked
+RESP=$(curl -s -H "Authorization: Bearer $ADMIN_TOKEN" http://localhost:8081/api/v1/admin/config)
+echo "$RESP" | grep -q '"INTERNAL_JOB_TOKEN":"\*\*\*"'
+echo "$RESP" | grep -q '"DB_DRIVER":"sqlite"'
+
+# UI Settings page renders for admin
+curl -sf -b $COOKIE_JAR http://localhost:8080/app/settings | grep -qi "configuration"
+# Operator gets no-access page
+# (manual: log out and back in as an operator-mapped user, verify /app/settings shows no-access)
+
+# Audit list page filters
+curl -sf -b $COOKIE_JAR "http://localhost:8080/app/audit?action=manual_block.created" > /dev/null
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M12): audit log emitter, filterable audit UI, settings page
+
+   - AuditEmitter wired into every write path
+   - service-token+impersonation audits attribute to user, not service token
+   - GET /api/v1/admin/audit-log with filters, pagination
+   - POST /api/v1/admin/jobs/trigger/{name} as admin wrapper around internal jobs
+   - GET /api/v1/admin/config (secrets masked) and jobs/status
+   - UI Audit and Settings pages
+   - [if applicable] user management UI + oidc role mappings UI
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M12 — Audit & settings (done)
+
+   **Built:** audit emission, audit UI, manual job trigger admin endpoint, settings page, [optional: user mgmt UI].
+
+   **Notes for next milestone:**
+   - Audit failures are logged but never fail the originating request.
+   - /api/v1/admin/jobs/trigger/{name} is the only path the UI uses to invoke jobs; /internal/jobs/* remains scheduler-only.
+   - Secrets-masked /admin/config is the source of truth for the settings page; M13 documentation references this endpoint.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** none.
+   ```
+
+3. **Stop.** Do not start M13.

+ 205 - 0
files/M13-polish-openapi-docs.md

@@ -0,0 +1,205 @@
+# M13 — Polish, OpenAPI, Documentation
+
+> Fresh Claude Code agent prompt. M12 must be complete and committed.
+> Estimated effort: large. Documentation is a real deliverable; budget time for accuracy.
+
+## Mission
+
+Generate `openapi.yaml` and serve it at `/api/v1/openapi.yaml` plus a viewer at `/api/docs`. Write the README with quickstart and operational guides. **Write every `doc/*.md` file as specified in `SPEC.md` §16.** Ship sample reporter scripts and firewall consumer configs in `examples/`. By the end, a fresh clone goes from `git clone` to a working blocklist via documented steps in under 10 minutes.
+
+## Before you start
+
+1. Verify M12:
+   ```bash
+   git log --oneline -12
+   cd api && composer test && cd ..
+   cd ui  && composer test && cd ..
+   ```
+2. Read **`SPEC.md` §16 in full**. The required outline of each `doc/*.md` is the contract for this milestone. Outline-skipping is a hard fail.
+3. Skim `SPEC.md` §3 (architecture, for the Mermaid diagram you'll embed in `doc/architecture.md`), §6 (API surface for OpenAPI), §8 (auth flows for `doc/auth-flows.md`).
+
+## Tasks
+
+### 1. OpenAPI spec
+
+Place at `api/public/openapi.yaml`. Generate, don't hand-write — but also don't pull in a heavy framework. Two acceptable approaches:
+
+- **Annotation-based**: use `zircote/swagger-php` to generate from PHP attributes on the controllers. Add the dep, sprinkle attributes, run `vendor/bin/openapi src -o public/openapi.yaml` as a composer script. CI runs this and fails if the result doesn't match what's committed.
+- **Hand-curated**: a single `api/openapi.yaml.template.php` file that produces the YAML from PHP code (a structured array you serialize). Simpler; less boilerplate.
+
+Pick one and document the choice in PROGRESS.md.
+
+Coverage:
+- All Public endpoints (`/api/v1/report`, `/api/v1/blocklist`).
+- All Admin endpoints (`/api/v1/admin/*`).
+- All Auth endpoints with `x-internal: true` extension and a clear "UI BFF only" description.
+- **Do not** document `/internal/jobs/*` — those are private. Mention in the spec description that they exist but are out of scope for the public contract.
+
+Schemas: define all request/response shapes (`Report`, `Blocklist`, `Token`, `Policy`, `User`, `AuditEntry`, error envelopes). Reuse via `$ref`.
+
+### 2. OpenAPI viewer
+
+Add a tiny route in `api/src/Application/Public/DocsController.php`:
+- `GET /api/v1/openapi.yaml` — serves the YAML file with `Content-Type: application/yaml`. Public; no auth.
+- `GET /api/docs` — serves an HTML page that loads Stoplight Elements or RapiDoc from a CDN-vendored npm static asset (no external CDN). Both are single-script viewers.
+
+Pick one (RapiDoc is smaller; Stoplight Elements is prettier). Document the choice.
+
+### 3. README
+
+Replace the M01 stub. New contents (in order):
+
+1. **One-paragraph elevator pitch** — what IRDB is, who it's for.
+2. **Quickstart** — the 5-minute path:
+   ```
+   git clone ...
+   cp .env.example .env
+   # edit .env: generate secrets, optionally configure OIDC
+   docker compose -f docker-compose.yml -f compose.scheduler.yml up -d
+   # browse to http://localhost:8080, log in
+   ```
+3. **Generating secrets** — the exact `openssl rand` and `php password_hash` commands.
+4. **First-time setup** — create a reporter, get a token, send a report; create a consumer, get a token, fetch the blocklist.
+5. **Reverse proxy setup** — point to `examples/reverse-proxy/Caddyfile`.
+6. **MySQL setup** — uncomment the section in compose; set `DB_DRIVER=mysql`.
+7. **OIDC setup** — point to `doc/oidc.md`.
+8. **Scheduling** — host cron, systemd timer, sidecar overlay; point to `examples/scheduler/`.
+9. **Backups** — what to back up: the `irdb-data` volume (or MySQL); how to restore.
+10. **Architecture** — point to `doc/architecture.md`.
+11. **API contract** — point to `/api/docs` viewer and `doc/api-overview.md`.
+12. **Replacing the UI** — for future Vue/native/mobile, point to `doc/frontend-development.md`.
+13. **License** — TBD (leave a placeholder).
+
+### 4. doc/ files (the real work)
+
+Write each file according to its required outline in `SPEC.md` §16. Quality bar applies: every snippet must run as-is against `docker compose up`; no TODOs; ≤500 lines per file.
+
+- `doc/architecture.md` — system overview, container topology (Mermaid), where state lives, stable-vs-replaceable surfaces table, why-this-split rationale.
+- `doc/api-overview.md` — base URL/versioning, auth summary, endpoint groups, common conventions (envelopes, pagination, ETag, rate limits, IP normalization), worked curl examples for: posting a report, pulling a blocklist (text + JSON), admin search via service-token impersonation, admin search via admin-kind token. Pointer to OpenAPI.
+- `doc/auth-flows.md` — overview table, sequence diagrams (Mermaid) for: machine reporter, admin token, UI BFF (OIDC + local), Entra setup walkthrough (extract from M08's `doc/oidc.md` and merge), local admin guidance, **future user-token flow sketch** marked NOT IMPLEMENTED, CSRF/sessions/CORS notes.
+- `doc/frontend-development.md` — the headline doc. Read-this-first; three integration patterns (BFF replacement, SPA + thin BFF, direct API + future user tokens) with worked pseudocode for the BFF replacement; minimum API surface checklist; CORS configuration; local dev (run only api, point a separate frontend dev server at it); migration path for swapping UIs at runtime; **what NOT to do** list (no business logic in frontend, no service token in browser, etc.).
+- `doc/api-reference.md` — short. Pointer to OpenAPI as canonical; documents what OpenAPI doesn't cleanly express: rate-limit headers, ETag semantics, the `X-Acting-User-Id` impersonation header convention, response envelope conventions for current and future batched endpoints.
+
+If `doc/oidc.md` was created in M08, **delete it** — its content goes into `doc/auth-flows.md` per `SPEC.md` §16.
+
+### 5. Examples
+
+In `examples/`:
+
+- `reporters/curl.sh` — a copy-paste shell script: takes an IP and category as args, posts a report. Reads `IRDB_URL` and `IRDB_TOKEN` from env.
+- `reporters/python.py` — same in Python. Single file, no deps beyond `urllib`. Example of a fail2ban-action wrapper inline as a comment.
+- `reporters/bash-fail2ban.sh` — drop-in fail2ban action.
+- `consumers/iptables-restore.sh` — pulls the blocklist, builds an `ipset`, atomic-replace via `ipset restore`.
+- `consumers/nginx-deny-include.sh` — pulls and writes an `include` file with `deny` directives, reloads nginx.
+- `consumers/haproxy-acl.sh` — pulls and updates an HAProxy ACL file.
+- `scheduler/host.crontab`, `scheduler/irdb-tick.service`, `scheduler/irdb-tick.timer` — already stubbed in M01, fill in real content.
+- `reverse-proxy/Caddyfile` — production-ready Caddy config fronting api and ui.
+
+Each script has a header comment explaining usage. Each is shell-checked (run shellcheck) and tested at least manually.
+
+### 6. End-to-end demo test
+
+Add `tests/e2e/demo.sh` (and a CI job that runs it) that automates the README quickstart:
+1. `docker compose -f ... up -d`
+2. Generate admin token via CLI
+3. Create reporter + consumer + tokens
+4. Submit reports
+5. Trigger recompute job
+6. Pull blocklist; assert non-empty
+7. `docker compose down -v`
+
+### 7. Doc accuracy CI check
+
+Add a CI job that:
+- Greps `doc/*.md` for endpoint paths; compares against the OpenAPI document. Any path in docs not in the spec → fail. (The other direction is OK; not every endpoint needs prose.)
+- Greps for token kind strings (`reporter`, `consumer`, `admin`, `service`); ensures spelling matches code.
+- Optional: dead-link checker on the docs.
+
+## Implementation notes
+
+- **Mermaid in GitHub-rendered Markdown**: use `mermaid` fenced code blocks. Test by rendering the file on a GitHub PR.
+- **Pseudocode in frontend-development.md**: keep it language-neutral or use a minimal Node/Express snippet that's clearly a sketch, not a runnable thing. The point is the pattern, not a working app.
+- **Avoid screenshots**: explicitly forbidden by SPEC §16 quality bar — they go stale.
+- **OpenAPI versioning**: declare `version: 1.0.0` in the spec; bump for breaking changes. Document the additive-only policy.
+- **Don't describe what doesn't exist**: every claim in docs must match the as-built code. If you find a discrepancy mid-writing, **fix the code**, not the docs.
+
+## Out of scope (DO NOT)
+
+- Marketing site / landing page. README is enough.
+- Versioned docs site (Docusaurus, MkDocs). The Markdown files in `doc/` are the docs.
+- Auto-generating client SDKs. Future work.
+- Tutorials beyond the quickstart in README. The doc files are reference, not tutorials.
+- Adding new endpoints. If something feels needed for docs that isn't in the code, stop and reconsider.
+- New dependencies beyond `zircote/swagger-php` (if you go that route). Record in PROGRESS.md.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+cd ui  && composer cs && composer stan && composer test && cd ..
+
+# OpenAPI is valid
+docker run --rm -v "$(pwd)/api/public:/spec" \
+  redocly/cli:latest lint /spec/openapi.yaml
+
+# Doc files exist and are non-empty
+for f in architecture api-overview auth-flows frontend-development api-reference; do
+  test -s "doc/$f.md" || { echo "missing or empty: doc/$f.md"; exit 1; }
+done
+
+# Each doc file is ≤500 lines
+for f in doc/*.md; do
+  L=$(wc -l < "$f"); [ "$L" -le 500 ] || { echo "$f too long: $L lines"; exit 1; }
+done
+
+# Doc accuracy: no stale endpoints
+# (run the CI check script you wrote)
+./scripts/check-doc-endpoints.sh
+
+# E2E demo script
+docker compose down -v
+./tests/e2e/demo.sh
+docker compose down -v
+
+# /api/docs serves a viewer
+docker compose up -d
+sleep 15
+curl -sf http://localhost:8081/api/v1/openapi.yaml | grep -q "openapi:"
+curl -sf http://localhost:8081/api/docs | grep -qE "(rapi-doc|stoplight|elements)"
+docker compose down -v
+
+# Examples are shellcheck-clean
+shellcheck examples/reporters/*.sh examples/consumers/*.sh examples/scheduler/host.crontab 2>/dev/null || true
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M13): polish — OpenAPI, README, doc/, examples, e2e demo
+
+   - openapi.yaml served at /api/v1/openapi.yaml; /api/docs viewer
+   - README with quickstart, OIDC pointer, scheduler options, backups
+   - doc/{architecture,api-overview,auth-flows,frontend-development,api-reference}.md
+   - examples/{reporters,consumers,scheduler,reverse-proxy} with shell-checked scripts
+   - tests/e2e/demo.sh: clone-to-blocklist in ~10 minutes
+   - CI: openapi validation, doc-endpoint accuracy check
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M13 — Polish, OpenAPI, docs (done)
+
+   **Built:** OpenAPI + viewer; README; all five doc files per SPEC §16; examples; e2e test.
+
+   **Notes for next milestone:**
+   - OpenAPI generation is via [zircote/swagger-php OR hand-curated array]; the source is `<path>`.
+   - Doc CI guard: ./scripts/check-doc-endpoints.sh
+   - examples/ scripts use IRDB_URL and IRDB_TOKEN env vars; document this convention.
+   - The "future user-token flow" in doc/auth-flows.md is the recommended extension point for SPA/native/mobile UIs.
+
+   **Deviations from SPEC:** none.
+   **Added dependencies:** [zircote/swagger-php if applicable]
+   ```
+
+3. **Stop.** Do not start M14.

+ 243 - 0
files/M14-hardening.md

@@ -0,0 +1,243 @@
+# M14 — Security Hardening
+
+> Fresh Claude Code agent prompt. M13 must be complete and committed.
+> Estimated effort: medium.
+
+## Mission
+
+Harden both containers: security headers, full brute-force lockout for local admin, audit secret-scrubbing in logs, token entropy verification, backup guidance verification, expired-manual-block cleanup. By the end, a security review checklist passes.
+
+## Before you start
+
+1. Verify M13:
+   ```bash
+   git log --oneline -13
+   ```
+2. Read `SPEC.md` §8 (auth, especially CSRF/sessions), §10 (backup notes), §12 M14 (the hardening milestone — your reference).
+3. The OWASP top 10 is a useful mental model for this milestone. Don't take it as a checklist; do treat it as "did I think about each of these?"
+
+## Tasks
+
+### 1. Security headers
+
+In both `api` and `ui` Caddy configs, add a header bundle on every response:
+
+- `Strict-Transport-Security: max-age=31536000; includeSubDomains` — only when `APP_ENV=production`. Don't HSTS in dev or you'll lock yourself out of localhost.
+- `X-Content-Type-Options: nosniff`
+- `X-Frame-Options: DENY` (UI) / `X-Frame-Options: SAMEORIGIN` (api)
+- `Referrer-Policy: strict-origin-when-cross-origin`
+- `Permissions-Policy: geolocation=(), microphone=(), camera=()`
+- **CSP for the UI**:
+  - `default-src 'self'`
+  - `script-src 'self' 'wasm-unsafe-eval'` (Alpine doesn't need `unsafe-eval`; only allow it if a build dep demands it)
+  - `style-src 'self' 'unsafe-inline'` (Tailwind compiled, but inline styles for dynamic things like score bars)
+  - `img-src 'self' data:` (data: for tiny inline icons)
+  - `connect-src 'self' <API_BASE_URL>` if the UI ever does direct browser→api calls (it doesn't today; but htmx might add one)
+  - `frame-ancestors 'none'`
+  - `base-uri 'self'`
+  - `form-action 'self'`
+  - Test that the UI doesn't violate its own CSP. Run a browser, check the console, fix any violations by either tightening the page's HTML or relaxing CSP minimally with comment justification.
+
+- **CSP for the api**: very restrictive (`default-src 'none'; frame-ancestors 'none'`) since the api serves only JSON, the OpenAPI viewer, and YAML. The `/api/docs` page does need styles+scripts for RapiDoc/Elements; relax CSP only on that route.
+
+### 2. Local admin brute-force lockout (full)
+
+Replace M08's basic 5/30s throttle with a persistent lockout:
+
+- Track failed attempts per `(LOCAL_ADMIN_USERNAME, source_ip)` pair in a small in-memory store (singleton service in the ui container) plus the session.
+- Failure progression: 1–4 failures fast retry; 5 failures → 1-minute lockout; 10 → 5-minute lockout; 15+ → 30-minute lockout. Reset the counter on a successful login.
+- Lock by username AND by IP separately so attackers can't lock out the legitimate admin from another IP.
+- Log every failure at WARN, every lockout at ERROR, with the source IP. Don't log the attempted password.
+- Document in `doc/auth-flows.md` (update from M13) — including how to clear a lockout (restart the ui container, since this is in-memory; the lockout is intentionally short enough that this is rarely needed).
+
+### 3. Token entropy verification
+
+In `api/tests/Unit/Auth/`:
+
+- `TokenEntropyTest.php` — generates 1000 tokens, asserts ≥160 bits of unique randomness (in practice, all-distinct).
+- Verifies the format `irdb_<3>_<32 base32 chars>`.
+- Confirms `random_bytes` (CSPRNG) is the source.
+
+### 4. Logs scrubbed of secrets
+
+- Audit all log output paths. Search the codebase for places that might log:
+  - Bearer tokens (any `Authorization` header content).
+  - `LOCAL_ADMIN_PASSWORD_HASH`.
+  - `OIDC_CLIENT_SECRET`.
+  - `MAXMIND_LICENSE_KEY`.
+  - Database passwords.
+- Add a Monolog processor that scrubs known-sensitive keys from the context array before formatting. Pattern:
+  ```
+  ['authorization' => 'Bearer abc...'] → ['authorization' => 'Bearer ***']
+  ```
+- Add a test that constructs a log record with a Bearer token in context and asserts the formatted output is scrubbed.
+
+### 5. Expired manual block cleanup
+
+A small loose end from M06: manual blocks have `expires_at` but nothing prunes expired ones. Two approaches:
+
+- **Filter at read time**: every read of `manual_blocks` ignores rows with `expires_at < now`. The CidrEvaluator already could do this — verify and fix if not. Pros: zero new infrastructure. Cons: rows accumulate.
+- **Add a cleanup job**: register `CleanupExpiredManualBlocksJob` that deletes them daily.
+
+Recommended: do both. Filter at read for correctness, prune in a daily job for tidiness.
+
+If adding a job: register it, add an audit entry per delete, verify with a test.
+
+### 6. Rate limiting beyond the public API
+
+- The current rate limiter applies only to public API endpoints. Add a soft limit to login attempts on the UI (covered by §2 above).
+- Consider whether admin endpoints need a limit. Real abuse on admin endpoints is rare (Bearer-authed humans/UI). Leave admin unrated unless you measure a problem.
+- Document the rate-limit posture in `doc/api-overview.md` (update from M13).
+
+### 7. Backups
+
+Verify M13's README has clear instructions for:
+
+- **SQLite + Docker volume**: `docker run --rm -v irdb-data:/data -v $(pwd):/backup alpine tar czf /backup/irdb-backup.tar.gz -C /data .` — describe the equivalent restore.
+- **MySQL**: `mysqldump` example via `docker compose exec`.
+- **Restore**: the inverse, with the api container stopped during restore.
+- **What to NOT back up**: rotating tokens (they're recoverable), GeoIP DBs (re-downloadable).
+
+Add to `doc/architecture.md` (update from M13): a "Disaster Recovery" subsection covering the same.
+
+### 8. Secrets at rest verification
+
+- Confirm tokens are never stored in plaintext (M03 work; verify with a manual SQL inspection).
+- Confirm no secret values appear in `audit_log.payload`.
+- Confirm `/api/v1/admin/config` masks all the secrets it should (M12).
+- Add a regression test that scans the schema for any column literally named `password` or containing `_secret` and asserts none store unhashed values (best-effort sanity check).
+
+### 9. Dependency vulnerability scan
+
+- Add a CI job: `composer audit` (PHP) and `npm audit --omit=dev` (UI). Fail on critical/high.
+- Document the policy: when an audit fails, an admin reviews and either patches or accepts with a documented exception.
+
+### 10. Final security review checklist
+
+Add `doc/security.md` capturing the actual posture: authn, authz, transport, data at rest, secrets management, logging, rate limits, supply chain. Concrete, factual, ≤300 lines. Do **not** make claims you can't back up.
+
+## Implementation notes
+
+- **CSP iteration**: enable in "Report-Only" mode first if you want a faster cycle (`Content-Security-Policy-Report-Only`), check the browser console, then switch to enforcing.
+- **HSTS gotcha**: HSTS is sticky in browsers. If you turn it on in dev with `localhost`, you may break local development for yourself. Gate strictly on `APP_ENV=production`.
+- **Brute-force lockout vs UX**: too aggressive = legit admins lock themselves out. The 1/5/30 progression is moderate. Don't go to "permanent ban" — the local admin path is a recovery channel, not a daily-use channel.
+- **Auditing the auditor**: changes to `audit_log` config (retention, etc.) should themselves be audited. Verify the M12 emitter wraps any settings endpoint that touches audit retention.
+- **Don't introduce new attack surface in the name of "hardening"**: e.g., don't add a "lockout-clear" endpoint reachable from the API. Reset is via container restart; that's safer.
+
+## Out of scope (DO NOT)
+
+- WAF rules, IPS integration, fail2ban for the admin UI itself. Out of scope.
+- 2FA on local admin. Use OIDC for that.
+- mTLS between containers. The Docker network isolation is the trust boundary; documenting that is enough.
+- Penetration test report. The agent is not a pentester.
+- Encryption at rest of the SQLite file. The volume's host-level disk encryption is the right layer.
+- Audit log signing / tamper-evidence. Future work.
+
+## Acceptance
+
+```bash
+cd api && composer cs && composer stan && composer test && cd ..
+cd ui  && composer cs && composer stan && composer test && cd ..
+
+# composer + npm audit
+cd api && composer audit && cd ..
+cd ui  && npm ci && npm audit --omit=dev && cd ..
+
+docker compose down -v
+cp .env.example .env
+docker compose up -d
+sleep 15
+
+# Security headers present on UI
+HEADERS=$(curl -sI http://localhost:8080/login)
+echo "$HEADERS" | grep -qi "X-Content-Type-Options: nosniff"
+echo "$HEADERS" | grep -qi "X-Frame-Options: DENY"
+echo "$HEADERS" | grep -qi "Content-Security-Policy:"
+echo "$HEADERS" | grep -qi "Referrer-Policy:"
+
+# Headers on API
+HEADERS=$(curl -sI http://localhost:8081/healthz)
+echo "$HEADERS" | grep -qi "X-Content-Type-Options: nosniff"
+echo "$HEADERS" | grep -qi "X-Frame-Options:"
+
+# In production mode, HSTS appears (skip if not testing prod)
+# HEADERS=$(APP_ENV=production curl -sI ...) — manual
+
+# Local admin lockout: 5 fails should trigger lockout
+COOKIE=$(mktemp)
+for i in 1 2 3 4 5; do
+  CSRF=$(curl -s -c $COOKIE http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+  curl -s -b $COOKIE -c $COOKIE -X POST \
+    -d "csrf_token=$CSRF&username=admin&password=WRONG" \
+    http://localhost:8080/login/local > /dev/null
+done
+CSRF=$(curl -s -c $COOKIE http://localhost:8080/login | grep -oE 'name="csrf_token" value="[^"]+"' | cut -d'"' -f4)
+RESP=$(curl -s -b $COOKIE -c $COOKIE -X POST \
+  -d "csrf_token=$CSRF&username=admin&password=test1234" \
+  http://localhost:8080/login/local -L)
+echo "$RESP" | grep -qi "locked\|too many\|wait"
+
+# Bearer tokens never appear unmasked in logs
+docker compose logs 2>&1 | grep -E "Bearer irdb_(rep|con|adm|svc)_[A-Z2-7]+" && \
+  { echo "TOKEN LEAKED IN LOGS"; exit 1; } || true
+
+# Token entropy test passes
+cd api && vendor/bin/phpunit --filter TokenEntropyTest && cd ..
+
+# Expired manual block test (insert one with a past expires_at, run cleanup, verify it's gone or filtered)
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet)
+INTERNAL_TOKEN=$(grep ^INTERNAL_JOB_TOKEN= .env | cut -d= -f2)
+curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
+  -d '{"kind":"ip","ip":"203.0.113.250","reason":"expired test","expires_at":"2020-01-01T00:00:00Z"}' \
+  http://localhost:8081/api/v1/admin/manual-blocks > /dev/null
+# Run cleanup if you added a job; otherwise just verify the read-time filter:
+curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \
+  http://localhost:8081/api/v1/admin/manual-blocks | grep -v "203.0.113.250"
+
+# Quick CSP smoke test: load the UI in headless chrome (manual or via puppeteer in CI), no CSP violations
+# (omit if no headless browser available; rely on developer manual verification)
+
+docker compose down -v
+```
+
+## Handoff
+
+1. Commit:
+   ```
+   feat(M14): security hardening
+
+   - CSP, HSTS (prod), X-Content-Type-Options, X-Frame-Options, Referrer-Policy
+   - local admin brute-force lockout (1/5/30 progression, by user+ip)
+   - log scrubbing of Bearer tokens and known secrets via Monolog processor
+   - token entropy regression test
+   - expired manual block read-time filter + daily cleanup job
+   - composer audit + npm audit in CI
+   - doc/security.md describing posture; backup/restore in README and architecture.md
+   ```
+
+2. Append to `PROGRESS.md`:
+   ```markdown
+   ## M14 — Hardening (done)
+
+   **Built:** security headers, lockout, log scrubbing, audits, doc/security.md.
+
+   **Production checklist (run before exposing to internet):**
+   - APP_ENV=production
+   - Real OIDC tenant configured
+   - Strong LOCAL_ADMIN_PASSWORD_HASH or LOCAL_ADMIN_ENABLED=false
+   - Reverse proxy with TLS in front
+   - Backups configured
+   - composer audit / npm audit clean
+   - Logs piped to your aggregator
+   - MAXMIND_LICENSE_KEY set so refresh-geoip works
+   - Scheduler running (host cron / systemd / sidecar)
+
+   **Known limitations:**
+   - In-process rate limiter and lockout state are per-replica.
+   - Audit log is append-only but not tamper-evident; sign+chain is future work.
+   - No 2FA on local admin (use OIDC instead).
+
+   **Build complete.** All 14 milestones executed.
+   ```
+
+3. **Stop.** Final milestone reached.

+ 81 - 0
files/README.md

@@ -0,0 +1,81 @@
+# Milestone Instructions
+
+Each `MXX-*.md` file in this directory is a self-contained prompt for a **fresh Claude Code session running Sonnet 4.6**. One agent process executes one milestone end-to-end, then stops.
+
+## Why fresh agents per milestone
+
+- Long agent runs accumulate context drift. By M8 a long-running agent would be making decisions based on a fuzzy memory of M2.
+- A fresh agent reading a focused prompt makes sharper decisions.
+- Hand-offs become explicit and reviewable: each milestone produces a commit and a `PROGRESS.md` entry the next agent reads.
+- If a milestone goes wrong, you re-run only that milestone. Nothing earlier is contaminated.
+
+## Setup (do this once, before M01)
+
+1. Place the master spec at the repo root as `SPEC.md`. Every milestone file references it by section number.
+2. Initialize git, create an empty `PROGRESS.md` at the repo root with a single header `# Build Progress`.
+3. Confirm the agent has access to: filesystem, shell, Docker, composer, npm, php.
+
+## Running a milestone
+
+For each `MXX-*.md` file in order:
+
+1. Open a new Claude Code session.
+2. Paste (or attach) the milestone file as the initial user message.
+3. Let the agent work. It will read `SPEC.md`, run verification commands, do the work, run acceptance commands, commit, and stop.
+4. Review the commit and the `PROGRESS.md` entry.
+5. If acceptance passes, move to the next file. If not, see "When a milestone fails."
+
+## Structure of each milestone file
+
+```
+# MXX — Title
+## Mission                 (what this milestone accomplishes, in 2-3 sentences)
+## Before you start        (prerequisites, verification commands)
+## Required reading        (specific SPEC.md sections)
+## Tasks                   (concrete checklist, in order)
+## Implementation notes    (patterns, gotchas, code shapes)
+## Out of scope            (DO NOT — explicit guardrails against scope creep)
+## Acceptance              (exact commands; all must pass before commit)
+## Handoff                 (commit message format, PROGRESS.md template)
+```
+
+## When a milestone fails
+
+If acceptance criteria don't pass:
+1. **Do not** start the next milestone. Out-of-order work is harder to fix than a stuck milestone.
+2. Read the agent's notes in chat and any partial `PROGRESS.md` updates.
+3. Either: (a) start a new fresh agent with the same milestone file plus a brief note about what went wrong, or (b) hand-fix and commit, then update `PROGRESS.md` manually.
+4. Only proceed when the current milestone's acceptance commands all pass.
+
+## Order
+
+| #   | File                                       | Builds on   |
+|-----|--------------------------------------------|-------------|
+| M01 | `M01-monorepo-skeleton.md`                 | —           |
+| M02 | `M02-database-migrations.md`               | M01         |
+| M03 | `M03-api-auth-foundations.md`              | M02         |
+| M04 | `M04-token-system-and-ingest.md`           | M03         |
+| M05 | `M05-reputation-engine-and-jobs.md`        | M04         |
+| M06 | `M06-manual-blocks-allowlist.md`           | M05         |
+| M07 | `M07-policies-and-distribution.md`         | M06         |
+| M08 | `M08-ui-scaffold-and-auth.md`              | M07         |
+| M09 | `M09-ui-ips-history-dashboard.md`          | M08         |
+| M10 | `M10-ui-admin-crud-pages.md`               | M09         |
+| M11 | `M11-enrichment.md`                        | M07         |
+| M12 | `M12-audit-and-settings.md`                | M10, M11    |
+| M13 | `M13-polish-openapi-docs.md`               | M12         |
+| M14 | `M14-hardening.md`                         | M13         |
+
+M11 (enrichment) only structurally depends on M07; it can be run in parallel with M08–M10 if you have multiple agents available, but the simpler thing is sequential execution.
+
+## Common rules — every milestone obeys these
+
+These are repeated in each file but worth stating once here:
+
+- **Read `SPEC.md` before doing anything.** It is the source of truth. The milestone file is a focused execution plan, not a full design document.
+- **Stay in scope.** Each milestone has explicit "out of scope" items. If you encounter something that feels like it should be done now but isn't listed in tasks, it belongs to a later milestone or is intentionally deferred.
+- **Stop and ask** if a requirement is ambiguous, contradicts `SPEC.md`, or appears wrong once you're in the code. Do not paper over it. Stopping mid-milestone is correct behavior in that case.
+- **No new dependencies** without justifying them in `PROGRESS.md`. The `SPEC.md` tech stack is non-negotiable.
+- **Tests must pass** before commit. Linters too. PHPStan level 8 on `src/` and php-cs-fixer.
+- **One commit per milestone**, conventional-commit style. Include the milestone number.
+- **Update `PROGRESS.md`** at the end with: what was built, what was deferred, anything the next milestone should know.

+ 126 - 0
scripts/ci.sh

@@ -0,0 +1,126 @@
+#!/usr/bin/env bash
+# Local CI runner. Runs lint / static analysis / tests for both subprojects,
+# builds the ui asset bundle, and verifies docker compose images build.
+#
+# Designed for a host with Docker installed but no PHP/Composer/Node toolchain:
+# every PHP and Node command runs inside an ephemeral container.
+#
+# Usage:
+#     ./scripts/ci.sh
+#
+# Env vars:
+#     DB_DRIVERS   space-separated list, default "sqlite mysql".
+#                  api test stage runs once per driver. mysql is skipped
+#                  with a warning if no MySQL is reachable on the host.
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$REPO_ROOT"
+
+DB_DRIVERS="${DB_DRIVERS:-sqlite mysql}"
+PHP_IMAGE="composer:2"
+NODE_IMAGE="node:20-alpine"
+
+UID_GID="$(id -u):$(id -g)"
+
+banner() {
+    printf '\n\033[1;36m==> %s\033[0m\n' "$*"
+}
+
+note() {
+    printf '\033[1;33m[note]\033[0m %s\n' "$*"
+}
+
+ok() {
+    printf '\033[1;32m%s\033[0m\n' "$*"
+}
+
+fail() {
+    printf '\033[1;31m[fail]\033[0m %s\n' "$*" >&2
+    exit 1
+}
+
+# Run a command inside a one-off PHP/Composer container, mounted at /app.
+# Args: <subproject-dir> <env-args> <command...>
+run_php() {
+    local dir="$1"; shift
+    docker run --rm \
+        -u "$UID_GID" \
+        -v "$REPO_ROOT/$dir":/app \
+        -v "$HOME/.composer-cache":/tmp \
+        -w /app \
+        -e COMPOSER_HOME=/tmp/composer \
+        -e COMPOSER_CACHE_DIR=/tmp/composer-cache \
+        -e XDG_CONFIG_HOME=/tmp \
+        "$@"
+}
+
+# Run a command inside a one-off Node container.
+run_node() {
+    local dir="$1"; shift
+    docker run --rm \
+        -u "$UID_GID" \
+        -v "$REPO_ROOT/$dir":/app \
+        -w /app \
+        -e HOME=/tmp \
+        "$NODE_IMAGE" "$@"
+}
+
+mkdir -p "$HOME/.composer-cache"
+
+# ---------- 1. api ----------
+banner "api: composer install"
+run_php api "$PHP_IMAGE" composer install --no-interaction --prefer-dist
+
+banner "api: phpstan"
+run_php api "$PHP_IMAGE" composer stan
+
+banner "api: php-cs-fixer (dry-run)"
+run_php api "$PHP_IMAGE" composer cs
+
+for driver in $DB_DRIVERS; do
+    if [ "$driver" = "mysql" ]; then
+        # No MySQL reachable from the host in M01; skip gracefully. Once
+        # later milestones add real DB-touching tests, replace this with a
+        # proper reachability probe.
+        note "api: skipping phpunit for driver=mysql (no MySQL configured for local CI in M01)"
+        continue
+    fi
+    banner "api: phpunit (DB_DRIVER=$driver)"
+    run_php api -e "DB_DRIVER=$driver" "$PHP_IMAGE" composer test
+done
+
+# ---------- 2. ui ----------
+banner "ui: composer install"
+run_php ui "$PHP_IMAGE" composer install --no-interaction --prefer-dist
+
+banner "ui: phpstan"
+run_php ui "$PHP_IMAGE" composer stan
+
+banner "ui: php-cs-fixer (dry-run)"
+run_php ui "$PHP_IMAGE" composer cs
+
+banner "ui: phpunit"
+run_php ui "$PHP_IMAGE" composer test
+
+# ---------- 3. ui frontend build ----------
+banner "ui: npm ci"
+if [ -f ui/package-lock.json ]; then
+    run_node ui npm ci --no-audit --no-fund
+else
+    note "ui/package-lock.json not present — using npm install to bootstrap"
+    run_node ui npm install --no-audit --no-fund
+fi
+
+banner "ui: npm run build"
+run_node ui npm run build
+
+if [ ! -f ui/public/assets/app.css ]; then
+    fail "ui/public/assets/app.css was not produced by the tailwind build"
+fi
+
+# ---------- 4. docker compose build ----------
+banner "docker compose build"
+docker compose build
+
+ok "CI OK"

+ 23 - 0
ui/.php-cs-fixer.dist.php

@@ -0,0 +1,23 @@
+<?php
+
+declare(strict_types=1);
+
+$finder = PhpCsFixer\Finder::create()
+    ->in(__DIR__ . '/src')
+    ->in(__DIR__ . '/tests')
+    ->in(__DIR__ . '/public');
+
+return (new PhpCsFixer\Config())
+    ->setRiskyAllowed(true)
+    ->setRules([
+        '@PSR12' => true,
+        '@PSR12:risky' => true,
+        'declare_strict_types' => true,
+        'array_syntax' => ['syntax' => 'short'],
+        'no_unused_imports' => true,
+        'ordered_imports' => ['sort_algorithm' => 'alpha'],
+        'single_quote' => true,
+        'trailing_comma_in_multiline' => true,
+    ])
+    ->setFinder($finder)
+    ->setCacheFile(__DIR__ . '/.php-cs-fixer.cache');

+ 46 - 0
ui/Dockerfile

@@ -0,0 +1,46 @@
+# syntax=docker/dockerfile:1.7
+
+# ---------- node stage: build Tailwind ----------
+FROM node:20-alpine AS assets
+WORKDIR /app
+COPY package.json package-lock.json* ./
+RUN npm install --no-audit --no-fund
+COPY tailwind.config.js postcss.config.js ./
+COPY resources ./resources
+RUN mkdir -p public/assets \
+    && npx tailwindcss -i resources/css/app.css -o public/assets/app.css --minify
+
+# ---------- composer stage ----------
+FROM composer:2 AS deps
+WORKDIR /app
+COPY composer.json composer.lock* ./
+RUN composer install --no-dev --no-interaction --no-scripts --no-progress --optimize-autoloader
+
+# ---------- runtime ----------
+FROM dunglas/frankenphp:1-php8.3-alpine
+
+ENV PHP_INI_SCAN_DIR=/usr/local/etc/php/conf.d
+
+RUN apk add --no-cache \
+        icu-dev \
+        oniguruma-dev \
+        bash \
+    && install-php-extensions \
+        mbstring \
+        intl \
+        opcache
+
+WORKDIR /app
+
+COPY --from=deps /app/vendor ./vendor
+COPY . ./
+COPY --from=assets /app/public/assets ./public/assets
+
+COPY docker/Caddyfile /etc/Caddyfile
+COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh
+RUN chmod +x /usr/local/bin/entrypoint.sh
+
+EXPOSE 8080
+
+ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
+CMD ["ui"]

+ 48 - 0
ui/composer.json

@@ -0,0 +1,48 @@
+{
+    "name": "irdb/ui",
+    "description": "IRDB — PHP+Twig BFF frontend (Slim 4 on FrankenPHP)",
+    "type": "project",
+    "license": "proprietary",
+    "require": {
+        "php": "^8.3",
+        "slim/slim": "^4.12",
+        "slim/psr7": "^1.6",
+        "slim/twig-view": "^3.4",
+        "twig/twig": "^3.8",
+        "guzzlehttp/guzzle": "^7.8",
+        "jumbojett/openid-connect-php": "^1.0",
+        "monolog/monolog": "^3.5",
+        "php-di/php-di": "^7.0"
+    },
+    "require-dev": {
+        "phpunit/phpunit": "^11.0",
+        "phpstan/phpstan": "^1.10",
+        "friendsofphp/php-cs-fixer": "^3.0",
+        "vlucas/phpdotenv": "^5.6"
+    },
+    "autoload": {
+        "psr-4": {
+            "App\\": "src/"
+        }
+    },
+    "autoload-dev": {
+        "psr-4": {
+            "App\\Tests\\": "tests/"
+        }
+    },
+    "config": {
+        "sort-packages": true,
+        "platform": {
+            "php": "8.3"
+        },
+        "allow-plugins": {
+            "php-http/discovery": true
+        }
+    },
+    "scripts": {
+        "test": "phpunit",
+        "stan": "phpstan analyse --memory-limit=512M",
+        "cs": "php-cs-fixer fix --dry-run --diff",
+        "cs-fix": "php-cs-fixer fix"
+    }
+}

+ 6402 - 0
ui/composer.lock

@@ -0,0 +1,6402 @@
+{
+    "_readme": [
+        "This file locks the dependencies of your project to a known state",
+        "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+        "This file is @generated automatically"
+    ],
+    "content-hash": "175f58ab4f32914da59ad61251cafd97",
+    "packages": [
+        {
+            "name": "fig/http-message-util",
+            "version": "1.1.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-message-util.git",
+                "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765",
+                "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.3 || ^7.0 || ^8.0"
+            },
+            "suggest": {
+                "psr/http-message": "The package containing the PSR-7 interfaces"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Fig\\Http\\Message\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Utility classes and constants for use with PSR-7 (psr/http-message)",
+            "keywords": [
+                "http",
+                "http-message",
+                "psr",
+                "psr-7",
+                "request",
+                "response"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/http-message-util/issues",
+                "source": "https://github.com/php-fig/http-message-util/tree/1.1.5"
+            },
+            "time": "2020-11-24T22:02:12+00:00"
+        },
+        {
+            "name": "guzzlehttp/guzzle",
+            "version": "7.10.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/guzzle/guzzle.git",
+                "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+                "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "guzzlehttp/promises": "^2.3",
+                "guzzlehttp/psr7": "^2.8",
+                "php": "^7.2.5 || ^8.0",
+                "psr/http-client": "^1.0",
+                "symfony/deprecation-contracts": "^2.2 || ^3.0"
+            },
+            "provide": {
+                "psr/http-client-implementation": "1.0"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "ext-curl": "*",
+                "guzzle/client-integration-tests": "3.0.2",
+                "php-http/message-factory": "^1.1",
+                "phpunit/phpunit": "^8.5.39 || ^9.6.20",
+                "psr/log": "^1.1 || ^2.0 || ^3.0"
+            },
+            "suggest": {
+                "ext-curl": "Required for CURL handler support",
+                "ext-intl": "Required for Internationalized Domain Name (IDN) support",
+                "psr/log": "Required for using the Log middleware"
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/functions_include.php"
+                ],
+                "psr-4": {
+                    "GuzzleHttp\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
+                {
+                    "name": "Michael Dowling",
+                    "email": "mtdowling@gmail.com",
+                    "homepage": "https://github.com/mtdowling"
+                },
+                {
+                    "name": "Jeremy Lindblom",
+                    "email": "jeremeamia@gmail.com",
+                    "homepage": "https://github.com/jeremeamia"
+                },
+                {
+                    "name": "George Mponos",
+                    "email": "gmponos@gmail.com",
+                    "homepage": "https://github.com/gmponos"
+                },
+                {
+                    "name": "Tobias Nyholm",
+                    "email": "tobias.nyholm@gmail.com",
+                    "homepage": "https://github.com/Nyholm"
+                },
+                {
+                    "name": "Márk Sági-Kazár",
+                    "email": "mark.sagikazar@gmail.com",
+                    "homepage": "https://github.com/sagikazarmark"
+                },
+                {
+                    "name": "Tobias Schultze",
+                    "email": "webmaster@tubo-world.de",
+                    "homepage": "https://github.com/Tobion"
+                }
+            ],
+            "description": "Guzzle is a PHP HTTP client library",
+            "keywords": [
+                "client",
+                "curl",
+                "framework",
+                "http",
+                "http client",
+                "psr-18",
+                "psr-7",
+                "rest",
+                "web service"
+            ],
+            "support": {
+                "issues": "https://github.com/guzzle/guzzle/issues",
+                "source": "https://github.com/guzzle/guzzle/tree/7.10.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/Nyholm",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-23T22:36:01+00:00"
+        },
+        {
+            "name": "guzzlehttp/promises",
+            "version": "2.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/guzzle/promises.git",
+                "reference": "481557b130ef3790cf82b713667b43030dc9c957"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
+                "reference": "481557b130ef3790cf82b713667b43030dc9c957",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "GuzzleHttp\\Promise\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
+                {
+                    "name": "Michael Dowling",
+                    "email": "mtdowling@gmail.com",
+                    "homepage": "https://github.com/mtdowling"
+                },
+                {
+                    "name": "Tobias Nyholm",
+                    "email": "tobias.nyholm@gmail.com",
+                    "homepage": "https://github.com/Nyholm"
+                },
+                {
+                    "name": "Tobias Schultze",
+                    "email": "webmaster@tubo-world.de",
+                    "homepage": "https://github.com/Tobion"
+                }
+            ],
+            "description": "Guzzle promises library",
+            "keywords": [
+                "promise"
+            ],
+            "support": {
+                "issues": "https://github.com/guzzle/promises/issues",
+                "source": "https://github.com/guzzle/promises/tree/2.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/Nyholm",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-22T14:34:08+00:00"
+        },
+        {
+            "name": "guzzlehttp/psr7",
+            "version": "2.9.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/guzzle/psr7.git",
+                "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884",
+                "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0",
+                "psr/http-factory": "^1.0",
+                "psr/http-message": "^1.1 || ^2.0",
+                "ralouphie/getallheaders": "^3.0"
+            },
+            "provide": {
+                "psr/http-factory-implementation": "1.0",
+                "psr/http-message-implementation": "1.0"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "http-interop/http-factory-tests": "0.9.0",
+                "jshttp/mime-db": "1.54.0.1",
+                "phpunit/phpunit": "^8.5.44 || ^9.6.25"
+            },
+            "suggest": {
+                "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "GuzzleHttp\\Psr7\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
+                {
+                    "name": "Michael Dowling",
+                    "email": "mtdowling@gmail.com",
+                    "homepage": "https://github.com/mtdowling"
+                },
+                {
+                    "name": "George Mponos",
+                    "email": "gmponos@gmail.com",
+                    "homepage": "https://github.com/gmponos"
+                },
+                {
+                    "name": "Tobias Nyholm",
+                    "email": "tobias.nyholm@gmail.com",
+                    "homepage": "https://github.com/Nyholm"
+                },
+                {
+                    "name": "Márk Sági-Kazár",
+                    "email": "mark.sagikazar@gmail.com",
+                    "homepage": "https://github.com/sagikazarmark"
+                },
+                {
+                    "name": "Tobias Schultze",
+                    "email": "webmaster@tubo-world.de",
+                    "homepage": "https://github.com/Tobion"
+                },
+                {
+                    "name": "Márk Sági-Kazár",
+                    "email": "mark.sagikazar@gmail.com",
+                    "homepage": "https://sagikazarmark.hu"
+                }
+            ],
+            "description": "PSR-7 message implementation that also provides common utility methods",
+            "keywords": [
+                "http",
+                "message",
+                "psr-7",
+                "request",
+                "response",
+                "stream",
+                "uri",
+                "url"
+            ],
+            "support": {
+                "issues": "https://github.com/guzzle/psr7/issues",
+                "source": "https://github.com/guzzle/psr7/tree/2.9.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/Nyholm",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-10T16:41:02+00:00"
+        },
+        {
+            "name": "jumbojett/openid-connect-php",
+            "version": "v1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/jumbojett/OpenID-Connect-PHP.git",
+                "reference": "f327e7eb0626d55ddb6abc7b7c9e6ad3af4e5d51"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/jumbojett/OpenID-Connect-PHP/zipball/f327e7eb0626d55ddb6abc7b7c9e6ad3af4e5d51",
+                "reference": "f327e7eb0626d55ddb6abc7b7c9e6ad3af4e5d51",
+                "shasum": ""
+            },
+            "require": {
+                "ext-curl": "*",
+                "ext-json": "*",
+                "php": ">=7.0",
+                "phpseclib/phpseclib": "^3.0.7"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "<10",
+                "roave/security-advisories": "dev-latest",
+                "yoast/phpunit-polyfills": "^2.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "description": "Bare-bones OpenID Connect client",
+            "support": {
+                "issues": "https://github.com/jumbojett/OpenID-Connect-PHP/issues",
+                "source": "https://github.com/jumbojett/OpenID-Connect-PHP/tree/v1.0.2"
+            },
+            "time": "2024-09-13T07:08:11+00:00"
+        },
+        {
+            "name": "laravel/serializable-closure",
+            "version": "v2.0.12",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/laravel/serializable-closure.git",
+                "reference": "a6abb4e54f6fcd3138120b9ad497f0bd146f9919"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/laravel/serializable-closure/zipball/a6abb4e54f6fcd3138120b9ad497f0bd146f9919",
+                "reference": "a6abb4e54f6fcd3138120b9ad497f0bd146f9919",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^8.1"
+            },
+            "require-dev": {
+                "illuminate/support": "^10.0|^11.0|^12.0|^13.0",
+                "nesbot/carbon": "^2.67|^3.0",
+                "pestphp/pest": "^2.36|^3.0|^4.0",
+                "phpstan/phpstan": "^2.0",
+                "symfony/var-dumper": "^6.2.0|^7.0.0|^8.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Laravel\\SerializableClosure\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Taylor Otwell",
+                    "email": "taylor@laravel.com"
+                },
+                {
+                    "name": "Nuno Maduro",
+                    "email": "nuno@laravel.com"
+                }
+            ],
+            "description": "Laravel Serializable Closure provides an easy and secure way to serialize closures in PHP.",
+            "keywords": [
+                "closure",
+                "laravel",
+                "serializable"
+            ],
+            "support": {
+                "issues": "https://github.com/laravel/serializable-closure/issues",
+                "source": "https://github.com/laravel/serializable-closure"
+            },
+            "time": "2026-04-14T13:33:34+00:00"
+        },
+        {
+            "name": "monolog/monolog",
+            "version": "3.10.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/Seldaek/monolog.git",
+                "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/Seldaek/monolog/zipball/b321dd6749f0bf7189444158a3ce785cc16d69b0",
+                "reference": "b321dd6749f0bf7189444158a3ce785cc16d69b0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "psr/log": "^2.0 || ^3.0"
+            },
+            "provide": {
+                "psr/log-implementation": "3.0.0"
+            },
+            "require-dev": {
+                "aws/aws-sdk-php": "^3.0",
+                "doctrine/couchdb": "~1.0@dev",
+                "elasticsearch/elasticsearch": "^7 || ^8",
+                "ext-json": "*",
+                "graylog2/gelf-php": "^1.4.2 || ^2.0",
+                "guzzlehttp/guzzle": "^7.4.5",
+                "guzzlehttp/psr7": "^2.2",
+                "mongodb/mongodb": "^1.8 || ^2.0",
+                "php-amqplib/php-amqplib": "~2.4 || ^3",
+                "php-console/php-console": "^3.1.8",
+                "phpstan/phpstan": "^2",
+                "phpstan/phpstan-deprecation-rules": "^2",
+                "phpstan/phpstan-strict-rules": "^2",
+                "phpunit/phpunit": "^10.5.17 || ^11.0.7",
+                "predis/predis": "^1.1 || ^2",
+                "rollbar/rollbar": "^4.0",
+                "ruflin/elastica": "^7 || ^8",
+                "symfony/mailer": "^5.4 || ^6",
+                "symfony/mime": "^5.4 || ^6"
+            },
+            "suggest": {
+                "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB",
+                "doctrine/couchdb": "Allow sending log messages to a CouchDB server",
+                "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client",
+                "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)",
+                "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler",
+                "ext-mbstring": "Allow to work properly with unicode symbols",
+                "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)",
+                "ext-openssl": "Required to send log messages using SSL",
+                "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)",
+                "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server",
+                "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)",
+                "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib",
+                "rollbar/rollbar": "Allow sending log messages to Rollbar",
+                "ruflin/elastica": "Allow sending log messages to an Elastic Search server"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Monolog\\": "src/Monolog"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jordi Boggiano",
+                    "email": "j.boggiano@seld.be",
+                    "homepage": "https://seld.be"
+                }
+            ],
+            "description": "Sends your logs to files, sockets, inboxes, databases and various web services",
+            "homepage": "https://github.com/Seldaek/monolog",
+            "keywords": [
+                "log",
+                "logging",
+                "psr-3"
+            ],
+            "support": {
+                "issues": "https://github.com/Seldaek/monolog/issues",
+                "source": "https://github.com/Seldaek/monolog/tree/3.10.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/Seldaek",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/monolog/monolog",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-01-02T08:56:05+00:00"
+        },
+        {
+            "name": "nikic/fast-route",
+            "version": "v1.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nikic/FastRoute.git",
+                "reference": "181d480e08d9476e61381e04a71b34dc0432e812"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nikic/FastRoute/zipball/181d480e08d9476e61381e04a71b34dc0432e812",
+                "reference": "181d480e08d9476e61381e04a71b34dc0432e812",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.4.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^4.8.35|~5.7"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/functions.php"
+                ],
+                "psr-4": {
+                    "FastRoute\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Nikita Popov",
+                    "email": "nikic@php.net"
+                }
+            ],
+            "description": "Fast request router for PHP",
+            "keywords": [
+                "router",
+                "routing"
+            ],
+            "support": {
+                "issues": "https://github.com/nikic/FastRoute/issues",
+                "source": "https://github.com/nikic/FastRoute/tree/master"
+            },
+            "time": "2018-02-13T20:26:39+00:00"
+        },
+        {
+            "name": "paragonie/constant_time_encoding",
+            "version": "v3.1.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/paragonie/constant_time_encoding.git",
+                "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+                "reference": "d5b01a39b3415c2cd581d3bd3a3575c1ebbd8e77",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^8"
+            },
+            "require-dev": {
+                "infection/infection": "^0",
+                "nikic/php-fuzzer": "^0",
+                "phpunit/phpunit": "^9|^10|^11",
+                "vimeo/psalm": "^4|^5|^6"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "ParagonIE\\ConstantTime\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Paragon Initiative Enterprises",
+                    "email": "security@paragonie.com",
+                    "homepage": "https://paragonie.com",
+                    "role": "Maintainer"
+                },
+                {
+                    "name": "Steve 'Sc00bz' Thomas",
+                    "email": "steve@tobtu.com",
+                    "homepage": "https://www.tobtu.com",
+                    "role": "Original Developer"
+                }
+            ],
+            "description": "Constant-time Implementations of RFC 4648 Encoding (Base-64, Base-32, Base-16)",
+            "keywords": [
+                "base16",
+                "base32",
+                "base32_decode",
+                "base32_encode",
+                "base64",
+                "base64_decode",
+                "base64_encode",
+                "bin2hex",
+                "encoding",
+                "hex",
+                "hex2bin",
+                "rfc4648"
+            ],
+            "support": {
+                "email": "info@paragonie.com",
+                "issues": "https://github.com/paragonie/constant_time_encoding/issues",
+                "source": "https://github.com/paragonie/constant_time_encoding"
+            },
+            "time": "2025-09-24T15:06:41+00:00"
+        },
+        {
+            "name": "paragonie/random_compat",
+            "version": "v9.99.100",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/paragonie/random_compat.git",
+                "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
+                "reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">= 7"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "4.*|5.*",
+                "vimeo/psalm": "^1"
+            },
+            "suggest": {
+                "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
+            },
+            "type": "library",
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Paragon Initiative Enterprises",
+                    "email": "security@paragonie.com",
+                    "homepage": "https://paragonie.com"
+                }
+            ],
+            "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
+            "keywords": [
+                "csprng",
+                "polyfill",
+                "pseudorandom",
+                "random"
+            ],
+            "support": {
+                "email": "info@paragonie.com",
+                "issues": "https://github.com/paragonie/random_compat/issues",
+                "source": "https://github.com/paragonie/random_compat"
+            },
+            "time": "2020-10-15T08:29:30+00:00"
+        },
+        {
+            "name": "php-di/invoker",
+            "version": "2.3.7",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHP-DI/Invoker.git",
+                "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHP-DI/Invoker/zipball/3c1ddfdef181431fbc4be83378f6d036d59e81e1",
+                "reference": "3c1ddfdef181431fbc4be83378f6d036d59e81e1",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.3",
+                "psr/container": "^1.0|^2.0"
+            },
+            "require-dev": {
+                "athletic/athletic": "~0.1.8",
+                "mnapoli/hard-mode": "~0.3.0",
+                "phpunit/phpunit": "^9.0 || ^10 || ^11 || ^12"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Invoker\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Generic and extensible callable invoker",
+            "homepage": "https://github.com/PHP-DI/Invoker",
+            "keywords": [
+                "callable",
+                "dependency",
+                "dependency-injection",
+                "injection",
+                "invoke",
+                "invoker"
+            ],
+            "support": {
+                "issues": "https://github.com/PHP-DI/Invoker/issues",
+                "source": "https://github.com/PHP-DI/Invoker/tree/2.3.7"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/mnapoli",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-08-30T10:22:22+00:00"
+        },
+        {
+            "name": "php-di/php-di",
+            "version": "7.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHP-DI/PHP-DI.git",
+                "reference": "f88054cc052e40dbe7b383c8817c19442d480352"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHP-DI/PHP-DI/zipball/f88054cc052e40dbe7b383c8817c19442d480352",
+                "reference": "f88054cc052e40dbe7b383c8817c19442d480352",
+                "shasum": ""
+            },
+            "require": {
+                "laravel/serializable-closure": "^1.0 || ^2.0",
+                "php": ">=8.0",
+                "php-di/invoker": "^2.0",
+                "psr/container": "^1.1 || ^2.0"
+            },
+            "provide": {
+                "psr/container-implementation": "^1.0"
+            },
+            "require-dev": {
+                "friendsofphp/php-cs-fixer": "^3",
+                "friendsofphp/proxy-manager-lts": "^1",
+                "mnapoli/phpunit-easymock": "^1.3",
+                "phpunit/phpunit": "^9.6 || ^10 || ^11",
+                "vimeo/psalm": "^5|^6"
+            },
+            "suggest": {
+                "friendsofphp/proxy-manager-lts": "Install it if you want to use lazy injection (version ^1)"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/functions.php"
+                ],
+                "psr-4": {
+                    "DI\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "The dependency injection container for humans",
+            "homepage": "https://php-di.org/",
+            "keywords": [
+                "PSR-11",
+                "container",
+                "container-interop",
+                "dependency injection",
+                "di",
+                "ioc",
+                "psr11"
+            ],
+            "support": {
+                "issues": "https://github.com/PHP-DI/PHP-DI/issues",
+                "source": "https://github.com/PHP-DI/PHP-DI/tree/7.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/mnapoli",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/php-di/php-di",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-16T11:10:48+00:00"
+        },
+        {
+            "name": "phpseclib/phpseclib",
+            "version": "3.0.52",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phpseclib/phpseclib.git",
+                "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/2adaefc83df2ec548558307690f376dd7d4f4fce",
+                "reference": "2adaefc83df2ec548558307690f376dd7d4f4fce",
+                "shasum": ""
+            },
+            "require": {
+                "paragonie/constant_time_encoding": "^1|^2|^3",
+                "paragonie/random_compat": "^1.4|^2.0|^9.99.99",
+                "php": ">=5.6.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "*"
+            },
+            "suggest": {
+                "ext-dom": "Install the DOM extension to load XML formatted public keys.",
+                "ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
+                "ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
+                "ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
+                "ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "phpseclib/bootstrap.php"
+                ],
+                "psr-4": {
+                    "phpseclib3\\": "phpseclib/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jim Wigginton",
+                    "email": "terrafrost@php.net",
+                    "role": "Lead Developer"
+                },
+                {
+                    "name": "Patrick Monnerat",
+                    "email": "pm@datasphere.ch",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Andreas Fischer",
+                    "email": "bantu@phpbb.com",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Hans-Jürgen Petrich",
+                    "email": "petrich@tronic-media.com",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Graham Campbell",
+                    "email": "graham@alt-three.com",
+                    "role": "Developer"
+                }
+            ],
+            "description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
+            "homepage": "http://phpseclib.sourceforge.net",
+            "keywords": [
+                "BigInteger",
+                "aes",
+                "asn.1",
+                "asn1",
+                "blowfish",
+                "crypto",
+                "cryptography",
+                "encryption",
+                "rsa",
+                "security",
+                "sftp",
+                "signature",
+                "signing",
+                "ssh",
+                "twofish",
+                "x.509",
+                "x509"
+            ],
+            "support": {
+                "issues": "https://github.com/phpseclib/phpseclib/issues",
+                "source": "https://github.com/phpseclib/phpseclib/tree/3.0.52"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/terrafrost",
+                    "type": "github"
+                },
+                {
+                    "url": "https://www.patreon.com/phpseclib",
+                    "type": "patreon"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-27T07:02:15+00:00"
+        },
+        {
+            "name": "psr/container",
+            "version": "2.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/container.git",
+                "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/container/zipball/c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+                "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.4.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Container\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common Container Interface (PHP FIG PSR-11)",
+            "homepage": "https://github.com/php-fig/container",
+            "keywords": [
+                "PSR-11",
+                "container",
+                "container-interface",
+                "container-interop",
+                "psr"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/container/issues",
+                "source": "https://github.com/php-fig/container/tree/2.0.2"
+            },
+            "time": "2021-11-05T16:47:00+00:00"
+        },
+        {
+            "name": "psr/http-client",
+            "version": "1.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-client.git",
+                "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
+                "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.0 || ^8.0",
+                "psr/http-message": "^1.0 || ^2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Client\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for HTTP clients",
+            "homepage": "https://github.com/php-fig/http-client",
+            "keywords": [
+                "http",
+                "http-client",
+                "psr",
+                "psr-18"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/http-client"
+            },
+            "time": "2023-09-23T14:17:50+00:00"
+        },
+        {
+            "name": "psr/http-factory",
+            "version": "1.1.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-factory.git",
+                "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+                "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1",
+                "psr/http-message": "^1.0 || ^2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Message\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
+            "keywords": [
+                "factory",
+                "http",
+                "message",
+                "psr",
+                "psr-17",
+                "psr-7",
+                "request",
+                "response"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/http-factory"
+            },
+            "time": "2024-04-15T12:06:14+00:00"
+        },
+        {
+            "name": "psr/http-message",
+            "version": "2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-message.git",
+                "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+                "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Message\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for HTTP messages",
+            "homepage": "https://github.com/php-fig/http-message",
+            "keywords": [
+                "http",
+                "http-message",
+                "psr",
+                "psr-7",
+                "request",
+                "response"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/http-message/tree/2.0"
+            },
+            "time": "2023-04-04T09:54:51+00:00"
+        },
+        {
+            "name": "psr/http-server-handler",
+            "version": "1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-server-handler.git",
+                "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4",
+                "reference": "84c4fb66179be4caaf8e97bd239203245302e7d4",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.0",
+                "psr/http-message": "^1.0 || ^2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Server\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for HTTP server-side request handler",
+            "keywords": [
+                "handler",
+                "http",
+                "http-interop",
+                "psr",
+                "psr-15",
+                "psr-7",
+                "request",
+                "response",
+                "server"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/http-server-handler/tree/1.0.2"
+            },
+            "time": "2023-04-10T20:06:20+00:00"
+        },
+        {
+            "name": "psr/http-server-middleware",
+            "version": "1.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/http-server-middleware.git",
+                "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
+                "reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.0",
+                "psr/http-message": "^1.0 || ^2.0",
+                "psr/http-server-handler": "^1.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Http\\Server\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for HTTP server-side middleware",
+            "keywords": [
+                "http",
+                "http-interop",
+                "middleware",
+                "psr",
+                "psr-15",
+                "psr-7",
+                "request",
+                "response"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/http-server-middleware/issues",
+                "source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2"
+            },
+            "time": "2023-04-11T06:14:47+00:00"
+        },
+        {
+            "name": "psr/log",
+            "version": "3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/log.git",
+                "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/log/zipball/f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+                "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.0.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\Log\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "https://www.php-fig.org/"
+                }
+            ],
+            "description": "Common interface for logging libraries",
+            "homepage": "https://github.com/php-fig/log",
+            "keywords": [
+                "log",
+                "psr",
+                "psr-3"
+            ],
+            "support": {
+                "source": "https://github.com/php-fig/log/tree/3.0.2"
+            },
+            "time": "2024-09-11T13:17:53+00:00"
+        },
+        {
+            "name": "ralouphie/getallheaders",
+            "version": "3.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ralouphie/getallheaders.git",
+                "reference": "120b605dfeb996808c31b6477290a714d356e822"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
+                "reference": "120b605dfeb996808c31b6477290a714d356e822",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.6"
+            },
+            "require-dev": {
+                "php-coveralls/php-coveralls": "^2.1",
+                "phpunit/phpunit": "^5 || ^6.5"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/getallheaders.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ralph Khattar",
+                    "email": "ralph.khattar@gmail.com"
+                }
+            ],
+            "description": "A polyfill for getallheaders.",
+            "support": {
+                "issues": "https://github.com/ralouphie/getallheaders/issues",
+                "source": "https://github.com/ralouphie/getallheaders/tree/develop"
+            },
+            "time": "2019-03-08T08:55:37+00:00"
+        },
+        {
+            "name": "slim/psr7",
+            "version": "1.8.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/slimphp/Slim-Psr7.git",
+                "reference": "76e7e3b1cdfd583e9035c4c966c08e01e45ce959"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/slimphp/Slim-Psr7/zipball/76e7e3b1cdfd583e9035c4c966c08e01e45ce959",
+                "reference": "76e7e3b1cdfd583e9035c4c966c08e01e45ce959",
+                "shasum": ""
+            },
+            "require": {
+                "fig/http-message-util": "^1.1.5",
+                "php": "^8.0",
+                "psr/http-factory": "^1.1",
+                "psr/http-message": "^1.0 || ^2.0",
+                "ralouphie/getallheaders": "^3.0"
+            },
+            "provide": {
+                "psr/http-factory-implementation": "^1.0",
+                "psr/http-message-implementation": "^1.0 || ^2.0"
+            },
+            "require-dev": {
+                "adriansuter/php-autoload-override": "^1.5|| ^2.0",
+                "ext-json": "*",
+                "http-interop/http-factory-tests": "^1.0 || ^2.0",
+                "php-http/psr7-integration-tests": "^1.5",
+                "phpstan/phpstan": "^2.1",
+                "phpunit/phpunit": "^9.6 || ^10",
+                "squizlabs/php_codesniffer": "^3.13"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Slim\\Psr7\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Josh Lockhart",
+                    "email": "hello@joshlockhart.com",
+                    "homepage": "https://joshlockhart.com"
+                },
+                {
+                    "name": "Andrew Smith",
+                    "email": "a.smith@silentworks.co.uk",
+                    "homepage": "https://silentworks.co.uk"
+                },
+                {
+                    "name": "Rob Allen",
+                    "email": "rob@akrabat.com",
+                    "homepage": "https://akrabat.com"
+                },
+                {
+                    "name": "Pierre Berube",
+                    "email": "pierre@lgse.com",
+                    "homepage": "https://www.lgse.com"
+                }
+            ],
+            "description": "Strict PSR-7 implementation",
+            "homepage": "https://www.slimframework.com",
+            "keywords": [
+                "http",
+                "psr-7",
+                "psr7"
+            ],
+            "support": {
+                "issues": "https://github.com/slimphp/Slim-Psr7/issues",
+                "source": "https://github.com/slimphp/Slim-Psr7/tree/1.8.0"
+            },
+            "time": "2025-11-02T17:51:19+00:00"
+        },
+        {
+            "name": "slim/slim",
+            "version": "4.15.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/slimphp/Slim.git",
+                "reference": "887893516557506f254d950425ce7f5387a26970"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/slimphp/Slim/zipball/887893516557506f254d950425ce7f5387a26970",
+                "reference": "887893516557506f254d950425ce7f5387a26970",
+                "shasum": ""
+            },
+            "require": {
+                "ext-json": "*",
+                "nikic/fast-route": "^1.3",
+                "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
+                "psr/container": "^1.0 || ^2.0",
+                "psr/http-factory": "^1.1",
+                "psr/http-message": "^1.1 || ^2.0",
+                "psr/http-server-handler": "^1.0",
+                "psr/http-server-middleware": "^1.0",
+                "psr/log": "^1.1 || ^2.0 || ^3.0"
+            },
+            "require-dev": {
+                "adriansuter/php-autoload-override": "^1.4 || ^2",
+                "ext-simplexml": "*",
+                "guzzlehttp/psr7": "^2.6",
+                "httpsoft/http-message": "^1.1",
+                "httpsoft/http-server-request": "^1.1",
+                "laminas/laminas-diactoros": "^2.17 || ^3",
+                "nyholm/psr7": "^1.8",
+                "nyholm/psr7-server": "^1.1",
+                "phpspec/prophecy": "^1.19",
+                "phpspec/prophecy-phpunit": "^2.1",
+                "phpstan/phpstan": "^1 || ^2",
+                "phpunit/phpunit": "^9.6 || ^10 || ^11 || ^12",
+                "slim/http": "^1.3",
+                "slim/psr7": "^1.6",
+                "squizlabs/php_codesniffer": "^3.10",
+                "vimeo/psalm": "^5 || ^6"
+            },
+            "suggest": {
+                "ext-simplexml": "Needed to support XML format in BodyParsingMiddleware",
+                "ext-xml": "Needed to support XML format in BodyParsingMiddleware",
+                "php-di/php-di": "PHP-DI is the recommended container library to be used with Slim",
+                "slim/psr7": "Slim PSR-7 implementation. See https://www.slimframework.com/docs/v4/start/installation.html for more information."
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Slim\\": "Slim"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Josh Lockhart",
+                    "email": "hello@joshlockhart.com",
+                    "homepage": "https://joshlockhart.com"
+                },
+                {
+                    "name": "Andrew Smith",
+                    "email": "a.smith@silentworks.co.uk",
+                    "homepage": "https://silentworks.co.uk"
+                },
+                {
+                    "name": "Rob Allen",
+                    "email": "rob@akrabat.com",
+                    "homepage": "https://akrabat.com"
+                },
+                {
+                    "name": "Pierre Berube",
+                    "email": "pierre@lgse.com",
+                    "homepage": "https://www.lgse.com"
+                },
+                {
+                    "name": "Gabriel Manricks",
+                    "email": "gmanricks@me.com",
+                    "homepage": "http://gabrielmanricks.com"
+                }
+            ],
+            "description": "Slim is a PHP micro framework that helps you quickly write simple yet powerful web applications and APIs",
+            "homepage": "https://www.slimframework.com",
+            "keywords": [
+                "api",
+                "framework",
+                "micro",
+                "router"
+            ],
+            "support": {
+                "docs": "https://www.slimframework.com/docs/v4/",
+                "forum": "https://discourse.slimframework.com/",
+                "irc": "irc://irc.freenode.net:6667/slimphp",
+                "issues": "https://github.com/slimphp/Slim/issues",
+                "rss": "https://www.slimframework.com/blog/feed.rss",
+                "slack": "https://slimphp.slack.com/",
+                "source": "https://github.com/slimphp/Slim",
+                "wiki": "https://github.com/slimphp/Slim/wiki"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/slimphp",
+                    "type": "open_collective"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/slim/slim",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-11-21T12:23:44+00:00"
+        },
+        {
+            "name": "slim/twig-view",
+            "version": "3.4.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/slimphp/Twig-View.git",
+                "reference": "b4268d87d0e327feba5f88d32031e9123655b909"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/slimphp/Twig-View/zipball/b4268d87d0e327feba5f88d32031e9123655b909",
+                "reference": "b4268d87d0e327feba5f88d32031e9123655b909",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.4 || ^8.0",
+                "psr/http-message": "^1.1 || ^2.0",
+                "slim/slim": "^4.12",
+                "symfony/polyfill-php81": "^1.29",
+                "twig/twig": "^3.11"
+            },
+            "require-dev": {
+                "phpspec/prophecy-phpunit": "^2.0",
+                "phpstan/phpstan": "^1.10.59",
+                "phpunit/phpunit": "^9.6 || ^10",
+                "psr/http-factory": "^1.0",
+                "squizlabs/php_codesniffer": "^3.9"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Slim\\Views\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Josh Lockhart",
+                    "email": "hello@joshlockhart.com",
+                    "homepage": "http://joshlockhart.com"
+                },
+                {
+                    "name": "Pierre Berube",
+                    "email": "pierre@lgse.com",
+                    "homepage": "http://www.lgse.com"
+                }
+            ],
+            "description": "Slim Framework 4 view helper built on top of the Twig 3 templating component",
+            "homepage": "https://www.slimframework.com",
+            "keywords": [
+                "framework",
+                "slim",
+                "template",
+                "twig",
+                "view"
+            ],
+            "support": {
+                "issues": "https://github.com/slimphp/Twig-View/issues",
+                "source": "https://github.com/slimphp/Twig-View/tree/3.4.1"
+            },
+            "time": "2024-09-26T05:42:02+00:00"
+        },
+        {
+            "name": "symfony/deprecation-contracts",
+            "version": "v3.6.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/deprecation-contracts.git",
+                "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/63afe740e99a13ba87ec199bb07bbdee937a5b62",
+                "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "3.6-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "function.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "A generic function and convention to trigger deprecation notices",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-25T14:21:43+00:00"
+        },
+        {
+            "name": "symfony/polyfill-ctype",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-ctype.git",
+                "reference": "141046a8f9477948ff284fa65be2095baafb94f2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/141046a8f9477948ff284fa65be2095baafb94f2",
+                "reference": "141046a8f9477948ff284fa65be2095baafb94f2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "provide": {
+                "ext-ctype": "*"
+            },
+            "suggest": {
+                "ext-ctype": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Ctype\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Gert de Pagter",
+                    "email": "BackEndTea@gmail.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for ctype functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "ctype",
+                "polyfill",
+                "portable"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-ctype/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-10T16:19:22+00:00"
+        },
+        {
+            "name": "symfony/polyfill-mbstring",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-mbstring.git",
+                "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/6a21eb99c6973357967f6ce3708cd55a6bec6315",
+                "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315",
+                "shasum": ""
+            },
+            "require": {
+                "ext-iconv": "*",
+                "php": ">=7.2"
+            },
+            "provide": {
+                "ext-mbstring": "*"
+            },
+            "suggest": {
+                "ext-mbstring": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Mbstring\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for the Mbstring extension",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "mbstring",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-10T17:25:58+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php81",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php81.git",
+                "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
+                "reference": "4a4cfc2d253c21a5ad0e53071df248ed48c6ce5c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php81\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php81/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-09T11:45:10+00:00"
+        },
+        {
+            "name": "twig/twig",
+            "version": "v3.24.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/twigphp/Twig.git",
+                "reference": "a6769aefb305efef849dc25c9fd1653358c148f0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/twigphp/Twig/zipball/a6769aefb305efef849dc25c9fd1653358c148f0",
+                "reference": "a6769aefb305efef849dc25c9fd1653358c148f0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1.0",
+                "symfony/deprecation-contracts": "^2.5|^3",
+                "symfony/polyfill-ctype": "^1.8",
+                "symfony/polyfill-mbstring": "^1.3"
+            },
+            "require-dev": {
+                "php-cs-fixer/shim": "^3.0@stable",
+                "phpstan/phpstan": "^2.0@stable",
+                "psr/container": "^1.0|^2.0",
+                "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/Resources/core.php",
+                    "src/Resources/debug.php",
+                    "src/Resources/escaper.php",
+                    "src/Resources/string_loader.php"
+                ],
+                "psr-4": {
+                    "Twig\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com",
+                    "homepage": "http://fabien.potencier.org",
+                    "role": "Lead Developer"
+                },
+                {
+                    "name": "Twig Team",
+                    "role": "Contributors"
+                },
+                {
+                    "name": "Armin Ronacher",
+                    "email": "armin.ronacher@active-4.com",
+                    "role": "Project Founder"
+                }
+            ],
+            "description": "Twig, the flexible, fast, and secure template language for PHP",
+            "homepage": "https://twig.symfony.com",
+            "keywords": [
+                "templating"
+            ],
+            "support": {
+                "issues": "https://github.com/twigphp/Twig/issues",
+                "source": "https://github.com/twigphp/Twig/tree/v3.24.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/twig/twig",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-17T21:31:11+00:00"
+        }
+    ],
+    "packages-dev": [
+        {
+            "name": "clue/ndjson-react",
+            "version": "v1.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/clue/reactphp-ndjson.git",
+                "reference": "392dc165fce93b5bb5c637b67e59619223c931b0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/clue/reactphp-ndjson/zipball/392dc165fce93b5bb5c637b67e59619223c931b0",
+                "reference": "392dc165fce93b5bb5c637b67e59619223c931b0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3",
+                "react/stream": "^1.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35",
+                "react/event-loop": "^1.2"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Clue\\React\\NDJson\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering"
+                }
+            ],
+            "description": "Streaming newline-delimited JSON (NDJSON) parser and encoder for ReactPHP.",
+            "homepage": "https://github.com/clue/reactphp-ndjson",
+            "keywords": [
+                "NDJSON",
+                "json",
+                "jsonlines",
+                "newline",
+                "reactphp",
+                "streaming"
+            ],
+            "support": {
+                "issues": "https://github.com/clue/reactphp-ndjson/issues",
+                "source": "https://github.com/clue/reactphp-ndjson/tree/v1.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://clue.engineering/support",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/clue",
+                    "type": "github"
+                }
+            ],
+            "time": "2022-12-23T10:58:28+00:00"
+        },
+        {
+            "name": "composer/pcre",
+            "version": "3.3.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/pcre.git",
+                "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+                "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.4 || ^8.0"
+            },
+            "conflict": {
+                "phpstan/phpstan": "<1.11.10"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^1.12 || ^2",
+                "phpstan/phpstan-strict-rules": "^1 || ^2",
+                "phpunit/phpunit": "^8 || ^9"
+            },
+            "type": "library",
+            "extra": {
+                "phpstan": {
+                    "includes": [
+                        "extension.neon"
+                    ]
+                },
+                "branch-alias": {
+                    "dev-main": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Composer\\Pcre\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jordi Boggiano",
+                    "email": "j.boggiano@seld.be",
+                    "homepage": "http://seld.be"
+                }
+            ],
+            "description": "PCRE wrapping library that offers type-safe preg_* replacements.",
+            "keywords": [
+                "PCRE",
+                "preg",
+                "regex",
+                "regular expression"
+            ],
+            "support": {
+                "issues": "https://github.com/composer/pcre/issues",
+                "source": "https://github.com/composer/pcre/tree/3.3.2"
+            },
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/composer",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-11-12T16:29:46+00:00"
+        },
+        {
+            "name": "composer/semver",
+            "version": "3.4.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/semver.git",
+                "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+                "reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^5.3.2 || ^7.0 || ^8.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^1.11",
+                "symfony/phpunit-bridge": "^3 || ^7"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Composer\\Semver\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nils Adermann",
+                    "email": "naderman@naderman.de",
+                    "homepage": "http://www.naderman.de"
+                },
+                {
+                    "name": "Jordi Boggiano",
+                    "email": "j.boggiano@seld.be",
+                    "homepage": "http://seld.be"
+                },
+                {
+                    "name": "Rob Bast",
+                    "email": "rob.bast@gmail.com",
+                    "homepage": "http://robbast.nl"
+                }
+            ],
+            "description": "Semver library that offers utilities, version constraint parsing and validation.",
+            "keywords": [
+                "semantic",
+                "semver",
+                "validation",
+                "versioning"
+            ],
+            "support": {
+                "irc": "ircs://irc.libera.chat:6697/composer",
+                "issues": "https://github.com/composer/semver/issues",
+                "source": "https://github.com/composer/semver/tree/3.4.4"
+            },
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/composer",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-08-20T19:15:30+00:00"
+        },
+        {
+            "name": "composer/xdebug-handler",
+            "version": "3.0.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/composer/xdebug-handler.git",
+                "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/composer/xdebug-handler/zipball/6c1925561632e83d60a44492e0b344cf48ab85ef",
+                "reference": "6c1925561632e83d60a44492e0b344cf48ab85ef",
+                "shasum": ""
+            },
+            "require": {
+                "composer/pcre": "^1 || ^2 || ^3",
+                "php": "^7.2.5 || ^8.0",
+                "psr/log": "^1 || ^2 || ^3"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "^1.0",
+                "phpstan/phpstan-strict-rules": "^1.1",
+                "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Composer\\XdebugHandler\\": "src"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "John Stevenson",
+                    "email": "john-stevenson@blueyonder.co.uk"
+                }
+            ],
+            "description": "Restarts a process without Xdebug.",
+            "keywords": [
+                "Xdebug",
+                "performance"
+            ],
+            "support": {
+                "irc": "ircs://irc.libera.chat:6697/composer",
+                "issues": "https://github.com/composer/xdebug-handler/issues",
+                "source": "https://github.com/composer/xdebug-handler/tree/3.0.5"
+            },
+            "funding": [
+                {
+                    "url": "https://packagist.com",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/composer",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/composer/composer",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-05-06T16:37:16+00:00"
+        },
+        {
+            "name": "ergebnis/agent-detector",
+            "version": "1.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/ergebnis/agent-detector.git",
+                "reference": "5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/ergebnis/agent-detector/zipball/5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64",
+                "reference": "5b654a9f1ff8a5d2ce6a57568df5ae8801c87f64",
+                "shasum": ""
+            },
+            "require": {
+                "php": "~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0 || ~8.6.0"
+            },
+            "require-dev": {
+                "ergebnis/composer-normalize": "^2.50.0",
+                "ergebnis/license": "^2.7.0",
+                "ergebnis/php-cs-fixer-config": "^6.60.2",
+                "ergebnis/phpstan-rules": "^2.13.1",
+                "ergebnis/phpunit-slow-test-detector": "^2.24.0",
+                "ergebnis/rector-rules": "^1.16.0",
+                "fakerphp/faker": "^1.24.1",
+                "infection/infection": "^0.26.6",
+                "phpstan/extension-installer": "^1.4.3",
+                "phpstan/phpstan": "^2.1.46",
+                "phpstan/phpstan-deprecation-rules": "^2.0.4",
+                "phpstan/phpstan-phpunit": "^2.0.16",
+                "phpstan/phpstan-strict-rules": "^2.0.10",
+                "phpunit/phpunit": "^9.6.34",
+                "rector/rector": "^2.4.1"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "1.0-dev"
+                },
+                "composer-normalize": {
+                    "indent-size": 2,
+                    "indent-style": "space"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Ergebnis\\AgentDetector\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Andreas Möller",
+                    "email": "am@localheinz.com",
+                    "homepage": "https://localheinz.com"
+                }
+            ],
+            "description": "Provides a detector for detecting the presence of an agent.",
+            "homepage": "https://github.com/ergebnis/agent-detector",
+            "support": {
+                "issues": "https://github.com/ergebnis/agent-detector/issues",
+                "security": "https://github.com/ergebnis/agent-detector/blob/main/.github/SECURITY.md",
+                "source": "https://github.com/ergebnis/agent-detector"
+            },
+            "time": "2026-04-10T13:45:13+00:00"
+        },
+        {
+            "name": "evenement/evenement",
+            "version": "v3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/igorw/evenement.git",
+                "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc",
+                "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9 || ^6"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Evenement\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Igor Wiedler",
+                    "email": "igor@wiedler.ch"
+                }
+            ],
+            "description": "Événement is a very simple event dispatching library for PHP",
+            "keywords": [
+                "event-dispatcher",
+                "event-emitter"
+            ],
+            "support": {
+                "issues": "https://github.com/igorw/evenement/issues",
+                "source": "https://github.com/igorw/evenement/tree/v3.0.2"
+            },
+            "time": "2023-08-08T05:53:35+00:00"
+        },
+        {
+            "name": "fidry/cpu-core-counter",
+            "version": "1.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/theofidry/cpu-core-counter.git",
+                "reference": "db9508f7b1474469d9d3c53b86f817e344732678"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/theofidry/cpu-core-counter/zipball/db9508f7b1474469d9d3c53b86f817e344732678",
+                "reference": "db9508f7b1474469d9d3c53b86f817e344732678",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "require-dev": {
+                "fidry/makefile": "^0.2.0",
+                "fidry/php-cs-fixer-config": "^1.1.2",
+                "phpstan/extension-installer": "^1.2.0",
+                "phpstan/phpstan": "^2.0",
+                "phpstan/phpstan-deprecation-rules": "^2.0.0",
+                "phpstan/phpstan-phpunit": "^2.0",
+                "phpstan/phpstan-strict-rules": "^2.0",
+                "phpunit/phpunit": "^8.5.31 || ^9.5.26",
+                "webmozarts/strict-phpunit": "^7.5"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Fidry\\CpuCoreCounter\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Théo FIDRY",
+                    "email": "theo.fidry@gmail.com"
+                }
+            ],
+            "description": "Tiny utility to get the number of CPU cores.",
+            "keywords": [
+                "CPU",
+                "core"
+            ],
+            "support": {
+                "issues": "https://github.com/theofidry/cpu-core-counter/issues",
+                "source": "https://github.com/theofidry/cpu-core-counter/tree/1.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theofidry",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-08-14T07:29:31+00:00"
+        },
+        {
+            "name": "friendsofphp/php-cs-fixer",
+            "version": "v3.95.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git",
+                "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/a9727678fbd12997f1d9de8f4a37824ed9df1065",
+                "reference": "a9727678fbd12997f1d9de8f4a37824ed9df1065",
+                "shasum": ""
+            },
+            "require": {
+                "clue/ndjson-react": "^1.3",
+                "composer/semver": "^3.4",
+                "composer/xdebug-handler": "^3.0.5",
+                "ergebnis/agent-detector": "^1.1.1",
+                "ext-filter": "*",
+                "ext-hash": "*",
+                "ext-json": "*",
+                "ext-tokenizer": "*",
+                "fidry/cpu-core-counter": "^1.3",
+                "php": "^7.4 || ^8.0",
+                "react/child-process": "^0.6.6",
+                "react/event-loop": "^1.5",
+                "react/socket": "^1.16",
+                "react/stream": "^1.4",
+                "sebastian/diff": "^4.0.6 || ^5.1.1 || ^6.0.2 || ^7.0 || ^8.0",
+                "symfony/console": "^5.4.47 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/event-dispatcher": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/filesystem": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/finder": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/options-resolver": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0",
+                "symfony/polyfill-mbstring": "^1.33",
+                "symfony/polyfill-php80": "^1.33",
+                "symfony/polyfill-php81": "^1.33",
+                "symfony/polyfill-php84": "^1.33",
+                "symfony/process": "^5.4.47 || ^6.4.24 || ^7.2 || ^8.0",
+                "symfony/stopwatch": "^5.4.45 || ^6.4.24 || ^7.0 || ^8.0"
+            },
+            "require-dev": {
+                "facile-it/paraunit": "^1.3.1 || ^2.8.0",
+                "infection/infection": "^0.32.6",
+                "justinrainbow/json-schema": "^6.8.0",
+                "keradus/cli-executor": "^2.3",
+                "mikey179/vfsstream": "^1.6.12",
+                "php-coveralls/php-coveralls": "^2.9.1",
+                "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8",
+                "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8",
+                "phpunit/phpunit": "^9.6.34 || ^10.5.63 || ^11.5.55",
+                "symfony/polyfill-php85": "^1.33",
+                "symfony/var-dumper": "^5.4.48 || ^6.4.32 || ^7.4.4 || ^8.0.8",
+                "symfony/yaml": "^5.4.45 || ^6.4.30 || ^7.4.1 || ^8.0.8"
+            },
+            "suggest": {
+                "ext-dom": "For handling output formats in XML",
+                "ext-mbstring": "For handling non-UTF8 characters."
+            },
+            "bin": [
+                "php-cs-fixer"
+            ],
+            "type": "application",
+            "autoload": {
+                "psr-4": {
+                    "PhpCsFixer\\": "src/"
+                },
+                "exclude-from-classmap": [
+                    "src/**/Internal/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Dariusz Rumiński",
+                    "email": "dariusz.ruminski@gmail.com"
+                }
+            ],
+            "description": "A tool to automatically fix PHP code style",
+            "keywords": [
+                "Static code analysis",
+                "fixer",
+                "standards",
+                "static analysis"
+            ],
+            "support": {
+                "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues",
+                "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/keradus",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-04-12T17:00:09+00:00"
+        },
+        {
+            "name": "graham-campbell/result-type",
+            "version": "v1.1.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/GrahamCampbell/Result-Type.git",
+                "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/GrahamCampbell/Result-Type/zipball/e01f4a821471308ba86aa202fed6698b6b695e3b",
+                "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0",
+                "phpoption/phpoption": "^1.9.5"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "GrahamCampbell\\ResultType\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                }
+            ],
+            "description": "An Implementation Of The Result Type",
+            "keywords": [
+                "Graham Campbell",
+                "GrahamCampbell",
+                "Result Type",
+                "Result-Type",
+                "result"
+            ],
+            "support": {
+                "issues": "https://github.com/GrahamCampbell/Result-Type/issues",
+                "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/graham-campbell/result-type",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-12-27T19:43:20+00:00"
+        },
+        {
+            "name": "myclabs/deep-copy",
+            "version": "1.13.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/myclabs/DeepCopy.git",
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+                "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.1 || ^8.0"
+            },
+            "conflict": {
+                "doctrine/collections": "<1.6.8",
+                "doctrine/common": "<2.13.3 || >=3 <3.2.2"
+            },
+            "require-dev": {
+                "doctrine/collections": "^1.6.8",
+                "doctrine/common": "^2.13.3 || ^3.2.2",
+                "phpspec/prophecy": "^1.10",
+                "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/DeepCopy/deep_copy.php"
+                ],
+                "psr-4": {
+                    "DeepCopy\\": "src/DeepCopy/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "Create deep copies (clones) of your objects",
+            "keywords": [
+                "clone",
+                "copy",
+                "duplicate",
+                "object",
+                "object graph"
+            ],
+            "support": {
+                "issues": "https://github.com/myclabs/DeepCopy/issues",
+                "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
+            },
+            "funding": [
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-01T08:46:24+00:00"
+        },
+        {
+            "name": "nikic/php-parser",
+            "version": "v5.7.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/nikic/PHP-Parser.git",
+                "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+                "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+                "shasum": ""
+            },
+            "require": {
+                "ext-ctype": "*",
+                "ext-json": "*",
+                "ext-tokenizer": "*",
+                "php": ">=7.4"
+            },
+            "require-dev": {
+                "ircmaxell/php-yacc": "^0.0.7",
+                "phpunit/phpunit": "^9.0"
+            },
+            "bin": [
+                "bin/php-parse"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "5.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "PhpParser\\": "lib/PhpParser"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Nikita Popov"
+                }
+            ],
+            "description": "A PHP parser written in PHP",
+            "keywords": [
+                "parser",
+                "php"
+            ],
+            "support": {
+                "issues": "https://github.com/nikic/PHP-Parser/issues",
+                "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
+            },
+            "time": "2025-12-06T11:56:16+00:00"
+        },
+        {
+            "name": "phar-io/manifest",
+            "version": "2.0.4",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/manifest.git",
+                "reference": "54750ef60c58e43759730615a392c31c80e23176"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/manifest/zipball/54750ef60c58e43759730615a392c31c80e23176",
+                "reference": "54750ef60c58e43759730615a392c31c80e23176",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "ext-phar": "*",
+                "ext-xmlwriter": "*",
+                "phar-io/version": "^3.0.1",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.0.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)",
+            "support": {
+                "issues": "https://github.com/phar-io/manifest/issues",
+                "source": "https://github.com/phar-io/manifest/tree/2.0.4"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-03-03T12:33:53+00:00"
+        },
+        {
+            "name": "phar-io/version",
+            "version": "3.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/phar-io/version.git",
+                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+                "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Heuer",
+                    "email": "sebastian@phpeople.de",
+                    "role": "Developer"
+                },
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "Library for handling version information and constraints",
+            "support": {
+                "issues": "https://github.com/phar-io/version/issues",
+                "source": "https://github.com/phar-io/version/tree/3.2.1"
+            },
+            "time": "2022-02-21T01:04:05+00:00"
+        },
+        {
+            "name": "phpoption/phpoption",
+            "version": "1.9.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/schmittjoh/php-option.git",
+                "reference": "75365b91986c2405cf5e1e012c5595cd487a98be"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/schmittjoh/php-option/zipball/75365b91986c2405cf5e1e012c5595cd487a98be",
+                "reference": "75365b91986c2405cf5e1e012c5595cd487a98be",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2.5 || ^8.0"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34"
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                },
+                "branch-alias": {
+                    "dev-master": "1.9-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "PhpOption\\": "src/PhpOption/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "Apache-2.0"
+            ],
+            "authors": [
+                {
+                    "name": "Johannes M. Schmitt",
+                    "email": "schmittjoh@gmail.com",
+                    "homepage": "https://github.com/schmittjoh"
+                },
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                }
+            ],
+            "description": "Option Type for PHP",
+            "keywords": [
+                "language",
+                "option",
+                "php",
+                "type"
+            ],
+            "support": {
+                "issues": "https://github.com/schmittjoh/php-option/issues",
+                "source": "https://github.com/schmittjoh/php-option/tree/1.9.5"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpoption/phpoption",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-12-27T19:41:33+00:00"
+        },
+        {
+            "name": "phpstan/phpstan",
+            "version": "1.12.33",
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/37982d6fc7cbb746dda7773530cda557cdf119e1",
+                "reference": "37982d6fc7cbb746dda7773530cda557cdf119e1",
+                "shasum": ""
+            },
+            "require": {
+                "php": "^7.2|^8.0"
+            },
+            "conflict": {
+                "phpstan/phpstan-shim": "*"
+            },
+            "bin": [
+                "phpstan",
+                "phpstan.phar"
+            ],
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "PHPStan - PHP Static Analysis Tool",
+            "keywords": [
+                "dev",
+                "static analysis"
+            ],
+            "support": {
+                "docs": "https://phpstan.org/user-guide/getting-started",
+                "forum": "https://github.com/phpstan/phpstan/discussions",
+                "issues": "https://github.com/phpstan/phpstan/issues",
+                "security": "https://github.com/phpstan/phpstan/security/policy",
+                "source": "https://github.com/phpstan/phpstan-src"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/ondrejmirtes",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/phpstan",
+                    "type": "github"
+                }
+            ],
+            "time": "2026-02-28T20:30:03+00:00"
+        },
+        {
+            "name": "phpunit/php-code-coverage",
+            "version": "11.0.12",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
+                "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/2c1ed04922802c15e1de5d7447b4856de949cf56",
+                "reference": "2c1ed04922802c15e1de5d7447b4856de949cf56",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-libxml": "*",
+                "ext-xmlwriter": "*",
+                "nikic/php-parser": "^5.7.0",
+                "php": ">=8.2",
+                "phpunit/php-file-iterator": "^5.1.0",
+                "phpunit/php-text-template": "^4.0.1",
+                "sebastian/code-unit-reverse-lookup": "^4.0.1",
+                "sebastian/complexity": "^4.0.1",
+                "sebastian/environment": "^7.2.1",
+                "sebastian/lines-of-code": "^3.0.1",
+                "sebastian/version": "^5.0.2",
+                "theseer/tokenizer": "^1.3.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.5.46"
+            },
+            "suggest": {
+                "ext-pcov": "PHP extension that provides line coverage",
+                "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "11.0.x-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
+            "homepage": "https://github.com/sebastianbergmann/php-code-coverage",
+            "keywords": [
+                "coverage",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
+                "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/11.0.12"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpunit/php-code-coverage",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-12-24T07:01:01+00:00"
+        },
+        {
+            "name": "phpunit/php-file-iterator",
+            "version": "5.1.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-file-iterator.git",
+                "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/2f3a64888c814fc235386b7387dd5b5ed92ad903",
+                "reference": "2f3a64888c814fc235386b7387dd5b5ed92ad903",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "FilterIterator implementation that filters files based on a list of suffixes.",
+            "homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
+            "keywords": [
+                "filesystem",
+                "iterator"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues",
+                "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/5.1.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpunit/php-file-iterator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-02-02T13:52:54+00:00"
+        },
+        {
+            "name": "phpunit/php-invoker",
+            "version": "5.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-invoker.git",
+                "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/c1ca3814734c07492b3d4c5f794f4b0995333da2",
+                "reference": "c1ca3814734c07492b3d4c5f794f4b0995333da2",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "ext-pcntl": "*",
+                "phpunit/phpunit": "^11.0"
+            },
+            "suggest": {
+                "ext-pcntl": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Invoke callables with a timeout",
+            "homepage": "https://github.com/sebastianbergmann/php-invoker/",
+            "keywords": [
+                "process"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-invoker/issues",
+                "security": "https://github.com/sebastianbergmann/php-invoker/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-invoker/tree/5.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T05:07:44+00:00"
+        },
+        {
+            "name": "phpunit/php-text-template",
+            "version": "4.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-text-template.git",
+                "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/3e0404dc6b300e6bf56415467ebcb3fe4f33e964",
+                "reference": "3e0404dc6b300e6bf56415467ebcb3fe4f33e964",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Simple template engine.",
+            "homepage": "https://github.com/sebastianbergmann/php-text-template/",
+            "keywords": [
+                "template"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-text-template/issues",
+                "security": "https://github.com/sebastianbergmann/php-text-template/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-text-template/tree/4.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T05:08:43+00:00"
+        },
+        {
+            "name": "phpunit/php-timer",
+            "version": "7.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/php-timer.git",
+                "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/3b415def83fbcb41f991d9ebf16ae4ad8b7837b3",
+                "reference": "3b415def83fbcb41f991d9ebf16ae4ad8b7837b3",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "7.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Utility class for timing",
+            "homepage": "https://github.com/sebastianbergmann/php-timer/",
+            "keywords": [
+                "timer"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/php-timer/issues",
+                "security": "https://github.com/sebastianbergmann/php-timer/security/policy",
+                "source": "https://github.com/sebastianbergmann/php-timer/tree/7.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T05:09:35+00:00"
+        },
+        {
+            "name": "phpunit/phpunit",
+            "version": "11.5.55",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/phpunit.git",
+                "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/adc7262fccc12de2b30f12a8aa0b33775d814f00",
+                "reference": "adc7262fccc12de2b30f12a8aa0b33775d814f00",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-json": "*",
+                "ext-libxml": "*",
+                "ext-mbstring": "*",
+                "ext-xml": "*",
+                "ext-xmlwriter": "*",
+                "myclabs/deep-copy": "^1.13.4",
+                "phar-io/manifest": "^2.0.4",
+                "phar-io/version": "^3.2.1",
+                "php": ">=8.2",
+                "phpunit/php-code-coverage": "^11.0.12",
+                "phpunit/php-file-iterator": "^5.1.1",
+                "phpunit/php-invoker": "^5.0.1",
+                "phpunit/php-text-template": "^4.0.1",
+                "phpunit/php-timer": "^7.0.1",
+                "sebastian/cli-parser": "^3.0.2",
+                "sebastian/code-unit": "^3.0.3",
+                "sebastian/comparator": "^6.3.3",
+                "sebastian/diff": "^6.0.2",
+                "sebastian/environment": "^7.2.1",
+                "sebastian/exporter": "^6.3.2",
+                "sebastian/global-state": "^7.0.2",
+                "sebastian/object-enumerator": "^6.0.1",
+                "sebastian/recursion-context": "^6.0.3",
+                "sebastian/type": "^5.1.3",
+                "sebastian/version": "^5.0.2",
+                "staabm/side-effects-detector": "^1.0.5"
+            },
+            "suggest": {
+                "ext-soap": "To be able to generate mocks based on WSDL files"
+            },
+            "bin": [
+                "phpunit"
+            ],
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "11.5-dev"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "src/Framework/Assert/Functions.php"
+                ],
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "The PHP Unit Testing framework.",
+            "homepage": "https://phpunit.de/",
+            "keywords": [
+                "phpunit",
+                "testing",
+                "xunit"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/phpunit/issues",
+                "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
+                "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.55"
+            },
+            "funding": [
+                {
+                    "url": "https://phpunit.de/sponsors.html",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-02-18T12:37:06+00:00"
+        },
+        {
+            "name": "psr/event-dispatcher",
+            "version": "1.0.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/php-fig/event-dispatcher.git",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/php-fig/event-dispatcher/zipball/dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "reference": "dbefd12671e8a14ec7f180cab83036ed26714bb0",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.0.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Psr\\EventDispatcher\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "PHP-FIG",
+                    "homepage": "http://www.php-fig.org/"
+                }
+            ],
+            "description": "Standard interfaces for event handling.",
+            "keywords": [
+                "events",
+                "psr",
+                "psr-14"
+            ],
+            "support": {
+                "issues": "https://github.com/php-fig/event-dispatcher/issues",
+                "source": "https://github.com/php-fig/event-dispatcher/tree/1.0.0"
+            },
+            "time": "2019-01-08T18:20:26+00:00"
+        },
+        {
+            "name": "react/cache",
+            "version": "v1.2.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/cache.git",
+                "reference": "d47c472b64aa5608225f47965a484b75c7817d5b"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b",
+                "reference": "d47c472b64aa5608225f47965a484b75c7817d5b",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0",
+                "react/promise": "^3.0 || ^2.0 || ^1.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Cache\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Async, Promise-based cache interface for ReactPHP",
+            "keywords": [
+                "cache",
+                "caching",
+                "promise",
+                "reactphp"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/cache/issues",
+                "source": "https://github.com/reactphp/cache/tree/v1.2.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2022-11-30T15:59:55+00:00"
+        },
+        {
+            "name": "react/child-process",
+            "version": "v0.6.7",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/child-process.git",
+                "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/child-process/zipball/970f0e71945556422ee4570ccbabaedc3cf04ad3",
+                "reference": "970f0e71945556422ee4570ccbabaedc3cf04ad3",
+                "shasum": ""
+            },
+            "require": {
+                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+                "php": ">=5.3.0",
+                "react/event-loop": "^1.2",
+                "react/stream": "^1.4"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+                "react/socket": "^1.16",
+                "sebastian/environment": "^5.0 || ^3.0 || ^2.0 || ^1.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\ChildProcess\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Event-driven library for executing child processes with ReactPHP.",
+            "keywords": [
+                "event-driven",
+                "process",
+                "reactphp"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/child-process/issues",
+                "source": "https://github.com/reactphp/child-process/tree/v0.6.7"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-12-23T15:25:20+00:00"
+        },
+        {
+            "name": "react/dns",
+            "version": "v1.14.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/dns.git",
+                "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/dns/zipball/7562c05391f42701c1fccf189c8225fece1cd7c3",
+                "reference": "7562c05391f42701c1fccf189c8225fece1cd7c3",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0",
+                "react/cache": "^1.0 || ^0.6 || ^0.5",
+                "react/event-loop": "^1.2",
+                "react/promise": "^3.2 || ^2.7 || ^1.2.1"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+                "react/async": "^4.3 || ^3 || ^2",
+                "react/promise-timer": "^1.11"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Dns\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Async DNS resolver for ReactPHP",
+            "keywords": [
+                "async",
+                "dns",
+                "dns-resolver",
+                "reactphp"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/dns/issues",
+                "source": "https://github.com/reactphp/dns/tree/v1.14.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-11-18T19:34:28+00:00"
+        },
+        {
+            "name": "react/event-loop",
+            "version": "v1.6.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/event-loop.git",
+                "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/event-loop/zipball/ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
+                "reference": "ba276bda6083df7e0050fd9b33f66ad7a4ac747a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=5.3.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+            },
+            "suggest": {
+                "ext-pcntl": "For signal handling support when using the StreamSelectLoop"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\EventLoop\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.",
+            "keywords": [
+                "asynchronous",
+                "event-loop"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/event-loop/issues",
+                "source": "https://github.com/reactphp/event-loop/tree/v1.6.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-11-17T20:46:25+00:00"
+        },
+        {
+            "name": "react/promise",
+            "version": "v3.3.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/promise.git",
+                "reference": "23444f53a813a3296c1368bb104793ce8d88f04a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/promise/zipball/23444f53a813a3296c1368bb104793ce8d88f04a",
+                "reference": "23444f53a813a3296c1368bb104793ce8d88f04a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.1.0"
+            },
+            "require-dev": {
+                "phpstan/phpstan": "1.12.28 || 1.4.10",
+                "phpunit/phpunit": "^9.6 || ^7.5"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "src/functions_include.php"
+                ],
+                "psr-4": {
+                    "React\\Promise\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "A lightweight implementation of CommonJS Promises/A for PHP",
+            "keywords": [
+                "promise",
+                "promises"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/promise/issues",
+                "source": "https://github.com/reactphp/promise/tree/v3.3.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-08-19T18:57:03+00:00"
+        },
+        {
+            "name": "react/socket",
+            "version": "v1.17.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/socket.git",
+                "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/socket/zipball/ef5b17b81f6f60504c539313f94f2d826c5faa08",
+                "reference": "ef5b17b81f6f60504c539313f94f2d826c5faa08",
+                "shasum": ""
+            },
+            "require": {
+                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+                "php": ">=5.3.0",
+                "react/dns": "^1.13",
+                "react/event-loop": "^1.2",
+                "react/promise": "^3.2 || ^2.6 || ^1.2.1",
+                "react/stream": "^1.4"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36",
+                "react/async": "^4.3 || ^3.3 || ^2",
+                "react/promise-stream": "^1.4",
+                "react/promise-timer": "^1.11"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Socket\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP",
+            "keywords": [
+                "Connection",
+                "Socket",
+                "async",
+                "reactphp",
+                "stream"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/socket/issues",
+                "source": "https://github.com/reactphp/socket/tree/v1.17.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2025-11-19T20:47:34+00:00"
+        },
+        {
+            "name": "react/stream",
+            "version": "v1.4.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/reactphp/stream.git",
+                "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d",
+                "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d",
+                "shasum": ""
+            },
+            "require": {
+                "evenement/evenement": "^3.0 || ^2.0 || ^1.0",
+                "php": ">=5.3.8",
+                "react/event-loop": "^1.2"
+            },
+            "require-dev": {
+                "clue/stream-filter": "~1.2",
+                "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "React\\Stream\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Christian Lück",
+                    "email": "christian@clue.engineering",
+                    "homepage": "https://clue.engineering/"
+                },
+                {
+                    "name": "Cees-Jan Kiewiet",
+                    "email": "reactphp@ceesjankiewiet.nl",
+                    "homepage": "https://wyrihaximus.net/"
+                },
+                {
+                    "name": "Jan Sorgalla",
+                    "email": "jsorgalla@gmail.com",
+                    "homepage": "https://sorgalla.com/"
+                },
+                {
+                    "name": "Chris Boden",
+                    "email": "cboden@gmail.com",
+                    "homepage": "https://cboden.dev/"
+                }
+            ],
+            "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP",
+            "keywords": [
+                "event-driven",
+                "io",
+                "non-blocking",
+                "pipe",
+                "reactphp",
+                "readable",
+                "stream",
+                "writable"
+            ],
+            "support": {
+                "issues": "https://github.com/reactphp/stream/issues",
+                "source": "https://github.com/reactphp/stream/tree/v1.4.0"
+            },
+            "funding": [
+                {
+                    "url": "https://opencollective.com/reactphp",
+                    "type": "open_collective"
+                }
+            ],
+            "time": "2024-06-11T12:45:25+00:00"
+        },
+        {
+            "name": "sebastian/cli-parser",
+            "version": "3.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/cli-parser.git",
+                "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/15c5dd40dc4f38794d383bb95465193f5e0ae180",
+                "reference": "15c5dd40dc4f38794d383bb95465193f5e0ae180",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for parsing CLI options",
+            "homepage": "https://github.com/sebastianbergmann/cli-parser",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/cli-parser/issues",
+                "security": "https://github.com/sebastianbergmann/cli-parser/security/policy",
+                "source": "https://github.com/sebastianbergmann/cli-parser/tree/3.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T04:41:36+00:00"
+        },
+        {
+            "name": "sebastian/code-unit",
+            "version": "3.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit.git",
+                "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/54391c61e4af8078e5b276ab082b6d3c54c9ad64",
+                "reference": "54391c61e4af8078e5b276ab082b6d3c54c9ad64",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/code-unit",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit/issues",
+                "security": "https://github.com/sebastianbergmann/code-unit/security/policy",
+                "source": "https://github.com/sebastianbergmann/code-unit/tree/3.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-03-19T07:56:08+00:00"
+        },
+        {
+            "name": "sebastian/code-unit-reverse-lookup",
+            "version": "4.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git",
+                "reference": "183a9b2632194febd219bb9246eee421dad8d45e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/183a9b2632194febd219bb9246eee421dad8d45e",
+                "reference": "183a9b2632194febd219bb9246eee421dad8d45e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Looks up which function or method a line of code belongs to",
+            "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues",
+                "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy",
+                "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/4.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T04:45:54+00:00"
+        },
+        {
+            "name": "sebastian/comparator",
+            "version": "6.3.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/comparator.git",
+                "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
+                "reference": "2c95e1e86cb8dd41beb8d502057d1081ccc8eca9",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-mbstring": "*",
+                "php": ">=8.2",
+                "sebastian/diff": "^6.0",
+                "sebastian/exporter": "^6.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.4"
+            },
+            "suggest": {
+                "ext-bcmath": "For comparing BcMath\\Number objects"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.3-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@2bepublished.at"
+                }
+            ],
+            "description": "Provides the functionality to compare PHP values for equality",
+            "homepage": "https://github.com/sebastianbergmann/comparator",
+            "keywords": [
+                "comparator",
+                "compare",
+                "equality"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/comparator/issues",
+                "security": "https://github.com/sebastianbergmann/comparator/security/policy",
+                "source": "https://github.com/sebastianbergmann/comparator/tree/6.3.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-01-24T09:26:40+00:00"
+        },
+        {
+            "name": "sebastian/complexity",
+            "version": "4.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/complexity.git",
+                "reference": "ee41d384ab1906c68852636b6de493846e13e5a0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/ee41d384ab1906c68852636b6de493846e13e5a0",
+                "reference": "ee41d384ab1906c68852636b6de493846e13e5a0",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^5.0",
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for calculating the complexity of PHP code units",
+            "homepage": "https://github.com/sebastianbergmann/complexity",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/complexity/issues",
+                "security": "https://github.com/sebastianbergmann/complexity/security/policy",
+                "source": "https://github.com/sebastianbergmann/complexity/tree/4.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T04:49:50+00:00"
+        },
+        {
+            "name": "sebastian/diff",
+            "version": "6.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/diff.git",
+                "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/b4ccd857127db5d41a5b676f24b51371d76d8544",
+                "reference": "b4ccd857127db5d41a5b676f24b51371d76d8544",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0",
+                "symfony/process": "^4.2 || ^5"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Kore Nordmann",
+                    "email": "mail@kore-nordmann.de"
+                }
+            ],
+            "description": "Diff implementation",
+            "homepage": "https://github.com/sebastianbergmann/diff",
+            "keywords": [
+                "diff",
+                "udiff",
+                "unidiff",
+                "unified diff"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/diff/issues",
+                "security": "https://github.com/sebastianbergmann/diff/security/policy",
+                "source": "https://github.com/sebastianbergmann/diff/tree/6.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T04:53:05+00:00"
+        },
+        {
+            "name": "sebastian/environment",
+            "version": "7.2.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/environment.git",
+                "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/a5c75038693ad2e8d4b6c15ba2403532647830c4",
+                "reference": "a5c75038693ad2e8d4b6c15ba2403532647830c4",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.3"
+            },
+            "suggest": {
+                "ext-posix": "*"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "7.2-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Provides functionality to handle HHVM/PHP environments",
+            "homepage": "https://github.com/sebastianbergmann/environment",
+            "keywords": [
+                "Xdebug",
+                "environment",
+                "hhvm"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/environment/issues",
+                "security": "https://github.com/sebastianbergmann/environment/security/policy",
+                "source": "https://github.com/sebastianbergmann/environment/tree/7.2.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/environment",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-05-21T11:55:47+00:00"
+        },
+        {
+            "name": "sebastian/exporter",
+            "version": "6.3.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/exporter.git",
+                "reference": "70a298763b40b213ec087c51c739efcaa90bcd74"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/70a298763b40b213ec087c51c739efcaa90bcd74",
+                "reference": "70a298763b40b213ec087c51c739efcaa90bcd74",
+                "shasum": ""
+            },
+            "require": {
+                "ext-mbstring": "*",
+                "php": ">=8.2",
+                "sebastian/recursion-context": "^6.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.3-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Volker Dusch",
+                    "email": "github@wallbash.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                },
+                {
+                    "name": "Bernhard Schussek",
+                    "email": "bschussek@gmail.com"
+                }
+            ],
+            "description": "Provides the functionality to export PHP variables for visualization",
+            "homepage": "https://www.github.com/sebastianbergmann/exporter",
+            "keywords": [
+                "export",
+                "exporter"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/exporter/issues",
+                "security": "https://github.com/sebastianbergmann/exporter/security/policy",
+                "source": "https://github.com/sebastianbergmann/exporter/tree/6.3.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-09-24T06:12:51+00:00"
+        },
+        {
+            "name": "sebastian/global-state",
+            "version": "7.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/global-state.git",
+                "reference": "3be331570a721f9a4b5917f4209773de17f747d7"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/3be331570a721f9a4b5917f4209773de17f747d7",
+                "reference": "3be331570a721f9a4b5917f4209773de17f747d7",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "sebastian/object-reflector": "^4.0",
+                "sebastian/recursion-context": "^6.0"
+            },
+            "require-dev": {
+                "ext-dom": "*",
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "7.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Snapshotting of global state",
+            "homepage": "https://www.github.com/sebastianbergmann/global-state",
+            "keywords": [
+                "global state"
+            ],
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/global-state/issues",
+                "security": "https://github.com/sebastianbergmann/global-state/security/policy",
+                "source": "https://github.com/sebastianbergmann/global-state/tree/7.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T04:57:36+00:00"
+        },
+        {
+            "name": "sebastian/lines-of-code",
+            "version": "3.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/lines-of-code.git",
+                "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/d36ad0d782e5756913e42ad87cb2890f4ffe467a",
+                "reference": "d36ad0d782e5756913e42ad87cb2890f4ffe467a",
+                "shasum": ""
+            },
+            "require": {
+                "nikic/php-parser": "^5.0",
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "3.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library for counting the lines of code in PHP source code",
+            "homepage": "https://github.com/sebastianbergmann/lines-of-code",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/lines-of-code/issues",
+                "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy",
+                "source": "https://github.com/sebastianbergmann/lines-of-code/tree/3.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T04:58:38+00:00"
+        },
+        {
+            "name": "sebastian/object-enumerator",
+            "version": "6.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-enumerator.git",
+                "reference": "f5b498e631a74204185071eb41f33f38d64608aa"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/f5b498e631a74204185071eb41f33f38d64608aa",
+                "reference": "f5b498e631a74204185071eb41f33f38d64608aa",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "sebastian/object-reflector": "^4.0",
+                "sebastian/recursion-context": "^6.0"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Traverses array structures and object graphs to enumerate all referenced objects",
+            "homepage": "https://github.com/sebastianbergmann/object-enumerator/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-enumerator/issues",
+                "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy",
+                "source": "https://github.com/sebastianbergmann/object-enumerator/tree/6.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T05:00:13+00:00"
+        },
+        {
+            "name": "sebastian/object-reflector",
+            "version": "4.0.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/object-reflector.git",
+                "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/6e1a43b411b2ad34146dee7524cb13a068bb35f9",
+                "reference": "6e1a43b411b2ad34146dee7524cb13a068bb35f9",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "4.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                }
+            ],
+            "description": "Allows reflection of object attributes, including inherited and non-public ones",
+            "homepage": "https://github.com/sebastianbergmann/object-reflector/",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/object-reflector/issues",
+                "security": "https://github.com/sebastianbergmann/object-reflector/security/policy",
+                "source": "https://github.com/sebastianbergmann/object-reflector/tree/4.0.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-07-03T05:01:32+00:00"
+        },
+        {
+            "name": "sebastian/recursion-context",
+            "version": "6.0.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/recursion-context.git",
+                "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/f6458abbf32a6c8174f8f26261475dc133b3d9dc",
+                "reference": "f6458abbf32a6c8174f8f26261475dc133b3d9dc",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "6.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de"
+                },
+                {
+                    "name": "Jeff Welch",
+                    "email": "whatthejeff@gmail.com"
+                },
+                {
+                    "name": "Adam Harvey",
+                    "email": "aharvey@php.net"
+                }
+            ],
+            "description": "Provides functionality to recursively process PHP variables",
+            "homepage": "https://github.com/sebastianbergmann/recursion-context",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/recursion-context/issues",
+                "security": "https://github.com/sebastianbergmann/recursion-context/security/policy",
+                "source": "https://github.com/sebastianbergmann/recursion-context/tree/6.0.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-13T04:42:22+00:00"
+        },
+        {
+            "name": "sebastian/type",
+            "version": "5.1.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/type.git",
+                "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/f77d2d4e78738c98d9a68d2596fe5e8fa380f449",
+                "reference": "f77d2d4e78738c98d9a68d2596fe5e8fa380f449",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "phpunit/phpunit": "^11.3"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.1-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Collection of value objects that represent the types of the PHP type system",
+            "homepage": "https://github.com/sebastianbergmann/type",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/type/issues",
+                "security": "https://github.com/sebastianbergmann/type/security/policy",
+                "source": "https://github.com/sebastianbergmann/type/tree/5.1.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                },
+                {
+                    "url": "https://liberapay.com/sebastianbergmann",
+                    "type": "liberapay"
+                },
+                {
+                    "url": "https://thanks.dev/u/gh/sebastianbergmann",
+                    "type": "thanks_dev"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/sebastian/type",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-08-09T06:55:48+00:00"
+        },
+        {
+            "name": "sebastian/version",
+            "version": "5.0.2",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/sebastianbergmann/version.git",
+                "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/c687e3387b99f5b03b6caa64c74b63e2936ff874",
+                "reference": "c687e3387b99f5b03b6caa64c74b63e2936ff874",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-main": "5.0-dev"
+                }
+            },
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Sebastian Bergmann",
+                    "email": "sebastian@phpunit.de",
+                    "role": "lead"
+                }
+            ],
+            "description": "Library that helps with managing the version number of Git-hosted PHP projects",
+            "homepage": "https://github.com/sebastianbergmann/version",
+            "support": {
+                "issues": "https://github.com/sebastianbergmann/version/issues",
+                "security": "https://github.com/sebastianbergmann/version/security/policy",
+                "source": "https://github.com/sebastianbergmann/version/tree/5.0.2"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/sebastianbergmann",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-10-09T05:16:32+00:00"
+        },
+        {
+            "name": "staabm/side-effects-detector",
+            "version": "1.0.5",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/staabm/side-effects-detector.git",
+                "reference": "d8334211a140ce329c13726d4a715adbddd0a163"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/staabm/side-effects-detector/zipball/d8334211a140ce329c13726d4a715adbddd0a163",
+                "reference": "d8334211a140ce329c13726d4a715adbddd0a163",
+                "shasum": ""
+            },
+            "require": {
+                "ext-tokenizer": "*",
+                "php": "^7.4 || ^8.0"
+            },
+            "require-dev": {
+                "phpstan/extension-installer": "^1.4.3",
+                "phpstan/phpstan": "^1.12.6",
+                "phpunit/phpunit": "^9.6.21",
+                "symfony/var-dumper": "^5.4.43",
+                "tomasvotruba/type-coverage": "1.0.0",
+                "tomasvotruba/unused-public": "1.0.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "lib/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "description": "A static analysis tool to detect side effects in PHP code",
+            "keywords": [
+                "static analysis"
+            ],
+            "support": {
+                "issues": "https://github.com/staabm/side-effects-detector/issues",
+                "source": "https://github.com/staabm/side-effects-detector/tree/1.0.5"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/staabm",
+                    "type": "github"
+                }
+            ],
+            "time": "2024-10-20T05:08:20+00:00"
+        },
+        {
+            "name": "symfony/console",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/console.git",
+                "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/console/zipball/1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707",
+                "reference": "1e92e39c51f95b88e3d66fa2d9f06d1fb45dd707",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/deprecation-contracts": "^2.5|^3",
+                "symfony/polyfill-mbstring": "~1.0",
+                "symfony/service-contracts": "^2.5|^3",
+                "symfony/string": "^7.2|^8.0"
+            },
+            "conflict": {
+                "symfony/dependency-injection": "<6.4",
+                "symfony/dotenv": "<6.4",
+                "symfony/event-dispatcher": "<6.4",
+                "symfony/lock": "<6.4",
+                "symfony/process": "<6.4"
+            },
+            "provide": {
+                "psr/log-implementation": "1.0|2.0|3.0"
+            },
+            "require-dev": {
+                "psr/log": "^1|^2|^3",
+                "symfony/config": "^6.4|^7.0|^8.0",
+                "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+                "symfony/event-dispatcher": "^6.4|^7.0|^8.0",
+                "symfony/http-foundation": "^6.4|^7.0|^8.0",
+                "symfony/http-kernel": "^6.4|^7.0|^8.0",
+                "symfony/lock": "^6.4|^7.0|^8.0",
+                "symfony/messenger": "^6.4|^7.0|^8.0",
+                "symfony/process": "^6.4|^7.0|^8.0",
+                "symfony/stopwatch": "^6.4|^7.0|^8.0",
+                "symfony/var-dumper": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Console\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Eases the creation of beautiful and testable command line interfaces",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "cli",
+                "command-line",
+                "console",
+                "terminal"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/console/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-30T13:54:39+00:00"
+        },
+        {
+            "name": "symfony/event-dispatcher",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/event-dispatcher.git",
+                "reference": "f57b899fa736fd71121168ef268f23c206083f0a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher/zipball/f57b899fa736fd71121168ef268f23c206083f0a",
+                "reference": "f57b899fa736fd71121168ef268f23c206083f0a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/event-dispatcher-contracts": "^2.5|^3"
+            },
+            "conflict": {
+                "symfony/dependency-injection": "<6.4",
+                "symfony/service-contracts": "<2.5"
+            },
+            "provide": {
+                "psr/event-dispatcher-implementation": "1.0",
+                "symfony/event-dispatcher-implementation": "2.0|3.0"
+            },
+            "require-dev": {
+                "psr/log": "^1|^2|^3",
+                "symfony/config": "^6.4|^7.0|^8.0",
+                "symfony/dependency-injection": "^6.4|^7.0|^8.0",
+                "symfony/error-handler": "^6.4|^7.0|^8.0",
+                "symfony/expression-language": "^6.4|^7.0|^8.0",
+                "symfony/framework-bundle": "^6.4|^7.0|^8.0",
+                "symfony/http-foundation": "^6.4|^7.0|^8.0",
+                "symfony/service-contracts": "^2.5|^3",
+                "symfony/stopwatch": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\EventDispatcher\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides tools that allow your application components to communicate with each other by dispatching events and listening to them",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/event-dispatcher/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-30T13:54:39+00:00"
+        },
+        {
+            "name": "symfony/event-dispatcher-contracts",
+            "version": "v3.6.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/event-dispatcher-contracts.git",
+                "reference": "59eb412e93815df44f05f342958efa9f46b1e586"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/59eb412e93815df44f05f342958efa9f46b1e586",
+                "reference": "59eb412e93815df44f05f342958efa9f46b1e586",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "psr/event-dispatcher": "^1"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "3.6-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\EventDispatcher\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to dispatching event",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.6.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-25T14:21:43+00:00"
+        },
+        {
+            "name": "symfony/filesystem",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/filesystem.git",
+                "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/filesystem/zipball/58b9790d12f9670b7f53a1c1738febd3108970a5",
+                "reference": "58b9790d12f9670b7f53a1c1738febd3108970a5",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/polyfill-ctype": "~1.8",
+                "symfony/polyfill-mbstring": "~1.8"
+            },
+            "require-dev": {
+                "symfony/process": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Filesystem\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides basic utilities for the filesystem",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/filesystem/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        },
+        {
+            "name": "symfony/finder",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/finder.git",
+                "reference": "e0be088d22278583a82da281886e8c3592fbf149"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/finder/zipball/e0be088d22278583a82da281886e8c3592fbf149",
+                "reference": "e0be088d22278583a82da281886e8c3592fbf149",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "require-dev": {
+                "symfony/filesystem": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Finder\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Finds files and directories via an intuitive fluent interface",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/finder/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        },
+        {
+            "name": "symfony/options-resolver",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/options-resolver.git",
+                "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/options-resolver/zipball/2888fcdc4dc2fd5f7c7397be78631e8af12e02b4",
+                "reference": "2888fcdc4dc2fd5f7c7397be78631e8af12e02b4",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/deprecation-contracts": "^2.5|^3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\OptionsResolver\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides an improved replacement for the array_replace PHP function",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "config",
+                "configuration",
+                "options"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/options-resolver/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        },
+        {
+            "name": "symfony/polyfill-intl-grapheme",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-intl-grapheme.git",
+                "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/4864388bfbd3001ce88e234fab652acd91fdc57e",
+                "reference": "4864388bfbd3001ce88e234fab652acd91fdc57e",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "suggest": {
+                "ext-intl": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\Grapheme\\": ""
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for intl's grapheme_* functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "grapheme",
+                "intl",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-26T13:13:48+00:00"
+        },
+        {
+            "name": "symfony/polyfill-intl-normalizer",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-intl-normalizer.git",
+                "reference": "3833d7255cc303546435cb650316bff708a1c75c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/3833d7255cc303546435cb650316bff708a1c75c",
+                "reference": "3833d7255cc303546435cb650316bff708a1c75c",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "suggest": {
+                "ext-intl": "For best performance"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Intl\\Normalizer\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill for intl's Normalizer class and related functions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "intl",
+                "normalizer",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2024-09-09T11:45:10+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php80",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php80.git",
+                "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+                "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php80\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Ion Bazan",
+                    "email": "ion.bazan@gmail.com"
+                },
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php80/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-10T16:19:22+00:00"
+        },
+        {
+            "name": "symfony/polyfill-php84",
+            "version": "v1.37.0",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/polyfill-php84.git",
+                "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/polyfill-php84/zipball/88486db2c389b290bf87ff1de7ebc1e13e42bb06",
+                "reference": "88486db2c389b290bf87ff1de7ebc1e13e42bb06",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=7.2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/polyfill",
+                    "name": "symfony/polyfill"
+                }
+            },
+            "autoload": {
+                "files": [
+                    "bootstrap.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Polyfill\\Php84\\": ""
+                },
+                "classmap": [
+                    "Resources/stubs"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Symfony polyfill backporting some PHP 8.4+ features to lower PHP versions",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "compatibility",
+                "polyfill",
+                "portable",
+                "shim"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/polyfill-php84/tree/v1.37.0"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-04-10T18:47:49+00:00"
+        },
+        {
+            "name": "symfony/process",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/process.git",
+                "reference": "60f19cd3badc8de688421e21e4305eba50f8089a"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/process/zipball/60f19cd3badc8de688421e21e4305eba50f8089a",
+                "reference": "60f19cd3badc8de688421e21e4305eba50f8089a",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Process\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Executes commands in sub-processes",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/process/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        },
+        {
+            "name": "symfony/service-contracts",
+            "version": "v3.6.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/service-contracts.git",
+                "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/service-contracts/zipball/45112560a3ba2d715666a509a0bc9521d10b6c43",
+                "reference": "45112560a3ba2d715666a509a0bc9521d10b6c43",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.1",
+                "psr/container": "^1.1|^2.0",
+                "symfony/deprecation-contracts": "^2.5|^3"
+            },
+            "conflict": {
+                "ext-psr": "<1.1|>=2"
+            },
+            "type": "library",
+            "extra": {
+                "thanks": {
+                    "url": "https://github.com/symfony/contracts",
+                    "name": "symfony/contracts"
+                },
+                "branch-alias": {
+                    "dev-main": "3.6-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Contracts\\Service\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Test/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Generic abstractions related to writing services",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "abstractions",
+                "contracts",
+                "decoupling",
+                "interfaces",
+                "interoperability",
+                "standards"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/service-contracts/tree/v3.6.1"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-07-15T11:30:57+00:00"
+        },
+        {
+            "name": "symfony/stopwatch",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/stopwatch.git",
+                "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/stopwatch/zipball/70a852d72fec4d51efb1f48dcd968efcaf5ccb89",
+                "reference": "70a852d72fec4d51efb1f48dcd968efcaf5ccb89",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/service-contracts": "^2.5|^3"
+            },
+            "type": "library",
+            "autoload": {
+                "psr-4": {
+                    "Symfony\\Component\\Stopwatch\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Fabien Potencier",
+                    "email": "fabien@symfony.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides a way to profile code",
+            "homepage": "https://symfony.com",
+            "support": {
+                "source": "https://github.com/symfony/stopwatch/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        },
+        {
+            "name": "symfony/string",
+            "version": "v7.4.8",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/symfony/string.git",
+                "reference": "114ac57257d75df748eda23dd003878080b8e688"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/symfony/string/zipball/114ac57257d75df748eda23dd003878080b8e688",
+                "reference": "114ac57257d75df748eda23dd003878080b8e688",
+                "shasum": ""
+            },
+            "require": {
+                "php": ">=8.2",
+                "symfony/deprecation-contracts": "^2.5|^3.0",
+                "symfony/polyfill-ctype": "~1.8",
+                "symfony/polyfill-intl-grapheme": "~1.33",
+                "symfony/polyfill-intl-normalizer": "~1.0",
+                "symfony/polyfill-mbstring": "~1.0"
+            },
+            "conflict": {
+                "symfony/translation-contracts": "<2.5"
+            },
+            "require-dev": {
+                "symfony/emoji": "^7.1|^8.0",
+                "symfony/http-client": "^6.4|^7.0|^8.0",
+                "symfony/intl": "^6.4|^7.0|^8.0",
+                "symfony/translation-contracts": "^2.5|^3.0",
+                "symfony/var-exporter": "^6.4|^7.0|^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "files": [
+                    "Resources/functions.php"
+                ],
+                "psr-4": {
+                    "Symfony\\Component\\String\\": ""
+                },
+                "exclude-from-classmap": [
+                    "/Tests/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "MIT"
+            ],
+            "authors": [
+                {
+                    "name": "Nicolas Grekas",
+                    "email": "p@tchwork.com"
+                },
+                {
+                    "name": "Symfony Community",
+                    "homepage": "https://symfony.com/contributors"
+                }
+            ],
+            "description": "Provides an object-oriented API to strings and deals with bytes, UTF-8 code points and grapheme clusters in a unified way",
+            "homepage": "https://symfony.com",
+            "keywords": [
+                "grapheme",
+                "i18n",
+                "string",
+                "unicode",
+                "utf-8",
+                "utf8"
+            ],
+            "support": {
+                "source": "https://github.com/symfony/string/tree/v7.4.8"
+            },
+            "funding": [
+                {
+                    "url": "https://symfony.com/sponsor",
+                    "type": "custom"
+                },
+                {
+                    "url": "https://github.com/fabpot",
+                    "type": "github"
+                },
+                {
+                    "url": "https://github.com/nicolas-grekas",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2026-03-24T13:12:05+00:00"
+        },
+        {
+            "name": "theseer/tokenizer",
+            "version": "1.3.1",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/theseer/tokenizer.git",
+                "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+                "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
+                "shasum": ""
+            },
+            "require": {
+                "ext-dom": "*",
+                "ext-tokenizer": "*",
+                "ext-xmlwriter": "*",
+                "php": "^7.2 || ^8.0"
+            },
+            "type": "library",
+            "autoload": {
+                "classmap": [
+                    "src/"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Arne Blankerts",
+                    "email": "arne@blankerts.de",
+                    "role": "Developer"
+                }
+            ],
+            "description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
+            "support": {
+                "issues": "https://github.com/theseer/tokenizer/issues",
+                "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/theseer",
+                    "type": "github"
+                }
+            ],
+            "time": "2025-11-17T20:03:58+00:00"
+        },
+        {
+            "name": "vlucas/phpdotenv",
+            "version": "v5.6.3",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/vlucas/phpdotenv.git",
+                "reference": "955e7815d677a3eaa7075231212f2110983adecc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/vlucas/phpdotenv/zipball/955e7815d677a3eaa7075231212f2110983adecc",
+                "reference": "955e7815d677a3eaa7075231212f2110983adecc",
+                "shasum": ""
+            },
+            "require": {
+                "ext-pcre": "*",
+                "graham-campbell/result-type": "^1.1.4",
+                "php": "^7.2.5 || ^8.0",
+                "phpoption/phpoption": "^1.9.5",
+                "symfony/polyfill-ctype": "^1.26",
+                "symfony/polyfill-mbstring": "^1.26",
+                "symfony/polyfill-php80": "^1.26"
+            },
+            "require-dev": {
+                "bamarni/composer-bin-plugin": "^1.8.2",
+                "ext-filter": "*",
+                "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2"
+            },
+            "suggest": {
+                "ext-filter": "Required to use the boolean validator."
+            },
+            "type": "library",
+            "extra": {
+                "bamarni-bin": {
+                    "bin-links": true,
+                    "forward-command": false
+                },
+                "branch-alias": {
+                    "dev-master": "5.6-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Dotenv\\": "src/"
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Graham Campbell",
+                    "email": "hello@gjcampbell.co.uk",
+                    "homepage": "https://github.com/GrahamCampbell"
+                },
+                {
+                    "name": "Vance Lucas",
+                    "email": "vance@vancelucas.com",
+                    "homepage": "https://github.com/vlucas"
+                }
+            ],
+            "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.",
+            "keywords": [
+                "dotenv",
+                "env",
+                "environment"
+            ],
+            "support": {
+                "issues": "https://github.com/vlucas/phpdotenv/issues",
+                "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3"
+            },
+            "funding": [
+                {
+                    "url": "https://github.com/GrahamCampbell",
+                    "type": "github"
+                },
+                {
+                    "url": "https://tidelift.com/funding/github/packagist/vlucas/phpdotenv",
+                    "type": "tidelift"
+                }
+            ],
+            "time": "2025-12-27T19:49:13+00:00"
+        }
+    ],
+    "aliases": [],
+    "minimum-stability": "stable",
+    "stability-flags": {},
+    "prefer-stable": false,
+    "prefer-lowest": false,
+    "platform": {
+        "php": "^8.3"
+    },
+    "platform-dev": {},
+    "platform-overrides": {
+        "php": "8.3"
+    },
+    "plugin-api-version": "2.9.0"
+}

+ 14 - 0
ui/docker/Caddyfile

@@ -0,0 +1,14 @@
+# FrankenPHP Caddyfile for the ui container.
+# Serves Slim from public/ on :8080.
+{
+    frankenphp
+    order php_server before file_server
+    auto_https off
+    admin off
+}
+
+:8080 {
+    root * /app/public
+    encode zstd gzip
+    php_server
+}

+ 15 - 0
ui/docker/entrypoint.sh

@@ -0,0 +1,15 @@
+#!/bin/sh
+set -eu
+
+mode="${1:-ui}"
+
+case "$mode" in
+    ui)
+        exec frankenphp run --config /etc/Caddyfile
+        ;;
+    *)
+        echo "Unknown mode: $mode" >&2
+        echo "Usage: entrypoint.sh [ui]" >&2
+        exit 1
+        ;;
+esac

+ 1238 - 0
ui/package-lock.json

@@ -0,0 +1,1238 @@
+{
+  "name": "irdb-ui",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "irdb-ui",
+      "version": "0.1.0",
+      "dependencies": {
+        "alpinejs": "^3.13.0",
+        "htmx.org": "^1.9.0"
+      },
+      "devDependencies": {
+        "autoprefixer": "^10.4.0",
+        "postcss": "^8.4.0",
+        "tailwindcss": "^3.4.0"
+      }
+    },
+    "node_modules/@alloc/quick-lru": {
+      "version": "5.2.0",
+      "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
+      "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/@jridgewell/gen-mapping": {
+      "version": "0.3.13",
+      "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+      "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.0",
+        "@jridgewell/trace-mapping": "^0.3.24"
+      }
+    },
+    "node_modules/@jridgewell/resolve-uri": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+      "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "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/@jridgewell/trace-mapping": {
+      "version": "0.3.31",
+      "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+      "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/resolve-uri": "^3.1.0",
+        "@jridgewell/sourcemap-codec": "^1.4.14"
+      }
+    },
+    "node_modules/@nodelib/fs.scandir": {
+      "version": "2.1.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
+      "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.stat": "2.0.5",
+        "run-parallel": "^1.1.9"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.stat": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
+      "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@nodelib/fs.walk": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
+      "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.scandir": "2.1.5",
+        "fastq": "^1.6.0"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.1.5.tgz",
+      "integrity": "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.1.5"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.1.5",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.1.5.tgz",
+      "integrity": "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA==",
+      "license": "MIT"
+    },
+    "node_modules/alpinejs": {
+      "version": "3.15.11",
+      "resolved": "https://registry.npmjs.org/alpinejs/-/alpinejs-3.15.11.tgz",
+      "integrity": "sha512-m26gkTg/MId8O+F4jHKK3vB3SjbFxxk/JHP+qzmw1H6aQrZuPAg4CUoAefnASzzp/eNroBjrRQe7950bNeaBJw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "~3.1.1"
+      }
+    },
+    "node_modules/any-promise": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
+      "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/anymatch": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
+      "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "normalize-path": "^3.0.0",
+        "picomatch": "^2.0.4"
+      },
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/arg": {
+      "version": "5.0.2",
+      "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
+      "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/autoprefixer": {
+      "version": "10.5.0",
+      "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz",
+      "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/autoprefixer"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "browserslist": "^4.28.2",
+        "caniuse-lite": "^1.0.30001787",
+        "fraction.js": "^5.3.4",
+        "picocolors": "^1.1.1",
+        "postcss-value-parser": "^4.2.0"
+      },
+      "bin": {
+        "autoprefixer": "bin/autoprefixer"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      },
+      "peerDependencies": {
+        "postcss": "^8.1.0"
+      }
+    },
+    "node_modules/baseline-browser-mapping": {
+      "version": "2.10.23",
+      "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.23.tgz",
+      "integrity": "sha512-xwVXGqevyKPsiuQdLj+dZMVjidjJV508TBqexND5HrF89cGdCYCJFB3qhcxRHSeMctdCfbR1jrxBajhDy7o29g==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "baseline-browser-mapping": "dist/cli.cjs"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/binary-extensions": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
+      "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/browserslist": {
+      "version": "4.28.2",
+      "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz",
+      "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "baseline-browser-mapping": "^2.10.12",
+        "caniuse-lite": "^1.0.30001782",
+        "electron-to-chromium": "^1.5.328",
+        "node-releases": "^2.0.36",
+        "update-browserslist-db": "^1.2.3"
+      },
+      "bin": {
+        "browserslist": "cli.js"
+      },
+      "engines": {
+        "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+      }
+    },
+    "node_modules/camelcase-css": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz",
+      "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001791",
+      "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz",
+      "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/chokidar": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+      "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "anymatch": "~3.1.2",
+        "braces": "~3.0.2",
+        "glob-parent": "~5.1.2",
+        "is-binary-path": "~2.1.0",
+        "is-glob": "~4.0.1",
+        "normalize-path": "~3.0.0",
+        "readdirp": "~3.6.0"
+      },
+      "engines": {
+        "node": ">= 8.10.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/chokidar/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/commander": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz",
+      "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/cssesc": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
+      "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "cssesc": "bin/cssesc"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/didyoumean": {
+      "version": "1.2.2",
+      "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
+      "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/dlv": {
+      "version": "1.1.3",
+      "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz",
+      "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/electron-to-chromium": {
+      "version": "1.5.344",
+      "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.344.tgz",
+      "integrity": "sha512-4MxfbmNDm+KPh066EZy+eUnkcDPcZ35wNmOWzFuh/ijvHsve6kbLTLURy88uCNK5FbpN+yk2nQY6BYh1GEt+wg==",
+      "dev": true,
+      "license": "ISC"
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escalade": {
+      "version": "3.2.0",
+      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+      "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/fast-glob": {
+      "version": "3.3.3",
+      "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
+      "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@nodelib/fs.stat": "^2.0.2",
+        "@nodelib/fs.walk": "^1.2.3",
+        "glob-parent": "^5.1.2",
+        "merge2": "^1.3.0",
+        "micromatch": "^4.0.8"
+      },
+      "engines": {
+        "node": ">=8.6.0"
+      }
+    },
+    "node_modules/fast-glob/node_modules/glob-parent": {
+      "version": "5.1.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
+      "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fastq": {
+      "version": "1.20.1",
+      "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
+      "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "reusify": "^1.0.4"
+      }
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/fraction.js": {
+      "version": "5.3.4",
+      "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
+      "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "type": "github",
+        "url": "https://github.com/sponsors/rawify"
+      }
+    },
+    "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/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "dev": true,
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/glob-parent": {
+      "version": "6.0.2",
+      "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+      "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "is-glob": "^4.0.3"
+      },
+      "engines": {
+        "node": ">=10.13.0"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/htmx.org": {
+      "version": "1.9.12",
+      "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.9.12.tgz",
+      "integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==",
+      "license": "0BSD"
+    },
+    "node_modules/is-binary-path": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
+      "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "binary-extensions": "^2.0.0"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/is-core-module": {
+      "version": "2.16.1",
+      "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
+      "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/jiti": {
+      "version": "1.21.7",
+      "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
+      "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "jiti": "bin/jiti.js"
+      }
+    },
+    "node_modules/lilconfig": {
+      "version": "3.1.3",
+      "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
+      "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=14"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antonk52"
+      }
+    },
+    "node_modules/lines-and-columns": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
+      "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/merge2": {
+      "version": "1.4.1",
+      "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
+      "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 8"
+      }
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
+      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/mz": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz",
+      "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "any-promise": "^1.0.0",
+        "object-assign": "^4.0.1",
+        "thenify-all": "^1.0.0"
+      }
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "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/node-releases": {
+      "version": "2.0.38",
+      "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz",
+      "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/normalize-path": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
+      "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-hash": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
+      "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/path-parse": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",
+      "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
+      "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/picomatch": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
+      "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pify": {
+      "version": "2.3.0",
+      "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz",
+      "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/pirates": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
+      "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.12",
+      "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
+      "integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
+      "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.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/postcss-import": {
+      "version": "15.1.0",
+      "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz",
+      "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "postcss-value-parser": "^4.0.0",
+        "read-cache": "^1.0.0",
+        "resolve": "^1.1.7"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "peerDependencies": {
+        "postcss": "^8.0.0"
+      }
+    },
+    "node_modules/postcss-js": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz",
+      "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "camelcase-css": "^2.0.1"
+      },
+      "engines": {
+        "node": "^12 || ^14 || >= 16"
+      },
+      "peerDependencies": {
+        "postcss": "^8.4.21"
+      }
+    },
+    "node_modules/postcss-load-config": {
+      "version": "6.0.1",
+      "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+      "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "lilconfig": "^3.1.1"
+      },
+      "engines": {
+        "node": ">= 18"
+      },
+      "peerDependencies": {
+        "jiti": ">=1.21.0",
+        "postcss": ">=8.0.9",
+        "tsx": "^4.8.1",
+        "yaml": "^2.4.2"
+      },
+      "peerDependenciesMeta": {
+        "jiti": {
+          "optional": true
+        },
+        "postcss": {
+          "optional": true
+        },
+        "tsx": {
+          "optional": true
+        },
+        "yaml": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/postcss-nested": {
+      "version": "6.2.0",
+      "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz",
+      "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "postcss-selector-parser": "^6.1.1"
+      },
+      "engines": {
+        "node": ">=12.0"
+      },
+      "peerDependencies": {
+        "postcss": "^8.2.14"
+      }
+    },
+    "node_modules/postcss-selector-parser": {
+      "version": "6.1.2",
+      "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
+      "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "cssesc": "^3.0.0",
+        "util-deprecate": "^1.0.2"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postcss-value-parser": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz",
+      "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/queue-microtask": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
+      "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/read-cache": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
+      "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "pify": "^2.3.0"
+      }
+    },
+    "node_modules/readdirp": {
+      "version": "3.6.0",
+      "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
+      "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "picomatch": "^2.2.1"
+      },
+      "engines": {
+        "node": ">=8.10.0"
+      }
+    },
+    "node_modules/resolve": {
+      "version": "1.22.12",
+      "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
+      "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "is-core-module": "^2.16.1",
+        "path-parse": "^1.0.7",
+        "supports-preserve-symlinks-flag": "^1.0.0"
+      },
+      "bin": {
+        "resolve": "bin/resolve"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/reusify": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
+      "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "iojs": ">=1.0.0",
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/run-parallel": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
+      "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "queue-microtask": "^1.2.2"
+      }
+    },
+    "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/sucrase": {
+      "version": "3.35.1",
+      "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz",
+      "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/gen-mapping": "^0.3.2",
+        "commander": "^4.0.0",
+        "lines-and-columns": "^1.1.6",
+        "mz": "^2.7.0",
+        "pirates": "^4.0.1",
+        "tinyglobby": "^0.2.11",
+        "ts-interface-checker": "^0.1.9"
+      },
+      "bin": {
+        "sucrase": "bin/sucrase",
+        "sucrase-node": "bin/sucrase-node"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      }
+    },
+    "node_modules/supports-preserve-symlinks-flag": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
+      "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/tailwindcss": {
+      "version": "3.4.19",
+      "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
+      "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@alloc/quick-lru": "^5.2.0",
+        "arg": "^5.0.2",
+        "chokidar": "^3.6.0",
+        "didyoumean": "^1.2.2",
+        "dlv": "^1.1.3",
+        "fast-glob": "^3.3.2",
+        "glob-parent": "^6.0.2",
+        "is-glob": "^4.0.3",
+        "jiti": "^1.21.7",
+        "lilconfig": "^3.1.3",
+        "micromatch": "^4.0.8",
+        "normalize-path": "^3.0.0",
+        "object-hash": "^3.0.0",
+        "picocolors": "^1.1.1",
+        "postcss": "^8.4.47",
+        "postcss-import": "^15.1.0",
+        "postcss-js": "^4.0.1",
+        "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0",
+        "postcss-nested": "^6.2.0",
+        "postcss-selector-parser": "^6.1.2",
+        "resolve": "^1.22.8",
+        "sucrase": "^3.35.0"
+      },
+      "bin": {
+        "tailwind": "lib/cli.js",
+        "tailwindcss": "lib/cli.js"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      }
+    },
+    "node_modules/thenify": {
+      "version": "3.3.1",
+      "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz",
+      "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "any-promise": "^1.0.0"
+      }
+    },
+    "node_modules/thenify-all": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz",
+      "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "thenify": ">= 3.1.0 < 4"
+      },
+      "engines": {
+        "node": ">=0.8"
+      }
+    },
+    "node_modules/tinyglobby": {
+      "version": "0.2.16",
+      "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
+      "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "fdir": "^6.5.0",
+        "picomatch": "^4.0.4"
+      },
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/SuperchupuDev"
+      }
+    },
+    "node_modules/tinyglobby/node_modules/fdir": {
+      "version": "6.5.0",
+      "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+      "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12.0.0"
+      },
+      "peerDependencies": {
+        "picomatch": "^3 || ^4"
+      },
+      "peerDependenciesMeta": {
+        "picomatch": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/tinyglobby/node_modules/picomatch": {
+      "version": "4.0.4",
+      "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
+      "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/ts-interface-checker": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz",
+      "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
+      "dev": true,
+      "license": "Apache-2.0"
+    },
+    "node_modules/update-browserslist-db": {
+      "version": "1.2.3",
+      "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+      "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+      "dev": true,
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/browserslist"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "escalade": "^3.2.0",
+        "picocolors": "^1.1.1"
+      },
+      "bin": {
+        "update-browserslist-db": "cli.js"
+      },
+      "peerDependencies": {
+        "browserslist": ">= 4.21.0"
+      }
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "dev": true,
+      "license": "MIT"
+    }
+  }
+}

+ 19 - 0
ui/package.json

@@ -0,0 +1,19 @@
+{
+  "name": "irdb-ui",
+  "version": "0.1.0",
+  "private": true,
+  "description": "IRDB — frontend assets (Tailwind + Alpine + htmx)",
+  "scripts": {
+    "build": "tailwindcss -i resources/css/app.css -o public/assets/app.css --minify",
+    "watch": "tailwindcss -i resources/css/app.css -o public/assets/app.css --watch"
+  },
+  "devDependencies": {
+    "autoprefixer": "^10.4.0",
+    "postcss": "^8.4.0",
+    "tailwindcss": "^3.4.0"
+  },
+  "dependencies": {
+    "alpinejs": "^3.13.0",
+    "htmx.org": "^1.9.0"
+  }
+}

+ 5 - 0
ui/phpstan.neon

@@ -0,0 +1,5 @@
+parameters:
+    level: 8
+    paths:
+        - src
+    tmpDir: .phpstan.cache

+ 23 - 0
ui/phpunit.xml

@@ -0,0 +1,23 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
+         bootstrap="vendor/autoload.php"
+         colors="true"
+         cacheDirectory=".phpunit.cache"
+         executionOrder="depends,defects"
+         beStrictAboutOutputDuringTests="true"
+         failOnWarning="true">
+    <testsuites>
+        <testsuite name="Unit">
+            <directory>tests/Unit</directory>
+        </testsuite>
+        <testsuite name="Integration">
+            <directory>tests/Integration</directory>
+        </testsuite>
+    </testsuites>
+    <source>
+        <include>
+            <directory>src</directory>
+        </include>
+    </source>
+</phpunit>

+ 6 - 0
ui/postcss.config.js

@@ -0,0 +1,6 @@
+module.exports = {
+  plugins: {
+    tailwindcss: {},
+    autoprefixer: {},
+  },
+};

+ 0 - 0
ui/public/assets/.gitkeep


+ 70 - 0
ui/public/index.php

@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+use Monolog\Formatter\JsonFormatter;
+use Monolog\Handler\StreamHandler;
+use Monolog\Logger;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Slim\Factory\AppFactory;
+use Slim\Views\Twig;
+use Slim\Views\TwigMiddleware;
+
+require __DIR__ . '/../vendor/autoload.php';
+
+$appEnv = getenv('APP_ENV') ?: 'production';
+$logLevelName = strtoupper((string) (getenv('LOG_LEVEL') ?: 'info'));
+$logLevel = match ($logLevelName) {
+    'DEBUG' => Monolog\Level::Debug,
+    'NOTICE' => Monolog\Level::Notice,
+    'WARNING' => Monolog\Level::Warning,
+    'ERROR' => Monolog\Level::Error,
+    'CRITICAL' => Monolog\Level::Critical,
+    default => Monolog\Level::Info,
+};
+
+$logger = new Logger('ui');
+$handler = new StreamHandler('php://stdout', $logLevel);
+$handler->setFormatter(new JsonFormatter());
+$logger->pushHandler($handler);
+
+$app = AppFactory::create();
+$twig = Twig::create(__DIR__ . '/../resources/views', [
+    'cache' => false,
+    'auto_reload' => $appEnv === 'development',
+]);
+$app->add(TwigMiddleware::create($app, $twig));
+$app->addRoutingMiddleware();
+$app->addBodyParsingMiddleware();
+$app->addErrorMiddleware($appEnv === 'development', true, true, $logger);
+
+$app->get('/', function (Request $request, Response $response) use ($twig): Response {
+    return $twig->render($response, 'pages/hello.twig');
+});
+
+$app->get('/healthz', function (Request $request, Response $response): Response {
+    // Stub healthcheck. Later milestones extend with real api reachability checks.
+    $payload = [
+        'status' => 'ok',
+        'api_reachable' => null,
+        'last_api_check_at' => null,
+    ];
+    $response->getBody()->write((string) json_encode($payload));
+
+    return $response->withHeader('Content-Type', 'application/json');
+});
+
+$app->map(
+    ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
+    '/{routes:.+}',
+    function (Request $request, Response $response): Response {
+        $response->getBody()->write((string) json_encode(['error' => 'not_found']));
+
+        return $response
+            ->withHeader('Content-Type', 'application/json')
+            ->withStatus(404);
+    }
+);
+
+$app->run();

+ 3 - 0
ui/resources/css/app.css

@@ -0,0 +1,3 @@
+@tailwind base;
+@tailwind components;
+@tailwind utilities;

+ 5 - 0
ui/resources/js/app.js

@@ -0,0 +1,5 @@
+import Alpine from 'alpinejs';
+import 'htmx.org';
+
+window.Alpine = Alpine;
+Alpine.start();

+ 24 - 0
ui/resources/views/layout.twig

@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html lang="en" class="dark:bg-slate-900">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>{% block title %}IRDB{% endblock %}</title>
+    <link rel="stylesheet" href="/assets/app.css">
+</head>
+<body class="min-h-screen bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100">
+    <header class="border-b border-slate-200 dark:border-slate-800">
+        <div class="mx-auto flex max-w-5xl items-center justify-between px-6 py-4">
+            <span class="font-mono text-lg font-semibold">IRDB</span>
+            <button type="button"
+                    aria-label="Toggle dark mode"
+                    class="rounded-md border border-slate-300 px-3 py-1 text-sm dark:border-slate-700">
+                Toggle theme
+            </button>
+        </div>
+    </header>
+    <main class="mx-auto max-w-5xl px-6 py-10">
+        {% block content %}{% endblock %}
+    </main>
+</body>
+</html>

+ 10 - 0
ui/resources/views/pages/hello.twig

@@ -0,0 +1,10 @@
+{% extends 'layout.twig' %}
+
+{% block title %}IRDB UI — milestone 1{% endblock %}
+
+{% block content %}
+    <h1 class="text-2xl font-semibold">IRDB UI — milestone 1</h1>
+    <p class="mt-2 text-slate-600 dark:text-slate-400">
+        Skeleton frontend. Real pages land in later milestones.
+    </p>
+{% endblock %}

+ 0 - 0
ui/resources/views/partials/.gitkeep


+ 0 - 0
ui/src/ApiClient/DTOs/.gitkeep


+ 0 - 0
ui/src/App/.gitkeep


+ 0 - 0
ui/src/Auth/.gitkeep


+ 0 - 0
ui/src/Controllers/.gitkeep


+ 0 - 0
ui/src/Http/.gitkeep


+ 0 - 0
ui/src/Support/.gitkeep


+ 12 - 0
ui/tailwind.config.js

@@ -0,0 +1,12 @@
+/** @type {import('tailwindcss').Config} */
+module.exports = {
+  content: [
+    './resources/views/**/*.twig',
+    './resources/js/**/*.js',
+  ],
+  darkMode: 'class',
+  theme: {
+    extend: {},
+  },
+  plugins: [],
+};

+ 15 - 0
ui/tests/Unit/SmokeTest.php

@@ -0,0 +1,15 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+
+final class SmokeTest extends TestCase
+{
+    public function testTrueIsTrue(): void
+    {
+        $this->assertTrue(true);
+    }
+}