SPEC.md 64 KB

IP Reputation Database — Build Specification

You are building a self-hosted IP Reputation Database that ships as a Docker Compose stack:

  • api — pure JSON REST backend. Owns the database, business logic, scoring, RBAC, and all auth decisions. Does not render HTML.
  • ui — thin PHP+Twig+Tailwind frontend (BFF). Owns the OIDC redirect flow, browser sessions, login forms, and server-rendered templates. Calls api for all data.
  • migrate — one-shot, runs Phinx migrations and seeds, then exits.
  • scheduler (optional sidecar) — busybox crond that pokes api's internal job endpoints.
  • mysql (optional) — replaces the default SQLite.

The ui container is deliberately replaceable. The current PHP+Twig implementation is one of several possible frontends; future rewrites in Vue, Svelte, native desktop, or mobile are explicitly anticipated (and out of scope for this build). The API contract and auth model must remain stable across such rewrites. Documentation for future frontend authors lives in doc/ and is a first-class deliverable, not an afterthought.

Read this entire spec before writing any code, then execute the milestones in order. Do not skip ahead. Commit after each milestone.


1. Project Goals

A central service that:

  1. Ingests abuse reports from many sources (web servers, IDS, fail2ban-like agents) via an authenticated REST API.
  2. Distributes tailored block lists to firewalls/proxies via an authenticated REST API, where each consumer gets a list shaped by a named policy.
  3. Lets humans manage IPs, subnets, allowlists, tokens, policies, and inspect full per-IP history through a modern web UI.
  4. Computes IP reputation as a decaying, weighted, per-category score.

2. Tech Stack (non-negotiable)

Shared

  • Language: PHP 8.3
  • Framework: Slim 4 (used in both containers, in different roles)
  • Web server / runtime: FrankenPHP (Caddy with embedded PHP) — single binary, single process per container, auto HTTPS in production, HTTP/2 + HTTP/3
  • Container base: dunglas/frankenphp:1-php8.3-alpine (or -bookworm if Alpine causes pain with extensions)
  • Build: Composer per app, npm for Tailwind. Multi-stage Dockerfile per container.
  • Tests: PHPUnit 11
  • Logging: Monolog → stdout in JSON. Both containers write structured logs.

api container (backend)

  • Database: SQLite 3 (default) or MySQL 8 / MariaDB 10.6+ (selected via env var). Use a thin DBAL — doctrine/dbal — so the same SQL works on both.
  • Migrations: Phinx
  • GeoIP/ASN enrichment: MaxMind GeoLite2-Country + GeoLite2-ASN, downloaded at container build time using a license key passed as build-arg, or refreshed at runtime via the refresh-geoip job
  • Output: JSON responses only (plus text/plain for blocklists). No HTML, no Twig.

ui container (frontend BFF)

  • Templating: Twig 3
  • Frontend: Tailwind CSS 3 (compiled at build time, no CDN), vanilla JS + Alpine.js for interactivity, htmx where it simplifies forms
  • OIDC: jumbojett/openid-connect-php for Microsoft Entra ID
  • HTTP client: guzzlehttp/guzzle for calls to the api container
  • Sessions: PHP native sessions, file-based on the container's writable layer (no shared volume needed; sessions are tied to a single ui replica)
  • No database access. The ui container holds zero persistent data of its own. Everything goes through the API.

Process model

  • One process per container.
  • Periodic batch work (score recompute, GeoIP refresh, audit cleanup) is exposed as authenticated internal HTTP endpoints inside the api container.
  • Job scheduling is external — host cron, systemd timer, or Kubernetes CronJob. Optional scheduler sidecar (busybox crond) provided as a compose overlay for users who don't want to touch the host.

Do not introduce additional frameworks (no Laravel, no Symfony full-stack). Keep dependencies minimal in both containers.


3. High-Level Architecture

                                                          ┌──────────────────────────────┐
                                                          │   api container (:8081)      │
                                                          │   FrankenPHP + Slim          │
   reporters     ──HTTPS POST /api/v1/report─────────────▶│                              │
   (webservers, IDS, fail2ban)                             │   Public API                 │
                                                          │   Bearer token auth          │
   consumers     ──HTTPS GET  /api/v1/blocklist──────────▶│                              │
   (firewalls, proxies)                                    │   Internal Jobs API          │
                                                          │   /internal/jobs/*           │
                                                          │   (loopback / RFC1918 only)  │
   scheduler     ──HTTPS POST /internal/jobs/tick────────▶│                              │
   (host cron / sidecar)                                   │   Reputation Engine          │
                                                          │   GeoIP enrichment           │
                                                          │   RBAC                       │
                                                          └──────┬───────────────────────┘
                                                                 │
                                                          ┌──────▼───────────────────────┐
                                                          │     SQLite or MySQL           │
                                                          └──────▲───────────────────────┘
                                                                 │
   admins        ──browser HTTPS───┐                             │
                                   ▼                             │
                       ┌──────────────────────────────┐          │
                       │   ui container (:8080)        │          │
                       │   FrankenPHP + Slim + Twig    │          │
                       │                               │          │
                       │   OIDC redirect flow          │  service-token + impersonation
                       │   Browser sessions            │  ────────▶
                       │   Login UI / local admin      │          │
                       │   Server-rendered templates   │          │
                       │   (Tailwind, Alpine, htmx)    │          │
                       │                               │          │
                       │   No database, no business    │          │
                       │   logic — pure BFF            │          │
                       └───────────────────────────────┘          │
                                                                  │
   future Vue/native/mobile clients ────(direct API, future)──────┘
   (out of scope; documented in doc/frontend-development.md)

Containers:
  • api       — backend, exposed on :8081; serves machine clients directly and ui as a server-side caller
  • ui        — frontend BFF, exposed on :8080; the only thing humans hit in their browser
  • migrate   — one-shot Phinx migrations + seed against the api's database, exits on success
  • mysql     — optional; SQLite via shared volume by default
  • scheduler — optional sidecar (busybox crond); disabled by default

The api and ui are independently deployable. In production, users typically front both with a reverse proxy / TLS terminator and route by hostname (reputation.example.com → ui, reputation-api.example.com → api). For the default compose deployment, both containers expose plain HTTP on different ports and FrankenPHP's auto-HTTPS handles TLS when a public hostname is configured.


4. Data Model

All timestamps UTC, stored as ISO 8601 strings on SQLite, DATETIME on MySQL. All IPs stored in two columns: ip_text (canonical string form) and ip_bin (16-byte binary, IPv4 mapped into IPv6 ::ffff:0:0/96). Indexes on ip_bin. Subnets stored as network_bin (16 bytes) + prefix_length (smallint).

Tables

reporters — one row per ingest source

  • id, name (unique, e.g. web-prod-01), description, trust_weight (decimal 0.0–2.0, default 1.0), is_active, created_at, created_by_user_id

consumers — one row per distribution consumer (firewall/proxy)

  • id, name (unique), description, policy_id (FK), is_active, created_at, created_by_user_id, last_pulled_at

api_tokens — Bearer tokens

  • id, token_hash (SHA-256 of token; raw token shown once at creation), token_prefix (first 8 chars, for UI display), kind (reporter or consumer), reporter_id (nullable FK), consumer_id (nullable FK), expires_at (nullable), revoked_at (nullable), last_used_at, created_at
  • Constraint: exactly one of reporter_id / consumer_id is set, matching kind.

categories — abuse categories

  • id, slug (e.g. brute_force, spam, scanner, malware_c2, web_attack), name, description, decay_function (linear | exponential), decay_param (for linear: days-to-zero; for exponential: half-life in days), is_active
  • Seed defaults on first run; admin can add/edit.

reports — append-only event log of incoming reports

  • id, ip_bin, ip_text, category_id, reporter_id, weight_at_report (snapshot of reporter trust_weight), received_at, metadata_json (free-form: URL, user-agent, etc., max 4 KB)
  • Index (ip_bin, category_id, received_at DESC)

ip_scores — denormalized current score per (ip, category). Touched synchronously on report ingest for the affected (ip, category) pair, and refreshed in bulk by the recompute-scores job to reapply decay.

  • ip_bin, ip_text, category_id, score, last_report_at, report_count_30d, recomputed_at
  • PK (ip_bin, category_id)

job_locks — mutual exclusion for periodic jobs

  • job_name (PK, e.g. recompute-scores, refresh-geoip, cleanup-audit, enrich-pending)
  • acquired_at, acquired_by (string identifier of the request, e.g. hostname + pid)
  • expires_at — hard deadline; jobs failing to release the lock by this time are considered crashed and the lock is reclaimable.
  • Implementation: SQLite uses INSERT OR FAIL with a delete-if-expired pre-step in a transaction; MySQL uses the same pattern (no GET_LOCK — it doesn't survive failover well). Service exposes tryAcquire($jobName, $maxRuntimeSeconds) and release($jobName).

job_runs — per-job execution history and freshness state

  • id, job_name, started_at, finished_at (nullable), status (running | success | failure | skipped_locked), items_processed (int), error_message (nullable), triggered_by (schedule | manual | api)
  • Index (job_name, started_at DESC). The latest row per job_name is the "freshness" answer.

ip_enrichment — GeoIP/ASN cache per IP

  • ip_bin, country_code, asn, as_org, enriched_at

manual_blocks — admin-defined blocks (overrides scoring)

  • id, kind (ip | subnet), ip_bin (if ip), network_bin + prefix_length (if subnet), reason, expires_at (nullable), created_at, created_by_user_id

allowlist — never-block entries

  • id, kind (ip | subnet), ip_bin / network_bin + prefix_length, reason, created_at, created_by_user_id

policies — distribution profiles

  • id, name (unique, e.g. strict, moderate, paranoid), description, include_manual_blocks (bool, default true), created_at
  • Each policy has many policy_category_thresholds:
    • policy_id, category_id, threshold (decimal; IP included if its score in this category ≥ threshold)
    • Absent row = category not considered.
  • Output rule: an IP appears in a policy's blocklist if any included category meets its threshold AND the IP is not on the allowlist. Plus all manual blocks if include_manual_blocks is true. Subnet manual blocks emit as CIDR.

users — UI users (identity records only; no credentials stored here)

  • id, subject (OIDC sub, nullable), email, display_name, role (admin | operator | viewer), is_local (bool, marks the local admin record), last_login_at, created_at
  • The local admin's password_hash lives in the ui container's environment, not in this table. The ui container validates the password and then calls POST /api/v1/auth/users/upsert to ensure a corresponding record exists with is_local=true and role=admin.

oidc_role_mappings — map Entra group object IDs to roles

  • id, group_id, role, created_at
  • On login, user's role = highest role granted by any matching group; default viewer if none match (configurable to "deny" instead).

audit_log — every write action in the system

  • id, actor_kind (user | token | system), actor_id, action, target_type, target_id, details_json, ip_address, created_at

ip_history_view — not a table, but a query: union of reports, manual_blocks events, allowlist events, and audit entries filtered by IP, ordered by time. Implemented as a service method.


5. Reputation Engine

Scoring formula

For an IP X and category C, score is the sum over all reports r where r.ip == X and r.category == C:

score(X, C) = Σ ( r.weight_at_report × decay(now − r.received_at, C) )

decay(age_days, C):

  • Linear: max(0, 1 − age_days / decay_param) where decay_param is days to zero (default 30).
  • Exponential: 0.5 ^ (age_days / decay_param) where decay_param is the half-life in days (default 14).

Reports older than 365 days are excluded from the sum (hard cutoff for performance). Configurable in env.

Recomputation

The recompute-scores job (invoked on a schedule, default every 5 minutes — configurable):

  1. Acquires the recompute-scores lock with a max runtime of e.g. 4 minutes. If the lock is held, returns 409 Conflict with status skipped_locked and exits immediately.
  2. Finds all (ip_bin, category_id) pairs touched by reports in the last interval plus all rows whose recomputed_at is older than a "freshness" window (default 1 hour) — capped at N rows per cycle to bound execution time.
  3. Recomputes and upserts into ip_scores.
  4. Drops rows where score < 0.01 and last report > 90 days ago.
  5. Records a job_runs entry, then releases the lock.

A full-table recompute is also runnable on demand from the UI ("Rebuild scores"), which calls the same job with a full=true flag and a longer max runtime.

Manual override semantics

manual_blocks and allowlist are evaluated at distribution time, not folded into scores. Allowlist always wins over everything (including manual blocks — log a warning if both match).


6. API Contracts

The api container exposes four logical groups of endpoints, distinguished by audience and authentication:

Group Path prefix Audience Auth
Public /api/v1/report, /api/v1/blocklist Machine clients (reporters, consumers) Bearer (reporter or consumer token kind)
Admin /api/v1/admin/* UI BFF, admin Bearer tokens Service token + X-Acting-User-Id, OR Bearer (admin kind)
Auth /api/v1/auth/* UI BFF only Service token
Internal /internal/jobs/* Scheduler INTERNAL_JOB_TOKEN, network-restricted

All responses JSON unless stated. All endpoints require Authorization: Bearer <token> unless explicitly public. Rate limit: 60 req/s per token (token-bucket), configurable. Return 429 with Retry-After.

Authentication tokens — three kinds

The api_tokens table's kind column gains two new values beyond reporter and consumer:

  • reporter — may call POST /api/v1/report. Bound to a reporter record.
  • consumer — may call GET /api/v1/blocklist. Bound to a consumer record.
  • admin — may call any /api/v1/admin/* endpoint as itself. For administrators or automation that doesn't go through the UI. Not bound to a reporter or consumer.
  • service — special class. Held by the ui container. Calls to /api/v1/admin/* and /api/v1/auth/* MUST include X-Acting-User-Id: <user_id>; the API verifies the user exists and applies RBAC for that user. There is exactly one service token at a time, set via UI_SERVICE_TOKEN env var on both containers; if it doesn't exist on startup the api container creates it. Service tokens are never returned in admin token-list endpoints.

Public API — machine clients

POST /api/v1/report — token must be kind=reporter

{
  "ip": "203.0.113.42",
  "category": "brute_force",
  "metadata": { "url": "/wp-login.php", "ua": "..." }
}

Response 202:

{ "report_id": 12345, "ip": "203.0.113.42", "received_at": "2026-04-27T10:11:12Z" }

Errors: 400 invalid IP/category, 401 bad token, 403 token revoked, 429 rate limited.

GET /api/v1/blocklist — token must be kind=consumer. Returns text/plain, one entry per line: bare IP or CIDR. No comments by default. Cached internally for 30 seconds per consumer.

Headers:

  • ETag: hash of body. Honor If-None-Match304.
  • X-Blocklist-Generated-At, X-Blocklist-Entries, X-Blocklist-Policy.

GET /api/v1/blocklist?format=json — convenience: array of {ip_or_cidr, categories, score, reason}.

Admin API — used by UI BFF and admin tokens

All endpoints accept either:

  • Authorization: Bearer <admin-kind-token> — RBAC role determined by the token's configured role, OR
  • Authorization: Bearer <UI_SERVICE_TOKEN> + X-Acting-User-Id: <user_id> — RBAC role determined by the user record.

Endpoints (representative, not exhaustive — see OpenAPI):

  • GET /api/v1/admin/me — current acting identity: {user_id, email, display_name, role, source: "oidc"|"local"|"admin-token"}
  • GET /api/v1/admin/ips/{ip} — full detail: scores per category, recent reports, manual block status, allowlist status, enrichment, history.
  • GET /api/v1/admin/ips?q=&category=&min_score=&country=&asn=&page= — search.
  • POST/DELETE /api/v1/admin/manual-blocks, POST/DELETE /api/v1/admin/allowlist
  • GET/POST/PATCH/DELETE /api/v1/admin/policies, /policies/{id}/thresholds
  • GET/POST/DELETE /api/v1/admin/reporters, /consumers, /tokens, /categories
  • GET/POST/PATCH/DELETE /api/v1/admin/users, /oidc-role-mappings
  • GET /api/v1/admin/audit-log
  • POST /api/v1/admin/jobs/trigger/{job_name} — admin-only thin wrapper that calls /internal/jobs/<name> server-side. The UI uses this to trigger manual jobs without needing the internal token.

Auth API — exclusively for UI BFF

These are how the ui container resolves a browser-authenticated user (OIDC or local) into a stable user record. Always called with the service token.

POST /api/v1/auth/users/upsert-oidc

{
  "subject": "...",
  "email": "...",
  "display_name": "...",
  "groups": ["group-id-1", "group-id-2"]
}

Returns {user_id, role, email, display_name, is_local: false}. API derives role from oidc_role_mappings; default applies if no match.

POST /api/v1/auth/users/upsert-local

{ "username": "admin" }

The UI calls this only after validating the local admin password against its own env config. Returns {user_id, role: "admin", email: null, display_name: "Local Admin", is_local: true}.

GET /api/v1/auth/users/{id} — used by UI to refresh user info during a session.

Internal Jobs API

Used by the scheduler (host cron / systemd / sidecar) to drive periodic batch work. Bound only to loopback and the Docker bridge network in the Caddyfile — never reachable from outside, even with a token. Bearer token: INTERNAL_JOB_TOKEN env var.

All endpoints share the same response envelope:

{ "job": "recompute-scores", "status": "success", "items_processed": 1284, "duration_ms": 8421, "run_id": 42 }
  • POST /internal/jobs/recompute-scores — body optional: {"full": true, "max_rows": 5000}. Returns 202 on success, 409 if lock held (status: "skipped_locked"), 500 on failure.
  • POST /internal/jobs/refresh-geoip — downloads fresh GeoLite2 DBs if MAXMIND_LICENSE_KEY is set; otherwise returns 412 Precondition Failed.
  • POST /internal/jobs/cleanup-audit — prunes audit log older than retention window.
  • POST /internal/jobs/enrich-pending — runs GeoIP/ASN enrichment for IPs missing it.
  • POST /internal/jobs/tick — convenience: examines job_runs and invokes any job whose interval has elapsed.
  • GET /internal/jobs/status — JSON: latest job_runs row per job, lock state, "is overdue" flag.

Each endpoint always writes a job_runs row, even on lock-skip and failure.

Bound endpoints in Caddyfile:

@internal {
    path /internal/*
    remote_ip 127.0.0.1/32 ::1/128 172.16.0.0/12 10.0.0.0/8 192.168.0.0/16
}
handle @internal {
    php
}
@external_internal_blocked {
    path /internal/*
    not remote_ip 127.0.0.1/32 ::1/128 172.16.0.0/12 10.0.0.0/8 192.168.0.0/16
}
respond @external_internal_blocked 404

The OpenAPI document includes Public and Admin groups. Auth and Internal endpoints are documented separately in doc/auth-flows.md (they are not part of the public contract — frontends call Admin endpoints).

CORS

The api container sets CORS headers permitting the configured UI_ORIGIN only, with Access-Control-Allow-Credentials: true and the X-Acting-User-Id header on the allow-list. This matters for any future browser-direct frontend; the current PHP UI calls server-to-server and doesn't trigger CORS.

OpenAPI

Generate openapi.yaml at /api/v1/openapi.yaml. Serve a Stoplight Elements or RapiDoc viewer at /api/docs. Document all Public and Admin endpoints with full request/response schemas and auth requirements.


7. UI Container (PHP+Twig BFF)

The ui container is a thin Backend-for-Frontend. It owns the browser-facing experience; it does not own any data. Every screen is rendered by fetching from the api container with the service token plus the acting user's ID, and every form action is forwarded as a corresponding api call.

Responsibilities

The ui container owns:

  • Browser sessions (PHP native, file-backed inside the container)
  • The OIDC redirect/callback flow (it holds the OIDC client config)
  • The local admin login form and password validation against env config
  • All HTML rendering (Twig + Tailwind + Alpine + htmx)
  • Static asset serving
  • Anti-CSRF for its own forms
  • Light/dark mode preference (in the user's browser localStorage)

The ui container does not:

  • Connect to the database
  • Implement business logic
  • Compute reputation scores
  • Hold any persistent data
  • Decide RBAC outcomes (it asks the API; the API decides)

Public routes

  • GET / — redirect to /login if not signed in, else /dashboard
  • GET /login — login page
  • POST /login/local — local admin form submission
  • GET /login/oidc — initiate OIDC flow
  • GET /oidc/callback — OIDC callback
  • POST /logout
  • GET /healthz — UI's own health: {status, api_reachable: bool, last_api_check_at}. Does not depend on the API being up to return 200.

Authenticated routes

All under /app/*. Top nav (logo, search box, dark-mode toggle, user menu) + sidebar (Dashboard, IPs, Subnets, Allowlist, Policies, Reporters, Consumers, Tokens, Categories, Audit, Settings).

Pages (data sources annotated)

  • DashboardGET /api/v1/admin/stats/dashboard (UI controllers should never assemble dashboards from multiple admin calls; the API exposes purpose-built endpoints).
  • IPsGET /api/v1/admin/ips?... paginated list. IP DetailGET /api/v1/admin/ips/{ip}.
  • Subnets / AllowlistGET/POST/DELETE /api/v1/admin/{manual-blocks,allowlist}.
  • PoliciesGET/POST/PATCH /api/v1/admin/policies and /policies/{id}/thresholds. Threshold matrix editor; preview of resulting blocklist count via GET /api/v1/admin/policies/{id}/preview.
  • Reporters / Consumers / Tokens — CRUD via admin endpoints. Token creation shows the raw token once in a modal with a copy button.
  • Categories — CRUD with decay function picker (linear / exponential) and decay parameter input with live preview chart (preview is local-only JS, no API call needed).
  • AuditGET /api/v1/admin/audit-log, filterable.
  • SettingsGET /api/v1/admin/jobs/status and GET /api/v1/admin/config (returns effective config with secrets masked). Admin-only manual job triggers via POST /api/v1/admin/jobs/trigger/{name}.

Identity resolution flow (login)

Local admin:

  1. User submits /login/local form.
  2. UI verifies password against LOCAL_ADMIN_PASSWORD_HASH (Argon2id, in UI env).
  3. UI calls POST /api/v1/auth/users/upsert-local with the username.
  4. UI stores the returned user_id in the session.

OIDC:

  1. User clicks "Sign in with Microsoft".
  2. UI starts authorization-code-with-PKCE flow against Entra.
  3. Callback arrives; UI exchanges code, validates ID token, extracts sub, email, name, groups.
  4. UI calls POST /api/v1/auth/users/upsert-oidc.
  5. UI stores the returned user_id in the session.

The session contains: user_id, display_name, role (cached from the upsert response), expires_at. On every request the UI sets Authorization: Bearer <UI_SERVICE_TOKEN> and X-Acting-User-Id: <user_id> when calling the API.

UX requirements

  • Light/dark mode toggle in top nav, persisted in localStorage, defaults to system preference. Tailwind dark: variant; CSS variables for accent.
  • Modern look: generous whitespace, rounded-xl, subtle shadows, monospace for IPs/tokens, color-coded category chips.
  • All destructive actions confirm via modal.
  • Mobile-responsive (sidebar collapses to drawer below md).
  • All forms server-validated by surfacing the API's validation errors; show inline.
  • No client-side framework heavier than Alpine.js.
  • API errors render as toast notifications, never raw JSON.
  • Dates and times render in the browser's locale (via Intl.DateTimeFormat). Templates emit ISO 8601 UTC inside <time class="irdb-dt" datetime="…">…</time>; a small client-side pass replaces the text content on load and after htmx swaps. Deployments can configure a UI_LOCALE BCP 47 fallback that's appended after the browser's preference.

RBAC matrix

Identical to before. The UI does not enforce RBAC by hiding buttons alone — the API is the source of truth. The UI does hide UI elements the user can't use, but treats this as cosmetic; security comes from the API rejecting unauthorized calls.

Action viewer operator admin
View IPs / scores / history
Create / remove manual blocks
Manage allowlist
Manage policies / categories
Manage reporters / consumers
Manage tokens
Manage users / role mappings
Trigger manual jobs
View audit log

8. Authentication & Authorization

Authentication is split between the two containers along clean lines:

  • api owns: validation of all token kinds (reporter, consumer, admin, service); the users, oidc_role_mappings, and api_tokens tables; RBAC enforcement on every admin endpoint.
  • ui owns: browser sessions; the OIDC redirect/callback flow; local admin password validation; rendering of login forms.

Token kinds (all stored in api_tokens, hashed at rest)

  • reporter — calls POST /api/v1/report. Bound to a reporter record.
  • consumer — calls GET /api/v1/blocklist. Bound to a consumer record.
  • admin — calls /api/v1/admin/* directly. Bound to a configured role (viewer / operator / admin). For automation that doesn't go through the UI.
  • service — calls /api/v1/admin/* and /api/v1/auth/* with X-Acting-User-Id. Held by the ui container and never exposed to humans.

Token format: irdb_<kind>_<32-char-base32> (e.g. irdb_rep_ABCD…, irdb_con_…, irdb_adm_…, irdb_svc_…). The kind prefix aids ops/log triage; auth still validates against the hashed full token. Service tokens never appear in the UI's token list.

How the UI authenticates the browser user (BFF flow)

Microsoft Entra ID (OIDC) — handled in ui

  • Standard authorization-code flow with PKCE.
  • Required scopes: openid profile email. Plus the groups claim (preferred via Entra app config, not the Graph API).
  • The UI validates the ID token (signature, issuer, audience, expiry, nonce) using the JWKS endpoint.
  • On successful validation, the UI calls POST /api/v1/auth/users/upsert-oidc. The API resolves the role from oidc_role_mappings (or OIDC_DEFAULT_ROLE if no group matches; set to none to deny login). The UI stores the returned user_id in the session.

Local admin — handled in ui

  • LOCAL_ADMIN_ENABLED, LOCAL_ADMIN_USERNAME, LOCAL_ADMIN_PASSWORD_HASH are env vars on the ui container only.
  • UI validates the password against the Argon2id hash, then calls POST /api/v1/auth/users/upsert-local.
  • Local admin always has admin role.
  • Login form at /login shows two options: "Sign in with Microsoft" (primary) and "Local sign-in" (collapsed by default; hidden entirely if LOCAL_ADMIN_ENABLED=false).
  • Sessions: PHP native, file-backed inside the container, SameSite=Lax, Secure when APP_ENV=production. Sessions are tied to a specific UI replica — sticky sessions required when scaling UI horizontally (which is unusual; UI is typically single-replica).

How the API authenticates calls from the UI

Every UI-originated API call carries:

Authorization: Bearer <UI_SERVICE_TOKEN>
X-Acting-User-Id: <user_id>

The API:

  1. Validates the service token.
  2. Looks up the user by id; rejects 404 if not found, 403 if the user is disabled.
  3. Applies RBAC for that user's role.
  4. Logs the resulting action in audit_log with actor_kind=user, actor_id=<user_id> (NOT the service token).

X-Acting-User-Id is only trusted in combination with the service token. It's ignored on calls authenticated with other token kinds.

RBAC enforcement

The API has a single RbacMiddleware that runs after authentication. Each admin endpoint declares the required role. The middleware checks the resolved role (from the user record or admin token) against the requirement and returns 403 on mismatch.

The UI also conditionally renders elements based on the cached role in the session, but this is purely cosmetic. Anything important is enforced server-side.

CSRF

  • UI forms: per-session CSRF token on every state-changing form. Validated by UI middleware before forwarding to the API.
  • API: stateless, Bearer-authenticated, CSRF-exempt.

9. Configuration

Single .env file at the repo root, consumed by docker-compose. Each container reads only the variables it needs.

Shared (both containers)

# A 32-byte hex string. Used by api to authenticate the ui's calls.
# Generate with: openssl rand -hex 32
UI_SERVICE_TOKEN=

api container

APP_ENV=production           # development | production
LOG_LEVEL=info
APP_SECRET=                  # 32-byte hex; used internally for signing things like ETags

# Database
DB_DRIVER=sqlite             # sqlite | mysql
DB_SQLITE_PATH=/data/irdb.sqlite
DB_MYSQL_HOST=
DB_MYSQL_PORT=3306
DB_MYSQL_DATABASE=
DB_MYSQL_USERNAME=
DB_MYSQL_PASSWORD=

# OIDC role mapping (defaults applied if no group mapping matches)
OIDC_DEFAULT_ROLE=viewer     # viewer | none

# Reputation engine
SCORE_RECOMPUTE_INTERVAL_SECONDS=300
SCORE_REPORT_HARD_CUTOFF_DAYS=365

# Internal jobs
INTERNAL_JOB_TOKEN=                       # 32-byte hex
JOB_RECOMPUTE_MAX_RUNTIME_SECONDS=240
JOB_RECOMPUTE_MAX_ROWS_PER_TICK=5000
JOB_AUDIT_RETENTION_DAYS=180
JOB_GEOIP_REFRESH_INTERVAL_DAYS=7

# GeoIP
GEOIP_ENABLED=true
GEOIP_COUNTRY_DB=/data/geoip/GeoLite2-Country.mmdb
GEOIP_ASN_DB=/data/geoip/GeoLite2-ASN.mmdb
MAXMIND_LICENSE_KEY=

# CORS — origin of the ui container (or future SPA frontend)
UI_ORIGIN=http://localhost:8080

# Rate limiting (public API)
API_RATE_LIMIT_PER_SECOND=60

ui container

APP_ENV=production
LOG_LEVEL=info
UI_SECRET=                   # 32-byte hex; signs session cookies
PUBLIC_URL=http://localhost:8080

# Where the ui finds the api (internal docker network DNS)
API_BASE_URL=http://api:8081

# OIDC (Entra ID) — lives in ui only
OIDC_ENABLED=true
OIDC_ISSUER=https://login.microsoftonline.com/<tenant>/v2.0
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=https://reputation.example.com/oidc/callback

# Local admin — lives in ui only
LOCAL_ADMIN_ENABLED=true
LOCAL_ADMIN_USERNAME=admin
LOCAL_ADMIN_PASSWORD_HASH=

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

A complete .env.example documents every variable with comments. The README walks through generating the secrets.


10. Docker

Two images, one repo. The api and ui are built independently from api/Dockerfile and ui/Dockerfile. They run as separate compose services. Periodic batch work in the api is triggered by an external scheduler hitting /internal/jobs/*. An optional sidecar overlay provides scheduling for users who don't want host cron.

Images

api/Dockerfile (multi-stage):

  1. composer:2 stage — composer install --no-dev --optimize-autoloader.
  2. Final dunglas/frankenphp:1-php8.3-alpine — install required PHP extensions (pdo_sqlite, pdo_mysql, mbstring, intl, opcache, bcmath), copy app + vendor, configure FrankenPHP via api/docker/Caddyfile. GeoLite2 download happens at build time if MAXMIND_LICENSE_KEY build-arg is provided.
  3. ENTRYPOINT is api/docker/entrypoint.sh — dispatcher with modes api (default), migrate.

ui/Dockerfile (multi-stage):

  1. node:20-alpine stage — npm ci && npm run build produces public/assets/app.css and public/assets/app.js.
  2. composer:2 stage — composer install --no-dev --optimize-autoloader.
  3. Final dunglas/frankenphp:1-php8.3-alpine — install mbstring, intl, opcache, copy app + assets + vendor, configure via ui/docker/Caddyfile.
  4. ENTRYPOINT is ui/docker/entrypoint.sh — single mode (ui), no migrations.

Containers

api — JSON backend on :8081

  • Healthcheck: GET /healthz returns 200 with {status, db, jobs: {...}}.
  • Stateless when using MySQL; can be scaled to N replicas.

ui — BFF on :8080

  • Healthcheck: GET /healthz returns 200 with {status, api_reachable, last_api_check_at}. Returns 200 even if the api is briefly unreachable (the UI renders degraded states).
  • Single replica is recommended (sticky sessions otherwise).

migrate — one-shot, runs Phinx migrations against the api's database, seeds defaults, ensures the service token exists, then exits 0.

  • Built from api/Dockerfile, command migrate.
  • restart: "no" in compose.

docker-compose.yml (canonical, host-driven scheduler)

services:
  migrate:
    image: irdb-api:latest
    build: { context: ./api }
    command: migrate
    env_file: .env
    volumes:
      - irdb-data:/data
    restart: "no"

  api:
    image: irdb-api:latest
    command: api
    env_file: .env
    ports:
      - "8081:8081"
    volumes:
      - irdb-data:/data
    depends_on:
      migrate:
        condition: service_completed_successfully
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8081/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3
    restart: unless-stopped

  ui:
    image: irdb-ui:latest
    build: { context: ./ui }
    env_file: .env
    ports:
      - "8080:8080"
    depends_on:
      api:
        condition: service_healthy
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:8080/healthz"]
      interval: 30s
      timeout: 5s
      retries: 3
    restart: unless-stopped

  # Uncomment to use MySQL. Also set DB_DRIVER=mysql in .env.
  # mysql:
  #   image: mysql:8
  #   environment:
  #     MYSQL_DATABASE: ${DB_MYSQL_DATABASE}
  #     MYSQL_USER: ${DB_MYSQL_USERNAME}
  #     MYSQL_PASSWORD: ${DB_MYSQL_PASSWORD}
  #     MYSQL_ROOT_PASSWORD: ${DB_MYSQL_ROOT_PASSWORD}
  #   volumes:
  #     - mysql-data:/var/lib/mysql
  #   healthcheck:
  #     test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
  #     interval: 10s
  #     timeout: 5s
  #     retries: 10
  #   restart: unless-stopped

volumes:
  irdb-data:
  # mysql-data:

Scheduling — three documented options

Option A: Host cron (recommended for VM deployments)

* * * * * curl -sf -m 280 -X POST -H "Authorization: Bearer $INTERNAL_JOB_TOKEN" http://localhost:8081/internal/jobs/tick > /dev/null

Option B: systemd timer

Provide examples/scheduler/irdb-tick.service and examples/scheduler/irdb-tick.timer. Documented in README.

Option C: Sidecar overlay (compose.scheduler.yml)

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

docker/scheduler.crontab:

* * * * * curl -sf -m 280 -X POST -H "Authorization: Bearer $INTERNAL_JOB_TOKEN" http://api:8081/internal/jobs/tick > /dev/null

Started with: docker compose -f docker-compose.yml -f compose.scheduler.yml up -d.

SQLite on a shared volume — known constraint

The api container writes to /data/irdb.sqlite through the irdb-data volume. SQLite's WAL mode handles this correctly on a local Docker volume. It does not work reliably on networked storage (NFS, SMB, EFS). The README must call this out and recommend MySQL for any deployment using networked storage or multiple hosts.

On every connection startup the api sets:

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA busy_timeout = 5000;
PRAGMA foreign_keys = ON;

The ui container does not touch the volume; the volume is exclusively the api's.

Scaling notes

  • With MySQL, api is stateless and can be replicated (docker compose up --scale api=3 behind a load balancer). The scheduler fires against the LB; job_locks ensures only one replica actually runs each job.
  • With SQLite, do not scale api beyond 1; vertical scaling only.
  • ui is typically single-replica due to local-file sessions. To scale, either: (a) use sticky sessions at the LB, or (b) replace the UI with a future stateless frontend (out of scope).

Reverse proxy in production

For production, users typically front both containers with nginx/Caddy/Traefik on the host. A representative Caddy config is provided in examples/reverse-proxy/:

reputation.example.com        → ui:8080
reputation-api.example.com    → api:8081

Single-hostname routing (e.g. everything under reputation.example.com with /api/* → api, /* → ui) also works and is documented as an alternative. The browser must reach the UI; firewalls/reporters reach the API.


11. Project Structure

Monorepo. Each container has its own subdirectory with its own composer.json, tests, and Dockerfile. Documentation lives at the root in doc/. Examples and shared compose files at the root.

.
├── README.md                        # quickstart, links to doc/
├── PLAN.md                          # written first, before coding
├── PROGRESS.md                      # updated after each milestone
├── docker-compose.yml
├── compose.scheduler.yml
├── .env.example
│
├── doc/                             # ★ first-class documentation, see §17
│   ├── architecture.md
│   ├── api-overview.md
│   ├── auth-flows.md
│   ├── frontend-development.md
│   └── api-reference.md
│
├── examples/
│   ├── reporters/                   # curl, python, bash sample report scripts
│   ├── consumers/                   # iptables-restore, nginx include, HAProxy ACL
│   ├── scheduler/
│   │   ├── host.crontab
│   │   ├── irdb-tick.service
│   │   └── irdb-tick.timer
│   └── reverse-proxy/
│       └── Caddyfile
│
├── api/                             # ─────── api container ───────
│   ├── Dockerfile
│   ├── composer.json
│   ├── phpunit.xml
│   ├── phpstan.neon
│   ├── bin/
│   │   └── console                  # CLI: migrate, seed, scores:rebuild, jobs:run <name>, tokens:create
│   ├── config/
│   │   ├── settings.php
│   │   └── phinx.php
│   ├── db/
│   │   ├── migrations/
│   │   └── seeds/
│   ├── docker/
│   │   ├── Caddyfile                # /api/v1/* public, /internal/* network-restricted
│   │   └── entrypoint.sh            # modes: api | migrate
│   ├── public/
│   │   └── index.php                # Slim entry
│   ├── src/
│   │   ├── App/
│   │   │   ├── Bootstrap.php
│   │   │   ├── Container.php
│   │   │   └── Routes.php
│   │   ├── Domain/
│   │   │   ├── Reputation/          # scoring, decay, policy evaluator
│   │   │   ├── Ip/                  # parsing, normalization, CIDR ops
│   │   │   ├── Enrichment/          # MaxMind wrapper
│   │   │   └── Audit/
│   │   ├── Infrastructure/
│   │   │   ├── Db/                  # DBAL, repositories
│   │   │   ├── Auth/                # token resolver, role mapper, RBAC middleware
│   │   │   ├── Http/                # middlewares (auth, rate limit, CORS, internal-only, error handler)
│   │   │   └── Jobs/                # job runner, locks, tick dispatcher, individual jobs
│   │   ├── Application/
│   │   │   ├── Public/              # /api/v1/{report,blocklist}
│   │   │   ├── Admin/               # /api/v1/admin/*
│   │   │   ├── Auth/                # /api/v1/auth/* (UI BFF only)
│   │   │   └── Internal/            # /internal/jobs/*
│   │   └── Support/
│   └── tests/
│       ├── Unit/
│       ├── Integration/             # spins up Slim app with in-memory SQLite
│       └── Fixtures/
│
└── ui/                              # ─────── ui container ───────
    ├── Dockerfile
    ├── composer.json
    ├── package.json
    ├── tailwind.config.js
    ├── postcss.config.js
    ├── phpunit.xml
    ├── phpstan.neon
    ├── docker/
    │   ├── Caddyfile
    │   └── entrypoint.sh
    ├── public/
    │   ├── index.php                # Slim entry
    │   └── assets/                  # built CSS/JS
    ├── resources/
    │   ├── css/app.css
    │   ├── js/app.js
    │   └── views/                   # Twig templates
    │       ├── layout.twig
    │       ├── pages/
    │       │   ├── login.twig
    │       │   ├── dashboard.twig
    │       │   ├── ips/
    │       │   ├── policies/
    │       │   ├── tokens/
    │       │   ├── audit.twig
    │       │   └── settings.twig
    │       └── partials/
    ├── src/
    │   ├── App/
    │   │   ├── Bootstrap.php
    │   │   ├── Container.php
    │   │   └── Routes.php
    │   ├── ApiClient/                # Guzzle-based; one method per endpoint group
    │   │   ├── ApiClient.php
    │   │   ├── AdminClient.php
    │   │   ├── AuthClient.php
    │   │   └── DTOs/
    │   ├── Auth/
    │   │   ├── OidcController.php
    │   │   ├── LocalLoginController.php
    │   │   ├── SessionManager.php
    │   │   └── ImpersonationHeaderMiddleware.php
    │   ├── Controllers/              # one per UI section; thin, calls ApiClient
    │   ├── Http/                     # CSRF, error handler, flash messages
    │   └── Support/
    └── tests/
        ├── Unit/
        └── Integration/              # spins up ui Slim app with mocked ApiClient

12. Implementation Milestones

Execute in order. After each milestone: run tests, run linter, commit with a clear message, and update PROGRESS.md with a one-paragraph summary. Do not start the next milestone until the current one passes its acceptance criteria.

M1 — Monorepo skeleton & toolchain

  • Repo layout from §11. Both api/composer.json and ui/composer.json boot a Slim app. ui/package.json builds Tailwind. Both PHPUnit suites run (empty). api/Dockerfile, ui/Dockerfile, docker-compose.yml, root .env.example.
  • All three services build and start under compose. api returns a placeholder /healthz. ui returns a placeholder /healthz and shows a "hello" page. migrate runs an empty Phinx set and exits 0.
  • Done when: docker compose build succeeds; docker compose up brings api and ui to healthy and migrate exited 0; both phpunit runs pass; CI runs phpstan and php-cs-fixer --dry-run on both subprojects.

M2 — Database & migrations (api)

  • DBAL configured for SQLite + MySQL. Phinx migrations for every table in §4 (including job_locks, job_runs). Seeds for default categories and policies (strict, moderate, paranoid).
  • IP normalization helper with full unit tests covering IPv4, IPv6, IPv4-in-IPv6, invalid inputs, CIDR parsing, subnet containment.
  • Done when: migrations run cleanly on both drivers (test both in CI); php api/bin/console db:seed populates defaults; IP helper has ≥95% coverage.

M3 — API auth foundations

  • Token kinds (reporter, consumer, admin, service); creation, hashing, validation. UI_SERVICE_TOKEN ensured on container startup. Token-resolver middleware extracts the active principal.
  • RbacMiddleware enforces required role per route. ImpersonationMiddleware reads X-Acting-User-Id only when the resolved token is a service token.
  • POST /api/v1/auth/users/upsert-oidc and upsert-local. GET /api/v1/admin/me.
  • Done when: integration tests cover every combination — bad token, wrong-kind token, valid admin token, service token without impersonation header, service token with non-existent user, service token with valid user, role enforcement (viewer denied write, admin allowed). Tests against both SQLite and MySQL.

M4 — Token system & ingest API

  • Reporter and consumer token CRUD via admin endpoints (raw token shown once in response). Rate limiter (token-bucket; in-process per replica — acceptable for single-replica deployments).
  • POST /api/v1/report end-to-end. Append to reports. Update ip_scores synchronously for the touched (ip, category) pair.
  • Done when: integration test posts 100 reports across categories and reads back correct denormalized scores; bad tokens rejected; rate limit returns 429; admin-token kind cannot post reports (wrong kind).

M5 — Reputation engine + internal job endpoints

  • Decay functions (linear + exponential), score recomputation service, Clock interface.
  • job_locks and job_runs repositories. Job runner abstraction: each job class declares its name, default interval, max runtime; runner handles lock acquire/release, job_runs write, error capture.
  • Internal jobs: recompute-scores, cleanup-audit, enrich-pending (skeleton — full enrichment lands in M9), and the tick dispatcher.
  • HTTP routes /internal/jobs/* behind InternalNetworkMiddleware (loopback + RFC1918 only) and InternalTokenMiddleware.
  • CLI php api/bin/console jobs:run <name> for local invocation.
  • Done when: unit tests verify decay math against hand-computed values; integration test ages reports via fixed clock and confirms recompute-scores updates ip_scores correctly; concurrent calls produce one success and one skipped_locked row in job_runs; calls from outside the allowed network return 404; missing/wrong token returns 401; tick invokes only jobs whose interval has elapsed.

M6 — Manual blocks, allowlist, subnets (api)

  • Repositories + services. Admin endpoints for IP and CIDR (v4/v6) entries. CIDR containment evaluator (in-memory, refresh on change).
  • Done when: an IP inside an allowlisted /24 is excluded from any blocklist regardless of score; a manually blocked /16 emits as a single CIDR line; admin tests cover both v4 and v6.

M7 — Policies & distribution API

  • Policy CRUD endpoints. GET /api/v1/blocklist with caching, ETag, plain-text and JSON formats.
  • GET /api/v1/admin/policies/{id}/preview returns count + sample of resulting blocklist (used by UI).
  • Done when: three seeded policies produce different blocklists from the same data; ETag round-trip returns 304; performance test: 50k scored IPs render blocklist in <500 ms.

M8 — UI scaffold + auth flows

  • ui container: Slim app, base layout (Twig + Tailwind + dark mode toggle), session manager, CSRF middleware, ApiClient with retry and error mapping.
  • Login page, OIDC redirect/callback (PKCE), local admin form. Both call the api's auth endpoints and store user_id in the session.
  • ImpersonationHeaderMiddleware adds Authorization: Bearer <UI_SERVICE_TOKEN> and X-Acting-User-Id to every outgoing API call.
  • Logout clears the session.
  • Done when: a fresh user can log in via OIDC against a test tenant (document setup in doc/oidc.md) and via local admin; /app/me page renders showing the user; logout works; CSRF is enforced; an api-down scenario shows a friendly degraded page rather than an exception.

M9 — UI: IPs, history, dashboard

  • IP search/filter table, IP detail page, timeline component, dashboard with Chart.js.
  • Done when: every page renders for all three roles with correct visibility (cosmetic) AND the api enforces correct access (security); dark mode persists; Lighthouse accessibility ≥90.

M10 — UI: subnets, allowlist, policies, tokens, categories

  • CRUD pages for every admin domain. Token creation modal shows raw token once with copy-to-clipboard.
  • Policy editor with category × threshold matrix.
  • Category editor with decay-curve preview.
  • Done when: every admin endpoint reachable from the UI by an admin role; operator can do operator-allowed actions; viewer is read-only; all destructive actions show confirmation modals.

M11 — Enrichment (api)

  • MaxMind wrapper. enrich-pending job processes IPs missing enrichment in batches; refresh-geoip job downloads fresh DBs when MAXMIND_LICENSE_KEY is set. UI shows country flag + ASN on IP detail.
  • Done when: known IPs show country + ASN within one tick after first sighting; missing-DB scenario logs a warning, the enrichment job no-ops cleanly, the rest of the system keeps working.

M12 — Audit log + Settings page

  • Every write through admin or auth endpoints produces an audit entry attributed to the acting user (NOT the service token). Filterable audit page in UI.
  • Settings page: effective config (secrets masked), per-job status with overdue badges, admin-only manual-trigger buttons that POST to /api/v1/admin/jobs/trigger/{name}.
  • Done when: every action in the RBAC matrix produces a correctly attributed audit entry; manual job triggers from UI succeed and resulting job_runs rows carry triggered_by = manual; non-admin users cannot see or invoke manual triggers (UI hides them, api rejects them).

M13 — Polish, OpenAPI, docs

  • Generate and serve openapi.yaml, /api/docs viewer.
  • README walks through quickstart (compose with sidecar scheduler), MySQL setup, OIDC setup, reverse-proxy setup.
  • All doc/*.md files written per §17 — this is a hard requirement, not a nice-to-have.
  • Sample reporter scripts (curl, Python, Bash) and sample firewall configs (iptables ipset refresh, nginx allow/deny include, HAProxy ACL) in examples/.
  • Done when: a fresh clone → docker compose -f docker-compose.yml -f compose.scheduler.yml up → admin login → token created → curl example reports an IP → second curl pulls a blocklist containing it → after one minute, a recompute-scores row appears in job_runs. Steps documented and executed verbatim in CI. All doc/ files reviewed for accuracy against the as-built code.

M14 — Hardening

  • Security headers on both containers (CSP, HSTS, X-Frame-Options, Referrer-Policy). UI login throttling and brute-force lockout on local admin. Token entropy verified. Logs scrubbed of secrets. Backup guidance for /data and MySQL in README.

13. Testing Strategy

  • Unit tests: IP helpers, decay functions, policy evaluator, RBAC checks. Aim ≥80% line coverage in Domain/.
  • Integration tests: spin up Slim app with in-memory SQLite per test class. Cover every API endpoint and every UI route's status + RBAC.
  • Matrix CI: run the full suite against SQLite and MySQL.
  • Static analysis: PHPStan level 8 on src/. PHP-CS-Fixer for style.
  • Security: composer audit in CI.

Running locally

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

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

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

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

Model selection for test/lint runs (Claude Code)

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

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


14. Coding Conventions

  • Strict types (declare(strict_types=1);) in every PHP file.
  • PSR-12.
  • DI via PHP-DI.
  • No business logic in controllers — controllers parse input, call a service, render a response.
  • Repositories return domain objects, not arrays.
  • All DB writes inside transactions. All time via an injectable Clock interface (production: system clock; tests: fixed clock).
  • Exceptions: domain layer throws typed exceptions; HTTP error middleware maps them to status codes.
  • Logging via Monolog to stdout in JSON.

15. Out of Scope (do not build)

  • Multi-tenancy.
  • Federation between IRDB instances.
  • Email/Slack alerting.
  • A public reputation lookup page.
  • Automatic subnet aggregation (manual only, per spec).
  • Future frontends (Vue/React/Svelte SPA, native desktop, mobile apps). The architecture deliberately enables them; do not build them. Do not add hooks or speculative endpoints for them. The contract for future frontend authors is documented in doc/frontend-development.md (§17), and that document is the deliverable that anticipates this work — not code.
  • Direct user-token issuance (OAuth flows where the API issues tokens to end users for SPA/native/mobile use). Sketched in doc/auth-flows.md as future work.

These can be future work; do not introduce hooks for them now beyond what naturally falls out of the design.


16. Documentation Requirements (doc/)

Documentation in doc/ is a deliverable, not a postscript. Future engineers building Vue/native/mobile frontends will read these files first, before touching code. They must be accurate against the as-built system. M13's acceptance criteria gate the milestone on these being complete.

Each file below has a required outline. Claude Code may add subsections but must not omit the required ones. Markdown only; diagrams in Mermaid (rendered by GitHub) where helpful.

doc/architecture.md — Overall architecture

Required sections:

  1. System overview — what IRDB does, in two paragraphs.
  2. Container topology — Mermaid diagram of api / ui / migrate / scheduler / mysql; what each owns.
  3. Where state lives — explicit list: api owns the database; ui owns browser sessions only.
  4. Stable surfaces vs replaceable parts — table identifying what new frontends can rely on (the API contract, token kinds, RBAC roles, users shape, OIDC role mapping semantics) versus what may change without notice (Twig templates, UI route paths under /app/*, internal class names).
  5. Why this split — short rationale: BFF pattern, why ui has no DB, why the api doesn't render HTML.

doc/api-overview.md — Public API surface

Audience: machine-client integrators (firewalls, fail2ban-style agents, monitoring) and frontend authors.

Required sections:

  1. Base URL & versioning — single v1 major version, additive-only changes within v1.
  2. Authentication — short summary of token kinds (table), with link to auth-flows.md for detail.
  3. Endpoint groups — Public, Admin, Auth, Internal — what each is for and who calls it.
  4. Common conventions — JSON shape, error envelope, pagination, ETag, rate limiting, IP normalization, CIDR notation.
  5. Worked examplescurl snippets for: posting a report, pulling a blocklist, an admin search with service-token impersonation, an admin search with an admin-kind token.
  6. Pointer to OpenAPI/api/v1/openapi.yaml is the source of truth for endpoint schemas; this document is for context the spec doesn't capture.

doc/auth-flows.md — All authentication flows

Required sections:

  1. Overview — table of who calls what with which token kind.
  2. Reporter / consumer flow (machine clients) — sequence diagram (Mermaid): client → api with Bearer.
  3. Admin token flow — for automation that doesn't go through the UI.
  4. UI BFF flow (current) — full sequence diagram showing browser → ui → api with service token + impersonation header. Cover both OIDC and local admin paths.
  5. OIDC details — Entra app registration steps (app type, redirect URI, claims config for groups, optional API permissions). Include screenshots-by-description so a reader can replicate without seeing real screenshots.
  6. Local admin — when to use it, why it's UI-side, how to generate the password hash (php -r "echo password_hash('s3cret', PASSWORD_ARGON2ID);"), why it's discouraged in production.
  7. Future: direct user tokens (out of scope, sketched) — a section describing how a future native/mobile/SPA flow would work without the BFF: a new /api/v1/auth/oauth/* endpoint group issuing user-bound bearer tokens after some flow (OIDC pass-through, device code, etc.). Explicitly marked "NOT IMPLEMENTED" with a note that this is the recommended extension point.
  8. CSRF, sessions, CORS — what's where and why.

doc/frontend-development.md — Building a new frontend

The headline document for future UI authors. Audience: someone tasked with rewriting the UI in Vue, building a Tauri desktop app, or a mobile app.

Required sections:

  1. Read this first — the contract: API + auth model is stable; UI can be replaced wholesale. Link to architecture.md's "stable surfaces" table.
  2. Three integration patterns:
    • (a) BFF replacement (drop-in for the current PHP UI) — same pattern, different language. New container holds the service token, manages sessions, calls api with impersonation header. Easiest path; works for SSR-style frontends (Next.js, Nuxt, Rails, Django). Worked example: pseudocode of a Node/Express BFF showing the three critical bits (OIDC handling, session storage, outgoing API call with impersonation header).
    • (b) SPA + thin BFF — Vue/React/Svelte SPA in browser, talks to a thin BFF for auth only. The BFF mints short-lived signed cookies the SPA presents on each request to the api. Pros, cons, when to choose this.
    • (c) Direct API access (native / mobile / SPA without BFF) — requires the user-token flow that's out of scope today. Reader is told: "this needs api work first; see auth-flows.md §7".
  3. Minimum API surface a frontend needs — checklist of admin endpoints a fully featured UI calls. Links to OpenAPI.
  4. CORS — how to configure UI_ORIGIN. For BFF pattern (server-to-server) CORS doesn't apply; for SPA pattern it does.
  5. Local development — how to run only the api container and point a non-PHP frontend at it (docker compose up api migrate then your frontend dev server with API_BASE_URL=http://localhost:8081).
  6. Migration path — how to swap the current UI with a new one without downtime: stand the new container next to the old at a different hostname, switch DNS, retire the old.
  7. What NOT to do — don't replicate business logic (scoring, RBAC, decay) in the frontend; don't store user data in the frontend's storage; don't bypass the service-token pattern by giving the SPA the service token directly.

doc/api-reference.md — Pointer + extras

Short. Tells readers the OpenAPI document is at /api/v1/openapi.yaml and is canonical. Documents the small set of things OpenAPI doesn't cleanly express: rate-limit headers, ETag semantics, the impersonation header convention, the response envelope for batched future endpoints.

Quality bar

  • Every code snippet in doc/ must be runnable as-is against a default docker compose up deployment, modulo tokens and hostnames the reader needs to fill in.
  • Every claim about the API or auth flow must match what the code does. CI step: a test that grep-checks for stale endpoint paths and token kinds.
  • No "TODO" or "coming soon" sections. If something isn't built, it's marked clearly under "Out of scope / future" with the rationale, not as an empty placeholder.
  • No screenshots (they go stale fast); use descriptive prose and Mermaid diagrams.
  • Each doc/*.md file ≤ 500 lines. If it grows beyond that, split it.

17. How to Work

  1. Start by creating a PLAN.md in the repo summarizing how you'll tackle M1–M3 in concrete tasks. Stop and wait for me to confirm before coding.
  2. Maintain a TODO list in your scratchpad. Tick items as you go.
  3. After each milestone, update PROGRESS.md and run the full test suite + linters.
  4. If a requirement here turns out to be ambiguous or wrong once you're in the code, stop and ask — don't paper over it.
  5. Prefer fewer, well-tested modules over many half-finished ones. It is better to ship M1–M5 solidly than M1–M11 shakily.

Begin with the PLAN.md.