# M04 — Token Management & Ingest API > Fresh Claude Code agent prompt. M03 must be complete and committed. > Estimated effort: medium. ## Mission Implement reporter and consumer CRUD plus token issuance via admin endpoints, the public `POST /api/v1/report` endpoint with synchronous `ip_scores` updates, and a per-token rate limiter. After this milestone, machine clients can report IPs and rate limits actually bite. ## Before you start 1. Verify M03: ```bash git log --oneline -3 cd api && composer test && composer stan && cd .. ``` 2. Read `SPEC.md` §4 (`reporters`, `consumers`, `api_tokens`, `reports`, `ip_scores` tables), §5 (Reputation Engine — scoring formula; you'll write the synchronous-update piece, but the bulk recompute is M05), §6 (API Contracts — Public API and the relevant Admin endpoints). 3. Confirm clean tree. ## Tasks ### 1. Reporter & Consumer admin CRUD In `api/src/Application/Admin/`: - `ReportersController.php`: - `GET /api/v1/admin/reporters` — list, paginated. - `GET /api/v1/admin/reporters/{id}` — detail. - `POST /api/v1/admin/reporters` — `{name, description, trust_weight}`. Returns the created record. - `PATCH /api/v1/admin/reporters/{id}` — partial update. - `DELETE /api/v1/admin/reporters/{id}` — soft delete (set `is_active=false`). Hard delete refused if reports exist (409). - `ConsumersController.php` — analogous, with `policy_id` instead of `trust_weight`. (Policy CRUD is M07; for now the FK is required and the UI will pass an existing policy id; in tests, you may seed a policy directly.) RBAC: all reporter/consumer endpoints require `Admin` role. ### 2. Token issuance & management In `api/src/Application/Admin/`: - `TokensController.php`: - `GET /api/v1/admin/tokens` — list. **Never** include `service`-kind tokens. Return prefix and metadata; never the raw token (it's not stored). - `POST /api/v1/admin/tokens` — body `{kind: "reporter"|"consumer"|"admin", reporter_id?, consumer_id?, role?, expires_at?}`. Validate constraints: - `kind=reporter` → `reporter_id` required, no `role`, no `consumer_id`. - `kind=consumer` → `consumer_id` required, no `role`, no `reporter_id`. - `kind=admin` → `role` required, no FKs. - `kind=service` → 400 always (service tokens cannot be created via API). - Returns `{id, kind, prefix, raw_token, ...}` — `raw_token` appears **only in this response**; document this in OpenAPI later. - `DELETE /api/v1/admin/tokens/{id}` — sets `revoked_at = now()`. Refuse on service tokens. RBAC: `Admin` role. Audit emission deferred to M12. ### 3. Public ingest: `POST /api/v1/report` In `api/src/Application/Public/ReportController.php`: - Auth: `TokenKind::Reporter` only. Reject all other kinds with 401 (wrong kind = generic unauthorized per M03 convention). - Body validation: `ip` (parse via `IpAddress::fromString`, 400 on failure), `category` (slug; lookup by `categories.slug`, 400 if unknown or `is_active=false`), `metadata` (optional, must be a JSON object ≤4 KB after re-encoding). - Insert a row into `reports`: - `weight_at_report` = current `trust_weight` of the reporter (snapshot). - `received_at` = current UTC time via injected `Clock`. - Update `ip_scores` for the affected `(ip_bin, category_id)` pair **synchronously**: - Compute the new score by re-running the formula (Σ weight × decay over reports for this ip+category, hard cutoff 365 days). The bulk recompute service lands in M05 — but for the synchronous-on-ingest path you need a small helper now. Place it at `api/src/Domain/Reputation/PairScorer.php` so M05 can build on it. - `UPSERT` the score row. - Return `202` with `{report_id, ip, received_at}`. ### 4. Rate limiter In `api/src/Infrastructure/Http/Middleware/RateLimitMiddleware.php`: - Token-bucket per token id. In-process state (PHP array attached to a singleton service); good enough for single-replica deployments and dev. - Bucket: capacity = `API_RATE_LIMIT_PER_SECOND` × 2, refill rate = `API_RATE_LIMIT_PER_SECOND` per second. Configurable. - On exhaustion: return `429` with `Retry-After: 1` (seconds, integer). - Apply to public endpoints (`/api/v1/report`, future `/api/v1/blocklist`). Skip for admin endpoints (admins are humans/UI; not a DDoS vector). - Tests: 60 requests in <1s with limit=60 → all 200/202; 120 in <1s → some 429s. Note for self: in-process means each replica has its own bucket. Document this in PROGRESS.md as a known limitation; multi-replica rate limiting needs a shared store and is out of scope. ### 5. Validation framework You'll need consistent request validation across this and future milestones. Two acceptable approaches: - **Hand-rolled** in each controller (acceptable for this scale). - **Lightweight library** like `respect/validation` (allowed; document in PROGRESS.md if added). Either way, validation errors must produce a uniform response: ```json {"error":"validation_failed","details":{"field":"reason"}} ``` HTTP status `400` for malformed; `422` is also acceptable but be consistent. ## Implementation notes - **PairScorer** signature: `score(string $ipBin, int $categoryId, DateTimeImmutable $now): float`. Reads from `reports`, applies category-specific decay (linear or exponential per `categories.decay_function` and `decay_param`). Hard cutoff at `SCORE_REPORT_HARD_CUTOFF_DAYS` (default 365). Returns the float score. - **Decay functions**: defined in SPEC §5. Linear: `max(0, 1 - age_days/decay_param)`. Exponential: `0.5 ^ (age_days/decay_param)`. Implement them in `api/src/Domain/Reputation/Decay.php` as pure functions with unit tests. - **`ip_scores` upsert**: use the DBAL adapter's UPSERT-equivalent. SQLite: `INSERT ... ON CONFLICT(ip_bin, category_id) DO UPDATE`. MySQL: `INSERT ... ON DUPLICATE KEY UPDATE`. Wrap in `RepositoryBase`. - **Report metadata size**: enforce ≤4 KB after `json_encode` of the parsed object. Reject larger with 400. - **IPv6 metadata**: just store the metadata as JSON — no special handling. - **Rate limit in tests**: inject the limiter so tests can either bypass it or fast-forward time via the `Clock`. Use a `ClockInterface` (you already created it for `received_at`). - **Reports are append-only**: never UPDATE or DELETE rows in `reports`. The ingest endpoint just inserts. ## Out of scope (DO NOT) - Bulk recompute / decay of all stored scores. M05. - Internal job endpoints. M05. - Allowlist / manual blocks. M06. - Distribution endpoint (`/api/v1/blocklist`). M07. - Audit log emission. M12. - Any UI changes. - GeoIP / enrichment. M11. - New dependencies beyond what's already in api/composer.json (one optional: `respect/validation` if you go that route — record in PROGRESS.md). ## Acceptance ```bash cd api && composer cs && composer stan && composer test && cd .. docker compose down -v cp .env.example .env # fill secrets if not already done docker compose up -d sleep 15 # Create a reporter and a token ADMIN_TOKEN=$(docker compose exec -T api php bin/console auth:create-token --kind=admin --role=admin --quiet) REPORTER=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"name":"web-prod-01","description":"prod webserver","trust_weight":1.0}' \ http://localhost:8081/api/v1/admin/reporters) REPORTER_ID=$(echo "$REPORTER" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["id"];') TOKEN_RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d "{\"kind\":\"reporter\",\"reporter_id\":$REPORTER_ID}" \ http://localhost:8081/api/v1/admin/tokens) RAW_TOKEN=$(echo "$TOKEN_RESP" | php -r 'echo json_decode(stream_get_contents(STDIN), true)["raw_token"];') [ -n "$RAW_TOKEN" ] # Submit a report RESP=$(curl -s -X POST -H "Authorization: Bearer $RAW_TOKEN" -H "Content-Type: application/json" \ -d '{"ip":"203.0.113.42","category":"brute_force","metadata":{"url":"/wp-login"}}' \ http://localhost:8081/api/v1/report) echo "$RESP" | grep -q '"report_id"' echo "$RESP" | grep -q '"received_at"' # ip_scores updated synchronously docker compose exec -T api sqlite3 /data/irdb.sqlite \ "SELECT ROUND(score, 4) FROM ip_scores WHERE ip_text='203.0.113.42';" | grep -q '^[0-9]' # Wrong-kind token rejected (admin token can't report) test "$(curl -s -o /dev/null -w '%{http_code}' \ -X POST -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{"ip":"1.2.3.4","category":"spam"}' \ http://localhost:8081/api/v1/report)" = "401" # Bad IP rejected test "$(curl -s -o /dev/null -w '%{http_code}' \ -X POST -H "Authorization: Bearer $RAW_TOKEN" -H "Content-Type: application/json" \ -d '{"ip":"not-an-ip","category":"spam"}' \ http://localhost:8081/api/v1/report)" = "400" # Unknown category rejected test "$(curl -s -o /dev/null -w '%{http_code}' \ -X POST -H "Authorization: Bearer $RAW_TOKEN" -H "Content-Type: application/json" \ -d '{"ip":"1.2.3.4","category":"nonexistent"}' \ http://localhost:8081/api/v1/report)" = "400" # Rate limit kicks in (with low API_RATE_LIMIT_PER_SECOND) docker compose down echo "API_RATE_LIMIT_PER_SECOND=2" >> .env docker compose up -d sleep 10 HITS_429=0 for i in $(seq 1 20); do CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST \ -H "Authorization: Bearer $RAW_TOKEN" -H "Content-Type: application/json" \ -d '{"ip":"1.2.3.4","category":"spam"}' \ http://localhost:8081/api/v1/report) [ "$CODE" = "429" ] && HITS_429=$((HITS_429+1)) done [ "$HITS_429" -gt 0 ] docker compose down -v ``` ## Handoff 1. Commit: ``` feat(M04): reporter/consumer CRUD, token issuance, ingest API, rate limiter - admin endpoints for reporters, consumers, tokens (raw token shown once) - POST /api/v1/report with synchronous ip_scores update via PairScorer - decay functions (linear + exponential) with unit tests - per-token in-process rate limiter on public endpoints ``` 2. Append to `PROGRESS.md`: ```markdown ## M04 — Token system & ingest (done) **Built:** reporter/consumer/token CRUD; POST /api/v1/report end-to-end; rate limiter; decay functions. **Notes for next milestone:** - Synchronous score updates are correct but only touch the (ip, category) pair just reported. Bulk decay re-application is M05's recompute job. - PairScorer is the authoritative single-pair scorer; the bulk recompute job in M05 should call into it (or a near-clone) so behavior stays consistent. - Rate limiter is in-process; document this in README. Multi-replica deployments need a shared store. - Service tokens cannot be created via the admin API; only the bootstrap path makes them. **Deviations from SPEC:** none. **Added dependencies:** [list any, e.g. respect/validation, or "none"]. ``` 3. **Stop.** Do not start M05.