Explorar el Código

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
- Clock interface with SystemClock + FixedClock for time-sensitive tests

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
chiappa hace 1 semana
padre
commit
ee391bcc6a
Se han modificado 34 ficheros con 2641 adiciones y 11 borrados
  1. 24 0
      PROGRESS.md
  2. 2 0
      api/config/settings.php
  3. 55 7
      api/src/App/AppFactory.php
  4. 52 4
      api/src/App/Container.php
  5. 83 0
      api/src/Application/Admin/AdminControllerSupport.php
  6. 208 0
      api/src/Application/Admin/ConsumersController.php
  7. 212 0
      api/src/Application/Admin/ReportersController.php
  8. 240 0
      api/src/Application/Admin/TokensController.php
  9. 180 0
      api/src/Application/Public/ReportController.php
  10. 25 0
      api/src/Domain/Category/Category.php
  11. 45 0
      api/src/Domain/Consumer/Consumer.php
  12. 42 0
      api/src/Domain/Reporter/Reporter.php
  13. 33 0
      api/src/Domain/Reputation/Decay.php
  14. 16 0
      api/src/Domain/Reputation/DecayFunction.php
  15. 54 0
      api/src/Domain/Reputation/PairScorer.php
  16. 17 0
      api/src/Domain/Time/Clock.php
  17. 36 0
      api/src/Domain/Time/FixedClock.php
  18. 16 0
      api/src/Domain/Time/SystemClock.php
  19. 51 0
      api/src/Infrastructure/Auth/TokenRepository.php
  20. 64 0
      api/src/Infrastructure/Category/CategoryRepository.php
  21. 129 0
      api/src/Infrastructure/Consumer/ConsumerRepository.php
  22. 49 0
      api/src/Infrastructure/Http/Middleware/RateLimitMiddleware.php
  23. 56 0
      api/src/Infrastructure/Http/RateLimiter.php
  24. 125 0
      api/src/Infrastructure/Reporter/ReporterRepository.php
  25. 70 0
      api/src/Infrastructure/Reputation/IpScoreRepository.php
  26. 92 0
      api/src/Infrastructure/Reputation/ReportRepository.php
  27. 50 0
      api/tests/Integration/Admin/ConsumersControllerTest.php
  28. 144 0
      api/tests/Integration/Admin/ReportersControllerTest.php
  29. 102 0
      api/tests/Integration/Admin/TokensControllerTest.php
  30. 92 0
      api/tests/Integration/Public/RateLimitTest.php
  31. 152 0
      api/tests/Integration/Public/ReportControllerTest.php
  32. 2 0
      api/tests/Integration/Support/AppTestCase.php
  33. 62 0
      api/tests/Unit/Http/RateLimiterTest.php
  34. 61 0
      api/tests/Unit/Reputation/DecayTest.php

+ 24 - 0
PROGRESS.md

@@ -65,3 +65,27 @@
 **Service-token rotation:** out of scope this milestone — `ServiceTokenBootstrap` only handles "set or not set". Rotation means: deploy with the new value, restart api, manually revoke the old hash via a future tool. The bootstrap logs a warning when it inserts a new service token while another already exists.
 
 **Added dependencies:** none.
+
+## 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` (`api/src/Domain/Reputation/PairScorer.php`) is the authoritative single-pair scorer; the bulk recompute job in M05 should call into it (or a near-clone) so behavior stays consistent. It depends on `Clock`, `CategoryRepository`, and `ReportRepository::forScoring()`.
+- Decay shapes live as pure functions in `Decay::value(DecayFunction, ageDays, decayParam)` with seven unit tests against hand-computed reference values. M05's recompute will reuse this.
+- Rate limiter is in-process (PHP array on a singleton `RateLimiter`); document this in README. Multi-replica deployments need a shared store. The bucket capacity is `API_RATE_LIMIT_PER_SECOND × 2` with refill = `API_RATE_LIMIT_PER_SECOND` per second; on exhaustion the middleware emits 429 with `Retry-After: 1`. Skipped on admin/auth routes.
+- Service tokens cannot be created via the admin API (`kind=service` → 400) and are filtered out of the list endpoint unconditionally; only the bootstrap path makes them. Revoke on a service token returns 403 from `DELETE /api/v1/admin/tokens/{id}`.
+- Tokens raw value appears **only** in the create response payload (`raw_token`); we persist its SHA-256 hash and the 8-char prefix.
+- `ip_scores` upsert is per-driver: SQLite uses `ON CONFLICT(ip_bin, category_id) DO UPDATE`, MySQL uses `ON DUPLICATE KEY UPDATE`. Single helper in `IpScoreRepository::upsert()`.
+- `Clock` interface (`App\Domain\Time\Clock`) wraps wall-time for `received_at`, decay age, and rate-limit refill. `SystemClock` in production; `FixedClock` (with `advance()`) in tests.
+
+**API contract decisions:**
+- Admin endpoints (`/api/v1/admin/{reporters,consumers,tokens}`) require `Admin` role. RBAC is enforced via `RbacMiddleware::require($rf, Role::Admin)` on the route group.
+- Validation errors return `400` with `{"error":"validation_failed","details":{"field":"reason"}}`. Hand-rolled validators per controller — small surface, no third-party validator added.
+- DELETE on a reporter with existing reports returns `409` and flips `is_active=false` (soft delete) rather than removing the row; the audit trail is preserved per the FK RESTRICT semantics on `reports.reporter_id`.
+- Public `POST /api/v1/report` — wrong-kind tokens (admin/consumer/service) and inactive reporters both return `401` with the uniform `{"error":"unauthorized"}` envelope, matching the M03 convention. Bad IP / unknown category / oversized metadata return `400` with the validation envelope.
+- Metadata size limit: 4 KB after `json_encode`. Non-object metadata (arrays, scalars) is rejected.
+
+**Deviations from SPEC:** none.
+**Added dependencies:** none (chose hand-rolled validation over `respect/validation`).

+ 2 - 0
api/config/settings.php

@@ -46,4 +46,6 @@ return [
     'internal_job_token' => getenv('INTERNAL_JOB_TOKEN') ?: '',
     'ui_origin' => getenv('UI_ORIGIN') ?: 'http://localhost:8080',
     'oidc_default_role' => $oidcDefaultRole,
+    'score_hard_cutoff_days' => (int) (getenv('SCORE_REPORT_HARD_CUTOFF_DAYS') ?: 365),
+    'rate_limit_per_second' => (int) (getenv('API_RATE_LIMIT_PER_SECOND') ?: 60),
 ];

+ 55 - 7
api/src/App/AppFactory.php

@@ -4,11 +4,16 @@ declare(strict_types=1);
 
 namespace App\App;
 
+use App\Application\Admin\ConsumersController;
 use App\Application\Admin\MeController;
+use App\Application\Admin\ReportersController;
+use App\Application\Admin\TokensController;
 use App\Application\Auth\AuthController;
+use App\Application\Public\ReportController;
 use App\Domain\Auth\Role;
 use App\Infrastructure\Http\JsonErrorHandler;
 use App\Infrastructure\Http\Middleware\ImpersonationMiddleware;
+use App\Infrastructure\Http\Middleware\RateLimitMiddleware;
 use App\Infrastructure\Http\Middleware\RbacMiddleware;
 use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
 use Psr\Container\ContainerInterface;
@@ -21,14 +26,16 @@ use Slim\Factory\AppFactory as SlimAppFactory;
 use Slim\Routing\RouteCollectorProxy;
 
 /**
- * Builds the configured Slim app — used by `public/index.php` for serving
- * and by integration tests for end-to-end coverage of the middleware
- * stack. Centralising it means routes only get wired once.
+ * Builds the configured Slim app.
  *
- * Slim middleware is LIFO: the *last* `->add()` runs first. So to get the
- * documented "TokenAuth → Impersonation → Rbac" order, we add them in
- * reverse: Rbac (innermost), then Impersonation, then TokenAuth
- * (outermost — runs first on the way in).
+ * Slim middleware is LIFO. To get "TokenAuth → Impersonation → Rbac" we
+ * add them in reverse on each route group.
+ *
+ * Route groups in M04:
+ *  - Public    /api/v1/report               TokenAuth → RateLimit → controller (kind check inside)
+ *  - Admin     /api/v1/admin/{reporters,consumers,tokens}  TokenAuth → Impersonation → Rbac(Admin)
+ *  - Admin     /api/v1/admin/me             TokenAuth → Impersonation → Rbac(Viewer)
+ *  - Auth      /api/v1/auth/*               TokenAuth (controller checks kind=service)
  */
 final class AppFactory
 {
@@ -60,6 +67,8 @@ final class AppFactory
         $tokenAuth = $container->get(TokenAuthenticationMiddleware::class);
         /** @var ImpersonationMiddleware $impersonation */
         $impersonation = $container->get(ImpersonationMiddleware::class);
+        /** @var RateLimitMiddleware $rateLimit */
+        $rateLimit = $container->get(RateLimitMiddleware::class);
 
         $app->get('/healthz', function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface {
             $response->getBody()->write((string) json_encode(['status' => 'ok']));
@@ -85,12 +94,51 @@ final class AppFactory
             });
         })->add($tokenAuth);
 
+        // Public API: ingest endpoint. Auth → rate limit → controller. The
+        // controller rejects non-reporter kinds itself (uniform 401 per
+        // SPEC).
+        $app->group('/api/v1', function (RouteCollectorProxy $public) use ($container): void {
+            /** @var ReportController $report */
+            $report = $container->get(ReportController::class);
+            $public->post('/report', $report);
+        })
+            ->add($rateLimit)
+            ->add($tokenAuth);
+
         // Admin API: token auth → impersonation → role check.
         $app->group('/api/v1/admin', function (RouteCollectorProxy $admin) use ($container, $rf): void {
             /** @var MeController $me */
             $me = $container->get(MeController::class);
             $admin->get('/me', $me)
                 ->add(RbacMiddleware::require($rf, Role::Viewer));
+
+            /** @var ReportersController $reporters */
+            $reporters = $container->get(ReportersController::class);
+            $admin->group('/reporters', function (RouteCollectorProxy $r) use ($reporters): void {
+                $r->get('', [$reporters, 'list']);
+                $r->post('', [$reporters, 'create']);
+                $r->get('/{id}', [$reporters, 'show']);
+                $r->patch('/{id}', [$reporters, 'update']);
+                $r->delete('/{id}', [$reporters, 'delete']);
+            })->add(RbacMiddleware::require($rf, Role::Admin));
+
+            /** @var ConsumersController $consumers */
+            $consumers = $container->get(ConsumersController::class);
+            $admin->group('/consumers', function (RouteCollectorProxy $r) use ($consumers): void {
+                $r->get('', [$consumers, 'list']);
+                $r->post('', [$consumers, 'create']);
+                $r->get('/{id}', [$consumers, 'show']);
+                $r->patch('/{id}', [$consumers, 'update']);
+                $r->delete('/{id}', [$consumers, 'delete']);
+            })->add(RbacMiddleware::require($rf, Role::Admin));
+
+            /** @var TokensController $tokens */
+            $tokens = $container->get(TokensController::class);
+            $admin->group('/tokens', function (RouteCollectorProxy $r) use ($tokens): void {
+                $r->get('', [$tokens, 'list']);
+                $r->post('', [$tokens, 'create']);
+                $r->delete('/{id}', [$tokens, 'delete']);
+            })->add(RbacMiddleware::require($rf, Role::Admin));
         })
             ->add($impersonation)
             ->add($tokenAuth);

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

@@ -4,19 +4,33 @@ declare(strict_types=1);
 
 namespace App\App;
 
+use App\Application\Admin\ConsumersController;
 use App\Application\Admin\MeController;
+use App\Application\Admin\ReportersController;
+use App\Application\Admin\TokensController;
 use App\Application\Auth\AuthController;
+use App\Application\Public\ReportController;
 use App\Domain\Auth\Role;
 use App\Domain\Auth\TokenHasher;
 use App\Domain\Auth\TokenIssuer;
+use App\Domain\Reputation\PairScorer;
+use App\Domain\Time\Clock;
+use App\Domain\Time\SystemClock;
 use App\Infrastructure\Auth\RoleMappingRepository;
 use App\Infrastructure\Auth\ServiceTokenBootstrap;
 use App\Infrastructure\Auth\TokenRepository;
 use App\Infrastructure\Auth\UserRepository;
+use App\Infrastructure\Category\CategoryRepository;
+use App\Infrastructure\Consumer\ConsumerRepository;
 use App\Infrastructure\Db\ConnectionFactory;
 use App\Infrastructure\Http\JsonErrorHandler;
 use App\Infrastructure\Http\Middleware\ImpersonationMiddleware;
+use App\Infrastructure\Http\Middleware\RateLimitMiddleware;
 use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
+use App\Infrastructure\Http\RateLimiter;
+use App\Infrastructure\Reporter\ReporterRepository;
+use App\Infrastructure\Reputation\IpScoreRepository;
+use App\Infrastructure\Reputation\ReportRepository;
 
 use function DI\autowire;
 
@@ -36,10 +50,10 @@ use Slim\Psr7\Factory\ResponseFactory;
 /**
  * Builds the api's DI container.
  *
- * Adds in M03: token domain helpers, repositories, middlewares, the JSON
- * error handler, and the controllers. Wiring is autowire-friendly except
- * where we need to inject a primitive (the OIDC default Role) or pull a
- * value out of `settings`.
+ * M04 additions: ReporterRepository / ConsumerRepository / CategoryRepository /
+ * ReportRepository / IpScoreRepository, the PairScorer + Clock pair, and the
+ * RateLimiter singleton plus its middleware. The rate-limit settings flow
+ * through `settings` so tests can override capacity/refill cleanly.
  */
 final class Container
 {
@@ -59,6 +73,8 @@ final class Container
             'settings.app_env' => $settings['app_env'] ?? 'production',
             'settings.log_level' => $settings['log_level'] ?? \Monolog\Level::Info,
             'settings.oidc_default_role' => $settings['oidc_default_role'] ?? Role::Viewer,
+            'settings.score_hard_cutoff_days' => (int) ($settings['score_hard_cutoff_days'] ?? 365),
+            'settings.rate_limit_per_second' => (int) ($settings['rate_limit_per_second'] ?? 60),
             ConnectionFactory::class => factory(static function (ContainerInterface $c): ConnectionFactory {
                 /** @var array{driver: string, sqlite_path: string, mysql_host: string, mysql_port: int, mysql_database: string, mysql_username: string, mysql_password: string} $db */
                 $db = $c->get('settings.db');
@@ -82,14 +98,42 @@ final class Container
                 return $logger;
             }),
             ResponseFactoryInterface::class => autowire(ResponseFactory::class),
+            Clock::class => autowire(SystemClock::class),
             TokenHasher::class => autowire(),
             TokenIssuer::class => autowire(),
             TokenRepository::class => autowire(),
             RoleMappingRepository::class => autowire(),
             UserRepository::class => autowire(),
+            ReporterRepository::class => autowire(),
+            ConsumerRepository::class => autowire(),
+            CategoryRepository::class => autowire(),
+            ReportRepository::class => autowire(),
+            IpScoreRepository::class => autowire(),
             ServiceTokenBootstrap::class => autowire(),
             TokenAuthenticationMiddleware::class => autowire(),
             ImpersonationMiddleware::class => autowire(),
+            PairScorer::class => factory(static function (ContainerInterface $c): PairScorer {
+                /** @var ReportRepository $reports */
+                $reports = $c->get(ReportRepository::class);
+                /** @var CategoryRepository $categories */
+                $categories = $c->get(CategoryRepository::class);
+                /** @var Clock $clock */
+                $clock = $c->get(Clock::class);
+                /** @var int $cutoff */
+                $cutoff = $c->get('settings.score_hard_cutoff_days');
+
+                return new PairScorer($reports, $categories, $clock, $cutoff);
+            }),
+            RateLimiter::class => factory(static function (ContainerInterface $c): RateLimiter {
+                /** @var Clock $clock */
+                $clock = $c->get(Clock::class);
+                /** @var int $perSecond */
+                $perSecond = $c->get('settings.rate_limit_per_second');
+                $perSecond = max(1, $perSecond);
+
+                return new RateLimiter($clock, (float) $perSecond, (float) ($perSecond * 2));
+            }),
+            RateLimitMiddleware::class => autowire(),
             JsonErrorHandler::class => factory(static function (ContainerInterface $c): JsonErrorHandler {
                 /** @var ResponseFactoryInterface $factory */
                 $factory = $c->get(ResponseFactoryInterface::class);
@@ -111,6 +155,10 @@ final class Container
                 return new AuthController($users, $role ?? Role::Viewer);
             }),
             MeController::class => autowire(),
+            ReportersController::class => autowire(),
+            ConsumersController::class => autowire(),
+            TokensController::class => autowire(),
+            ReportController::class => autowire(),
         ]);
 
         return $builder->build();

+ 83 - 0
api/src/Application/Admin/AdminControllerSupport.php

@@ -0,0 +1,83 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Domain\Auth\AuthenticatedPrincipal;
+use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Tiny helpers shared by admin controllers — response shaping, JSON body
+ * parsing, validation envelopes, pagination params. Kept as a trait so
+ * controllers stay simple and DI-free.
+ */
+trait AdminControllerSupport
+{
+    /**
+     * @return array<string, mixed>
+     */
+    private static function jsonBody(ServerRequestInterface $request): array
+    {
+        $parsed = $request->getParsedBody();
+        if (is_array($parsed)) {
+            return $parsed;
+        }
+        $raw = (string) $request->getBody();
+        if ($raw === '') {
+            return [];
+        }
+        $decoded = json_decode($raw, true);
+
+        return is_array($decoded) ? $decoded : [];
+    }
+
+    /**
+     * @param array<string, mixed> $payload
+     */
+    private static function json(ResponseInterface $response, int $status, array $payload): ResponseInterface
+    {
+        $response = $response->withStatus($status)->withHeader('Content-Type', 'application/json');
+        $response->getBody()->write((string) json_encode($payload));
+
+        return $response;
+    }
+
+    /**
+     * @param array<string, string> $details
+     */
+    private static function validationFailed(ResponseInterface $response, array $details): ResponseInterface
+    {
+        return self::json($response, 400, [
+            'error' => 'validation_failed',
+            'details' => $details,
+        ]);
+    }
+
+    private static function error(ResponseInterface $response, int $status, string $error): ResponseInterface
+    {
+        return self::json($response, $status, ['error' => $error]);
+    }
+
+    /**
+     * @return array{limit: int, offset: int, page: int}
+     */
+    private static function pagination(ServerRequestInterface $request, int $defaultLimit = 50, int $maxLimit = 200): array
+    {
+        $params = $request->getQueryParams();
+        $page = isset($params['page']) && ctype_digit((string) $params['page']) ? max(1, (int) $params['page']) : 1;
+        $limit = isset($params['limit']) && ctype_digit((string) $params['limit']) ? (int) $params['limit'] : $defaultLimit;
+        $limit = max(1, min($maxLimit, $limit));
+
+        return ['limit' => $limit, 'offset' => ($page - 1) * $limit, 'page' => $page];
+    }
+
+    private static function actingUserId(ServerRequestInterface $request): ?int
+    {
+        $principal = $request->getAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL);
+
+        return $principal instanceof AuthenticatedPrincipal ? $principal->userId : null;
+    }
+}

+ 208 - 0
api/src/Application/Admin/ConsumersController.php

@@ -0,0 +1,208 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Infrastructure\Consumer\ConsumerRepository;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Admin CRUD over consumers. Mirrors ReportersController's shape; the
+ * differences are `policy_id` (FK validated against `policies`) and the
+ * delete-on-active-tokens consideration: the api_tokens FK is CASCADE,
+ * so dropping a consumer revokes its tokens automatically. We still do
+ * a soft delete by default to match reporters.
+ */
+final class ConsumersController
+{
+    use AdminControllerSupport;
+
+    public function __construct(private readonly ConsumerRepository $consumers)
+    {
+    }
+
+    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $page = self::pagination($request);
+        $rows = $this->consumers->list($page['limit'], $page['offset']);
+        $total = $this->consumers->count();
+
+        return self::json($response, 200, [
+            'data' => array_map(static fn ($c) => $c->toArray(), $rows),
+            'page' => $page['page'],
+            'limit' => $page['limit'],
+            'total' => $total,
+        ]);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $consumer = $this->consumers->findById($id);
+        if ($consumer === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        return self::json($response, 200, $consumer->toArray());
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $body = self::jsonBody($request);
+        $errors = [];
+
+        $name = isset($body['name']) && is_string($body['name']) ? trim($body['name']) : '';
+        if ($name === '' || strlen($name) > 128) {
+            $errors['name'] = 'required, 1–128 chars';
+        }
+
+        $description = null;
+        if (array_key_exists('description', $body)) {
+            if ($body['description'] !== null && !is_string($body['description'])) {
+                $errors['description'] = 'must be string or null';
+            } else {
+                $description = $body['description'];
+            }
+        }
+
+        $policyId = null;
+        if (!array_key_exists('policy_id', $body) || !is_int($body['policy_id'])) {
+            // Accept numeric string too — JSON usually has int but be lenient.
+            if (isset($body['policy_id']) && is_string($body['policy_id']) && ctype_digit($body['policy_id'])) {
+                $policyId = (int) $body['policy_id'];
+            } else {
+                $errors['policy_id'] = 'required, integer';
+            }
+        } else {
+            $policyId = $body['policy_id'];
+        }
+
+        if ($policyId !== null && $policyId > 0 && !$this->consumers->policyExists($policyId)) {
+            $errors['policy_id'] = 'unknown policy';
+        }
+
+        if ($errors === [] && $this->consumers->findByName($name) !== null) {
+            $errors['name'] = 'already exists';
+        }
+
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        /** @var int $policyId */
+        $id = $this->consumers->create($name, $description, $policyId, self::actingUserId($request));
+        $created = $this->consumers->findById($id);
+        if ($created === null) {
+            return self::error($response, 500, 'create_failed');
+        }
+
+        return self::json($response, 201, $created->toArray());
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $existing = $this->consumers->findById($id);
+        if ($existing === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $body = self::jsonBody($request);
+        $errors = [];
+        $fields = [];
+
+        if (array_key_exists('name', $body)) {
+            if (!is_string($body['name']) || trim($body['name']) === '' || strlen(trim($body['name'])) > 128) {
+                $errors['name'] = 'required, 1–128 chars';
+            } else {
+                $name = trim($body['name']);
+                $other = $this->consumers->findByName($name);
+                if ($other !== null && $other->id !== $id) {
+                    $errors['name'] = 'already exists';
+                } else {
+                    $fields['name'] = $name;
+                }
+            }
+        }
+        if (array_key_exists('description', $body)) {
+            if ($body['description'] !== null && !is_string($body['description'])) {
+                $errors['description'] = 'must be string or null';
+            } else {
+                $fields['description'] = $body['description'];
+            }
+        }
+        if (array_key_exists('policy_id', $body)) {
+            $newPolicy = null;
+            if (is_int($body['policy_id'])) {
+                $newPolicy = $body['policy_id'];
+            } elseif (is_string($body['policy_id']) && ctype_digit($body['policy_id'])) {
+                $newPolicy = (int) $body['policy_id'];
+            }
+            if ($newPolicy === null || $newPolicy <= 0) {
+                $errors['policy_id'] = 'must be positive integer';
+            } elseif (!$this->consumers->policyExists($newPolicy)) {
+                $errors['policy_id'] = 'unknown policy';
+            } else {
+                $fields['policy_id'] = $newPolicy;
+            }
+        }
+        if (array_key_exists('is_active', $body)) {
+            if (!is_bool($body['is_active'])) {
+                $errors['is_active'] = 'must be boolean';
+            } else {
+                $fields['is_active'] = $body['is_active'] ? 1 : 0;
+            }
+        }
+
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        $this->consumers->update($id, $fields);
+        $updated = $this->consumers->findById($id);
+        if ($updated === null) {
+            return self::error($response, 500, 'update_failed');
+        }
+
+        return self::json($response, 200, $updated->toArray());
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $existing = $this->consumers->findById($id);
+        if ($existing === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $this->consumers->softDelete($id);
+
+        return $response->withStatus(204);
+    }
+
+    private static function parseId(string $raw): ?int
+    {
+        return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
+    }
+}

+ 212 - 0
api/src/Application/Admin/ReportersController.php

@@ -0,0 +1,212 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Infrastructure\Reporter\ReporterRepository;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Admin CRUD over reporters. Validation is hand-rolled — small surface,
+ * no need for a third-party validator. Errors flow through the uniform
+ * `validation_failed` envelope per the M04 task.
+ *
+ * Soft delete (DELETE) flips `is_active=false`. Hard delete is refused
+ * with 409 if reports exist; otherwise the row is removed (a stale
+ * unused reporter is fine to drop).
+ */
+final class ReportersController
+{
+    use AdminControllerSupport;
+
+    public function __construct(private readonly ReporterRepository $reporters)
+    {
+    }
+
+    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $page = self::pagination($request);
+        $rows = $this->reporters->list($page['limit'], $page['offset']);
+        $total = $this->reporters->count();
+
+        return self::json($response, 200, [
+            'data' => array_map(static fn ($r) => $r->toArray(), $rows),
+            'page' => $page['page'],
+            'limit' => $page['limit'],
+            'total' => $total,
+        ]);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function show(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $reporter = $this->reporters->findById($id);
+        if ($reporter === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        return self::json($response, 200, $reporter->toArray());
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $body = self::jsonBody($request);
+        $errors = [];
+
+        $name = isset($body['name']) && is_string($body['name']) ? trim($body['name']) : '';
+        if ($name === '' || strlen($name) > 128) {
+            $errors['name'] = 'required, 1–128 chars';
+        }
+
+        $description = null;
+        if (array_key_exists('description', $body)) {
+            if ($body['description'] !== null && !is_string($body['description'])) {
+                $errors['description'] = 'must be string or null';
+            } else {
+                $description = $body['description'];
+            }
+        }
+
+        $trustWeight = 1.0;
+        if (array_key_exists('trust_weight', $body)) {
+            if (!is_numeric($body['trust_weight'])) {
+                $errors['trust_weight'] = 'must be numeric';
+            } else {
+                $trustWeight = (float) $body['trust_weight'];
+                if ($trustWeight < 0.0 || $trustWeight > 2.0) {
+                    $errors['trust_weight'] = 'must be between 0.0 and 2.0';
+                }
+            }
+        }
+
+        if ($errors === [] && $this->reporters->findByName($name) !== null) {
+            $errors['name'] = 'already exists';
+        }
+
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        $id = $this->reporters->create($name, $description, $trustWeight, self::actingUserId($request));
+        $created = $this->reporters->findById($id);
+        if ($created === null) {
+            return self::error($response, 500, 'create_failed');
+        }
+
+        return self::json($response, 201, $created->toArray());
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function update(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $existing = $this->reporters->findById($id);
+        if ($existing === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        $body = self::jsonBody($request);
+        $errors = [];
+        $fields = [];
+
+        if (array_key_exists('name', $body)) {
+            if (!is_string($body['name']) || trim($body['name']) === '' || strlen(trim($body['name'])) > 128) {
+                $errors['name'] = 'required, 1–128 chars';
+            } else {
+                $name = trim($body['name']);
+                $other = $this->reporters->findByName($name);
+                if ($other !== null && $other->id !== $id) {
+                    $errors['name'] = 'already exists';
+                } else {
+                    $fields['name'] = $name;
+                }
+            }
+        }
+        if (array_key_exists('description', $body)) {
+            if ($body['description'] !== null && !is_string($body['description'])) {
+                $errors['description'] = 'must be string or null';
+            } else {
+                $fields['description'] = $body['description'];
+            }
+        }
+        if (array_key_exists('trust_weight', $body)) {
+            if (!is_numeric($body['trust_weight'])) {
+                $errors['trust_weight'] = 'must be numeric';
+            } else {
+                $weight = (float) $body['trust_weight'];
+                if ($weight < 0.0 || $weight > 2.0) {
+                    $errors['trust_weight'] = 'must be between 0.0 and 2.0';
+                } else {
+                    $fields['trust_weight'] = number_format($weight, 2, '.', '');
+                }
+            }
+        }
+        if (array_key_exists('is_active', $body)) {
+            if (!is_bool($body['is_active'])) {
+                $errors['is_active'] = 'must be boolean';
+            } else {
+                $fields['is_active'] = $body['is_active'] ? 1 : 0;
+            }
+        }
+
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        $this->reporters->update($id, $fields);
+        $updated = $this->reporters->findById($id);
+        if ($updated === null) {
+            return self::error($response, 500, 'update_failed');
+        }
+
+        return self::json($response, 200, $updated->toArray());
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $existing = $this->reporters->findById($id);
+        if ($existing === null) {
+            return self::error($response, 404, 'not_found');
+        }
+
+        if ($this->reporters->reportCount($id) > 0) {
+            // SPEC: refuse hard delete when reports exist; flip to inactive.
+            $this->reporters->softDelete($id);
+
+            return self::json($response, 409, [
+                'error' => 'has_reports',
+                'message' => 'Reporter has historical reports; flagged inactive instead of deleted.',
+            ]);
+        }
+
+        $this->reporters->softDelete($id);
+
+        return $response->withStatus(204);
+    }
+
+    private static function parseId(string $raw): ?int
+    {
+        return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
+    }
+}

+ 240 - 0
api/src/Application/Admin/TokensController.php

@@ -0,0 +1,240 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenHasher;
+use App\Domain\Auth\TokenIssuer;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Time\Clock;
+use App\Infrastructure\Auth\TokenRecord;
+use App\Infrastructure\Auth\TokenRepository;
+use App\Infrastructure\Consumer\ConsumerRepository;
+use App\Infrastructure\Reporter\ReporterRepository;
+use DateTimeImmutable;
+use DateTimeZone;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * Admin token CRUD. Three creatable kinds — `reporter`, `consumer`,
+ * `admin` — each with its own constraint:
+ *
+ *   reporter → reporter_id required, no role / consumer_id
+ *   consumer → consumer_id required, no role / reporter_id
+ *   admin    → role required, no FKs
+ *
+ * `service` is rejected with 400; service tokens come from the bootstrap
+ * path only. The list endpoint filters service-kind out unconditionally.
+ *
+ * The raw token string appears **only** in the create response — we
+ * persist its SHA-256 hash and forget the rest.
+ */
+final class TokensController
+{
+    use AdminControllerSupport;
+
+    public function __construct(
+        private readonly TokenRepository $tokens,
+        private readonly TokenIssuer $issuer,
+        private readonly TokenHasher $hasher,
+        private readonly ReporterRepository $reporters,
+        private readonly ConsumerRepository $consumers,
+        private readonly Clock $clock,
+    ) {
+    }
+
+    public function list(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $page = self::pagination($request);
+        $rows = $this->tokens->listNonService($page['limit'], $page['offset']);
+        $total = $this->tokens->countNonService();
+
+        $data = array_map(static function (TokenRecord $r): array {
+            return [
+                'id' => $r->id,
+                'kind' => $r->kind->value,
+                'prefix' => $r->prefix,
+                'reporter_id' => $r->reporterId,
+                'consumer_id' => $r->consumerId,
+                'role' => $r->role?->value,
+                'expires_at' => $r->expiresAt?->format('Y-m-d\TH:i:s\Z'),
+                'revoked_at' => $r->revokedAt?->format('Y-m-d\TH:i:s\Z'),
+                'last_used_at' => $r->lastUsedAt?->format('Y-m-d\TH:i:s\Z'),
+            ];
+        }, $rows);
+
+        return self::json($response, 200, [
+            'data' => $data,
+            'page' => $page['page'],
+            'limit' => $page['limit'],
+            'total' => $total,
+        ]);
+    }
+
+    public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $body = self::jsonBody($request);
+        $errors = [];
+
+        $kindValue = isset($body['kind']) && is_string($body['kind']) ? $body['kind'] : '';
+        $kind = TokenKind::tryFrom($kindValue);
+        if ($kind === null) {
+            return self::validationFailed($response, ['kind' => 'must be reporter, consumer, or admin']);
+        }
+        if ($kind === TokenKind::Service) {
+            return self::error($response, 400, 'service tokens cannot be created via API');
+        }
+
+        $reporterId = self::optInt($body['reporter_id'] ?? null);
+        $consumerId = self::optInt($body['consumer_id'] ?? null);
+        $roleValue = isset($body['role']) && is_string($body['role']) ? $body['role'] : null;
+        $role = $roleValue !== null ? Role::tryFrom($roleValue) : null;
+
+        $expiresAt = null;
+        if (array_key_exists('expires_at', $body) && $body['expires_at'] !== null) {
+            if (!is_string($body['expires_at'])) {
+                $errors['expires_at'] = 'must be ISO 8601 string';
+            } else {
+                $parsed = self::parseUtc($body['expires_at']);
+                if ($parsed === null) {
+                    $errors['expires_at'] = 'must be ISO 8601 string';
+                } elseif ($parsed <= $this->clock->now()) {
+                    $errors['expires_at'] = 'must be in the future';
+                } else {
+                    $expiresAt = $parsed;
+                }
+            }
+        }
+
+        switch ($kind) {
+            case TokenKind::Reporter:
+                if ($reporterId === null) {
+                    $errors['reporter_id'] = 'required for reporter tokens';
+                } elseif ($this->reporters->findById($reporterId) === null) {
+                    $errors['reporter_id'] = 'unknown reporter';
+                }
+                if ($consumerId !== null) {
+                    $errors['consumer_id'] = 'must be null for reporter tokens';
+                }
+                if ($roleValue !== null) {
+                    $errors['role'] = 'must be null for reporter tokens';
+                }
+                break;
+            case TokenKind::Consumer:
+                if ($consumerId === null) {
+                    $errors['consumer_id'] = 'required for consumer tokens';
+                } elseif ($this->consumers->findById($consumerId) === null) {
+                    $errors['consumer_id'] = 'unknown consumer';
+                }
+                if ($reporterId !== null) {
+                    $errors['reporter_id'] = 'must be null for consumer tokens';
+                }
+                if ($roleValue !== null) {
+                    $errors['role'] = 'must be null for consumer tokens';
+                }
+                break;
+            case TokenKind::Admin:
+                if ($role === null) {
+                    $errors['role'] = 'required for admin tokens (viewer|operator|admin)';
+                }
+                if ($reporterId !== null) {
+                    $errors['reporter_id'] = 'must be null for admin tokens';
+                }
+                if ($consumerId !== null) {
+                    $errors['consumer_id'] = 'must be null for admin tokens';
+                }
+                break;
+            default:
+                // Unreachable; service was already rejected.
+                break;
+        }
+
+        if ($errors !== []) {
+            return self::validationFailed($response, $errors);
+        }
+
+        $raw = $this->issuer->issue($kind);
+        $hash = $this->hasher->hash($raw);
+        $prefix = substr($raw, 0, 8);
+
+        $this->tokens->create(new TokenRecord(
+            id: null,
+            kind: $kind,
+            hash: $hash,
+            prefix: $prefix,
+            reporterId: $kind === TokenKind::Reporter ? $reporterId : null,
+            consumerId: $kind === TokenKind::Consumer ? $consumerId : null,
+            role: $kind === TokenKind::Admin ? $role : null,
+            expiresAt: $expiresAt,
+            revokedAt: null,
+            lastUsedAt: null,
+        ));
+
+        $created = $this->tokens->findByHashIncludingInvalid($hash);
+        if ($created === null) {
+            return self::error($response, 500, 'create_failed');
+        }
+
+        return self::json($response, 201, [
+            'id' => $created->id,
+            'kind' => $created->kind->value,
+            'prefix' => $created->prefix,
+            'reporter_id' => $created->reporterId,
+            'consumer_id' => $created->consumerId,
+            'role' => $created->role?->value,
+            'expires_at' => $created->expiresAt?->format('Y-m-d\TH:i:s\Z'),
+            'raw_token' => $raw,
+        ]);
+    }
+
+    /**
+     * @param array{id: string} $args
+     */
+    public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
+    {
+        $id = self::parseId($args['id']);
+        if ($id === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        $token = $this->tokens->findById($id);
+        if ($token === null) {
+            return self::error($response, 404, 'not_found');
+        }
+        if ($token->kind === TokenKind::Service) {
+            return self::error($response, 403, 'cannot revoke service tokens via API');
+        }
+
+        $this->tokens->revoke($id, $this->clock->now());
+
+        return $response->withStatus(204);
+    }
+
+    private static function optInt(mixed $value): ?int
+    {
+        if (is_int($value) && $value > 0) {
+            return $value;
+        }
+        if (is_string($value) && ctype_digit($value) && $value !== '0') {
+            return (int) $value;
+        }
+
+        return null;
+    }
+
+    private static function parseUtc(string $iso): ?DateTimeImmutable
+    {
+        try {
+            return new DateTimeImmutable($iso, new DateTimeZone('UTC'));
+        } catch (\Exception) {
+            return null;
+        }
+    }
+
+    private static function parseId(string $raw): ?int
+    {
+        return preg_match('/^[1-9][0-9]*$/', $raw) === 1 ? (int) $raw : null;
+    }
+}

+ 180 - 0
api/src/Application/Public/ReportController.php

@@ -0,0 +1,180 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Application\Public;
+
+use App\Domain\Auth\AuthenticatedPrincipal;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Ip\InvalidIpException;
+use App\Domain\Ip\IpAddress;
+use App\Domain\Reputation\PairScorer;
+use App\Domain\Time\Clock;
+use App\Infrastructure\Category\CategoryRepository;
+use App\Infrastructure\Http\Middleware\TokenAuthenticationMiddleware;
+use App\Infrastructure\Reporter\ReporterRepository;
+use App\Infrastructure\Reputation\IpScoreRepository;
+use App\Infrastructure\Reputation\ReportRepository;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+
+/**
+ * `POST /api/v1/report` — the only public ingest path.
+ *
+ * Order of operations:
+ *  1. Reject any non-reporter token kind with 401 (per the M03 convention
+ *     that wrong-kind == generic unauthorized).
+ *  2. Validate the body — IP, category slug, optional metadata (≤4 KB).
+ *  3. Look up the reporter via the principal's `reporterId` and refuse if
+ *     it's been deactivated.
+ *  4. Insert the report (append-only) and synchronously upsert
+ *     `ip_scores` for the touched (ip, category) pair via PairScorer.
+ *  5. Return 202 with the report id and received_at.
+ *
+ * The synchronous upsert is correct for this single pair only; ageing of
+ * untouched scores is M05's recompute job.
+ */
+final class ReportController
+{
+    private const METADATA_MAX_BYTES = 4096;
+
+    public function __construct(
+        private readonly ReporterRepository $reporters,
+        private readonly CategoryRepository $categories,
+        private readonly ReportRepository $reports,
+        private readonly IpScoreRepository $ipScores,
+        private readonly PairScorer $scorer,
+        private readonly Clock $clock,
+    ) {
+    }
+
+    public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
+    {
+        $principal = $request->getAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL);
+        if (!$principal instanceof AuthenticatedPrincipal || $principal->tokenKind !== TokenKind::Reporter) {
+            return self::error($response, 401, 'unauthorized');
+        }
+        if ($principal->reporterId === null) {
+            return self::error($response, 401, 'unauthorized');
+        }
+
+        $reporter = $this->reporters->findById($principal->reporterId);
+        if ($reporter === null || !$reporter->isActive) {
+            return self::error($response, 401, 'unauthorized');
+        }
+
+        $body = self::jsonBody($request);
+        $errors = [];
+
+        $ipRaw = isset($body['ip']) && is_string($body['ip']) ? trim($body['ip']) : '';
+        $ip = null;
+        if ($ipRaw === '') {
+            $errors['ip'] = 'required';
+        } else {
+            try {
+                $ip = IpAddress::fromString($ipRaw);
+            } catch (InvalidIpException) {
+                $errors['ip'] = 'invalid';
+            }
+        }
+
+        $categorySlug = isset($body['category']) && is_string($body['category']) ? trim($body['category']) : '';
+        $category = null;
+        if ($categorySlug === '') {
+            $errors['category'] = 'required';
+        } else {
+            $category = $this->categories->findActiveBySlug($categorySlug);
+            if ($category === null) {
+                $errors['category'] = 'unknown or inactive';
+            }
+        }
+
+        $metadataJson = null;
+        if (array_key_exists('metadata', $body) && $body['metadata'] !== null) {
+            if (!is_array($body['metadata']) || array_is_list($body['metadata'])) {
+                $errors['metadata'] = 'must be a JSON object';
+            } else {
+                $encoded = json_encode($body['metadata']);
+                if ($encoded === false) {
+                    $errors['metadata'] = 'not encodable as JSON';
+                } elseif (strlen($encoded) > self::METADATA_MAX_BYTES) {
+                    $errors['metadata'] = sprintf('exceeds %d bytes', self::METADATA_MAX_BYTES);
+                } else {
+                    $metadataJson = $encoded;
+                }
+            }
+        }
+
+        if ($errors !== []) {
+            return self::json($response, 400, [
+                'error' => 'validation_failed',
+                'details' => $errors,
+            ]);
+        }
+
+        /** @var IpAddress $ip */
+        /** @var \App\Domain\Category\Category $category */
+        $now = $this->clock->now();
+        $reportId = $this->reports->insert(
+            ipBin: $ip->binary(),
+            ipText: $ip->text(),
+            categoryId: $category->id,
+            reporterId: $reporter->id,
+            weightAtReport: $reporter->trustWeight,
+            metadataJson: $metadataJson,
+            receivedAt: $now,
+        );
+
+        $score = $this->scorer->score($ip->binary(), $category->id, $now);
+        $count30d = $this->reports->recentCount($ip->binary(), $category->id, $now->modify('-30 days'));
+        $this->ipScores->upsert(
+            ipBin: $ip->binary(),
+            ipText: $ip->text(),
+            categoryId: $category->id,
+            score: $score,
+            reportCount30d: $count30d,
+            lastReportAt: $now,
+            recomputedAt: $now,
+        );
+
+        return self::json($response, 202, [
+            'report_id' => $reportId,
+            'ip' => $ip->text(),
+            'received_at' => $now->format('Y-m-d\TH:i:s\Z'),
+        ]);
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    private static function jsonBody(ServerRequestInterface $request): array
+    {
+        $parsed = $request->getParsedBody();
+        if (is_array($parsed)) {
+            return $parsed;
+        }
+        $raw = (string) $request->getBody();
+        if ($raw === '') {
+            return [];
+        }
+        $decoded = json_decode($raw, true);
+
+        return is_array($decoded) ? $decoded : [];
+    }
+
+    /**
+     * @param array<string, mixed> $payload
+     */
+    private static function json(ResponseInterface $response, int $status, array $payload): ResponseInterface
+    {
+        $response = $response->withStatus($status)->withHeader('Content-Type', 'application/json');
+        $response->getBody()->write((string) json_encode($payload));
+
+        return $response;
+    }
+
+    private static function error(ResponseInterface $response, int $status, string $message): ResponseInterface
+    {
+        return self::json($response, $status, ['error' => $message]);
+    }
+}

+ 25 - 0
api/src/Domain/Category/Category.php

@@ -0,0 +1,25 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Category;
+
+use App\Domain\Reputation\DecayFunction;
+
+/**
+ * Abuse category. Owns the decay function and parameter that scoring uses
+ * to age reports in this category — see `Decay::value()`.
+ */
+final class Category
+{
+    public function __construct(
+        public readonly int $id,
+        public readonly string $slug,
+        public readonly string $name,
+        public readonly ?string $description,
+        public readonly DecayFunction $decayFunction,
+        public readonly float $decayParam,
+        public readonly bool $isActive,
+    ) {
+    }
+}

+ 45 - 0
api/src/Domain/Consumer/Consumer.php

@@ -0,0 +1,45 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Consumer;
+
+use DateTimeImmutable;
+
+/**
+ * A distribution consumer (firewall/proxy). Bound to a policy that
+ * shapes the blocklist they pull. `lastPulledAt` is touched by the
+ * blocklist endpoint (M07) — we expose the column now so admin
+ * responses can include it.
+ */
+final class Consumer
+{
+    public function __construct(
+        public readonly int $id,
+        public readonly string $name,
+        public readonly ?string $description,
+        public readonly int $policyId,
+        public readonly bool $isActive,
+        public readonly ?int $createdByUserId,
+        public readonly DateTimeImmutable $createdAt,
+        public readonly ?DateTimeImmutable $lastPulledAt,
+    ) {
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function toArray(): array
+    {
+        return [
+            'id' => $this->id,
+            'name' => $this->name,
+            'description' => $this->description,
+            'policy_id' => $this->policyId,
+            'is_active' => $this->isActive,
+            'created_by_user_id' => $this->createdByUserId,
+            'created_at' => $this->createdAt->format('Y-m-d\TH:i:s\Z'),
+            'last_pulled_at' => $this->lastPulledAt?->format('Y-m-d\TH:i:s\Z'),
+        ];
+    }
+}

+ 42 - 0
api/src/Domain/Reporter/Reporter.php

@@ -0,0 +1,42 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Reporter;
+
+use DateTimeImmutable;
+
+/**
+ * One ingest source. `trustWeight` (0.0–2.0) snapshots into each report
+ * via `weight_at_report` so historical scoring isn't disturbed by later
+ * trust changes.
+ */
+final class Reporter
+{
+    public function __construct(
+        public readonly int $id,
+        public readonly string $name,
+        public readonly ?string $description,
+        public readonly float $trustWeight,
+        public readonly bool $isActive,
+        public readonly ?int $createdByUserId,
+        public readonly DateTimeImmutable $createdAt,
+    ) {
+    }
+
+    /**
+     * @return array<string, mixed>
+     */
+    public function toArray(): array
+    {
+        return [
+            'id' => $this->id,
+            'name' => $this->name,
+            'description' => $this->description,
+            'trust_weight' => $this->trustWeight,
+            'is_active' => $this->isActive,
+            'created_by_user_id' => $this->createdByUserId,
+            'created_at' => $this->createdAt->format('Y-m-d\TH:i:s\Z'),
+        ];
+    }
+}

+ 33 - 0
api/src/Domain/Reputation/Decay.php

@@ -0,0 +1,33 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Reputation;
+
+/**
+ * Pure decay math per SPEC §5. Stateless — easy to unit-test against
+ * hand-computed values.
+ *
+ *  - Linear     : max(0, 1 − age_days / decay_param)
+ *  - Exponential: 0.5 ^ (age_days / decay_param)        (half-life)
+ *
+ * `age_days` may be fractional. Negative ages (future-dated reports —
+ * shouldn't happen, but guard anyway) clamp to zero so a fresh report
+ * always counts at full weight.
+ */
+final class Decay
+{
+    public static function value(DecayFunction $function, float $ageDays, float $decayParam): float
+    {
+        if ($decayParam <= 0.0) {
+            return 0.0;
+        }
+
+        $age = max(0.0, $ageDays);
+
+        return match ($function) {
+            DecayFunction::Linear => max(0.0, 1.0 - ($age / $decayParam)),
+            DecayFunction::Exponential => 0.5 ** ($age / $decayParam),
+        };
+    }
+}

+ 16 - 0
api/src/Domain/Reputation/DecayFunction.php

@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Reputation;
+
+/**
+ * Per-category decay shape. The `decay_param` semantics depend on which:
+ *  - Linear: days-to-zero (default 30).
+ *  - Exponential: half-life in days (default 14).
+ */
+enum DecayFunction: string
+{
+    case Linear = 'linear';
+    case Exponential = 'exponential';
+}

+ 54 - 0
api/src/Domain/Reputation/PairScorer.php

@@ -0,0 +1,54 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Reputation;
+
+use App\Domain\Time\Clock;
+use App\Infrastructure\Category\CategoryRepository;
+use App\Infrastructure\Reputation\ReportRepository;
+use DateTimeImmutable;
+use RuntimeException;
+
+/**
+ * Computes the score for one (ip, category) pair by summing weight × decay
+ * across reports inside the hard cutoff window.
+ *
+ * SPEC §5: `score(X, C) = Σ weight × decay(now − received_at, C)`. The
+ * cutoff is `SCORE_REPORT_HARD_CUTOFF_DAYS` (default 365) — older reports
+ * are skipped for performance.
+ *
+ * The bulk recompute job in M05 will reuse this class so the synchronous
+ * and batch paths can't drift.
+ */
+final class PairScorer
+{
+    public function __construct(
+        private readonly ReportRepository $reports,
+        private readonly CategoryRepository $categories,
+        private readonly Clock $clock,
+        private readonly int $hardCutoffDays = 365,
+    ) {
+    }
+
+    public function score(string $ipBin, int $categoryId, ?DateTimeImmutable $now = null): float
+    {
+        $category = $this->categories->findById($categoryId);
+        if ($category === null) {
+            throw new RuntimeException(sprintf('Unknown category id %d', $categoryId));
+        }
+
+        $now ??= $this->clock->now();
+        $cutoff = $now->modify(sprintf('-%d days', $this->hardCutoffDays));
+
+        $rows = $this->reports->forScoring($ipBin, $categoryId, $cutoff);
+
+        $score = 0.0;
+        foreach ($rows as $row) {
+            $ageDays = ($now->getTimestamp() - $row['received_at']->getTimestamp()) / 86400.0;
+            $score += $row['weight'] * Decay::value($category->decayFunction, $ageDays, $category->decayParam);
+        }
+
+        return $score;
+    }
+}

+ 17 - 0
api/src/Domain/Time/Clock.php

@@ -0,0 +1,17 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Time;
+
+use DateTimeImmutable;
+
+/**
+ * Injected wall clock. Tests substitute a fixed-time implementation so
+ * `received_at`, decay age, and rate-limit refill calculations are
+ * reproducible. Production wires `SystemClock`.
+ */
+interface Clock
+{
+    public function now(): DateTimeImmutable;
+}

+ 36 - 0
api/src/Domain/Time/FixedClock.php

@@ -0,0 +1,36 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Time;
+
+use DateTimeImmutable;
+use DateTimeZone;
+
+/**
+ * Test-friendly clock. Construct with a fixed UTC datetime; `advance()`
+ * rewinds it by a `DateInterval`-style spec. Tests use this to age
+ * reports past decay or step through rate-limit refill windows.
+ */
+final class FixedClock implements Clock
+{
+    public function __construct(private DateTimeImmutable $now)
+    {
+        $this->now = $now->setTimezone(new DateTimeZone('UTC'));
+    }
+
+    public static function at(string $iso): self
+    {
+        return new self(new DateTimeImmutable($iso, new DateTimeZone('UTC')));
+    }
+
+    public function now(): DateTimeImmutable
+    {
+        return $this->now;
+    }
+
+    public function advance(string $modifier): void
+    {
+        $this->now = $this->now->modify($modifier);
+    }
+}

+ 16 - 0
api/src/Domain/Time/SystemClock.php

@@ -0,0 +1,16 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Domain\Time;
+
+use DateTimeImmutable;
+use DateTimeZone;
+
+final class SystemClock implements Clock
+{
+    public function now(): DateTimeImmutable
+    {
+        return new DateTimeImmutable('now', new DateTimeZone('UTC'));
+    }
+}

+ 51 - 0
api/src/Infrastructure/Auth/TokenRepository.php

@@ -91,6 +91,57 @@ final class TokenRepository
         );
     }
 
+    public function findById(int $id): ?TokenRecord
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative(
+            'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, '
+            . 'expires_at, revoked_at, last_used_at FROM api_tokens WHERE id = :id',
+            ['id' => $id]
+        );
+
+        return $row === false ? null : $this->hydrate($row);
+    }
+
+    /**
+     * Lists tokens for the admin UI. `service`-kind rows are filtered out
+     * unconditionally — they are never an operator's concern.
+     *
+     * @return list<TokenRecord>
+     */
+    public function listNonService(int $limit, int $offset): array
+    {
+        $sql = 'SELECT id, kind, token_hash, token_prefix, reporter_id, consumer_id, role, '
+            . 'expires_at, revoked_at, last_used_at FROM api_tokens '
+            . 'WHERE kind != :svc ORDER BY id DESC LIMIT :limit OFFSET :offset';
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection->fetchAllAssociative($sql, [
+            'svc' => TokenKind::Service->value,
+            'limit' => $limit,
+            'offset' => $offset,
+        ]);
+
+        return array_map(fn (array $r): TokenRecord => $this->hydrate($r), $rows);
+    }
+
+    public function countNonService(): int
+    {
+        return (int) $this->connection->fetchOne(
+            'SELECT COUNT(*) FROM api_tokens WHERE kind != :svc',
+            ['svc' => TokenKind::Service->value]
+        );
+    }
+
+    public function revoke(int $id, DateTimeImmutable $when): void
+    {
+        $this->connection->update(
+            'api_tokens',
+            ['revoked_at' => $when->format('Y-m-d H:i:s')],
+            ['id' => $id]
+        );
+    }
+
     /**
      * Used by ServiceTokenBootstrap to detect rotation scenarios — i.e. an
      * existing service-kind row whose hash differs from the one we are

+ 64 - 0
api/src/Infrastructure/Category/CategoryRepository.php

@@ -0,0 +1,64 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Category;
+
+use App\Domain\Category\Category;
+use App\Domain\Reputation\DecayFunction;
+use Doctrine\DBAL\Connection;
+
+/**
+ * Read-mostly gateway for the categories table.
+ *
+ * Ingest looks up by `slug`; the PairScorer needs the decay function and
+ * parameter while computing a score. CRUD on categories is M07's job.
+ */
+final class CategoryRepository
+{
+    public function __construct(private readonly Connection $connection)
+    {
+    }
+
+    public function findActiveBySlug(string $slug): ?Category
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative(
+            'SELECT id, slug, name, description, decay_function, decay_param, is_active '
+            . 'FROM categories WHERE slug = :slug AND is_active = :active',
+            ['slug' => $slug, 'active' => 1]
+        );
+
+        return $row === false ? null : self::hydrate($row);
+    }
+
+    public function findById(int $id): ?Category
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative(
+            'SELECT id, slug, name, description, decay_function, decay_param, is_active '
+            . 'FROM categories WHERE id = :id',
+            ['id' => $id]
+        );
+
+        return $row === false ? null : self::hydrate($row);
+    }
+
+    /**
+     * @param array<string, mixed> $row
+     */
+    private static function hydrate(array $row): Category
+    {
+        $function = DecayFunction::tryFrom((string) $row['decay_function']) ?? DecayFunction::Exponential;
+
+        return new Category(
+            id: (int) $row['id'],
+            slug: (string) $row['slug'],
+            name: (string) $row['name'],
+            description: $row['description'] !== null ? (string) $row['description'] : null,
+            decayFunction: $function,
+            decayParam: (float) $row['decay_param'],
+            isActive: (bool) $row['is_active'],
+        );
+    }
+}

+ 129 - 0
api/src/Infrastructure/Consumer/ConsumerRepository.php

@@ -0,0 +1,129 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Consumer;
+
+use App\Domain\Consumer\Consumer;
+use DateTimeImmutable;
+use DateTimeZone;
+use Doctrine\DBAL\Connection;
+
+/**
+ * DBAL gateway for the consumers table.
+ *
+ * `policy_id` is RESTRICT-on-delete, so the FK protects the consumer's
+ * referential integrity. We do not validate the policy exists here —
+ * the controller looks it up first and returns 400 if missing.
+ */
+final class ConsumerRepository
+{
+    public function __construct(private readonly Connection $connection)
+    {
+    }
+
+    public function findById(int $id): ?Consumer
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative(
+            'SELECT id, name, description, policy_id, is_active, created_by_user_id, created_at, last_pulled_at '
+            . 'FROM consumers WHERE id = :id',
+            ['id' => $id]
+        );
+
+        return $row === false ? null : self::hydrate($row);
+    }
+
+    public function findByName(string $name): ?Consumer
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative(
+            'SELECT id, name, description, policy_id, is_active, created_by_user_id, created_at, last_pulled_at '
+            . 'FROM consumers WHERE name = :name',
+            ['name' => $name]
+        );
+
+        return $row === false ? null : self::hydrate($row);
+    }
+
+    /**
+     * @return list<Consumer>
+     */
+    public function list(int $limit, int $offset): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection->fetchAllAssociative(
+            'SELECT id, name, description, policy_id, is_active, created_by_user_id, created_at, last_pulled_at '
+            . 'FROM consumers ORDER BY id DESC LIMIT :limit OFFSET :offset',
+            ['limit' => $limit, 'offset' => $offset]
+        );
+
+        return array_map(static fn (array $r): Consumer => self::hydrate($r), $rows);
+    }
+
+    public function count(): int
+    {
+        return (int) $this->connection->fetchOne('SELECT COUNT(*) FROM consumers');
+    }
+
+    public function create(string $name, ?string $description, int $policyId, ?int $createdByUserId): int
+    {
+        $this->connection->insert('consumers', [
+            'name' => $name,
+            'description' => $description,
+            'policy_id' => $policyId,
+            'is_active' => 1,
+            'created_by_user_id' => $createdByUserId,
+        ]);
+
+        return (int) $this->connection->lastInsertId();
+    }
+
+    /**
+     * @param array<string, mixed> $fields
+     */
+    public function update(int $id, array $fields): void
+    {
+        if ($fields === []) {
+            return;
+        }
+        $this->connection->update('consumers', $fields, ['id' => $id]);
+    }
+
+    public function softDelete(int $id): void
+    {
+        $this->connection->update('consumers', ['is_active' => 0], ['id' => $id]);
+    }
+
+    public function policyExists(int $policyId): bool
+    {
+        return (int) $this->connection->fetchOne(
+            'SELECT COUNT(*) FROM policies WHERE id = :id',
+            ['id' => $policyId]
+        ) > 0;
+    }
+
+    /**
+     * @param array<string, mixed> $row
+     */
+    private static function hydrate(array $row): Consumer
+    {
+        $createdAt = isset($row['created_at']) && $row['created_at'] !== null
+            ? new DateTimeImmutable((string) $row['created_at'], new DateTimeZone('UTC'))
+            : new DateTimeImmutable('now', new DateTimeZone('UTC'));
+        $lastPulled = isset($row['last_pulled_at']) && $row['last_pulled_at'] !== null
+            ? new DateTimeImmutable((string) $row['last_pulled_at'], new DateTimeZone('UTC'))
+            : null;
+
+        return new Consumer(
+            id: (int) $row['id'],
+            name: (string) $row['name'],
+            description: $row['description'] !== null ? (string) $row['description'] : null,
+            policyId: (int) $row['policy_id'],
+            isActive: (bool) $row['is_active'],
+            createdByUserId: $row['created_by_user_id'] !== null ? (int) $row['created_by_user_id'] : null,
+            createdAt: $createdAt,
+            lastPulledAt: $lastPulled,
+        );
+    }
+}

+ 49 - 0
api/src/Infrastructure/Http/Middleware/RateLimitMiddleware.php

@@ -0,0 +1,49 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Http\Middleware;
+
+use App\Domain\Auth\AuthenticatedPrincipal;
+use App\Infrastructure\Http\RateLimiter;
+use Psr\Http\Message\ResponseFactoryInterface;
+use Psr\Http\Message\ResponseInterface;
+use Psr\Http\Message\ServerRequestInterface;
+use Psr\Http\Server\MiddlewareInterface;
+use Psr\Http\Server\RequestHandlerInterface;
+
+/**
+ * Per-token-id rate limit. Runs after TokenAuthenticationMiddleware so we
+ * have a `tokenId` to bucket on. If the principal is somehow missing
+ * (defensive), the limiter is bypassed — auth/RBAC will reject the
+ * request shortly anyway.
+ *
+ * On exhaustion: 429 with `Retry-After: 1` per SPEC §6.
+ */
+final class RateLimitMiddleware implements MiddlewareInterface
+{
+    public function __construct(
+        private readonly RateLimiter $limiter,
+        private readonly ResponseFactoryInterface $responseFactory,
+    ) {
+    }
+
+    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
+    {
+        $principal = $request->getAttribute(TokenAuthenticationMiddleware::ATTR_PRINCIPAL);
+        if (!$principal instanceof AuthenticatedPrincipal) {
+            return $handler->handle($request);
+        }
+
+        if (!$this->limiter->tryConsume($principal->tokenId)) {
+            $response = $this->responseFactory->createResponse(429)
+                ->withHeader('Content-Type', 'application/json')
+                ->withHeader('Retry-After', '1');
+            $response->getBody()->write((string) json_encode(['error' => 'rate_limited']));
+
+            return $response;
+        }
+
+        return $handler->handle($request);
+    }
+}

+ 56 - 0
api/src/Infrastructure/Http/RateLimiter.php

@@ -0,0 +1,56 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Http;
+
+use App\Domain\Time\Clock;
+
+/**
+ * In-process token-bucket rate limiter, keyed by token id.
+ *
+ * Capacity = `API_RATE_LIMIT_PER_SECOND × 2`, refill rate = same per second.
+ * Refill is computed lazily on each `tryConsume()` call, so an idle bucket
+ * jumps from empty to full once `2/refill` seconds elapse.
+ *
+ * Holds state in a PHP array on a singleton instance — fine for a single
+ * api replica. PROGRESS.md flags multi-replica deployments as needing a
+ * shared store.
+ */
+final class RateLimiter
+{
+    /** @var array<int, array{tokens: float, updated: float}> */
+    private array $buckets = [];
+
+    public function __construct(
+        private readonly Clock $clock,
+        private readonly float $refillPerSecond,
+        private readonly float $capacity,
+    ) {
+    }
+
+    public function tryConsume(int $key): bool
+    {
+        $now = (float) $this->clock->now()->getTimestamp() + ((float) $this->clock->now()->format('u') / 1_000_000.0);
+
+        $state = $this->buckets[$key] ?? ['tokens' => $this->capacity, 'updated' => $now];
+        $elapsed = max(0.0, $now - $state['updated']);
+        $tokens = min($this->capacity, $state['tokens'] + ($elapsed * $this->refillPerSecond));
+
+        if ($tokens < 1.0) {
+            $this->buckets[$key] = ['tokens' => $tokens, 'updated' => $now];
+
+            return false;
+        }
+
+        $this->buckets[$key] = ['tokens' => $tokens - 1.0, 'updated' => $now];
+
+        return true;
+    }
+
+    /** Test hook: drop all bucket state. */
+    public function reset(): void
+    {
+        $this->buckets = [];
+    }
+}

+ 125 - 0
api/src/Infrastructure/Reporter/ReporterRepository.php

@@ -0,0 +1,125 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Reporter;
+
+use App\Domain\Reporter\Reporter;
+use DateTimeImmutable;
+use DateTimeZone;
+use Doctrine\DBAL\Connection;
+
+/**
+ * DBAL gateway for the reporters table.
+ *
+ * `softDelete()` flips `is_active=false`; the SPEC's "hard delete refused
+ * if reports exist (409)" rule lives in the controller — the repo just
+ * exposes the count so the controller can decide.
+ */
+final class ReporterRepository
+{
+    public function __construct(private readonly Connection $connection)
+    {
+    }
+
+    public function findById(int $id): ?Reporter
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative(
+            'SELECT id, name, description, trust_weight, is_active, created_by_user_id, created_at '
+            . 'FROM reporters WHERE id = :id',
+            ['id' => $id]
+        );
+
+        return $row === false ? null : self::hydrate($row);
+    }
+
+    public function findByName(string $name): ?Reporter
+    {
+        /** @var array<string, mixed>|false $row */
+        $row = $this->connection->fetchAssociative(
+            'SELECT id, name, description, trust_weight, is_active, created_by_user_id, created_at '
+            . 'FROM reporters WHERE name = :name',
+            ['name' => $name]
+        );
+
+        return $row === false ? null : self::hydrate($row);
+    }
+
+    /**
+     * @return list<Reporter>
+     */
+    public function list(int $limit, int $offset): array
+    {
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $this->connection->fetchAllAssociative(
+            'SELECT id, name, description, trust_weight, is_active, created_by_user_id, created_at '
+            . 'FROM reporters ORDER BY id DESC LIMIT :limit OFFSET :offset',
+            ['limit' => $limit, 'offset' => $offset]
+        );
+
+        return array_map(static fn (array $r): Reporter => self::hydrate($r), $rows);
+    }
+
+    public function count(): int
+    {
+        return (int) $this->connection->fetchOne('SELECT COUNT(*) FROM reporters');
+    }
+
+    public function create(string $name, ?string $description, float $trustWeight, ?int $createdByUserId): int
+    {
+        $this->connection->insert('reporters', [
+            'name' => $name,
+            'description' => $description,
+            'trust_weight' => number_format($trustWeight, 2, '.', ''),
+            'is_active' => 1,
+            'created_by_user_id' => $createdByUserId,
+        ]);
+
+        return (int) $this->connection->lastInsertId();
+    }
+
+    /**
+     * @param array<string, mixed> $fields
+     */
+    public function update(int $id, array $fields): void
+    {
+        if ($fields === []) {
+            return;
+        }
+        $this->connection->update('reporters', $fields, ['id' => $id]);
+    }
+
+    public function softDelete(int $id): void
+    {
+        $this->connection->update('reporters', ['is_active' => 0], ['id' => $id]);
+    }
+
+    public function reportCount(int $reporterId): int
+    {
+        return (int) $this->connection->fetchOne(
+            'SELECT COUNT(*) FROM reports WHERE reporter_id = :id',
+            ['id' => $reporterId]
+        );
+    }
+
+    /**
+     * @param array<string, mixed> $row
+     */
+    private static function hydrate(array $row): Reporter
+    {
+        $createdAt = isset($row['created_at']) && $row['created_at'] !== null
+            ? new DateTimeImmutable((string) $row['created_at'], new DateTimeZone('UTC'))
+            : new DateTimeImmutable('now', new DateTimeZone('UTC'));
+
+        return new Reporter(
+            id: (int) $row['id'],
+            name: (string) $row['name'],
+            description: $row['description'] !== null ? (string) $row['description'] : null,
+            trustWeight: (float) $row['trust_weight'],
+            isActive: (bool) $row['is_active'],
+            createdByUserId: $row['created_by_user_id'] !== null ? (int) $row['created_by_user_id'] : null,
+            createdAt: $createdAt,
+        );
+    }
+}

+ 70 - 0
api/src/Infrastructure/Reputation/IpScoreRepository.php

@@ -0,0 +1,70 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Reputation;
+
+use App\Infrastructure\Db\RepositoryBase;
+use DateTimeImmutable;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * UPSERT into `ip_scores` for the synchronous-on-ingest path.
+ *
+ * SQLite uses `ON CONFLICT(ip_bin, category_id) DO UPDATE`; MySQL uses
+ * `ON DUPLICATE KEY UPDATE`. Single-replica deployments don't need
+ * row-level locking — `ip_scores` rows churn fast and the bulk recompute
+ * (M05) is the authority anyway.
+ */
+final class IpScoreRepository extends RepositoryBase
+{
+    public function upsert(
+        string $ipBin,
+        string $ipText,
+        int $categoryId,
+        float $score,
+        int $reportCount30d,
+        DateTimeImmutable $lastReportAt,
+        DateTimeImmutable $recomputedAt,
+    ): void {
+        $platform = $this->connection()->getDatabasePlatform()::class;
+        $isMysql = stripos($platform, 'mysql') !== false || stripos($platform, 'mariadb') !== false;
+
+        if ($isMysql) {
+            $sql = 'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
+                . 'VALUES (:ip_bin, :ip_text, :cat, :score, :cnt, :last, :recomputed) '
+                . 'ON DUPLICATE KEY UPDATE '
+                . 'ip_text = VALUES(ip_text), score = VALUES(score), report_count_30d = VALUES(report_count_30d), '
+                . 'last_report_at = VALUES(last_report_at), recomputed_at = VALUES(recomputed_at)';
+        } else {
+            $sql = 'INSERT INTO ip_scores (ip_bin, ip_text, category_id, score, report_count_30d, last_report_at, recomputed_at) '
+                . 'VALUES (:ip_bin, :ip_text, :cat, :score, :cnt, :last, :recomputed) '
+                . 'ON CONFLICT(ip_bin, category_id) DO UPDATE SET '
+                . 'ip_text = excluded.ip_text, score = excluded.score, report_count_30d = excluded.report_count_30d, '
+                . 'last_report_at = excluded.last_report_at, recomputed_at = excluded.recomputed_at';
+        }
+
+        $stmt = $this->connection()->prepare($sql);
+        $stmt->bindValue('ip_bin', $ipBin, ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('ip_text', $ipText);
+        $stmt->bindValue('cat', $categoryId, ParameterType::INTEGER);
+        $stmt->bindValue('score', number_format($score, 4, '.', ''));
+        $stmt->bindValue('cnt', $reportCount30d, ParameterType::INTEGER);
+        $stmt->bindValue('last', $lastReportAt->format('Y-m-d H:i:s'));
+        $stmt->bindValue('recomputed', $recomputedAt->format('Y-m-d H:i:s'));
+
+        $stmt->executeStatement();
+    }
+
+    public function findScore(string $ipBin, int $categoryId): ?float
+    {
+        $stmt = $this->connection()->prepare(
+            'SELECT score FROM ip_scores WHERE ip_bin = :ip AND category_id = :cat'
+        );
+        $stmt->bindValue('ip', $ipBin, ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('cat', $categoryId, ParameterType::INTEGER);
+        $value = $stmt->executeQuery()->fetchOne();
+
+        return $value === false ? null : (float) $value;
+    }
+}

+ 92 - 0
api/src/Infrastructure/Reputation/ReportRepository.php

@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Infrastructure\Reputation;
+
+use App\Infrastructure\Db\RepositoryBase;
+use DateTimeImmutable;
+use DateTimeZone;
+use Doctrine\DBAL\ParameterType;
+
+/**
+ * Append-only writer + reader for the `reports` table.
+ *
+ * `insert()` returns the new id and writes binary `ip_bin` correctly on
+ * both SQLite and MySQL via RepositoryBase's binding helper. `forScoring()`
+ * loads the (received_at, weight_at_report) pairs needed by PairScorer.
+ */
+final class ReportRepository extends RepositoryBase
+{
+    public function insert(
+        string $ipBin,
+        string $ipText,
+        int $categoryId,
+        int $reporterId,
+        float $weightAtReport,
+        ?string $metadataJson,
+        DateTimeImmutable $receivedAt,
+    ): int {
+        $this->insertRow(
+            'reports',
+            [
+                'ip_bin' => $ipBin,
+                'ip_text' => $ipText,
+                'category_id' => $categoryId,
+                'reporter_id' => $reporterId,
+                'weight_at_report' => number_format($weightAtReport, 2, '.', ''),
+                'metadata_json' => $metadataJson,
+                'received_at' => $receivedAt->format('Y-m-d H:i:s'),
+            ],
+            ['ip_bin' => ParameterType::LARGE_OBJECT]
+        );
+
+        return (int) $this->connection()->lastInsertId();
+    }
+
+    /**
+     * Returns the (received_at, weight_at_report) pairs for an ip+category
+     * within the cutoff window, newest first. PairScorer iterates them.
+     *
+     * @return list<array{received_at: DateTimeImmutable, weight: float}>
+     */
+    public function forScoring(string $ipBin, int $categoryId, DateTimeImmutable $cutoff): array
+    {
+        $sql = 'SELECT received_at, weight_at_report '
+            . 'FROM reports WHERE ip_bin = :ip AND category_id = :cat AND received_at >= :cutoff '
+            . 'ORDER BY received_at DESC';
+
+        $stmt = $this->connection()->prepare($sql);
+        $stmt->bindValue('ip', $ipBin, ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('cat', $categoryId, ParameterType::INTEGER);
+        $stmt->bindValue('cutoff', $cutoff->format('Y-m-d H:i:s'));
+
+        /** @var list<array<string, mixed>> $rows */
+        $rows = $stmt->executeQuery()->fetchAllAssociative();
+        $out = [];
+        foreach ($rows as $row) {
+            $out[] = [
+                'received_at' => new DateTimeImmutable((string) $row['received_at'], new DateTimeZone('UTC')),
+                'weight' => (float) $row['weight_at_report'],
+            ];
+        }
+
+        return $out;
+    }
+
+    /**
+     * Count of reports in the last 30 days for (ip, category) — used to
+     * keep `ip_scores.report_count_30d` in sync on the synchronous path.
+     */
+    public function recentCount(string $ipBin, int $categoryId, DateTimeImmutable $since): int
+    {
+        $stmt = $this->connection()->prepare(
+            'SELECT COUNT(*) FROM reports WHERE ip_bin = :ip AND category_id = :cat AND received_at >= :since'
+        );
+        $stmt->bindValue('ip', $ipBin, ParameterType::LARGE_OBJECT);
+        $stmt->bindValue('cat', $categoryId, ParameterType::INTEGER);
+        $stmt->bindValue('since', $since->format('Y-m-d H:i:s'));
+
+        return (int) $stmt->executeQuery()->fetchOne();
+    }
+}

+ 50 - 0
api/tests/Integration/Admin/ConsumersControllerTest.php

@@ -0,0 +1,50 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Tests\Integration\Support\AppTestCase;
+
+final class ConsumersControllerTest extends AppTestCase
+{
+    public function testCreateAndListConsumer(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $policyId = (int) $this->db->fetchOne('SELECT id FROM policies WHERE name = :name', ['name' => 'moderate']);
+
+        $created = $this->request(
+            'POST',
+            '/api/v1/admin/consumers',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['name' => 'fw-edge-01', 'policy_id' => $policyId]) ?: null,
+        );
+        self::assertSame(201, $created->getStatusCode());
+        $body = $this->decode($created);
+        self::assertSame('fw-edge-01', $body['name']);
+        self::assertSame($policyId, $body['policy_id']);
+
+        $list = $this->request('GET', '/api/v1/admin/consumers', [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $list->getStatusCode());
+        $listBody = $this->decode($list);
+        self::assertGreaterThan(0, $listBody['total']);
+    }
+
+    public function testCreateRejectsUnknownPolicy(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/consumers',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['name' => 'bogus', 'policy_id' => 99999]) ?: null,
+        );
+        self::assertSame(400, $resp->getStatusCode());
+        self::assertArrayHasKey('policy_id', $this->decode($resp)['details']);
+    }
+}

+ 144 - 0
api/tests/Integration/Admin/ReportersControllerTest.php

@@ -0,0 +1,144 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Tests\Integration\Support\AppTestCase;
+
+final class ReportersControllerTest extends AppTestCase
+{
+    public function testNonAdminCannotCreateReporter(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Operator);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/reporters',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['name' => 'web-prod']) ?: null,
+        );
+        self::assertSame(403, $response->getStatusCode());
+    }
+
+    public function testAdminCanCreateAndFetchReporter(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+
+        $created = $this->request(
+            'POST',
+            '/api/v1/admin/reporters',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode([
+                'name' => 'web-prod-01',
+                'description' => 'first webserver',
+                'trust_weight' => 1.5,
+            ]) ?: null,
+        );
+        self::assertSame(201, $created->getStatusCode());
+        $body = $this->decode($created);
+        self::assertSame('web-prod-01', $body['name']);
+        self::assertSame(1.5, $body['trust_weight']);
+        self::assertTrue($body['is_active']);
+
+        $id = (int) $body['id'];
+        $detail = $this->request('GET', "/api/v1/admin/reporters/{$id}", [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $detail->getStatusCode());
+        self::assertSame('web-prod-01', $this->decode($detail)['name']);
+    }
+
+    public function testCreateRejectsOutOfRangeTrustWeight(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $response = $this->request(
+            'POST',
+            '/api/v1/admin/reporters',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['name' => 'bad', 'trust_weight' => 5.0]) ?: null,
+        );
+        self::assertSame(400, $response->getStatusCode());
+        $body = $this->decode($response);
+        self::assertSame('validation_failed', $body['error']);
+        self::assertArrayHasKey('trust_weight', $body['details']);
+    }
+
+    public function testDuplicateNameRejected(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $this->request(
+            'POST',
+            '/api/v1/admin/reporters',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['name' => 'dup']) ?: null,
+        );
+        $second = $this->request(
+            'POST',
+            '/api/v1/admin/reporters',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['name' => 'dup']) ?: null,
+        );
+        self::assertSame(400, $second->getStatusCode());
+    }
+
+    public function testPatchUpdatesFields(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $reporterId = $this->createReporter('web-edit');
+
+        $patch = $this->request(
+            'PATCH',
+            "/api/v1/admin/reporters/{$reporterId}",
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['trust_weight' => 0.25, 'is_active' => false]) ?: null,
+        );
+        self::assertSame(200, $patch->getStatusCode());
+        $body = $this->decode($patch);
+        self::assertSame(0.25, $body['trust_weight']);
+        self::assertFalse($body['is_active']);
+    }
+
+    public function testDeleteWithoutReportsSoftDeletes(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $reporterId = $this->createReporter('web-disposable');
+
+        $delete = $this->request('DELETE', "/api/v1/admin/reporters/{$reporterId}", [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(204, $delete->getStatusCode());
+
+        $detail = $this->request('GET', "/api/v1/admin/reporters/{$reporterId}", [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(200, $detail->getStatusCode());
+        self::assertFalse($this->decode($detail)['is_active']);
+    }
+
+    public function testDeleteWithReportsReturns409(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $reporterId = $this->createReporter('web-with-reports');
+
+        $categoryId = (int) $this->db->fetchOne(
+            'SELECT id FROM categories WHERE slug = :slug',
+            ['slug' => 'brute_force']
+        );
+
+        $this->db->insert('reports', [
+            'category_id' => $categoryId,
+            'reporter_id' => $reporterId,
+            'ip_bin' => str_repeat("\0", 12) . "\xff\xff\x01\x02",
+            'ip_text' => '0.0.0.1',
+            'weight_at_report' => '1.00',
+            'received_at' => '2026-01-01 00:00:00',
+        ]);
+
+        $delete = $this->request('DELETE', "/api/v1/admin/reporters/{$reporterId}", [
+            'Authorization' => 'Bearer ' . $token,
+        ]);
+        self::assertSame(409, $delete->getStatusCode());
+    }
+}

+ 102 - 0
api/tests/Integration/Admin/TokensControllerTest.php

@@ -0,0 +1,102 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Admin;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Tests\Integration\Support\AppTestCase;
+
+final class TokensControllerTest extends AppTestCase
+{
+    public function testCreateReporterTokenReturnsRawOnce(): void
+    {
+        $admin = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $reporterId = $this->createReporter('web-tokens');
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/tokens',
+            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'reporter', 'reporter_id' => $reporterId]) ?: null,
+        );
+        self::assertSame(201, $resp->getStatusCode());
+
+        $body = $this->decode($resp);
+        self::assertSame('reporter', $body['kind']);
+        self::assertSame($reporterId, $body['reporter_id']);
+        self::assertArrayHasKey('raw_token', $body);
+        self::assertStringStartsWith('irdb_rep_', (string) $body['raw_token']);
+
+        // Second fetch via list must not include raw_token.
+        $list = $this->request('GET', '/api/v1/admin/tokens', [
+            'Authorization' => 'Bearer ' . $admin,
+        ]);
+        $listBody = $this->decode($list);
+        foreach ($listBody['data'] as $row) {
+            self::assertArrayNotHasKey('raw_token', $row);
+        }
+    }
+
+    public function testCreateAdminTokenRequiresRole(): void
+    {
+        $admin = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/tokens',
+            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'admin']) ?: null,
+        );
+        self::assertSame(400, $resp->getStatusCode());
+        self::assertArrayHasKey('role', $this->decode($resp)['details']);
+    }
+
+    public function testServiceKindRefused(): void
+    {
+        $admin = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $resp = $this->request(
+            'POST',
+            '/api/v1/admin/tokens',
+            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'service']) ?: null,
+        );
+        self::assertSame(400, $resp->getStatusCode());
+    }
+
+    public function testListExcludesServiceTokens(): void
+    {
+        $admin = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $this->createToken(TokenKind::Service);
+
+        $resp = $this->request('GET', '/api/v1/admin/tokens', [
+            'Authorization' => 'Bearer ' . $admin,
+        ]);
+        $body = $this->decode($resp);
+        foreach ($body['data'] as $row) {
+            self::assertNotSame('service', $row['kind']);
+        }
+    }
+
+    public function testRevokeMarksRevokedAt(): void
+    {
+        $admin = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        $reporterId = $this->createReporter('web-revoke');
+        $created = $this->request(
+            'POST',
+            '/api/v1/admin/tokens',
+            ['Authorization' => 'Bearer ' . $admin, 'Content-Type' => 'application/json'],
+            json_encode(['kind' => 'reporter', 'reporter_id' => $reporterId]) ?: null,
+        );
+        $tokenId = (int) $this->decode($created)['id'];
+
+        $delete = $this->request('DELETE', "/api/v1/admin/tokens/{$tokenId}", [
+            'Authorization' => 'Bearer ' . $admin,
+        ]);
+        self::assertSame(204, $delete->getStatusCode());
+
+        $row = $this->db->fetchAssociative('SELECT revoked_at FROM api_tokens WHERE id = :id', ['id' => $tokenId]);
+        self::assertIsArray($row);
+        self::assertNotNull($row['revoked_at']);
+    }
+}

+ 92 - 0
api/tests/Integration/Public/RateLimitTest.php

@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Public;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Domain\Time\Clock;
+use App\Domain\Time\FixedClock;
+use App\Infrastructure\Http\Middleware\RateLimitMiddleware;
+use App\Infrastructure\Http\RateLimiter;
+use App\Tests\Integration\Support\AppTestCase;
+use Psr\Http\Message\ResponseFactoryInterface;
+
+/**
+ * Override the AppTestCase to inject a tight rate limit and a fixed clock,
+ * then run a burst that exceeds capacity (rate × 2).
+ */
+final class RateLimitTest extends AppTestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        // Replace the clock + limiter with a tight, fixed-time pair: refill=2/s,
+        // capacity=4. A burst of 20 must produce some 429s. We must also rebuild
+        // the RateLimitMiddleware singleton — PHP-DI caches the constructed
+        // instance, which already holds the wide-open limiter from setup.
+        if (method_exists($this->container, 'set')) {
+            /** @var \DI\Container $c */
+            $c = $this->container;
+            $clock = FixedClock::at('2026-04-29T00:00:00Z');
+            $limiter = new RateLimiter($clock, 2.0, 4.0);
+            $c->set(Clock::class, $clock);
+            $c->set(RateLimiter::class, $limiter);
+            /** @var ResponseFactoryInterface $rf */
+            $rf = $c->get(ResponseFactoryInterface::class);
+            $c->set(RateLimitMiddleware::class, new RateLimitMiddleware($limiter, $rf));
+            $this->app = \App\App\AppFactory::build($this->container);
+        }
+    }
+
+    public function testBurstExceedingCapacityProduces429s(): void
+    {
+        $reporterId = $this->createReporter('web-limited');
+        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
+
+        $headers = ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'];
+        $body = json_encode(['ip' => '203.0.113.1', 'category' => 'brute_force']) ?: null;
+
+        $statuses = [];
+        for ($i = 0; $i < 20; $i++) {
+            $statuses[] = $this->request('POST', '/api/v1/report', $headers, $body)->getStatusCode();
+        }
+
+        $accepted = count(array_filter($statuses, static fn (int $s): bool => $s === 202));
+        $limited = count(array_filter($statuses, static fn (int $s): bool => $s === 429));
+
+        // At fixed time + capacity=4 we expect exactly 4 successes and 16 throttles.
+        self::assertSame(4, $accepted, 'capacity-bounded successes');
+        self::assertSame(16, $limited, 'remainder rate-limited');
+    }
+
+    public function testRateLimit429IncludesRetryAfter(): void
+    {
+        $reporterId = $this->createReporter('web-retry');
+        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
+        $headers = ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'];
+        $body = json_encode(['ip' => '203.0.113.1', 'category' => 'brute_force']) ?: null;
+
+        // Drain capacity first.
+        for ($i = 0; $i < 4; $i++) {
+            $this->request('POST', '/api/v1/report', $headers, $body);
+        }
+        $resp = $this->request('POST', '/api/v1/report', $headers, $body);
+        self::assertSame(429, $resp->getStatusCode());
+        self::assertSame('1', $resp->getHeaderLine('Retry-After'));
+    }
+
+    public function testAdminRoutesNotRateLimited(): void
+    {
+        $admin = $this->createToken(TokenKind::Admin, role: Role::Admin);
+        // Admin routes should never 429 even when smashed.
+        for ($i = 0; $i < 50; $i++) {
+            $resp = $this->request('GET', '/api/v1/admin/me', [
+                'Authorization' => 'Bearer ' . $admin,
+            ]);
+            self::assertNotSame(429, $resp->getStatusCode());
+        }
+    }
+}

+ 152 - 0
api/tests/Integration/Public/ReportControllerTest.php

@@ -0,0 +1,152 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Integration\Public;
+
+use App\Domain\Auth\Role;
+use App\Domain\Auth\TokenKind;
+use App\Tests\Integration\Support\AppTestCase;
+
+final class ReportControllerTest extends AppTestCase
+{
+    public function testValidReportInsertedAndScoreUpdated(): void
+    {
+        $reporterId = $this->createReporter('web-prod');
+        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/report',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['ip' => '203.0.113.42', 'category' => 'brute_force']) ?: null,
+        );
+        self::assertSame(202, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertArrayHasKey('report_id', $body);
+        self::assertSame('203.0.113.42', $body['ip']);
+        self::assertArrayHasKey('received_at', $body);
+
+        // ip_scores must have a row > 0.
+        $score = $this->db->fetchOne(
+            "SELECT score FROM ip_scores WHERE ip_text = '203.0.113.42'"
+        );
+        self::assertNotFalse($score);
+        self::assertGreaterThan(0.0, (float) $score);
+    }
+
+    public function testManyReportsAccumulateMonotonically(): void
+    {
+        $reporterId = $this->createReporter('web-many');
+        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
+
+        for ($i = 0; $i < 5; $i++) {
+            $this->request(
+                'POST',
+                '/api/v1/report',
+                ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+                json_encode(['ip' => '198.51.100.7', 'category' => 'scanner']) ?: null,
+            );
+        }
+
+        $count = (int) $this->db->fetchOne(
+            "SELECT COUNT(*) FROM reports WHERE ip_text = '198.51.100.7'"
+        );
+        self::assertSame(5, $count);
+
+        $score = (float) $this->db->fetchOne(
+            "SELECT score FROM ip_scores WHERE ip_text = '198.51.100.7'"
+        );
+        // 5 fresh reports × weight 1.0 × decay~1.0 should be ~5.0.
+        self::assertEqualsWithDelta(5.0, $score, 0.05);
+    }
+
+    public function testWrongKindTokenRejected(): void
+    {
+        $token = $this->createToken(TokenKind::Admin, role: Role::Admin);
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/report',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['ip' => '1.2.3.4', 'category' => 'spam']) ?: null,
+        );
+        self::assertSame(401, $resp->getStatusCode());
+    }
+
+    public function testInvalidIpReturns400(): void
+    {
+        $reporterId = $this->createReporter('web-bad-ip');
+        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/report',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['ip' => 'not-an-ip', 'category' => 'spam']) ?: null,
+        );
+        self::assertSame(400, $resp->getStatusCode());
+        $body = $this->decode($resp);
+        self::assertSame('validation_failed', $body['error']);
+        self::assertArrayHasKey('ip', $body['details']);
+    }
+
+    public function testUnknownCategoryReturns400(): void
+    {
+        $reporterId = $this->createReporter('web-bad-cat');
+        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/report',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['ip' => '1.2.3.4', 'category' => 'no-such']) ?: null,
+        );
+        self::assertSame(400, $resp->getStatusCode());
+        self::assertArrayHasKey('category', $this->decode($resp)['details']);
+    }
+
+    public function testMetadataMustBeObject(): void
+    {
+        $reporterId = $this->createReporter('web-bad-meta');
+        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/report',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['ip' => '1.2.3.4', 'category' => 'spam', 'metadata' => [1, 2, 3]]) ?: null,
+        );
+        self::assertSame(400, $resp->getStatusCode());
+    }
+
+    public function testMetadataExceedingLimitRejected(): void
+    {
+        $reporterId = $this->createReporter('web-big-meta');
+        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
+
+        $big = ['blob' => str_repeat('A', 5000)];
+        $resp = $this->request(
+            'POST',
+            '/api/v1/report',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['ip' => '1.2.3.4', 'category' => 'spam', 'metadata' => $big]) ?: null,
+        );
+        self::assertSame(400, $resp->getStatusCode());
+    }
+
+    public function testInactiveReporterTokenRejected(): void
+    {
+        $reporterId = $this->createReporter('web-disabled');
+        $token = $this->createToken(TokenKind::Reporter, reporterId: $reporterId);
+        $this->db->update('reporters', ['is_active' => 0], ['id' => $reporterId]);
+
+        $resp = $this->request(
+            'POST',
+            '/api/v1/report',
+            ['Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json'],
+            json_encode(['ip' => '1.2.3.4', 'category' => 'spam']) ?: null,
+        );
+        self::assertSame(401, $resp->getStatusCode());
+    }
+}

+ 2 - 0
api/tests/Integration/Support/AppTestCase.php

@@ -85,6 +85,8 @@ abstract class AppTestCase extends TestCase
             'internal_job_token' => '',
             'ui_origin' => 'http://localhost:8080',
             'oidc_default_role' => Role::Viewer,
+            'score_hard_cutoff_days' => 365,
+            'rate_limit_per_second' => 1000,
         ];
 
         $this->container = Container::build($settings);

+ 62 - 0
api/tests/Unit/Http/RateLimiterTest.php

@@ -0,0 +1,62 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Http;
+
+use App\Domain\Time\FixedClock;
+use App\Infrastructure\Http\RateLimiter;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Token-bucket invariants:
+ *  - capacity = 2 × refill, so 6 immediate consumes succeed at refill=3.
+ *  - the 7th fails, then advancing 1s restores 3 tokens.
+ *  - per-key isolation: two keys do not share a bucket.
+ */
+final class RateLimiterTest extends TestCase
+{
+    public function testBurstUpToCapacitySucceeds(): void
+    {
+        $clock = FixedClock::at('2026-04-29T00:00:00Z');
+        $limiter = new RateLimiter($clock, refillPerSecond: 3.0, capacity: 6.0);
+
+        for ($i = 0; $i < 6; $i++) {
+            self::assertTrue($limiter->tryConsume(1), "consume #{$i} should succeed");
+        }
+        self::assertFalse($limiter->tryConsume(1), 'consume #7 should be denied');
+    }
+
+    public function testRefillRestoresTokensOverTime(): void
+    {
+        $clock = FixedClock::at('2026-04-29T00:00:00Z');
+        $limiter = new RateLimiter($clock, refillPerSecond: 3.0, capacity: 6.0);
+
+        for ($i = 0; $i < 6; $i++) {
+            $limiter->tryConsume(1);
+        }
+        self::assertFalse($limiter->tryConsume(1));
+
+        $clock->advance('+1 second');
+
+        // After 1s @ 3/s we should have 3 fresh tokens.
+        self::assertTrue($limiter->tryConsume(1));
+        self::assertTrue($limiter->tryConsume(1));
+        self::assertTrue($limiter->tryConsume(1));
+        self::assertFalse($limiter->tryConsume(1));
+    }
+
+    public function testKeysAreIsolated(): void
+    {
+        $clock = FixedClock::at('2026-04-29T00:00:00Z');
+        $limiter = new RateLimiter($clock, refillPerSecond: 1.0, capacity: 2.0);
+
+        self::assertTrue($limiter->tryConsume(1));
+        self::assertTrue($limiter->tryConsume(1));
+        self::assertFalse($limiter->tryConsume(1));
+
+        // Token 2's bucket is untouched.
+        self::assertTrue($limiter->tryConsume(2));
+        self::assertTrue($limiter->tryConsume(2));
+    }
+}

+ 61 - 0
api/tests/Unit/Reputation/DecayTest.php

@@ -0,0 +1,61 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Tests\Unit\Reputation;
+
+use App\Domain\Reputation\Decay;
+use App\Domain\Reputation\DecayFunction;
+use PHPUnit\Framework\TestCase;
+
+/**
+ * Hand-computed reference values for both decay shapes. The exponential
+ * cases hit half-life multiples so the answers are clean fractions.
+ */
+final class DecayTest extends TestCase
+{
+    public function testLinearAtZeroReturnsFullWeight(): void
+    {
+        self::assertSame(1.0, Decay::value(DecayFunction::Linear, 0.0, 30.0));
+    }
+
+    public function testLinearMidwayReturnsHalf(): void
+    {
+        self::assertEqualsWithDelta(0.5, Decay::value(DecayFunction::Linear, 15.0, 30.0), 1e-9);
+    }
+
+    public function testLinearAtOrPastDecayParamClampsToZero(): void
+    {
+        self::assertSame(0.0, Decay::value(DecayFunction::Linear, 30.0, 30.0));
+        self::assertSame(0.0, Decay::value(DecayFunction::Linear, 100.0, 30.0));
+    }
+
+    public function testExponentialAtZeroReturnsFullWeight(): void
+    {
+        self::assertSame(1.0, Decay::value(DecayFunction::Exponential, 0.0, 14.0));
+    }
+
+    public function testExponentialAtOneHalfLifeReturnsHalf(): void
+    {
+        self::assertEqualsWithDelta(0.5, Decay::value(DecayFunction::Exponential, 14.0, 14.0), 1e-9);
+    }
+
+    public function testExponentialAtTwoHalfLivesReturnsQuarter(): void
+    {
+        self::assertEqualsWithDelta(0.25, Decay::value(DecayFunction::Exponential, 28.0, 14.0), 1e-9);
+    }
+
+    public function testNegativeAgeClampsToFullWeight(): void
+    {
+        // Future-dated reports shouldn't happen, but if they do they count
+        // at full weight rather than blowing up.
+        self::assertSame(1.0, Decay::value(DecayFunction::Linear, -5.0, 30.0));
+        self::assertSame(1.0, Decay::value(DecayFunction::Exponential, -5.0, 14.0));
+    }
+
+    public function testZeroDecayParamReturnsZero(): void
+    {
+        self::assertSame(0.0, Decay::value(DecayFunction::Linear, 5.0, 0.0));
+        self::assertSame(0.0, Decay::value(DecayFunction::Exponential, 5.0, 0.0));
+    }
+}