Browse Source

feat(M13): polish — OpenAPI, README, doc/, examples, e2e demo

- openapi.yaml served at /api/v1/openapi.yaml; /api/docs RapiDoc viewer
- README rewritten: quickstart, secrets, OIDC pointer, scheduler
  options, backups, MySQL, reverse-proxy
- doc/{architecture,api-overview,auth-flows,frontend-development,api-reference}.md
  per SPEC §16; doc/oidc.md merged into auth-flows.md and removed
- examples/{reporters,consumers,scheduler,reverse-proxy} with
  shellcheck-clean scripts (curl/python/fail2ban; iptables/nginx/haproxy;
  cron/systemd; Caddyfile)
- tests/e2e/demo.sh: clone-to-blocklist smoke check
- scripts/check-doc-endpoints.sh: doc-accuracy CI guard

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa 1 tuần trước cách đây
mục cha
commit
edcdeb3f2d

+ 86 - 0
PROGRESS.md

@@ -465,3 +465,89 @@ run against a clean compose stack.
 **Added dependencies:** none.
 
 **Added env vars:** none.
+
+## M13 — Polish, OpenAPI, docs (done)
+
+**Built:** OpenAPI document at `/api/v1/openapi.yaml` + RapiDoc viewer
+at `/api/docs`; new README with quickstart and operational guides;
+all five `doc/*.md` files per SPEC §16; `examples/` with reporter
+scripts (curl, python, fail2ban shim) + consumer scripts (iptables/ipset
+swap, nginx deny-include, HAProxy ACL with runtime-API path) +
+scheduler (host crontab, systemd unit + timer) + reverse-proxy
+Caddyfile; `tests/e2e/demo.sh` end-to-end smoke check;
+`scripts/check-doc-endpoints.sh` doc-accuracy CI guard.
+
+**Notes for next milestone:**
+- OpenAPI generation is **hand-curated**: source of truth is
+  `api/openapi.php`, generated YAML lives at
+  `api/public/openapi.yaml`, build via `composer openapi:build`.
+  Trade-off recorded inline at the top of `api/openapi.php`. The
+  recommended approach if drift becomes a problem is to switch to
+  `zircote/swagger-php` annotations on the controllers — adding a dep
+  and ~100 LOC of attributes, but turning the spec into a test of the
+  controllers.
+- Doc-accuracy CI guard: `./scripts/check-doc-endpoints.sh`. Run after
+  editing either side. Templatizes literal IDs, IPs, and category
+  slugs so `/api/v1/admin/ips/{ip}` in the spec covers
+  `/api/v1/admin/ips/203.0.113.42` in prose. Allowlist for known
+  doc-only paths (e.g. `/api/v1/openapi.yaml`, the
+  `/api/v1/auth/oauth/*` future-flow sketch) is at the top of the
+  script.
+- `examples/` scripts use the `IRDB_URL` and `IRDB_TOKEN` env vars
+  uniformly; this is the convention for the operator-facing tooling.
+  All shell scripts are shellcheck-clean.
+- The "future user-token flow" in `doc/auth-flows.md` (§ "Future:
+  direct user tokens") is the recommended extension point for SPA /
+  native / mobile UIs that don't want a BFF. Marked NOT IMPLEMENTED;
+  not in OpenAPI; allowlisted in the doc-endpoint checker.
+- `doc/oidc.md` (created in M08) was deleted — its content is now in
+  `doc/auth-flows.md` § "Entra setup walkthrough" with troubleshooting.
+- The `tests/e2e/demo.sh` runtime is gated on Docker availability;
+  it's documented as the operator-driven smoke check, not run as part
+  of `composer test`. CI integration is left for M14.
+
+**RapiDoc choice:** the viewer at `/api/docs` is RapiDoc 9.3.4
+loaded from `cdn.jsdelivr.net` (locked to a specific version). Smaller
+than Stoplight Elements, supports try-it-now and authentication, dark
+theme matches the rest of the UI palette. CDN is the only failure
+mode — an offline deployment would fail the viewer, but the YAML
+itself remains available at `/api/v1/openapi.yaml` for local tools.
+
+**OpenAPI scope:**
+- All Public endpoints (`/api/v1/report`, `/api/v1/blocklist`).
+- All Admin endpoints (`/api/v1/admin/*`), including audit, jobs
+  status/trigger, and config.
+- Auth endpoints (`/api/v1/auth/*`) marked `x-internal: true` with a
+  "UI BFF only" description.
+- `/internal/jobs/*` deliberately omitted per SPEC §M13.1 — private
+  contract, scheduler-only.
+
+The committed `api/public/openapi.yaml` validates clean against
+`redocly/cli lint`: 0 errors, 89 warnings (all stylistic — missing
+`example` blocks on schema fields, no `info.license.url`, etc.). The
+warnings are deferred to a future polish pass.
+
+**Schema:** none. The doc-accuracy guard added a new shell script
+under `scripts/`; no DB changes.
+
+**Tests:** 336 api / 78 ui pass; cs + stan clean on both. The
+e2e/demo.sh is shell-tested but not exercised end-to-end here (no
+Docker daemon at the host level during the milestone implementation
+phase). It's preserved for the next operator with a clean compose
+stack.
+
+**Deviations from SPEC:**
+- M13.7 mentions a CI job for openapi validation and doc-endpoint
+  accuracy. The `scripts/check-doc-endpoints.sh` script is in place;
+  the openapi-lint and demo.sh aren't yet wired into
+  `scripts/ci.sh` or a GitHub Actions workflow. CI integration
+  deferred to M14 hardening.
+- The `oidc-role-mappings` admin REST endpoints aren't built (M03
+  owns the schema/repository; the admin-UI for editing the table is
+  the M14 hardening item). Documentation no longer references the
+  REST path; both the README and `doc/auth-flows.md` describe the
+  SQL path with a note that the UI is forthcoming.
+
+**Added dependencies:** none.
+
+**Added env vars:** none.

+ 286 - 8
README.md

@@ -1,12 +1,266 @@
 # 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`).
+Self-hosted service that ingests abuse reports from many sources (web servers,
+IDS, fail2ban-like agents) and distributes tailored, decay-weighted block lists
+to firewalls and proxies. Ships as a Docker Compose stack — `api` (JSON
+backend), `ui` (PHP+Twig BFF), and optional `mysql` / `scheduler` sidecars.
 
-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).
+For who: ops engineers who run their own infra, want a single place to
+collect abuse signal across hosts, and need consumer-shaped output (one
+firewall = one tailored list).
+
+The full design is in [`SPEC.md`](./SPEC.md). Per-milestone progress is in
+[`PROGRESS.md`](./PROGRESS.md). Documentation for operators and future
+frontend authors lives in [`doc/`](./doc/).
+
+---
+
+## Quickstart (≈5 minutes)
+
+```bash
+git clone <this-repo> irdb && cd irdb
+cp .env.example .env
+
+# Generate secrets — see "Generating secrets" below for the exact commands.
+$EDITOR .env
+
+docker compose -f docker-compose.yml -f compose.scheduler.yml up -d
+```
+
+That's it. The UI is at `http://localhost:8080`, the api at
+`http://localhost:8081`, and the API reference viewer at
+`http://localhost:8081/api/docs`.
+
+Log in with the local admin credentials you set in `.env`
+(`LOCAL_ADMIN_USERNAME` / `LOCAL_ADMIN_PASSWORD_HASH`). OIDC works too —
+see [`doc/auth-flows.md`](./doc/auth-flows.md).
+
+---
+
+## Generating secrets
+
+Every value in `.env` marked with a comment "32-byte hex" or similar is a
+secret you need to generate. Use these one-liners:
+
+```bash
+# 32-byte hex (UI_SECRET, APP_SECRET, INTERNAL_JOB_TOKEN)
+openssl rand -hex 32
+
+# IRDB-format service token (UI_SERVICE_TOKEN — looks like irdb_svc_…)
+docker compose run --rm -T api php -r 'require "/app/vendor/autoload.php";
+    echo (new App\Domain\Auth\TokenIssuer())->issue(App\Domain\Auth\TokenKind::Service);'
+
+# Local admin password hash (LOCAL_ADMIN_PASSWORD_HASH — Argon2id)
+php -r "echo password_hash('your-admin-password', PASSWORD_ARGON2ID);"
+# Note: in your .env, double every $ in the hash to $$ so docker-compose
+# variable substitution doesn't eat it.
+```
+
+The api validates required env vars on boot; misconfiguration crashes
+`docker compose up` rather than the first user click.
+
+---
+
+## First-time setup — reporter and consumer
+
+Once the stack is up, log in to the UI as the local admin and:
+
+1. **Create a category** if the seeded ones don't fit. `Categories →
+   New`. Slugs are kebab-ish (`brute_force`, `web_attack`).
+2. **Create a reporter** at `Reporters → New`. Trust weight defaults to
+   `1.0`; lower it to dampen a noisy source.
+3. **Create a token for the reporter**: `Tokens → New`, kind = reporter,
+   pick the reporter you just made. **Copy the raw token now** — it is
+   shown once and never displayed again.
+
+Then post a report from the command line:
+
+```bash
+curl -X POST http://localhost:8081/api/v1/report \
+  -H "Authorization: Bearer irdb_rep_…" \
+  -H "Content-Type: application/json" \
+  -d '{"ip":"203.0.113.42","category":"brute_force","metadata":{"url":"/wp-login.php"}}'
+# → 202 {"report_id":1,"ip":"203.0.113.42","received_at":"…"}
+```
+
+For the distribution side: create a consumer (`Consumers → New`, pick a
+policy — `moderate` is a good default), create a consumer token, then
+pull the blocklist:
+
+```bash
+curl http://localhost:8081/api/v1/blocklist -H "Authorization: Bearer irdb_con_…"
+# → text/plain: one IP or CIDR per line
+```
+
+Add `?format=json` for richer per-entry data. Use the `ETag`
++ `If-None-Match` round-trip to skip retransfer if nothing changed.
+
+End-to-end examples for fail2ban, iptables-restore, nginx, and HAProxy
+are in [`examples/`](./examples/).
+
+---
+
+## Reverse proxy in production
+
+The default compose deployment exposes plain HTTP on `:8080` (UI) and
+`:8081` (api). For production, front them with Caddy / nginx / Traefik
+and route by hostname:
+
+```
+reputation.example.com       → ui:8080
+reputation-api.example.com   → api:8081
+```
+
+A working Caddy config is in
+[`examples/reverse-proxy/Caddyfile`](./examples/reverse-proxy/Caddyfile)
+— it terminates TLS via Let's Encrypt and forwards both hostnames.
+
+Single-hostname routing (everything under `reputation.example.com` with
+`/api/*` → api, `/*` → ui) is documented as an alternative in the
+example file.
+
+---
+
+## MySQL (optional)
+
+SQLite (default) is fine for single-host deployments. For networked
+storage or multi-replica `api` scaling, switch to MySQL:
+
+1. Uncomment the `mysql` service block in `docker-compose.yml`.
+2. Set `DB_DRIVER=mysql` and the `DB_MYSQL_*` vars in `.env`.
+3. `docker compose up -d`.
+
+The `migrate` container runs the same Phinx migrations against MySQL on
+boot. Phinx detects the adapter; the only schema-shape difference is
+adapter-aware `DATETIME(6)` vs SQLite `TEXT` for timestamps (handled
+in `BaseMigration`).
+
+> **Networked storage warning**: SQLite's WAL mode is unreliable on
+> NFS / SMB / EFS. If you use networked storage, use MySQL.
+
+---
+
+## OIDC (Microsoft Entra ID)
+
+Walkthrough in [`doc/auth-flows.md`](./doc/auth-flows.md), sections
+"Entra setup" and "OIDC configuration variables". Set
+`OIDC_*` vars in `.env`, restart the `ui` container, and the login page
+gains a "Sign in with Microsoft" button.
+
+Group → role mapping lives in the `oidc_role_mappings` table. Until
+the dedicated admin UI ships, populate it directly:
+
+```bash
+docker compose exec -T api sh -c \
+  "sqlite3 /data/irdb.sqlite \\
+    \"INSERT INTO oidc_role_mappings(group_id, role) VALUES('<entra-group-id>', 'admin');\""
+```
+
+Default role for unmapped users is `viewer`; set
+`OIDC_DEFAULT_ROLE=none` in `.env` to deny logins instead.
+
+---
+
+## Scheduling
+
+Periodic jobs (recompute scores, refresh GeoIP, prune audit log,
+enrich pending IPs) are exposed at `/internal/jobs/*` on the api. Three
+ways to drive them:
+
+**Sidecar (default in compose.scheduler.yml)** — busybox `crond` posts
+to the api once a minute. No host setup required. Started by:
+
+```bash
+docker compose -f docker-compose.yml -f compose.scheduler.yml up -d
+```
+
+**Host cron** — install
+[`examples/scheduler/host.crontab`](./examples/scheduler/host.crontab)
+into the system crontab. Suitable when you don't want a sidecar.
+
+**systemd timer** — install
+[`examples/scheduler/irdb-tick.service`](./examples/scheduler/irdb-tick.service)
+and
+[`examples/scheduler/irdb-tick.timer`](./examples/scheduler/irdb-tick.timer)
+into `/etc/systemd/system`, then `systemctl enable --now irdb-tick.timer`.
+
+All three drive the same `/internal/jobs/tick` endpoint, which is the
+dispatcher: it asks `job_runs` what's due and invokes those jobs in
+turn. The endpoint is bound to RFC1918 + loopback only (Caddyfile
+config in `api/docker/Caddyfile`); external requests get `404`.
+
+---
+
+## Backups
+
+The api's persistent state lives in one of two places:
+
+**SQLite (default)**: the `irdb-data` Docker volume holds
+`/data/irdb.sqlite`. Back it up with:
+
+```bash
+docker compose exec api sh -c \
+  'sqlite3 /data/irdb.sqlite ".backup /data/irdb-backup.sqlite"'
+docker compose cp api:/data/irdb-backup.sqlite ./irdb-backup-$(date +%F).sqlite
+```
+
+The `.backup` SQLite command is online-safe and quiesces WAL.
+
+**MySQL**: `mysqldump --single-transaction` against the `mysql`
+container. The schema is small (under 20 tables); a multi-GB dump is a
+red flag — the `audit_log` and `reports` tables are the only ones that
+grow with use, and `cleanup-audit` + the `score_hard_cutoff_days`
+horizon bound them.
+
+Restore: `docker compose down -v` (drops the volume), restore the file
+into a fresh volume, `docker compose up -d`. The `migrate` container
+runs idempotently so repeating it after a restore is safe.
+
+GeoIP DBs (`/data/geoip/*.mmdb`) don't need backup — the
+`refresh-geoip` job repopulates them on the next schedule, and they're
+purely a cache.
+
+---
+
+## Architecture
+
+Three containers (`api`, `ui`, `migrate`) plus optional `mysql` and
+`scheduler` sidecars. The split is a **BFF pattern**: `api` is the
+JSON backend (owns the database, business logic, RBAC); `ui` is the
+browser-facing PHP+Twig frontend that holds sessions and forwards
+calls with a service token + impersonation header.
+
+Full diagram + rationale in [`doc/architecture.md`](./doc/architecture.md).
+
+---
+
+## API contract
+
+The OpenAPI document is the source of truth: visit
+**`http://localhost:8081/api/docs`** for the interactive viewer, or
+fetch the YAML at `/api/v1/openapi.yaml`.
+
+Higher-level prose (token kinds, auth flows, common conventions) lives
+in [`doc/api-overview.md`](./doc/api-overview.md). For machine clients
+specifically, [`examples/`](./examples/) has copy-paste shell + Python
+scripts for both reporters and consumers.
+
+---
+
+## Replacing the UI
+
+The PHP+Twig UI is **deliberately replaceable**. The api's contract,
+auth model, and token kinds are stable; the UI is one of several
+possible frontends (Vue, native desktop, mobile clients are
+explicitly anticipated).
+
+If you're building a replacement, start at
+[`doc/frontend-development.md`](./doc/frontend-development.md). It
+describes the three integration patterns (BFF replacement, SPA + thin
+BFF, direct API), the minimum API surface a fully-featured UI uses,
+and what NOT to do.
+
+---
 
 ## Local CI
 
@@ -14,5 +268,29 @@ Per-milestone progress is tracked in [`PROGRESS.md`](./PROGRESS.md).
 ./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.
+Runs cs/stan/test for both subprojects and verifies the docker compose
+images build. Requires Docker; no PHP/Node toolchain needed on the host.
+
+```bash
+./scripts/check-doc-endpoints.sh
+```
+
+Doc accuracy guard: greps `doc/*.md` for `/api/v1/*` paths, fails if any
+mentioned path is not in `api/public/openapi.yaml`. Run after editing
+either side.
+
+```bash
+./tests/e2e/demo.sh
+```
+
+End-to-end smoke check — boots compose, creates a reporter+consumer
++tokens, posts a report, pulls the blocklist, and tears down. Mirrors
+the quickstart documented above.
+
+---
+
+## License
+
+TBD. The repository's existing code is provided "as-is" pending a
+final licensing decision; treat it as proprietary until that's
+resolved.

+ 2 - 1
api/composer.json

@@ -45,6 +45,7 @@
         "test-perf": "@php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=64M -d opcache.jit=tracing vendor/bin/phpunit --group perf",
         "stan": "phpstan analyse --memory-limit=512M",
         "cs": "php-cs-fixer fix --dry-run --diff",
-        "cs-fix": "php-cs-fixer fix"
+        "cs-fix": "php-cs-fixer fix",
+        "openapi:build": "@php openapi.php > public/openapi.yaml"
     }
 }

+ 739 - 0
api/openapi.php

@@ -0,0 +1,739 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Hand-curated OpenAPI 3.0 spec for IRDB.
+ *
+ * Run: `composer openapi:build` (or `php api/openapi.php > api/public/openapi.yaml`).
+ * The committed `api/public/openapi.yaml` MUST match this file's output —
+ * the doc-accuracy CI guard (`scripts/check-doc-endpoints.sh`) treats the
+ * generated spec as the source of truth for which endpoints exist.
+ *
+ * Why hand-curated and not annotation-based: keeps the API surface in one
+ * place where it can be reviewed in PR diffs, avoids pulling
+ * zircote/swagger-php into prod deps, and the static array fits in <500
+ * lines with room for descriptions. Trade-off: drift between code and
+ * spec is possible — the integration tests assert response shapes
+ * separately, and CI runs `redocly lint` against the YAML to catch
+ * malformed entries.
+ *
+ * Internal endpoints (`/internal/jobs/*`) are deliberately omitted per
+ * SPEC §M13.1; they're scheduler-only and not part of the public
+ * contract.
+ */
+
+require_once __DIR__ . '/vendor/autoload.php';
+
+$errorEnvelope = [
+    'type' => 'object',
+    'required' => ['error'],
+    'properties' => [
+        'error' => ['type' => 'string', 'example' => 'unauthorized'],
+        'details' => [
+            'type' => 'object',
+            'description' => 'Field-level errors for `validation_failed`.',
+            'additionalProperties' => ['type' => 'string'],
+        ],
+    ],
+];
+
+$pageMeta = [
+    'page' => ['type' => 'integer', 'minimum' => 1, 'example' => 1],
+    'page_size' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200, 'example' => 50],
+    'total' => ['type' => 'integer', 'minimum' => 0, 'example' => 1284],
+];
+
+$components = [
+    'securitySchemes' => [
+        'BearerAuth' => [
+            'type' => 'http',
+            'scheme' => 'bearer',
+            'description' => "Token in the form `irdb_<kind>_<32 base32 chars>`.\n"
+                . 'See `doc/auth-flows.md` for the four token kinds.',
+        ],
+    ],
+    'parameters' => [
+        'Page' => [
+            'name' => 'page',
+            'in' => 'query',
+            'schema' => ['type' => 'integer', 'minimum' => 1, 'default' => 1],
+        ],
+        'PageSize' => [
+            'name' => 'page_size',
+            'in' => 'query',
+            'schema' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 200, 'default' => 50],
+        ],
+        'ActingUserId' => [
+            'name' => 'X-Acting-User-Id',
+            'in' => 'header',
+            'description' => "Required when authenticating with a `service` token.\n"
+                . 'The api applies RBAC for the named user. Ignored on other token kinds.',
+            'schema' => ['type' => 'integer'],
+        ],
+    ],
+    'schemas' => [
+        'Error' => $errorEnvelope,
+        'ReportRequest' => [
+            'type' => 'object',
+            'required' => ['ip', 'category'],
+            'properties' => [
+                'ip' => ['type' => 'string', 'example' => '203.0.113.42'],
+                'category' => ['type' => 'string', 'example' => 'brute_force'],
+                'metadata' => [
+                    'type' => 'object',
+                    'description' => 'Free-form per-report data, max 4 KB after json_encode.',
+                    'additionalProperties' => true,
+                ],
+            ],
+        ],
+        'ReportResponse' => [
+            'type' => 'object',
+            'properties' => [
+                'report_id' => ['type' => 'integer', 'example' => 12345],
+                'ip' => ['type' => 'string', 'example' => '203.0.113.42'],
+                'received_at' => ['type' => 'string', 'format' => 'date-time'],
+            ],
+        ],
+        'BlocklistEntry' => [
+            'type' => 'object',
+            'properties' => [
+                'ip_or_cidr' => ['type' => 'string', 'example' => '203.0.113.42'],
+                'categories' => ['type' => 'array', 'items' => ['type' => 'string']],
+                'score' => ['type' => 'number', 'format' => 'float', 'example' => 1.42],
+                'reason' => ['type' => 'string', 'enum' => ['scored', 'manual'], 'example' => 'scored'],
+            ],
+        ],
+        'BlocklistJson' => [
+            'type' => 'object',
+            'properties' => [
+                'count' => ['type' => 'integer', 'example' => 42],
+                'generated_at' => ['type' => 'string', 'format' => 'date-time'],
+                'policy' => ['type' => 'string', 'example' => 'moderate'],
+                'entries' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/BlocklistEntry']],
+            ],
+        ],
+        'Token' => [
+            'type' => 'object',
+            'properties' => [
+                'id' => ['type' => 'integer'],
+                'kind' => ['type' => 'string', 'enum' => ['reporter', 'consumer', 'admin']],
+                'prefix' => ['type' => 'string', 'example' => 'irdb_adm'],
+                'reporter_id' => ['type' => 'integer', 'nullable' => true],
+                'consumer_id' => ['type' => 'integer', 'nullable' => true],
+                'role' => ['type' => 'string', 'nullable' => true, 'enum' => ['viewer', 'operator', 'admin', null]],
+                'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
+                'revoked_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
+                'last_used_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
+            ],
+        ],
+        'TokenCreated' => [
+            'allOf' => [
+                ['$ref' => '#/components/schemas/Token'],
+                [
+                    'type' => 'object',
+                    'properties' => [
+                        'raw_token' => [
+                            'type' => 'string',
+                            'description' => 'Returned ONCE on creation — copy it now, never displayed again.',
+                            'example' => 'irdb_adm_AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD',
+                        ],
+                    ],
+                ],
+            ],
+        ],
+        'Reporter' => [
+            'type' => 'object',
+            'properties' => [
+                'id' => ['type' => 'integer'],
+                'name' => ['type' => 'string', 'example' => 'web-prod-01'],
+                'description' => ['type' => 'string', 'nullable' => true],
+                'trust_weight' => ['type' => 'number', 'format' => 'float', 'minimum' => 0.0, 'maximum' => 2.0],
+                'is_active' => ['type' => 'boolean'],
+            ],
+        ],
+        'Consumer' => [
+            'type' => 'object',
+            'properties' => [
+                'id' => ['type' => 'integer'],
+                'name' => ['type' => 'string', 'example' => 'edge-fw-01'],
+                'description' => ['type' => 'string', 'nullable' => true],
+                'policy_id' => ['type' => 'integer'],
+                'is_active' => ['type' => 'boolean'],
+                'last_pulled_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
+            ],
+        ],
+        'Category' => [
+            'type' => 'object',
+            'properties' => [
+                'id' => ['type' => 'integer'],
+                'slug' => ['type' => 'string', 'example' => 'brute_force'],
+                'name' => ['type' => 'string'],
+                'description' => ['type' => 'string', 'nullable' => true],
+                'decay_function' => ['type' => 'string', 'enum' => ['linear', 'exponential']],
+                'decay_param' => ['type' => 'number', 'format' => 'float'],
+                'is_active' => ['type' => 'boolean'],
+            ],
+        ],
+        'Policy' => [
+            'type' => 'object',
+            'properties' => [
+                'id' => ['type' => 'integer'],
+                'name' => ['type' => 'string', 'example' => 'moderate'],
+                'description' => ['type' => 'string', 'nullable' => true],
+                'include_manual_blocks' => ['type' => 'boolean'],
+                'thresholds' => [
+                    'type' => 'object',
+                    'description' => '`{category_slug: threshold}`',
+                    'additionalProperties' => ['type' => 'number', 'format' => 'float'],
+                ],
+            ],
+        ],
+        'ManualBlock' => [
+            'type' => 'object',
+            'properties' => [
+                'id' => ['type' => 'integer'],
+                'kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']],
+                'ip' => ['type' => 'string', 'nullable' => true],
+                'cidr' => ['type' => 'string', 'nullable' => true],
+                'reason' => ['type' => 'string', 'nullable' => true],
+                'expires_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
+                'created_at' => ['type' => 'string', 'format' => 'date-time'],
+            ],
+        ],
+        'AllowlistEntry' => [
+            'type' => 'object',
+            'properties' => [
+                'id' => ['type' => 'integer'],
+                'kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']],
+                'ip' => ['type' => 'string', 'nullable' => true],
+                'cidr' => ['type' => 'string', 'nullable' => true],
+                'reason' => ['type' => 'string', 'nullable' => true],
+                'created_at' => ['type' => 'string', 'format' => 'date-time'],
+            ],
+        ],
+        'IpDetail' => [
+            'type' => 'object',
+            'properties' => [
+                'ip' => ['type' => 'string'],
+                'is_ipv4' => ['type' => 'boolean'],
+                'scores' => [
+                    'type' => 'array',
+                    'items' => [
+                        'type' => 'object',
+                        'properties' => [
+                            'category' => ['type' => 'string'],
+                            'score' => ['type' => 'number', 'format' => 'float'],
+                            'last_report_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
+                            'report_count_30d' => ['type' => 'integer'],
+                        ],
+                    ],
+                ],
+                'enrichment' => [
+                    'type' => 'object',
+                    'properties' => [
+                        'country_code' => ['type' => 'string', 'nullable' => true],
+                        'asn' => ['type' => 'integer', 'nullable' => true],
+                        'as_org' => ['type' => 'string', 'nullable' => true],
+                        'enriched_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
+                    ],
+                ],
+                'status' => ['type' => 'string', 'enum' => ['scored', 'manually_blocked', 'allowlisted', 'clean']],
+                'manual_block' => ['type' => 'object', 'nullable' => true],
+                'allowlist' => ['type' => 'object', 'nullable' => true],
+                'history' => ['type' => 'array', 'items' => ['type' => 'object']],
+                'has_more' => ['type' => 'boolean'],
+            ],
+        ],
+        'AuditEntry' => [
+            'type' => 'object',
+            'properties' => [
+                'id' => ['type' => 'integer'],
+                'occurred_at' => ['type' => 'string', 'format' => 'date-time'],
+                'actor_kind' => ['type' => 'string', 'enum' => ['user', 'admin-token', 'reporter', 'consumer', 'system']],
+                'actor_id' => ['type' => 'string', 'nullable' => true],
+                'action' => ['type' => 'string', 'example' => 'manual_block.created'],
+                'entity_type' => ['type' => 'string', 'nullable' => true],
+                'entity_id' => ['type' => 'string', 'nullable' => true],
+                'details' => ['type' => 'object', 'nullable' => true, 'additionalProperties' => true],
+                'source_ip' => ['type' => 'string', 'nullable' => true],
+            ],
+        ],
+        'JobStatus' => [
+            'type' => 'object',
+            'properties' => [
+                'name' => ['type' => 'string'],
+                'default_interval_seconds' => ['type' => 'integer'],
+                'max_runtime_seconds' => ['type' => 'integer'],
+                'overdue' => ['type' => 'boolean'],
+                'last_run' => [
+                    'type' => 'object',
+                    'nullable' => true,
+                    'properties' => [
+                        'id' => ['type' => 'integer'],
+                        'status' => ['type' => 'string', 'enum' => ['success', 'failure', 'skipped_locked', 'running']],
+                        'items_processed' => ['type' => 'integer'],
+                        'triggered_by' => ['type' => 'string', 'enum' => ['schedule', 'manual', 'api']],
+                        'started_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
+                        'finished_at' => ['type' => 'string', 'format' => 'date-time', 'nullable' => true],
+                        'error_message' => ['type' => 'string', 'nullable' => true],
+                    ],
+                ],
+            ],
+        ],
+        'JobOutcome' => [
+            'type' => 'object',
+            'properties' => [
+                'job' => ['type' => 'string'],
+                'status' => ['type' => 'string', 'enum' => ['success', 'failure', 'skipped_locked', 'running']],
+                'items_processed' => ['type' => 'integer'],
+                'duration_ms' => ['type' => 'integer'],
+                'run_id' => ['type' => 'integer', 'nullable' => true],
+                'error' => ['type' => 'string', 'nullable' => true],
+            ],
+        ],
+        'User' => [
+            'type' => 'object',
+            'properties' => [
+                'id' => ['type' => 'integer'],
+                'email' => ['type' => 'string', 'nullable' => true],
+                'display_name' => ['type' => 'string'],
+                'role' => ['type' => 'string', 'enum' => ['viewer', 'operator', 'admin']],
+                'source' => ['type' => 'string', 'enum' => ['oidc', 'local', 'admin-token']],
+                'is_local' => ['type' => 'boolean'],
+            ],
+        ],
+        'Pagination' => [
+            'type' => 'object',
+            'properties' => $pageMeta,
+        ],
+    ],
+];
+
+$paths = [
+    // ---------- Public ----------
+    '/api/v1/report' => [
+        'post' => [
+            'tags' => ['Public'],
+            'summary' => 'Submit an abuse report',
+            'description' => "Token kind: `reporter`. Rate limit: 60 req/s per token (configurable).\n"
+                . 'Returns `202 Accepted` on success.',
+            'security' => [['BearerAuth' => []]],
+            'requestBody' => [
+                'required' => true,
+                'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportRequest']]],
+            ],
+            'responses' => [
+                '202' => [
+                    'description' => 'Report accepted',
+                    'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ReportResponse']]],
+                ],
+                '400' => ['description' => 'Validation failed', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]]],
+                '401' => ['description' => 'Bad / wrong-kind token', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]]],
+                '429' => [
+                    'description' => 'Rate limited',
+                    'headers' => ['Retry-After' => ['schema' => ['type' => 'integer'], 'description' => 'Seconds to wait before retrying.']],
+                    'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Error']]],
+                ],
+            ],
+        ],
+    ],
+    '/api/v1/blocklist' => [
+        'get' => [
+            'tags' => ['Public'],
+            'summary' => 'Pull a tailored blocklist',
+            'description' => "Token kind: `consumer`. The consumer's bound policy decides which IPs/CIDRs land in the output.\n"
+                . "Cached internally for 30 s per consumer. Honour `If-None-Match` to skip retransfer.\n"
+                . "`?format=json` returns structured rows; default is `text/plain`, one entry per line.",
+            'security' => [['BearerAuth' => []]],
+            'parameters' => [
+                ['name' => 'format', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['text', 'json'], 'default' => 'text']],
+                ['name' => 'If-None-Match', 'in' => 'header', 'schema' => ['type' => 'string']],
+            ],
+            'responses' => [
+                '200' => [
+                    'description' => 'Current blocklist',
+                    'headers' => [
+                        'ETag' => ['schema' => ['type' => 'string']],
+                        'X-Blocklist-Generated-At' => ['schema' => ['type' => 'string', 'format' => 'date-time']],
+                        'X-Blocklist-Entries' => ['schema' => ['type' => 'integer']],
+                        'X-Blocklist-Policy' => ['schema' => ['type' => 'string']],
+                    ],
+                    'content' => [
+                        'text/plain' => ['schema' => ['type' => 'string', 'example' => "203.0.113.42\n198.51.100.0/24\n"]],
+                        'application/json' => ['schema' => ['$ref' => '#/components/schemas/BlocklistJson']],
+                    ],
+                ],
+                '304' => ['description' => 'Not modified — body matches `If-None-Match`'],
+                '401' => ['description' => 'Bad / wrong-kind token'],
+            ],
+        ],
+    ],
+
+    // ---------- Admin: identity ----------
+    '/api/v1/admin/me' => [
+        'get' => [
+            'tags' => ['Admin'],
+            'summary' => 'Current acting identity',
+            'security' => [['BearerAuth' => []]],
+            'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
+            'responses' => [
+                '200' => ['description' => 'Identity info', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]],
+            ],
+        ],
+    ],
+
+    // ---------- Admin: IPs ----------
+    '/api/v1/admin/ips' => [
+        'get' => [
+            'tags' => ['Admin'],
+            'summary' => 'Search IPs',
+            'security' => [['BearerAuth' => []]],
+            'parameters' => [
+                ['$ref' => '#/components/parameters/ActingUserId'],
+                ['name' => 'q', 'in' => 'query', 'schema' => ['type' => 'string']],
+                ['name' => 'category', 'in' => 'query', 'schema' => ['type' => 'string']],
+                ['name' => 'min_score', 'in' => 'query', 'schema' => ['type' => 'number', 'format' => 'float']],
+                ['name' => 'max_score', 'in' => 'query', 'schema' => ['type' => 'number', 'format' => 'float']],
+                ['name' => 'country', 'in' => 'query', 'schema' => ['type' => 'string']],
+                ['name' => 'asn', 'in' => 'query', 'schema' => ['type' => 'integer']],
+                ['name' => 'status', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['scored', 'manual', 'allowlisted', 'clean']]],
+                ['$ref' => '#/components/parameters/Page'],
+                ['$ref' => '#/components/parameters/PageSize'],
+            ],
+            'responses' => ['200' => ['description' => 'Page of IPs', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => array_merge($pageMeta, ['items' => ['type' => 'array', 'items' => ['type' => 'object']]])]]]]],
+        ],
+    ],
+    '/api/v1/admin/ips/countries' => [
+        'get' => [
+            'tags' => ['Admin'],
+            'summary' => 'Country code dropdown source',
+            'security' => [['BearerAuth' => []]],
+            'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
+            'responses' => ['200' => ['description' => '`[{code, count}]`', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['items' => ['type' => 'array', 'items' => ['type' => 'object', 'properties' => ['code' => ['type' => 'string'], 'count' => ['type' => 'integer']]]]]]]]]],
+        ],
+    ],
+    '/api/v1/admin/ips/{ip}' => [
+        'get' => [
+            'tags' => ['Admin'],
+            'summary' => 'IP detail',
+            'security' => [['BearerAuth' => []]],
+            'parameters' => [
+                ['$ref' => '#/components/parameters/ActingUserId'],
+                ['name' => 'ip', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']],
+            ],
+            'responses' => [
+                '200' => ['description' => 'IP detail', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/IpDetail']]]],
+                '404' => ['description' => 'Invalid IP / not found'],
+            ],
+        ],
+    ],
+
+    // ---------- Admin: stats / dashboard ----------
+    '/api/v1/admin/stats/dashboard' => [
+        'get' => [
+            'tags' => ['Admin'],
+            'summary' => 'Dashboard stats (30s cached)',
+            'security' => [['BearerAuth' => []]],
+            'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
+            'responses' => ['200' => ['description' => 'Dashboard payload', 'content' => ['application/json' => ['schema' => ['type' => 'object']]]]],
+        ],
+    ],
+
+    // ---------- Admin: manual blocks / allowlist ----------
+    '/api/v1/admin/manual-blocks' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'List manual blocks', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
+        'post' => [
+            'tags' => ['Admin'], 'summary' => 'Create manual block',
+            'description' => 'Operator+ role required. `kind=ip` requires `ip`; `kind=subnet` requires `cidr`.',
+            'security' => [['BearerAuth' => []]],
+            'parameters' => [['$ref' => '#/components/parameters/ActingUserId']],
+            'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']], 'ip' => ['type' => 'string'], 'cidr' => ['type' => 'string'], 'reason' => ['type' => 'string'], 'expires_at' => ['type' => 'string', 'format' => 'date-time']]]]]],
+            'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ManualBlock']]]]],
+        ],
+    ],
+    '/api/v1/admin/manual-blocks/{id}' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'Show manual block', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'manual block', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/ManualBlock']]]]]],
+        'delete' => ['tags' => ['Admin'], 'summary' => 'Delete manual block', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted']]],
+    ],
+    '/api/v1/admin/allowlist' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'List allowlist', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
+        'post' => ['tags' => ['Admin'], 'summary' => 'Create allowlist entry', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['ip', 'subnet']], 'ip' => ['type' => 'string'], 'cidr' => ['type' => 'string'], 'reason' => ['type' => 'string']]]]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/AllowlistEntry']]]]]],
+    ],
+    '/api/v1/admin/allowlist/{id}' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'Show allowlist entry', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'allowlist entry', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/AllowlistEntry']]]]]],
+        'delete' => ['tags' => ['Admin'], 'summary' => 'Delete allowlist entry', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted']]],
+    ],
+
+    // ---------- Admin: reporters / consumers / tokens ----------
+    '/api/v1/admin/reporters' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'List reporters', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
+        'post' => ['tags' => ['Admin'], 'summary' => 'Create reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]]]],
+    ],
+    '/api/v1/admin/reporters/{id}' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'Show reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'reporter', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]]]],
+        'patch' => ['tags' => ['Admin'], 'summary' => 'Update reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]], 'responses' => ['200' => ['description' => 'Updated', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Reporter']]]]]],
+        'delete' => ['tags' => ['Admin'], 'summary' => 'Soft-delete reporter', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted'], '409' => ['description' => 'Has reports — flagged inactive instead.']]],
+    ],
+    '/api/v1/admin/consumers' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'List consumers', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
+        'post' => ['tags' => ['Admin'], 'summary' => 'Create consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]]]],
+    ],
+    '/api/v1/admin/consumers/{id}' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'Show consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'consumer', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]]]],
+        'patch' => ['tags' => ['Admin'], 'summary' => 'Update consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Consumer']]]], 'responses' => ['200' => ['description' => 'Updated']]],
+        'delete' => ['tags' => ['Admin'], 'summary' => 'Soft-delete consumer', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted']]],
+    ],
+    '/api/v1/admin/tokens' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'List tokens', 'description' => 'Service tokens are filtered out unconditionally.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
+        'post' => ['tags' => ['Admin'], 'summary' => 'Create token', 'description' => 'Returns the raw token ONCE in `raw_token`. Service tokens are not creatable here.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['kind' => ['type' => 'string', 'enum' => ['reporter', 'consumer', 'admin']], 'reporter_id' => ['type' => 'integer'], 'consumer_id' => ['type' => 'integer'], 'role' => ['type' => 'string', 'enum' => ['viewer', 'operator', 'admin']], 'expires_at' => ['type' => 'string', 'format' => 'date-time']]]]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/TokenCreated']]]]]],
+    ],
+    '/api/v1/admin/tokens/{id}' => [
+        'delete' => ['tags' => ['Admin'], 'summary' => 'Revoke token', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Revoked']]],
+    ],
+
+    // ---------- Admin: categories / policies ----------
+    '/api/v1/admin/categories' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'List categories', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
+        'post' => ['tags' => ['Admin'], 'summary' => 'Create category', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]]]],
+    ],
+    '/api/v1/admin/categories/{id}' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'Show category', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'category', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]]]],
+        'patch' => ['tags' => ['Admin'], 'summary' => 'Update category', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Category']]]], 'responses' => ['200' => ['description' => 'Updated']]],
+        'delete' => ['tags' => ['Admin'], 'summary' => 'Hard-delete category (refused if in use)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted'], '409' => ['description' => 'Category in use; soft-delete via PATCH `is_active=false`.']]],
+    ],
+    '/api/v1/admin/policies' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'List policies', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'list']]],
+        'post' => ['tags' => ['Admin'], 'summary' => 'Create policy', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]], 'responses' => ['201' => ['description' => 'Created', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]]]],
+    ],
+    '/api/v1/admin/policies/{id}' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'Show policy', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'policy', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]]]],
+        'patch' => ['tags' => ['Admin'], 'summary' => 'Update policy (replaces thresholds wholesale when present)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'requestBody' => ['content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/Policy']]]], 'responses' => ['200' => ['description' => 'Updated']]],
+        'delete' => ['tags' => ['Admin'], 'summary' => 'Delete policy (refused if used by consumers)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['204' => ['description' => 'Deleted'], '409' => ['description' => 'Policy in use']]],
+    ],
+    '/api/v1/admin/policies/{id}/preview' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'Preview policy (count + sample)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]], 'responses' => ['200' => ['description' => 'Preview']]],
+    ],
+
+    // ---------- Admin: audit / jobs / config ----------
+    '/api/v1/admin/audit-log' => [
+        'get' => [
+            'tags' => ['Admin'], 'summary' => 'Filtered audit log',
+            'security' => [['BearerAuth' => []]],
+            'parameters' => [
+                ['$ref' => '#/components/parameters/ActingUserId'],
+                ['name' => 'actor_kind', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => ['user', 'admin-token', 'reporter', 'consumer', 'system']]],
+                ['name' => 'actor_id', 'in' => 'query', 'schema' => ['type' => 'integer']],
+                ['name' => 'action', 'in' => 'query', 'schema' => ['type' => 'string']],
+                ['name' => 'entity_type', 'in' => 'query', 'schema' => ['type' => 'string']],
+                ['name' => 'entity_id', 'in' => 'query', 'schema' => ['type' => 'string']],
+                ['name' => 'from', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time']],
+                ['name' => 'to', 'in' => 'query', 'schema' => ['type' => 'string', 'format' => 'date-time']],
+                ['$ref' => '#/components/parameters/Page'],
+                ['$ref' => '#/components/parameters/PageSize'],
+            ],
+            'responses' => ['200' => ['description' => 'Audit page', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => array_merge($pageMeta, ['items' => ['type' => 'array', 'items' => ['$ref' => '#/components/schemas/AuditEntry']]])]]]]],
+        ],
+    ],
+    '/api/v1/admin/jobs/status' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'Jobs status (Viewer)', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'Jobs status', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['now' => ['type' => 'string', 'format' => 'date-time'], 'jobs' => ['type' => 'object', 'additionalProperties' => ['$ref' => '#/components/schemas/JobStatus']]]]]]]]],
+    ],
+    '/api/v1/admin/jobs/trigger/{name}' => [
+        'post' => [
+            'tags' => ['Admin'], 'summary' => 'Manually trigger a job (Admin)',
+            'description' => 'Whitelisted params: `full`, `max_rows`, `reenrich`. Other body fields are dropped.',
+            'security' => [['BearerAuth' => []]],
+            'parameters' => [['$ref' => '#/components/parameters/ActingUserId'], ['name' => 'name', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'string']]],
+            'requestBody' => ['content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['full' => ['type' => 'boolean'], 'max_rows' => ['type' => 'integer'], 'reenrich' => ['type' => 'boolean']]]]]],
+            'responses' => [
+                '200' => ['description' => 'Job ran', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/JobOutcome']]]],
+                '404' => ['description' => 'Unknown job'],
+                '409' => ['description' => 'Lock held; status=`skipped_locked`'],
+                '412' => ['description' => 'refresh-geoip without credential'],
+            ],
+        ],
+    ],
+    '/api/v1/admin/config' => [
+        'get' => ['tags' => ['Admin'], 'summary' => 'Effective config (secrets masked)', 'description' => 'Admin only.', 'security' => [['BearerAuth' => []]], 'parameters' => [['$ref' => '#/components/parameters/ActingUserId']], 'responses' => ['200' => ['description' => 'Config sections', 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['sections' => ['type' => 'object', 'additionalProperties' => ['type' => 'object']]]]]]]]],
+    ],
+
+    // ---------- Auth (UI BFF only) ----------
+    '/api/v1/auth/users/upsert-oidc' => [
+        'post' => [
+            'tags' => ['Auth'],
+            'summary' => 'Upsert an OIDC-authenticated user',
+            'description' => "**UI BFF only.** Service-token-required, no impersonation header.\n"
+                . 'Resolves the user record + role (via `oidc_role_mappings`) for a freshly-validated ID token.',
+            'x-internal' => true,
+            'security' => [['BearerAuth' => []]],
+            'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['subject' => ['type' => 'string'], 'email' => ['type' => 'string'], 'display_name' => ['type' => 'string'], 'groups' => ['type' => 'array', 'items' => ['type' => 'string']]]]]]],
+            'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
+        ],
+    ],
+    '/api/v1/auth/users/upsert-local' => [
+        'post' => [
+            'tags' => ['Auth'],
+            'summary' => 'Upsert the local-admin user',
+            'description' => '**UI BFF only.** Called after the UI validates the local-admin password.',
+            'x-internal' => true,
+            'security' => [['BearerAuth' => []]],
+            'requestBody' => ['required' => true, 'content' => ['application/json' => ['schema' => ['type' => 'object', 'properties' => ['username' => ['type' => 'string']]]]]],
+            'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
+        ],
+    ],
+    '/api/v1/auth/users/{id}' => [
+        'get' => [
+            'tags' => ['Auth'],
+            'summary' => 'Refetch a user record',
+            'description' => '**UI BFF only.** Used to refresh role / display_name during a session.',
+            'x-internal' => true,
+            'security' => [['BearerAuth' => []]],
+            'parameters' => [['name' => 'id', 'in' => 'path', 'required' => true, 'schema' => ['type' => 'integer']]],
+            'responses' => ['200' => ['description' => 'User record', 'content' => ['application/json' => ['schema' => ['$ref' => '#/components/schemas/User']]]]],
+        ],
+    ],
+];
+
+$spec = [
+    'openapi' => '3.0.3',
+    'info' => [
+        'title' => 'IRDB — IP Reputation Database',
+        'version' => '1.0.0',
+        'description' => "Self-hosted IP reputation service: ingest abuse reports, distribute tailored block lists.\n\n"
+            . "## Versioning\n\n"
+            . "Single major version `v1`. Changes within `v1` are additive only — new endpoints, new optional fields, new optional query params. Breaking changes ship as `v2`.\n\n"
+            . "## Endpoint groups\n\n"
+            . "- **Public**: machine clients (reporters, consumers).\n"
+            . "- **Admin**: UI BFF + admin-kind tokens. RBAC enforced server-side.\n"
+            . "- **Auth**: UI BFF only — bridges browser auth to user records. Marked `x-internal: true`.\n"
+            . "- **Internal jobs**: not in this spec. Scheduler-only, network-restricted.\n\n"
+            . "## Authentication\n\n"
+            . "Bearer token in the `Authorization` header. Four token kinds: `reporter`, `consumer`, `admin`, `service`. See `doc/auth-flows.md`.\n\n"
+            . "## Errors\n\n"
+            . "Uniform envelope: `{\"error\":\"<code>\",\"details\":{...}}`. Validation errors include `details`. Authentication failures return `401` `unauthorized`. Authorization failures return `403`.\n\n"
+            . "## Rate limiting\n\n"
+            . "Public endpoints: 60 req/s/token (configurable). On exhaustion: `429` with `Retry-After: 1`.",
+        'license' => ['name' => 'TBD'],
+    ],
+    'servers' => [
+        ['url' => 'http://localhost:8081', 'description' => 'Default compose deployment'],
+        ['url' => 'https://reputation-api.example.com', 'description' => 'Production (replace hostname)'],
+    ],
+    'tags' => [
+        ['name' => 'Public', 'description' => 'Machine clients: reporters and blocklist consumers.'],
+        ['name' => 'Admin', 'description' => 'UI BFF + admin-kind tokens.'],
+        ['name' => 'Auth', 'description' => 'UI BFF only. Service token required, no impersonation.'],
+    ],
+    'security' => [['BearerAuth' => []]],
+    'paths' => $paths,
+    'components' => $components,
+];
+
+echo dumpYaml($spec);
+
+/**
+ * Minimal YAML dumper that handles the subset we use here:
+ *  - assoc arrays become mappings
+ *  - lists become block sequences
+ *  - strings get quoted only when needed (newlines, leading specials, special chars)
+ *  - bools / ints / floats / null serialise plainly
+ *
+ * @param mixed $value
+ */
+function dumpYaml(mixed $value, int $indent = 0): string
+{
+    if (!is_array($value)) {
+        return scalarYaml($value) . "\n";
+    }
+    if ($value === []) {
+        // Caller decides whether `[]` is "empty list" or "empty object".
+        // We always emit empty list — callers that need an empty object
+        // pass a sentinel (we don't have any in this spec).
+        return "[]\n";
+    }
+    $isList = array_is_list($value);
+    $out = '';
+    $pad = str_repeat('  ', $indent);
+    foreach ($value as $k => $v) {
+        if ($isList) {
+            if (is_array($v) && $v !== []) {
+                // Render the child at indent 0, then prefix the first line
+                // with "- " and subsequent lines with two-space continuation.
+                $rawSub = rtrim(dumpYaml($v, 0), "\n");
+                $childPad = str_repeat('  ', $indent + 1);
+                $lines = explode("\n", $rawSub);
+                $first = true;
+                foreach ($lines as $line) {
+                    if ($first) {
+                        $out .= $pad . '- ' . $line . "\n";
+                        $first = false;
+                    } else {
+                        $out .= $childPad . $line . "\n";
+                    }
+                }
+            } else {
+                $out .= $pad . '- ' . scalarYaml($v, $indent) . "\n";
+            }
+        } else {
+            $key = (string) $k;
+            // Quote keys that contain special chars
+            if (preg_match('/^[A-Za-z_][A-Za-z0-9_-]*$/', $key) !== 1 || $key === 'on' || $key === 'no' || $key === 'yes' || $key === 'off') {
+                $key = "'" . str_replace("'", "''", $key) . "'";
+            }
+            if (is_array($v)) {
+                if ($v === []) {
+                    $out .= $pad . $key . ": []\n";
+                } else {
+                    $out .= $pad . $key . ":\n" . dumpYaml($v, $indent + 1);
+                }
+            } else {
+                $out .= $pad . $key . ': ' . scalarYaml($v, $indent) . "\n";
+            }
+        }
+    }
+
+    return $out;
+}
+
+function scalarYaml(mixed $value, int $parentIndent = 0): string
+{
+    if ($value === null) {
+        return 'null';
+    }
+    if (is_bool($value)) {
+        return $value ? 'true' : 'false';
+    }
+    if (is_int($value) || is_float($value)) {
+        return (string) $value;
+    }
+    $s = (string) $value;
+    if ($s === '') {
+        return "''";
+    }
+    // Multi-line: literal block scalar. Content must be indented one level
+    // *deeper* than the key — i.e. `parentIndent + 1`. The caller already
+    // emitted "<key>: <this>" so we return "|\n<indented lines>".
+    if (str_contains($s, "\n")) {
+        $lines = explode("\n", rtrim($s, "\n"));
+        $contentPad = str_repeat('  ', $parentIndent + 1);
+        $block = '|';
+        foreach ($lines as $line) {
+            $block .= "\n" . ($line === '' ? '' : $contentPad . $line);
+        }
+
+        return $block;
+    }
+    // Quote if it could be misread as bool/null/number, contains special chars, or starts with sigils.
+    $needsQuote = preg_match('/^(true|false|null|yes|no|on|off|~)$/i', $s) === 1
+        || preg_match('/^[\\-?:,\\[\\]\\{\\}#&*!|>\'"%@`]/', $s) === 1
+        || preg_match('/^[+\\-]?\\d/', $s) === 1
+        || str_contains($s, ': ')
+        || str_contains($s, ' #')
+        || str_starts_with($s, ' ')
+        || str_ends_with($s, ' ');
+    if ($needsQuote) {
+        return "'" . str_replace("'", "''", $s) . "'";
+    }
+
+    return $s;
+}

+ 1654 - 0
api/public/openapi.yaml

@@ -0,0 +1,1654 @@
+openapi: '3.0.3'
+info:
+  title: IRDB — IP Reputation Database
+  version: '1.0.0'
+  description: |
+    Self-hosted IP reputation service: ingest abuse reports, distribute tailored block lists.
+
+    ## Versioning
+
+    Single major version `v1`. Changes within `v1` are additive only — new endpoints, new optional fields, new optional query params. Breaking changes ship as `v2`.
+
+    ## Endpoint groups
+
+    - **Public**: machine clients (reporters, consumers).
+    - **Admin**: UI BFF + admin-kind tokens. RBAC enforced server-side.
+    - **Auth**: UI BFF only — bridges browser auth to user records. Marked `x-internal: true`.
+    - **Internal jobs**: not in this spec. Scheduler-only, network-restricted.
+
+    ## Authentication
+
+    Bearer token in the `Authorization` header. Four token kinds: `reporter`, `consumer`, `admin`, `service`. See `doc/auth-flows.md`.
+
+    ## Errors
+
+    Uniform envelope: `{"error":"<code>","details":{...}}`. Validation errors include `details`. Authentication failures return `401` `unauthorized`. Authorization failures return `403`.
+
+    ## Rate limiting
+
+    Public endpoints: 60 req/s/token (configurable). On exhaustion: `429` with `Retry-After: 1`.
+  license:
+    name: TBD
+servers:
+  - url: http://localhost:8081
+    description: Default compose deployment
+  - url: https://reputation-api.example.com
+    description: Production (replace hostname)
+tags:
+  - name: Public
+    description: 'Machine clients: reporters and blocklist consumers.'
+  - name: Admin
+    description: UI BFF + admin-kind tokens.
+  - name: Auth
+    description: UI BFF only. Service token required, no impersonation.
+security:
+  - BearerAuth: []
+paths:
+  '/api/v1/report':
+    post:
+      tags:
+        - Public
+      summary: Submit an abuse report
+      description: |
+        Token kind: `reporter`. Rate limit: 60 req/s per token (configurable).
+        Returns `202 Accepted` on success.
+      security:
+        - BearerAuth: []
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              '$ref': '#/components/schemas/ReportRequest'
+      responses:
+        '202':
+          description: Report accepted
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/ReportResponse'
+        '400':
+          description: Validation failed
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/Error'
+        '401':
+          description: Bad / wrong-kind token
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/Error'
+        '429':
+          description: Rate limited
+          headers:
+            Retry-After:
+              schema:
+                type: integer
+              description: Seconds to wait before retrying.
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/Error'
+  '/api/v1/blocklist':
+    get:
+      tags:
+        - Public
+      summary: Pull a tailored blocklist
+      description: |
+        Token kind: `consumer`. The consumer's bound policy decides which IPs/CIDRs land in the output.
+        Cached internally for 30 s per consumer. Honour `If-None-Match` to skip retransfer.
+        `?format=json` returns structured rows; default is `text/plain`, one entry per line.
+      security:
+        - BearerAuth: []
+      parameters:
+        - name: format
+          in: query
+          schema:
+            type: string
+            enum:
+              - text
+              - json
+            default: text
+        - name: If-None-Match
+          in: header
+          schema:
+            type: string
+      responses:
+        '200':
+          description: Current blocklist
+          headers:
+            ETag:
+              schema:
+                type: string
+            X-Blocklist-Generated-At:
+              schema:
+                type: string
+                format: date-time
+            X-Blocklist-Entries:
+              schema:
+                type: integer
+            X-Blocklist-Policy:
+              schema:
+                type: string
+          content:
+            'text/plain':
+              schema:
+                type: string
+                example: |
+                  203.0.113.42
+                  198.51.100.0/24
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/BlocklistJson'
+        '304':
+          description: Not modified — body matches `If-None-Match`
+        '401':
+          description: Bad / wrong-kind token
+  '/api/v1/admin/me':
+    get:
+      tags:
+        - Admin
+      summary: Current acting identity
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: Identity info
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/User'
+  '/api/v1/admin/ips':
+    get:
+      tags:
+        - Admin
+      summary: Search IPs
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: q
+          in: query
+          schema:
+            type: string
+        - name: category
+          in: query
+          schema:
+            type: string
+        - name: min_score
+          in: query
+          schema:
+            type: number
+            format: float
+        - name: max_score
+          in: query
+          schema:
+            type: number
+            format: float
+        - name: country
+          in: query
+          schema:
+            type: string
+        - name: asn
+          in: query
+          schema:
+            type: integer
+        - name: status
+          in: query
+          schema:
+            type: string
+            enum:
+              - scored
+              - manual
+              - allowlisted
+              - clean
+        - '$ref': '#/components/parameters/Page'
+        - '$ref': '#/components/parameters/PageSize'
+      responses:
+        '200':
+          description: Page of IPs
+          content:
+            'application/json':
+              schema:
+                type: object
+                properties:
+                  page:
+                    type: integer
+                    minimum: 1
+                    example: 1
+                  page_size:
+                    type: integer
+                    minimum: 1
+                    maximum: 200
+                    example: 50
+                  total:
+                    type: integer
+                    minimum: 0
+                    example: 1284
+                  items:
+                    type: array
+                    items:
+                      type: object
+  '/api/v1/admin/ips/countries':
+    get:
+      tags:
+        - Admin
+      summary: Country code dropdown source
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: '`[{code, count}]`'
+          content:
+            'application/json':
+              schema:
+                type: object
+                properties:
+                  items:
+                    type: array
+                    items:
+                      type: object
+                      properties:
+                        code:
+                          type: string
+                        count:
+                          type: integer
+  '/api/v1/admin/ips/{ip}':
+    get:
+      tags:
+        - Admin
+      summary: IP detail
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: ip
+          in: path
+          required: true
+          schema:
+            type: string
+      responses:
+        '200':
+          description: IP detail
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/IpDetail'
+        '404':
+          description: Invalid IP / not found
+  '/api/v1/admin/stats/dashboard':
+    get:
+      tags:
+        - Admin
+      summary: Dashboard stats (30s cached)
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: Dashboard payload
+          content:
+            'application/json':
+              schema:
+                type: object
+  '/api/v1/admin/manual-blocks':
+    get:
+      tags:
+        - Admin
+      summary: List manual blocks
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: list
+    post:
+      tags:
+        - Admin
+      summary: Create manual block
+      description: Operator+ role required. `kind=ip` requires `ip`; `kind=subnet` requires `cidr`.
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              type: object
+              properties:
+                kind:
+                  type: string
+                  enum:
+                    - ip
+                    - subnet
+                ip:
+                  type: string
+                cidr:
+                  type: string
+                reason:
+                  type: string
+                expires_at:
+                  type: string
+                  format: date-time
+      responses:
+        '201':
+          description: Created
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/ManualBlock'
+  '/api/v1/admin/manual-blocks/{id}':
+    get:
+      tags:
+        - Admin
+      summary: Show manual block
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '200':
+          description: manual block
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/ManualBlock'
+    delete:
+      tags:
+        - Admin
+      summary: Delete manual block
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '204':
+          description: Deleted
+  '/api/v1/admin/allowlist':
+    get:
+      tags:
+        - Admin
+      summary: List allowlist
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: list
+    post:
+      tags:
+        - Admin
+      summary: Create allowlist entry
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              type: object
+              properties:
+                kind:
+                  type: string
+                  enum:
+                    - ip
+                    - subnet
+                ip:
+                  type: string
+                cidr:
+                  type: string
+                reason:
+                  type: string
+      responses:
+        '201':
+          description: Created
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/AllowlistEntry'
+  '/api/v1/admin/allowlist/{id}':
+    get:
+      tags:
+        - Admin
+      summary: Show allowlist entry
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '200':
+          description: allowlist entry
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/AllowlistEntry'
+    delete:
+      tags:
+        - Admin
+      summary: Delete allowlist entry
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '204':
+          description: Deleted
+  '/api/v1/admin/reporters':
+    get:
+      tags:
+        - Admin
+      summary: List reporters
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: list
+    post:
+      tags:
+        - Admin
+      summary: Create reporter
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              '$ref': '#/components/schemas/Reporter'
+      responses:
+        '201':
+          description: Created
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/Reporter'
+  '/api/v1/admin/reporters/{id}':
+    get:
+      tags:
+        - Admin
+      summary: Show reporter
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '200':
+          description: reporter
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/Reporter'
+    patch:
+      tags:
+        - Admin
+      summary: Update reporter
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      requestBody:
+        content:
+          'application/json':
+            schema:
+              '$ref': '#/components/schemas/Reporter'
+      responses:
+        '200':
+          description: Updated
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/Reporter'
+    delete:
+      tags:
+        - Admin
+      summary: Soft-delete reporter
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '204':
+          description: Deleted
+        '409':
+          description: Has reports — flagged inactive instead.
+  '/api/v1/admin/consumers':
+    get:
+      tags:
+        - Admin
+      summary: List consumers
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: list
+    post:
+      tags:
+        - Admin
+      summary: Create consumer
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              '$ref': '#/components/schemas/Consumer'
+      responses:
+        '201':
+          description: Created
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/Consumer'
+  '/api/v1/admin/consumers/{id}':
+    get:
+      tags:
+        - Admin
+      summary: Show consumer
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '200':
+          description: consumer
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/Consumer'
+    patch:
+      tags:
+        - Admin
+      summary: Update consumer
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      requestBody:
+        content:
+          'application/json':
+            schema:
+              '$ref': '#/components/schemas/Consumer'
+      responses:
+        '200':
+          description: Updated
+    delete:
+      tags:
+        - Admin
+      summary: Soft-delete consumer
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '204':
+          description: Deleted
+  '/api/v1/admin/tokens':
+    get:
+      tags:
+        - Admin
+      summary: List tokens
+      description: Service tokens are filtered out unconditionally.
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: list
+    post:
+      tags:
+        - Admin
+      summary: Create token
+      description: Returns the raw token ONCE in `raw_token`. Service tokens are not creatable here.
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              type: object
+              properties:
+                kind:
+                  type: string
+                  enum:
+                    - reporter
+                    - consumer
+                    - admin
+                reporter_id:
+                  type: integer
+                consumer_id:
+                  type: integer
+                role:
+                  type: string
+                  enum:
+                    - viewer
+                    - operator
+                    - admin
+                expires_at:
+                  type: string
+                  format: date-time
+      responses:
+        '201':
+          description: Created
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/TokenCreated'
+  '/api/v1/admin/tokens/{id}':
+    delete:
+      tags:
+        - Admin
+      summary: Revoke token
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '204':
+          description: Revoked
+  '/api/v1/admin/categories':
+    get:
+      tags:
+        - Admin
+      summary: List categories
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: list
+    post:
+      tags:
+        - Admin
+      summary: Create category
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              '$ref': '#/components/schemas/Category'
+      responses:
+        '201':
+          description: Created
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/Category'
+  '/api/v1/admin/categories/{id}':
+    get:
+      tags:
+        - Admin
+      summary: Show category
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '200':
+          description: category
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/Category'
+    patch:
+      tags:
+        - Admin
+      summary: Update category
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      requestBody:
+        content:
+          'application/json':
+            schema:
+              '$ref': '#/components/schemas/Category'
+      responses:
+        '200':
+          description: Updated
+    delete:
+      tags:
+        - Admin
+      summary: Hard-delete category (refused if in use)
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '204':
+          description: Deleted
+        '409':
+          description: Category in use; soft-delete via PATCH `is_active=false`.
+  '/api/v1/admin/policies':
+    get:
+      tags:
+        - Admin
+      summary: List policies
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: list
+    post:
+      tags:
+        - Admin
+      summary: Create policy
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              '$ref': '#/components/schemas/Policy'
+      responses:
+        '201':
+          description: Created
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/Policy'
+  '/api/v1/admin/policies/{id}':
+    get:
+      tags:
+        - Admin
+      summary: Show policy
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '200':
+          description: policy
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/Policy'
+    patch:
+      tags:
+        - Admin
+      summary: Update policy (replaces thresholds wholesale when present)
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      requestBody:
+        content:
+          'application/json':
+            schema:
+              '$ref': '#/components/schemas/Policy'
+      responses:
+        '200':
+          description: Updated
+    delete:
+      tags:
+        - Admin
+      summary: Delete policy (refused if used by consumers)
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '204':
+          description: Deleted
+        '409':
+          description: Policy in use
+  '/api/v1/admin/policies/{id}/preview':
+    get:
+      tags:
+        - Admin
+      summary: Preview policy (count + sample)
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '200':
+          description: Preview
+  '/api/v1/admin/audit-log':
+    get:
+      tags:
+        - Admin
+      summary: Filtered audit log
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: actor_kind
+          in: query
+          schema:
+            type: string
+            enum:
+              - user
+              - admin-token
+              - reporter
+              - consumer
+              - system
+        - name: actor_id
+          in: query
+          schema:
+            type: integer
+        - name: action
+          in: query
+          schema:
+            type: string
+        - name: entity_type
+          in: query
+          schema:
+            type: string
+        - name: entity_id
+          in: query
+          schema:
+            type: string
+        - name: from
+          in: query
+          schema:
+            type: string
+            format: date-time
+        - name: to
+          in: query
+          schema:
+            type: string
+            format: date-time
+        - '$ref': '#/components/parameters/Page'
+        - '$ref': '#/components/parameters/PageSize'
+      responses:
+        '200':
+          description: Audit page
+          content:
+            'application/json':
+              schema:
+                type: object
+                properties:
+                  page:
+                    type: integer
+                    minimum: 1
+                    example: 1
+                  page_size:
+                    type: integer
+                    minimum: 1
+                    maximum: 200
+                    example: 50
+                  total:
+                    type: integer
+                    minimum: 0
+                    example: 1284
+                  items:
+                    type: array
+                    items:
+                      '$ref': '#/components/schemas/AuditEntry'
+  '/api/v1/admin/jobs/status':
+    get:
+      tags:
+        - Admin
+      summary: Jobs status (Viewer)
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: Jobs status
+          content:
+            'application/json':
+              schema:
+                type: object
+                properties:
+                  now:
+                    type: string
+                    format: date-time
+                  jobs:
+                    type: object
+                    additionalProperties:
+                      '$ref': '#/components/schemas/JobStatus'
+  '/api/v1/admin/jobs/trigger/{name}':
+    post:
+      tags:
+        - Admin
+      summary: Manually trigger a job (Admin)
+      description: 'Whitelisted params: `full`, `max_rows`, `reenrich`. Other body fields are dropped.'
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+        - name: name
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        content:
+          'application/json':
+            schema:
+              type: object
+              properties:
+                full:
+                  type: boolean
+                max_rows:
+                  type: integer
+                reenrich:
+                  type: boolean
+      responses:
+        '200':
+          description: Job ran
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/JobOutcome'
+        '404':
+          description: Unknown job
+        '409':
+          description: Lock held; status=`skipped_locked`
+        '412':
+          description: refresh-geoip without credential
+  '/api/v1/admin/config':
+    get:
+      tags:
+        - Admin
+      summary: Effective config (secrets masked)
+      description: Admin only.
+      security:
+        - BearerAuth: []
+      parameters:
+        - '$ref': '#/components/parameters/ActingUserId'
+      responses:
+        '200':
+          description: Config sections
+          content:
+            'application/json':
+              schema:
+                type: object
+                properties:
+                  sections:
+                    type: object
+                    additionalProperties:
+                      type: object
+  '/api/v1/auth/users/upsert-oidc':
+    post:
+      tags:
+        - Auth
+      summary: Upsert an OIDC-authenticated user
+      description: |
+        **UI BFF only.** Service-token-required, no impersonation header.
+        Resolves the user record + role (via `oidc_role_mappings`) for a freshly-validated ID token.
+      x-internal: true
+      security:
+        - BearerAuth: []
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              type: object
+              properties:
+                subject:
+                  type: string
+                email:
+                  type: string
+                display_name:
+                  type: string
+                groups:
+                  type: array
+                  items:
+                    type: string
+      responses:
+        '200':
+          description: User record
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/User'
+  '/api/v1/auth/users/upsert-local':
+    post:
+      tags:
+        - Auth
+      summary: Upsert the local-admin user
+      description: '**UI BFF only.** Called after the UI validates the local-admin password.'
+      x-internal: true
+      security:
+        - BearerAuth: []
+      requestBody:
+        required: true
+        content:
+          'application/json':
+            schema:
+              type: object
+              properties:
+                username:
+                  type: string
+      responses:
+        '200':
+          description: User record
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/User'
+  '/api/v1/auth/users/{id}':
+    get:
+      tags:
+        - Auth
+      summary: Refetch a user record
+      description: '**UI BFF only.** Used to refresh role / display_name during a session.'
+      x-internal: true
+      security:
+        - BearerAuth: []
+      parameters:
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+      responses:
+        '200':
+          description: User record
+          content:
+            'application/json':
+              schema:
+                '$ref': '#/components/schemas/User'
+components:
+  securitySchemes:
+    BearerAuth:
+      type: http
+      scheme: bearer
+      description: |
+        Token in the form `irdb_<kind>_<32 base32 chars>`.
+        See `doc/auth-flows.md` for the four token kinds.
+  parameters:
+    Page:
+      name: page
+      in: query
+      schema:
+        type: integer
+        minimum: 1
+        default: 1
+    PageSize:
+      name: page_size
+      in: query
+      schema:
+        type: integer
+        minimum: 1
+        maximum: 200
+        default: 50
+    ActingUserId:
+      name: X-Acting-User-Id
+      in: header
+      description: |
+        Required when authenticating with a `service` token.
+        The api applies RBAC for the named user. Ignored on other token kinds.
+      schema:
+        type: integer
+  schemas:
+    Error:
+      type: object
+      required:
+        - error
+      properties:
+        error:
+          type: string
+          example: unauthorized
+        details:
+          type: object
+          description: Field-level errors for `validation_failed`.
+          additionalProperties:
+            type: string
+    ReportRequest:
+      type: object
+      required:
+        - ip
+        - category
+      properties:
+        ip:
+          type: string
+          example: '203.0.113.42'
+        category:
+          type: string
+          example: brute_force
+        metadata:
+          type: object
+          description: Free-form per-report data, max 4 KB after json_encode.
+          additionalProperties: true
+    ReportResponse:
+      type: object
+      properties:
+        report_id:
+          type: integer
+          example: 12345
+        ip:
+          type: string
+          example: '203.0.113.42'
+        received_at:
+          type: string
+          format: date-time
+    BlocklistEntry:
+      type: object
+      properties:
+        ip_or_cidr:
+          type: string
+          example: '203.0.113.42'
+        categories:
+          type: array
+          items:
+            type: string
+        score:
+          type: number
+          format: float
+          example: 1.42
+        reason:
+          type: string
+          enum:
+            - scored
+            - manual
+          example: scored
+    BlocklistJson:
+      type: object
+      properties:
+        count:
+          type: integer
+          example: 42
+        generated_at:
+          type: string
+          format: date-time
+        policy:
+          type: string
+          example: moderate
+        entries:
+          type: array
+          items:
+            '$ref': '#/components/schemas/BlocklistEntry'
+    Token:
+      type: object
+      properties:
+        id:
+          type: integer
+        kind:
+          type: string
+          enum:
+            - reporter
+            - consumer
+            - admin
+        prefix:
+          type: string
+          example: irdb_adm
+        reporter_id:
+          type: integer
+          nullable: true
+        consumer_id:
+          type: integer
+          nullable: true
+        role:
+          type: string
+          nullable: true
+          enum:
+            - viewer
+            - operator
+            - admin
+            - null
+        expires_at:
+          type: string
+          format: date-time
+          nullable: true
+        revoked_at:
+          type: string
+          format: date-time
+          nullable: true
+        last_used_at:
+          type: string
+          format: date-time
+          nullable: true
+    TokenCreated:
+      allOf:
+        - '$ref': '#/components/schemas/Token'
+        - type: object
+          properties:
+            raw_token:
+              type: string
+              description: Returned ONCE on creation — copy it now, never displayed again.
+              example: irdb_adm_AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD
+    Reporter:
+      type: object
+      properties:
+        id:
+          type: integer
+        name:
+          type: string
+          example: web-prod-01
+        description:
+          type: string
+          nullable: true
+        trust_weight:
+          type: number
+          format: float
+          minimum: 0
+          maximum: 2
+        is_active:
+          type: boolean
+    Consumer:
+      type: object
+      properties:
+        id:
+          type: integer
+        name:
+          type: string
+          example: edge-fw-01
+        description:
+          type: string
+          nullable: true
+        policy_id:
+          type: integer
+        is_active:
+          type: boolean
+        last_pulled_at:
+          type: string
+          format: date-time
+          nullable: true
+    Category:
+      type: object
+      properties:
+        id:
+          type: integer
+        slug:
+          type: string
+          example: brute_force
+        name:
+          type: string
+        description:
+          type: string
+          nullable: true
+        decay_function:
+          type: string
+          enum:
+            - linear
+            - exponential
+        decay_param:
+          type: number
+          format: float
+        is_active:
+          type: boolean
+    Policy:
+      type: object
+      properties:
+        id:
+          type: integer
+        name:
+          type: string
+          example: moderate
+        description:
+          type: string
+          nullable: true
+        include_manual_blocks:
+          type: boolean
+        thresholds:
+          type: object
+          description: '`{category_slug: threshold}`'
+          additionalProperties:
+            type: number
+            format: float
+    ManualBlock:
+      type: object
+      properties:
+        id:
+          type: integer
+        kind:
+          type: string
+          enum:
+            - ip
+            - subnet
+        ip:
+          type: string
+          nullable: true
+        cidr:
+          type: string
+          nullable: true
+        reason:
+          type: string
+          nullable: true
+        expires_at:
+          type: string
+          format: date-time
+          nullable: true
+        created_at:
+          type: string
+          format: date-time
+    AllowlistEntry:
+      type: object
+      properties:
+        id:
+          type: integer
+        kind:
+          type: string
+          enum:
+            - ip
+            - subnet
+        ip:
+          type: string
+          nullable: true
+        cidr:
+          type: string
+          nullable: true
+        reason:
+          type: string
+          nullable: true
+        created_at:
+          type: string
+          format: date-time
+    IpDetail:
+      type: object
+      properties:
+        ip:
+          type: string
+        is_ipv4:
+          type: boolean
+        scores:
+          type: array
+          items:
+            type: object
+            properties:
+              category:
+                type: string
+              score:
+                type: number
+                format: float
+              last_report_at:
+                type: string
+                format: date-time
+                nullable: true
+              report_count_30d:
+                type: integer
+        enrichment:
+          type: object
+          properties:
+            country_code:
+              type: string
+              nullable: true
+            asn:
+              type: integer
+              nullable: true
+            as_org:
+              type: string
+              nullable: true
+            enriched_at:
+              type: string
+              format: date-time
+              nullable: true
+        status:
+          type: string
+          enum:
+            - scored
+            - manually_blocked
+            - allowlisted
+            - clean
+        manual_block:
+          type: object
+          nullable: true
+        allowlist:
+          type: object
+          nullable: true
+        history:
+          type: array
+          items:
+            type: object
+        has_more:
+          type: boolean
+    AuditEntry:
+      type: object
+      properties:
+        id:
+          type: integer
+        occurred_at:
+          type: string
+          format: date-time
+        actor_kind:
+          type: string
+          enum:
+            - user
+            - admin-token
+            - reporter
+            - consumer
+            - system
+        actor_id:
+          type: string
+          nullable: true
+        action:
+          type: string
+          example: manual_block.created
+        entity_type:
+          type: string
+          nullable: true
+        entity_id:
+          type: string
+          nullable: true
+        details:
+          type: object
+          nullable: true
+          additionalProperties: true
+        source_ip:
+          type: string
+          nullable: true
+    JobStatus:
+      type: object
+      properties:
+        name:
+          type: string
+        default_interval_seconds:
+          type: integer
+        max_runtime_seconds:
+          type: integer
+        overdue:
+          type: boolean
+        last_run:
+          type: object
+          nullable: true
+          properties:
+            id:
+              type: integer
+            status:
+              type: string
+              enum:
+                - success
+                - failure
+                - skipped_locked
+                - running
+            items_processed:
+              type: integer
+            triggered_by:
+              type: string
+              enum:
+                - schedule
+                - manual
+                - api
+            started_at:
+              type: string
+              format: date-time
+              nullable: true
+            finished_at:
+              type: string
+              format: date-time
+              nullable: true
+            error_message:
+              type: string
+              nullable: true
+    JobOutcome:
+      type: object
+      properties:
+        job:
+          type: string
+        status:
+          type: string
+          enum:
+            - success
+            - failure
+            - skipped_locked
+            - running
+        items_processed:
+          type: integer
+        duration_ms:
+          type: integer
+        run_id:
+          type: integer
+          nullable: true
+        error:
+          type: string
+          nullable: true
+    User:
+      type: object
+      properties:
+        id:
+          type: integer
+        email:
+          type: string
+          nullable: true
+        display_name:
+          type: string
+        role:
+          type: string
+          enum:
+            - viewer
+            - operator
+            - admin
+        source:
+          type: string
+          enum:
+            - oidc
+            - local
+            - admin-token
+        is_local:
+          type: boolean
+    Pagination:
+      type: object
+      properties:
+        page:
+          type: integer
+          minimum: 1
+          example: 1
+        page_size:
+          type: integer
+          minimum: 1
+          maximum: 200
+          example: 50
+        total:
+          type: integer
+          minimum: 0
+          example: 1284

+ 8 - 0
api/src/App/AppFactory.php

@@ -20,6 +20,7 @@ use App\Application\Admin\TokensController;
 use App\Application\Auth\AuthController;
 use App\Application\Internal\JobsController;
 use App\Application\Public\BlocklistController;
+use App\Application\Public\DocsController;
 use App\Application\Public\ReportController;
 use App\Domain\Auth\Role;
 use App\Infrastructure\Http\JsonErrorHandler;
@@ -90,6 +91,13 @@ final class AppFactory
         /** @var InternalTokenMiddleware $internalToken */
         $internalToken = $container->get(InternalTokenMiddleware::class);
 
+        // Public docs (no auth, no rate limit). The viewer is HTML-only;
+        // the spec is the YAML file shipped with the api image.
+        /** @var DocsController $docs */
+        $docs = $container->get(DocsController::class);
+        $app->get('/api/v1/openapi.yaml', [$docs, 'spec']);
+        $app->get('/api/docs', [$docs, 'viewer']);
+
         $app->get('/healthz', function (ServerRequestInterface $request, ResponseInterface $response) use ($container): ResponseInterface {
             /** @var array{driver: string} $dbSettings */
             $dbSettings = $container->get('settings.db');

+ 4 - 0
api/src/App/Container.php

@@ -25,6 +25,7 @@ use App\Application\Jobs\RecomputeScoresJob;
 use App\Application\Jobs\RefreshGeoipJob;
 use App\Application\Jobs\TickJob;
 use App\Application\Public\BlocklistController;
+use App\Application\Public\DocsController;
 use App\Application\Public\ReportController;
 use App\Domain\Audit\AuditEmitter;
 use App\Domain\Auth\Role;
@@ -428,6 +429,9 @@ final class Container
 
                 return new ConfigController($settings);
             }),
+            DocsController::class => factory(static function (): DocsController {
+                return new DocsController(__DIR__ . '/../../public/openapi.yaml');
+            }),
         ]);
 
         return $builder->build();

+ 87 - 0
api/src/Application/Public/DocsController.php

@@ -0,0 +1,87 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Public;
+
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Serves the OpenAPI document and a viewer for it.
+ *
+ *  - `GET /api/v1/openapi.yaml` — the raw spec, `application/yaml`.
+ *  - `GET /api/docs`           — single-page HTML loading RapiDoc.
+ *
+ * RapiDoc is loaded from a CDN-vendored npm static asset (jsDelivr SRI'd
+ * URL). It's the smallest viable viewer — ~120 KB minified, no
+ * dependencies. We deliberately don't ship a vendored copy in `public/`
+ * because the asset path would need to coexist with FrankenPHP's
+ * static-file serving rules and the M01 Caddyfile is configured to
+ * route everything through PHP. The CDN URL is locked to a specific
+ * version, so an outage there is the only failure mode.
+ */
+final class DocsController
+{
+    private const RAPIDOC_URL = 'https://cdn.jsdelivr.net/npm/rapidoc@9.3.4/dist/rapidoc-min.js';
+
+    public function __construct(private readonly string $openapiPath)
+    {
+    }
+
+    public function spec(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        if (!is_file($this->openapiPath) || !is_readable($this->openapiPath)) {
+            $response->getBody()->write((string) json_encode(['error' => 'spec_unavailable']));
+
+            return $response->withStatus(500)->withHeader('Content-Type', 'application/json');
+        }
+        $body = (string) file_get_contents($this->openapiPath);
+        $response->getBody()->write($body);
+
+        return $response
+            ->withStatus(200)
+            ->withHeader('Content-Type', 'application/yaml; charset=utf-8')
+            ->withHeader('Cache-Control', 'public, max-age=300');
+    }
+
+    public function viewer(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $rapidoc = self::RAPIDOC_URL;
+        $html = <<<HTML
+            <!DOCTYPE html>
+            <html lang="en">
+            <head>
+                <meta charset="utf-8">
+                <title>IRDB — API Reference</title>
+                <meta name="viewport" content="width=device-width, initial-scale=1">
+                <script type="module" src="{$rapidoc}"></script>
+                <style>html, body { margin: 0; padding: 0; height: 100%; }</style>
+            </head>
+            <body>
+                <rapi-doc
+                    spec-url="/api/v1/openapi.yaml"
+                    theme="dark"
+                    bg-color="#0f172a"
+                    text-color="#e2e8f0"
+                    primary-color="#6366f1"
+                    render-style="read"
+                    show-header="false"
+                    show-info="true"
+                    allow-try="true"
+                    allow-server-selection="true"
+                    allow-authentication="true"
+                    schema-style="table"
+                    style="height: 100vh;">
+                </rapi-doc>
+            </body>
+            </html>
+            HTML;
+
+        $response->getBody()->write($html);
+
+        return $response
+            ->withStatus(200)
+            ->withHeader('Content-Type', 'text/html; charset=utf-8');
+    }
+}

+ 210 - 0
doc/api-overview.md

@@ -0,0 +1,210 @@
+# API overview
+
+> Audience: integrators (firewalls, fail2ban-style agents, monitoring) and
+> frontend authors. The OpenAPI document at `/api/v1/openapi.yaml` is the
+> source of truth for endpoint shapes; this file covers the surrounding
+> conventions OpenAPI doesn't express.
+
+## Base URL & versioning
+
+Default compose deployment: **`http://localhost:8081`**. Production is typically
+behind a reverse proxy at `https://reputation-api.example.com`.
+
+Single major version `v1`. The contract is **additive-only within `v1`**:
+
+- new endpoints may be added at any time
+- new optional query parameters may be added
+- new optional fields may appear in responses
+- existing fields will not change shape, type, or semantics
+- breaking changes ship as a new major (`v2`) at a different path prefix
+
+Don't pin to undocumented behaviour (e.g. response field ordering); pin to
+the documented schema in OpenAPI.
+
+## Authentication
+
+Every endpoint takes `Authorization: Bearer <token>`. There are four token
+kinds, distinguishable by their prefix:
+
+| Token format            | Kind        | What it can do                                                              |
+|-------------------------|-------------|-----------------------------------------------------------------------------|
+| `irdb_rep_<32 base32>`  | `reporter`  | `POST /api/v1/report` only. Bound to a reporter row.                        |
+| `irdb_con_<32 base32>`  | `consumer`  | `GET /api/v1/blocklist` only. Bound to a consumer row.                      |
+| `irdb_adm_<32 base32>`  | `admin`     | All `/api/v1/admin/*` as itself, with a configured role. For automation.    |
+| `irdb_svc_<32 base32>`  | `service`   | UI BFF only. Combined with `X-Acting-User-Id`, executes as that user.       |
+
+Tokens are persisted as their SHA-256 hash; the raw value is shown **once**
+on creation and never echoed again. Revoke + re-issue if it leaks.
+
+Failure modes return a uniform `401 unauthorized` envelope — bad token,
+revoked token, expired token, wrong-kind token all look identical to the
+caller. Authorization failures (authenticated but RBAC-denied) return
+`403`.
+
+See [`auth-flows.md`](./auth-flows.md) for the full picture of how each
+flow plays out.
+
+## Endpoint groups
+
+| Group         | Path prefix                  | Audience                          | Documented in OpenAPI |
+|---------------|------------------------------|-----------------------------------|-----------------------|
+| Public        | `/api/v1/report`, `/api/v1/blocklist` | machine clients     | yes                   |
+| Admin         | `/api/v1/admin/*`            | UI BFF + admin-kind tokens        | yes                   |
+| Auth          | `/api/v1/auth/*`             | UI BFF only                       | yes (`x-internal: true`) |
+| Internal jobs | `/internal/jobs/*`           | scheduler                         | **no** — private contract |
+
+The internal jobs surface is bound to RFC1918 + loopback in the api's
+Caddyfile and authenticated with a dedicated `INTERNAL_JOB_TOKEN`.
+External callers get `404`. The admin proxy at
+`POST /api/v1/admin/jobs/trigger/{name}` is the public path for
+authorized human operators to invoke the same logic.
+
+## Common conventions
+
+### JSON shape
+
+Bodies are JSON unless explicitly noted (the blocklist endpoint
+returns `text/plain` by default). Successful responses use the shape
+the OpenAPI document describes; errors always look like:
+
+```json
+{ "error": "validation_failed", "details": { "ip": "must be a valid IPv4 or IPv6 address" } }
+```
+
+`details` is present on `400 validation_failed` only. Other errors
+(`401`, `403`, `404`, `409`, `412`, `429`, `500`) carry just `error`
+and sometimes a sibling diagnostic field (`provider` and `missing` for
+`412 no_credential`, `consumers` for `409 policy_in_use`, etc.).
+
+### Pagination
+
+Paginated lists take `page` (1-indexed, default 1) and `page_size`
+(default 50, max 200). The response carries `{items, page, page_size,
+total}`. Don't compute total pages from `total / page_size` on the
+client; do `ceil`. The api returns `items: []` past the last page
+rather than 404.
+
+### ETags
+
+`GET /api/v1/blocklist` returns an `ETag` (SHA-256 over the rendered
+body, excluding `generated_at`). Re-fetching with `If-None-Match: "<etag>"`
+returns `304 Not Modified` and an empty body. The `ETag` value is
+content-type-sensitive — `text/plain` and `application/json` produce
+different ETags for the same logical state.
+
+### Rate limiting
+
+Public endpoints are rate-limited at **60 req/s per token** (configurable
+via `API_RATE_LIMIT_PER_SECOND`). Token-bucket; refill rate equals the
+configured per-second value, bucket size is `2x` the refill rate. On
+exhaustion: `429 Too Many Requests` with `Retry-After: 1` (seconds).
+Admin endpoints aren't rate-limited.
+
+### IP normalization
+
+Every IP in a request body or path segment is normalized:
+
+- IPv4 like `203.0.113.42` is canonicalised and stored as a 16-byte
+  IPv4-mapped-in-IPv6 binary (`::ffff:203.0.113.42`). v4 input is
+  echoed back as v4 in responses.
+- IPv6 is canonicalised (`2001:DB8::1` → `2001:db8::1`).
+- Embedded zone identifiers (`fe80::1%eth0`) are rejected.
+- `"203.0.113.42 "` (whitespace) is rejected — strip on the client.
+
+CIDRs follow the same rules; the network address is canonicalised so
+`203.0.113.55/24` is silently normalized to `203.0.113.0/24` and the
+response includes `normalized_from: "203.0.113.55/24"` so the caller
+can see the change.
+
+### Timestamps
+
+All timestamps are UTC, ISO 8601, with `Z` suffix:
+`2026-04-29T10:00:00Z`. Don't try to send local-time strings.
+
+## Worked examples
+
+### Posting an abuse report
+
+```bash
+curl -X POST http://localhost:8081/api/v1/report \
+  -H "Authorization: Bearer $IRDB_REP_TOKEN" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "ip": "203.0.113.42",
+    "category": "brute_force",
+    "metadata": {"url": "/wp-login.php", "ua": "Mozilla/5.0 (...)"}
+  }'
+
+# 202 Accepted
+# {"report_id": 17, "ip": "203.0.113.42", "received_at": "2026-04-29T10:00:00Z"}
+```
+
+The score for `(203.0.113.42, brute_force)` is updated synchronously
+in `ip_scores`. Bulk decay is reapplied periodically by the
+`recompute-scores` job.
+
+### Pulling a blocklist
+
+```bash
+# Plain text — one IP or CIDR per line.
+curl http://localhost:8081/api/v1/blocklist \
+  -H "Authorization: Bearer $IRDB_CON_TOKEN"
+# 203.0.113.42
+# 198.51.100.0/24
+
+# JSON — richer per-entry data.
+curl 'http://localhost:8081/api/v1/blocklist?format=json' \
+  -H "Authorization: Bearer $IRDB_CON_TOKEN"
+# {"count":2,"generated_at":"2026-04-29T10:00:00Z","policy":"moderate","entries":[...]}
+
+# ETag round-trip — second request returns 304 if nothing changed.
+ETAG=$(curl -sI -H "Authorization: Bearer $IRDB_CON_TOKEN" \
+  http://localhost:8081/api/v1/blocklist | awk '/^etag:/i {print $2}' | tr -d '\r')
+curl -i -H "Authorization: Bearer $IRDB_CON_TOKEN" \
+  -H "If-None-Match: $ETAG" \
+  http://localhost:8081/api/v1/blocklist
+# HTTP/1.1 304 Not Modified
+```
+
+Drop-in shell wrappers for iptables, nginx, and HAProxy are in
+[`examples/consumers/`](../examples/consumers/).
+
+### Admin: search IPs (via service-token impersonation)
+
+This is what the UI BFF does on every admin page. You'd only do this
+yourself if you're building a replacement BFF.
+
+```bash
+curl 'http://localhost:8081/api/v1/admin/ips?q=203.0.113&page=1&page_size=25' \
+  -H "Authorization: Bearer $UI_SERVICE_TOKEN" \
+  -H "X-Acting-User-Id: 7"
+# {"items": [...], "page": 1, "page_size": 25, "total": 42}
+```
+
+The api validates the service token, looks up user `7`, applies that
+user's role to the request, and writes any audit row as
+`actor_kind=user, actor_id=7` (never the service token).
+
+### Admin: search IPs (via admin-kind token)
+
+Direct path for automation that doesn't go through a BFF.
+
+```bash
+curl 'http://localhost:8081/api/v1/admin/ips?q=203.0.113' \
+  -H "Authorization: Bearer $IRDB_ADM_TOKEN"
+# Same response shape; no impersonation header.
+```
+
+The audit row records `actor_kind=admin-token, actor_id=<token row id>`.
+
+## OpenAPI
+
+Canonical reference for endpoint shapes:
+
+- **Viewer**: `http://localhost:8081/api/docs` (RapiDoc)
+- **YAML**: `http://localhost:8081/api/v1/openapi.yaml`
+
+If anything in this document conflicts with the OpenAPI spec, the OpenAPI
+spec wins — file an issue against this doc. Discrepancies between docs
+and code are a hard CI failure
+(`scripts/check-doc-endpoints.sh`).

+ 161 - 0
doc/api-reference.md

@@ -0,0 +1,161 @@
+# API reference
+
+The OpenAPI document at **[`/api/v1/openapi.yaml`](http://localhost:8081/api/v1/openapi.yaml)**
+is canonical for endpoint shapes, request bodies, response schemas,
+and error envelopes. Browse it interactively at
+**[`/api/docs`](http://localhost:8081/api/docs)** (RapiDoc).
+
+This file documents the bits OpenAPI doesn't cleanly express.
+
+## Rate-limit headers
+
+Public endpoints return `429 Too Many Requests` when the per-token
+bucket is exhausted, with:
+
+```http
+HTTP/1.1 429 Too Many Requests
+Retry-After: 1
+Content-Type: application/json
+
+{"error":"rate_limited"}
+```
+
+`Retry-After` is always `1` (second) — the bucket refills at
+`API_RATE_LIMIT_PER_SECOND` per second, so 1 s of wait restores at
+least one token. Don't sleep longer than that on a 429; just back off
+once and try again.
+
+Admin endpoints aren't rate-limited; the UI's request volume is
+bounded by human interaction.
+
+## ETag semantics
+
+`GET /api/v1/blocklist` is the only endpoint that serves ETags today.
+Rules:
+
+- The ETag is `SHA-256` over the rendered body, **excluding**
+  `generated_at` (which changes every cache rebuild).
+- Different `format=` values yield different ETags by design — the
+  bytes differ, so the validator differs.
+- The api accepts both strong (`"abc"`) and weak (`W/"abc"`) forms in
+  `If-None-Match`, and the wildcard `*`.
+- A 304 response carries no body and copies the `ETag` and the
+  `X-Blocklist-*` headers from the would-be 200.
+
+The cache TTL is `BLOCKLIST_CACHE_TTL_SECONDS` (default 30 s) per
+policy. Mutations to `policies`, `manual_blocks`, or `allowlist`
+invalidate the affected cache entries; cross-replica visibility lags
+by up to that TTL when running multiple api replicas.
+
+## Impersonation header
+
+The api accepts `X-Acting-User-Id: <integer>` **only** in combination
+with a `service` token. On any other token kind, the header is
+silently ignored (it's not an error to send it; the api just doesn't
+use it).
+
+| Auth combination                                  | Result                          |
+|---------------------------------------------------|---------------------------------|
+| Service token, header present, user exists       | RBAC applied for the user; audited as `actor_kind=user, actor_id=<header>` |
+| Service token, header missing                    | `400 Bad Request`               |
+| Service token, header malformed (non-integer)    | `400 Bad Request`               |
+| Service token, header points at unknown user     | `404 Not Found`                 |
+| Service token, user is disabled                  | `403 Forbidden`                 |
+| Admin/reporter/consumer token, header present     | header ignored; audited normally |
+| Admin/reporter/consumer token, header missing    | normal path                     |
+
+The Auth API (`/api/v1/auth/*`) is service-token-only and **does not
+take the impersonation header** — those endpoints exist to *produce*
+the user record the BFF would later impersonate. Sending the header
+there is silently ignored.
+
+## Response envelopes
+
+Most endpoints return a domain object directly (`Reporter`,
+`Consumer`, `Token`, etc.) for single-resource GETs and POSTs.
+Paginated lists use `{items, page, page_size, total}`. There is no
+top-level `data` wrapper today.
+
+Reasonable assumption for **future** batched endpoints (not yet
+built): a `meta` envelope alongside `items` would be added rather than
+re-shaping existing responses. The contract is additive; we won't
+introduce `{data: ...}` and break every existing client.
+
+Token creation has one envelope variation: it returns the normal
+`Token` shape **plus** a `raw_token` field that's the only place the
+raw value ever appears. Audit and list endpoints never carry
+`raw_token`. After the create response, the raw value can't be
+recovered — only revoked + re-issued.
+
+## Date formats
+
+Every timestamp is UTC, ISO 8601 with `Z` suffix:
+`2026-04-29T10:00:00Z`. Inputs accept anything PHP's `DateTimeImmutable`
+parses (so `2026-04-29T10:00:00+00:00` works too), but outputs are
+canonicalised. Microsecond precision is **not** guaranteed in `v1`;
+the api stores `DATETIME(6)` on MySQL but only emits seconds.
+
+## IP and CIDR formats
+
+- IPv4: dotted quad. Trailing whitespace is rejected; embedded
+  whitespace is rejected. Stored as 16-byte binary
+  (IPv4-mapped-in-IPv6 prefix `::ffff:0:0/96`).
+- IPv6: any RFC-4291 form. Canonicalised on output (no leading zeros,
+  longest run of zeros compressed once). Zone identifiers
+  (`fe80::1%eth0`) are rejected.
+- CIDRs: network address + prefix. Non-canonical input is silently
+  normalized, with `normalized_from` echoed on the response when the
+  normalization changed the input.
+
+URL path segments take an IP directly (`/api/v1/admin/ips/203.0.113.42`,
+`/api/v1/admin/ips/2001:db8::1`). Slim's default segment regex
+disallows colons; the api uses `/{ip:.+}` to allow IPv6 paths.
+
+## Audit log shape
+
+The on-disk schema column names are `target_type` and `target_id`;
+the API surfaces them under the brief's vocabulary
+**`entity_type`** and **`entity_id`** for clarity. Filter parameters
+use the API names too. If you're inspecting the database directly,
+remember the renaming.
+
+`actor_kind` enum: `user`, `admin-token`, `reporter`, `consumer`,
+`system`. `actor_id` is a string in the API for forward-compat (token
+ids, reporter ids, user ids could diverge in shape over time); cast on
+the client.
+
+Audit emission failures are logged but never propagate. A successful
+state-changing call always commits; the audit row is best-effort. If
+you observe a state change without a corresponding audit row, check
+the api's structured log for `audit_emit_failed`.
+
+## Job triggering
+
+`POST /api/v1/admin/jobs/trigger/{name}` whitelists the request body
+to:
+
+```json
+{ "full": true, "max_rows": 10000, "reenrich": true }
+```
+
+Other keys are silently dropped. The intent is to prevent admins from
+smuggling config-shaped values into the runner via shape mismatch.
+
+The `refresh-geoip` job short-circuits with `412 no_credential` when
+the configured provider is opt-in (MaxMind / IPinfo) and its
+credential isn't set. The DB-IP default never returns 412.
+
+```json
+{
+  "error": "no_credential",
+  "provider": "maxmind",
+  "missing": "MAXMIND_LICENSE_KEY"
+}
+```
+
+## See also
+
+- [`api-overview.md`](./api-overview.md) — base URL, auth summary, conventions, worked examples.
+- [`auth-flows.md`](./auth-flows.md) — every auth path in detail.
+- [`architecture.md`](./architecture.md) — system overview.
+- [`frontend-development.md`](./frontend-development.md) — for replacement UIs.

+ 137 - 0
doc/architecture.md

@@ -0,0 +1,137 @@
+# Architecture
+
+> Audience: operators running IRDB, and engineers building replacement
+> frontends. Read this first if you're new to the codebase.
+
+## System overview
+
+IRDB is an **IP reputation database** — it ingests abuse reports about
+specific IP addresses, applies a decaying weighted score per category,
+and distributes tailored block lists to firewalls and proxies.
+Reporters (web servers, IDS, fail2ban-style agents) push events; each
+**consumer** (firewall, proxy) pulls a block list shaped by a named
+**policy** that decides which categories and thresholds count.
+
+The system is shipped as a small Docker Compose stack. Reporters and
+consumers talk JSON to the `api` over an authenticated REST surface;
+human operators use a thin PHP UI that calls the same api with a
+service token plus an impersonation header. The split is deliberate:
+the UI is replaceable, the api is the contract.
+
+## Container topology
+
+```mermaid
+flowchart LR
+    subgraph clients[Clients]
+        rep[Reporters<br/>web/IDS/fail2ban]
+        con[Consumers<br/>firewalls/proxies]
+        adm[Admins<br/>browser]
+    end
+
+    subgraph stack[Compose stack]
+        ui[ui<br/>PHP+Twig BFF<br/>:8080]
+        api[api<br/>Slim+FrankenPHP<br/>:8081]
+        migrate[migrate<br/>one-shot Phinx]
+        sched[scheduler<br/>busybox crond]
+        mysql[(mysql<br/>optional)]
+    end
+
+    db[(SQLite or MySQL)]
+
+    rep -- POST /api/v1/report ----> api
+    con -- GET /api/v1/blocklist --> api
+    adm -- HTTPS --> ui
+    ui -- service token + X-Acting-User-Id --> api
+    sched -- POST /internal/jobs/tick --> api
+    migrate --> db
+    api --> db
+    mysql -.-> db
+```
+
+Five services in compose; only two run continuously (`api` + `ui`).
+`migrate` is one-shot (`restart: "no"`); `scheduler` is opt-in via the
+`compose.scheduler.yml` overlay; `mysql` is opt-in via uncommented
+config. Single-host SQLite deployments run with just `migrate` + `api`
++ `ui`; the data volume is shared between `migrate` and `api`.
+
+## Where state lives
+
+| Where                            | What                                                       |
+|----------------------------------|------------------------------------------------------------|
+| `irdb-data` Docker volume        | SQLite database file `/data/irdb.sqlite`, GeoIP MMDBs at `/data/geoip/`. Owned exclusively by the `api` container; `migrate` mounts it briefly to apply schema changes. |
+| `mysql-data` Docker volume       | Optional MySQL `/var/lib/mysql`. Replaces the SQLite path when `DB_DRIVER=mysql`. |
+| `ui` container's writable layer  | PHP file-backed sessions (`/tmp` per the FrankenPHP base). Tied to a single replica; sticky sessions required if scaling UI horizontally. |
+| `.env`                           | All secrets (service token, internal job token, OIDC client secret, MySQL password, local-admin Argon2id hash). |
+| **Browser localStorage**         | Light/dark theme preference only. Session is in a Secure cookie. |
+
+Notably absent: the `ui` container has zero persistent state. Replace
+its image and nothing of operational importance is lost. The api owns
+everything.
+
+## Stable surfaces vs replaceable parts
+
+The contract for future frontends, integrators, and replacement UIs.
+**Anything in the "stable" column should not break across `v1` minor
+releases.**
+
+| Surface                                              | Stability      | Notes                                                                                  |
+|------------------------------------------------------|----------------|----------------------------------------------------------------------------------------|
+| `/api/v1/*` paths and request/response shapes        | **stable**     | Additive changes only within `v1`. Breaking changes ship as `v2`. OpenAPI is canonical. |
+| Token kinds (`reporter`, `consumer`, `admin`, `service`) | **stable** | Strings are persisted in `api_tokens.kind` and used in audit logs.                    |
+| RBAC roles (`viewer`, `operator`, `admin`)            | **stable**     | Persisted in `users.role` and `api_tokens.role`.                                       |
+| `users` shape and `oidc_role_mappings` semantics      | **stable**     | Future SPAs/native UIs upsert via `/api/v1/auth/users/upsert-oidc` the same way the BFF does today. |
+| Service-token + `X-Acting-User-Id` impersonation     | **stable**     | The pattern any BFF replacement uses; the API doesn't trust the header without a service token. |
+| Error envelope `{error, details?}`                   | **stable**     | Validation errors include `details`; auth failures don't.                              |
+| Audit `actor_kind` enum                              | **stable**     | `user`, `admin-token`, `reporter`, `consumer`, `system`. Used by external SIEM exporters in future work. |
+| Twig template names under `ui/resources/views/`      | replaceable    | Tied to the current UI implementation; replacement UIs render however they like.       |
+| UI route paths under `/app/*`                         | replaceable    | Browser-only; not part of any contract.                                                |
+| Internal class names, method signatures, file layout | replaceable    | Refactoring is fair game.                                                              |
+| `/internal/jobs/*`                                    | replaceable    | Scheduler-only; no public guarantee. Not in OpenAPI.                                   |
+| Twig globals, htmx attributes, Alpine components     | replaceable    | Implementation details of this UI.                                                     |
+
+## Why this split
+
+**Backend-for-Frontend (BFF) pattern.** The `api` is a pure JSON service:
+no HTML, no sessions, no cookies. The `ui` is a thin browser-facing
+process that owns OIDC handshakes, browser sessions, CSRF, and Twig
+rendering — but no business logic, no scoring, no database. Every
+admin page on the UI fetches from the api with the service token plus
+the acting user's id.
+
+Why this matters:
+
+1. **The UI is replaceable.** A team that wants Vue, Svelte, or a
+   Tauri desktop app can replace the `ui` container without touching
+   the api. The contract is the OpenAPI document and the impersonation
+   pattern. See [`frontend-development.md`](./frontend-development.md).
+
+2. **No HTML in the api.** Other clients (firewalls, fail2ban,
+   monitoring) call the same admin endpoints the UI does. If the api
+   started rendering Twig, those integrations would suddenly carry HTML
+   they don't want.
+
+3. **Auditing is honest.** The api records the impersonated user as
+   the actor (`actor_kind=user`) when called via the BFF, never the
+   service token. An admin token used directly records as
+   `actor_kind=admin-token`. Future external authentication paths can
+   plug in alongside without breaking the audit trail.
+
+4. **The api can scale independently.** With MySQL, the api is
+   stateless and can run as N replicas behind a load balancer; the
+   `job_locks` table mediates between them. The UI is typically
+   single-replica because of file-backed sessions.
+
+The trade-off: every UI request makes one or more outbound calls to
+the api, which costs latency and an HTTP hop. In practice the
+containers run on the same Docker network and the per-request cost is
+sub-millisecond.
+
+## Where the rest of the docs live
+
+- [`api-overview.md`](./api-overview.md) — the public API surface, with worked examples.
+- [`auth-flows.md`](./auth-flows.md) — every authentication flow (machine, BFF, OIDC, local admin), Entra setup, future user-token sketch.
+- [`frontend-development.md`](./frontend-development.md) — the headline doc for replacement UIs.
+- [`api-reference.md`](./api-reference.md) — short. Pointer to OpenAPI plus the things OpenAPI doesn't cleanly express (rate limits, ETag semantics, impersonation header, response conventions).
+
+The OpenAPI document itself is at `api/public/openapi.yaml` and served
+at `/api/v1/openapi.yaml` with a viewer at `/api/docs`.

+ 316 - 0
doc/auth-flows.md

@@ -0,0 +1,316 @@
+# Auth flows
+
+> Audience: anyone wiring a client to IRDB. Covers all four token kinds,
+> the BFF impersonation pattern, the OIDC and local-admin login flows,
+> and the recommended extension point for SPA / native / mobile clients
+> that don't use a BFF.
+
+## Overview
+
+| Caller                   | Token kind  | Extra header                  | Audited as                   |
+|--------------------------|-------------|-------------------------------|------------------------------|
+| Web/IDS/fail2ban agent   | `reporter`  | —                             | `actor_kind=reporter`        |
+| Firewall / proxy         | `consumer`  | —                             | `actor_kind=consumer`        |
+| Automation script        | `admin`     | —                             | `actor_kind=admin-token`     |
+| UI BFF (current)         | `service`   | `X-Acting-User-Id: <id>`      | `actor_kind=user, actor_id=<id>` |
+| Future SPA/native/mobile | (not built) | (would issue user-bound tokens) | `actor_kind=user`           |
+
+The api refuses requests with no token. It always returns the same `401
+unauthorized` envelope for any auth failure (bad token, expired,
+revoked, wrong-kind for the route) — callers can never tell which.
+RBAC failures (authenticated but role-denied) are `403`; an audit row
+is **not** emitted on either.
+
+## Token format
+
+Every IRDB token is `irdb_<kind3>_<32 base32 chars>` where `kind3` is
+one of `rep`, `con`, `adm`, `svc`. 160 bits of entropy. The first 8
+characters (`irdb_<kind3>`) form the `token_prefix` recorded for log
+triage; the full string is SHA-256 hashed at rest in
+`api_tokens.token_hash`.
+
+The raw value is shown **once** at creation. After that the api can
+verify a token by hashing the presented value and looking it up — but
+nobody, including admins, can recover the raw form. Treat tokens like
+passwords.
+
+## Reporter / consumer flow (machine clients)
+
+```mermaid
+sequenceDiagram
+    participant Agent as Reporter or Consumer
+    participant API
+    participant DB
+
+    Agent->>API: GET /api/v1/blocklist<br/>Authorization: Bearer irdb_con_…
+    API->>DB: lookup token by hash<br/>verify kind=consumer, not revoked, not expired
+    DB-->>API: TokenRecord
+    API->>API: build blocklist for consumer's policy<br/>(30s cache)
+    API-->>Agent: 200 text/plain<br/>ETag: …
+```
+
+For reporters the path is identical except the agent sends `POST
+/api/v1/report` and the api validates `kind=reporter`. The token
+binds to a row in `reporters` / `consumers`, which carries the
+trust weight (reporters) or policy id (consumers). Audit records
+the FK on each successful state change.
+
+## Admin token flow
+
+```mermaid
+sequenceDiagram
+    participant Script as Automation
+    participant API
+    participant DB
+
+    Script->>API: GET /api/v1/admin/ips<br/>Authorization: Bearer irdb_adm_…
+    API->>DB: lookup token by hash<br/>verify kind=admin, role=viewer/operator/admin
+    DB-->>API: TokenRecord(role=admin)
+    API->>API: RBAC: route requires Viewer → ok
+    API-->>Script: 200 {items, page, …}
+```
+
+Admin tokens carry their own role (set at creation). The route's
+required role is checked against the token's role; same matrix as the
+UI uses. No impersonation — the audit row records `actor_kind=admin-token,
+actor_id=<token id>`, not a user.
+
+Admin tokens are the recommended path for **automation that doesn't
+go through a browser** — CI scripts, Terraform providers, monitoring
+that creates tokens for new reporters automatically. Don't use a
+service token for those use cases; service tokens are for the BFF.
+
+## UI BFF flow (current)
+
+The `ui` container holds the `service` token. On every admin-API call
+it adds **two** headers:
+
+```http
+Authorization: Bearer <UI_SERVICE_TOKEN>
+X-Acting-User-Id: <user-id-from-session>
+```
+
+```mermaid
+sequenceDiagram
+    participant Browser
+    participant UI as ui (BFF)
+    participant API as api
+    participant DB
+
+    Browser->>UI: GET /app/dashboard<br/>(session cookie)
+    UI->>UI: SessionMiddleware → user_id=7
+    UI->>API: GET /api/v1/admin/stats/dashboard<br/>Authorization: Bearer <svc><br/>X-Acting-User-Id: 7
+    API->>DB: validate service token<br/>load user 7 + role
+    DB-->>API: User(role=admin)
+    API->>API: RBAC: route requires Viewer → ok<br/>audit context: user=7
+    API-->>UI: 200 {stats}
+    UI->>Browser: 200 HTML (Twig)
+```
+
+The api **never trusts `X-Acting-User-Id` without a service token** —
+on a reporter / consumer / admin token the header is silently ignored.
+On a service token without the header the api returns `400`.
+
+This pattern works because:
+
+1. The service token authenticates the **caller** (the BFF process).
+2. The header identifies the **principal** (the human in front of the browser).
+3. Audit records the principal, not the caller — so an audit row never
+   reads as "the service token did this".
+
+Building a different BFF (Node, Rails, Django) is mostly a matter of
+implementing this two-header pattern, plus session management. See
+[`frontend-development.md`](./frontend-development.md).
+
+### Local admin login
+
+The local admin only exists if `LOCAL_ADMIN_ENABLED=true`. The UI
+validates the password against `LOCAL_ADMIN_PASSWORD_HASH` (Argon2id),
+then asks the api to upsert a user record:
+
+```mermaid
+sequenceDiagram
+    participant Browser
+    participant UI
+    participant API
+
+    Browser->>UI: POST /login/local<br/>(username, password, csrf_token)
+    UI->>UI: csrf check; argon2id verify against env hash
+    UI->>API: POST /api/v1/auth/users/upsert-local<br/>{"username":"admin"}<br/>Authorization: Bearer <svc>
+    API-->>UI: {user_id, role: "admin", is_local: true}
+    UI->>UI: regenerate session id; store user_id
+    UI-->>Browser: 303 → /app/dashboard
+```
+
+The local admin is always role `admin`. Generate the password hash:
+
+```bash
+php -r "echo password_hash('your-password', PASSWORD_ARGON2ID);"
+```
+
+Then double every `$` in the resulting hash before pasting into `.env`
+— Docker Compose's variable substitution otherwise eats them. For
+production deployments **disable** the local admin (`LOCAL_ADMIN_ENABLED=false`)
+once an OIDC user with admin role exists; it's a defence-in-depth
+recommendation, not a hard requirement.
+
+### OIDC login (Microsoft Entra ID)
+
+```mermaid
+sequenceDiagram
+    participant Browser
+    participant UI
+    participant Entra
+    participant API
+
+    Browser->>UI: GET /login/oidc
+    UI->>Browser: 302 → Entra authorize URL<br/>(PKCE code_challenge, state, nonce)
+    Browser->>Entra: GET /authorize
+    Entra-->>Browser: 302 → /oidc/callback?code=…&state=…
+    Browser->>UI: GET /oidc/callback
+    UI->>Entra: POST /token (code + verifier + secret)
+    Entra-->>UI: id_token + access_token
+    UI->>UI: validate id_token (sig/iss/aud/nonce/exp)<br/>extract sub, email, groups
+    UI->>API: POST /api/v1/auth/users/upsert-oidc<br/>{subject, email, display_name, groups}
+    API->>API: resolve role via oidc_role_mappings<br/>(else OIDC_DEFAULT_ROLE)
+    API-->>UI: {user_id, role}
+    UI-->>Browser: 303 → /app/dashboard
+```
+
+When `role=none` (no mapping matched and `OIDC_DEFAULT_ROLE=none`), the
+UI sends the user to `/no-access` instead of completing the session.
+
+### Entra setup walkthrough
+
+> Tested against an Entra workforce tenant. Replace example values with
+> your own.
+
+1. **Register an application** in the Entra admin centre
+   (`entra.microsoft.com` → App registrations → New registration):
+   - Name: `IRDB — UI` (any human-readable label).
+   - Account types: "Accounts in this organizational directory only" is
+     fine for the common single-tenant case. Multi-tenant only if you
+     genuinely need it.
+   - Redirect URI: type `Web`, URI matching `OIDC_REDIRECT_URI`, e.g.
+     `http://localhost:8080/oidc/callback` for dev,
+     `https://reputation.example.com/oidc/callback` for prod.
+
+   Save the **Application (client) ID** → `OIDC_CLIENT_ID` and the
+   **Directory (tenant) ID**, which goes into
+   `OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0`.
+
+2. **Create a client secret** (Certificates & secrets → Client secrets
+   → New). Copy the secret **Value** (not the Secret ID) →
+   `OIDC_CLIENT_SECRET`. The portal hides it after you leave the page.
+
+3. **Configure the `groups` claim** (Token configuration → Add groups
+   claim → Security groups). For each token type (ID + Access), pick
+   "Group ID" — the api expects Entra group object IDs.
+   `sAMAccountName` or display names will not match.
+
+   If the user has more than ~150 groups assigned, Entra emits an
+   `_claim_names` overage indicator instead of the raw `groups` array.
+   The current implementation doesn't follow the indicator (would
+   require a Graph call); keep test users under the threshold or
+   reduce their group count.
+
+4. **API permissions**: the default `User.Read` is enough for `openid
+   profile email` plus the groups claim. No additional scopes unless
+   you call Graph elsewhere. Click **Grant admin consent** if your
+   tenant requires it.
+
+5. **Map groups to roles** in IRDB. The dedicated admin UI for this
+   is forthcoming; until then, write to the `oidc_role_mappings` table
+   directly:
+
+   ```sql
+   INSERT INTO oidc_role_mappings (group_id, role) VALUES
+       ('11111111-1111-1111-1111-111111111111', 'admin'),
+       ('22222222-2222-2222-2222-222222222222', 'operator'),
+       ('33333333-3333-3333-3333-333333333333', 'viewer');
+   ```
+
+   Group IDs are GUID/UUID strings as Entra emits them. The api
+   resolves the user's role on each login as the *highest* matching
+   role across their groups.
+
+6. **Set the UI env** (in the repo's `.env`):
+
+   ```dotenv
+   OIDC_ENABLED=true
+   OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
+   OIDC_CLIENT_ID=<application-client-id>
+   OIDC_CLIENT_SECRET=<value-from-step-2>
+   OIDC_REDIRECT_URI=http://localhost:8080/oidc/callback
+   ```
+
+   Restart `ui`. The login page now offers "Sign in with Microsoft".
+
+#### Troubleshooting
+
+- **`AADSTS50011: redirect URI mismatch`** — the URI in the request
+  doesn't match what's registered. `/oidc/callback` is case-sensitive;
+  the host must match exactly.
+- **`groups` claim missing in the ID token** — re-check step 3 above,
+  including selecting the claim for the **ID token** type (not just
+  access token). Sign in again from a private window so a fresh token
+  is issued.
+- **Role always `viewer`** — the matched group ID isn't in
+  `oidc_role_mappings`. The api returns `OIDC_DEFAULT_ROLE` when no
+  row matches.
+- **`OIDC handshake failed: state mismatch`** — the session cookie was
+  lost between `/login/oidc` and `/oidc/callback` (third-party-cookie
+  blocking on cross-site flows; session fixation reset). Confirm
+  `SameSite=Lax` is set and that the redirect URI is on the same host
+  the user reaches the UI through.
+
+## Future: direct user tokens
+
+> **NOT IMPLEMENTED.** This section sketches the recommended extension
+> point for replacement frontends that don't want a BFF.
+
+The current model assumes a server-side process that holds the service
+token. A native or single-page app can't do that — the service token
+would be exposed to the browser/device and would impersonate every
+user. Instead, the api would need to issue **user-bound tokens**
+directly, and a new endpoint group would handle the flow:
+
+```
+POST /api/v1/auth/oauth/start          → returns an OIDC redirect URL
+GET  /api/v1/auth/oauth/callback       → exchanges code; issues user token
+POST /api/v1/auth/oauth/refresh        → exchanges refresh token
+POST /api/v1/auth/oauth/revoke         → revokes a user token
+```
+
+The user token would be a fifth `kind=user` row in `api_tokens`,
+bound to a `user_id` rather than a reporter/consumer. The same RBAC
+middleware would apply.
+
+This is **future work**. If you're building an SPA today, the
+recommended path is **option (b)** in
+[`frontend-development.md`](./frontend-development.md): a thin BFF
+that mints short-lived signed cookies the SPA presents.
+
+## CSRF, sessions, CORS
+
+- **CSRF** — UI forms only. The UI middleware injects a per-session
+  token into every state-changing form and validates on submit
+  (constant-time compare, header or form-field). The api is stateless
+  and Bearer-authenticated, so CSRF doesn't apply.
+- **Sessions** — PHP native, file-backed in the `ui` container's
+  writable layer. 8 h idle, 24 h absolute (configurable via
+  `SESSION_IDLE_SECONDS` / `SESSION_ABSOLUTE_SECONDS`). Tied to a
+  specific UI replica — sticky sessions required if scaling UI
+  horizontally. Session ids regenerate after every auth-state change
+  (login success, logout) to defeat fixation.
+- **CORS** — the api emits CORS headers for the configured `UI_ORIGIN`
+  with `Access-Control-Allow-Credentials: true` and
+  `X-Acting-User-Id` on the allow-list. The current PHP UI calls
+  server-to-server and doesn't trigger CORS; the headers exist for
+  future browser-direct frontends.
+
+## See also
+
+- [`api-overview.md`](./api-overview.md) — endpoint groups, conventions, worked examples.
+- [`frontend-development.md`](./frontend-development.md) — three patterns for replacement UIs.
+- [`/api/v1/openapi.yaml`](http://localhost:8081/api/v1/openapi.yaml) — canonical endpoint shapes.

+ 285 - 0
doc/frontend-development.md

@@ -0,0 +1,285 @@
+# Building a new frontend
+
+> Audience: anyone tasked with rewriting the UI in Vue, building a Tauri
+> desktop app, or a mobile client. Read this before touching any code.
+
+## Read this first
+
+The api's contract is **stable**. The PHP+Twig UI is **replaceable**.
+You can throw out the entire `ui/` directory and replace it with a
+Next.js app, a Vue SPA, a Tauri desktop, or a SwiftUI client without
+the api caring at all — provided the new frontend respects the
+authentication model.
+
+What's stable, in order of importance:
+
+1. **`/api/v1/*` paths and shapes**, as documented in OpenAPI
+   (`/api/v1/openapi.yaml`).
+2. **Token kinds** (`reporter`, `consumer`, `admin`, `service`).
+3. **RBAC roles** (`viewer`, `operator`, `admin`).
+4. **The impersonation pattern**: service token + `X-Acting-User-Id`.
+5. **The `users` shape and `oidc_role_mappings` semantics**.
+
+What's **not** stable (and you shouldn't depend on):
+
+- Template names under `ui/resources/views/`.
+- UI route paths under `/app/*`.
+- Internal class names, file layout, migrations of UI-only state.
+- The `/internal/jobs/*` surface — scheduler-only, not in OpenAPI.
+
+The full stable-vs-replaceable table is in
+[`architecture.md`](./architecture.md#stable-surfaces-vs-replaceable-parts).
+
+## Three integration patterns
+
+### (a) BFF replacement — drop-in for the current PHP UI
+
+Easiest path. A new server process holds the service token, manages
+browser sessions, and forwards each request to the api with the
+two-header pattern. Works for SSR-style frontends (Next.js, Nuxt,
+Rails, Django, Phoenix).
+
+What the BFF owns:
+- The OIDC redirect / callback flow (PKCE).
+- Browser sessions (any backend; the current PHP UI uses file-backed,
+  but Redis or a signed cookie store works).
+- A local-admin form (optional).
+- HTML rendering or hydration of an SPA shell.
+- CSRF for its own forms.
+
+What the BFF does **not** own:
+- Business logic. Scoring, RBAC, decay all live in the api.
+- Database access. The BFF holds zero persistent state of its own.
+- The decision of "who is this user". The api answers that via
+  `/api/v1/auth/users/upsert-oidc` and `/api/v1/auth/users/upsert-local`.
+
+Pseudocode for the three critical bits in a Node/Express BFF:
+
+```js
+// 1. Validate an OIDC ID token; upsert via the api.
+app.get('/oidc/callback', async (req, res) => {
+    const tokens = await exchangeCode(req.query.code, req.session.pkceVerifier);
+    const id = await validateIdToken(tokens.id_token, oidcConfig);
+
+    const user = await fetch(`${API_BASE}/api/v1/auth/users/upsert-oidc`, {
+        method: 'POST',
+        headers: {
+            'Authorization': `Bearer ${UI_SERVICE_TOKEN}`,
+            'Content-Type': 'application/json',
+        },
+        body: JSON.stringify({
+            subject: id.sub,
+            email: id.email,
+            display_name: id.name,
+            groups: id.groups ?? [],
+        }),
+    }).then(r => r.json());
+
+    if (user.role === null) {
+        return res.redirect('/no-access');
+    }
+
+    req.session.regenerate();          // defeat fixation
+    req.session.user_id = user.id;
+    req.session.role = user.role;
+    res.redirect('/app/dashboard');
+});
+
+// 2. The two-header pattern, applied to every admin call.
+async function callApi(req, path, init = {}) {
+    const headers = new Headers(init.headers);
+    headers.set('Authorization', `Bearer ${UI_SERVICE_TOKEN}`);
+    if (req.session.user_id) {
+        headers.set('X-Acting-User-Id', String(req.session.user_id));
+    }
+    return fetch(`${API_BASE}${path}`, { ...init, headers });
+}
+
+// 3. A typical handler: BFF is a pure proxy with HTML rendering on top.
+app.get('/app/ips', async (req, res) => {
+    const data = await callApi(req, `/api/v1/admin/ips?${req.query}`)
+        .then(r => r.json());
+    res.render('ips', { user: req.session, data });
+});
+```
+
+That's the pattern in 30 lines. The current PHP UI is the same shape,
+just longer because it does form rendering and CSRF too.
+
+### (b) SPA + thin BFF
+
+Browser SPA (Vue / React / Svelte) for rendering; a thin BFF for
+auth only. The BFF mints short-lived signed cookies the SPA presents
+on each request to the api. The SPA itself never sees the service
+token.
+
+When to choose this:
+- You want the snappy navigation and component reuse of an SPA.
+- Your auth is OIDC and you don't want to put OIDC logic in the
+  browser.
+- You're OK with one server process whose only job is auth.
+
+When **not** to choose this:
+- You want a thinner stack — pattern (a) does the same job with one
+  process and one render path.
+
+Trade-offs to think through:
+- The BFF still needs CSRF / cross-site protections for the cookie.
+- Bearer-style headers between SPA and BFF are easier than cookies if
+  you'll terminate at a single hostname.
+- The SPA can call the api directly, with the BFF's signed token
+  acting as a translation layer that converts cookie → service token
+  + impersonation header.
+
+### (c) Direct API access (native / mobile / SPA without BFF)
+
+This is what you'd want for a desktop client (Tauri, Electron) or a
+mobile app where there's no server. The user authenticates against the
+api directly, and the api issues a **user-bound token** that the
+client carries.
+
+**This needs api work first.** The user-token issuance flow doesn't
+exist today. See
+[`auth-flows.md`](./auth-flows.md#future-direct-user-tokens) for the
+sketched extension point — the recommended shape is a new
+`/api/v1/auth/oauth/*` group that mints `kind=user` tokens after an
+OIDC pass-through or device-code flow.
+
+Don't try to give the SPA a service token. The service token can
+impersonate any user; if it leaks to a browser or a native binary,
+your audit trail becomes worthless and an attacker has full admin
+without ever touching a real account.
+
+## Minimum API surface
+
+A fully-featured frontend uses about 25 endpoints. Checklist (canonical
+shapes are in OpenAPI):
+
+**Identity**
+- `GET /api/v1/admin/me`
+- `POST /api/v1/auth/users/upsert-oidc` (BFF)
+- `POST /api/v1/auth/users/upsert-local` (BFF, only if local admin enabled)
+
+**IPs**
+- `GET /api/v1/admin/ips`
+- `GET /api/v1/admin/ips/countries`
+- `GET /api/v1/admin/ips/{ip}`
+
+**Stats**
+- `GET /api/v1/admin/stats/dashboard`
+
+**Manual blocks / allowlist**
+- `GET / POST /api/v1/admin/manual-blocks`
+- `GET / DELETE /api/v1/admin/manual-blocks/{id}`
+- `GET / POST /api/v1/admin/allowlist`
+- `GET / DELETE /api/v1/admin/allowlist/{id}`
+
+**Reporters / consumers / tokens**
+- `GET / POST /api/v1/admin/reporters` (and `/{id}` PATCH/DELETE)
+- `GET / POST /api/v1/admin/consumers` (and `/{id}` PATCH/DELETE)
+- `GET / POST /api/v1/admin/tokens` (and `/{id}` DELETE)
+
+**Categories / policies**
+- `GET / POST / PATCH / DELETE /api/v1/admin/categories[/{id}]`
+- `GET / POST / PATCH / DELETE /api/v1/admin/policies[/{id}]`
+- `GET /api/v1/admin/policies/{id}/preview`
+
+**Audit / settings**
+- `GET /api/v1/admin/audit-log`
+- `GET /api/v1/admin/jobs/status`
+- `POST /api/v1/admin/jobs/trigger/{name}`
+- `GET /api/v1/admin/config`
+
+A simpler "viewer-only" frontend can skip every write endpoint. The api
+enforces RBAC server-side, so a frontend that hides the buttons is doing
+cosmetic work — the api will reject unauthorized calls regardless.
+
+## CORS
+
+For BFF patterns (a) — server-to-server calls — CORS doesn't apply.
+
+For SPA patterns (b) and (c) — browser-direct calls to the api —
+configure the api with the right `UI_ORIGIN`:
+
+```dotenv
+# api .env
+UI_ORIGIN=https://reputation.example.com
+```
+
+The api emits `Access-Control-Allow-Origin: <UI_ORIGIN>`,
+`Access-Control-Allow-Credentials: true`, and includes
+`X-Acting-User-Id` in the allow-list. Multiple origins aren't
+supported in `v1`; if you need that, deploy multiple api replicas with
+different `UI_ORIGIN` values, or front them with a reverse proxy that
+adjusts the header.
+
+## Local development
+
+Spin up only the api and a fresh SQLite, point your frontend dev
+server at it:
+
+```bash
+docker compose up -d migrate api
+# api is now reachable at http://localhost:8081
+
+# Generate an admin token to bypass the BFF locally:
+docker compose exec -T api php bin/console auth:create-token \
+    --kind=admin --role=admin --quiet
+# Use this in your frontend's dev server as IRDB_API_TOKEN.
+
+# Run your frontend dev server:
+API_BASE_URL=http://localhost:8081 npm run dev
+```
+
+For SPA-with-BFF patterns, run the BFF as a separate process. For
+direct-API patterns (when that's built), point the OIDC redirect URI
+at your dev frontend's URL.
+
+## Migration path
+
+To swap the current UI with a new one **without downtime**:
+
+1. Build the new container image; expose it on a different port.
+2. Stand it up alongside `ui` in compose with its own healthcheck.
+3. At the reverse proxy / DNS level, switch the hostname
+   (`reputation.example.com`) from old → new.
+4. Once the new UI is verified, scale the old one to zero or remove
+   it from compose.
+
+The api doesn't notice. Sessions belong to whichever UI the user
+hits, so users will be asked to log in again on the new UI — that's
+the only externally-visible side-effect of the swap.
+
+## What NOT to do
+
+- **Don't replicate business logic in the frontend.** Scoring,
+  decay, RBAC checks, manual-block CIDR containment — all of that
+  lives in the api. The frontend just renders what the api returns
+  and forwards what the user types.
+- **Don't store user data in the frontend's storage.** The session
+  knows `user_id`, `display_name`, `role`. Anything else (their
+  manual-block list, their allowlist) is the api's data; ask for it
+  on each render.
+- **Don't bypass the service-token pattern by giving the SPA the
+  service token directly.** Pattern (c) needs the user-token flow
+  built first; pattern (b) needs a BFF that mints short-lived
+  cookies. Both are above zero work — but skipping that work means
+  the audit trail no longer attributes actions to humans.
+- **Don't rely on UI route paths or template names.** The current
+  `/app/*` paths exist because the PHP UI happens to use them. A
+  Vue SPA might use `/dashboard`, `/ips`, etc.; both are fine.
+- **Don't add custom auth headers.** The api accepts exactly
+  `Authorization: Bearer …` and (for service tokens)
+  `X-Acting-User-Id: …`. Anything else is ignored.
+- **Don't try to talk to `/internal/jobs/*` from the frontend.**
+  Those routes are RFC1918 + loopback bound and authenticated with
+  the internal job token. They will return `404` from any
+  external-looking IP. The public path for triggering a job is
+  `POST /api/v1/admin/jobs/trigger/{name}`.
+
+## See also
+
+- [`api-overview.md`](./api-overview.md) — base URL, auth summary, conventions, worked curl examples.
+- [`auth-flows.md`](./auth-flows.md) — every auth path in detail; the future user-token sketch is here.
+- [`architecture.md`](./architecture.md) — system overview, container topology, stable-vs-replaceable surfaces.
+- [`/api/docs`](http://localhost:8081/api/docs) — interactive OpenAPI viewer.

+ 0 - 99
doc/oidc.md

@@ -1,99 +0,0 @@
-# OIDC setup — Microsoft Entra ID
-
-This document describes the Entra ID app-registration steps needed to make the IRDB UI's "Sign in with Microsoft" button work. Tested manually against an Entra workforce tenant; replace the example values with your own.
-
-> Polished documentation lands in M13. This file is here so M08 reviewers (and the engineer doing the manual OIDC verification step) have a reproducible path.
-
-## 1. Register an application
-
-In the Entra admin centre (`entra.microsoft.com`):
-
-1. **App registrations → New registration**
-2. **Name**: `IRDB — UI` (any human-readable label).
-3. **Supported account types**: "Accounts in this organizational directory only" is fine for the common single-tenant case. Multi-tenant only if you actually need it.
-4. **Redirect URI**: pick **Web** as the platform and use the URI matching `OIDC_REDIRECT_URI` in the UI's environment, e.g.
-   ```
-   http://localhost:8080/oidc/callback
-   ```
-   For production: `https://reputation.example.com/oidc/callback`.
-5. **Register**.
-
-Note down:
-- the application (client) ID → `OIDC_CLIENT_ID`
-- the directory (tenant) ID → used to build `OIDC_ISSUER`:
-  ```
-  OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
-  ```
-
-## 2. Create a client secret
-
-1. **Certificates & secrets → Client secrets → New client secret**
-2. Description: `irdb-ui`. Expiry: per your org policy.
-3. Copy the secret **Value** (not the Secret ID) → `OIDC_CLIENT_SECRET`. The portal hides it after you leave the page.
-
-## 3. Configure the `groups` claim in the ID token
-
-The api decides RBAC roles from the user's group memberships, so the ID token must include a `groups` claim.
-
-1. **Token configuration → Add groups claim**.
-2. **Group types**: Security groups (or whichever type your IRDB role mappings target).
-3. For each token type (ID, Access), under **Customize token properties by type**, choose **Group ID**. The api expects Entra group object IDs; emitting `sAMAccountName` or display names will not match.
-4. **Save**.
-
-If your tenant has many groups assigned to the user, Entra may emit an overage indicator (`_claim_names`) instead of the raw `groups` array. In that case you'd have to call MS Graph to enumerate groups — out of scope here. For test tenants stay under the threshold (usually ~150 groups).
-
-## 4. API permissions
-
-The default `User.Read` permission delegated to Microsoft Graph is enough to receive `openid profile email` + the `groups` claim once configured above. No additional scopes needed unless you want to call Graph elsewhere.
-
-If your tenant requires admin consent for the app, click **Grant admin consent for <tenant>** under **API permissions**.
-
-## 5. Configure the role mappings in IRDB
-
-In the api's database, populate the `oidc_role_mappings` table:
-
-```sql
-INSERT INTO oidc_role_mappings (group_id, role) VALUES
-    ('11111111-1111-1111-1111-111111111111', 'admin'),
-    ('22222222-2222-2222-2222-222222222222', 'operator'),
-    ('33333333-3333-3333-3333-333333333333', 'viewer');
-```
-
-(Group IDs are GUID/UUID strings as Entra emits them.)
-
-The admin UI for managing this lands in M10. Until then, use SQL or the seeders.
-
-`OIDC_DEFAULT_ROLE` (api env) controls behaviour for users whose groups don't match any row:
-- `viewer` (default) — read-only fallback.
-- `none` — refuse the login; the UI shows the `/no-access` page.
-
-## 6. UI environment
-
-Fill in the matching values in `.env`:
-
-```dotenv
-OIDC_ENABLED=true
-OIDC_ISSUER=https://login.microsoftonline.com/<tenant-id>/v2.0
-OIDC_CLIENT_ID=<application-client-id>
-OIDC_CLIENT_SECRET=<value-from-step-2>
-OIDC_REDIRECT_URI=http://localhost:8080/oidc/callback   # or your prod URL
-```
-
-## 7. Test users
-
-Assign at least one test user to one of the groups you mapped. With Entra free tiers you can add guest accounts to the tenant; for paid tiers create dedicated test users. Sign them out of any browser session and walk the flow end-to-end:
-
-1. `GET /` → `302 /login`.
-2. Click **Sign in with Microsoft**.
-3. Sign in to Entra; consent if prompted.
-4. Land back at `/oidc/callback` → `302 /app/me`.
-5. The `/app/me` page shows `role` matching the group mapping.
-
-If the user has no matching group and `OIDC_DEFAULT_ROLE=none`, the flow ends at `/no-access`.
-
-## 8. Troubleshooting
-
-- **"AADSTS50011: redirect URI mismatch"** — the URI in the request doesn't match what's registered. The `/oidc/callback` path is case-sensitive; the host must match exactly.
-- **`groups` claim missing in the ID token** — re-check step 3, including selecting the claim for the **ID token** type (not just access token). Sign in again from a private window so a fresh token is issued.
-- **Role always `viewer`** — your group ID isn't in `oidc_role_mappings`. The api returns the default role when no row matches.
-- **`OIDC handshake failed: state mismatch`** — the session cookie was lost between `/login/oidc` and `/oidc/callback` (third-party-cookie blocking on cross-site flows, or session fixation reset). Confirm `SameSite=Lax` is set on the session cookie and that the redirect URI is on the same host.

+ 58 - 0
examples/consumers/haproxy-acl.sh

@@ -0,0 +1,58 @@
+#!/usr/bin/env bash
+# Pull the IRDB blocklist and update an HAProxy ACL file in-place.
+#
+# Usage (cron):
+#     IRDB_URL=http://localhost:8081 IRDB_TOKEN=irdb_con_... \
+#         OUTPUT=/etc/haproxy/irdb-blocked.lst \
+#         examples/consumers/haproxy-acl.sh
+#
+# In your haproxy.cfg:
+#
+#     frontend http_front
+#         bind *:80
+#         acl irdb_blocked src -f /etc/haproxy/irdb-blocked.lst
+#         http-request deny if irdb_blocked
+#         default_backend app
+#
+# This script uses HAProxy's runtime API (`set acl`) when available
+# to update without a reload; otherwise it falls back to writing the
+# file and emitting a hint that the operator should reload.
+set -euo pipefail
+
+: "${IRDB_URL:?must be set}"
+: "${IRDB_TOKEN:?must be set}"
+OUTPUT="${OUTPUT:-/etc/haproxy/irdb-blocked.lst}"
+TIMEOUT="${IRDB_TIMEOUT:-30}"
+HAPROXY_SOCKET="${HAPROXY_SOCKET:-/run/haproxy/admin.sock}"
+
+TMP=$(mktemp)
+trap 'rm -f "$TMP"' EXIT
+
+curl -fsS --max-time "$TIMEOUT" \
+    -H "Authorization: Bearer $IRDB_TOKEN" \
+    -H "Accept: text/plain" \
+    "$IRDB_URL/api/v1/blocklist" > "$TMP"
+
+if [ -f "$OUTPUT" ] && cmp -s "$OUTPUT" "$TMP"; then
+    echo "irdb-blocklist unchanged"
+    exit 0
+fi
+
+mv "$TMP" "$OUTPUT"
+trap - EXIT
+
+if [ -S "$HAPROXY_SOCKET" ] && command -v socat >/dev/null 2>&1; then
+    # Replace the ACL contents at runtime, no reload required.
+    {
+        echo "clear acl irdb_blocked"
+        while IFS= read -r entry; do
+            [ -z "$entry" ] && continue
+            echo "add acl irdb_blocked $entry"
+        done < "$OUTPUT"
+        echo "show acl irdb_blocked | head -1"
+    } | socat - "UNIX-CONNECT:$HAPROXY_SOCKET" >/dev/null
+    echo "irdb-blocklist updated via haproxy socket"
+else
+    echo "irdb-blocklist written to $OUTPUT — reload haproxy to pick it up:"
+    echo "    systemctl reload haproxy"
+fi

+ 54 - 0
examples/consumers/iptables-restore.sh

@@ -0,0 +1,54 @@
+#!/usr/bin/env bash
+# Pull the IRDB blocklist and atomic-replace an ipset.
+#
+# Usage (cron-friendly):
+#     IRDB_URL=http://localhost:8081 IRDB_TOKEN=irdb_con_... \
+#         IPSET_NAME=irdb-blocked examples/consumers/iptables-restore.sh
+#
+# Prerequisites:
+#     - the ipset is referenced by an iptables rule, e.g.
+#         iptables -I INPUT -m set --match-set irdb-blocked src -j DROP
+#     - ipset and iptables-restore are installed
+#     - the consumer's policy returns IPv4 entries (or a mixed set;
+#       this script filters per family)
+#
+# Atomic-replace pattern: build a swap set in memory, swap, destroy
+# old. Even an in-flight rule keeps working through the swap.
+set -euo pipefail
+
+: "${IRDB_URL:?must be set}"
+: "${IRDB_TOKEN:?must be set}"
+IPSET_NAME="${IPSET_NAME:-irdb-blocked}"
+TYPE="${IPSET_TYPE:-hash:net}"
+TIMEOUT="${IRDB_TIMEOUT:-30}"
+
+LIST=$(curl -fsS --max-time "$TIMEOUT" \
+    -H "Authorization: Bearer $IRDB_TOKEN" \
+    -H "Accept: text/plain" \
+    "$IRDB_URL/api/v1/blocklist")
+
+# Split into v4 / v6 — this script handles v4 only by default. Drop
+# v6 entries if your ipset is `hash:net` family inet; mirror this
+# script for inet6 if you need both.
+SWAP="${IPSET_NAME}-swap"
+
+# Re-create the swap set; ignore "Set cannot be created: set with the
+# same name already exists" since we destroy unconditionally below.
+ipset create "$SWAP" "$TYPE" -exist
+ipset flush  "$SWAP"
+
+while IFS= read -r line; do
+    [ -z "$line" ] && continue
+    case "$line" in
+        *:*) continue ;;            # skip v6
+        *)   ipset add "$SWAP" "$line" -exist ;;
+    esac
+done <<<"$LIST"
+
+# Make the existing target set if absent so the swap target is valid.
+ipset create "$IPSET_NAME" "$TYPE" -exist
+ipset swap   "$SWAP" "$IPSET_NAME"
+ipset destroy "$SWAP"
+
+COUNT=$(ipset list -t "$IPSET_NAME" | awk -F': ' '/^Number of entries:/ {print $2}')
+echo "irdb-blocklist updated: $COUNT entries in $IPSET_NAME"

+ 56 - 0
examples/consumers/nginx-deny-include.sh

@@ -0,0 +1,56 @@
+#!/usr/bin/env bash
+# Pull the IRDB blocklist and write an nginx `deny` include file.
+# Reload nginx atomically only if the file changed.
+#
+# Usage (cron):
+#     IRDB_URL=http://localhost:8081 IRDB_TOKEN=irdb_con_... \
+#         OUTPUT=/etc/nginx/conf.d/irdb-deny.conf \
+#         examples/consumers/nginx-deny-include.sh
+#
+# In your nginx config, include the file inside a `server {}` block:
+#
+#     server {
+#         ...
+#         include /etc/nginx/conf.d/irdb-deny.conf;
+#     }
+#
+# The script is idempotent: if the new file content matches what's
+# already on disk, nginx is left alone.
+set -euo pipefail
+
+: "${IRDB_URL:?must be set}"
+: "${IRDB_TOKEN:?must be set}"
+OUTPUT="${OUTPUT:-/etc/nginx/conf.d/irdb-deny.conf}"
+TIMEOUT="${IRDB_TIMEOUT:-30}"
+
+TMP=$(mktemp)
+trap 'rm -f "$TMP"' EXIT
+
+# Header makes the include file self-describing.
+{
+    echo "# Generated by examples/consumers/nginx-deny-include.sh"
+    echo "# Updated: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
+} > "$TMP"
+
+curl -fsS --max-time "$TIMEOUT" \
+    -H "Authorization: Bearer $IRDB_TOKEN" \
+    -H "Accept: text/plain" \
+    "$IRDB_URL/api/v1/blocklist" \
+    | while IFS= read -r line; do
+        [ -z "$line" ] && continue
+        echo "deny $line;"
+    done >> "$TMP"
+
+# Only reload nginx if the file actually changed.
+if [ -f "$OUTPUT" ] && cmp -s "$OUTPUT" "$TMP"; then
+    echo "irdb-blocklist unchanged; nginx not reloaded"
+    exit 0
+fi
+
+mv "$TMP" "$OUTPUT"
+trap - EXIT
+
+# `nginx -t` first so we never reload onto a syntax error.
+nginx -t
+nginx -s reload
+echo "irdb-blocklist written to $OUTPUT; nginx reloaded"

+ 35 - 0
examples/reporters/bash-fail2ban.sh

@@ -0,0 +1,35 @@
+#!/usr/bin/env bash
+# fail2ban action shim: post the banned IP to IRDB.
+#
+# Wire it up by dropping a fail2ban action file in /etc/fail2ban/action.d/:
+#
+#     [Definition]
+#     actionban = /usr/local/bin/irdb-fail2ban.sh <ip> brute_force '{"jail":"<name>"}'
+#     actionunban = true
+#
+#     [Init]
+#     name = default
+#
+# And set IRDB_URL + IRDB_TOKEN in fail2ban's environment (typically
+# /etc/default/fail2ban or a systemd drop-in).
+#
+# This script is intentionally tiny — fail2ban actions execute often,
+# in restricted environments, and any sleep/retry logic belongs in a
+# higher layer.
+set -euo pipefail
+
+IP="${1:?ip required}"
+CATEGORY="${2:?category required}"
+METADATA="${3:-{\}}"
+
+: "${IRDB_URL:?must be set}"
+: "${IRDB_TOKEN:?must be set}"
+
+# Best-effort: 5 second timeout, no retries. fail2ban won't block on
+# the action; if IRDB is briefly unreachable we lose this report
+# rather than holding the ban.
+exec curl -fsS --max-time 5 -X POST \
+    -H "Authorization: Bearer $IRDB_TOKEN" \
+    -H "Content-Type: application/json" \
+    -d "{\"ip\":\"$IP\",\"category\":\"$CATEGORY\",\"metadata\":$METADATA}" \
+    "$IRDB_URL/api/v1/report" >/dev/null

+ 38 - 0
examples/reporters/curl.sh

@@ -0,0 +1,38 @@
+#!/usr/bin/env bash
+# Post a single abuse report to IRDB.
+#
+# Usage:
+#     IRDB_URL=http://localhost:8081 IRDB_TOKEN=irdb_rep_... \
+#         examples/reporters/curl.sh 203.0.113.42 brute_force
+#
+# Optional third arg: a JSON metadata object to attach to the report.
+# Anything in metadata is opaque to the api beyond a 4 KB size cap.
+set -euo pipefail
+
+if [ "$#" -lt 2 ]; then
+    echo "Usage: $0 <ip> <category> [<metadata-json>]" >&2
+    exit 64
+fi
+
+IP="$1"
+CATEGORY="$2"
+METADATA="${3:-{\}}"
+
+: "${IRDB_URL:?must be set, e.g. http://localhost:8081}"
+: "${IRDB_TOKEN:?must be set, e.g. irdb_rep_...}"
+
+# Build the JSON body. jq is the cleanest path; fall back to printf if
+# jq is unavailable.
+if command -v jq >/dev/null 2>&1; then
+    BODY=$(jq -nc --arg ip "$IP" --arg cat "$CATEGORY" --argjson meta "$METADATA" \
+        '{ip: $ip, category: $cat, metadata: $meta}')
+else
+    BODY=$(printf '{"ip":"%s","category":"%s","metadata":%s}' \
+        "$IP" "$CATEGORY" "$METADATA")
+fi
+
+curl -fsS -X POST \
+    -H "Authorization: Bearer $IRDB_TOKEN" \
+    -H "Content-Type: application/json" \
+    --data "$BODY" \
+    "$IRDB_URL/api/v1/report"

+ 78 - 0
examples/reporters/python.py

@@ -0,0 +1,78 @@
+#!/usr/bin/env python3
+"""Post a single abuse report to IRDB.
+
+Usage:
+    IRDB_URL=http://localhost:8081 IRDB_TOKEN=irdb_rep_... \\
+        examples/reporters/python.py 203.0.113.42 brute_force '{"url":"/wp-login.php"}'
+
+The third argument (metadata) is optional and must be valid JSON.
+
+This script uses only the stdlib (`urllib`); no third-party deps.
+
+fail2ban-action wrapper (drop into `/etc/fail2ban/action.d/irdb.conf`):
+
+    [Definition]
+    actionban = /usr/local/bin/irdb-report.py <ip> brute_force
+                '{"jail":"<name>","host":"<ipfailures>"}'
+    actionunban = true
+
+    [Init]
+    name = default
+
+`<ip>` and `<name>` are substituted by fail2ban at action time. Set
+IRDB_URL and IRDB_TOKEN in fail2ban's environment (e.g. via
+`/etc/default/fail2ban`).
+"""
+from __future__ import annotations
+
+import json
+import os
+import sys
+import urllib.error
+import urllib.request
+
+
+def main(argv: list[str]) -> int:
+    if len(argv) < 3:
+        print(f"Usage: {argv[0]} <ip> <category> [<metadata-json>]", file=sys.stderr)
+        return 64
+
+    ip = argv[1]
+    category = argv[2]
+    metadata_raw = argv[3] if len(argv) > 3 else "{}"
+    try:
+        metadata = json.loads(metadata_raw)
+    except json.JSONDecodeError as e:
+        print(f"metadata must be valid JSON: {e}", file=sys.stderr)
+        return 65
+
+    url_base = os.environ.get("IRDB_URL")
+    token = os.environ.get("IRDB_TOKEN")
+    if not url_base or not token:
+        print("IRDB_URL and IRDB_TOKEN must be set", file=sys.stderr)
+        return 78
+
+    body = json.dumps({"ip": ip, "category": category, "metadata": metadata}).encode()
+    req = urllib.request.Request(
+        f"{url_base.rstrip('/')}/api/v1/report",
+        data=body,
+        method="POST",
+        headers={
+            "Authorization": f"Bearer {token}",
+            "Content-Type": "application/json",
+        },
+    )
+    try:
+        with urllib.request.urlopen(req, timeout=10) as resp:
+            print(resp.read().decode())
+            return 0
+    except urllib.error.HTTPError as e:
+        print(f"http {e.code}: {e.read().decode(errors='replace')}", file=sys.stderr)
+        return 1
+    except urllib.error.URLError as e:
+        print(f"network error: {e}", file=sys.stderr)
+        return 2
+
+
+if __name__ == "__main__":
+    sys.exit(main(sys.argv))

+ 90 - 0
examples/reverse-proxy/Caddyfile

@@ -0,0 +1,90 @@
+# Production-ready Caddy config fronting both api and ui.
+#
+# Two-hostname pattern (recommended):
+#     reputation.example.com       → ui   :8080
+#     reputation-api.example.com   → api  :8081
+#
+# Caddy auto-provisions Let's Encrypt certs for both names. ACME HTTP-01
+# requires port 80 to be reachable from the internet; ACME TLS-ALPN
+# (default in Caddy 2) uses port 443.
+#
+# Replace the two hostnames with your own and put this file in
+# /etc/caddy/Caddyfile (or `caddy run --config Caddyfile` for a manual
+# deployment).
+#
+# If the api and ui run on the same host as Caddy, the upstream
+# addresses below ("ui:8080" / "api:8081") work when Caddy joins the
+# Docker network. If Caddy runs on the host, use 127.0.0.1:8080 /
+# 127.0.0.1:8081 instead.
+
+# ----- UI (humans in browsers) -----------------------------------
+reputation.example.com {
+    encode zstd gzip
+
+    # Security headers — sensible defaults for an admin UI.
+    header {
+        Strict-Transport-Security "max-age=31536000; includeSubDomains"
+        X-Frame-Options "DENY"
+        X-Content-Type-Options "nosniff"
+        Referrer-Policy "strict-origin-when-cross-origin"
+        # Tailwind + Alpine + htmx + chart.js — relax `unsafe-inline`
+        # if you tighten Tailwind to no inline styles. The current UI
+        # ships a small inline `<head>` script for theme-before-paint
+        # which needs `unsafe-inline` on script-src too.
+        Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self'"
+    }
+
+    reverse_proxy ui:8080 {
+        # Forward client info so the ui can log + audit accurately.
+        header_up X-Forwarded-Proto {scheme}
+        header_up X-Forwarded-Host {host}
+        header_up X-Real-IP {remote}
+    }
+}
+
+# ----- API (machine clients + ui server-to-server) ---------------
+reputation-api.example.com {
+    encode zstd gzip
+
+    header {
+        Strict-Transport-Security "max-age=31536000; includeSubDomains"
+        X-Content-Type-Options "nosniff"
+        # The api never frames anything; allowlist nothing.
+        X-Frame-Options "DENY"
+    }
+
+    # /internal/* must NEVER traverse the public proxy. The api's own
+    # Caddyfile inside the container blocks RFC1918-only paths, but
+    # we belt-and-braces 404 it here so a misconfigured upstream
+    # doesn't accidentally expose them.
+    @internal {
+        path /internal/*
+    }
+    handle @internal {
+        respond 404
+    }
+
+    reverse_proxy api:8081 {
+        header_up X-Forwarded-Proto {scheme}
+        header_up X-Forwarded-Host {host}
+        header_up X-Real-IP {remote}
+    }
+}
+
+# ---- Single-hostname alternative --------------------------------
+# If you don't want two hostnames, point everything at one and route
+# by path prefix. Drop the two blocks above and use this one instead:
+#
+# reputation.example.com {
+#     encode zstd gzip
+#     @api path /api/* /healthz
+#     handle @api {
+#         reverse_proxy api:8081
+#     }
+#     handle {
+#         reverse_proxy ui:8080
+#     }
+# }
+#
+# Caveat: future browser-direct frontends would then share an origin
+# with the api, which simplifies CORS but couples deployment.

+ 17 - 4
examples/scheduler/host.crontab

@@ -1,4 +1,17 @@
-# 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
+# IRDB scheduler — host crontab
+# ============================================================
+# Drives the periodic batch jobs by poking /internal/jobs/tick
+# every minute. The tick endpoint dispatches whichever jobs are
+# due (recompute-scores, cleanup-audit, enrich-pending,
+# refresh-geoip).
+#
+# Install with:
+#     sudo cp examples/scheduler/host.crontab /etc/cron.d/irdb
+#     sudo chmod 644 /etc/cron.d/irdb
+#
+# Or copy into a user crontab via `crontab -e`. Set INTERNAL_JOB_TOKEN
+# in /etc/default/irdb or similar; the example below assumes localhost.
+
+# Run every minute. -m 280 caps the request at just under the
+# default cron interval so we never queue overlapping ticks.
+* * * * * root curl -sf -m 280 -X POST -H "Authorization: Bearer $INTERNAL_JOB_TOKEN" http://localhost:8081/internal/jobs/tick > /dev/null

+ 26 - 0
examples/scheduler/irdb-tick.service

@@ -0,0 +1,26 @@
+# IRDB scheduler tick — systemd service
+#
+# Install:
+#     sudo cp examples/scheduler/irdb-tick.{service,timer} /etc/systemd/system/
+#     sudo systemctl daemon-reload
+#     sudo systemctl enable --now irdb-tick.timer
+#
+# Set INTERNAL_JOB_TOKEN in /etc/default/irdb (or whichever
+# EnvironmentFile your distro convention uses).
+
+[Unit]
+Description=IRDB scheduler tick — invoke any due periodic job
+After=docker.service
+
+[Service]
+Type=oneshot
+EnvironmentFile=/etc/default/irdb
+# -m 280 caps the request at just under the timer's once-per-minute
+# cadence so we never queue overlapping ticks.
+ExecStart=/usr/bin/curl -sf -m 280 -X POST \
+    -H "Authorization: Bearer ${INTERNAL_JOB_TOKEN}" \
+    http://localhost:8081/internal/jobs/tick
+
+# This is a poll, not state-changing; failure is non-fatal — the next
+# minute's tick will retry.
+SuccessExitStatus=0 22 28

+ 22 - 0
examples/scheduler/irdb-tick.timer

@@ -0,0 +1,22 @@
+# IRDB scheduler tick — systemd timer
+#
+# Pairs with irdb-tick.service. Install both, then:
+#     sudo systemctl daemon-reload
+#     sudo systemctl enable --now irdb-tick.timer
+#     systemctl list-timers irdb-tick.timer
+
+[Unit]
+Description=Periodically run irdb-tick.service
+
+[Timer]
+# Once a minute, slightly randomised to avoid thundering-herd on a
+# multi-host deployment. The api's job_locks table mediates between
+# replicas, so simultaneous ticks are correct but wasteful.
+OnBootSec=30s
+OnUnitActiveSec=60s
+RandomizedDelaySec=10s
+AccuracySec=5s
+Persistent=true
+
+[Install]
+WantedBy=timers.target

+ 123 - 0
scripts/check-doc-endpoints.sh

@@ -0,0 +1,123 @@
+#!/usr/bin/env bash
+# Doc accuracy guard.
+#
+# Greps `doc/*.md` and the README for `/api/v1/<...>` paths and
+# compares against the OpenAPI document. The matcher templatizes
+# literal IDs, IPs, and category slugs so `/api/v1/admin/ips/{ip}`
+# in the spec covers `/api/v1/admin/ips/203.0.113.42` in prose.
+#
+# Also ensures every token kind and role name appears at least once
+# in the doc set — lazy proxy for "the docs cover the auth model".
+#
+# Allowlisted paths (declared in `KNOWN_OK`) are accepted without a
+# spec entry — for things like `/api/v1/openapi.yaml` itself, which
+# is a public asset, not a typed API endpoint.
+#
+# Run from the repo root:
+#     ./scripts/check-doc-endpoints.sh
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+cd "$REPO_ROOT"
+
+SPEC="api/public/openapi.yaml"
+[ -s "$SPEC" ] || { echo "[fail] $SPEC missing or empty — run composer openapi:build" >&2; exit 1; }
+
+DOCS=( README.md doc/architecture.md doc/api-overview.md doc/auth-flows.md doc/frontend-development.md doc/api-reference.md )
+
+# Paths declared in docs but intentionally outside the spec.
+KNOWN_OK=$(cat <<'EOF'
+/api/v1/openapi.yaml
+/api/v1/auth/oauth
+/api/v1/auth/oauth/start
+/api/v1/auth/oauth/callback
+/api/v1/auth/oauth/refresh
+/api/v1/auth/oauth/revoke
+EOF
+)
+
+# Templatize: replace IPv4 octets, IPv6 segments, and bare integers
+# in path tail with the OpenAPI param placeholders {ip} / {id} /
+# {name}.
+templatize() {
+    sed -E '
+        # IPv4-shaped path tail
+        s#/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/|$)#/{ip}\1#g
+        # IPv6 (rough): hexes with at least one colon
+        s#/[0-9a-fA-F:]*:[0-9a-fA-F:]+(/|$)#/{ip}\1#g
+        # Trailing bare integer → {id}
+        s#/[0-9]+(/|$)#/{id}\1#g
+        # /jobs/trigger/<name> → /jobs/trigger/{name}
+        s#/jobs/trigger/[A-Za-z0-9_-]+#/jobs/trigger/{name}#g
+        # /admin/categories/{slug} after templatize: leave as is
+    '
+}
+
+# ---- Pull doc paths ----
+# Match `/api/v1/...` followed by allowed chars; bound at quote /
+# whitespace / closing punct that markdown wraps them in. Strip
+# query strings and trailing punctuation.
+docs_paths=$(grep -hoE '/api/v1/[A-Za-z0-9_/{}.:-]+' "${DOCS[@]}" 2>/dev/null \
+    | sed -E 's/[?#].*$//' \
+    | sed -E 's/[.,;:`)]+$//' \
+    | sed -E 's#/+$##' \
+    | grep -vE '^/api/v1$' \
+    | grep -vE '^/api/v1/(admin|auth)$' \
+    | sort -u \
+    | templatize \
+    | sort -u)
+
+# ---- Pull spec paths ----
+# OpenAPI YAML: every line at exactly two-space indent under `paths:`
+# whose key starts with `/api/v1/` is an endpoint. Quoted keys are
+# stripped.
+spec_paths=$(awk '
+    /^paths:/ {p=1; next}
+    p && /^[A-Za-z]/ {p=0}
+    p && /^  [^ ]/ {
+        line=$0
+        sub(/^  /, "", line)
+        sub(/:$/, "", line)
+        gsub(/^[ ]+|[ ]+$/, "", line)
+        gsub(/^['"'"'"]/, "", line)
+        gsub(/['"'"'"]$/, "", line)
+        if (line ~ /^\/api\/v1/) print line
+    }
+' "$SPEC" | sort -u)
+
+# ---- Compare ----
+missing=$(
+    while IFS= read -r p; do
+        [ -z "$p" ] && continue
+        # Allowlisted?
+        if printf '%s\n' "$KNOWN_OK" | grep -qFx "$p"; then
+            continue
+        fi
+        # Direct match?
+        if printf '%s\n' "$spec_paths" | grep -qFx "$p"; then
+            continue
+        fi
+        # Match against spec with the spec also templatized? No — spec
+        # already uses {ip}/{id} placeholders; the templatize sed
+        # above on $docs_paths already aligns them.
+        echo "$p"
+    done <<<"$docs_paths"
+)
+
+if [ -n "$missing" ]; then
+    echo "[fail] doc-mentioned paths not present in $SPEC:"
+    echo "$missing" | sed 's/^/  - /'
+    exit 1
+fi
+
+# ---- Token-kind / role spelling ----
+for word in reporter consumer admin service viewer operator; do
+    if ! grep -qE "\\b$word\\b" "${DOCS[@]}"; then
+        echo "[fail] doc set never mentions '$word'"
+        exit 1
+    fi
+done
+
+docs_count=$(echo "$docs_paths" | grep -c . || true)
+spec_count=$(echo "$spec_paths" | grep -c . || true)
+echo "[ok] docs reference $docs_count /api/v1/* paths; spec declares $spec_count"

+ 151 - 0
tests/e2e/demo.sh

@@ -0,0 +1,151 @@
+#!/usr/bin/env bash
+# End-to-end smoke check.
+#
+# Mirrors the README quickstart: boots the compose stack, creates an
+# admin token, creates a reporter + consumer + their tokens, posts a
+# report, triggers the recompute job, pulls the blocklist, and asserts
+# the IP shows up. Tears down at the end.
+#
+# Run from the repo root:
+#     ./tests/e2e/demo.sh
+#
+# Requirements: Docker. No PHP/Node/Composer needed on the host.
+# Expected runtime: ~90 seconds (most of it the FrankenPHP cold start).
+#
+# Exits non-zero on any step failure. Sets `set -e` so the first error
+# aborts; `trap` ensures the stack is torn down even on early exit.
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+cd "$REPO_ROOT"
+
+LOG_PREFIX="\033[1;36m[e2e]\033[0m"
+log()  { printf "%b %s\n" "$LOG_PREFIX" "$*"; }
+fail() { printf "\033[1;31m[fail]\033[0m %s\n" "$*" >&2; exit 1; }
+
+cleanup() {
+    log "tearing down compose stack"
+    docker compose down -v >/dev/null 2>&1 || true
+}
+trap cleanup EXIT
+
+# ---- Step 1: prepare a clean .env ---------------------------------
+log "preparing .env from .env.example"
+cp .env.example .env
+
+# Generate a deterministic local-admin password hash so the test is
+# reproducible. The helper escape doubles every $ so docker-compose
+# variable substitution doesn't eat them.
+HASH=$(php -r "echo password_hash('e2e-demo-password', PASSWORD_ARGON2ID);" | sed 's/\$/$$/g')
+
+# Fill in the secrets needed for boot. Use a small awk to substitute
+# in place — POSIX sed's behaviour for "key=" lines varies across
+# distros for special chars in the value.
+awk -v hash="$HASH" '
+    BEGIN { ui_secret=ui_svc=int_tok=app_secret="" }
+    /^UI_SECRET=/      { print "UI_SECRET="                            "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; next }
+    /^APP_SECRET=/     { print "APP_SECRET="                           "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"; next }
+    /^INTERNAL_JOB_TOKEN=/ { print "INTERNAL_JOB_TOKEN="               "abababababababababababababababababababababababababababababababab"; next }
+    /^UI_SERVICE_TOKEN=/   { print "UI_SERVICE_TOKEN="                 "irdb_svc_AAAAAAAABBBBBBBBCCCCCCCCDDDDDDDD"; next }
+    /^OIDC_ENABLED=/   { print "OIDC_ENABLED=false"; next }
+    /^LOCAL_ADMIN_PASSWORD_HASH=/ { print "LOCAL_ADMIN_PASSWORD_HASH=" hash; next }
+    { print }
+' .env > .env.tmp && mv .env.tmp .env
+
+# ---- Step 2: bring the stack up ----------------------------------
+log "compose up"
+docker compose up -d --build >/dev/null
+
+log "waiting for api healthcheck (up to 60s)"
+for _ in $(seq 1 60); do
+    if curl -sf http://localhost:8081/healthz >/dev/null 2>&1; then
+        break
+    fi
+    sleep 1
+done
+curl -sf http://localhost:8081/healthz >/dev/null || fail "api never became healthy"
+
+log "waiting for ui healthcheck (up to 30s)"
+for _ in $(seq 1 30); do
+    if curl -sf http://localhost:8080/healthz >/dev/null 2>&1; then
+        break
+    fi
+    sleep 1
+done
+curl -sf http://localhost:8080/healthz >/dev/null || fail "ui never became healthy"
+
+# ---- Step 3: bootstrap the service token + admin token -----------
+log "bootstrapping the UI service token row"
+docker compose exec -T api php bin/console auth:bootstrap-service-token >/dev/null
+
+log "creating an admin token via CLI"
+ADMIN_TOKEN=$(docker compose exec -T api php bin/console \
+    auth:create-token --kind=admin --role=admin --quiet | tr -d '\r')
+[ -n "$ADMIN_TOKEN" ] || fail "no admin token returned"
+
+# ---- Step 4: create a reporter + reporter token ------------------
+log "creating reporter + token"
+RID=$(curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
+    -H "Content-Type: application/json" \
+    -d '{"name":"e2e-rep","trust_weight":1.0}' \
+    http://localhost:8081/api/v1/admin/reporters \
+    | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];')
+[ -n "$RID" ] || fail "no reporter id returned"
+
+REP_TOKEN=$(curl -sf -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"];')
+[ -n "$REP_TOKEN" ] || fail "no reporter token returned"
+
+# ---- Step 5: post some reports ----------------------------------
+log "submitting reports for 203.0.113.10..12"
+for i in 10 11 12; do
+    curl -sf -X POST -H "Authorization: Bearer $REP_TOKEN" \
+        -H "Content-Type: application/json" \
+        -d "{\"ip\":\"203.0.113.$i\",\"category\":\"brute_force\"}" \
+        http://localhost:8081/api/v1/report > /dev/null
+done
+
+# ---- Step 6: create a consumer + consumer token -----------------
+log "creating consumer + token"
+# Pick policy id 1 (the seeded "moderate" policy).
+CID=$(curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
+    -H "Content-Type: application/json" \
+    -d '{"name":"e2e-con","policy_id":1}' \
+    http://localhost:8081/api/v1/admin/consumers \
+    | php -r 'echo json_decode(stream_get_contents(STDIN),true)["id"];')
+[ -n "$CID" ] || fail "no consumer id returned"
+
+CON_TOKEN=$(curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
+    -H "Content-Type: application/json" \
+    -d "{\"kind\":\"consumer\",\"consumer_id\":$CID}" \
+    http://localhost:8081/api/v1/admin/tokens \
+    | php -r 'echo json_decode(stream_get_contents(STDIN),true)["raw_token"];')
+[ -n "$CON_TOKEN" ] || fail "no consumer token returned"
+
+# ---- Step 7: trigger recompute via admin endpoint ---------------
+log "triggering recompute-scores"
+RESP=$(curl -sf -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
+    -H "Content-Type: application/json" -d '{"full":true}' \
+    http://localhost:8081/api/v1/admin/jobs/trigger/recompute-scores)
+echo "$RESP" | grep -q '"status":"success"' || fail "recompute did not succeed: $RESP"
+
+# ---- Step 8: pull the blocklist; assert non-empty ----------------
+log "pulling blocklist"
+LIST=$(curl -sf -H "Authorization: Bearer $CON_TOKEN" http://localhost:8081/api/v1/blocklist)
+COUNT=$(printf '%s\n' "$LIST" | grep -cE '^[0-9]' || true)
+[ "$COUNT" -ge 1 ] || fail "blocklist empty (count=$COUNT)"
+log "blocklist has $COUNT entries"
+
+# ---- Step 9: poke the OpenAPI viewer -----------------------------
+log "fetching /api/v1/openapi.yaml"
+curl -sf http://localhost:8081/api/v1/openapi.yaml | grep -q '^openapi:' \
+    || fail "openapi.yaml missing or unexpected"
+
+log "fetching /api/docs"
+curl -sf http://localhost:8081/api/docs | grep -qi 'rapi-doc' \
+    || fail "/api/docs did not render the viewer"
+
+log "all checks passed"