*/ 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 AuditContextMiddleware $auditContext */ $auditContext = $container->get(AuditContextMiddleware::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); // Public docs (no auth, no rate limit). The viewer is HTML-only; // the spec is the YAML file shipped with the api image. /** @var DocsController $docs */ $docs = $container->get(DocsController::class); $app->get('/api/v1/openapi.yaml', [$docs, 'spec']); $app->get('/api/docs', [$docs, 'viewer']); $app->get('/healthz', function (ServerRequestInterface $request, ResponseInterface $response) use ($container): ResponseInterface { /** @var array{driver: string} $dbSettings */ $dbSettings = $container->get('settings.db'); $dbConnected = false; try { /** @var \Doctrine\DBAL\Connection $conn */ $conn = $container->get(\Doctrine\DBAL\Connection::class); $conn->executeQuery('SELECT 1')->fetchOne(); $dbConnected = true; } catch (\Throwable) { $dbConnected = false; } /** @var array{enabled: bool, provider: string, country_db: string, asn_db: string, maxmind_license_key: string, ipinfo_token: string} $geoip */ $geoip = $container->get('settings.geoip'); $providerConfigured = match ($geoip['provider']) { 'maxmind' => $geoip['maxmind_license_key'] !== '', 'ipinfo' => $geoip['ipinfo_token'] !== '', default => true, }; $countryPresent = is_file($geoip['country_db']) && is_readable($geoip['country_db']); $asnPresent = is_file($geoip['asn_db']) && is_readable($geoip['asn_db']); $countryMtime = $countryPresent ? gmdate('Y-m-d\TH:i:s\Z', (int) filemtime($geoip['country_db'])) : null; $asnMtime = $asnPresent ? gmdate('Y-m-d\TH:i:s\Z', (int) filemtime($geoip['asn_db'])) : null; $response->getBody()->write((string) json_encode([ 'status' => 'ok', 'db' => [ 'connected' => $dbConnected, 'driver' => $dbSettings['driver'], ], 'geoip' => [ 'provider' => $geoip['provider'], 'provider_configured' => $providerConfigured, 'country_db_present' => $countryPresent, 'asn_db_present' => $asnPresent, 'country_db_modified' => $countryMtime, 'asn_db_modified' => $asnMtime, ], ])); 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)); // /ips/countries must come BEFORE /ips/{ip:.+} or it'd be // matched as an IP. $admin->get('/ips/countries', [$ips, 'countries']) ->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)); // Categories: list/show = Viewer; write = Admin (per SPEC §M10.1). /** @var CategoriesController $categories */ $categories = $container->get(CategoriesController::class); $admin->get('/categories', [$categories, 'list']) ->add(RbacMiddleware::require($rf, Role::Viewer)); $admin->get('/categories/{id}', [$categories, 'show']) ->add(RbacMiddleware::require($rf, Role::Viewer)); $admin->post('/categories', [$categories, 'create']) ->add(RbacMiddleware::require($rf, Role::Admin)); $admin->patch('/categories/{id}', [$categories, 'update']) ->add(RbacMiddleware::require($rf, Role::Admin)); $admin->delete('/categories/{id}', [$categories, 'delete']) ->add(RbacMiddleware::require($rf, Role::Admin)); // Audit log: Viewer. /** @var AuditController $audit */ $audit = $container->get(AuditController::class); $admin->get('/audit-log', [$audit, 'list']) ->add(RbacMiddleware::require($rf, Role::Viewer)); // Jobs admin (Viewer for status, Admin for trigger). /** @var JobsAdminController $jobsAdmin */ $jobsAdmin = $container->get(JobsAdminController::class); $admin->get('/jobs/status', [$jobsAdmin, 'status']) ->add(RbacMiddleware::require($rf, Role::Viewer)); $admin->post('/jobs/trigger/{name}', [$jobsAdmin, 'trigger']) ->add(RbacMiddleware::require($rf, Role::Admin)); // Effective config (secrets masked) — Admin only. /** @var ConfigController $config */ $config = $container->get(ConfigController::class); $admin->get('/config', [$config, 'show']) ->add(RbacMiddleware::require($rf, Role::Admin)); // Demo / maintenance — Admin only. Both wipe and seed are // destructive in different directions; the UI guards each with a // confirmation modal and the purge body must include the literal // "PURGE" string. /** @var MaintenanceController $maintenance */ $maintenance = $container->get(MaintenanceController::class); $admin->post('/maintenance/purge', [$maintenance, 'purge']) ->add(RbacMiddleware::require($rf, Role::Admin)); $admin->post('/maintenance/seed-demo', [$maintenance, 'seedDemo']) ->add(RbacMiddleware::require($rf, Role::Admin)); // 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->get('/policies/{id}/score-distribution', [$policies, 'scoreDistribution']) ->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($auditContext) ->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('/cleanup-expired-manual-blocks', [$jobs, 'cleanupExpiredManualBlocks']); $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; } }