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.
A central service that:
dunglas/frankenphp:1-php8.3-alpine (or -bookworm if Alpine causes pain with extensions)api container (backend)doctrine/dbal — so the same SQL works on both.refresh-geoip jobtext/plain for blocklists). No HTML, no Twig.ui container (frontend BFF)jumbojett/openid-connect-php for Microsoft Entra IDguzzlehttp/guzzle for calls to the api containerui container holds zero persistent data of its own. Everything goes through the API.api container.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.
┌──────────────────────────────┐
│ 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.
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).
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_idconsumers — one row per distribution consumer (firewall/proxy)
id, name (unique), description, policy_id (FK), is_active, created_at, created_by_user_id, last_pulled_atapi_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_atreporter_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_activereports — 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)(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(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.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)(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_atmanual_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_idallowlist — never-block entries
id, kind (ip | subnet), ip_bin / network_bin + prefix_length, reason, created_at, created_by_user_idpolicies — distribution profiles
id, name (unique, e.g. strict, moderate, paranoid), description, include_manual_blocks (bool, default true), created_atpolicy_category_thresholds:
policy_id, category_id, threshold (decimal; IP included if its score in this category ≥ threshold)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_atpassword_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_atviewer 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_atip_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.
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):
max(0, 1 − age_days / decay_param) where decay_param is days to zero (default 30).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.
The recompute-scores job (invoked on a schedule, default every 5 minutes — configurable):
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.(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.ip_scores.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_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).
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.
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.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-Match → 304.X-Blocklist-Generated-At, X-Blocklist-Entries, X-Blocklist-Policy.GET /api/v1/blocklist?format=json — convenience: array of {ip_or_cidr, categories, score, reason}.
All endpoints accept either:
Authorization: Bearer <admin-kind-token> — RBAC role determined by the token's configured role, ORAuthorization: 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/allowlistGET/POST/PATCH/DELETE /api/v1/admin/policies, /policies/{id}/thresholdsGET/POST/DELETE /api/v1/admin/reporters, /consumers, /tokens, /categoriesGET/POST/PATCH/DELETE /api/v1/admin/users, /oidc-role-mappingsGET /api/v1/admin/audit-logPOST /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.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.
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).
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.
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.
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.
The ui container owns:
The ui container does not:
GET / — redirect to /login if not signed in, else /dashboardGET /login — login pagePOST /login/local — local admin form submissionGET /login/oidc — initiate OIDC flowGET /oidc/callback — OIDC callbackPOST /logoutGET /healthz — UI's own health: {status, api_reachable: bool, last_api_check_at}. Does not depend on the API being up to return 200.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).
GET /api/v1/admin/stats/dashboard (UI controllers should never assemble dashboards from multiple admin calls; the API exposes purpose-built endpoints).GET /api/v1/admin/ips?... paginated list. IP Detail — GET /api/v1/admin/ips/{ip}.GET/POST/DELETE /api/v1/admin/{manual-blocks,allowlist}.GET/POST/PATCH /api/v1/admin/policies and /policies/{id}/thresholds. Threshold matrix editor; preview of resulting blocklist count via GET /api/v1/admin/policies/{id}/preview.GET /api/v1/admin/audit-log, filterable.GET /api/v1/admin/jobs/status and GET /api/v1/admin/config (returns effective config with secrets masked). Admin-only manual job triggers via POST /api/v1/admin/jobs/trigger/{name}.Local admin:
/login/local form.LOCAL_ADMIN_PASSWORD_HASH (Argon2id, in UI env).POST /api/v1/auth/users/upsert-local with the username.user_id in the session.OIDC:
sub, email, name, groups.POST /api/v1/auth/users/upsert-oidc.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.
localStorage, defaults to system preference. Tailwind dark: variant; CSS variables for accent.md).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 | ✓ | ✓ | ✓ |
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.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.
uiopenid profile email. Plus the groups claim (preferred via Entra app config, not the Graph API).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.uiLOCAL_ADMIN_ENABLED, LOCAL_ADMIN_USERNAME, LOCAL_ADMIN_PASSWORD_HASH are env vars on the ui container only.POST /api/v1/auth/users/upsert-local.admin role./login shows two options: "Sign in with Microsoft" (primary) and "Local sign-in" (collapsed by default; hidden entirely if LOCAL_ADMIN_ENABLED=false).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).Every UI-originated API call carries:
Authorization: Bearer <UI_SERVICE_TOKEN>
X-Acting-User-Id: <user_id>
The API:
404 if not found, 403 if the user is disabled.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.
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.
Single .env file at the repo root, consumed by docker-compose. Each container reads only the variables it needs.
# A 32-byte hex string. Used by api to authenticate the ui's calls.
# Generate with: openssl rand -hex 32
UI_SERVICE_TOKEN=
api containerAPP_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 containerAPP_ENV=production
LOG_LEVEL=info
UI_SECRET= # 32-byte hex; signs session cookies
PUBLIC_URL=http://localhost:8080
# Where the ui finds the api (internal docker network DNS)
API_BASE_URL=http://api:8081
# OIDC (Entra ID) — lives in ui only
OIDC_ENABLED=true
OIDC_ISSUER=https://login.microsoftonline.com/<tenant>/v2.0
OIDC_CLIENT_ID=
OIDC_CLIENT_SECRET=
OIDC_REDIRECT_URI=https://reputation.example.com/oidc/callback
# Local admin — lives in ui only
LOCAL_ADMIN_ENABLED=true
LOCAL_ADMIN_USERNAME=admin
LOCAL_ADMIN_PASSWORD_HASH=
A complete .env.example documents every variable with comments. The README walks through generating the secrets.
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.
api/Dockerfile (multi-stage):
composer:2 stage — composer install --no-dev --optimize-autoloader.dunglas/frankenphp:1-php8.3-alpine — install required PHP extensions (pdo_sqlite, pdo_mysql, mbstring, intl, opcache, bcmath), copy app + vendor, configure FrankenPHP via api/docker/Caddyfile. GeoLite2 download happens at build time if MAXMIND_LICENSE_KEY build-arg is provided.ENTRYPOINT is api/docker/entrypoint.sh — dispatcher with modes api (default), migrate.ui/Dockerfile (multi-stage):
node:20-alpine stage — npm ci && npm run build produces public/assets/app.css and public/assets/app.js.composer:2 stage — composer install --no-dev --optimize-autoloader.dunglas/frankenphp:1-php8.3-alpine — install mbstring, intl, opcache, copy app + assets + vendor, configure via ui/docker/Caddyfile.ENTRYPOINT is ui/docker/entrypoint.sh — single mode (ui), no migrations.api — JSON backend on :8081
GET /healthz returns 200 with {status, db, jobs: {...}}.ui — BFF on :8080
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).migrate — one-shot, runs Phinx migrations against the api's database, seeds defaults, ensures the service token exists, then exits 0.
api/Dockerfile, command migrate.restart: "no" in compose.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:
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.
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.
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.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).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.
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
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.
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.api returns a placeholder /healthz. ui returns a placeholder /healthz and shows a "hello" page. migrate runs an empty Phinx set and exits 0.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.job_locks, job_runs). Seeds for default categories and policies (strict, moderate, paranoid).php api/bin/console db:seed populates defaults; IP helper has ≥95% coverage.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.POST /api/v1/report end-to-end. Append to reports. Update ip_scores synchronously for the touched (ip, category) pair.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.recompute-scores, cleanup-audit, enrich-pending (skeleton — full enrichment lands in M9), and the tick dispatcher./internal/jobs/* behind InternalNetworkMiddleware (loopback + RFC1918 only) and InternalTokenMiddleware.php api/bin/console jobs:run <name> for local invocation.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.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).ui container: Slim app, base layout (Twig + Tailwind + dark mode toggle), session manager, CSRF middleware, ApiClient with retry and error mapping.user_id in the session.ImpersonationHeaderMiddleware adds Authorization: Bearer <UI_SERVICE_TOKEN> and X-Acting-User-Id to every outgoing API call.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.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./api/v1/admin/jobs/trigger/{name}.job_runs rows carry triggered_by = manual; non-admin users cannot see or invoke manual triggers (UI hides them, api rejects them).openapi.yaml, /api/docs viewer.doc/*.md files written per §17 — this is a hard requirement, not a nice-to-have.curl, Python, Bash) and sample firewall configs (iptables ipset refresh, nginx allow/deny include, HAProxy ACL) in examples/.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./data and MySQL in README.Domain/.src/. PHP-CS-Fixer for style.composer audit in CI.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 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./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.
declare(strict_types=1);) in every PHP file.Clock interface (production: system clock; tests: fixed clock).doc/frontend-development.md (§17), and that document is the deliverable that anticipates this work — not code.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.
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 architectureRequired sections:
api owns the database; ui owns browser sessions only.users shape, OIDC role mapping semantics) versus what may change without notice (Twig templates, UI route paths under /app/*, internal class names).doc/api-overview.md — Public API surfaceAudience: machine-client integrators (firewalls, fail2ban-style agents, monitoring) and frontend authors.
Required sections:
v1 major version, additive-only changes within v1.auth-flows.md for detail.curl snippets for: posting a report, pulling a blocklist, an admin search with service-token impersonation, an admin search with an admin-kind token./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 flowsRequired sections:
groups, optional API permissions). Include screenshots-by-description so a reader can replicate without seeing real screenshots.php -r "echo password_hash('s3cret', PASSWORD_ARGON2ID);"), why it's discouraged in production./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.doc/frontend-development.md — Building a new frontendThe 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:
architecture.md's "stable surfaces" table.auth-flows.md §7".UI_ORIGIN. For BFF pattern (server-to-server) CORS doesn't apply; for SPA pattern it does.docker compose up api migrate then your frontend dev server with API_BASE_URL=http://localhost:8081).doc/api-reference.md — Pointer + extrasShort. 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.
doc/ must be runnable as-is against a default docker compose up deployment, modulo tokens and hostnames the reader needs to fill in.doc/*.md file ≤ 500 lines. If it grows beyond that, split it.PLAN.md in the repo summarizing how you'll tackle M1–M3 in concrete tasks. Stop and wait for me to confirm before coding.PROGRESS.md and run the full test suite + linters.Begin with the PLAN.md.