*/ public static function build(ContainerInterface $container): App { SlimAppFactory::setContainer($container); $app = SlimAppFactory::create(); $app->addRoutingMiddleware(); $app->addBodyParsingMiddleware(); /** @var array{app_env: string} $settings */ $settings = $container->get('settings'); $isDev = $settings['app_env'] === 'development'; /** @var LoggerInterface $logger */ $logger = $container->get(LoggerInterface::class); $errorMiddleware = $app->addErrorMiddleware($isDev, true, true, $logger); /** @var JsonErrorHandler $handler */ $handler = $container->get(JsonErrorHandler::class); $errorMiddleware->setDefaultErrorHandler($handler); /** @var ResponseFactoryInterface $rf */ $rf = $container->get(ResponseFactoryInterface::class); /** @var TokenAuthenticationMiddleware $tokenAuth */ $tokenAuth = $container->get(TokenAuthenticationMiddleware::class); /** @var ImpersonationMiddleware $impersonation */ $impersonation = $container->get(ImpersonationMiddleware::class); /** @var RateLimitMiddleware $rateLimit */ $rateLimit = $container->get(RateLimitMiddleware::class); /** @var InternalNetworkMiddleware $internalNetwork */ $internalNetwork = $container->get(InternalNetworkMiddleware::class); /** @var InternalTokenMiddleware $internalToken */ $internalToken = $container->get(InternalTokenMiddleware::class); $app->get('/healthz', function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $response->getBody()->write((string) json_encode(['status' => 'ok'])); return $response->withHeader('Content-Type', 'application/json'); }); // Auth API: service-token-only. No impersonation — these endpoints // exist to *produce* user_ids the ui can later impersonate. The // controller enforces kind=service on each call. $app->group('/api/v1/auth', function (RouteCollectorProxy $auth) use ($container): void { /** @var AuthController $controller */ $controller = $container->get(AuthController::class); $auth->post('/users/upsert-oidc', [$controller, 'upsertOidc']); $auth->post('/users/upsert-local', [$controller, 'upsertLocal']); $auth->get('/users/{id}', function ( ServerRequestInterface $request, ResponseInterface $response, array $args, ) use ($controller): ResponseInterface { /** @var array{id: string} $args */ return $controller->getUser($request, $response, $args['id']); }); })->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); /** @var BlocklistController $blocklist */ $blocklist = $container->get(BlocklistController::class); $public->get('/blocklist', $blocklist); }) ->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)); // Manual blocks: list/show require Viewer, create/delete require Operator. // Per-route middleware lets us split read vs write at one URL group. /** @var ManualBlocksController $manualBlocks */ $manualBlocks = $container->get(ManualBlocksController::class); $admin->get('/manual-blocks', [$manualBlocks, 'list']) ->add(RbacMiddleware::require($rf, Role::Viewer)); $admin->get('/manual-blocks/{id}', [$manualBlocks, 'show']) ->add(RbacMiddleware::require($rf, Role::Viewer)); $admin->post('/manual-blocks', [$manualBlocks, 'create']) ->add(RbacMiddleware::require($rf, Role::Operator)); $admin->delete('/manual-blocks/{id}', [$manualBlocks, 'delete']) ->add(RbacMiddleware::require($rf, Role::Operator)); /** @var AllowlistController $allowlist */ $allowlist = $container->get(AllowlistController::class); $admin->get('/allowlist', [$allowlist, 'list']) ->add(RbacMiddleware::require($rf, Role::Viewer)); $admin->get('/allowlist/{id}', [$allowlist, 'show']) ->add(RbacMiddleware::require($rf, Role::Viewer)); $admin->post('/allowlist', [$allowlist, 'create']) ->add(RbacMiddleware::require($rf, Role::Operator)); $admin->delete('/allowlist/{id}', [$allowlist, 'delete']) ->add(RbacMiddleware::require($rf, Role::Operator)); // IPs: list, detail, stats — all Viewer (read-only this milestone). /** @var IpsController $ips */ $ips = $container->get(IpsController::class); $admin->get('/ips', [$ips, 'list']) ->add(RbacMiddleware::require($rf, Role::Viewer)); $admin->get('/ips/{ip:.+}', [$ips, 'show']) ->add(RbacMiddleware::require($rf, Role::Viewer)); /** @var StatsController $stats */ $stats = $container->get(StatsController::class); $admin->get('/stats/dashboard', [$stats, 'dashboard']) ->add(RbacMiddleware::require($rf, Role::Viewer)); // Policies: list/show/preview = Viewer; write = Admin. /** @var PoliciesController $policies */ $policies = $container->get(PoliciesController::class); $admin->get('/policies', [$policies, 'list']) ->add(RbacMiddleware::require($rf, Role::Viewer)); $admin->get('/policies/{id}', [$policies, 'show']) ->add(RbacMiddleware::require($rf, Role::Viewer)); $admin->get('/policies/{id}/preview', [$policies, 'preview']) ->add(RbacMiddleware::require($rf, Role::Viewer)); $admin->post('/policies', [$policies, 'create']) ->add(RbacMiddleware::require($rf, Role::Admin)); $admin->patch('/policies/{id}', [$policies, 'update']) ->add(RbacMiddleware::require($rf, Role::Admin)); $admin->delete('/policies/{id}', [$policies, 'delete']) ->add(RbacMiddleware::require($rf, Role::Admin)); }) ->add($impersonation) ->add($tokenAuth); // Internal jobs API: scheduler-only. Network gate (404 outside // RFC1918) → token gate (401) → controller. Order matters: // network rejection must not leak through token-attempt logs. $app->group('/internal/jobs', function (RouteCollectorProxy $internal) use ($container): void { /** @var JobsController $jobs */ $jobs = $container->get(JobsController::class); $internal->post('/recompute-scores', [$jobs, 'recomputeScores']); $internal->post('/cleanup-audit', [$jobs, 'cleanupAudit']); $internal->post('/enrich-pending', [$jobs, 'enrichPending']); $internal->post('/tick', [$jobs, 'tick']); $internal->post('/refresh-geoip', [$jobs, 'refreshGeoip']); $internal->get('/status', [$jobs, 'status']); }) ->add($internalToken) ->add($internalNetwork); $app->map( ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], '/{routes:.+}', function (ServerRequestInterface $request, ResponseInterface $response): ResponseInterface { $response->getBody()->write((string) json_encode(['error' => 'not_found'])); return $response ->withHeader('Content-Type', 'application/json') ->withStatus(404); } ); return $app; } }