# 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
}
handle @internal {
    php
}
@external_internal_blocked {
    path /internal/*
    not remote_ip 127.0.0.1/32 ::1/128
}
respond @external_internal_blocked 404
```

The default loopback-only matcher (and the matching default in
`InternalNetworkMiddleware`) is the post-F25 hardening: trusting the
entire RFC1918 universe meant any docker-bridge neighbour could reach
`:8081` from a private IP and forge `REMOTE_ADDR` via XFF. The bundled
`compose.scheduler.yml` joins the api's network namespace
(`network_mode: "service:api"`) so its tick calls land on `127.0.0.1`;
host-cron / systemd timer deployments target `localhost`. Production
topologies that genuinely need extra source CIDRs list them in the
`INTERNAL_CIDR_ALLOWLIST` env var (PHP) AND mirror them into the
Caddyfile matcher above.

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.
- Dates and times render in the browser's locale (via `Intl.DateTimeFormat`). Templates emit ISO 8601 UTC inside `<time class="irdb-dt" datetime="…">…</time>`; a small client-side pass replaces the text content on load and after htmx swaps. Deployments can configure a `UI_LOCALE` BCP 47 fallback that's appended after the browser's preference.

### 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=

# Optional BCP 47 locale fallback for date/time formatting (e.g. de-CH,
# en-GB). Browser locale wins; this is the fallback when unsupported.
# Empty = browser-only.
UI_LOCALE=
```

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`)**

Built from `scheduler/Dockerfile` (digest-pinned alpine + pinned curl/tini
versions installed at build time, schedule baked into the image — see
SEC_REVIEW F22 for why the previous floating-tag + runtime-apk pattern
was retired).

```yaml
services:
  scheduler:
    image: irdb-scheduler:latest
    build: { context: ./scheduler }
    environment:
      INTERNAL_JOB_TOKEN: ${INTERNAL_JOB_TOKEN}
    # SEC_REVIEW F25: share the api's network namespace so the crontab
    # can hit /internal/jobs/tick over loopback. The api's /internal/*
    # gate is restricted to 127.0.0.1/::1 on both Caddy and PHP layers
    # to keep docker-bridge neighbours out.
    network_mode: "service:api"
    read_only: true
    tmpfs:
      - /run:mode=0755
      - /tmp:mode=1777
    security_opt:
      - no-new-privileges:true
    depends_on:
      api:
        condition: service_healthy
    restart: unless-stopped
```

`scheduler/scheduler.crontab` (baked into the image):
```cron
* * * * * curl -sf -m 280 -X POST -H "Authorization: Bearer $INTERNAL_JOB_TOKEN" http://localhost: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
│
├── scheduler/                       # ─────── scheduler sidecar ───────
│   ├── Dockerfile                   # alpine + curl + busybox crond, all pinned at build time
│   └── scheduler.crontab            # canonical schedule baked into the image
│
├── 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.

### Running locally

Composer/PHP are **not** installed on the host — every PHP-side command runs inside the prebuilt `irdb-api` / `irdb-ui` images, mounting the working tree at `/app` and bypassing the entrypoint with `--entrypoint php`.

```bash
# api
docker run --rm -v "$PWD/api":/app -w /app --entrypoint php irdb-api:latest vendor/bin/phpunit --exclude-group perf
docker run --rm -v "$PWD/api":/app -w /app --entrypoint php irdb-api:latest vendor/bin/phpstan analyse --memory-limit=512M
docker run --rm -v "$PWD/api":/app -w /app --entrypoint php irdb-api:latest vendor/bin/php-cs-fixer fix --dry-run --diff

# ui (same pattern, swap image + path)
docker run --rm -v "$PWD/ui":/app -w /app --entrypoint php irdb-ui:latest vendor/bin/phpunit
docker run --rm -v "$PWD/ui":/app -w /app --entrypoint php irdb-ui:latest vendor/bin/phpstan analyse --memory-limit=512M
docker run --rm -v "$PWD/ui":/app -w /app --entrypoint php irdb-ui:latest vendor/bin/php-cs-fixer fix --dry-run --diff
```

Filter to a single test with `--filter <ClassName>`. Pass `-d memory_limit=…` if a suite needs more headroom.

### Model selection for test/lint runs (Claude Code)

- **Switch to Sonnet 4.6 (`/model sonnet`) before running test suites, PHPStan, or PHP-CS-Fixer.** These are long, repetitive tool-call loops with cheap reasoning per step — Sonnet is the right tool for the job.
- **Switch back to Opus (`/model opus`) to diagnose failures, design fixes, or once verification is finished.** Opus is the right tool for understanding why a test failed and deciding what to change.

This is a workflow guideline, not a hard rule: if Sonnet gets stuck interpreting an error, escalate to Opus immediately rather than thrashing.

---

## 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`.