refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
323
core/Http/ApiBootstrap.php
Normal file
323
core/Http/ApiBootstrap.php
Normal file
@@ -0,0 +1,323 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Http;
|
||||
|
||||
use MintyPHP\Service\Security\RateLimiterService;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
|
||||
// Entry point bootstrapper called once at the top of every API action.
|
||||
// Handles: request context, CORS, audit start, rate limiting, and optional Bearer auth.
|
||||
class ApiBootstrap
|
||||
{
|
||||
// Two separate rate-limit buckets: one per IP (broad), one per token+IP (fine-grained).
|
||||
private const API_RATE_SCOPE_TOKEN = 'api.token_ip';
|
||||
private const API_RATE_SCOPE_IP = 'api.ip';
|
||||
private const API_RATE_LIMIT_TOKEN = 300;
|
||||
private const API_RATE_LIMIT_IP = 120;
|
||||
private const API_RATE_WINDOW_SECONDS = 60;
|
||||
private const API_RATE_BLOCK_SECONDS = 120;
|
||||
|
||||
private static bool $initialized = false;
|
||||
private static bool $shutdownRegistered = false;
|
||||
/** @var (callable(): object)|null Resolver for API audit service (provided by audit module) */
|
||||
private static $apiAuditServiceResolver = null;
|
||||
/** @var (callable(): SettingsApiPolicyGateway)|null */
|
||||
private static $settingsApiPolicyGatewayResolver = null;
|
||||
/** @var (callable(): RateLimiterService)|null */
|
||||
private static $rateLimiterServiceResolver = null;
|
||||
/** @var (callable(): object)|null Resolver for API system audit reporter (provided by audit module) */
|
||||
private static $apiSystemAuditReporterResolver = null;
|
||||
|
||||
public static function configure(
|
||||
?callable $apiAuditServiceResolver,
|
||||
callable $settingsApiPolicyGatewayResolver,
|
||||
callable $rateLimiterServiceResolver,
|
||||
?callable $apiSystemAuditReporterResolver
|
||||
): void {
|
||||
self::$apiAuditServiceResolver = $apiAuditServiceResolver;
|
||||
self::$settingsApiPolicyGatewayResolver = $settingsApiPolicyGatewayResolver;
|
||||
self::$rateLimiterServiceResolver = $rateLimiterServiceResolver;
|
||||
self::$apiSystemAuditReporterResolver = $apiSystemAuditReporterResolver;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the API request context.
|
||||
*
|
||||
* Call this at the top of every API page action.
|
||||
* Sets MINTY_ALLOW_OUTPUT, CORS headers and optional Bearer auth.
|
||||
*/
|
||||
public static function init(bool $requireAuth = true): void
|
||||
{
|
||||
if (self::$initialized) {
|
||||
return;
|
||||
}
|
||||
self::$initialized = true;
|
||||
|
||||
if (!defined('MINTY_ALLOW_OUTPUT')) {
|
||||
define('MINTY_ALLOW_OUTPUT', true);
|
||||
}
|
||||
|
||||
RequestContext::start([
|
||||
'channel' => 'api',
|
||||
'method' => strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')),
|
||||
'path' => (string) parse_url((string) ($_SERVER['REQUEST_URI'] ?? ''), PHP_URL_PATH),
|
||||
]);
|
||||
header('X-Request-Id: ' . RequestContext::requestHeaderValue());
|
||||
|
||||
self::registerShutdownHandler();
|
||||
self::tryStartAudit();
|
||||
self::startSystemAuditReporter();
|
||||
self::setCorsHeaders();
|
||||
|
||||
if (strtoupper($_SERVER['REQUEST_METHOD'] ?? '') === 'OPTIONS') {
|
||||
http_response_code(204);
|
||||
die();
|
||||
}
|
||||
|
||||
self::enforceRateLimit();
|
||||
|
||||
if ($requireAuth) {
|
||||
$authenticated = ApiAuth::authenticate();
|
||||
if (!$authenticated) {
|
||||
ApiResponse::unauthorized();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function setCorsHeaders(): void
|
||||
{
|
||||
$rawOrigin = trim((string) ($_SERVER['HTTP_ORIGIN'] ?? ''));
|
||||
if ($rawOrigin === '') {
|
||||
return;
|
||||
}
|
||||
$origin = self::normalizeOrigin($rawOrigin);
|
||||
if ($origin === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$allowedOrigins = array_fill_keys(self::settingsApiPolicy()->getApiCorsAllowedOrigins(), true);
|
||||
if (!isset($allowedOrigins[$origin])) {
|
||||
return;
|
||||
}
|
||||
|
||||
header('Vary: Origin');
|
||||
header('Access-Control-Allow-Origin: ' . $origin);
|
||||
header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
||||
header('Access-Control-Allow-Headers: Authorization, Content-Type, Accept');
|
||||
header('Access-Control-Max-Age: 86400');
|
||||
}
|
||||
|
||||
// Guarantees audit records are written even on uncaught fatal errors.
|
||||
private static function registerShutdownHandler(): void
|
||||
{
|
||||
if (self::$shutdownRegistered) {
|
||||
return;
|
||||
}
|
||||
self::$shutdownRegistered = true;
|
||||
|
||||
register_shutdown_function(static function (): void {
|
||||
$error = error_get_last();
|
||||
if (!is_array($error)) {
|
||||
$statusCode = (int) http_response_code();
|
||||
if ($statusCode <= 0) {
|
||||
$statusCode = 200;
|
||||
}
|
||||
self::tryFinishAudit($statusCode);
|
||||
self::finishSystemAuditReporter($statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
$type = (int) $error['type'];
|
||||
$fatalTypes = [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR];
|
||||
if (!in_array($type, $fatalTypes, true)) {
|
||||
$statusCode = (int) http_response_code();
|
||||
if ($statusCode <= 0) {
|
||||
$statusCode = 200;
|
||||
}
|
||||
self::tryFinishAudit($statusCode);
|
||||
self::finishSystemAuditReporter($statusCode);
|
||||
return;
|
||||
}
|
||||
|
||||
$statusCode = (int) http_response_code();
|
||||
if ($statusCode < 400) {
|
||||
$statusCode = 500;
|
||||
}
|
||||
self::tryFinishAudit($statusCode, 'fatal_error');
|
||||
self::finishSystemAuditReporter($statusCode, 'fatal_error');
|
||||
});
|
||||
}
|
||||
|
||||
private static function normalizeOrigin(string $origin): ?string
|
||||
{
|
||||
$origin = rtrim(trim($origin), '/');
|
||||
if ($origin === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$parsed = parse_url($origin);
|
||||
if (!is_array($parsed)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) ($parsed['scheme'] ?? ''));
|
||||
$host = strtolower((string) ($parsed['host'] ?? ''));
|
||||
$port = isset($parsed['port']) ? (int) $parsed['port'] : null;
|
||||
|
||||
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
isset($parsed['path'])
|
||||
|| isset($parsed['query'])
|
||||
|| isset($parsed['fragment'])
|
||||
|| isset($parsed['user'])
|
||||
|| isset($parsed['pass'])
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$defaultPort = ($scheme === 'https') ? 443 : 80;
|
||||
$portSuffix = ($port !== null && $port !== $defaultPort) ? ':' . $port : '';
|
||||
return $scheme . '://' . $host . $portSuffix;
|
||||
}
|
||||
|
||||
private static function enforceRateLimit(): void
|
||||
{
|
||||
$ip = trim((string) ($_SERVER['REMOTE_ADDR'] ?? ''));
|
||||
if ($ip === '') {
|
||||
$ip = 'unknown';
|
||||
}
|
||||
|
||||
$ipResult = self::rateLimiter()->hit(
|
||||
self::API_RATE_SCOPE_IP,
|
||||
$ip,
|
||||
self::API_RATE_LIMIT_IP,
|
||||
self::API_RATE_WINDOW_SECONDS,
|
||||
self::API_RATE_BLOCK_SECONDS
|
||||
);
|
||||
if (!($ipResult['allowed'] ?? true)) {
|
||||
ApiResponse::tooManyRequests(max(1, (int) ($ipResult['retry_after'] ?? self::API_RATE_BLOCK_SECONDS)));
|
||||
}
|
||||
|
||||
$selector = self::extractBearerSelector();
|
||||
if ($selector === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$tokenResult = self::rateLimiter()->hit(
|
||||
self::API_RATE_SCOPE_TOKEN,
|
||||
strtolower($selector) . '|' . $ip,
|
||||
self::API_RATE_LIMIT_TOKEN,
|
||||
self::API_RATE_WINDOW_SECONDS,
|
||||
self::API_RATE_BLOCK_SECONDS
|
||||
);
|
||||
|
||||
if (!($tokenResult['allowed'] ?? true)) {
|
||||
ApiResponse::tooManyRequests(max(1, (int) ($tokenResult['retry_after'] ?? self::API_RATE_BLOCK_SECONDS)));
|
||||
}
|
||||
}
|
||||
|
||||
// API tokens are formatted as "selector:verifier". The selector (24-char hex) is used
|
||||
// as the rate-limit key so a single token can't bypass the per-IP bucket by rotating IPs.
|
||||
private static function extractBearerSelector(): string
|
||||
{
|
||||
$token = ApiAuth::extractBearerToken();
|
||||
if ($token === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$parts = explode(':', $token, 2);
|
||||
$selector = trim((string) ($parts[0] ?? ''));
|
||||
if ($selector === '' || !preg_match('/^[a-f0-9]{24}$/i', $selector)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $selector;
|
||||
}
|
||||
|
||||
private static function settingsApiPolicy(): SettingsApiPolicyGateway
|
||||
{
|
||||
return self::resolveDependency(
|
||||
self::$settingsApiPolicyGatewayResolver,
|
||||
SettingsApiPolicyGateway::class,
|
||||
'ApiBootstrap'
|
||||
);
|
||||
}
|
||||
|
||||
private static function rateLimiter(): RateLimiterService
|
||||
{
|
||||
return self::resolveDependency(self::$rateLimiterServiceResolver, RateLimiterService::class, 'ApiBootstrap');
|
||||
}
|
||||
|
||||
private static function tryStartAudit(): void
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiAuditServiceResolver)) {
|
||||
$service = (self::$apiAuditServiceResolver)();
|
||||
if (is_object($service) && method_exists($service, 'startRequestContext')) {
|
||||
$service->startRequestContext();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
|
||||
private static function tryFinishAudit(int $statusCode, ?string $errorCode = null): void
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiAuditServiceResolver)) {
|
||||
$service = (self::$apiAuditServiceResolver)();
|
||||
if (is_object($service) && method_exists($service, 'finish')) {
|
||||
$service->finish($statusCode, $errorCode);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
|
||||
private static function startSystemAuditReporter(): void
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiSystemAuditReporterResolver)) {
|
||||
$service = (self::$apiSystemAuditReporterResolver)();
|
||||
if (is_object($service) && method_exists($service, 'start')) {
|
||||
$service->start();
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
|
||||
private static function finishSystemAuditReporter(int $statusCode, ?string $errorCode = null): void
|
||||
{
|
||||
try {
|
||||
if (is_callable(self::$apiSystemAuditReporterResolver)) {
|
||||
$service = (self::$apiSystemAuditReporterResolver)();
|
||||
if (is_object($service) && method_exists($service, 'finish')) {
|
||||
$service->finish($statusCode, $errorCode);
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// fail-open
|
||||
}
|
||||
}
|
||||
|
||||
private static function resolveDependency(mixed $resolver, string $expectedClass, string $consumer): mixed
|
||||
{
|
||||
if (!is_callable($resolver)) {
|
||||
throw new \RuntimeException($consumer . ' is not configured for dependency: ' . $expectedClass);
|
||||
}
|
||||
|
||||
$service = $resolver();
|
||||
if (!$service instanceof $expectedClass) {
|
||||
throw new \RuntimeException($consumer . ' resolver returned invalid dependency: ' . $expectedClass);
|
||||
}
|
||||
|
||||
return $service;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user