# IRDB User Manual

> Audience: operators using the IRDB web UI day-to-day. Covers
> first-time setup, every screen and field, how the decay engine works,
> installation, and backup / disaster recovery.

This document is the operational guide. For architecture see
[`architecture.md`](./architecture.md); for API contracts see
[`api-overview.md`](./api-overview.md); for authentication wiring see
[`auth-flows.md`](./auth-flows.md).

---

## 1. Installation and first run

### 1.1 Prerequisites

- A Linux host with Docker 24+ and the `docker compose` plugin.
- Outbound HTTPS to download container images and (optionally) GeoIP
  MMDBs from your chosen provider.
- A DNS name and TLS certificate if you intend to expose the UI to
  non-loopback clients (use a reverse proxy — see
  [`README.md` § Reverse proxy](../README.md#reverse-proxy-in-production)).

### 1.2 Boot sequence (≈5 minutes)

```bash
git clone <repo-url> irdb && cd irdb
cp .env.example .env
$EDITOR .env                      # fill in the secrets — see § 1.3
docker compose -f docker-compose.yml -f compose.scheduler.yml up -d
```

That brings up:

| Container   | Port | Role                                                              |
|-------------|------|-------------------------------------------------------------------|
| `migrate`   | —    | Runs Phinx migrations + seeds, exits 0.                           |
| `api`       | 8081 | JSON backend (reporters, consumers, admin endpoints, jobs).       |
| `ui`        | 8080 | Browser-facing PHP+Twig BFF.                                      |
| `scheduler` | —    | (Overlay) busybox `crond` posting `/internal/jobs/tick` once/min. |
| `mysql`     | 3306 | (Optional) only if you uncomment the block and set `DB_DRIVER=mysql`. |

Health checks:

- `curl http://localhost:8080/healthz` → UI status (`api_reachable`).
- `curl http://localhost:8081/healthz` → API status with DB + jobs
  freshness.

The api validates required env vars on boot, so misconfiguration
crashes `docker compose up` rather than the first user click.

### 1.3 Required secrets

Every value in `.env` marked as a secret must be generated. The full
recipe lives in [`README.md` § Generating secrets](../README.md#generating-secrets);
the key ones are:

| Variable                  | How to generate                                                                                  |
|---------------------------|--------------------------------------------------------------------------------------------------|
| `INTERNAL_JOB_TOKEN`      | `openssl rand -hex 32` — Bearer for `/internal/jobs/*`.                                          |
| `UI_SERVICE_TOKEN`        | `irdb_svc_…` — bridge between UI and API (see `.env.example` for the one-liner).                |
| `LOCAL_ADMIN_PASSWORD_HASH` | `php -r "echo password_hash('your-pw', PASSWORD_ARGON2ID);"` — **double every `$` to `$$`** in the .env file so docker-compose doesn't eat it. |

### 1.4 First-time configuration walkthrough

After `docker compose up -d`, sign in to the UI at
`http://localhost:8080/` with the local admin credentials you set in
`.env`. The first session lands on the dashboard.

Do these in order — each step depends on the previous one:

1. **Verify defaults.** Open `Categories`. Five seeded categories
   (`brute_force`, `spam`, `scanner`, `malware_c2`, `web_attack`)
   should be present, each with `exponential` decay and a 14-day
   half-life. Open `Policies`; three seeded policies (`strict`,
   `moderate`, `paranoid`) should be present.
2. **Create the categories you actually need.** If the seeded set
   doesn't fit (e.g. you want `phishing`), use `Categories → New`.
   You can deactivate seeded categories you won't use by opening their
   detail page and unchecking "active".
3. **Adjust policy thresholds.** Open `Policies → moderate → Edit`.
   The threshold matrix has one row per category. Empty value = "this
   category does not contribute to this policy". The default seed sets
   every category to `1.0` for `moderate`, `2.5` for `strict`, `0.3`
   for `paranoid`. Tune to taste; the live preview at the bottom of
   the edit page shows the resulting blocklist size.
4. **Create your first reporter.** `Reporters → New`. Pick a name
   (e.g. `web-prod-01`) and leave `Trust weight` at `1.0`.
5. **Issue a reporter token.** `Tokens → Issue`, kind = `reporter`,
   pick the reporter you just made. **A modal shows the raw
   `irdb_rep_…` token once.** Copy it into your reporter's config
   immediately — refreshing the page discards it permanently.
6. **Smoke-test ingestion.** From any host that can reach the api:
   ```bash
   curl -X POST http://<api-host>:8081/api/v1/report \
     -H "Authorization: Bearer irdb_rep_..." \
     -H "Content-Type: application/json" \
     -d '{"ip":"203.0.113.42","category":"brute_force"}'
   # → 202 with {"report_id": 1, ...}
   ```
   Refresh the UI dashboard; counters should go up.
7. **Create your first consumer.** `Consumers → New`. Pick a policy
   (`moderate` is a good default).
8. **Issue a consumer token.** `Tokens → Issue`, kind = `consumer`,
   pick the consumer.
9. **Smoke-test distribution.**
   ```bash
   curl http://<api-host>:8081/api/v1/blocklist \
     -H "Authorization: Bearer irdb_con_..."
   # → text/plain, one IP/CIDR per line
   ```
10. **Set up scheduling.** If you used `compose.scheduler.yml`, the
    sidecar is already firing `/internal/jobs/tick` once a minute.
    Otherwise install the host crontab from `examples/scheduler/`.
    Without a tick driver, scores never decay.
11. **Configure GeoIP** (optional). Default provider is **DB-IP**
    (no credential needed). Switch to MaxMind by setting
    `GEOIP_PROVIDER=maxmind` + `MAXMIND_LICENSE_KEY`, or to IPinfo
    with `GEOIP_PROVIDER=ipinfo` + `IPINFO_TOKEN`. Restart the api
    and trigger `refresh-geoip` from `Settings`.
12. **Map OIDC groups to roles** (if using Entra). Until the
    dedicated admin UI ships, populate `oidc_role_mappings` directly:
    ```bash
    docker compose exec -T api sqlite3 /data/irdb.sqlite \
      "INSERT INTO oidc_role_mappings(group_id, role) VALUES('<group-id>', 'admin');"
    ```

### 1.5 Reverse-proxy and TLS

The default compose deployment serves plain HTTP. Production should
sit behind a reverse proxy that terminates TLS and routes by
hostname. A working Caddy config is in
[`examples/reverse-proxy/Caddyfile`](../examples/reverse-proxy/Caddyfile).

When deploying behind a proxy, set `APP_ENV=production` so HSTS,
Secure-cookie, and CSRF behave correctly.

---

## 2. Roles and permissions

Three roles are persisted on the user record (`viewer`, `operator`,
`admin`). They are the source of truth — the UI hides buttons the
current user can't use, but security is enforced server-side. If a
viewer crafts a direct URL to a write endpoint, the api returns
`403`.

| Action                                    | viewer | operator | admin |
|-------------------------------------------|:------:|:--------:|:-----:|
| Browse IPs / scores / history             |   ✓    |    ✓     |   ✓   |
| Browse audit log                          |   ✓    |    ✓     |   ✓   |
| Add / remove manual blocks                |        |    ✓     |   ✓   |
| Manage allowlist                          |        |    ✓     |   ✓   |
| Manage policies / categories              |        |          |   ✓   |
| Manage reporters / consumers              |        |          |   ✓   |
| Manage tokens                             |        |          |   ✓   |
| Trigger jobs from Settings                |        |          |   ✓   |
| View Settings (config + jobs)             |        |          |   ✓   |

The local admin always has `admin` role. OIDC users get their role
from `oidc_role_mappings`; the default for unmapped users is
`OIDC_DEFAULT_ROLE` (`viewer` by default; set to `none` to deny
login outright).

---

## 3. UI screens — field-by-field reference

The UI is laid out as a sticky top bar plus a left sidebar. The top
bar carries the IRDB logo (link to `/app/me`), a theme toggle, and
the user menu (`My identity` + `Sign out`). The sidebar lists every
section; the active section is highlighted.

All forms are CSRF-protected. Destructive actions (delete, revoke)
open a confirmation modal before submitting.

### 3.1 `/login` — Sign-in screen

Shown when no session is active.

| Element                    | Purpose                                                                           |
|----------------------------|-----------------------------------------------------------------------------------|
| **Sign in with Microsoft** | Initiates the OIDC authorization-code-with-PKCE flow against Entra. Visible when `OIDC_ENABLED=true`. |
| **Use local sign-in**      | Toggle that reveals the username/password form. Visible when `LOCAL_ADMIN_ENABLED=true`. Hidden entirely otherwise. |
| **Username** field         | Plaintext username; must match `LOCAL_ADMIN_USERNAME`.                            |
| **Password** field         | Validated against `LOCAL_ADMIN_PASSWORD_HASH` (Argon2id) on the UI side.          |
| **Sign in** button         | Submits the form. After 5 failures the form is locked for 30 s (per-process; restarting the UI clears the lockout — that is the documented "unlock the admin" path). |

If neither method is enabled, the page shows an error pointing you
at the env vars to set.

### 3.2 `/no-access`

Shown when an OIDC sign-in succeeded but the user's groups didn't
match any row in `oidc_role_mappings` and `OIDC_DEFAULT_ROLE=none`.
Has one button — **Back to sign-in** — and a hint to ask an admin
to add a mapping.

### 3.3 `/app/dashboard` — Dashboard

Default landing page after login. Refreshes every 30 s on the api
side (cached); reload the page to fetch fresh numbers.

**Header** — title and a hint reading "Last 24 hours, refreshed every
30 s. reference policy: moderate". The reference policy comes from
the api and is currently fixed to `moderate`; it appears in the
"Active blocks" caveat.

**Counter cards** (four):

| Card             | Source                                                                          |
|------------------|----------------------------------------------------------------------------------|
| Active blocks    | Distinct IPs in `ip_scores` with score > 0 plus single-IP `manual_blocks`. Approximation under the `moderate` reference policy. |
| Manual blocks    | Total rows in `manual_blocks` across IPs and subnets.                            |
| Allowlist entries| Total rows in `allowlist`.                                                       |
| Reports (24h)    | Rows in `reports` with `received_at` in the last 24 h, all categories.           |

**Reports per hour** chart — Chart.js bar chart, 24 buckets keyed by
hour (UTC). Hover to read each bucket.

**Top reporters (24h)** and **Top categories (24h)** — leader-board
tables sorted by report count. Empty if no reports ingested in the
last day.

**Jobs status** — one row per registered job: name, last status
pill (`success` / `failure` / `skipped_locked` / `running`), an
amber **overdue** pill if the job hasn't completed within its
declared interval, plus the `last_finished_at` timestamp. Read-only
here; trigger buttons live on the Settings page.

If the api is unreachable, a yellow banner appears at the top and
the cards/charts are skipped — the UI degrades gracefully.

### 3.4 `/app/ips` — IP search

Paginated table of every IP with a row in `ip_scores`.

**Filter form** (collapsed into a card):

| Field         | Purpose                                                                                 |
|---------------|------------------------------------------------------------------------------------------|
| **IP / prefix** | Free text (`q`). Matches via `LIKE 'prefix%'` on `ip_text`; `203.0.113.` lists everything in that /24. Substring match (`%foo%`) works but is slower. |
| **Category**  | Limits to IPs scored in the picked category (slug).                                     |
| **Min score** | Floor on the IP's max score across categories. Decimal.                                  |
| **Max score** | Ceiling on the IP's max score.                                                           |
| **Country**   | Two-letter code. If the api has any enrichment data, becomes a dropdown of seen codes with counts; otherwise a free-text input. |
| **ASN**       | Integer ASN. Filters on the enrichment table.                                            |
| **Status**    | Dropdown — `clean`, `scored`, `manually_blocked`, `allowlisted`. Combines score row presence with the manual-block / allowlist tables. |
| **Reset / Filter** | Reset clears the form; Filter submits.                                              |

**Results table** — IP (clickable, links to detail), Country flag,
ASN, top category, max score, last report timestamp, status pill.
Pagination underneath when total > page size.

**Notes / known limitations**

- IPs that are *only* manually blocked or allowlisted (never reported
  yet) won't appear in this list — they aren't in `ip_scores`. Use
  the dedicated `Manual blocks` / `Allowlist` pages.
- Manual subnets and allowlist subnets aren't expanded for search;
  only single-IP entries from those tables intersect via the
  `Status` filter.
- Country / ASN columns are blank for IPs the enrichment job hasn't
  processed yet; trigger `enrich-pending` from Settings to backfill.

### 3.5 `/app/ips/{ip}` — IP detail

Reached by clicking an IP from the list, or by direct URL (e.g.
`/app/ips/203.0.113.42`). Both IPv4 and IPv6 work; Slim's segment
matcher allows colons via the `{ip:.+}` route.

**Header** — IP in monospace + status pill + back link.

**Action buttons** (operator/admin only):

| Button                      | Effect                                                                                |
|-----------------------------|---------------------------------------------------------------------------------------|
| **Add to allowlist** / **Remove from allowlist** | Toggles whether this single IP is on the allowlist. Allowlist always wins over scoring and manual blocks. |
| **Manually block** / **Remove manual block**     | Toggles a single-IP `manual_blocks` row. The "Manually block" modal accepts an optional reason and an optional `expires_at` (datetime-local). |

**Enrichment card** — country flag + code, ASN (linked to bgp.he.net
in a new tab), AS organisation, `enriched_at` timestamp. If no row
exists yet, says "not yet enriched". Footer carries the data-licence
attribution required by DB-IP and IPinfo (MaxMind requires no
attribution).

**Override status card** — shows whether the IP is currently
allowlisted or manually blocked, with the reason text and creation
time. Empty otherwise.

**Score per category** — one bar per category the IP has a non-zero
score in. Bar width = score / max-score-on-this-IP. Suffix shows
`<score>` with `(<n> in 30d)`.

**History** — most recent 200 events (reports + manual-block adds +
allowlist adds), newest first. Reports show category, reporter,
weight, and any metadata (pretty-printed JSON). A footer note
indicates if older events exist beyond the 200-row cap.

### 3.6 `/app/manual-blocks` — Manual blocks

Lists every row in `manual_blocks`. The sidebar entry "Subnets" goes
to the same page with `?kind=subnet`.

**Kind filter chips** — `All` / `IPs` / `Subnets`. Each filters the
listing.

**Add manual block** form (operator/admin only):

| Field         | Values                                                                          |
|---------------|----------------------------------------------------------------------------------|
| **Kind**      | `Single IP` or `Subnet (CIDR)`. Toggling swaps the visible target field.         |
| **IP**        | Visible when Kind = Single IP. Plain v4 or v6 address.                          |
| **CIDR**      | Visible when Kind = Subnet. e.g. `192.0.2.0/24` or `2001:db8::/48`. Non-canonical input is silently normalised; the response echoes a `normalized_from` note when the input changed. |
| **Reason**    | Free text. Stored verbatim in `manual_blocks.reason` and shown in audit + UI.   |
| **Expires (optional)** | datetime-local. Empty = never expires. Past expiry, the read-time filter excludes the block from blocklists; the daily `cleanup-expired-manual-blocks` job hard-deletes it (one audit row per prune). |

**Listing table** — Kind, target (IP or CIDR), reason, expiry,
created-at. Operator/admin sees a **Remove** action that opens the
confirmation modal.

### 3.7 `/app/allowlist` — Allowlist

Same shape as Manual blocks but green-themed. Allowlist entries
suppress an IP from any blocklist regardless of score and regardless
of manual-block presence. The `CidrEvaluator` logs a WARNING when
allowlist and manual-blocks overlap; allowlist wins.

**Add allowlist entry** form (operator/admin only):

| Field    | Values                                                                              |
|----------|--------------------------------------------------------------------------------------|
| **Kind** | `Single IP` or `Subnet (CIDR)`.                                                     |
| **IP** / **CIDR** | As per Manual blocks.                                                       |
| **Reason** | Free text. There is no expiry on allowlist entries — remove them manually when no longer needed. |

### 3.8 `/app/policies` — Policies

A policy is a named set of (category, threshold) pairs plus a flag
controlling whether manual blocks are merged in. The seeded
`strict` / `moderate` / `paranoid` policies cover the common
spectrum.

**Listing** — policy name (clickable), description, count of
configured thresholds, "manual blocks: yes/no". Admin sees Edit and
Delete buttons. Delete is refused with `409` if any consumer
references the policy.

**New policy** form (admin only):

| Field                       | Values                                                                          |
|-----------------------------|----------------------------------------------------------------------------------|
| **Name**                    | Unique. Freeform, but kebab-ish identifiers work best (used in audit logs).     |
| **Description**             | Optional, free text.                                                             |
| **include manual blocks**   | Checkbox; checked by default. When checked, every entry in `manual_blocks` (subject to allowlist) appears in this policy's blocklist. |

Thresholds are configured on the edit page after creation.

### 3.9 `/app/policies/{id}` — Policy editor

**Metadata card** — Name, Description, "include manual blocks". The
form is read-only for viewers.

**Threshold matrix** — every active category with three columns:

| Column        | Meaning                                                                          |
|---------------|----------------------------------------------------------------------------------|
| Category      | `<slug>` + the human name.                                                      |
| Decay         | The category's decay function and parameter, for context.                       |
| Threshold     | The minimum score for an IP to land in this policy's blocklist for that category. **Empty value = category not in this policy.** Saving with empty drops the row from `policy_category_thresholds`; saving with a number replaces the existing row. |

The PATCH replaces the entire threshold set in one transaction;
unchecked rows simply don't appear in the body.

**Preview card** — calls `/app/policies/{id}/preview-proxy` (a UI
proxy that adds the service token and forwards to
`/api/v1/admin/policies/{id}/preview`). Renders the resulting count
of entries plus a sample of the first 50. **Refresh** re-runs the
query without saving the form. Useful to gauge the impact of a
threshold change before saving.

### 3.10 `/app/reporters` — Reporters

Reporters are the upstream sources of abuse signal — one row per
host or service that posts to `POST /api/v1/report`.

**New reporter** form (admin only):

| Field         | Values                                                                                                                    |
|---------------|----------------------------------------------------------------------------------------------------------------------------|
| **Name**      | Required. Unique. Used in audit logs and dashboard top-list.                                                              |
| **Trust weight** | Decimal `0.00`–`2.00`, default `1.0`. Multiplies every report's contribution to score. `0.0` mutes the source; `2.0` doubles its influence. The value is snapshotted into `reports.weight_at_report` at ingest time, so changing trust later doesn't retroactively rewrite history. |
| **Description** | Optional free text.                                                                                                     |

**Listing** — name (clickable, edit page), trust weight, description,
status pill (`active` / `inactive`). Admin sees Edit and (for active
rows) **Deactivate**, which is a soft-delete. Deactivated reporters
stop accepting new reports (their tokens get `401`); existing
`reports` rows are preserved for the audit trail.

**Edit page** — same fields plus an `active` checkbox.

### 3.11 `/app/consumers` — Consumers

Consumers are the downstream pull clients — one row per firewall /
proxy / iptables instance that fetches `GET /api/v1/blocklist`.

**New consumer** form (admin only):

| Field         | Values                                                                          |
|---------------|----------------------------------------------------------------------------------|
| **Name**      | Required, unique. Used in audit + audit attribution.                            |
| **Policy**    | Required. Pick from the existing policies; this is what shapes the consumer's blocklist. |
| **Description** | Optional.                                                                     |

**Listing** — name, policy, description, status. Admin sees Edit /
Deactivate; Deactivate is a soft-delete (returns 401 on subsequent
pulls).

### 3.12 `/app/tokens` — API tokens

Bearer tokens for every machine client and admin automation. Service
tokens are deliberately not listed (the UI's own service token is
held only in `.env` and `api_tokens`, never exposed in management
views).

**Issue token** form (admin only):

| Field         | Values                                                                                                          |
|---------------|------------------------------------------------------------------------------------------------------------------|
| **Kind**      | `admin`, `reporter`, or `consumer`. Toggling swaps the visible secondary field.                                 |
| **Role**      | (Visible when Kind = admin) `viewer`, `operator`, or `admin`. Persisted in `api_tokens.role`. Determines what `/api/v1/admin/*` endpoints the token may call. |
| **Reporter**  | (Visible when Kind = reporter) Pick the reporter the token belongs to.                                          |
| **Consumer**  | (Visible when Kind = consumer) Pick the consumer the token belongs to.                                          |

**Token-just-created modal** — appears once after submission. Shows:

- Kind + 8-char prefix (this is what's stored permanently).
- The full raw token string in a read-only input + **Copy** button.
- "I have stored it safely" closes the modal. **Refreshing this page
  or navigating away discards the raw token permanently.** If lost,
  revoke and re-issue.

**Listing** — Kind, prefix, role-or-target column, last-used
timestamp, status (`active` / `revoked`). Admin sees a **Revoke**
button per active row; revocation is immediate and irreversible
(the row stays for audit; clients start getting `401`).

### 3.13 `/app/categories` — Abuse categories

Each category is a column in the (IP × category) score matrix. The
five seeded categories cover most use cases; add more for niche
signal sources.

**New category** form (admin only):

| Field             | Values                                                                                  |
|-------------------|------------------------------------------------------------------------------------------|
| **Slug**          | Required, unique. Pattern `^[a-z][a-z0-9_]*$`. Used in API requests, blocklist filters, and audit logs. |
| **Name**          | Required, human-readable.                                                                |
| **Decay function**| `exponential` (half-life days) or `linear` (days to zero). See § 4 below.               |
| **Decay param**   | Decimal ≥ 0.1, default 14 (interpreted as half-life when exponential, days-to-zero when linear). |
| **Description**   | Optional.                                                                                 |

**Listing** — slug, name, decay function + parameter, status pill.
Admin sees Edit and Delete. **Delete returns 409** if any policy or
report references the category — soft-delete by editing the
category and unchecking `active` instead.

**Edit page** — same fields + an `active` checkbox + a live
**Decay preview** SVG plot (x-axis: 0–60 days, y-axis: 1.0 → 0.0).
Toggling the function or changing the parameter redraws the curve
without saving.

### 3.14 `/app/audit` — Audit log

Append-only record of every successful state-changing call through
the admin and auth APIs. Visible to all signed-in roles.

**Filter form**:

| Field             | Values                                                                                                |
|-------------------|--------------------------------------------------------------------------------------------------------|
| **Actor kind**    | `user`, `admin-token`, `reporter`, `consumer`, `system`. Service-token + impersonation always records as `user` with the impersonated `user_id`. |
| **Actor id**      | Integer FK into the relevant table for that actor kind.                                              |
| **Action**        | Dropdown of known action strings (`reporter.created`, `policy.updated`, `manual_block.deleted`, `job.triggered`, etc.). |
| **Entity type**   | Free text matching `audit_log.target_type` (the API/UI surface them as `entity_type`).               |
| **Entity id**     | Free text — entity primary key as a string.                                                          |
| **From / To (ISO)** | ISO-8601 timestamps to bound the time window (`2026-04-01T00:00:00Z`).                            |

**Result table** — When (timestamp), Actor (kind pill + id), Action
(coloured pill grouped by domain), Entity (type + id), Source IP,
and a **View** button that expands a JSON pretty-print of the
event's `details` payload below the row.

Failed (4xx) calls are deliberately not audited — only successful
state changes leave a trace. Token-creation events store the prefix,
never the raw token. Pagination at the bottom for total > page size.

### 3.15 `/app/settings` — Settings (admin only)

Three sections:

#### Configuration

The api's effective config, grouped into sections (database, jobs,
GeoIP, etc.). Secrets are masked: `***` for `INTERNAL_JOB_TOKEN`,
`MAXMIND_LICENSE_KEY`, `IPINFO_TOKEN`, `DB_MYSQL_PASSWORD`.
`UI_SERVICE_TOKEN` is shown as the first 8 characters + `…`. Empty
values are rendered as `(empty)` so misconfiguration is visible.

#### Jobs

One row per registered job with its last run status, last
finished-at, items-processed count, and an amber **overdue** pill
when the job hasn't completed within its `default_interval_seconds`.

| Job                                | Default interval | Purpose                                                                                  |
|------------------------------------|------------------|------------------------------------------------------------------------------------------|
| `recompute-scores`                 | 5 min            | Re-applies decay to every (IP, category) pair touched recently or stale beyond freshness window. |
| `cleanup-audit`                    | 24 h             | Prunes `audit_log` rows older than `JOB_AUDIT_RETENTION_DAYS` (default 180).             |
| `enrich-pending`                   | 5 min            | Looks up GeoIP/ASN for IPs without an `ip_enrichment` row.                              |
| `cleanup-expired-manual-blocks`    | 24 h             | Hard-deletes manual-block rows past their `expires_at`. Emits one audit row per prune.   |
| `refresh-geoip`                    | 7 days (`JOB_GEOIP_REFRESH_INTERVAL_DAYS`) | Downloads fresh MMDB files from the configured provider; atomic-replaces them on disk. |
| `tick`                             | external         | The dispatcher invoked by host cron / sidecar; iterates the registry and runs whatever is overdue. |

**Run now** button — admin-only. Posts to
`POST /app/settings/jobs/trigger/<name>` which proxies to the api's
admin endpoint. Runs synchronously — wait for the response. Does
**not** appear next to `tick` (drive that externally). The
`refresh-geoip` button returns `412` if the configured provider's
credential is unset (DB-IP needs none; MaxMind needs
`MAXMIND_LICENSE_KEY`; IPinfo needs `IPINFO_TOKEN`).

#### GeoIP

Read-only summary of the GeoIP configuration:

| Field                | Source                                                                              |
|----------------------|--------------------------------------------------------------------------------------|
| Provider             | `GEOIP_PROVIDER` (`dbip` / `maxmind` / `ipinfo`).                                    |
| Country DB / ASN DB  | On-disk paths (`/data/geoip/country.mmdb` and `/data/geoip/asn.mmdb`).              |
| MaxMind key          | Status only: shows `(unset)` when empty.                                            |
| IPinfo token         | Status only.                                                                        |

To rotate provider or credentials, edit `.env` and restart the api
container.

### 3.16 `/app/me` — My identity

Shows what `GET /api/v1/admin/me` returns for the current request:
User ID, Display name, Email, Role pill, Source (`oidc` / `local` /
`admin-token`). A **Sign out** button at the bottom posts to
`/logout` (CSRF-protected). The page also surfaces an amber banner
if the api was unreachable at render time.

---

## 4. The decay engine — how scores age out

Every report contributes to a single `(IP, category)` score. The
score is recomputed as the sum of every report's `weight_at_report`
multiplied by a category-specific decay function of the report's
age:

```
score(IP, category) = Σ over reports r:
                       r.weight_at_report × decay(now − r.received_at, category)
```

Reports older than `SCORE_REPORT_HARD_CUTOFF_DAYS` (default 365) are
excluded outright — the cutoff bounds memory and query time as the
table grows.

### 4.1 Two decay shapes

Each category picks one of two functions, configured on the
`Categories` page:

| Function                | Formula                                            | `decay_param` interpretation             | When to pick                                                                                       |
|-------------------------|----------------------------------------------------|------------------------------------------|----------------------------------------------------------------------------------------------------|
| **Linear**              | `max(0, 1 − age_days / decay_param)`               | Days until contribution reaches zero.    | Hard cutoff: a report stops counting after exactly N days. Used when "contribution = 1 if recent, 0 if old" is the right model. |
| **Exponential**         | `0.5 ^ (age_days / decay_param)` (half-life)       | Days for contribution to halve.          | Smooth decay: a report's contribution halves every N days, never reaches zero (until the hard cutoff). The default for all five seeded categories. |

A few worked numbers, with `decay_param = 14`:

| Age (days) | Linear | Exponential |
|-----------:|-------:|------------:|
| 0          | 1.000  | 1.000       |
| 7          | 0.500  | 0.707       |
| 14         | 0.000  | 0.500       |
| 28         | 0.000  | 0.250       |
| 90         | 0.000  | 0.012       |

The `Categories` edit page renders the same curve live as you
change the parameter.

### 4.2 What you can tune

| Knob                                        | Where                                                                       | Effect                                                                                                  |
|---------------------------------------------|------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------|
| **Per-category `decay_function`**           | `Categories → edit`                                                          | Switches between linear and exponential. Curve shape changes immediately on save; existing scores recompute on next tick. |
| **Per-category `decay_param`**              | `Categories → edit`                                                          | Faster (smaller param) decay = scores fall off quickly = blocklist drops IPs sooner. Slower (larger) = sticky scores. |
| **Per-reporter `trust_weight`**             | `Reporters → edit`                                                           | Multiplies the contribution of every new report from this source. Snapshotted into `reports.weight_at_report` at ingest, so changes don't rewrite history. Range `0.0`–`2.0`. |
| **Hard cutoff `SCORE_REPORT_HARD_CUTOFF_DAYS`** | `.env` (`api`)                                                          | Floor on memory: reports older than this are excluded entirely. Default 365.                            |
| **Recompute interval `SCORE_RECOMPUTE_INTERVAL_SECONDS`** | `.env` (`api`)                                                  | How often the bulk recompute job runs; lower = scores reflect decay sooner; higher = less load. Default 300 s. |
| **Recompute batch size `JOB_RECOMPUTE_MAX_ROWS_PER_TICK`** | `.env` (`api`)                                                 | Cap on rows refreshed per tick; tune if recompute starts overrunning its window. Default 5000.          |
| **Recompute timeout `JOB_RECOMPUTE_MAX_RUNTIME_SECONDS`** | `.env` (`api`)                                                  | Hard ceiling. The lock auto-expires after this; concurrent calls return `skipped_locked`. Default 240 s. |
| **Per-policy thresholds**                   | `Policies → edit`                                                            | The score floor for inclusion in a policy's blocklist. Higher = stricter inclusion = smaller list. Lower = looser. |

### 4.3 How recompute actually works

The job runs every `SCORE_RECOMPUTE_INTERVAL_SECONDS`:

1. Acquires the `recompute-scores` lock with a max runtime of
   `JOB_RECOMPUTE_MAX_RUNTIME_SECONDS`. Concurrent calls get `409
   skipped_locked` immediately.
2. Selects every `(ip, category)` pair touched by reports in the
   recent interval, **plus** every row whose `recomputed_at` is
   stale (older than the freshness window) — capped at
   `JOB_RECOMPUTE_MAX_ROWS_PER_TICK`.
3. Recomputes the score by summing decayed contributions over all
   in-cutoff reports for that pair.
4. Upserts into `ip_scores`.
5. Drops rows where `score < 0.01 AND last_report_at < now − 90
   days` so the table doesn't grow unbounded.
6. Writes a `job_runs` row and releases the lock.

Single-pair updates also happen **synchronously** at report time
(via `PairScorer`), so the dashboard and detail page reflect a new
report immediately. The bulk recompute is what *ages* every score
between reports.

A full-table recompute is exposed as an admin-triggered job
(`Settings → recompute-scores → Run now` with `full=true` body, or
the CLI `php api/bin/console scores:rebuild`). Use after large
policy / decay changes that should reflect everywhere immediately.

### 4.4 Manual override semantics

`manual_blocks` and `allowlist` are **not** folded into scores. They
are evaluated at distribution time:

- **Allowlist always wins.** An IP on the allowlist is excluded from
  every blocklist regardless of score and regardless of any manual
  block. Overlap is logged as a WARNING.
- **Manual blocks** appear in a policy's blocklist if and only if
  the policy's `include_manual_blocks` flag is set. Subnet manual
  blocks emit as a single CIDR line; covered single-IP scored entries
  are deduplicated.

---

## 5. Backup and disaster recovery

### 5.1 What carries irreplaceable state

| Resource                          | Where                       | Recovery                                  |
|-----------------------------------|-----------------------------|-------------------------------------------|
| Reports + scores                  | `reports`, `ip_scores`      | DB backup                                 |
| Manual blocks + allowlist         | `manual_blocks`, `allowlist`| DB backup                                 |
| Policies + thresholds             | `policies`, `policy_category_thresholds` | DB backup                       |
| Categories                        | `categories`                | DB backup                                 |
| Reporters / consumers / users     | `reporters`, `consumers`, `users` | DB backup                          |
| API tokens                        | `api_tokens` (hashes only)  | DB backup; **raw values gone — re-issue** |
| Audit log                         | `audit_log`                 | DB backup                                 |
| OIDC role mappings                | `oidc_role_mappings`        | DB backup                                 |
| Service token                     | `.env` (`UI_SERVICE_TOKEN`) | Back up `.env` or regenerate              |
| Internal job token                | `.env` (`INTERNAL_JOB_TOKEN`) | Back up `.env` or regenerate            |
| OIDC client secret                | `.env`                      | Re-fetch from Entra app registration      |
| Local admin password hash         | `.env`                      | Regenerate via `password_hash()`          |
| MaxMind / IPinfo credentials      | `.env`                      | Re-fetch from provider                    |
| GeoIP MMDBs                       | `irdb-data` volume          | `refresh-geoip` job re-downloads          |
| Browser sessions                  | UI container's writable layer | Discarded on restart — users re-login   |

### 5.2 SQLite backup (default)

The SQLite backup API quiesces WAL and produces a consistent
snapshot online — no downtime:

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

Schedule this from host cron alongside your existing backup
infrastructure. The file is small — under 20 tables; even a
year-old install is typically tens of MB.

**Whole-volume tarball** (alternative, requires the api stopped):

```bash
docker compose stop api
docker run --rm -v irdb-data:/data -v "$(pwd):/backup" alpine \
  tar czf /backup/irdb-backup.tar.gz -C /data .
docker compose start api
```

### 5.3 SQLite restore

```bash
docker compose down
docker run --rm -v irdb-data:/data -v "$(pwd):/backup" alpine \
  sh -c 'rm -rf /data/* && tar xzf /backup/irdb-backup.tar.gz -C /data'
docker compose up -d
```

The `migrate` container reruns Phinx idempotently — safe to run on a
restored DB that's already at HEAD; it exits immediately with "no
migrations to run" if nothing's pending.

### 5.4 MySQL backup and restore

```bash
docker compose exec mysql sh -c \
  'mysqldump --single-transaction --routines --quick \
    -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
  > irdb-mysql-$(date +%F).sql
```

Restore (api must be stopped to avoid observing a half-loaded
schema):

```bash
docker compose stop api migrate
docker compose exec -T mysql sh -c \
  'mysql -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" "$MYSQL_DATABASE"' \
  < irdb-mysql-2026-04-29.sql
docker compose up -d migrate api
```

### 5.5 Disaster-recovery checklist

Use this when restoring on a fresh host, e.g. after a hardware
failure or migration:

1. **Provision** the host: install Docker, clone the repo, restore
   `.env` from your secret store (or generate fresh — the service
   token can be rotated, the local-admin hash regenerated).
2. **Restore the database** via the relevant block above. SQLite:
   drop the file into the `irdb-data` volume. MySQL: pipe the dump
   through `mysql`.
3. **Bring up the stack**: `docker compose up -d`. The `migrate`
   container is idempotent.
4. **Verify health.** `/healthz` on both containers; log in to the
   UI; visit `/app/dashboard` and `/app/settings`.
5. **Trigger `refresh-geoip`** from Settings (or wait for the
   scheduled run) so enrichment is repopulated.
6. **Re-issue tokens** for any reporter or consumer whose raw token
   was lost (the database has the SHA-256 hash, but the original
   string isn't recoverable). Until re-issued, those clients will
   see `401`.
7. **Notify users** that browser sessions are gone — they must
   sign in again.

### 5.6 What's explicitly NOT supported

- **Encryption at rest of the SQLite file** — host-level disk
  encryption is the right layer; no application-level encryption.
- **Audit-log signing or tamper-evidence** — `audit_log` is
  append-only at the application layer, but a sufficiently
  privileged attacker with database access can rewrite history.
- **Cross-region replication** — Docker volumes are local. High
  availability requires managed MySQL with replication plus an
  `api` topology that exploits the stateless mode.
- **Backup of secrets** (service token, OIDC client secret, etc.)
  is out of scope for the database backup. Back up `.env`
  separately or regenerate on restore.

### 5.7 Operational sanity checks

Things that often go wrong, and where to look:

| Symptom                                                   | Likely cause                                                                                  |
|-----------------------------------------------------------|----------------------------------------------------------------------------------------------|
| Scores never decay; `recompute-scores` shows "never run" | Scheduler isn't running. Check `compose.scheduler.yml` is included or host cron is firing.   |
| Dashboard "API unreachable" banner                        | The `api` container is down or the UI can't resolve `API_BASE_URL`.                          |
| All reporter ingest returns `401`                         | Token revoked, expired, or wrong kind. Check `Tokens` and `Audit`.                           |
| `refresh-geoip` returns 412                               | Selected provider's credential is unset. Switch provider or set the key.                     |
| Local-admin login is locked out                           | Five failed attempts in succession. Restart the `ui` container to clear the throttle.        |
| `migrate` keeps restarting                                | Database connection failed. Check `DB_DRIVER`, `DB_MYSQL_*`, and the volume mount.           |
| Disk filling up                                           | `audit_log` growing without bounds. Check `cleanup-audit` is running and `JOB_AUDIT_RETENTION_DAYS` is set. |

---

## 6. Where to go next

- [`README.md`](../README.md) — quickstart, generating secrets, scheduling.
- [`architecture.md`](./architecture.md) — container topology and stable surfaces.
- [`auth-flows.md`](./auth-flows.md) — Entra setup, every authentication path.
- [`api-overview.md`](./api-overview.md) — public API surface with worked examples.
- [`security.md`](./security.md) — security posture, hardening notes, known limitations.
- `/api/docs` (live, served by the api container) — interactive OpenAPI viewer.
