From 65c1b5fb75c285e0354a52d7384a3c25bbba6159 Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Mon, 27 Jul 2026 00:48:11 -0400 Subject: [PATCH] refactor(kernel): unify HTTP and CLI application lifecycle Signed-off-by: Sebastian Krupinski --- bin/console | 10 +- core/lib/Application.php | 154 +++++++++ .../Execution/ExecutionContext.php | 29 ++ .../Execution/ExecutionDescriptor.php | 35 ++ .../Execution/ExecutionOutcome.php | 30 ++ .../Application/Execution/ExecutionScope.php | 43 +++ .../lib/Application/Execution/RuntimeType.php | 11 + .../Execution/TerminationReport.php | 20 ++ core/lib/Application/KernelOptions.php | 18 + core/lib/Application/ProjectPaths.php | 53 +++ core/lib/Console/Event/EventsDebugCommand.php | 48 +++ core/lib/Context/IdentityContext.php | 101 ++++++ core/lib/Context/IdentityContextInterface.php | 23 ++ core/lib/Context/TenantContext.php | 117 +++++++ core/lib/Context/TenantContextInterface.php | 23 ++ core/lib/Controllers/DefaultController.php | 8 +- core/lib/Controllers/InitController.php | 26 +- .../Controllers/TenantSettingsController.php | 10 +- .../Controllers/UserAccountsController.php | 36 +- .../lib/Controllers/UserProfileController.php | 12 +- core/lib/Controllers/UserRolesController.php | 16 +- .../Controllers/UserSettingsController.php | 8 +- .../Middleware/AuthenticationMiddleware.php | 16 +- core/lib/Http/Middleware/RouterMiddleware.php | 6 +- core/lib/Http/Middleware/TenantMiddleware.php | 18 +- core/lib/Kernel.php | 264 ++++++++------- core/lib/KernelInterface.php | 31 ++ core/lib/Logger/LoggerFactory.php | 2 +- core/lib/Logger/TenantAwareLogger.php | 12 +- core/lib/Module/Module.php | 36 +- core/lib/Module/ModuleAutoloader.php | 12 +- core/lib/Module/ModuleManager.php | 3 +- core/lib/Runtime/Console/ConsoleRuntime.php | 97 ++++++ core/lib/Runtime/Http/HttpRuntime.php | 96 ++++++ core/lib/Security/AuthenticationManager.php | 20 +- .../Authorization/PermissionChecker.php | 8 +- core/lib/Server.php | 311 ------------------ core/lib/Service/ConfigurationService.php | 28 +- core/lib/Service/FirewallService.php | 74 ++--- core/lib/Service/SecurityService.php | 8 +- core/lib/Service/TokenService.php | 4 +- core/lib/Service/UserAccountsService.php | 32 +- core/lib/Service/UserRolesService.php | 24 +- core/lib/SessionIdentity.php | 94 ------ core/lib/SessionTenant.php | 145 -------- core/lib/index.php | 8 +- shared/lib/Cache/BlobCacheInterface.php | 4 + shared/lib/Cache/CacheInterface.php | 4 + .../Event/DeferredEventProcessorInterface.php | 14 + shared/lib/Event/DeferredProcessingResult.php | 16 + shared/lib/Event/DeliveryMode.php | 11 + shared/lib/Event/EventBus.php | 186 ----------- shared/lib/Event/EventDispatcher.php | 124 +++++++ shared/lib/Event/EventDispatcherInterface.php | 10 + shared/lib/Event/EventListenerDefinition.php | 22 ++ shared/lib/Event/EventListenerRegistry.php | 104 ++++++ shared/lib/Event/FailurePolicy.php | 11 + shared/lib/Security/Crypto.php | 6 +- tests/php/Unit/Application/KernelTest.php | 213 ++++++++++++ .../php/Unit/Application/ProjectPathsTest.php | 45 +++ .../Tenant/TenantCreateCommandTest.php | 17 +- .../Console/Tenant/TenantListCommandTest.php | 5 +- tests/php/Unit/Event/EventDispatcherTest.php | 268 +++++++++++++++ .../php/Unit/Execution/ExecutionScopeTest.php | 93 ++++++ tests/php/Unit/Module/CoreModuleTest.php | 46 +++ tests/php/Unit/Runtime/ConsoleRuntimeTest.php | 176 ++++++++++ tests/php/Unit/Runtime/HttpRuntimeTest.php | 252 ++++++++++++++ 67 files changed, 2737 insertions(+), 1070 deletions(-) create mode 100644 core/lib/Application.php create mode 100644 core/lib/Application/Execution/ExecutionContext.php create mode 100644 core/lib/Application/Execution/ExecutionDescriptor.php create mode 100644 core/lib/Application/Execution/ExecutionOutcome.php create mode 100644 core/lib/Application/Execution/ExecutionScope.php create mode 100644 core/lib/Application/Execution/RuntimeType.php create mode 100644 core/lib/Application/Execution/TerminationReport.php create mode 100644 core/lib/Application/KernelOptions.php create mode 100644 core/lib/Application/ProjectPaths.php create mode 100644 core/lib/Console/Event/EventsDebugCommand.php create mode 100644 core/lib/Context/IdentityContext.php create mode 100644 core/lib/Context/IdentityContextInterface.php create mode 100644 core/lib/Context/TenantContext.php create mode 100644 core/lib/Context/TenantContextInterface.php create mode 100644 core/lib/KernelInterface.php create mode 100644 core/lib/Runtime/Console/ConsoleRuntime.php create mode 100644 core/lib/Runtime/Http/HttpRuntime.php delete mode 100644 core/lib/Server.php delete mode 100644 core/lib/SessionIdentity.php delete mode 100644 core/lib/SessionTenant.php create mode 100644 shared/lib/Event/DeferredEventProcessorInterface.php create mode 100644 shared/lib/Event/DeferredProcessingResult.php create mode 100644 shared/lib/Event/DeliveryMode.php delete mode 100644 shared/lib/Event/EventBus.php create mode 100644 shared/lib/Event/EventDispatcher.php create mode 100644 shared/lib/Event/EventDispatcherInterface.php create mode 100644 shared/lib/Event/EventListenerDefinition.php create mode 100644 shared/lib/Event/EventListenerRegistry.php create mode 100644 shared/lib/Event/FailurePolicy.php create mode 100644 tests/php/Unit/Application/KernelTest.php create mode 100644 tests/php/Unit/Application/ProjectPathsTest.php create mode 100644 tests/php/Unit/Event/EventDispatcherTest.php create mode 100644 tests/php/Unit/Execution/ExecutionScopeTest.php create mode 100644 tests/php/Unit/Module/CoreModuleTest.php create mode 100644 tests/php/Unit/Runtime/ConsoleRuntimeTest.php create mode 100644 tests/php/Unit/Runtime/HttpRuntimeTest.php diff --git a/bin/console b/bin/console index a4b912e..6e058a3 100755 --- a/bin/console +++ b/bin/console @@ -7,21 +7,21 @@ declare(strict_types=1); -use KTXC\Server; +use KTXC\Application; if (!is_dir(dirname(__DIR__).'/vendor')) { fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n"); exit(1); } -require_once dirname(__DIR__).'/vendor/autoload.php'; +$composerLoader = require_once dirname(__DIR__).'/vendor/autoload.php'; try { - $server = new Server(dirname(__DIR__)); - exit($server->runConsole()); + $application = Application::create(dirname(__DIR__), $composerLoader); + exit($application->runConsole()); } catch (\Throwable $e) { fwrite(STDERR, "Fatal error: {$e->getMessage()}\n"); - if (isset($server) && $server->debug()) { + if (($application ?? null)?->debug()) { fwrite(STDERR, $e->getTraceAsString()."\n"); } exit(1); diff --git a/core/lib/Application.php b/core/lib/Application.php new file mode 100644 index 0000000..d6b6d8a --- /dev/null +++ b/core/lib/Application.php @@ -0,0 +1,154 @@ +paths = ProjectPaths::resolve($projectDir); + $this->config = $this->loadConfig(); + + $environment ??= $this->config['environment'] ?? 'prod'; + $debug ??= (bool) ($this->config['debug'] ?? false); + + $this->kernel = new Kernel( + new KernelOptions( + $this->paths, + $environment, + $debug, + ), + $this->config, + ); + + (new ModuleAutoloader( + $this->moduleDir(), + $composerLoader, + ))->register(); + + $this->http = new HttpRuntime($this->kernel, $debug); + $this->console = new ConsoleRuntime($this->kernel); + } + + public static function create( + string $projectDir, + ?ClassLoader $composerLoader = null, + ?string $environment = null, + ?bool $debug = null, + ): self { + return new self($projectDir, $composerLoader, $environment, $debug); + } + + public function runHttp(): void + { + try { + $this->http->run(); + } finally { + $this->kernel->shutdown(); + } + } + + public function handleHttp(Request $request): Response + { + return $this->http->handle($request); + } + + public function runConsole(): int + { + try { + return $this->console->run(); + } finally { + $this->kernel->shutdown(); + } + } + + public function shutdown(): void + { + $this->kernel->shutdown(); + } + + public function kernel(): KernelInterface + { + return $this->kernel; + } + + public function container(): ContainerInterface + { + return $this->kernel->container(); + } + + public function environment(): string + { + return $this->kernel->environment(); + } + + public function debug(): bool + { + return $this->kernel->debug(); + } + + public function projectDir(): string + { + return $this->paths->project; + } + + public function moduleDir(): string + { + return $this->paths->modules(); + } + + public function config(?string $key = null, mixed $default = null): mixed + { + if ($key === null) { + return $this->config; + } + + $value = $this->config; + foreach (explode('.', $key) as $part) { + if (!is_array($value) || !array_key_exists($part, $value)) { + return $default; + } + $value = $value[$part]; + } + + return $value; + } + + private function loadConfig(): array + { + $path = $this->paths->configuration() . '/system.php'; + if (!is_file($path)) { + return []; + } + + $config = require $path; + if (!is_array($config)) { + throw new \RuntimeException("Configuration file must return an array: {$path}"); + } + + return $config; + } + +} diff --git a/core/lib/Application/Execution/ExecutionContext.php b/core/lib/Application/Execution/ExecutionContext.php new file mode 100644 index 0000000..fe037d2 --- /dev/null +++ b/core/lib/Application/Execution/ExecutionContext.php @@ -0,0 +1,29 @@ +runtime, + $descriptor->executionId, + $descriptor->correlationId, + operationName: $descriptor->operationName, + ); + } +} diff --git a/core/lib/Application/Execution/ExecutionDescriptor.php b/core/lib/Application/Execution/ExecutionDescriptor.php new file mode 100644 index 0000000..b3ac857 --- /dev/null +++ b/core/lib/Application/Execution/ExecutionDescriptor.php @@ -0,0 +1,35 @@ +tenantContext->clear(); + $this->identityContext->clear(); + } + + public function markTerminated(): void + { + if ($this->terminated) { + throw new \LogicException('The execution scope has already been terminated.'); + } + + $this->terminated = true; + } + + public function terminated(): bool + { + return $this->terminated; + } + + public function dispose(): void + { + $this->identityContext->clear(); + $this->tenantContext->clear(); + } +} diff --git a/core/lib/Application/Execution/RuntimeType.php b/core/lib/Application/Execution/RuntimeType.php new file mode 100644 index 0000000..5687d10 --- /dev/null +++ b/core/lib/Application/Execution/RuntimeType.php @@ -0,0 +1,11 @@ + $failures + */ + public function __construct( + public int $deferredProcessed = 0, + public int $deferredRemaining = 0, + public array $failures = [], + public bool $deadlineExceeded = false, + public bool $limitExceeded = false, + ) { + } +} diff --git a/core/lib/Application/KernelOptions.php b/core/lib/Application/KernelOptions.php new file mode 100644 index 0000000..023eb28 --- /dev/null +++ b/core/lib/Application/KernelOptions.php @@ -0,0 +1,18 @@ +environment === '') { + throw new \InvalidArgumentException('Kernel environment cannot be empty.'); + } + } +} diff --git a/core/lib/Application/ProjectPaths.php b/core/lib/Application/ProjectPaths.php new file mode 100644 index 0000000..fe18d1a --- /dev/null +++ b/core/lib/Application/ProjectPaths.php @@ -0,0 +1,53 @@ +project . '/config'; + } + + public function modules(): string + { + return $this->project . '/modules'; + } + + public function cache(string $environment): string + { + return $this->project . '/var/cache/' . $environment; + } + + public function logs(): string + { + return $this->project . '/var/log'; + } + + public function runtime(): string + { + return $this->project . '/var'; + } +} diff --git a/core/lib/Console/Event/EventsDebugCommand.php b/core/lib/Console/Event/EventsDebugCommand.php new file mode 100644 index 0000000..1bda498 --- /dev/null +++ b/core/lib/Console/Event/EventsDebugCommand.php @@ -0,0 +1,48 @@ +registry->definitions() as $listener) { + $rows[] = [ + $listener->module, + $listener->event, + $listener->service . '::' . $listener->method, + $listener->delivery->value, + $listener->priority, + $listener->failurePolicy->value, + ]; + } + + $io->table( + ['Module', 'Event', 'Listener', 'Delivery', 'Priority', 'Failure'], + $rows, + ); + + return Command::SUCCESS; + } +} diff --git a/core/lib/Context/IdentityContext.php b/core/lib/Context/IdentityContext.php new file mode 100644 index 0000000..97f6442 --- /dev/null +++ b/core/lib/Context/IdentityContext.php @@ -0,0 +1,101 @@ +identity !== null) { + throw new \LogicException('The execution identity has already been initialized.'); + } + + $this->identity = $identity; + } + + public function clear(): void + { + $this->identity = null; + } + + public function present(): bool + { + return $this->identity !== null; + } + + public function identity(): ?User + { + return $this->identity; + } + + public function identifier(): ?string + { + return $this->identity?->getId(); + } + + public function requireIdentifier(): string + { + return $this->identifier() + ?? throw new \LogicException('This operation requires an identity context.'); + } + + public function label(): ?string + { + return $this->identity?->getLabel(); + } + + public function mailAddress(): ?string + { + return $this->identity?->getIdentity(); + } + + public function nameFirst(): ?string + { + return null; + } + + public function nameLast(): ?string + { + return null; + } + + public function permissions(): array + { + return $this->identity?->getPermissions() ?? []; + } + + public function roles(): array + { + return $this->identity?->getRoles() ?? []; + } + + public function hasPermission(string $permission): bool + { + $permissions = $this->permissions(); + if (in_array($permission, $permissions, true) || in_array('*', $permissions, true)) { + return true; + } + + foreach ($permissions as $userPermission) { + if (str_ends_with($userPermission, '.*')) { + $prefix = substr($userPermission, 0, -2); + if (str_starts_with($permission, $prefix . '.')) { + return true; + } + } + } + + return false; + } + + public function hasRole(string $role): bool + { + return in_array($role, $this->roles(), true); + } +} diff --git a/core/lib/Context/IdentityContextInterface.php b/core/lib/Context/IdentityContextInterface.php new file mode 100644 index 0000000..602b2e6 --- /dev/null +++ b/core/lib/Context/IdentityContextInterface.php @@ -0,0 +1,23 @@ +clear(); + $tenant = $this->tenantService->fetchByDomain($domain); + if ($tenant === null) { + return false; + } + + $this->domain = $domain; + $this->tenant = $tenant; + + return true; + } + + public function resolveIdentifier(string $identifier): bool + { + $this->clear(); + $tenant = $this->tenantService->fetchById($identifier); + if ($tenant === null) { + return false; + } + + $this->domain = $identifier; + $this->tenant = $tenant; + + return true; + } + + public function clear(): void + { + $this->tenant = null; + $this->domain = null; + } + + public function present(): bool + { + return $this->tenant !== null; + } + + public function configured(): bool + { + return $this->present(); + } + + public function enabled(): bool + { + return $this->tenant?->getEnabled() ?? false; + } + + public function domain(): ?string + { + return $this->domain; + } + + public function identifier(): ?string + { + return $this->tenant?->getIdentifier(); + } + + public function requireIdentifier(): string + { + return $this->identifier() + ?? throw new \LogicException('This operation requires a tenant context.'); + } + + public function label(): ?string + { + return $this->tenant?->getLabel(); + } + + public function configuration(): ?TenantConfiguration + { + return $this->tenant?->getConfiguration(); + } + + public function settings(): array + { + return $this->tenant?->getSettings() ?? []; + } + + public function identityProviders(): array + { + return $this->tenant?->getConfiguration()['identity']['providers'] ?? []; + } + + public function identityProviderConfig(string $providerId): ?array + { + return $this->identityProviders()[$providerId] ?? null; + } + + public function isIdentityProviderEnabled(string $providerId): bool + { + $config = $this->identityProviderConfig($providerId); + + return $config !== null && ($config['enabled'] ?? false); + } +} diff --git a/core/lib/Context/TenantContextInterface.php b/core/lib/Context/TenantContextInterface.php new file mode 100644 index 0000000..29a914a --- /dev/null +++ b/core/lib/Context/TenantContextInterface.php @@ -0,0 +1,23 @@ +identity->identifier()) { + if ($this->identityContext->identifier()) { return new FileResponse( $this->rootDir . '/public/private.html', Response::HTTP_OK, @@ -116,7 +116,7 @@ class DefaultController extends ControllerAbstract public function catchAll(Request $request, string $path = ''): Response { // If an authenticated identity is available, serve the private app - if ($this->identity->identifier()) { + if ($this->identityContext->identifier()) { return new FileResponse( $this->rootDir . '/public/private.html', Response::HTTP_OK, diff --git a/core/lib/Controllers/InitController.php b/core/lib/Controllers/InitController.php index 6de2b1f..bff27b0 100644 --- a/core/lib/Controllers/InitController.php +++ b/core/lib/Controllers/InitController.php @@ -8,16 +8,16 @@ use KTXC\L10N\LocaleResolver; use KTXC\Module\ModuleManager; use KTXC\Security\Authorization\PermissionChecker; use KTXC\Service\UserAccountsService; -use KTXC\SessionIdentity; +use KTXC\Context\IdentityContextInterface; use KTXF\Controller\ControllerAbstract; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; use KTXF\Routing\Attributes\AuthenticatedRoute; class InitController extends ControllerAbstract { public function __construct( - private readonly SessionTenant $tenant, - private readonly SessionIdentity $userIdentity, + private readonly TenantContextInterface $tenantContext, + private readonly IdentityContextInterface $identityContext, private readonly ModuleManager $moduleManager, private readonly UserAccountsService $userService, private readonly PermissionChecker $permissionChecker, @@ -54,21 +54,21 @@ class InitController extends ControllerAbstract // tenant $configuration['tenant'] = [ - 'id' => $this->tenant->identifier(), - 'domain' => $this->tenant->domain(), - 'label' => $this->tenant->label(), + 'id' => $this->tenantContext->identifier(), + 'domain' => $this->tenantContext->domain(), + 'label' => $this->tenantContext->label(), ]; // user $configuration['user'] = [ 'auth' => [ - 'identifier' => $this->userIdentity->identifier(), - 'identity' => $this->userIdentity->identity()->getIdentity(), - 'label' => $this->userIdentity->label(), - 'roles' => $this->userIdentity->identity()->getRoles(), - 'permissions' => $this->userIdentity->identity()->getPermissions(), + 'identifier' => $this->identityContext->identifier(), + 'identity' => $this->identityContext->identity()->getIdentity(), + 'label' => $this->identityContext->label(), + 'roles' => $this->identityContext->identity()->getRoles(), + 'permissions' => $this->identityContext->identity()->getPermissions(), ], - 'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()), + 'profile' => $this->userService->getEditableFields($this->identityContext->identifier()), 'settings' => $this->userService->fetchSettings([], true), ]; diff --git a/core/lib/Controllers/TenantSettingsController.php b/core/lib/Controllers/TenantSettingsController.php index dd8101b..3eecb95 100644 --- a/core/lib/Controllers/TenantSettingsController.php +++ b/core/lib/Controllers/TenantSettingsController.php @@ -4,7 +4,7 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; use KTXC\Service\TenantService; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; use KTXF\Controller\ControllerAbstract; use KTXF\Routing\Attributes\AuthenticatedRoute; @@ -19,7 +19,7 @@ use KTXF\Routing\Attributes\AuthenticatedRoute; class TenantSettingsController extends ControllerAbstract { public function __construct( - private readonly SessionTenant $tenantIdentity, + private readonly TenantContextInterface $tenantContext, private readonly TenantService $tenantService, ) {} @@ -36,7 +36,7 @@ class TenantSettingsController extends ControllerAbstract )] public function read(): JsonResponse { - $settings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier()); + $settings = $this->tenantService->fetchSettings($this->tenantContext->identifier()); return new JsonResponse($settings, JsonResponse::HTTP_OK); } @@ -65,9 +65,9 @@ class TenantSettingsController extends ControllerAbstract )] public function update(array $data): JsonResponse { - $this->tenantService->storeSettings($this->tenantIdentity->identifier(), $data); + $this->tenantService->storeSettings($this->tenantContext->identifier(), $data); - $updatedSettings = $this->tenantService->fetchSettings($this->tenantIdentity->identifier(), array_keys($data)); + $updatedSettings = $this->tenantService->fetchSettings($this->tenantContext->identifier(), array_keys($data)); return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK); } diff --git a/core/lib/Controllers/UserAccountsController.php b/core/lib/Controllers/UserAccountsController.php index 57bac4e..585d402 100644 --- a/core/lib/Controllers/UserAccountsController.php +++ b/core/lib/Controllers/UserAccountsController.php @@ -4,8 +4,8 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; use KTXC\Service\UserAccountsService; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; +use KTXC\Context\IdentityContextInterface; +use KTXC\Context\TenantContextInterface; use KTXF\Controller\ControllerAbstract; use KTXF\Routing\Attributes\AuthenticatedRoute; use Psr\Log\LoggerInterface; @@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface; class UserAccountsController extends ControllerAbstract { public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, + private readonly TenantContextInterface $tenantContext, + private readonly IdentityContextInterface $identityContext, private readonly UserAccountsService $userService, private readonly LoggerInterface $logger ) {} @@ -31,7 +31,7 @@ class UserAccountsController extends ControllerAbstract { try { // Check admin permission - if (!$this->userIdentity->hasPermission('user.admin')) { + if (!$this->identityContext->hasPermission('user.admin')) { return new JsonResponse([ 'status' => 'error', 'data' => ['code' => 403, 'message' => 'Insufficient permissions'] @@ -125,7 +125,7 @@ class UserAccountsController extends ControllerAbstract */ private function userCreate(array $data): array { - if (!$this->userIdentity->hasPermission('user.create')) { + if (!$this->identityContext->hasPermission('user.create')) { throw new \InvalidArgumentException('Insufficient permissions to create users'); } @@ -142,9 +142,9 @@ class UserAccountsController extends ControllerAbstract ]; $this->logger->info('Creating user', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'identity' => $userData['identity'], - 'actor' => $this->userIdentity->identifier() + 'actor' => $this->identityContext->identifier() ]); return $this->userService->createUser($userData); @@ -155,7 +155,7 @@ class UserAccountsController extends ControllerAbstract */ private function userUpdate(array $data): bool { - if (!$this->userIdentity->hasPermission('user.update')) { + if (!$this->identityContext->hasPermission('user.update')) { throw new \InvalidArgumentException('Insufficient permissions to update users'); } @@ -186,9 +186,9 @@ class UserAccountsController extends ControllerAbstract } $this->logger->info('Updating user', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'uid' => $uid, - 'actor' => $this->userIdentity->identifier() + 'actor' => $this->identityContext->identifier() ]); return $this->userService->updateUser($uid, $updates); @@ -199,21 +199,21 @@ class UserAccountsController extends ControllerAbstract */ private function userDelete(array $data): bool { - if (!$this->userIdentity->hasPermission('user.delete')) { + if (!$this->identityContext->hasPermission('user.delete')) { throw new \InvalidArgumentException('Insufficient permissions to delete users'); } $uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required'); // Prevent self-deletion - if ($uid === $this->userIdentity->identifier()) { + if ($uid === $this->identityContext->identifier()) { throw new \InvalidArgumentException('Cannot delete your own account'); } $this->logger->info('Deleting user', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'uid' => $uid, - 'actor' => $this->userIdentity->identifier() + 'actor' => $this->identityContext->identifier() ]); return $this->userService->deleteUser($uid); @@ -228,7 +228,7 @@ class UserAccountsController extends ControllerAbstract */ private function userProviderUnlink(array $data): bool { - if (!$this->userIdentity->hasPermission('user.admin')) { + if (!$this->identityContext->hasPermission('user.admin')) { throw new \InvalidArgumentException('Insufficient permissions'); } @@ -241,9 +241,9 @@ class UserAccountsController extends ControllerAbstract ]; $this->logger->info('Unlinking provider', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'uid' => $uid, - 'actor' => $this->userIdentity->identifier() + 'actor' => $this->identityContext->identifier() ]); return $this->userService->updateUser($uid, $updates); diff --git a/core/lib/Controllers/UserProfileController.php b/core/lib/Controllers/UserProfileController.php index fbeedad..88af7f3 100644 --- a/core/lib/Controllers/UserProfileController.php +++ b/core/lib/Controllers/UserProfileController.php @@ -4,16 +4,16 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; use KTXC\Service\UserAccountsService; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; +use KTXC\Context\IdentityContextInterface; +use KTXC\Context\TenantContextInterface; use KTXF\Controller\ControllerAbstract; use KTXF\Routing\Attributes\AuthenticatedRoute; class UserProfileController extends ControllerAbstract { public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, + private readonly TenantContextInterface $tenantContext, + private readonly IdentityContextInterface $identityContext, private readonly UserAccountsService $userService ) {} @@ -30,7 +30,7 @@ class UserProfileController extends ControllerAbstract )] public function read(): JsonResponse { - $userId = $this->userIdentity->identifier(); + $userId = $this->identityContext->identifier(); // Get profile with editability metadata $profile = $this->userService->getEditableFields($userId); @@ -63,7 +63,7 @@ class UserProfileController extends ControllerAbstract )] public function update(array $data): JsonResponse { - $userId = $this->userIdentity->identifier(); + $userId = $this->identityContext->identifier(); // storeProfile automatically filters out provider-managed fields $this->userService->storeProfile($userId, $data); diff --git a/core/lib/Controllers/UserRolesController.php b/core/lib/Controllers/UserRolesController.php index 630c58c..3349373 100644 --- a/core/lib/Controllers/UserRolesController.php +++ b/core/lib/Controllers/UserRolesController.php @@ -3,8 +3,8 @@ namespace KTXC\Controllers; use KTXC\Http\Response\JsonResponse; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; +use KTXC\Context\IdentityContextInterface; +use KTXC\Context\TenantContextInterface; use KTXC\Service\UserRolesService; use KTXF\Controller\ControllerAbstract; use KTXF\Routing\Attributes\AuthenticatedRoute; @@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface; class UserRolesController extends ControllerAbstract { public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, + private readonly TenantContextInterface $tenantContext, + private readonly IdentityContextInterface $identityContext, private readonly UserRolesService $roleService, private readonly LoggerInterface $logger ) {} @@ -31,7 +31,7 @@ class UserRolesController extends ControllerAbstract { try { // Check role admin permission - if (!$this->userIdentity->hasPermission('role.admin')) { + if (!$this->identityContext->hasPermission('role.admin')) { return new JsonResponse([ 'status' => 'error', 'data' => ['code' => 403, 'message' => 'Insufficient permissions'] @@ -137,7 +137,7 @@ class UserRolesController extends ControllerAbstract */ private function roleCreate(array $data): array { - if (!$this->userIdentity->hasPermission('role.manage')) { + if (!$this->identityContext->hasPermission('role.manage')) { throw new \InvalidArgumentException('Insufficient permissions to create roles'); } @@ -155,7 +155,7 @@ class UserRolesController extends ControllerAbstract */ private function roleUpdate(array $data): bool { - if (!$this->userIdentity->hasPermission('role.manage')) { + if (!$this->identityContext->hasPermission('role.manage')) { throw new \InvalidArgumentException('Insufficient permissions to update roles'); } @@ -182,7 +182,7 @@ class UserRolesController extends ControllerAbstract */ private function roleDelete(array $data): bool { - if (!$this->userIdentity->hasPermission('role.manage')) { + if (!$this->identityContext->hasPermission('role.manage')) { throw new \InvalidArgumentException('Insufficient permissions to delete roles'); } diff --git a/core/lib/Controllers/UserSettingsController.php b/core/lib/Controllers/UserSettingsController.php index b71aa83..4d27ecc 100644 --- a/core/lib/Controllers/UserSettingsController.php +++ b/core/lib/Controllers/UserSettingsController.php @@ -5,16 +5,16 @@ namespace KTXC\Controllers; use KTXC\Http\Request\Request; use KTXC\Http\Response\JsonResponse; use KTXC\Service\UserAccountsService; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; +use KTXC\Context\IdentityContextInterface; +use KTXC\Context\TenantContextInterface; use KTXF\Controller\ControllerAbstract; use KTXF\Routing\Attributes\AuthenticatedRoute; class UserSettingsController extends ControllerAbstract { public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, + private readonly TenantContextInterface $tenantContext, + private readonly IdentityContextInterface $identityContext, private readonly UserAccountsService $userService ) {} diff --git a/core/lib/Http/Middleware/AuthenticationMiddleware.php b/core/lib/Http/Middleware/AuthenticationMiddleware.php index 73a72ab..e106e99 100644 --- a/core/lib/Http/Middleware/AuthenticationMiddleware.php +++ b/core/lib/Http/Middleware/AuthenticationMiddleware.php @@ -5,7 +5,10 @@ namespace KTXC\Http\Middleware; use KTXC\Http\Request\Request; use KTXC\Http\Response\Response; use KTXC\Service\SecurityService; -use KTXC\SessionIdentity; +use KTXC\Context\IdentityContext; +use KTXF\Cache\BlobCacheInterface; +use KTXF\Cache\EphemeralCacheInterface; +use KTXF\Cache\PersistentCacheInterface; /** * Authentication middleware @@ -19,7 +22,10 @@ class AuthenticationMiddleware implements MiddlewareInterface { public function __construct( private readonly SecurityService $securityService, - private readonly SessionIdentity $sessionIdentity + private readonly IdentityContext $identityContext, + private readonly EphemeralCacheInterface $ephemeralCache, + private readonly PersistentCacheInterface $persistentCache, + private readonly BlobCacheInterface $blobCache, ) {} public function process(Request $request, RequestHandlerInterface $handler): Response @@ -29,7 +35,11 @@ class AuthenticationMiddleware implements MiddlewareInterface // Initialize session identity if authentication succeeded if ($identity) { - $this->sessionIdentity->initialize($identity, true); + $this->identityContext->initialize($identity); + $identityId = $this->identityContext->identifier(); + $this->ephemeralCache->setUserContext($identityId); + $this->persistentCache->setUserContext($identityId); + $this->blobCache->setUserContext($identityId); } // Continue to next middleware (authentication is optional at this stage) diff --git a/core/lib/Http/Middleware/RouterMiddleware.php b/core/lib/Http/Middleware/RouterMiddleware.php index 410cb34..8bedfa7 100644 --- a/core/lib/Http/Middleware/RouterMiddleware.php +++ b/core/lib/Http/Middleware/RouterMiddleware.php @@ -6,7 +6,7 @@ use KTXC\Http\Request\Request; use KTXC\Http\Response\Response; use KTXC\Routing\Router; use KTXC\Routing\Route; -use KTXC\SessionIdentity; +use KTXC\Context\IdentityContextInterface; use KTXC\Security\Authorization\PermissionChecker; /** @@ -17,7 +17,7 @@ class RouterMiddleware implements MiddlewareInterface { public function __construct( private readonly Router $router, - private readonly SessionIdentity $sessionIdentity, + private readonly IdentityContextInterface $identityContext, private readonly PermissionChecker $permissionChecker ) {} @@ -32,7 +32,7 @@ class RouterMiddleware implements MiddlewareInterface } // Check if route requires authentication - if ($match->authenticated && $this->sessionIdentity->identity() === null) { + if ($match->authenticated && $this->identityContext->identity() === null) { return new Response( Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::HTTP_UNAUTHORIZED diff --git a/core/lib/Http/Middleware/TenantMiddleware.php b/core/lib/Http/Middleware/TenantMiddleware.php index 8f9be5d..728acd5 100644 --- a/core/lib/Http/Middleware/TenantMiddleware.php +++ b/core/lib/Http/Middleware/TenantMiddleware.php @@ -4,7 +4,10 @@ namespace KTXC\Http\Middleware; use KTXC\Http\Request\Request; use KTXC\Http\Response\Response; -use KTXC\SessionTenant; +use KTXC\Context\TenantContext; +use KTXF\Cache\BlobCacheInterface; +use KTXF\Cache\EphemeralCacheInterface; +use KTXF\Cache\PersistentCacheInterface; /** * Tenant resolution middleware @@ -13,16 +16,23 @@ use KTXC\SessionTenant; class TenantMiddleware implements MiddlewareInterface { public function __construct( - private readonly SessionTenant $sessionTenant + private readonly TenantContext $tenantContext, + private readonly EphemeralCacheInterface $ephemeralCache, + private readonly PersistentCacheInterface $persistentCache, + private readonly BlobCacheInterface $blobCache, ) {} public function process(Request $request, RequestHandlerInterface $handler): Response { // Configure tenant from request host - $this->sessionTenant->configure($request->getHost()); + $this->tenantContext->resolveDomain($request->getHost()); + $tenantId = $this->tenantContext->identifier(); + $this->ephemeralCache->setTenantContext($tenantId); + $this->persistentCache->setTenantContext($tenantId); + $this->blobCache->setTenantContext($tenantId); // Check if tenant is configured and enabled - if (!$this->sessionTenant->configured() || !$this->sessionTenant->enabled()) { + if (!$this->tenantContext->configured() || !$this->tenantContext->enabled()) { return new Response( Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::HTTP_UNAUTHORIZED diff --git a/core/lib/Kernel.php b/core/lib/Kernel.php index c50f8f7..11b265f 100644 --- a/core/lib/Kernel.php +++ b/core/lib/Kernel.php @@ -9,13 +9,15 @@ namespace KTXC; -use KTXC\Http\Request\Request; -use KTXC\Http\Response\Response; -use KTXC\Http\Middleware\MiddlewarePipeline; -use KTXC\Http\Middleware\TenantMiddleware; -use KTXC\Http\Middleware\FirewallMiddleware; -use KTXC\Http\Middleware\AuthenticationMiddleware; -use KTXC\Http\Middleware\RouterMiddleware; +use KTXC\Application\KernelOptions; +use KTXC\Application\Execution\ExecutionDescriptor; +use KTXC\Application\Execution\ExecutionOutcome; +use KTXC\Application\Execution\ExecutionScope; +use KTXC\Application\Execution\TerminationReport; +use KTXC\Context\IdentityContext; +use KTXC\Context\IdentityContextInterface; +use KTXC\Context\TenantContext; +use KTXC\Context\TenantContextInterface; use KTXC\Injection\Builder; use KTXC\Injection\Container; use Psr\Container\ContainerInterface; @@ -23,7 +25,10 @@ use KTXC\Module\ModuleManager; use Psr\Log\LoggerInterface; use KTXC\Logger\LoggerFactory; use KTXC\Logger\TenantAwareLogger; -use KTXF\Event\EventBus; +use KTXF\Event\DeferredEventProcessorInterface; +use KTXF\Event\EventDispatcher; +use KTXF\Event\EventDispatcherInterface; +use KTXF\Event\EventListenerRegistry; use KTXF\Cache\EphemeralCacheInterface; use KTXF\Cache\PersistentCacheInterface; use KTXF\Cache\BlobCacheInterface; @@ -31,7 +36,7 @@ use KTXF\Cache\Store\FileEphemeralCache; use KTXF\Cache\Store\FilePersistentCache; use KTXF\Cache\Store\FileBlobCache; -class Kernel +class Kernel implements KernelInterface { public const VERSION = '1.0.0'; public const VERSION_ID = 10000; @@ -45,26 +50,16 @@ class Kernel protected ?float $startTime = null; protected ?ContainerInterface $container = null; protected ?LoggerInterface $logger = null; - protected ?MiddlewarePipeline $pipeline = null; + private bool $errorHandlerInstalled = false; + private ?ExecutionScope $activeScope = null; - private string $projectDir; private array $config; public function __construct( - protected string $environment = 'prod', - protected bool $debug = false, + private readonly KernelOptions $options, array $config = [], - ?string $projectDir = null, ) { - if (!$environment) { - throw new \InvalidArgumentException(\sprintf('Invalid environment provided to "%s": the environment cannot be empty.', get_debug_type($this))); - } - $this->config = $config; - - if ($projectDir !== null) { - $this->projectDir = $projectDir; - } } public function __clone() @@ -77,10 +72,10 @@ class Kernel private function initialize(): void { - if ($this->debug) { + if ($this->debug()) { $this->startTime = microtime(true); } - if ($this->debug && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) { + if ($this->debug() && !isset($_ENV['SHELL_VERBOSITY']) && !isset($_SERVER['SHELL_VERBOSITY'])) { if (\function_exists('putenv')) { putenv('SHELL_VERBOSITY=3'); } @@ -129,23 +124,7 @@ class Kernel return true; }); - // Handle uncaught exceptions - set_exception_handler(function (\Throwable $exception) { - $this->logger->error('Exception caught: ' . $exception->getMessage(), [ - 'exception' => $exception, - 'file' => $exception->getFile(), - 'line' => $exception->getLine(), - 'trace' => $exception->getTraceAsString(), - ]); - - if ($this->debug) { - echo '
Uncaught Exception: ' . $exception . '
'; - } else { - echo 'An unexpected error occurred. Please try again later.'; - } - - exit(1); - }); + $this->errorHandlerInstalled = true; // Handle fatal errors register_shutdown_function(function () { @@ -161,11 +140,6 @@ class Kernel $this->logger->error($message, $error); - if ($this->debug) { - echo '
' . $message . '
'; - } else { - echo 'A fatal error occurred. Please try again later.'; - } } }); } @@ -180,9 +154,9 @@ class Kernel /** @var ModuleManager $moduleManager */ $moduleManager = $this->container->get(ModuleManager::class); $moduleManager->modulesBoot(); - - // Build middleware pipeline - $this->pipeline = $this->buildMiddlewarePipeline(); + $this->container + ->get(EventListenerRegistry::class) + ->freeze($this->container); $this->booted = true; } @@ -199,52 +173,108 @@ class Kernel if (false === $this->initialized) { return; } + if ($this->activeScope !== null && !$this->activeScope->terminated()) { + throw new \LogicException('Cannot shut down the kernel while an execution scope is active.'); + } $this->initialized = false; $this->booted = false; $this->container = null; + if ($this->errorHandlerInstalled) { + restore_error_handler(); + $this->errorHandlerInstalled = false; + } } - public function handle(Request $request): Response + public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope { if (!$this->booted) { $this->boot(); } + if ($this->activeScope !== null) { + throw new \LogicException('The kernel already has an active execution scope.'); + } - // Use middleware pipeline to handle the request - return $this->pipeline->handle($request); + $scope = new ExecutionScope( + $descriptor, + \KTXC\Application\Execution\ExecutionContext::fromDescriptor($descriptor), + $this->container->get(TenantContext::class), + $this->container->get(IdentityContext::class), + ); + $this->container + ->get(DeferredEventProcessorInterface::class) + ->beginExecution($descriptor->executionId); + $this->activeScope = $scope; + + return $scope; } - /** - * Build the middleware pipeline - */ - protected function buildMiddlewarePipeline(): MiddlewarePipeline + public function terminateExecution( + ExecutionScope $scope, + ExecutionOutcome $outcome, + ): TerminationReport { - $pipeline = new MiddlewarePipeline($this->container); - - // Register middleware in execution order - $pipeline->pipe(TenantMiddleware::class); - $pipeline->pipe(FirewallMiddleware::class); - $pipeline->pipe(AuthenticationMiddleware::class); - $pipeline->pipe(RouterMiddleware::class); - - return $pipeline; - } + if ($scope->terminated()) { + return new TerminationReport(); + } + if ($this->activeScope !== $scope) { + throw new \LogicException('Cannot terminate a scope that is not active.'); + } + + $processed = 0; + $remaining = 0; + $deadlineExceeded = false; + $limitExceeded = false; + $failures = []; - /** - * Process deferred events at the end of the request - */ - public function processEvents(): void - { try { - if ($this->container && $this->container->has(EventBus::class)) { - /** @var EventBus $eventBus */ - $eventBus = $this->container->get(EventBus::class); - $eventBus->processDeferred(); + if ($this->container && $this->container->has(DeferredEventProcessorInterface::class)) { + $result = $this->container + ->get(DeferredEventProcessorInterface::class) + ->processDeferred($scope->descriptor->executionId); + $processed = $result->processed; + $remaining = $result->remaining; + $deadlineExceeded = $result->deadlineExceeded; + $limitExceeded = $result->limitExceeded; } } catch (\Throwable $e) { - error_log('Event processing error: ' . $e->getMessage()); + $failures[] = $e; + try { + $this->container + ?->get(DeferredEventProcessorInterface::class) + ->discardDeferred($scope->descriptor->executionId); + } catch (\Throwable $discardError) { + $failures[] = $discardError; + } + $this->logger?->error('Deferred event processing failed.', [ + 'exception' => $e, + 'execution_id' => $scope->descriptor->executionId, + 'runtime' => $scope->descriptor->runtime->value, + ]); + } finally { + if ($this->container !== null) { + foreach ([ + EphemeralCacheInterface::class, + PersistentCacheInterface::class, + BlobCacheInterface::class, + ] as $cacheType) { + $cache = $this->container->get($cacheType); + $cache->setUserContext(null); + $cache->setTenantContext(null); + } + } + $scope->dispose(); + $scope->markTerminated(); + $this->activeScope = null; } + + return new TerminationReport( + deferredProcessed: $processed, + deferredRemaining: $remaining, + failures: $failures, + deadlineExceeded: $deadlineExceeded, + limitExceeded: $limitExceeded, + ); } /** @@ -256,13 +286,13 @@ class Kernel { return [ 'kernel.project_dir' => realpath($this->folderRoot()) ?: $this->folderRoot(), - 'kernel.environment' => $this->environment, + 'kernel.environment' => $this->environment(), 'kernel.runtime_environment' => '%env(default:kernel.environment:APP_RUNTIME_ENV)%', 'kernel.runtime_mode' => '%env(query_string:default:container.runtime_mode:APP_RUNTIME_MODE)%', 'kernel.runtime_mode.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%', 'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%', 'kernel.runtime_mode.worker' => '%env(bool:default::key:worker:default:kernel.runtime_mode:)%', - 'kernel.debug' => $this->debug, + 'kernel.debug' => $this->debug(), 'kernel.build_dir' => realpath($this->getBuildDir()) ?: $this->getBuildDir(), 'kernel.cache_dir' => realpath($this->getCacheDir()) ?: $this->getCacheDir(), 'kernel.logs_dir' => realpath($this->getLogDir()) ?: $this->getLogDir(), @@ -272,12 +302,12 @@ class Kernel public function environment(): string { - return $this->environment; + return $this->options->environment; } public function debug(): bool { - return $this->debug; + return $this->options->debug; } public function container(): ContainerInterface @@ -291,7 +321,7 @@ class Kernel public function getStartTime(): float { - return $this->debug && null !== $this->startTime ? $this->startTime : -\INF; + return $this->debug() && null !== $this->startTime ? $this->startTime : -\INF; } /** @@ -299,24 +329,7 @@ class Kernel */ public function folderRoot(): string { - if (!isset($this->projectDir)) { - $r = new \ReflectionObject($this); - - if (!is_file($dir = $r->getFileName())) { - throw new \LogicException(\sprintf('Cannot auto-detect project dir for kernel of class "%s".', $r->name)); - } - - $dir = $rootDir = \dirname($dir); - while (!is_file($dir.'/composer.json')) { - if ($dir === \dirname($dir)) { - return $this->projectDir = $rootDir; - } - $dir = \dirname($dir); - } - $this->projectDir = $dir; - } - - return $this->projectDir; + return $this->options->paths->project; } @@ -325,12 +338,12 @@ class Kernel */ private function getConfigDir(): string { - return $this->folderRoot().'/config'; + return $this->options->paths->configuration(); } public function getCacheDir(): string { - return $this->folderRoot().'/var/cache/'.$this->environment; + return $this->options->paths->cache($this->environment()); } public function getBuildDir(): string @@ -340,7 +353,7 @@ class Kernel public function getLogDir(): string { - return $this->folderRoot().'/var/log'; + return $this->options->paths->logs(); } public function getCharset(): string @@ -382,7 +395,7 @@ class Kernel // Service definitions $projectDir = $this->folderRoot(); $moduleDir = $projectDir . '/modules'; - $environment = $this->environment; + $environment = $this->environment(); $builder->addDefinitions([ @@ -395,6 +408,9 @@ class Kernel // Without this alias, PHP-DI will happily autowire a new empty Container when asked Container::class => \DI\get(ContainerInterface::class), + TenantContextInterface::class => \DI\get(TenantContext::class), + IdentityContextInterface::class => \DI\get(IdentityContext::class), + LoggerInterface::class => function (ContainerInterface $c) use ($projectDir) { $logConfig = $this->config['log'] ?? []; @@ -405,7 +421,7 @@ class Kernel return new TenantAwareLogger( $this->logger, - $c->get(SessionTenant::class), + $c->get(TenantContextInterface::class), $logDir, $channel, $level, @@ -413,8 +429,8 @@ class Kernel ); }, - // EventBus as singleton for consistent event handling - EventBus::class => \DI\create(EventBus::class), + EventDispatcherInterface::class => \DI\get(EventDispatcher::class), + DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class), // Ephemeral Cache - for short-lived data (sessions, rate limits, challenges) EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) { $storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file'; @@ -433,13 +449,13 @@ class Kernel $cache = new $storeClass($projectDir); // Set tenant/user context if available - if ($c->has(SessionTenant::class)) { - $tenant = $c->get(SessionTenant::class); - $cache->setTenantContext($tenant->identifier()); + if ($c->has(TenantContextInterface::class)) { + $tenantContext = $c->get(TenantContextInterface::class); + $cache->setTenantContext($tenantContext->identifier()); } - if ($c->has(SessionIdentity::class)) { - $identity = $c->get(SessionIdentity::class); - $cache->setUserContext($identity->identifier()); + if ($c->has(IdentityContextInterface::class)) { + $identityContext = $c->get(IdentityContextInterface::class); + $cache->setUserContext($identityContext->identifier()); } return $cache; @@ -462,13 +478,13 @@ class Kernel $cache = new $storeClass($projectDir); // Set tenant/user context if available - if ($c->has(SessionTenant::class)) { - $tenant = $c->get(SessionTenant::class); - $cache->setTenantContext($tenant->identifier()); + if ($c->has(TenantContextInterface::class)) { + $tenantContext = $c->get(TenantContextInterface::class); + $cache->setTenantContext($tenantContext->identifier()); } - if ($c->has(SessionIdentity::class)) { - $identity = $c->get(SessionIdentity::class); - $cache->setUserContext($identity->identifier()); + if ($c->has(IdentityContextInterface::class)) { + $identityContext = $c->get(IdentityContextInterface::class); + $cache->setUserContext($identityContext->identifier()); } return $cache; @@ -491,13 +507,13 @@ class Kernel $cache = new $storeClass($projectDir); // Set tenant/user context if available - if ($c->has(SessionTenant::class)) { - $tenant = $c->get(SessionTenant::class); - $cache->setTenantContext($tenant->identifier()); + if ($c->has(TenantContextInterface::class)) { + $tenantContext = $c->get(TenantContextInterface::class); + $cache->setTenantContext($tenantContext->identifier()); } - if ($c->has(SessionIdentity::class)) { - $identity = $c->get(SessionIdentity::class); - $cache->setUserContext($identity->identifier()); + if ($c->has(IdentityContextInterface::class)) { + $identityContext = $c->get(IdentityContextInterface::class); + $cache->setUserContext($identityContext->identifier()); } return $cache; diff --git a/core/lib/KernelInterface.php b/core/lib/KernelInterface.php new file mode 100644 index 0000000..f5b4f60 --- /dev/null +++ b/core/lib/KernelInterface.php @@ -0,0 +1,31 @@ +sessionTenant->configured()) { - $tenantId = $this->sessionTenant->identifier() ?? 'system'; + if ($this->tenantContext->configured()) { + $tenantId = $this->tenantContext->identifier() ?? 'system'; } // Inject tenant id as a reserved context key that concrete loggers extract. diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 8556c5a..0733768 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -2,6 +2,10 @@ namespace KTXC\Module; +use KTXC\Service\FirewallService; +use KTXF\Event\DeliveryMode; +use KTXF\Event\EventListenerRegistry; +use KTXF\Event\SecurityEvent; use KTXF\Module\ModuleBrowserInterface; use KTXF\Module\ModuleConsoleInterface; use KTXF\Module\ModuleInstanceAbstract; @@ -13,7 +17,36 @@ use KTXF\Module\ModuleInstanceAbstract; */ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface { - public function __construct() {} + public function __construct( + private readonly EventListenerRegistry $events, + ) { + } + + public function boot(): void + { + $this->events->listen( + 'core', + SecurityEvent::AUTH_FAILURE, + FirewallService::class, + 'handleAuthFailure', + priority: 100, + ); + + foreach ([ + SecurityEvent::AUTH_FAILURE, + SecurityEvent::AUTH_SUCCESS, + SecurityEvent::ACCESS_DENIED, + SecurityEvent::BRUTE_FORCE_DETECTED, + ] as $event) { + $this->events->listen( + 'core', + $event, + FirewallService::class, + 'logSecurityEvent', + DeliveryMode::Deferred, + ); + } + } public function handle(): string { @@ -99,6 +132,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M public function registerCI(): array { return [ + \KTXC\Console\Event\EventsDebugCommand::class, \KTXC\Console\Module\ModuleListCommand::class, \KTXC\Console\Module\ModuleEnableCommand::class, \KTXC\Console\Module\ModuleDisableCommand::class, diff --git a/core/lib/Module/ModuleAutoloader.php b/core/lib/Module/ModuleAutoloader.php index ea840fc..fd78f68 100644 --- a/core/lib/Module/ModuleAutoloader.php +++ b/core/lib/Module/ModuleAutoloader.php @@ -2,7 +2,7 @@ namespace KTXC\Module; -use KTXC\Server; +use Composer\Autoload\ClassLoader; /** * Custom autoloader for modules that allows PascalCase namespaces @@ -20,7 +20,10 @@ class ModuleAutoloader private array $namespaceMap = []; private bool $scanned = false; - public function __construct(string $modulesRoot) + public function __construct( + string $modulesRoot, + private readonly ?ClassLoader $composerLoader = null, + ) { $this->modulesRoot = rtrim($modulesRoot, '/'); } @@ -73,10 +76,9 @@ class ModuleAutoloader } // Register module namespaces with Composer ClassLoader - $composerLoader = Server::getComposerLoader(); - if ($composerLoader !== null) { + if ($this->composerLoader !== null) { foreach ($this->namespaceMap as $namespace => $folderName) { - $composerLoader->addPsr4( + $this->composerLoader->addPsr4( 'KTXM\\' . $namespace . '\\', $this->modulesRoot . '/' . $folderName . '/lib/' ); diff --git a/core/lib/Module/ModuleManager.php b/core/lib/Module/ModuleManager.php index c550451..dc8bea1 100644 --- a/core/lib/Module/ModuleManager.php +++ b/core/lib/Module/ModuleManager.php @@ -276,12 +276,13 @@ class ModuleManager try { $module->boot(); $this->logger->debug('Module booted', ['handle' => $handle]); - } catch (Exception $e) { + } catch (\Throwable $e) { $this->logger->error('Module boot failed: ' . $handle, [ 'exception' => $e, 'message' => $e->getMessage(), 'code' => $e->getCode(), ]); + throw $e; } } } diff --git a/core/lib/Runtime/Console/ConsoleRuntime.php b/core/lib/Runtime/Console/ConsoleRuntime.php new file mode 100644 index 0000000..6312a82 --- /dev/null +++ b/core/lib/Runtime/Console/ConsoleRuntime.php @@ -0,0 +1,97 @@ +kernel->beginExecution(ExecutionDescriptor::cli()); + $outcome = ExecutionOutcome::incomplete(); + + try { + $exitCode = $this->application()->run($input, $output); + $outcome = ExecutionOutcome::success($exitCode); + + return $exitCode; + } catch (\Throwable $error) { + $outcome = ExecutionOutcome::failure($error); + throw $error; + } finally { + $this->kernel->terminateExecution($scope, $outcome); + } + } + + private function application(): ConsoleApplication + { + $container = $this->kernel->container(); + $console = new ConsoleApplication('Vallarx Console', Kernel::VERSION); + $console->setAutoExit(false); + + /** @var ModuleManager $moduleManager */ + $moduleManager = $container->get(ModuleManager::class); + foreach ($moduleManager->list() as $module) { + $instance = $module->instance(); + if (!$instance instanceof ModuleConsoleInterface) { + continue; + } + + foreach ($instance->registerCI() as $commandClass) { + $this->registerLazyCommand($console, $container, $commandClass); + } + } + + return $console; + } + + /** + * @param class-string $commandClass + */ + private function registerLazyCommand( + ConsoleApplication $console, + ContainerInterface $container, + string $commandClass, + ): void { + if (!class_exists($commandClass)) { + throw new \RuntimeException("Command class not found: {$commandClass}"); + } + + $reflection = new \ReflectionClass($commandClass); + $attributes = $reflection->getAttributes(AsCommand::class); + if ($attributes === []) { + throw new \RuntimeException("Command {$commandClass} is missing #[AsCommand]."); + } + + $attribute = $attributes[0]->newInstance(); + $console->add(new LazyCommand( + $attribute->name, + [], + $attribute->description ?? '', + $attribute->hidden ?? false, + static fn() => $container->get($commandClass), + )); + } +} diff --git a/core/lib/Runtime/Http/HttpRuntime.php b/core/lib/Runtime/Http/HttpRuntime.php new file mode 100644 index 0000000..be214a8 --- /dev/null +++ b/core/lib/Runtime/Http/HttpRuntime.php @@ -0,0 +1,96 @@ +execute( + $request ?? Request::createFromGlobals(), + send: true, + ); + } + + public function handle(Request $request): Response + { + return $this->execute($request, send: false); + } + + private function execute(Request $request, bool $send): Response + { + $scope = null; + $outcome = ExecutionOutcome::incomplete(); + + try { + $scope = $this->kernel->beginExecution(ExecutionDescriptor::http()); + $response = $this->pipeline()->handle($request); + $outcome = ExecutionOutcome::success($response); + if ($send) { + $response->send(); + } + + return $response; + } catch (\Throwable $error) { + $response = $this->errorResponse($error); + $outcome = ExecutionOutcome::failure($error, $response); + if ($send) { + $response->send(); + } + + return $response; + } finally { + if ($scope !== null) { + $this->kernel->terminateExecution($scope, $outcome); + } + } + } + + private function pipeline(): MiddlewarePipeline + { + $pipeline = new MiddlewarePipeline($this->kernel->container()); + $pipeline->pipe(TenantMiddleware::class); + $pipeline->pipe(FirewallMiddleware::class); + $pipeline->pipe(AuthenticationMiddleware::class); + $pipeline->pipe(RouterMiddleware::class); + + return $pipeline; + } + + private function errorResponse(\Throwable $error): Response + { + error_log(sprintf( + 'Application error: %s in %s:%d', + $error->getMessage(), + $error->getFile(), + $error->getLine(), + )); + + $content = $this->debug + ? '
' . htmlspecialchars((string) $error) . '
' + : 'An error occurred. Please try again later.'; + + return new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [ + 'Content-Type' => 'text/html; charset=UTF-8', + ]); + } +} diff --git a/core/lib/Security/AuthenticationManager.php b/core/lib/Security/AuthenticationManager.php index af2e037..11641e1 100644 --- a/core/lib/Security/AuthenticationManager.php +++ b/core/lib/Security/AuthenticationManager.php @@ -10,7 +10,7 @@ use KTXC\Security\Authentication\AuthenticationRequest; use KTXC\Security\Authentication\AuthenticationResponse; use KTXC\Service\TokenService; use KTXC\Service\UserAccountsService; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; use KTXF\Cache\CacheScope; use KTXF\Cache\EphemeralCacheInterface; use KTXF\Security\Authentication\AuthenticationProviderInterface; @@ -26,13 +26,13 @@ class AuthenticationManager private string $securityCode; public function __construct( - private readonly SessionTenant $tenant, + private readonly TenantContextInterface $tenantContext, private readonly EphemeralCacheInterface $cache, private readonly ProviderManager $providerManager, private readonly TokenService $tokenService, private readonly UserAccountsService $userService, ) { - $this->securityCode = $this->tenant->configuration()->security()->code(); + $this->securityCode = $this->tenantContext->configuration()->security()->code(); } // ========================================================================= @@ -75,7 +75,7 @@ class AuthenticationManager $methods = $this->methodsConfigured(); $session = AuthenticationSession::create( - $this->tenant->identifier(), + $this->tenantContext->identifier(), AuthenticationSession::STATE_FRESH ); @@ -103,7 +103,7 @@ class AuthenticationManager // Filter to non-redirect methods since redirects don't need identity first $methods = $this->methodsConfigured(); $methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect')); - $require = $this->tenant->configuration()->authentication()->methodsMinimal(); + $require = $this->tenantContext->configuration()->authentication()->methodsMinimal(); // Store identity in session without validating to prevent enumeration $session->setMethods(array_column($methods, 'id'), $require); @@ -429,7 +429,7 @@ class AuthenticationManager $session->methodCompleted($method); // Check if MFA is required - $require = $this->tenant->configuration()->authentication()->methodsMinimal(); + $require = $this->tenantContext->configuration()->authentication()->methodsMinimal(); if ($require > 1) { $remainingMethods = $this->methodsConfigured([$method]); // Filter out redirect methods - they can't be used as secondary factors @@ -523,7 +523,7 @@ class AuthenticationManager $accessToken = $this->tokenService->createToken( [ - 'tenant' => $this->tenant->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'identifier' => $user->getId(), 'identity' => $user->getIdentity(), 'label' => $user->getLabel(), @@ -585,7 +585,7 @@ class AuthenticationManager */ private function getProviderConfig(string $method): array { - $providers = $this->tenant->configuration()->authentication()->providers(); + $providers = $this->tenantContext->configuration()->authentication()->providers(); return $providers[$method]['config'] ?? []; } @@ -635,7 +635,7 @@ class AuthenticationManager */ private function methodsConfigured(array $methodsCompleted = []): array { - $tenantProviders = $this->tenant->configuration()->authentication()->providers(); + $tenantProviders = $this->tenantContext->configuration()->authentication()->providers(); $methods = []; foreach ($tenantProviders as $providerId => $providerConfiguration) { @@ -669,7 +669,7 @@ class AuthenticationManager private function createTokens(User $user, bool $mfaVerified = false): array { $payload = [ - 'tenant' => $this->tenant->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'identifier' => $user->getId(), 'identity' => $user->getIdentity(), 'label' => $user->getLabel(), diff --git a/core/lib/Security/Authorization/PermissionChecker.php b/core/lib/Security/Authorization/PermissionChecker.php index 690b0f0..0d03b32 100644 --- a/core/lib/Security/Authorization/PermissionChecker.php +++ b/core/lib/Security/Authorization/PermissionChecker.php @@ -2,7 +2,7 @@ namespace KTXC\Security\Authorization; -use KTXC\SessionIdentity; +use KTXC\Context\IdentityContextInterface; /** * Permission Checker @@ -11,7 +11,7 @@ use KTXC\SessionIdentity; class PermissionChecker { public function __construct( - private readonly SessionIdentity $sessionIdentity + private readonly IdentityContextInterface $identityContext ) {} /** @@ -24,7 +24,7 @@ class PermissionChecker */ public function can(string $permission, mixed $resource = null): bool { - $identity = $this->sessionIdentity->identity(); + $identity = $this->identityContext->identity(); if (!$identity) { return false; @@ -113,7 +113,7 @@ class PermissionChecker */ public function getUserPermissions(): array { - $identity = $this->sessionIdentity->identity(); + $identity = $this->identityContext->identity(); if (!$identity) { return []; diff --git a/core/lib/Server.php b/core/lib/Server.php deleted file mode 100644 index 6a6d93b..0000000 --- a/core/lib/Server.php +++ /dev/null @@ -1,311 +0,0 @@ -rootDir = $this->resolveProjectRoot($rootDir); - - // Load configuration - $this->config = $this->loadConfig(); - - // Determine environment and debug mode - $environment = $environment ?? $this->config['environment'] ?? 'prod'; - $debug = $debug ?? $this->config['debug'] ?? false; - - // Create kernel with configuration - $this->kernel = new Kernel($environment, $debug, $this->config, $rootDir); - - // Register module autoloader for both HTTP and CLI contexts - $moduleAutoloader = new ModuleAutoloader($this->moduleDir()); - $moduleAutoloader->register(); - } - - /** - * Run the application - handle incoming request and send response - */ - public function runHttp(): void - { - try { - $request = Request::createFromGlobals(); - $response = $this->handle($request); - $response->send(); - $this->terminate(); - } catch (\Throwable $e) { - // Last resort error handling for kernel initialization failures - error_log('Application error: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine()); - $content = $this->kernel->debug() - ? '
' . htmlspecialchars((string) $e) . '
' - : 'An error occurred. Please try again later.'; - $response = new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [ - 'Content-Type' => 'text/html; charset=UTF-8', - ]); - $response->send(); - exit(1); - } - } - - /** - * Run as a console application (CLI runtime). - */ - public function runConsole(): int - { - $this->kernel()->boot(); - $container = $this->container(); - - $console = new ConsoleApplication('Vallarx Console', Kernel::VERSION); - - /** @var ModuleManager $moduleManager */ - $moduleManager = $container->get(ModuleManager::class); - - foreach ($moduleManager->list() as $module) { - $instance = $module->instance(); - if (!$instance instanceof ModuleConsoleInterface) { - continue; - } - try { - foreach ($instance->registerCI() as $commandClass) { - if (!class_exists($commandClass)) { - fwrite(STDERR, "Warning: Command class not found: {$commandClass}\n"); - continue; - } - $this->registerLazyCommand($console, $container, $commandClass); - } - } catch (\Throwable $e) { - fwrite(STDERR, "Warning: Failed to load commands from module {$module->handle()}: {$e->getMessage()}\n"); - } - } - - return $console->run(); - } - - /** - * Handle a request - */ - public function handle(Request $request): Response - { - return $this->kernel->handle($request); - } - - /** - * Terminate the application - process deferred events - */ - public function terminate(): void - { - $this->kernel->processEvents(); - } - - /** - * Get the kernel instance - */ - public function kernel(): Kernel - { - return $this->kernel; - } - - /** - * Get the container instance - */ - public function container(): ContainerInterface - { - return $this->kernel->container(); - } - - /** - * Get the application root directory - */ - public function rootDir(): string - { - return $this->rootDir; - } - - /** - * Get the modules directory - */ - public function moduleDir(): string - { - return $this->rootDir . '/modules'; - } - - public function varDir(): string - { - return $this->rootDir . '/var'; - } - - public function logDir(): string - { - return $this->varDir() . '/logs'; - } - - /** - * Get configuration value - */ - public function config(?string $key = null, mixed $default = null): mixed - { - if ($key === null) { - return $this->config; - } - - // Support dot notation: 'database.uri' - $keys = explode('.', $key); - $value = $this->config; - - foreach ($keys as $k) { - if (!is_array($value) || !array_key_exists($k, $value)) { - return $default; - } - $value = $value[$k]; - } - - return $value; - } - - /** - * Get environment - */ - public function environment(): string - { - return $this->kernel->environment(); - } - - /** - * Check if debug mode is enabled - */ - public function debug(): bool - { - return $this->kernel->debug(); - } - - /** - * Load configuration from config directory - */ - protected function loadConfig(): array - { - $configFile = $this->rootDir . '/config/system.php'; - - if (!file_exists($configFile)) { - error_log('Configuration file not found: ' . $configFile); - return []; - } - - $config = include $configFile; - - if (!is_array($config)) { - throw new \RuntimeException('Configuration file must return an array'); - } - - return $config; - } - - /** - * Resolve the project root directory. - * - * Some entrypoints may pass the public/ directory or another subdirectory. - * We walk up the directory tree until we find composer.json. - */ - private function resolveProjectRoot(string $startDir): string - { - $dir = rtrim($startDir, '/'); - if ($dir === '') { - return $startDir; - } - - // If startDir is a file path, use its directory. - if (is_file($dir)) { - $dir = dirname($dir); - } - - $current = $dir; - while (true) { - if (is_file($current . '/composer.json')) { - return $current; - } - - $parent = dirname($current); - if ($parent === $current) { - // Reached filesystem root - return $dir; - } - $current = $parent; - } - } - - /** - * Set the Composer ClassLoader instance - */ - public static function setComposerLoader($loader): void - { - self::$composerLoader = $loader; - } - - /** - * Get the Composer ClassLoader instance - */ - public static function getComposerLoader() - { - return self::$composerLoader; - } - - /** - * Get the current Application instance - */ - public static function getInstance(): ?self - { - return self::$instance; - } - - /** - * Register a single command via lazy loading using its #[AsCommand] attribute. - */ - private function registerLazyCommand( - ConsoleApplication $console, - ContainerInterface $container, - string $commandClass - ): void { - try { - $ref = new \ReflectionClass($commandClass); - $attrs = $ref->getAttributes(AsCommand::class); - - if (empty($attrs)) { - fwrite(STDERR, "Warning: Command {$commandClass} missing #[AsCommand] attribute\n"); - return; - } - - $attr = $attrs[0]->newInstance(); - $console->add(new LazyCommand( - $attr->name, - [], - $attr->description ?? '', - $attr->hidden ?? false, - fn() => $container->get($commandClass) - )); - } catch (\Throwable $e) { - fwrite(STDERR, "Warning: Failed to register command {$commandClass}: {$e->getMessage()}\n"); - } - } -} diff --git a/core/lib/Service/ConfigurationService.php b/core/lib/Service/ConfigurationService.php index a5ea6b8..597d0bc 100644 --- a/core/lib/Service/ConfigurationService.php +++ b/core/lib/Service/ConfigurationService.php @@ -5,7 +5,7 @@ namespace KTXC\Service; use KTXC\Db\DataStore; use KTXC\Db\Collection; use KTXC\Db\UTCDateTime; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; class ConfigurationService { @@ -24,7 +24,7 @@ class ConfigurationService public function __construct( DataStore $store, - private readonly SessionTenant $tenant + private readonly TenantContextInterface $tenantContext ) { // DataStore provides selectCollection method $this->collection = $store->selectCollection(self::TABLE_NAME); @@ -36,10 +36,10 @@ class ConfigurationService */ public function get(string $path, string $key, mixed $default = null, ?string $tenant = null): mixed { - if ($tenant === null && !$this->tenant->isConfigured()) { + if ($tenant === null && !$this->tenantContext->configured()) { throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); } elseif ($tenant === null) { - $tenant = $this->tenant->identifier(); + $tenant = $this->tenantContext->identifier(); } $doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]); @@ -54,10 +54,10 @@ class ConfigurationService */ public function set(string $path, string $key, mixed $value, mixed $default = null, ?string $tenant = null): bool { - if ($tenant === null && !$this->tenant->isConfigured()) { + if ($tenant === null && !$this->tenantContext->configured()) { throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); } elseif ($tenant === null) { - $tenant = $this->tenant->identifier(); + $tenant = $this->tenantContext->identifier(); } $type = $this->determineType($value); @@ -84,10 +84,10 @@ class ConfigurationService */ public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array { - if ($tenant === null && !$this->tenant->isConfigured()) { + if ($tenant === null && !$this->tenantContext->configured()) { throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); } elseif ($tenant === null) { - $tenant = $this->tenant->identifier(); + $tenant = $this->tenantContext->identifier(); } $filter = ['did' => $tenant]; @@ -116,10 +116,10 @@ class ConfigurationService */ public function delete(string $path, string $key, ?string $tenant = null): bool { - if ($tenant === null && !$this->tenant->isConfigured()) { + if ($tenant === null && !$this->tenantContext->configured()) { throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); } elseif ($tenant === null) { - $tenant = $this->tenant->identifier(); + $tenant = $this->tenantContext->identifier(); } $this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]); @@ -131,10 +131,10 @@ class ConfigurationService */ public function deleteByPath(string $path, bool $includeSubPaths = false, ?string $tenant = null): bool { - if ($tenant === null && !$this->tenant->isConfigured()) { + if ($tenant === null && !$this->tenantContext->configured()) { throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); } elseif ($tenant === null) { - $tenant = $this->tenant->identifier(); + $tenant = $this->tenantContext->identifier(); } $filter = ['did' => $tenant]; @@ -155,10 +155,10 @@ class ConfigurationService */ public function exists(string $path, string $key, ?string $tenant = null): bool { - if ($tenant === null && !$this->tenant->isConfigured()) { + if ($tenant === null && !$this->tenantContext->configured()) { throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.'); } elseif ($tenant === null) { - $tenant = $this->tenant->identifier(); + $tenant = $this->tenantContext->identifier(); } return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0; diff --git a/core/lib/Service/FirewallService.php b/core/lib/Service/FirewallService.php index da872f5..0cae13b 100644 --- a/core/lib/Service/FirewallService.php +++ b/core/lib/Service/FirewallService.php @@ -8,8 +8,8 @@ use KTXC\Http\Request\Request; use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Stores\FirewallStore; -use KTXC\SessionTenant; -use KTXF\Event\EventBus; +use KTXC\Context\TenantContextInterface; +use KTXF\Event\EventDispatcherInterface; use KTXF\Event\SecurityEvent; use KTXF\IpUtils; @@ -41,33 +41,9 @@ class FirewallService public function __construct( private readonly FirewallStore $store, - private readonly SessionTenant $tenant, - private readonly EventBus $eventBus + private readonly TenantContextInterface $tenantContext, + private readonly EventDispatcherInterface $events, ) { - // Listen for auth failures to detect brute force - $this->eventBus->subscribe( - SecurityEvent::AUTH_FAILURE, - [$this, 'handleAuthFailure'], - 100 // High priority - ); - - // Log all security events asynchronously - $this->eventBus->subscribeAsync( - SecurityEvent::AUTH_FAILURE, - [$this, 'logSecurityEvent'] - ); - $this->eventBus->subscribeAsync( - SecurityEvent::AUTH_SUCCESS, - [$this, 'logSecurityEvent'] - ); - $this->eventBus->subscribeAsync( - SecurityEvent::ACCESS_DENIED, - [$this, 'logSecurityEvent'] - ); - $this->eventBus->subscribeAsync( - SecurityEvent::BRUTE_FORCE_DETECTED, - [$this, 'logSecurityEvent'] - ); } /** @@ -100,7 +76,7 @@ class FirewallService return new FirewallAnalyzeResult(true); } - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { return new FirewallAnalyzeResult(true); } @@ -158,7 +134,7 @@ class FirewallService public function handleAuthFailure(SecurityEvent $event): void { $ipAddress = $event->getIpAddress(); - $tenantId = $event->getTenantId() ?? $this->tenant->identifier(); + $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier(); if (!$ipAddress || !$tenantId) { return; @@ -198,8 +174,8 @@ class FirewallService ): void { // Publish brute force event $event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds); - $event->setTenantId($this->tenant->identifier()); - $this->eventBus->publish($event); + $event->setTenantId($this->tenantContext->identifier()); + $this->events->dispatch($event); // Auto-block the IP $blockDuration = $this->getConfig( @@ -220,7 +196,7 @@ class FirewallService */ public function logSecurityEvent(SecurityEvent $event): void { - $tenantId = $event->getTenantId() ?? $this->tenant->identifier(); + $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier(); if (!$tenantId) { return; } @@ -283,8 +259,8 @@ class FirewallService $rule->getId(), $rule->getReason() ); - $event->setTenantId($this->tenant->identifier()); - $this->eventBus->publish($event); + $event->setTenantId($this->tenantContext->identifier()); + $this->events->dispatch($event); } // ======================================== @@ -300,7 +276,7 @@ class FirewallService ?string $createdBy = null, ?int $durationSeconds = null ): FirewallRuleObject { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); } @@ -340,7 +316,7 @@ class FirewallService $event->setIpAddress($ipAddress) ->setReason($reason) ->setTenantId($tenantId); - $this->eventBus->publish($event); + $this->events->dispatch($event); return $rule; } @@ -353,7 +329,7 @@ class FirewallService ?string $reason = null, ?string $createdBy = null ): FirewallRuleObject { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); } @@ -376,7 +352,7 @@ class FirewallService $event->setIpAddress($ipAddress) ->setReason($reason) ->setTenantId($tenantId); - $this->eventBus->publish($event); + $this->events->dispatch($event); return $rule; } @@ -389,7 +365,7 @@ class FirewallService ?string $reason = null, ?string $createdBy = null ): FirewallRuleObject { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); } @@ -419,7 +395,7 @@ class FirewallService ?string $createdBy = null, ?int $durationSeconds = null ): FirewallRuleObject { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); } @@ -448,7 +424,7 @@ class FirewallService $event->setDeviceFingerprint($fingerprint) ->setReason($reason) ->setTenantId($tenantId); - $this->eventBus->publish($event); + $this->events->dispatch($event); return $rule; } @@ -464,7 +440,7 @@ class FirewallService } // Verify tenant ownership - if ($rule->getTenantId() !== $this->tenant->identifier()) { + if ($rule->getTenantId() !== $this->tenantContext->identifier()) { return false; } @@ -485,7 +461,7 @@ class FirewallService } // Verify tenant ownership - if ($rule->getTenantId() !== $this->tenant->identifier()) { + if ($rule->getTenantId() !== $this->tenantContext->identifier()) { return false; } @@ -501,7 +477,7 @@ class FirewallService */ public function listRules(bool $activeOnly = true): array { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { return []; } @@ -518,7 +494,7 @@ class FirewallService ?string $result = null, int $limit = 100 ): array { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { return []; } @@ -531,7 +507,7 @@ class FirewallService */ public function getBlockedCount(?\DateTimeImmutable $since = null): int { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); if (!$tenantId) { return 0; } @@ -556,7 +532,7 @@ class FirewallService */ private function getConfig(string $key, mixed $default = null): mixed { - $config = $this->tenant->configuration(); + $config = $this->tenantContext->configuration(); $parts = explode('.', $key); foreach ($parts as $part) { @@ -576,7 +552,7 @@ class FirewallService private function getActiveRules(): array { if ($this->rulesCache === null) { - $tenantId = $this->tenant->identifier(); + $tenantId = $this->tenantContext->identifier(); $this->rulesCache = $tenantId ? $this->store->listRules($tenantId, true) : []; diff --git a/core/lib/Service/SecurityService.php b/core/lib/Service/SecurityService.php index f29b252..a2ab71d 100644 --- a/core/lib/Service/SecurityService.php +++ b/core/lib/Service/SecurityService.php @@ -7,7 +7,7 @@ namespace KTXC\Service; use KTXC\Http\Request\Request; use KTXC\Models\Identity\User; use KTXC\Resource\ProviderManager; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; use KTXF\Security\Authentication\AuthenticationProviderInterface; /** @@ -23,12 +23,12 @@ class SecurityService private string $securityCode; public function __construct( - private readonly SessionTenant $sessionTenant, + private readonly TenantContextInterface $tenantContext, private readonly TokenService $tokenService, private readonly UserAccountsService $userService, private readonly ProviderManager $providerManager, ) { - $this->securityCode = $this->sessionTenant->configuration()->security()->code(); + $this->securityCode = $this->tenantContext->configuration()->security()->code(); } /** @@ -118,7 +118,7 @@ class SecurityService continue; } $context = new \KTXF\Security\Authentication\ProviderContext( - tenantId: $this->sessionTenant->identifier(), + tenantId: $this->tenantContext->identifier(), userIdentity: $identity, ); $result = $provider->verify($context, $credentials); diff --git a/core/lib/Service/TokenService.php b/core/lib/Service/TokenService.php index ef22868..d9ca6a3 100644 --- a/core/lib/Service/TokenService.php +++ b/core/lib/Service/TokenService.php @@ -2,7 +2,7 @@ namespace KTXC\Service; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; use KTXF\Cache\CacheScope; use KTXF\Cache\EphemeralCacheInterface; @@ -26,7 +26,7 @@ class TokenService private string $algorithm = 'HS256'; public function __construct( - private readonly SessionTenant $sessionTenant, + private readonly TenantContextInterface $tenantContext, private readonly EphemeralCacheInterface $cache, ) { } diff --git a/core/lib/Service/UserAccountsService.php b/core/lib/Service/UserAccountsService.php index 7936b5c..c29c9d0 100644 --- a/core/lib/Service/UserAccountsService.php +++ b/core/lib/Service/UserAccountsService.php @@ -3,16 +3,16 @@ namespace KTXC\Service; use KTXC\Models\Identity\User; -use KTXC\SessionIdentity; -use KTXC\SessionTenant; +use KTXC\Context\IdentityContextInterface; +use KTXC\Context\TenantContextInterface; use KTXC\Stores\UserAccountsStore; class UserAccountsService { public function __construct( - private readonly SessionTenant $tenantIdentity, - private readonly SessionIdentity $userIdentity, + private readonly TenantContextInterface $tenantContext, + private readonly IdentityContextInterface $identityContext, private readonly UserAccountsStore $userStore ) { } @@ -26,7 +26,7 @@ class UserAccountsService */ public function listUsers(array $filters = []): array { - $users = $this->userStore->listUsers($this->tenantIdentity->identifier(), $filters); + $users = $this->userStore->listUsers($this->tenantContext->identifier(), $filters); // Remove sensitive data foreach ($users as &$user) { @@ -38,7 +38,7 @@ class UserAccountsService public function fetchByIdentity(string $identifier): User | null { - $data = $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier); + $data = $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier); if (!$data) { return null; } @@ -50,32 +50,32 @@ class UserAccountsService public function fetchByIdentifier(string $identifier): array | null { - return $this->userStore->fetchByIdentifier($this->tenantIdentity->identifier(), $identifier); + return $this->userStore->fetchByIdentifier($this->tenantContext->identifier(), $identifier); } public function fetchByIdentityRaw(string $identifier): array | null { - return $this->userStore->fetchByIdentity($this->tenantIdentity->identifier(), $identifier); + return $this->userStore->fetchByIdentity($this->tenantContext->identifier(), $identifier); } public function fetchByProviderSubject(string $provider, string $subject): ?array { - return $this->userStore->fetchByProviderSubject($this->tenantIdentity->identifier(), $provider, $subject); + return $this->userStore->fetchByProviderSubject($this->tenantContext->identifier(), $provider, $subject); } public function createUser(array $userData): array { - return $this->userStore->createUser($this->tenantIdentity->identifier(), $userData); + return $this->userStore->createUser($this->tenantContext->identifier(), $userData); } public function updateUser(string $uid, array $updates): bool { - return $this->userStore->updateUser($this->tenantIdentity->identifier(), $uid, $updates); + return $this->userStore->updateUser($this->tenantContext->identifier(), $uid, $updates); } public function deleteUser(string $uid): bool { - return $this->userStore->deleteUser($this->tenantIdentity->identifier(), $uid); + return $this->userStore->deleteUser($this->tenantContext->identifier(), $uid); } // ========================================================================= @@ -84,7 +84,7 @@ class UserAccountsService public function fetchProfile(string $uid): ?array { - return $this->userStore->fetchProfile($this->tenantIdentity->identifier(), $uid); + return $this->userStore->fetchProfile($this->tenantContext->identifier(), $uid); } public function storeProfile(string $uid, array $profileFields): bool @@ -109,7 +109,7 @@ class UserAccountsService return false; } - return $this->userStore->storeProfile($this->tenantIdentity->identifier(), $uid, $editableFields); + return $this->userStore->storeProfile($this->tenantContext->identifier(), $uid, $editableFields); } // ========================================================================= @@ -118,12 +118,12 @@ class UserAccountsService public function fetchSettings(array $settings = [], bool $flatten = false): array | null { - return $this->userStore->fetchSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings, $flatten); + return $this->userStore->fetchSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings, $flatten); } public function storeSettings(array $settings): bool { - return $this->userStore->storeSettings($this->tenantIdentity->identifier(), $this->userIdentity->identifier(), $settings); + return $this->userStore->storeSettings($this->tenantContext->identifier(), $this->identityContext->identifier(), $settings); } // ========================================================================= diff --git a/core/lib/Service/UserRolesService.php b/core/lib/Service/UserRolesService.php index 7383f93..e1ccfae 100644 --- a/core/lib/Service/UserRolesService.php +++ b/core/lib/Service/UserRolesService.php @@ -2,7 +2,7 @@ namespace KTXC\Service; -use KTXC\SessionTenant; +use KTXC\Context\TenantContextInterface; use KTXC\Stores\UserRolesStore; use Psr\Log\LoggerInterface; @@ -12,7 +12,7 @@ use Psr\Log\LoggerInterface; class UserRolesService { public function __construct( - private readonly SessionTenant $tenantIdentity, + private readonly TenantContextInterface $tenantContext, private readonly UserRolesStore $roleStore, private readonly LoggerInterface $logger ) {} @@ -26,7 +26,7 @@ class UserRolesService */ public function listRoles(): array { - return $this->roleStore->listRoles($this->tenantIdentity->identifier()); + return $this->roleStore->listRoles($this->tenantContext->identifier()); } /** @@ -34,7 +34,7 @@ class UserRolesService */ public function getRole(string $rid): ?array { - return $this->roleStore->fetchByRid($this->tenantIdentity->identifier(), $rid); + return $this->roleStore->fetchByRid($this->tenantContext->identifier(), $rid); } /** @@ -45,11 +45,11 @@ class UserRolesService $this->validateRoleData($roleData); $this->logger->info('Creating role', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'label' => $roleData['label'] ?? 'Unnamed' ]); - return $this->roleStore->createRole($this->tenantIdentity->identifier(), $roleData); + return $this->roleStore->createRole($this->tenantContext->identifier(), $roleData); } /** @@ -70,11 +70,11 @@ class UserRolesService $this->validateRoleData($updates, false); $this->logger->info('Updating role', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'rid' => $rid ]); - return $this->roleStore->updateRole($this->tenantIdentity->identifier(), $rid, $updates); + return $this->roleStore->updateRole($this->tenantContext->identifier(), $rid, $updates); } /** @@ -93,17 +93,17 @@ class UserRolesService } // Check if role is assigned to users - $userCount = $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid); + $userCount = $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid); if ($userCount > 0) { throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)"); } $this->logger->info('Deleting role', [ - 'tenant' => $this->tenantIdentity->identifier(), + 'tenant' => $this->tenantContext->identifier(), 'rid' => $rid ]); - return $this->roleStore->deleteRole($this->tenantIdentity->identifier(), $rid); + return $this->roleStore->deleteRole($this->tenantContext->identifier(), $rid); } /** @@ -111,7 +111,7 @@ class UserRolesService */ public function getRoleUserCount(string $rid): int { - return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid); + return $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid); } /** diff --git a/core/lib/SessionIdentity.php b/core/lib/SessionIdentity.php deleted file mode 100644 index cbd7979..0000000 --- a/core/lib/SessionIdentity.php +++ /dev/null @@ -1,94 +0,0 @@ -identityLock) { - throw new \RuntimeException('Identity is already locked and cannot be changed.'); - } - - $this->identityData = $identity; - $this->identityLock = $lock; - } - - public function identity(): ?User - { - return $this->identityData; - } - - public function identifier(): ?string - { - return $this->identityData?->getId(); - } - - public function label(): ?string - { - return $this->identityData?->getLabel(); - } - - public function mailAddress(): ?string - { - return $this->identityData?->getIdentity(); - } - - public function nameFirst(): ?string - { - return null; - } - - public function nameLast(): ?string - { - return null; - } - - public function permissions(): array - { - return $this->identityData?->getPermissions() ?? []; - } - - public function roles(): array - { - return $this->identityData?->getRoles() ?? []; - } - - public function hasPermission(string $permission): bool - { - $permissions = $this->permissions(); - - // Exact match - if (in_array($permission, $permissions)) { - return true; - } - - // Wildcard match - foreach ($permissions as $userPerm) { - if (str_ends_with($userPerm, '.*')) { - $prefix = substr($userPerm, 0, -2); - if (str_starts_with($permission, $prefix . '.')) { - return true; - } - } - } - - // Full wildcard - if (in_array('*', $permissions)) { - return true; - } - - return false; - } - - public function hasRole(string $role): bool - { - return in_array($role, $this->roles()); - } - -} diff --git a/core/lib/SessionTenant.php b/core/lib/SessionTenant.php deleted file mode 100644 index 9a3244f..0000000 --- a/core/lib/SessionTenant.php +++ /dev/null @@ -1,145 +0,0 @@ -configured) { - return; - } - $tenant = $this->tenantService->fetchByDomain($domain); - if ($tenant) { - $this->domain = $domain; - $this->tenant = $tenant; - $this->configured = true; - } else { - $this->domain = null; - $this->tenant = null; - $this->configured = false; - } - } - - /** - * Configure the tenant by its identifier (for console / CLI usage). - */ - public function configureById(string $identifier): void - { - if ($this->configured) { - return; - } - $tenant = $this->tenantService->fetchById($identifier); - if ($tenant) { - $this->domain = $identifier; - $this->tenant = $tenant; - $this->configured = true; - } else { - $this->domain = null; - $this->tenant = null; - $this->configured = false; - } - } - - /** - * Is the tenant configured - */ - public function configured(): bool - { - return $this->configured; - } - - /** - * Is the tenant enabled - */ - public function enabled(): bool - { - return $this->tenant?->getEnabled() ?? false; - } - - /** - * Current tenant domain - */ - public function domain(): ?string - { - return $this->domain; - } - - /** - * Current tenant identifier - */ - public function identifier(): ?string - { - return $this->tenant?->getIdentifier(); - } - - /** - * Current tenant label - */ - public function label(): ?string - { - return $this->tenant?->getLabel(); - } - - /** - * Current tenant configuration - */ - public function configuration(): TenantConfiguration - { - return $this->tenant?->getConfiguration(); - } - - /** - * Current tenant settings - */ - public function settings(): array - { - return $this->tenant?->getSettings() ?? []; - } - - /** - * Get all identity providers configuration for this tenant - * @return array Map of provider ID to provider config - */ - public function identityProviders(): array - { - return $this->tenant?->getConfiguration()['identity']['providers'] ?? []; - } - - /** - * Get configuration for a specific identity provider - * - * @param string $providerId Provider identifier (e.g., 'default', 'oidc') - * @return array|null Provider configuration or null if not found - */ - public function identityProviderConfig(string $providerId): ?array - { - $providers = $this->identityProviders(); - return $providers[$providerId] ?? null; - } - - /** - * Check if an identity provider is enabled for this tenant - */ - public function isIdentityProviderEnabled(string $providerId): bool - { - $config = $this->identityProviderConfig($providerId); - return $config !== null && ($config['enabled'] ?? false); - } -} diff --git a/core/lib/index.php b/core/lib/index.php index 824edf2..106724e 100644 --- a/core/lib/index.php +++ b/core/lib/index.php @@ -1,11 +1,9 @@ runHttp(); \ No newline at end of file +$application = Application::create(dirname(__DIR__), $composerLoader); +$application->runHttp(); diff --git a/shared/lib/Cache/BlobCacheInterface.php b/shared/lib/Cache/BlobCacheInterface.php index b408d18..23b03c6 100644 --- a/shared/lib/Cache/BlobCacheInterface.php +++ b/shared/lib/Cache/BlobCacheInterface.php @@ -14,6 +14,10 @@ namespace KTXF\Cache; */ interface BlobCacheInterface { + public function setTenantContext(?string $tenantId): void; + + public function setUserContext(?string $userId): void; + /** * Default TTL for blob cache entries (7 days) */ diff --git a/shared/lib/Cache/CacheInterface.php b/shared/lib/Cache/CacheInterface.php index 3835b42..c45d2ef 100644 --- a/shared/lib/Cache/CacheInterface.php +++ b/shared/lib/Cache/CacheInterface.php @@ -12,6 +12,10 @@ namespace KTXF\Cache; */ interface CacheInterface { + public function setTenantContext(?string $tenantId): void; + + public function setUserContext(?string $userId): void; + /** * Retrieve an item from the cache * diff --git a/shared/lib/Event/DeferredEventProcessorInterface.php b/shared/lib/Event/DeferredEventProcessorInterface.php new file mode 100644 index 0000000..f094866 --- /dev/null +++ b/shared/lib/Event/DeferredEventProcessorInterface.php @@ -0,0 +1,14 @@ +> */ - private array $listeners = []; - - /** @var array> */ - private array $asyncListeners = []; - - /** @var Event[] */ - private array $deferredEvents = []; - - /** - * Subscribe to an event with optional priority - * Higher priority listeners are called first - */ - public function subscribe(string $eventName, callable $listener, int $priority = 0): self - { - $this->listeners[$eventName][] = [ - 'callback' => $listener, - 'priority' => $priority, - ]; - - // Sort by priority (higher first) - usort( - $this->listeners[$eventName], - fn($a, $b) => $b['priority'] <=> $a['priority'] - ); - - return $this; - } - - /** - * Subscribe to an event for async/deferred processing - * These handlers run at the end of the request cycle - */ - public function subscribeAsync(string $eventName, callable $listener): self - { - $this->asyncListeners[$eventName][] = $listener; - return $this; - } - - /** - * Unsubscribe a listener from an event - */ - public function unsubscribe(string $eventName, callable $listener): self - { - if (isset($this->listeners[$eventName])) { - $this->listeners[$eventName] = array_filter( - $this->listeners[$eventName], - fn($item) => $item['callback'] !== $listener - ); - } - - if (isset($this->asyncListeners[$eventName])) { - $this->asyncListeners[$eventName] = array_filter( - $this->asyncListeners[$eventName], - fn($item) => $item !== $listener - ); - } - - return $this; - } - - /** - * Publish an event to all subscribers - */ - public function publish(Event $event): self - { - $eventName = $event->getName(); - - // Execute synchronous listeners - if (isset($this->listeners[$eventName])) { - foreach ($this->listeners[$eventName] as $listenerData) { - if ($event->isPropagationStopped()) { - break; - } - - try { - call_user_func($listenerData['callback'], $event); - } catch (\Throwable $e) { - // Log error but don't break the chain - error_log(sprintf( - 'Event listener error for %s: %s', - $eventName, - $e->getMessage() - )); - } - } - } - - // Queue for async processing if there are async listeners - if (isset($this->asyncListeners[$eventName]) && !empty($this->asyncListeners[$eventName])) { - $this->deferredEvents[] = $event; - } - - return $this; - } - - /** - * Process deferred/async events - * Call this at the end of the request cycle - */ - public function processDeferred(): int - { - $processed = 0; - - foreach ($this->deferredEvents as $event) { - $eventName = $event->getName(); - - if (!isset($this->asyncListeners[$eventName])) { - continue; - } - - foreach ($this->asyncListeners[$eventName] as $listener) { - try { - call_user_func($listener, $event); - $processed++; - } catch (\Throwable $e) { - // Log but don't fail - these are non-critical - error_log(sprintf( - 'Async event handler error for %s: %s', - $eventName, - $e->getMessage() - )); - } - } - } - - $this->deferredEvents = []; - - return $processed; - } - - /** - * Check if an event has any listeners - */ - public function hasListeners(string $eventName): bool - { - return !empty($this->listeners[$eventName]) || !empty($this->asyncListeners[$eventName]); - } - - /** - * Get count of listeners for an event - */ - public function getListenerCount(string $eventName): int - { - $sync = isset($this->listeners[$eventName]) ? count($this->listeners[$eventName]) : 0; - $async = isset($this->asyncListeners[$eventName]) ? count($this->asyncListeners[$eventName]) : 0; - - return $sync + $async; - } - - /** - * Get count of pending deferred events - */ - public function getDeferredCount(): int - { - return count($this->deferredEvents); - } - - /** - * Clear all listeners (useful for testing) - */ - public function clear(): self - { - $this->listeners = []; - $this->asyncListeners = []; - $this->deferredEvents = []; - - return $this; - } -} diff --git a/shared/lib/Event/EventDispatcher.php b/shared/lib/Event/EventDispatcher.php new file mode 100644 index 0000000..d83e390 --- /dev/null +++ b/shared/lib/Event/EventDispatcher.php @@ -0,0 +1,124 @@ +> */ + private array $deferred = []; + private ?string $activeExecution = null; + private int $dispatchDepth = 0; + + public function __construct( + private readonly EventListenerRegistry $registry, + private readonly ContainerInterface $container, + private readonly LoggerInterface $logger, + ) { + } + + public function dispatch(Event $event): void + { + if (++$this->dispatchDepth > 32) { + --$this->dispatchDepth; + throw new \RuntimeException('Event dispatch recursion limit exceeded.'); + } + + try { + $this->invoke($event, DeliveryMode::Immediate); + if ($this->registry->listeners($event->getName(), DeliveryMode::Deferred) !== []) { + if ($this->activeExecution === null) { + throw new \LogicException('Deferred events require an active execution scope.'); + } + $this->deferred[$this->activeExecution][] = $event; + } + } finally { + --$this->dispatchDepth; + } + } + + public function beginExecution(string $executionId): void + { + if ($this->activeExecution !== null) { + throw new \LogicException('An event execution scope is already active.'); + } + $this->activeExecution = $executionId; + $this->deferred[$executionId] = []; + } + + public function processDeferred(string $executionId): DeferredProcessingResult + { + if ($this->activeExecution !== $executionId) { + throw new \LogicException('Cannot process deferred events for an inactive execution.'); + } + + $processed = 0; + $deadline = microtime(true) + 1.0; + $deadlineExceeded = false; + $limitExceeded = false; + while (($event = array_shift($this->deferred[$executionId])) !== null) { + if ($processed >= 1000) { + $limitExceeded = true; + array_unshift($this->deferred[$executionId], $event); + break; + } + if (microtime(true) >= $deadline) { + $deadlineExceeded = true; + array_unshift($this->deferred[$executionId], $event); + break; + } + $processed += $this->invoke($event, DeliveryMode::Deferred); + } + + $remaining = count($this->deferred[$executionId]); + unset($this->deferred[$executionId]); + $this->activeExecution = null; + + return new DeferredProcessingResult( + $processed, + $remaining, + $deadlineExceeded, + $limitExceeded, + ); + } + + public function discardDeferred(string $executionId): void + { + unset($this->deferred[$executionId]); + if ($this->activeExecution === $executionId) { + $this->activeExecution = null; + } + } + + private function invoke(Event $event, DeliveryMode $delivery): int + { + $processed = 0; + foreach ($this->registry->listeners($event->getName(), $delivery) as $listener) { + if ($event->isPropagationStopped()) { + break; + } + + try { + $service = $this->container->get($listener->service); + $service->{$listener->method}($event); + $processed++; + } catch (\Throwable $error) { + $this->logger->error('Event listener failed.', [ + 'event' => $event->getName(), + 'module' => $listener->module, + 'listener' => $listener->service . '::' . $listener->method, + 'exception' => $error, + ]); + if ($listener->failurePolicy === FailurePolicy::Propagate) { + throw $error; + } + } + } + + return $processed; + } +} diff --git a/shared/lib/Event/EventDispatcherInterface.php b/shared/lib/Event/EventDispatcherInterface.php new file mode 100644 index 0000000..f15b21d --- /dev/null +++ b/shared/lib/Event/EventDispatcherInterface.php @@ -0,0 +1,10 @@ +> */ + private array $listeners = []; + /** @var array */ + private array $registrationIds = []; + private bool $frozen = false; + + /** + * @param class-string $service + */ + public function listen( + string $module, + string $event, + string $service, + string $method, + DeliveryMode $delivery = DeliveryMode::Immediate, + int $priority = 0, + FailurePolicy $failurePolicy = FailurePolicy::Continue, + ): void { + if ($this->frozen) { + throw new \LogicException('The event listener registry is frozen.'); + } + if ($module === '' || $event === '' || $service === '' || $method === '') { + throw new \InvalidArgumentException('Event listener registrations require module, event, service, and method.'); + } + if (!class_exists($service) || !method_exists($service, $method)) { + throw new \InvalidArgumentException("Invalid event listener {$service}::{$method}."); + } + if ($priority < -10000 || $priority > 10000) { + throw new \InvalidArgumentException('Event listener priority must be between -10000 and 10000.'); + } + + $id = implode('|', [$module, $event, $service, $method, $delivery->value]); + if (isset($this->registrationIds[$id])) { + throw new \LogicException("Duplicate event listener registration: {$id}."); + } + $this->registrationIds[$id] = true; + + $this->listeners[$event][] = new EventListenerDefinition( + $module, + $event, + $service, + $method, + $delivery, + $priority, + $failurePolicy, + ); + } + + public function freeze(?ContainerInterface $container = null): void + { + foreach ($this->listeners as &$listeners) { + if ($container !== null) { + foreach ($listeners as $listener) { + if (!$container->has($listener->service)) { + throw new \LogicException( + "Event listener service is not resolvable: {$listener->service}.", + ); + } + } + } + usort( + $listeners, + static fn(EventListenerDefinition $a, EventListenerDefinition $b): int => + $b->priority <=> $a->priority, + ); + } + unset($listeners); + $this->frozen = true; + } + + /** + * @return list + */ + public function listeners(string $event, DeliveryMode $delivery): array + { + return array_values(array_filter( + $this->listeners[$event] ?? [], + static fn(EventListenerDefinition $listener): bool => $listener->delivery === $delivery, + )); + } + + public function frozen(): bool + { + return $this->frozen; + } + + /** + * @return list + */ + public function definitions(): array + { + return array_merge(...array_values($this->listeners)); + } +} diff --git a/shared/lib/Event/FailurePolicy.php b/shared/lib/Event/FailurePolicy.php new file mode 100644 index 0000000..7943953 --- /dev/null +++ b/shared/lib/Event/FailurePolicy.php @@ -0,0 +1,11 @@ +sessionTenant->configuration(); + $config = $this->tenantContext->configuration(); return $config->security()->code(); } diff --git a/tests/php/Unit/Application/KernelTest.php b/tests/php/Unit/Application/KernelTest.php new file mode 100644 index 0000000..294b9e4 --- /dev/null +++ b/tests/php/Unit/Application/KernelTest.php @@ -0,0 +1,213 @@ + */ + private array $kernels = []; + + protected function tearDown(): void + { + foreach ($this->kernels as $kernel) { + try { + $kernel->shutdown(); + } catch (\LogicException) { + // Tests that intentionally leave a scope active terminate it themselves. + } + } + } + + #[Test] + #[TestDox('Kernel boot is idempotent and freezes one registry')] + public function bootsOnce(): void + { + [$kernel, $modules, $registry] = $this->kernel(); + + $kernel->boot(); + $kernel->boot(); + + self::assertTrue($registry->frozen()); + } + + #[Test] + #[TestDox('Beginning execution boots the kernel automatically')] + public function bootsOnExecution(): void + { + [$kernel, $modules] = $this->kernel(); + + $scope = $kernel->beginExecution(ExecutionDescriptor::cli()); + $kernel->terminateExecution($scope, ExecutionOutcome::success()); + + self::assertTrue($scope->terminated()); + } + + #[Test] + #[TestDox('Only one execution scope can be active')] + public function rejectsConcurrentScopes(): void + { + [$kernel] = $this->kernel(); + $scope = $kernel->beginExecution(ExecutionDescriptor::cli()); + + try { + $kernel->beginExecution(ExecutionDescriptor::http()); + self::fail('Expected the second execution scope to be rejected.'); + } catch (\LogicException) { + self::assertFalse($scope->terminated()); + } finally { + $kernel->terminateExecution($scope, ExecutionOutcome::success()); + } + } + + #[Test] + #[TestDox('Repeated termination does not repeat side effects')] + public function terminatesOnce(): void + { + $processor = new RecordingProcessor(); + [$kernel] = $this->kernel($processor); + $scope = $kernel->beginExecution(ExecutionDescriptor::cli()); + + $first = $kernel->terminateExecution($scope, ExecutionOutcome::success()); + $second = $kernel->terminateExecution($scope, ExecutionOutcome::success()); + + self::assertSame(1, $processor->processed); + self::assertSame(1, $first->deferredProcessed); + self::assertSame(0, $second->deferredProcessed); + } + + #[Test] + #[TestDox('Shutdown rejects active execution scopes')] + public function rejectsActiveShutdown(): void + { + [$kernel] = $this->kernel(); + $scope = $kernel->beginExecution(ExecutionDescriptor::cli()); + + try { + $kernel->shutdown(); + self::fail('Expected active shutdown to be rejected.'); + } catch (\LogicException) { + self::assertFalse($scope->terminated()); + } finally { + $kernel->terminateExecution($scope, ExecutionOutcome::success()); + } + } + + #[Test] + #[TestDox('Deferred failures are reported and the next execution can start')] + public function recoversFromDeferredFailure(): void + { + $processor = new RecordingProcessor(); + $processor->failure = new \RuntimeException('Deferred processing failed.'); + [$kernel] = $this->kernel($processor); + $first = $kernel->beginExecution(ExecutionDescriptor::cli()); + + $report = $kernel->terminateExecution($first, ExecutionOutcome::success()); + $processor->failure = null; + $second = $kernel->beginExecution(ExecutionDescriptor::cli()); + $kernel->terminateExecution($second, ExecutionOutcome::success()); + + self::assertCount(1, $report->failures); + self::assertTrue($first->terminated()); + self::assertTrue($second->terminated()); + } + + /** + * @return array{TestKernel, ModuleManager&MockObject, EventListenerRegistry} + */ + private function kernel(?RecordingProcessor $processor = null): array + { + $modules = $this->createMock(ModuleManager::class); + $modules->expects(self::once())->method('modulesBoot'); + $registry = new EventListenerRegistry(); + $processor ??= new RecordingProcessor(); + $container = new Container(); + $container->set(ModuleManager::class, $modules); + $container->set(EventListenerRegistry::class, $registry); + $container->set(DeferredEventProcessorInterface::class, $processor); + $container->set( + TenantContext::class, + new TenantContext($this->createStub(TenantService::class)), + ); + $container->set(IdentityContext::class, new IdentityContext()); + $container->set(EphemeralCacheInterface::class, $this->createStub(EphemeralCacheInterface::class)); + $container->set(PersistentCacheInterface::class, $this->createStub(PersistentCacheInterface::class)); + $container->set(BlobCacheInterface::class, $this->createStub(BlobCacheInterface::class)); + + $kernel = new TestKernel( + new KernelOptions(ProjectPaths::resolve(dirname(__DIR__, 4)), 'test'), + $container, + ); + $this->kernels[] = $kernel; + + return [$kernel, $modules, $registry]; + } +} + +final class TestKernel extends Kernel +{ + public function __construct( + KernelOptions $options, + private readonly Container $testContainer, + ) { + parent::__construct($options, ['log' => ['driver' => 'null']]); + } + + protected function initializeContainer(): Container + { + return $this->testContainer; + } +} + +final class RecordingProcessor implements DeferredEventProcessorInterface +{ + public int $processed = 0; + public ?\Throwable $failure = null; + private ?string $active = null; + + public function beginExecution(string $executionId): void + { + if ($this->active !== null) { + throw new \LogicException('Execution already active.'); + } + $this->active = $executionId; + } + + public function processDeferred(string $executionId): DeferredProcessingResult + { + if ($this->failure !== null) { + throw $this->failure; + } + $this->active = null; + $this->processed++; + + return new DeferredProcessingResult(1, 0, false); + } + + public function discardDeferred(string $executionId): void + { + $this->active = null; + } +} diff --git a/tests/php/Unit/Application/ProjectPathsTest.php b/tests/php/Unit/Application/ProjectPathsTest.php new file mode 100644 index 0000000..5dc37de --- /dev/null +++ b/tests/php/Unit/Application/ProjectPathsTest.php @@ -0,0 +1,45 @@ +project, $fromCore->project); + self::assertSame($fromRoot->project, $fromConsole->project); + self::assertSame($fromRoot->modules(), $fromCore->modules()); + self::assertSame($fromRoot->logs(), $fromConsole->logs()); + } + + #[Test] + #[TestDox('Missing project roots are rejected')] + public function rejectsMissingRoots(): void + { + $this->expectException(\InvalidArgumentException::class); + ProjectPaths::resolve(sys_get_temp_dir()); + } + + #[Test] + #[TestDox('Empty kernel environments are rejected')] + public function rejectsEmptyEnvironments(): void + { + $this->expectException(\InvalidArgumentException::class); + new KernelOptions(ProjectPaths::resolve(dirname(__DIR__, 4)), ''); + } +} diff --git a/tests/php/Unit/Console/Tenant/TenantCreateCommandTest.php b/tests/php/Unit/Console/Tenant/TenantCreateCommandTest.php index a2820e8..554cd7e 100644 --- a/tests/php/Unit/Console/Tenant/TenantCreateCommandTest.php +++ b/tests/php/Unit/Console/Tenant/TenantCreateCommandTest.php @@ -7,23 +7,38 @@ namespace KTXT\Unit\Console\Tenant; use KTXC\Console\Tenant\TenantCreateCommand; use KTXC\Models\Tenant\TenantObject; use KTXC\Service\TenantService; +use KTXC\Stores\UserAccountsStore; +use KTXC\Stores\UserRolesStore; use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; +#[AllowMockObjectsWithoutExpectations] class TenantCreateCommandTest extends TestCase { private TenantService&MockObject $tenantService; + private UserRolesStore $rolesStore; + private UserAccountsStore $userStore; private CommandTester $tester; private ?TenantObject $deposited = null; protected function setUp(): void { $this->tenantService = $this->createMock(TenantService::class); + $this->rolesStore = $this->createStub(UserRolesStore::class); + $this->rolesStore->method('createRole')->willReturn(['rid' => 'admin']); + $this->userStore = $this->createStub(UserAccountsStore::class); + $this->userStore->method('createUser')->willReturn(['uid' => 'admin']); $this->tester = new CommandTester( - new TenantCreateCommand($this->tenantService, new NullLogger()) + new TenantCreateCommand( + $this->tenantService, + $this->rolesStore, + $this->userStore, + new NullLogger(), + ) ); } diff --git a/tests/php/Unit/Console/Tenant/TenantListCommandTest.php b/tests/php/Unit/Console/Tenant/TenantListCommandTest.php index 3e89692..c033e33 100644 --- a/tests/php/Unit/Console/Tenant/TenantListCommandTest.php +++ b/tests/php/Unit/Console/Tenant/TenantListCommandTest.php @@ -8,19 +8,18 @@ use KTXC\Console\Tenant\TenantListCommand; use KTXC\Models\Tenant\DomainCollection; use KTXC\Models\Tenant\TenantObject; use KTXC\Service\TenantService; -use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; class TenantListCommandTest extends TestCase { - private TenantService&MockObject $tenantService; + private TenantService $tenantService; private CommandTester $tester; protected function setUp(): void { - $this->tenantService = $this->createMock(TenantService::class); + $this->tenantService = $this->createStub(TenantService::class); $this->tester = new CommandTester( new TenantListCommand($this->tenantService) ); diff --git a/tests/php/Unit/Event/EventDispatcherTest.php b/tests/php/Unit/Event/EventDispatcherTest.php new file mode 100644 index 0000000..1b25791 --- /dev/null +++ b/tests/php/Unit/Event/EventDispatcherTest.php @@ -0,0 +1,268 @@ + $listener]); + $registry = new EventListenerRegistry(); + $registry->listen('test', 'test.event', RecordingListener::class, 'immediate'); + $registry->listen( + 'test', + 'test.event', + RecordingListener::class, + 'deferred', + DeliveryMode::Deferred, + ); + $registry->freeze(); + + self::assertSame(0, $container->resolutions); + + $dispatcher = new EventDispatcher($registry, $container, new NullLogger()); + $dispatcher->beginExecution('first'); + $dispatcher->dispatch(new Event('test.event')); + + self::assertSame(1, $listener->immediate); + self::assertSame(0, $listener->deferred); + + $result = $dispatcher->processDeferred('first'); + self::assertSame(1, $result->processed); + self::assertSame(1, $listener->deferred); + + $dispatcher->beginExecution('second'); + $second = $dispatcher->processDeferred('second'); + self::assertSame(0, $second->processed); + self::assertSame(0, $second->remaining); + } + + #[Test] + #[TestDox('Frozen registries reject new listeners')] + public function freezes(): void + { + $registry = new EventListenerRegistry(); + $registry->freeze(); + + $this->expectException(\LogicException::class); + $registry->listen('test', 'test.event', RecordingListener::class, 'immediate'); + } + + #[Test] + #[TestDox('Duplicate listener registrations are rejected')] + public function rejectsDuplicates(): void + { + $registry = new EventListenerRegistry(); + $registry->listen('test', 'test.event', RecordingListener::class, 'immediate'); + + $this->expectException(\LogicException::class); + $registry->listen('test', 'test.event', RecordingListener::class, 'immediate'); + } + + #[Test] + #[TestDox('Invalid listener methods are rejected during registration')] + public function validatesMethods(): void + { + $registry = new EventListenerRegistry(); + + $this->expectException(\InvalidArgumentException::class); + $registry->listen('test', 'test.event', RecordingListener::class, 'missing'); + } + + #[Test] + #[TestDox('Unresolvable listener services fail registry compilation')] + public function validatesServices(): void + { + $registry = new EventListenerRegistry(); + $registry->listen('test', 'test.event', RecordingListener::class, 'immediate'); + + $this->expectException(\LogicException::class); + $registry->freeze(new RecordingContainer([])); + } + + #[Test] + #[TestDox('Listener priority determines dispatch order')] + public function prioritizes(): void + { + $listener = new RecordingListener(); + $registry = new EventListenerRegistry(); + $registry->listen('test', 'test.event', RecordingListener::class, 'low', priority: 1); + $registry->listen('test', 'test.event', RecordingListener::class, 'high', priority: 100); + $registry->freeze(); + + $dispatcher = new EventDispatcher( + $registry, + new RecordingContainer([RecordingListener::class => $listener]), + new NullLogger(), + ); + $dispatcher->beginExecution('test'); + $dispatcher->dispatch(new Event('test.event')); + $dispatcher->processDeferred('test'); + + self::assertSame(['high', 'low'], $listener->order); + } + + #[Test] + #[TestDox('Continue failures do not prevent later listeners')] + public function continues(): void + { + $listener = new RecordingListener(); + $registry = new EventListenerRegistry(); + $registry->listen('test', 'test.event', FailingListener::class, 'fail'); + $registry->listen('test', 'test.event', RecordingListener::class, 'immediate'); + $registry->freeze(); + + $dispatcher = new EventDispatcher( + $registry, + new RecordingContainer([ + FailingListener::class => new FailingListener(), + RecordingListener::class => $listener, + ]), + new NullLogger(), + ); + $dispatcher->beginExecution('test'); + $dispatcher->dispatch(new Event('test.event')); + + self::assertSame(1, $listener->immediate); + } + + #[Test] + #[TestDox('Propagate failures reach the publisher')] + public function propagates(): void + { + $registry = new EventListenerRegistry(); + $registry->listen( + 'test', + 'test.event', + FailingListener::class, + 'fail', + failurePolicy: FailurePolicy::Propagate, + ); + $registry->freeze(); + + $dispatcher = new EventDispatcher( + $registry, + new RecordingContainer([FailingListener::class => new FailingListener()]), + new NullLogger(), + ); + $dispatcher->beginExecution('test'); + + $this->expectException(\RuntimeException::class); + $dispatcher->dispatch(new Event('test.event')); + } + + #[Test] + #[TestDox('Deferred processing stops at its configured count limit')] + public function boundsDeferredWork(): void + { + $listener = new RecursiveListener(); + $registry = new EventListenerRegistry(); + $registry->listen( + 'test', + 'test.event', + RecursiveListener::class, + 'deferred', + DeliveryMode::Deferred, + ); + $registry->freeze(); + $dispatcher = new EventDispatcher( + $registry, + new RecordingContainer([RecursiveListener::class => $listener]), + new NullLogger(), + ); + $listener->dispatcher = $dispatcher; + $dispatcher->beginExecution('test'); + $dispatcher->dispatch(new Event('test.event')); + + $result = $dispatcher->processDeferred('test'); + + self::assertSame(1000, $result->processed); + self::assertSame(1, $result->remaining); + self::assertFalse($result->deadlineExceeded); + self::assertTrue($result->limitExceeded); + } +} + +final class RecordingListener +{ + public int $immediate = 0; + public int $deferred = 0; + public array $order = []; + + public function immediate(Event $event): void + { + $this->immediate++; + } + + public function deferred(Event $event): void + { + $this->deferred++; + } + + public function high(Event $event): void + { + $this->order[] = 'high'; + } + + public function low(Event $event): void + { + $this->order[] = 'low'; + } +} + +final class FailingListener +{ + public function fail(Event $event): void + { + throw new \RuntimeException('Listener failed.'); + } +} + +final class RecursiveListener +{ + public EventDispatcher $dispatcher; + + public function deferred(Event $event): void + { + $this->dispatcher->dispatch(new Event('test.event')); + } +} + +final class RecordingContainer implements ContainerInterface +{ + public int $resolutions = 0; + + public function __construct( + private readonly array $services, + ) { + } + + public function get(string $id): mixed + { + $this->resolutions++; + + return $this->services[$id] + ?? throw new \RuntimeException("Service not found: {$id}"); + } + + public function has(string $id): bool + { + return array_key_exists($id, $this->services); + } +} diff --git a/tests/php/Unit/Execution/ExecutionScopeTest.php b/tests/php/Unit/Execution/ExecutionScopeTest.php new file mode 100644 index 0000000..3cd833d --- /dev/null +++ b/tests/php/Unit/Execution/ExecutionScopeTest.php @@ -0,0 +1,93 @@ +setIdentifier('tenant-a') + ->setEnabled(true); + $service = $this->createStub(TenantService::class); + $service->method('fetchById')->willReturn($tenantObject); + $tenant = new TenantContext($service); + $identity = new IdentityContext(); + $descriptor = ExecutionDescriptor::cli(); + $scope = new ExecutionScope( + $descriptor, + ExecutionContext::fromDescriptor($descriptor), + $tenant, + $identity, + ); + + $tenant->resolveIdentifier('tenant-a'); + $user = new User(); + $user->setId('user-a'); + $identity->initialize($user); + $scope->dispose(); + + self::assertFalse($tenant->present()); + self::assertFalse($identity->present()); + } + + #[Test] + #[TestDox('New scopes cannot see state from an earlier execution')] + public function isolates(): void + { + $tenantObject = (new TenantObject()) + ->setIdentifier('tenant-a') + ->setEnabled(true); + $service = $this->createStub(TenantService::class); + $service->method('fetchById')->willReturn($tenantObject); + $tenant = new TenantContext($service); + $identity = new IdentityContext(); + + $tenant->resolveIdentifier('tenant-a'); + $identity->initialize(new User()); + + $descriptor = ExecutionDescriptor::cli(); + new ExecutionScope( + $descriptor, + ExecutionContext::fromDescriptor($descriptor), + $tenant, + $identity, + ); + + self::assertFalse($tenant->present()); + self::assertFalse($identity->present()); + } + + #[Test] + #[TestDox('Execution scopes can only be marked terminated once')] + public function terminatesOnce(): void + { + $descriptor = ExecutionDescriptor::cli(); + $scope = new ExecutionScope( + $descriptor, + ExecutionContext::fromDescriptor($descriptor), + new TenantContext($this->createStub(TenantService::class)), + new IdentityContext(), + ); + $scope->markTerminated(); + + $this->expectException(\LogicException::class); + $scope->markTerminated(); + } +} diff --git a/tests/php/Unit/Module/CoreModuleTest.php b/tests/php/Unit/Module/CoreModuleTest.php new file mode 100644 index 0000000..fa7ae23 --- /dev/null +++ b/tests/php/Unit/Module/CoreModuleTest.php @@ -0,0 +1,46 @@ +boot(); + $definitions = $registry->definitions(); + + self::assertCount(5, $definitions); + self::assertSame(['core'], array_values(array_unique(array_column($definitions, 'module')))); + self::assertSame( + FirewallService::class, + $registry->listeners(SecurityEvent::AUTH_FAILURE, DeliveryMode::Immediate)[0]->service, + ); + self::assertFalse($registry->frozen()); + } + + #[Test] + #[TestDox('Core exposes the event registry debug command')] + public function exposesDebugCommand(): void + { + $module = new Module(new EventListenerRegistry()); + + self::assertContains(EventsDebugCommand::class, $module->registerCI()); + } +} diff --git a/tests/php/Unit/Runtime/ConsoleRuntimeTest.php b/tests/php/Unit/Runtime/ConsoleRuntimeTest.php new file mode 100644 index 0000000..87fdb69 --- /dev/null +++ b/tests/php/Unit/Runtime/ConsoleRuntimeTest.php @@ -0,0 +1,176 @@ +createStub(ModuleManager::class); + $modules->method('list')->willReturn(new ModuleCollection()); + $kernel = $this->kernel(new ConsoleContainer([ModuleManager::class => $modules])); + + $exitCode = (new ConsoleRuntime($kernel))->run( + new ArrayInput(['command' => 'list', '--raw' => true]), + new BufferedOutput(), + ); + + self::assertSame(0, $exitCode); + self::assertSame(1, $kernel->terminations); + self::assertTrue($kernel->outcome?->successful); + } + + #[Test] + #[TestDox('Console failures terminate before being rethrown')] + public function fails(): void + { + $kernel = $this->kernel(new ConsoleContainer([], new \RuntimeException('Discovery failed.'))); + + try { + (new ConsoleRuntime($kernel))->run( + new ArrayInput(['command' => 'list']), + new BufferedOutput(), + ); + self::fail('Expected console discovery to fail.'); + } catch (\RuntimeException $error) { + self::assertSame('Discovery failed.', $error->getMessage()); + } + + self::assertSame(1, $kernel->terminations); + self::assertFalse($kernel->outcome?->successful); + } + + #[Test] + #[TestDox('Deferred termination failures do not replace command exit codes')] + public function preservesExitCodes(): void + { + $modules = $this->createStub(ModuleManager::class); + $modules->method('list')->willReturn(new ModuleCollection()); + $kernel = $this->kernel(new ConsoleContainer([ModuleManager::class => $modules])); + $kernel->terminationReport = new TerminationReport( + failures: [new \RuntimeException('Deferred listener failed.')], + ); + + $exitCode = (new ConsoleRuntime($kernel))->run( + new ArrayInput(['command' => 'list', '--raw' => true]), + new BufferedOutput(), + ); + + self::assertSame(0, $exitCode); + } + + private function kernel(ContainerInterface $container): ConsoleKernel + { + return new ConsoleKernel( + $container, + new TenantContext($this->createStub(TenantService::class)), + new IdentityContext(), + ); + } +} + +final class ConsoleKernel implements KernelInterface +{ + public int $terminations = 0; + public ?ExecutionOutcome $outcome = null; + public TerminationReport $terminationReport; + + public function __construct( + private readonly ContainerInterface $services, + private readonly TenantContext $tenantContext, + private readonly IdentityContext $identityContext, + ) { + $this->terminationReport = new TerminationReport(); + } + + public function boot(): void + { + } + + public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope + { + return new ExecutionScope( + $descriptor, + ExecutionContext::fromDescriptor($descriptor), + $this->tenantContext, + $this->identityContext, + ); + } + + public function terminateExecution( + ExecutionScope $scope, + ExecutionOutcome $outcome, + ): TerminationReport { + $this->terminations++; + $this->outcome = $outcome; + $scope->dispose(); + $scope->markTerminated(); + + return $this->terminationReport; + } + + public function shutdown(): void + { + } + + public function container(): ContainerInterface + { + return $this->services; + } + + public function environment(): string + { + return 'test'; + } + + public function debug(): bool + { + return false; + } +} + +final class ConsoleContainer implements ContainerInterface +{ + public function __construct( + private readonly array $services, + private readonly ?\Throwable $failure = null, + ) { + } + + public function get(string $id): mixed + { + if ($this->failure !== null) { + throw $this->failure; + } + + return $this->services[$id]; + } + + public function has(string $id): bool + { + return isset($this->services[$id]); + } +} diff --git a/tests/php/Unit/Runtime/HttpRuntimeTest.php b/tests/php/Unit/Runtime/HttpRuntimeTest.php new file mode 100644 index 0000000..3c637f5 --- /dev/null +++ b/tests/php/Unit/Runtime/HttpRuntimeTest.php @@ -0,0 +1,252 @@ + new PassMiddleware(), + FirewallMiddleware::class => new PassMiddleware(), + AuthenticationMiddleware::class => new PassMiddleware(), + RouterMiddleware::class => new StreamMiddleware( + static function () use (&$kernel): bool { + return $kernel->active; + }, + ), + ]), + new TenantContext($this->createStub(TenantService::class)), + new IdentityContext(), + ); + + ob_start(); + try { + (new HttpRuntime($kernel, false))->run(Request::create('/stream')); + $content = ob_get_contents(); + } finally { + ob_end_clean(); + } + + self::assertSame('active', $content); + self::assertFalse($kernel->active); + self::assertSame(1, $kernel->terminations); + } + + #[Test] + #[TestDox('Successful HTTP handling terminates exactly once')] + public function succeeds(): void + { + $kernel = $this->kernel(new ResponseMiddleware(new Response('ok', 200))); + $response = (new HttpRuntime($kernel, false))->handle(Request::create('/')); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('ok', $response->getContent()); + self::assertSame(1, $kernel->terminations); + self::assertTrue($kernel->outcome?->successful); + } + + #[Test] + #[TestDox('HTTP exceptions render a response and still terminate')] + public function fails(): void + { + $kernel = $this->kernel(new ThrowingMiddleware()); + $previousLog = ini_get('error_log'); + ini_set('error_log', '/dev/null'); + try { + $response = (new HttpRuntime($kernel, false))->handle(Request::create('/')); + } finally { + ini_set('error_log', (string) $previousLog); + } + + self::assertSame(500, $response->getStatusCode()); + self::assertSame(1, $kernel->terminations); + self::assertFalse($kernel->outcome?->successful); + self::assertInstanceOf(\RuntimeException::class, $kernel->outcome?->error); + } + + #[Test] + #[TestDox('Deferred termination failures do not replace the HTTP response')] + public function preservesResponses(): void + { + $kernel = $this->kernel(new ResponseMiddleware(new Response('accepted', 202))); + $kernel->terminationReport = new TerminationReport( + failures: [new \RuntimeException('Deferred listener failed.')], + ); + + $response = (new HttpRuntime($kernel, false))->handle(Request::create('/')); + + self::assertSame(202, $response->getStatusCode()); + self::assertSame('accepted', $response->getContent()); + } + + private function kernel(MiddlewareInterface $terminal): RuntimeKernel + { + return new RuntimeKernel( + new RuntimeContainer([ + TenantMiddleware::class => new PassMiddleware(), + FirewallMiddleware::class => new PassMiddleware(), + AuthenticationMiddleware::class => new PassMiddleware(), + RouterMiddleware::class => $terminal, + ]), + new TenantContext($this->createStub(TenantService::class)), + new IdentityContext(), + ); + } +} + +final class RuntimeKernel implements KernelInterface +{ + public bool $active = false; + public int $terminations = 0; + public ?ExecutionOutcome $outcome = null; + public TerminationReport $terminationReport; + + public function __construct( + private readonly ContainerInterface $services, + private readonly TenantContext $tenantContext, + private readonly IdentityContext $identityContext, + ) { + $this->terminationReport = new TerminationReport(); + } + + public function boot(): void + { + } + + public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope + { + $this->active = true; + + return new ExecutionScope( + $descriptor, + ExecutionContext::fromDescriptor($descriptor), + $this->tenantContext, + $this->identityContext, + ); + } + + public function terminateExecution( + ExecutionScope $scope, + ExecutionOutcome $outcome, + ): TerminationReport { + $this->active = false; + $this->terminations++; + $this->outcome = $outcome; + $scope->dispose(); + $scope->markTerminated(); + + return $this->terminationReport; + } + + public function shutdown(): void + { + } + + public function container(): ContainerInterface + { + return $this->services; + } + + public function environment(): string + { + return 'test'; + } + + public function debug(): bool + { + return false; + } +} + +final class RuntimeContainer implements ContainerInterface +{ + public function __construct( + private readonly array $services, + ) { + } + + public function get(string $id): mixed + { + return $this->services[$id]; + } + + public function has(string $id): bool + { + return isset($this->services[$id]); + } +} + +class PassMiddleware implements MiddlewareInterface +{ + public function process(Request $request, RequestHandlerInterface $handler): Response + { + return $handler->handle($request); + } +} + +final class StreamMiddleware extends PassMiddleware +{ + public function __construct( + private readonly \Closure $active, + ) { + } + + public function process(Request $request, RequestHandlerInterface $handler): Response + { + return new StreamedResponse(function (): void { + echo ($this->active)() ? 'active' : 'terminated'; + }); + } +} + +final class ResponseMiddleware extends PassMiddleware +{ + public function __construct( + private readonly Response $response, + ) { + } + + public function process(Request $request, RequestHandlerInterface $handler): Response + { + return $this->response; + } +} + +final class ThrowingMiddleware extends PassMiddleware +{ + public function process(Request $request, RequestHandlerInterface $handler): Response + { + throw new \RuntimeException('HTTP failed.'); + } +}