refactor(kernel): unify HTTP and CLI application lifecycle

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-07-27 00:48:11 -04:00
parent 98826143a8
commit 65c1b5fb75
67 changed files with 2737 additions and 1070 deletions
+5 -5
View File
@@ -7,21 +7,21 @@
declare(strict_types=1); declare(strict_types=1);
use KTXC\Server; use KTXC\Application;
if (!is_dir(dirname(__DIR__).'/vendor')) { if (!is_dir(dirname(__DIR__).'/vendor')) {
fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n"); fwrite(STDERR, "Dependencies are missing. Run 'composer install' first.\n");
exit(1); exit(1);
} }
require_once dirname(__DIR__).'/vendor/autoload.php'; $composerLoader = require_once dirname(__DIR__).'/vendor/autoload.php';
try { try {
$server = new Server(dirname(__DIR__)); $application = Application::create(dirname(__DIR__), $composerLoader);
exit($server->runConsole()); exit($application->runConsole());
} catch (\Throwable $e) { } catch (\Throwable $e) {
fwrite(STDERR, "Fatal error: {$e->getMessage()}\n"); fwrite(STDERR, "Fatal error: {$e->getMessage()}\n");
if (isset($server) && $server->debug()) { if (($application ?? null)?->debug()) {
fwrite(STDERR, $e->getTraceAsString()."\n"); fwrite(STDERR, $e->getTraceAsString()."\n");
} }
exit(1); exit(1);
+154
View File
@@ -0,0 +1,154 @@
<?php
declare(strict_types=1);
namespace KTXC;
use Composer\Autoload\ClassLoader;
use KTXC\Application\KernelOptions;
use KTXC\Application\ProjectPaths;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Module\ModuleAutoloader;
use KTXC\Runtime\Console\ConsoleRuntime;
use KTXC\Runtime\Http\HttpRuntime;
use Psr\Container\ContainerInterface;
final class Application
{
private readonly ProjectPaths $paths;
private readonly array $config;
private readonly Kernel $kernel;
private readonly HttpRuntime $http;
private readonly ConsoleRuntime $console;
public function __construct(
string $projectDir,
?ClassLoader $composerLoader = null,
?string $environment = null,
?bool $debug = null,
) {
$this->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;
}
}
@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class ExecutionContext
{
public function __construct(
public RuntimeType $runtime,
public string $executionId,
public string $correlationId,
public ?string $causationId = null,
public ?string $requestId = null,
public ?string $operationName = null,
public array $traceMetadata = [],
) {
}
public static function fromDescriptor(ExecutionDescriptor $descriptor): self
{
return new self(
$descriptor->runtime,
$descriptor->executionId,
$descriptor->correlationId,
operationName: $descriptor->operationName,
);
}
}
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class ExecutionDescriptor
{
public function __construct(
public RuntimeType $runtime,
public string $executionId,
public string $correlationId,
public ?string $operationName = null,
) {
}
public static function http(?string $operationName = null): self
{
$id = self::id();
return new self(RuntimeType::HTTP, $id, $id, $operationName);
}
public static function cli(?string $operationName = null): self
{
$id = self::id();
return new self(RuntimeType::CLI, $id, $id, $operationName);
}
private static function id(): string
{
return bin2hex(random_bytes(16));
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class ExecutionOutcome
{
private function __construct(
public bool $successful,
public mixed $result = null,
public ?\Throwable $error = null,
) {
}
public static function success(mixed $result = null): self
{
return new self(true, $result);
}
public static function failure(\Throwable $error, mixed $result = null): self
{
return new self(false, $result, $error);
}
public static function incomplete(): self
{
return new self(false);
}
}
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
use KTXC\Context\IdentityContext;
use KTXC\Context\TenantContext;
final class ExecutionScope
{
private bool $terminated = false;
public function __construct(
public readonly ExecutionDescriptor $descriptor,
public readonly ExecutionContext $context,
private readonly TenantContext $tenantContext,
private readonly IdentityContext $identityContext,
) {
$this->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();
}
}
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
enum RuntimeType: string
{
case HTTP = 'http';
case CLI = 'cli';
}
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace KTXC\Application\Execution;
final readonly class TerminationReport
{
/**
* @param list<\Throwable> $failures
*/
public function __construct(
public int $deferredProcessed = 0,
public int $deferredRemaining = 0,
public array $failures = [],
public bool $deadlineExceeded = false,
public bool $limitExceeded = false,
) {
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace KTXC\Application;
final readonly class KernelOptions
{
public function __construct(
public ProjectPaths $paths,
public string $environment = 'prod',
public bool $debug = false,
) {
if ($this->environment === '') {
throw new \InvalidArgumentException('Kernel environment cannot be empty.');
}
}
}
+53
View File
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace KTXC\Application;
final readonly class ProjectPaths
{
private function __construct(
public string $project,
) {
}
public static function resolve(string $start): self
{
$directory = is_file($start) ? dirname($start) : $start;
$directory = rtrim($directory, DIRECTORY_SEPARATOR);
while ($directory !== dirname($directory)) {
if (is_file($directory . '/composer.json')) {
return new self(realpath($directory) ?: $directory);
}
$directory = dirname($directory);
}
throw new \InvalidArgumentException("Unable to resolve project root from {$start}.");
}
public function configuration(): string
{
return $this->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';
}
}
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace KTXC\Console\Event;
use KTXF\Event\EventListenerRegistry;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
#[AsCommand(
name: 'events:debug',
description: 'Display the frozen event listener registry',
)]
final class EventsDebugCommand extends Command
{
public function __construct(
private readonly EventListenerRegistry $registry,
) {
parent::__construct();
}
protected function execute(InputInterface $input, OutputInterface $output): int
{
$io = new SymfonyStyle($input, $output);
$rows = [];
foreach ($this->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;
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Identity\User;
final class IdentityContext implements IdentityContextInterface
{
private ?User $identity = null;
public function initialize(User $identity): void
{
if ($this->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);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Identity\User;
interface IdentityContextInterface
{
public function present(): bool;
public function identity(): ?User;
public function identifier(): ?string;
public function requireIdentifier(): string;
public function label(): ?string;
public function mailAddress(): ?string;
public function nameFirst(): ?string;
public function nameLast(): ?string;
public function permissions(): array;
public function roles(): array;
public function hasPermission(string $permission): bool;
public function hasRole(string $role): bool;
}
+117
View File
@@ -0,0 +1,117 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
final class TenantContext implements TenantContextInterface
{
private ?TenantObject $tenant = null;
private ?string $domain = null;
public function __construct(
private readonly TenantService $tenantService,
) {
}
public function resolveDomain(string $domain): bool
{
$this->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);
}
}
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace KTXC\Context;
use KTXC\Models\Tenant\TenantConfiguration;
interface TenantContextInterface
{
public function present(): bool;
public function configured(): bool;
public function enabled(): bool;
public function domain(): ?string;
public function identifier(): ?string;
public function requireIdentifier(): string;
public function label(): ?string;
public function configuration(): ?TenantConfiguration;
public function settings(): array;
public function identityProviders(): array;
public function identityProviderConfig(string $providerId): ?array;
public function isIdentityProviderEnabled(string $providerId): bool;
}
+4 -4
View File
@@ -10,14 +10,14 @@ use KTXC\Http\Response\RedirectResponse;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AnonymousRoute; use KTXF\Routing\Attributes\AnonymousRoute;
use KTXC\Service\SecurityService; use KTXC\Service\SecurityService;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
class DefaultController extends ControllerAbstract class DefaultController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SecurityService $securityService, private readonly SecurityService $securityService,
private readonly SessionIdentity $identity, private readonly IdentityContextInterface $identityContext,
#[Inject('rootDir')] private readonly string $rootDir, #[Inject('rootDir')] private readonly string $rootDir,
) {} ) {}
@@ -25,7 +25,7 @@ class DefaultController extends ControllerAbstract
public function home(Request $request): Response public function home(Request $request): Response
{ {
// If an authenticated identity is available, serve the private app // If an authenticated identity is available, serve the private app
if ($this->identity->identifier()) { if ($this->identityContext->identifier()) {
return new FileResponse( return new FileResponse(
$this->rootDir . '/public/private.html', $this->rootDir . '/public/private.html',
Response::HTTP_OK, Response::HTTP_OK,
@@ -116,7 +116,7 @@ class DefaultController extends ControllerAbstract
public function catchAll(Request $request, string $path = ''): Response public function catchAll(Request $request, string $path = ''): Response
{ {
// If an authenticated identity is available, serve the private app // If an authenticated identity is available, serve the private app
if ($this->identity->identifier()) { if ($this->identityContext->identifier()) {
return new FileResponse( return new FileResponse(
$this->rootDir . '/public/private.html', $this->rootDir . '/public/private.html',
Response::HTTP_OK, Response::HTTP_OK,
+13 -13
View File
@@ -8,16 +8,16 @@ use KTXC\L10N\LocaleResolver;
use KTXC\Module\ModuleManager; use KTXC\Module\ModuleManager;
use KTXC\Security\Authorization\PermissionChecker; use KTXC\Security\Authorization\PermissionChecker;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
class InitController extends ControllerAbstract class InitController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenant, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly ModuleManager $moduleManager, private readonly ModuleManager $moduleManager,
private readonly UserAccountsService $userService, private readonly UserAccountsService $userService,
private readonly PermissionChecker $permissionChecker, private readonly PermissionChecker $permissionChecker,
@@ -54,21 +54,21 @@ class InitController extends ControllerAbstract
// tenant // tenant
$configuration['tenant'] = [ $configuration['tenant'] = [
'id' => $this->tenant->identifier(), 'id' => $this->tenantContext->identifier(),
'domain' => $this->tenant->domain(), 'domain' => $this->tenantContext->domain(),
'label' => $this->tenant->label(), 'label' => $this->tenantContext->label(),
]; ];
// user // user
$configuration['user'] = [ $configuration['user'] = [
'auth' => [ 'auth' => [
'identifier' => $this->userIdentity->identifier(), 'identifier' => $this->identityContext->identifier(),
'identity' => $this->userIdentity->identity()->getIdentity(), 'identity' => $this->identityContext->identity()->getIdentity(),
'label' => $this->userIdentity->label(), 'label' => $this->identityContext->label(),
'roles' => $this->userIdentity->identity()->getRoles(), 'roles' => $this->identityContext->identity()->getRoles(),
'permissions' => $this->userIdentity->identity()->getPermissions(), 'permissions' => $this->identityContext->identity()->getPermissions(),
], ],
'profile' => $this->userService->getEditableFields($this->userIdentity->identifier()), 'profile' => $this->userService->getEditableFields($this->identityContext->identifier()),
'settings' => $this->userService->fetchSettings([], true), 'settings' => $this->userService->fetchSettings([], true),
]; ];
@@ -4,7 +4,7 @@ namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\Service\TenantService; use KTXC\Service\TenantService;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
@@ -19,7 +19,7 @@ use KTXF\Routing\Attributes\AuthenticatedRoute;
class TenantSettingsController extends ControllerAbstract class TenantSettingsController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly TenantService $tenantService, private readonly TenantService $tenantService,
) {} ) {}
@@ -36,7 +36,7 @@ class TenantSettingsController extends ControllerAbstract
)] )]
public function read(): JsonResponse 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); return new JsonResponse($settings, JsonResponse::HTTP_OK);
} }
@@ -65,9 +65,9 @@ class TenantSettingsController extends ControllerAbstract
)] )]
public function update(array $data): JsonResponse 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); return new JsonResponse($updatedSettings, JsonResponse::HTTP_OK);
} }
+18 -18
View File
@@ -4,8 +4,8 @@ namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface;
class UserAccountsController extends ControllerAbstract class UserAccountsController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsService $userService, private readonly UserAccountsService $userService,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) {} ) {}
@@ -31,7 +31,7 @@ class UserAccountsController extends ControllerAbstract
{ {
try { try {
// Check admin permission // Check admin permission
if (!$this->userIdentity->hasPermission('user.admin')) { if (!$this->identityContext->hasPermission('user.admin')) {
return new JsonResponse([ return new JsonResponse([
'status' => 'error', 'status' => 'error',
'data' => ['code' => 403, 'message' => 'Insufficient permissions'] 'data' => ['code' => 403, 'message' => 'Insufficient permissions']
@@ -125,7 +125,7 @@ class UserAccountsController extends ControllerAbstract
*/ */
private function userCreate(array $data): array 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'); throw new \InvalidArgumentException('Insufficient permissions to create users');
} }
@@ -142,9 +142,9 @@ class UserAccountsController extends ControllerAbstract
]; ];
$this->logger->info('Creating user', [ $this->logger->info('Creating user', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'identity' => $userData['identity'], 'identity' => $userData['identity'],
'actor' => $this->userIdentity->identifier() 'actor' => $this->identityContext->identifier()
]); ]);
return $this->userService->createUser($userData); return $this->userService->createUser($userData);
@@ -155,7 +155,7 @@ class UserAccountsController extends ControllerAbstract
*/ */
private function userUpdate(array $data): bool 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'); throw new \InvalidArgumentException('Insufficient permissions to update users');
} }
@@ -186,9 +186,9 @@ class UserAccountsController extends ControllerAbstract
} }
$this->logger->info('Updating user', [ $this->logger->info('Updating user', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'uid' => $uid, 'uid' => $uid,
'actor' => $this->userIdentity->identifier() 'actor' => $this->identityContext->identifier()
]); ]);
return $this->userService->updateUser($uid, $updates); return $this->userService->updateUser($uid, $updates);
@@ -199,21 +199,21 @@ class UserAccountsController extends ControllerAbstract
*/ */
private function userDelete(array $data): bool 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'); throw new \InvalidArgumentException('Insufficient permissions to delete users');
} }
$uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required'); $uid = $data['uid'] ?? throw new \InvalidArgumentException('User ID required');
// Prevent self-deletion // Prevent self-deletion
if ($uid === $this->userIdentity->identifier()) { if ($uid === $this->identityContext->identifier()) {
throw new \InvalidArgumentException('Cannot delete your own account'); throw new \InvalidArgumentException('Cannot delete your own account');
} }
$this->logger->info('Deleting user', [ $this->logger->info('Deleting user', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'uid' => $uid, 'uid' => $uid,
'actor' => $this->userIdentity->identifier() 'actor' => $this->identityContext->identifier()
]); ]);
return $this->userService->deleteUser($uid); return $this->userService->deleteUser($uid);
@@ -228,7 +228,7 @@ class UserAccountsController extends ControllerAbstract
*/ */
private function userProviderUnlink(array $data): bool private function userProviderUnlink(array $data): bool
{ {
if (!$this->userIdentity->hasPermission('user.admin')) { if (!$this->identityContext->hasPermission('user.admin')) {
throw new \InvalidArgumentException('Insufficient permissions'); throw new \InvalidArgumentException('Insufficient permissions');
} }
@@ -241,9 +241,9 @@ class UserAccountsController extends ControllerAbstract
]; ];
$this->logger->info('Unlinking provider', [ $this->logger->info('Unlinking provider', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'uid' => $uid, 'uid' => $uid,
'actor' => $this->userIdentity->identifier() 'actor' => $this->identityContext->identifier()
]); ]);
return $this->userService->updateUser($uid, $updates); return $this->userService->updateUser($uid, $updates);
@@ -4,16 +4,16 @@ namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
class UserProfileController extends ControllerAbstract class UserProfileController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsService $userService private readonly UserAccountsService $userService
) {} ) {}
@@ -30,7 +30,7 @@ class UserProfileController extends ControllerAbstract
)] )]
public function read(): JsonResponse public function read(): JsonResponse
{ {
$userId = $this->userIdentity->identifier(); $userId = $this->identityContext->identifier();
// Get profile with editability metadata // Get profile with editability metadata
$profile = $this->userService->getEditableFields($userId); $profile = $this->userService->getEditableFields($userId);
@@ -63,7 +63,7 @@ class UserProfileController extends ControllerAbstract
)] )]
public function update(array $data): JsonResponse public function update(array $data): JsonResponse
{ {
$userId = $this->userIdentity->identifier(); $userId = $this->identityContext->identifier();
// storeProfile automatically filters out provider-managed fields // storeProfile automatically filters out provider-managed fields
$this->userService->storeProfile($userId, $data); $this->userService->storeProfile($userId, $data);
+8 -8
View File
@@ -3,8 +3,8 @@
namespace KTXC\Controllers; namespace KTXC\Controllers;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXC\Service\UserRolesService; use KTXC\Service\UserRolesService;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
@@ -17,8 +17,8 @@ use Psr\Log\LoggerInterface;
class UserRolesController extends ControllerAbstract class UserRolesController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly UserRolesService $roleService, private readonly UserRolesService $roleService,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) {} ) {}
@@ -31,7 +31,7 @@ class UserRolesController extends ControllerAbstract
{ {
try { try {
// Check role admin permission // Check role admin permission
if (!$this->userIdentity->hasPermission('role.admin')) { if (!$this->identityContext->hasPermission('role.admin')) {
return new JsonResponse([ return new JsonResponse([
'status' => 'error', 'status' => 'error',
'data' => ['code' => 403, 'message' => 'Insufficient permissions'] 'data' => ['code' => 403, 'message' => 'Insufficient permissions']
@@ -137,7 +137,7 @@ class UserRolesController extends ControllerAbstract
*/ */
private function roleCreate(array $data): array 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'); throw new \InvalidArgumentException('Insufficient permissions to create roles');
} }
@@ -155,7 +155,7 @@ class UserRolesController extends ControllerAbstract
*/ */
private function roleUpdate(array $data): bool 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'); throw new \InvalidArgumentException('Insufficient permissions to update roles');
} }
@@ -182,7 +182,7 @@ class UserRolesController extends ControllerAbstract
*/ */
private function roleDelete(array $data): bool 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'); throw new \InvalidArgumentException('Insufficient permissions to delete roles');
} }
@@ -5,16 +5,16 @@ namespace KTXC\Controllers;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\JsonResponse;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Controller\ControllerAbstract; use KTXF\Controller\ControllerAbstract;
use KTXF\Routing\Attributes\AuthenticatedRoute; use KTXF\Routing\Attributes\AuthenticatedRoute;
class UserSettingsController extends ControllerAbstract class UserSettingsController extends ControllerAbstract
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsService $userService private readonly UserAccountsService $userService
) {} ) {}
@@ -5,7 +5,10 @@ namespace KTXC\Http\Middleware;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response; use KTXC\Http\Response\Response;
use KTXC\Service\SecurityService; 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 * Authentication middleware
@@ -19,7 +22,10 @@ class AuthenticationMiddleware implements MiddlewareInterface
{ {
public function __construct( public function __construct(
private readonly SecurityService $securityService, 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 public function process(Request $request, RequestHandlerInterface $handler): Response
@@ -29,7 +35,11 @@ class AuthenticationMiddleware implements MiddlewareInterface
// Initialize session identity if authentication succeeded // Initialize session identity if authentication succeeded
if ($identity) { 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) // Continue to next middleware (authentication is optional at this stage)
@@ -6,7 +6,7 @@ use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response; use KTXC\Http\Response\Response;
use KTXC\Routing\Router; use KTXC\Routing\Router;
use KTXC\Routing\Route; use KTXC\Routing\Route;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\Security\Authorization\PermissionChecker; use KTXC\Security\Authorization\PermissionChecker;
/** /**
@@ -17,7 +17,7 @@ class RouterMiddleware implements MiddlewareInterface
{ {
public function __construct( public function __construct(
private readonly Router $router, private readonly Router $router,
private readonly SessionIdentity $sessionIdentity, private readonly IdentityContextInterface $identityContext,
private readonly PermissionChecker $permissionChecker private readonly PermissionChecker $permissionChecker
) {} ) {}
@@ -32,7 +32,7 @@ class RouterMiddleware implements MiddlewareInterface
} }
// Check if route requires authentication // Check if route requires authentication
if ($match->authenticated && $this->sessionIdentity->identity() === null) { if ($match->authenticated && $this->identityContext->identity() === null) {
return new Response( return new Response(
Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
Response::HTTP_UNAUTHORIZED Response::HTTP_UNAUTHORIZED
+14 -4
View File
@@ -4,7 +4,10 @@ namespace KTXC\Http\Middleware;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response; 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 * Tenant resolution middleware
@@ -13,16 +16,23 @@ use KTXC\SessionTenant;
class TenantMiddleware implements MiddlewareInterface class TenantMiddleware implements MiddlewareInterface
{ {
public function __construct( 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 public function process(Request $request, RequestHandlerInterface $handler): Response
{ {
// Configure tenant from request host // 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 // Check if tenant is configured and enabled
if (!$this->sessionTenant->configured() || !$this->sessionTenant->enabled()) { if (!$this->tenantContext->configured() || !$this->tenantContext->enabled()) {
return new Response( return new Response(
Response::$statusTexts[Response::HTTP_UNAUTHORIZED], Response::$statusTexts[Response::HTTP_UNAUTHORIZED],
Response::HTTP_UNAUTHORIZED Response::HTTP_UNAUTHORIZED
+140 -124
View File
@@ -9,13 +9,15 @@
namespace KTXC; namespace KTXC;
use KTXC\Http\Request\Request; use KTXC\Application\KernelOptions;
use KTXC\Http\Response\Response; use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Http\Middleware\MiddlewarePipeline; use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Http\Middleware\TenantMiddleware; use KTXC\Application\Execution\ExecutionScope;
use KTXC\Http\Middleware\FirewallMiddleware; use KTXC\Application\Execution\TerminationReport;
use KTXC\Http\Middleware\AuthenticationMiddleware; use KTXC\Context\IdentityContext;
use KTXC\Http\Middleware\RouterMiddleware; use KTXC\Context\IdentityContextInterface;
use KTXC\Context\TenantContext;
use KTXC\Context\TenantContextInterface;
use KTXC\Injection\Builder; use KTXC\Injection\Builder;
use KTXC\Injection\Container; use KTXC\Injection\Container;
use Psr\Container\ContainerInterface; use Psr\Container\ContainerInterface;
@@ -23,7 +25,10 @@ use KTXC\Module\ModuleManager;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use KTXC\Logger\LoggerFactory; use KTXC\Logger\LoggerFactory;
use KTXC\Logger\TenantAwareLogger; 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\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface; use KTXF\Cache\PersistentCacheInterface;
use KTXF\Cache\BlobCacheInterface; use KTXF\Cache\BlobCacheInterface;
@@ -31,7 +36,7 @@ use KTXF\Cache\Store\FileEphemeralCache;
use KTXF\Cache\Store\FilePersistentCache; use KTXF\Cache\Store\FilePersistentCache;
use KTXF\Cache\Store\FileBlobCache; use KTXF\Cache\Store\FileBlobCache;
class Kernel class Kernel implements KernelInterface
{ {
public const VERSION = '1.0.0'; public const VERSION = '1.0.0';
public const VERSION_ID = 10000; public const VERSION_ID = 10000;
@@ -45,26 +50,16 @@ class Kernel
protected ?float $startTime = null; protected ?float $startTime = null;
protected ?ContainerInterface $container = null; protected ?ContainerInterface $container = null;
protected ?LoggerInterface $logger = null; protected ?LoggerInterface $logger = null;
protected ?MiddlewarePipeline $pipeline = null; private bool $errorHandlerInstalled = false;
private ?ExecutionScope $activeScope = null;
private string $projectDir;
private array $config; private array $config;
public function __construct( public function __construct(
protected string $environment = 'prod', private readonly KernelOptions $options,
protected bool $debug = false,
array $config = [], 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; $this->config = $config;
if ($projectDir !== null) {
$this->projectDir = $projectDir;
}
} }
public function __clone() public function __clone()
@@ -77,10 +72,10 @@ class Kernel
private function initialize(): void private function initialize(): void
{ {
if ($this->debug) { if ($this->debug()) {
$this->startTime = microtime(true); $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')) { if (\function_exists('putenv')) {
putenv('SHELL_VERBOSITY=3'); putenv('SHELL_VERBOSITY=3');
} }
@@ -129,23 +124,7 @@ class Kernel
return true; return true;
}); });
// Handle uncaught exceptions $this->errorHandlerInstalled = true;
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 '<pre>Uncaught Exception: ' . $exception . '</pre>';
} else {
echo 'An unexpected error occurred. Please try again later.';
}
exit(1);
});
// Handle fatal errors // Handle fatal errors
register_shutdown_function(function () { register_shutdown_function(function () {
@@ -161,11 +140,6 @@ class Kernel
$this->logger->error($message, $error); $this->logger->error($message, $error);
if ($this->debug) {
echo '<pre>' . $message . '</pre>';
} else {
echo 'A fatal error occurred. Please try again later.';
}
} }
}); });
} }
@@ -180,9 +154,9 @@ class Kernel
/** @var ModuleManager $moduleManager */ /** @var ModuleManager $moduleManager */
$moduleManager = $this->container->get(ModuleManager::class); $moduleManager = $this->container->get(ModuleManager::class);
$moduleManager->modulesBoot(); $moduleManager->modulesBoot();
$this->container
// Build middleware pipeline ->get(EventListenerRegistry::class)
$this->pipeline = $this->buildMiddlewarePipeline(); ->freeze($this->container);
$this->booted = true; $this->booted = true;
} }
@@ -199,52 +173,108 @@ class Kernel
if (false === $this->initialized) { if (false === $this->initialized) {
return; 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->initialized = false;
$this->booted = false; $this->booted = false;
$this->container = null; $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) { if (!$this->booted) {
$this->boot(); $this->boot();
} }
if ($this->activeScope !== null) {
throw new \LogicException('The kernel already has an active execution scope.');
}
// Use middleware pipeline to handle the request $scope = new ExecutionScope(
return $this->pipeline->handle($request); $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;
} }
/** public function terminateExecution(
* Build the middleware pipeline ExecutionScope $scope,
*/ ExecutionOutcome $outcome,
protected function buildMiddlewarePipeline(): MiddlewarePipeline ): TerminationReport
{ {
$pipeline = new MiddlewarePipeline($this->container); if ($scope->terminated()) {
return new TerminationReport();
// Register middleware in execution order }
$pipeline->pipe(TenantMiddleware::class); if ($this->activeScope !== $scope) {
$pipeline->pipe(FirewallMiddleware::class); throw new \LogicException('Cannot terminate a scope that is not active.');
$pipeline->pipe(AuthenticationMiddleware::class); }
$pipeline->pipe(RouterMiddleware::class);
$processed = 0;
return $pipeline; $remaining = 0;
} $deadlineExceeded = false;
$limitExceeded = false;
$failures = [];
/**
* Process deferred events at the end of the request
*/
public function processEvents(): void
{
try { try {
if ($this->container && $this->container->has(EventBus::class)) { if ($this->container && $this->container->has(DeferredEventProcessorInterface::class)) {
/** @var EventBus $eventBus */ $result = $this->container
$eventBus = $this->container->get(EventBus::class); ->get(DeferredEventProcessorInterface::class)
$eventBus->processDeferred(); ->processDeferred($scope->descriptor->executionId);
$processed = $result->processed;
$remaining = $result->remaining;
$deadlineExceeded = $result->deadlineExceeded;
$limitExceeded = $result->limitExceeded;
} }
} catch (\Throwable $e) { } 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 [ return [
'kernel.project_dir' => realpath($this->folderRoot()) ?: $this->folderRoot(), '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_environment' => '%env(default:kernel.environment:APP_RUNTIME_ENV)%',
'kernel.runtime_mode' => '%env(query_string:default:container.runtime_mode:APP_RUNTIME_MODE)%', '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.web' => '%env(bool:default::key:web:default:kernel.runtime_mode:)%',
'kernel.runtime_mode.cli' => '%env(not:default:kernel.runtime_mode.web:)%', '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.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.build_dir' => realpath($this->getBuildDir()) ?: $this->getBuildDir(),
'kernel.cache_dir' => realpath($this->getCacheDir()) ?: $this->getCacheDir(), 'kernel.cache_dir' => realpath($this->getCacheDir()) ?: $this->getCacheDir(),
'kernel.logs_dir' => realpath($this->getLogDir()) ?: $this->getLogDir(), 'kernel.logs_dir' => realpath($this->getLogDir()) ?: $this->getLogDir(),
@@ -272,12 +302,12 @@ class Kernel
public function environment(): string public function environment(): string
{ {
return $this->environment; return $this->options->environment;
} }
public function debug(): bool public function debug(): bool
{ {
return $this->debug; return $this->options->debug;
} }
public function container(): ContainerInterface public function container(): ContainerInterface
@@ -291,7 +321,7 @@ class Kernel
public function getStartTime(): float 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 public function folderRoot(): string
{ {
if (!isset($this->projectDir)) { return $this->options->paths->project;
$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;
} }
@@ -325,12 +338,12 @@ class Kernel
*/ */
private function getConfigDir(): string private function getConfigDir(): string
{ {
return $this->folderRoot().'/config'; return $this->options->paths->configuration();
} }
public function getCacheDir(): string public function getCacheDir(): string
{ {
return $this->folderRoot().'/var/cache/'.$this->environment; return $this->options->paths->cache($this->environment());
} }
public function getBuildDir(): string public function getBuildDir(): string
@@ -340,7 +353,7 @@ class Kernel
public function getLogDir(): string public function getLogDir(): string
{ {
return $this->folderRoot().'/var/log'; return $this->options->paths->logs();
} }
public function getCharset(): string public function getCharset(): string
@@ -382,7 +395,7 @@ class Kernel
// Service definitions // Service definitions
$projectDir = $this->folderRoot(); $projectDir = $this->folderRoot();
$moduleDir = $projectDir . '/modules'; $moduleDir = $projectDir . '/modules';
$environment = $this->environment; $environment = $this->environment();
$builder->addDefinitions([ $builder->addDefinitions([
@@ -395,6 +408,9 @@ class Kernel
// Without this alias, PHP-DI will happily autowire a new empty Container when asked // Without this alias, PHP-DI will happily autowire a new empty Container when asked
Container::class => \DI\get(ContainerInterface::class), 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) { LoggerInterface::class => function (ContainerInterface $c) use ($projectDir) {
$logConfig = $this->config['log'] ?? []; $logConfig = $this->config['log'] ?? [];
@@ -405,7 +421,7 @@ class Kernel
return new TenantAwareLogger( return new TenantAwareLogger(
$this->logger, $this->logger,
$c->get(SessionTenant::class), $c->get(TenantContextInterface::class),
$logDir, $logDir,
$channel, $channel,
$level, $level,
@@ -413,8 +429,8 @@ class Kernel
); );
}, },
// EventBus as singleton for consistent event handling EventDispatcherInterface::class => \DI\get(EventDispatcher::class),
EventBus::class => \DI\create(EventBus::class), DeferredEventProcessorInterface::class => \DI\get(EventDispatcher::class),
// Ephemeral Cache - for short-lived data (sessions, rate limits, challenges) // Ephemeral Cache - for short-lived data (sessions, rate limits, challenges)
EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) { EphemeralCacheInterface::class => function(ContainerInterface $c) use ($projectDir) {
$storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file'; $storeType = $c->has('cache.ephemeral') ? $c->get('cache.ephemeral') : 'file';
@@ -433,13 +449,13 @@ class Kernel
$cache = new $storeClass($projectDir); $cache = new $storeClass($projectDir);
// Set tenant/user context if available // Set tenant/user context if available
if ($c->has(SessionTenant::class)) { if ($c->has(TenantContextInterface::class)) {
$tenant = $c->get(SessionTenant::class); $tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenant->identifier()); $cache->setTenantContext($tenantContext->identifier());
} }
if ($c->has(SessionIdentity::class)) { if ($c->has(IdentityContextInterface::class)) {
$identity = $c->get(SessionIdentity::class); $identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identity->identifier()); $cache->setUserContext($identityContext->identifier());
} }
return $cache; return $cache;
@@ -462,13 +478,13 @@ class Kernel
$cache = new $storeClass($projectDir); $cache = new $storeClass($projectDir);
// Set tenant/user context if available // Set tenant/user context if available
if ($c->has(SessionTenant::class)) { if ($c->has(TenantContextInterface::class)) {
$tenant = $c->get(SessionTenant::class); $tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenant->identifier()); $cache->setTenantContext($tenantContext->identifier());
} }
if ($c->has(SessionIdentity::class)) { if ($c->has(IdentityContextInterface::class)) {
$identity = $c->get(SessionIdentity::class); $identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identity->identifier()); $cache->setUserContext($identityContext->identifier());
} }
return $cache; return $cache;
@@ -491,13 +507,13 @@ class Kernel
$cache = new $storeClass($projectDir); $cache = new $storeClass($projectDir);
// Set tenant/user context if available // Set tenant/user context if available
if ($c->has(SessionTenant::class)) { if ($c->has(TenantContextInterface::class)) {
$tenant = $c->get(SessionTenant::class); $tenantContext = $c->get(TenantContextInterface::class);
$cache->setTenantContext($tenant->identifier()); $cache->setTenantContext($tenantContext->identifier());
} }
if ($c->has(SessionIdentity::class)) { if ($c->has(IdentityContextInterface::class)) {
$identity = $c->get(SessionIdentity::class); $identityContext = $c->get(IdentityContextInterface::class);
$cache->setUserContext($identity->identifier()); $cache->setUserContext($identityContext->identifier());
} }
return $cache; return $cache;
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace KTXC;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Application\Execution\ExecutionScope;
use KTXC\Application\Execution\TerminationReport;
use Psr\Container\ContainerInterface;
interface KernelInterface
{
public function boot(): void;
public function beginExecution(ExecutionDescriptor $descriptor): ExecutionScope;
public function terminateExecution(
ExecutionScope $scope,
ExecutionOutcome $outcome,
): TerminationReport;
public function shutdown(): void;
public function container(): ContainerInterface;
public function environment(): string;
public function debug(): bool;
}
+1 -1
View File
@@ -15,7 +15,7 @@ use Psr\Log\NullLogger;
* *
* Per-tenant routing (TenantAwareLogger) is applied separately inside the * Per-tenant routing (TenantAwareLogger) is applied separately inside the
* DI container definition in Kernel::configureContainer(), because that * DI container definition in Kernel::configureContainer(), because that
* requires access to the SessionTenant singleton which lives in the container. * requires access to the TenantContextInterface singleton which lives in the container.
* *
* Supported config keys inside $config['log']: * Supported config keys inside $config['log']:
* *
+6 -6
View File
@@ -2,7 +2,7 @@
namespace KTXC\Logger; namespace KTXC\Logger;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
/** /**
@@ -20,7 +20,7 @@ use Psr\Log\LoggerInterface;
* {logDir}/tenant/{tenantIdentifier}/{channel}.jsonl * {logDir}/tenant/{tenantIdentifier}/{channel}.jsonl
* Messages that carry tenant "system" always go to the global logger. * Messages that carry tenant "system" always go to the global logger.
* *
* Both behaviours rely on a live SessionTenant reference that is populated lazily * Both behaviours rely on a live TenantContextInterface reference that is populated lazily
* by TenantMiddleware — the same pattern the cache stores use in Kernel::configureContainer(). * by TenantMiddleware — the same pattern the cache stores use in Kernel::configureContainer().
*/ */
class TenantAwareLogger implements LoggerInterface class TenantAwareLogger implements LoggerInterface
@@ -30,7 +30,7 @@ class TenantAwareLogger implements LoggerInterface
/** /**
* @param LoggerInterface $globalLogger Fallback logger (also used when perTenant = false). * @param LoggerInterface $globalLogger Fallback logger (also used when perTenant = false).
* @param SessionTenant $sessionTenant Live reference configured by TenantMiddleware. * @param TenantContextInterface $tenantContext Live reference configured by TenantMiddleware.
* @param string $logDir Base log directory (e.g. /var/www/app/var/log). * @param string $logDir Base log directory (e.g. /var/www/app/var/log).
* @param string $channel Log file basename (e.g. 'app' → app.jsonl). * @param string $channel Log file basename (e.g. 'app' → app.jsonl).
* @param string $minLevel Minimum PSR-3 level for lazily-created per-tenant loggers. * @param string $minLevel Minimum PSR-3 level for lazily-created per-tenant loggers.
@@ -38,7 +38,7 @@ class TenantAwareLogger implements LoggerInterface
*/ */
public function __construct( public function __construct(
private readonly LoggerInterface $globalLogger, private readonly LoggerInterface $globalLogger,
private readonly SessionTenant $sessionTenant, private readonly TenantContextInterface $tenantContext,
private readonly string $logDir, private readonly string $logDir,
private readonly string $channel = 'app', private readonly string $channel = 'app',
private readonly string $minLevel = 'debug', private readonly string $minLevel = 'debug',
@@ -60,8 +60,8 @@ class TenantAwareLogger implements LoggerInterface
{ {
// Resolve current tenant id; fall back to 'system' for CLI / boot phase. // Resolve current tenant id; fall back to 'system' for CLI / boot phase.
$tenantId = 'system'; $tenantId = 'system';
if ($this->sessionTenant->configured()) { if ($this->tenantContext->configured()) {
$tenantId = $this->sessionTenant->identifier() ?? 'system'; $tenantId = $this->tenantContext->identifier() ?? 'system';
} }
// Inject tenant id as a reserved context key that concrete loggers extract. // Inject tenant id as a reserved context key that concrete loggers extract.
+35 -1
View File
@@ -2,6 +2,10 @@
namespace KTXC\Module; 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\ModuleBrowserInterface;
use KTXF\Module\ModuleConsoleInterface; use KTXF\Module\ModuleConsoleInterface;
use KTXF\Module\ModuleInstanceAbstract; use KTXF\Module\ModuleInstanceAbstract;
@@ -13,7 +17,36 @@ use KTXF\Module\ModuleInstanceAbstract;
*/ */
class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface 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 public function handle(): string
{ {
@@ -99,6 +132,7 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M
public function registerCI(): array public function registerCI(): array
{ {
return [ return [
\KTXC\Console\Event\EventsDebugCommand::class,
\KTXC\Console\Module\ModuleListCommand::class, \KTXC\Console\Module\ModuleListCommand::class,
\KTXC\Console\Module\ModuleEnableCommand::class, \KTXC\Console\Module\ModuleEnableCommand::class,
\KTXC\Console\Module\ModuleDisableCommand::class, \KTXC\Console\Module\ModuleDisableCommand::class,
+7 -5
View File
@@ -2,7 +2,7 @@
namespace KTXC\Module; namespace KTXC\Module;
use KTXC\Server; use Composer\Autoload\ClassLoader;
/** /**
* Custom autoloader for modules that allows PascalCase namespaces * Custom autoloader for modules that allows PascalCase namespaces
@@ -20,7 +20,10 @@ class ModuleAutoloader
private array $namespaceMap = []; private array $namespaceMap = [];
private bool $scanned = false; private bool $scanned = false;
public function __construct(string $modulesRoot) public function __construct(
string $modulesRoot,
private readonly ?ClassLoader $composerLoader = null,
)
{ {
$this->modulesRoot = rtrim($modulesRoot, '/'); $this->modulesRoot = rtrim($modulesRoot, '/');
} }
@@ -73,10 +76,9 @@ class ModuleAutoloader
} }
// Register module namespaces with Composer ClassLoader // Register module namespaces with Composer ClassLoader
$composerLoader = Server::getComposerLoader(); if ($this->composerLoader !== null) {
if ($composerLoader !== null) {
foreach ($this->namespaceMap as $namespace => $folderName) { foreach ($this->namespaceMap as $namespace => $folderName) {
$composerLoader->addPsr4( $this->composerLoader->addPsr4(
'KTXM\\' . $namespace . '\\', 'KTXM\\' . $namespace . '\\',
$this->modulesRoot . '/' . $folderName . '/lib/' $this->modulesRoot . '/' . $folderName . '/lib/'
); );
+2 -1
View File
@@ -276,12 +276,13 @@ class ModuleManager
try { try {
$module->boot(); $module->boot();
$this->logger->debug('Module booted', ['handle' => $handle]); $this->logger->debug('Module booted', ['handle' => $handle]);
} catch (Exception $e) { } catch (\Throwable $e) {
$this->logger->error('Module boot failed: ' . $handle, [ $this->logger->error('Module boot failed: ' . $handle, [
'exception' => $e, 'exception' => $e,
'message' => $e->getMessage(), 'message' => $e->getMessage(),
'code' => $e->getCode(), 'code' => $e->getCode(),
]); ]);
throw $e;
} }
} }
} }
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace KTXC\Runtime\Console;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Kernel;
use KTXC\KernelInterface;
use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\LazyCommand;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
final class ConsoleRuntime
{
public function __construct(
private readonly KernelInterface $kernel,
) {
}
public function run(
?InputInterface $input = null,
?OutputInterface $output = null,
): int
{
$scope = $this->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),
));
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace KTXC\Runtime\Http;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Http\Middleware\AuthenticationMiddleware;
use KTXC\Http\Middleware\FirewallMiddleware;
use KTXC\Http\Middleware\MiddlewarePipeline;
use KTXC\Http\Middleware\RouterMiddleware;
use KTXC\Http\Middleware\TenantMiddleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\KernelInterface;
final class HttpRuntime
{
public function __construct(
private readonly KernelInterface $kernel,
private readonly bool $debug,
) {
}
public function run(?Request $request = null): Response
{
return $this->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
? '<pre>' . htmlspecialchars((string) $error) . '</pre>'
: 'An error occurred. Please try again later.';
return new Response($content, Response::HTTP_INTERNAL_SERVER_ERROR, [
'Content-Type' => 'text/html; charset=UTF-8',
]);
}
}
+10 -10
View File
@@ -10,7 +10,7 @@ use KTXC\Security\Authentication\AuthenticationRequest;
use KTXC\Security\Authentication\AuthenticationResponse; use KTXC\Security\Authentication\AuthenticationResponse;
use KTXC\Service\TokenService; use KTXC\Service\TokenService;
use KTXC\Service\UserAccountsService; use KTXC\Service\UserAccountsService;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Cache\CacheScope; use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface; use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Security\Authentication\AuthenticationProviderInterface; use KTXF\Security\Authentication\AuthenticationProviderInterface;
@@ -26,13 +26,13 @@ class AuthenticationManager
private string $securityCode; private string $securityCode;
public function __construct( public function __construct(
private readonly SessionTenant $tenant, private readonly TenantContextInterface $tenantContext,
private readonly EphemeralCacheInterface $cache, private readonly EphemeralCacheInterface $cache,
private readonly ProviderManager $providerManager, private readonly ProviderManager $providerManager,
private readonly TokenService $tokenService, private readonly TokenService $tokenService,
private readonly UserAccountsService $userService, 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(); $methods = $this->methodsConfigured();
$session = AuthenticationSession::create( $session = AuthenticationSession::create(
$this->tenant->identifier(), $this->tenantContext->identifier(),
AuthenticationSession::STATE_FRESH AuthenticationSession::STATE_FRESH
); );
@@ -103,7 +103,7 @@ class AuthenticationManager
// Filter to non-redirect methods since redirects don't need identity first // Filter to non-redirect methods since redirects don't need identity first
$methods = $this->methodsConfigured(); $methods = $this->methodsConfigured();
$methods = array_values(array_filter($methods, fn($m) => $m['method'] !== 'redirect')); $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 // Store identity in session without validating to prevent enumeration
$session->setMethods(array_column($methods, 'id'), $require); $session->setMethods(array_column($methods, 'id'), $require);
@@ -429,7 +429,7 @@ class AuthenticationManager
$session->methodCompleted($method); $session->methodCompleted($method);
// Check if MFA is required // Check if MFA is required
$require = $this->tenant->configuration()->authentication()->methodsMinimal(); $require = $this->tenantContext->configuration()->authentication()->methodsMinimal();
if ($require > 1) { if ($require > 1) {
$remainingMethods = $this->methodsConfigured([$method]); $remainingMethods = $this->methodsConfigured([$method]);
// Filter out redirect methods - they can't be used as secondary factors // Filter out redirect methods - they can't be used as secondary factors
@@ -523,7 +523,7 @@ class AuthenticationManager
$accessToken = $this->tokenService->createToken( $accessToken = $this->tokenService->createToken(
[ [
'tenant' => $this->tenant->identifier(), 'tenant' => $this->tenantContext->identifier(),
'identifier' => $user->getId(), 'identifier' => $user->getId(),
'identity' => $user->getIdentity(), 'identity' => $user->getIdentity(),
'label' => $user->getLabel(), 'label' => $user->getLabel(),
@@ -585,7 +585,7 @@ class AuthenticationManager
*/ */
private function getProviderConfig(string $method): array private function getProviderConfig(string $method): array
{ {
$providers = $this->tenant->configuration()->authentication()->providers(); $providers = $this->tenantContext->configuration()->authentication()->providers();
return $providers[$method]['config'] ?? []; return $providers[$method]['config'] ?? [];
} }
@@ -635,7 +635,7 @@ class AuthenticationManager
*/ */
private function methodsConfigured(array $methodsCompleted = []): array private function methodsConfigured(array $methodsCompleted = []): array
{ {
$tenantProviders = $this->tenant->configuration()->authentication()->providers(); $tenantProviders = $this->tenantContext->configuration()->authentication()->providers();
$methods = []; $methods = [];
foreach ($tenantProviders as $providerId => $providerConfiguration) { foreach ($tenantProviders as $providerId => $providerConfiguration) {
@@ -669,7 +669,7 @@ class AuthenticationManager
private function createTokens(User $user, bool $mfaVerified = false): array private function createTokens(User $user, bool $mfaVerified = false): array
{ {
$payload = [ $payload = [
'tenant' => $this->tenant->identifier(), 'tenant' => $this->tenantContext->identifier(),
'identifier' => $user->getId(), 'identifier' => $user->getId(),
'identity' => $user->getIdentity(), 'identity' => $user->getIdentity(),
'label' => $user->getLabel(), 'label' => $user->getLabel(),
@@ -2,7 +2,7 @@
namespace KTXC\Security\Authorization; namespace KTXC\Security\Authorization;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
/** /**
* Permission Checker * Permission Checker
@@ -11,7 +11,7 @@ use KTXC\SessionIdentity;
class PermissionChecker class PermissionChecker
{ {
public function __construct( 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 public function can(string $permission, mixed $resource = null): bool
{ {
$identity = $this->sessionIdentity->identity(); $identity = $this->identityContext->identity();
if (!$identity) { if (!$identity) {
return false; return false;
@@ -113,7 +113,7 @@ class PermissionChecker
*/ */
public function getUserPermissions(): array public function getUserPermissions(): array
{ {
$identity = $this->sessionIdentity->identity(); $identity = $this->identityContext->identity();
if (!$identity) { if (!$identity) {
return []; return [];
-311
View File
@@ -1,311 +0,0 @@
<?php
namespace KTXC;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Module\ModuleAutoloader;
use KTXC\Module\ModuleManager;
use KTXF\Module\ModuleConsoleInterface;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Application as ConsoleApplication;
use Symfony\Component\Console\Attribute\AsCommand;
use Symfony\Component\Console\Command\LazyCommand;
/**
* Server class - entry point for the framework
* Handles configuration loading and kernel lifecycle
*/
class Server
{
private static $composerLoader = null;
private static ?self $instance = null;
private Kernel $kernel;
private array $config;
private string $rootDir;
public function __construct(string $rootDir, ?string $environment = null, ?bool $debug = null)
{
self::$instance = $this;
$this->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()
? '<pre>' . htmlspecialchars((string) $e) . '</pre>'
: '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");
}
}
}
+14 -14
View File
@@ -5,7 +5,7 @@ namespace KTXC\Service;
use KTXC\Db\DataStore; use KTXC\Db\DataStore;
use KTXC\Db\Collection; use KTXC\Db\Collection;
use KTXC\Db\UTCDateTime; use KTXC\Db\UTCDateTime;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
class ConfigurationService class ConfigurationService
{ {
@@ -24,7 +24,7 @@ class ConfigurationService
public function __construct( public function __construct(
DataStore $store, DataStore $store,
private readonly SessionTenant $tenant private readonly TenantContextInterface $tenantContext
) { ) {
// DataStore provides selectCollection method // DataStore provides selectCollection method
$this->collection = $store->selectCollection(self::TABLE_NAME); $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 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.'); throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) { } elseif ($tenant === null) {
$tenant = $this->tenant->identifier(); $tenant = $this->tenantContext->identifier();
} }
$doc = $this->collection->findOne(['did' => $tenant, 'path' => $path, 'key' => $key]); $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 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.'); throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) { } elseif ($tenant === null) {
$tenant = $this->tenant->identifier(); $tenant = $this->tenantContext->identifier();
} }
$type = $this->determineType($value); $type = $this->determineType($value);
@@ -84,10 +84,10 @@ class ConfigurationService
*/ */
public function getByPath(?string $path = null, bool $subset = false, ?string $tenant = null): array 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.'); throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) { } elseif ($tenant === null) {
$tenant = $this->tenant->identifier(); $tenant = $this->tenantContext->identifier();
} }
$filter = ['did' => $tenant]; $filter = ['did' => $tenant];
@@ -116,10 +116,10 @@ class ConfigurationService
*/ */
public function delete(string $path, string $key, ?string $tenant = null): bool 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.'); throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) { } elseif ($tenant === null) {
$tenant = $this->tenant->identifier(); $tenant = $this->tenantContext->identifier();
} }
$this->collection->deleteOne(['did' => $tenant, 'path' => $path, 'key' => $key]); $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 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.'); throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) { } elseif ($tenant === null) {
$tenant = $this->tenant->identifier(); $tenant = $this->tenantContext->identifier();
} }
$filter = ['did' => $tenant]; $filter = ['did' => $tenant];
@@ -155,10 +155,10 @@ class ConfigurationService
*/ */
public function exists(string $path, string $key, ?string $tenant = null): bool 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.'); throw new \InvalidArgumentException('Tenant must be configured or provided explicitly.');
} elseif ($tenant === null) { } elseif ($tenant === null) {
$tenant = $this->tenant->identifier(); $tenant = $this->tenantContext->identifier();
} }
return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0; return $this->collection->countDocuments(['did' => $tenant, 'path' => $path, 'key' => $key]) > 0;
+25 -49
View File
@@ -8,8 +8,8 @@ use KTXC\Http\Request\Request;
use KTXC\Models\Firewall\FirewallRuleObject; use KTXC\Models\Firewall\FirewallRuleObject;
use KTXC\Models\Firewall\FirewallLogObject; use KTXC\Models\Firewall\FirewallLogObject;
use KTXC\Stores\FirewallStore; use KTXC\Stores\FirewallStore;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Event\EventBus; use KTXF\Event\EventDispatcherInterface;
use KTXF\Event\SecurityEvent; use KTXF\Event\SecurityEvent;
use KTXF\IpUtils; use KTXF\IpUtils;
@@ -41,33 +41,9 @@ class FirewallService
public function __construct( public function __construct(
private readonly FirewallStore $store, private readonly FirewallStore $store,
private readonly SessionTenant $tenant, private readonly TenantContextInterface $tenantContext,
private readonly EventBus $eventBus 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); return new FirewallAnalyzeResult(true);
} }
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
return new FirewallAnalyzeResult(true); return new FirewallAnalyzeResult(true);
} }
@@ -158,7 +134,7 @@ class FirewallService
public function handleAuthFailure(SecurityEvent $event): void public function handleAuthFailure(SecurityEvent $event): void
{ {
$ipAddress = $event->getIpAddress(); $ipAddress = $event->getIpAddress();
$tenantId = $event->getTenantId() ?? $this->tenant->identifier(); $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
if (!$ipAddress || !$tenantId) { if (!$ipAddress || !$tenantId) {
return; return;
@@ -198,8 +174,8 @@ class FirewallService
): void { ): void {
// Publish brute force event // Publish brute force event
$event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds); $event = SecurityEvent::bruteForceDetected($ipAddress, $failureCount, $windowSeconds);
$event->setTenantId($this->tenant->identifier()); $event->setTenantId($this->tenantContext->identifier());
$this->eventBus->publish($event); $this->events->dispatch($event);
// Auto-block the IP // Auto-block the IP
$blockDuration = $this->getConfig( $blockDuration = $this->getConfig(
@@ -220,7 +196,7 @@ class FirewallService
*/ */
public function logSecurityEvent(SecurityEvent $event): void public function logSecurityEvent(SecurityEvent $event): void
{ {
$tenantId = $event->getTenantId() ?? $this->tenant->identifier(); $tenantId = $event->getTenantId() ?? $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
return; return;
} }
@@ -283,8 +259,8 @@ class FirewallService
$rule->getId(), $rule->getId(),
$rule->getReason() $rule->getReason()
); );
$event->setTenantId($this->tenant->identifier()); $event->setTenantId($this->tenantContext->identifier());
$this->eventBus->publish($event); $this->events->dispatch($event);
} }
// ======================================== // ========================================
@@ -300,7 +276,7 @@ class FirewallService
?string $createdBy = null, ?string $createdBy = null,
?int $durationSeconds = null ?int $durationSeconds = null
): FirewallRuleObject { ): FirewallRuleObject {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
} }
@@ -340,7 +316,7 @@ class FirewallService
$event->setIpAddress($ipAddress) $event->setIpAddress($ipAddress)
->setReason($reason) ->setReason($reason)
->setTenantId($tenantId); ->setTenantId($tenantId);
$this->eventBus->publish($event); $this->events->dispatch($event);
return $rule; return $rule;
} }
@@ -353,7 +329,7 @@ class FirewallService
?string $reason = null, ?string $reason = null,
?string $createdBy = null ?string $createdBy = null
): FirewallRuleObject { ): FirewallRuleObject {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
} }
@@ -376,7 +352,7 @@ class FirewallService
$event->setIpAddress($ipAddress) $event->setIpAddress($ipAddress)
->setReason($reason) ->setReason($reason)
->setTenantId($tenantId); ->setTenantId($tenantId);
$this->eventBus->publish($event); $this->events->dispatch($event);
return $rule; return $rule;
} }
@@ -389,7 +365,7 @@ class FirewallService
?string $reason = null, ?string $reason = null,
?string $createdBy = null ?string $createdBy = null
): FirewallRuleObject { ): FirewallRuleObject {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
} }
@@ -419,7 +395,7 @@ class FirewallService
?string $createdBy = null, ?string $createdBy = null,
?int $durationSeconds = null ?int $durationSeconds = null
): FirewallRuleObject { ): FirewallRuleObject {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
throw new \RuntimeException('Cannot create firewall rule: no tenant configured'); throw new \RuntimeException('Cannot create firewall rule: no tenant configured');
} }
@@ -448,7 +424,7 @@ class FirewallService
$event->setDeviceFingerprint($fingerprint) $event->setDeviceFingerprint($fingerprint)
->setReason($reason) ->setReason($reason)
->setTenantId($tenantId); ->setTenantId($tenantId);
$this->eventBus->publish($event); $this->events->dispatch($event);
return $rule; return $rule;
} }
@@ -464,7 +440,7 @@ class FirewallService
} }
// Verify tenant ownership // Verify tenant ownership
if ($rule->getTenantId() !== $this->tenant->identifier()) { if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false; return false;
} }
@@ -485,7 +461,7 @@ class FirewallService
} }
// Verify tenant ownership // Verify tenant ownership
if ($rule->getTenantId() !== $this->tenant->identifier()) { if ($rule->getTenantId() !== $this->tenantContext->identifier()) {
return false; return false;
} }
@@ -501,7 +477,7 @@ class FirewallService
*/ */
public function listRules(bool $activeOnly = true): array public function listRules(bool $activeOnly = true): array
{ {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
return []; return [];
} }
@@ -518,7 +494,7 @@ class FirewallService
?string $result = null, ?string $result = null,
int $limit = 100 int $limit = 100
): array { ): array {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
return []; return [];
} }
@@ -531,7 +507,7 @@ class FirewallService
*/ */
public function getBlockedCount(?\DateTimeImmutable $since = null): int public function getBlockedCount(?\DateTimeImmutable $since = null): int
{ {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
if (!$tenantId) { if (!$tenantId) {
return 0; return 0;
} }
@@ -556,7 +532,7 @@ class FirewallService
*/ */
private function getConfig(string $key, mixed $default = null): mixed private function getConfig(string $key, mixed $default = null): mixed
{ {
$config = $this->tenant->configuration(); $config = $this->tenantContext->configuration();
$parts = explode('.', $key); $parts = explode('.', $key);
foreach ($parts as $part) { foreach ($parts as $part) {
@@ -576,7 +552,7 @@ class FirewallService
private function getActiveRules(): array private function getActiveRules(): array
{ {
if ($this->rulesCache === null) { if ($this->rulesCache === null) {
$tenantId = $this->tenant->identifier(); $tenantId = $this->tenantContext->identifier();
$this->rulesCache = $tenantId $this->rulesCache = $tenantId
? $this->store->listRules($tenantId, true) ? $this->store->listRules($tenantId, true)
: []; : [];
+4 -4
View File
@@ -7,7 +7,7 @@ namespace KTXC\Service;
use KTXC\Http\Request\Request; use KTXC\Http\Request\Request;
use KTXC\Models\Identity\User; use KTXC\Models\Identity\User;
use KTXC\Resource\ProviderManager; use KTXC\Resource\ProviderManager;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Security\Authentication\AuthenticationProviderInterface; use KTXF\Security\Authentication\AuthenticationProviderInterface;
/** /**
@@ -23,12 +23,12 @@ class SecurityService
private string $securityCode; private string $securityCode;
public function __construct( public function __construct(
private readonly SessionTenant $sessionTenant, private readonly TenantContextInterface $tenantContext,
private readonly TokenService $tokenService, private readonly TokenService $tokenService,
private readonly UserAccountsService $userService, private readonly UserAccountsService $userService,
private readonly ProviderManager $providerManager, 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; continue;
} }
$context = new \KTXF\Security\Authentication\ProviderContext( $context = new \KTXF\Security\Authentication\ProviderContext(
tenantId: $this->sessionTenant->identifier(), tenantId: $this->tenantContext->identifier(),
userIdentity: $identity, userIdentity: $identity,
); );
$result = $provider->verify($context, $credentials); $result = $provider->verify($context, $credentials);
+2 -2
View File
@@ -2,7 +2,7 @@
namespace KTXC\Service; namespace KTXC\Service;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXF\Cache\CacheScope; use KTXF\Cache\CacheScope;
use KTXF\Cache\EphemeralCacheInterface; use KTXF\Cache\EphemeralCacheInterface;
@@ -26,7 +26,7 @@ class TokenService
private string $algorithm = 'HS256'; private string $algorithm = 'HS256';
public function __construct( public function __construct(
private readonly SessionTenant $sessionTenant, private readonly TenantContextInterface $tenantContext,
private readonly EphemeralCacheInterface $cache, private readonly EphemeralCacheInterface $cache,
) { ) {
} }
+16 -16
View File
@@ -3,16 +3,16 @@
namespace KTXC\Service; namespace KTXC\Service;
use KTXC\Models\Identity\User; use KTXC\Models\Identity\User;
use KTXC\SessionIdentity; use KTXC\Context\IdentityContextInterface;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXC\Stores\UserAccountsStore; use KTXC\Stores\UserAccountsStore;
class UserAccountsService class UserAccountsService
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly SessionIdentity $userIdentity, private readonly IdentityContextInterface $identityContext,
private readonly UserAccountsStore $userStore private readonly UserAccountsStore $userStore
) { ) {
} }
@@ -26,7 +26,7 @@ class UserAccountsService
*/ */
public function listUsers(array $filters = []): array 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 // Remove sensitive data
foreach ($users as &$user) { foreach ($users as &$user) {
@@ -38,7 +38,7 @@ class UserAccountsService
public function fetchByIdentity(string $identifier): User | null 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) { if (!$data) {
return null; return null;
} }
@@ -50,32 +50,32 @@ class UserAccountsService
public function fetchByIdentifier(string $identifier): array | null 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 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 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 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 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 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 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 public function storeProfile(string $uid, array $profileFields): bool
@@ -109,7 +109,7 @@ class UserAccountsService
return false; 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 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 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);
} }
// ========================================================================= // =========================================================================
+12 -12
View File
@@ -2,7 +2,7 @@
namespace KTXC\Service; namespace KTXC\Service;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use KTXC\Stores\UserRolesStore; use KTXC\Stores\UserRolesStore;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
@@ -12,7 +12,7 @@ use Psr\Log\LoggerInterface;
class UserRolesService class UserRolesService
{ {
public function __construct( public function __construct(
private readonly SessionTenant $tenantIdentity, private readonly TenantContextInterface $tenantContext,
private readonly UserRolesStore $roleStore, private readonly UserRolesStore $roleStore,
private readonly LoggerInterface $logger private readonly LoggerInterface $logger
) {} ) {}
@@ -26,7 +26,7 @@ class UserRolesService
*/ */
public function listRoles(): array 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 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->validateRoleData($roleData);
$this->logger->info('Creating role', [ $this->logger->info('Creating role', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'label' => $roleData['label'] ?? 'Unnamed' '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->validateRoleData($updates, false);
$this->logger->info('Updating role', [ $this->logger->info('Updating role', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'rid' => $rid '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 // 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) { if ($userCount > 0) {
throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)"); throw new \InvalidArgumentException("Cannot delete role assigned to {$userCount} user(s)");
} }
$this->logger->info('Deleting role', [ $this->logger->info('Deleting role', [
'tenant' => $this->tenantIdentity->identifier(), 'tenant' => $this->tenantContext->identifier(),
'rid' => $rid '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 public function getRoleUserCount(string $rid): int
{ {
return $this->roleStore->countUsersInRole($this->tenantIdentity->identifier(), $rid); return $this->roleStore->countUsersInRole($this->tenantContext->identifier(), $rid);
} }
/** /**
-94
View File
@@ -1,94 +0,0 @@
<?php
namespace KTXC;
use KTXC\Models\Identity\User;
class SessionIdentity
{
private bool $identityLock = false;
private ?User $identityData = null;
public function initialize(User $identity, bool $lock = true): void
{
if ($this->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());
}
}
-145
View File
@@ -1,145 +0,0 @@
<?php
namespace KTXC;
use KTXC\Models\Tenant\TenantConfiguration;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
class SessionTenant
{
private ?TenantObject $tenant = null;
private ?string $domain = null;
private bool $configured = false;
public function __construct(
private readonly TenantService $tenantService
) {}
/**
* Configure the tenant information
* This method is called by the SecurityMiddleware after validation
*/
public function configure(string $domain): void
{
if ($this->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<string, 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);
}
}
+3 -5
View File
@@ -1,11 +1,9 @@
<?php <?php
use KTXC\Server; use KTXC\Application;
// Capture Composer ClassLoader instance for compatibility // Capture Composer ClassLoader instance for compatibility
$composerLoader = require_once __DIR__ . '/../vendor/autoload.php'; $composerLoader = require_once __DIR__ . '/../vendor/autoload.php';
$server = new Server(dirname(__DIR__)); $application = Application::create(dirname(__DIR__), $composerLoader);
Server::setComposerLoader($composerLoader); $application->runHttp();
$server->runHttp();
+4
View File
@@ -14,6 +14,10 @@ namespace KTXF\Cache;
*/ */
interface BlobCacheInterface interface BlobCacheInterface
{ {
public function setTenantContext(?string $tenantId): void;
public function setUserContext(?string $userId): void;
/** /**
* Default TTL for blob cache entries (7 days) * Default TTL for blob cache entries (7 days)
*/ */
+4
View File
@@ -12,6 +12,10 @@ namespace KTXF\Cache;
*/ */
interface CacheInterface interface CacheInterface
{ {
public function setTenantContext(?string $tenantId): void;
public function setUserContext(?string $userId): void;
/** /**
* Retrieve an item from the cache * Retrieve an item from the cache
* *
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace KTXF\Event;
interface DeferredEventProcessorInterface
{
public function beginExecution(string $executionId): void;
public function processDeferred(string $executionId): DeferredProcessingResult;
public function discardDeferred(string $executionId): void;
}
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace KTXF\Event;
final readonly class DeferredProcessingResult
{
public function __construct(
public int $processed,
public int $remaining,
public bool $deadlineExceeded,
public bool $limitExceeded = false,
) {
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace KTXF\Event;
enum DeliveryMode: string
{
case Immediate = 'immediate';
case Deferred = 'deferred';
}
-186
View File
@@ -1,186 +0,0 @@
<?php
declare(strict_types=1);
namespace KTXF\Event;
/**
* Simple event bus for decoupled pub/sub communication between services
*
* Features:
* - Priority-based listener ordering
* - Synchronous and asynchronous (deferred) event handling
* - Event propagation control
*/
class EventBus
{
/** @var array<string, array<array{callback: callable, priority: int}>> */
private array $listeners = [];
/** @var array<string, array<callable>> */
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;
}
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace KTXF\Event;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
final class EventDispatcher implements EventDispatcherInterface, DeferredEventProcessorInterface
{
/** @var array<string, list<Event>> */
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;
}
}
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace KTXF\Event;
interface EventDispatcherInterface
{
public function dispatch(Event $event): void;
}
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace KTXF\Event;
final readonly class EventListenerDefinition
{
/**
* @param class-string $service
*/
public function __construct(
public string $module,
public string $event,
public string $service,
public string $method,
public DeliveryMode $delivery,
public int $priority,
public FailurePolicy $failurePolicy,
) {
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace KTXF\Event;
use Psr\Container\ContainerInterface;
final class EventListenerRegistry
{
/** @var array<string, list<EventListenerDefinition>> */
private array $listeners = [];
/** @var array<string, true> */
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<EventListenerDefinition>
*/
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<EventListenerDefinition>
*/
public function definitions(): array
{
return array_merge(...array_values($this->listeners));
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace KTXF\Event;
enum FailurePolicy: string
{
case Continue = 'continue';
case Propagate = 'propagate';
}
+3 -3
View File
@@ -2,7 +2,7 @@
namespace KTXF\Security; namespace KTXF\Security;
use KTXC\SessionTenant; use KTXC\Context\TenantContextInterface;
use phpseclib3\Crypt\AES; use phpseclib3\Crypt\AES;
/** /**
@@ -27,7 +27,7 @@ class Crypto
private const ENCODING_HEADER_VERSION = 1; private const ENCODING_HEADER_VERSION = 1;
private const ENCODING_HEADER_LEN = 14; private const ENCODING_HEADER_LEN = 14;
public function __construct(protected SessionTenant $sessionTenant) public function __construct(protected TenantContextInterface $tenantContext)
{ } { }
/** /**
@@ -136,7 +136,7 @@ class Crypto
private function tenantSecret(): ?string private function tenantSecret(): ?string
{ {
$config = $this->sessionTenant->configuration(); $config = $this->tenantContext->configuration();
return $config->security()->code(); return $config->security()->code();
} }
+213
View File
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Application;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionOutcome;
use KTXC\Application\KernelOptions;
use KTXC\Application\ProjectPaths;
use KTXC\Context\IdentityContext;
use KTXC\Context\TenantContext;
use KTXC\Injection\Container;
use KTXC\Kernel;
use KTXC\Module\ModuleManager;
use KTXC\Service\TenantService;
use KTXF\Cache\BlobCacheInterface;
use KTXF\Cache\EphemeralCacheInterface;
use KTXF\Cache\PersistentCacheInterface;
use KTXF\Event\DeferredEventProcessorInterface;
use KTXF\Event\DeferredProcessingResult;
use KTXF\Event\EventListenerRegistry;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
final class KernelTest extends TestCase
{
/** @var list<Kernel> */
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;
}
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Application;
use KTXC\Application\KernelOptions;
use KTXC\Application\ProjectPaths;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class ProjectPathsTest extends TestCase
{
#[Test]
#[TestDox('Project roots resolve identically from nested entrypoints')]
public function resolves(): void
{
$root = dirname(__DIR__, 4);
$fromRoot = ProjectPaths::resolve($root);
$fromCore = ProjectPaths::resolve($root . '/core/lib/index.php');
$fromConsole = ProjectPaths::resolve($root . '/bin/console');
self::assertSame($fromRoot->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)), '');
}
}
@@ -7,23 +7,38 @@ namespace KTXT\Unit\Console\Tenant;
use KTXC\Console\Tenant\TenantCreateCommand; use KTXC\Console\Tenant\TenantCreateCommand;
use KTXC\Models\Tenant\TenantObject; use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService; use KTXC\Service\TenantService;
use KTXC\Stores\UserAccountsStore;
use KTXC\Stores\UserRolesStore;
use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger; use Psr\Log\NullLogger;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
#[AllowMockObjectsWithoutExpectations]
class TenantCreateCommandTest extends TestCase class TenantCreateCommandTest extends TestCase
{ {
private TenantService&MockObject $tenantService; private TenantService&MockObject $tenantService;
private UserRolesStore $rolesStore;
private UserAccountsStore $userStore;
private CommandTester $tester; private CommandTester $tester;
private ?TenantObject $deposited = null; private ?TenantObject $deposited = null;
protected function setUp(): void protected function setUp(): void
{ {
$this->tenantService = $this->createMock(TenantService::class); $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( $this->tester = new CommandTester(
new TenantCreateCommand($this->tenantService, new NullLogger()) new TenantCreateCommand(
$this->tenantService,
$this->rolesStore,
$this->userStore,
new NullLogger(),
)
); );
} }
@@ -8,19 +8,18 @@ use KTXC\Console\Tenant\TenantListCommand;
use KTXC\Models\Tenant\DomainCollection; use KTXC\Models\Tenant\DomainCollection;
use KTXC\Models\Tenant\TenantObject; use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService; use KTXC\Service\TenantService;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Console\Tester\CommandTester;
class TenantListCommandTest extends TestCase class TenantListCommandTest extends TestCase
{ {
private TenantService&MockObject $tenantService; private TenantService $tenantService;
private CommandTester $tester; private CommandTester $tester;
protected function setUp(): void protected function setUp(): void
{ {
$this->tenantService = $this->createMock(TenantService::class); $this->tenantService = $this->createStub(TenantService::class);
$this->tester = new CommandTester( $this->tester = new CommandTester(
new TenantListCommand($this->tenantService) new TenantListCommand($this->tenantService)
); );
@@ -0,0 +1,268 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Event;
use KTXF\Event\DeliveryMode;
use KTXF\Event\Event;
use KTXF\Event\EventDispatcher;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\FailurePolicy;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Psr\Container\ContainerInterface;
use Psr\Log\NullLogger;
final class EventDispatcherTest extends TestCase
{
#[Test]
#[TestDox('Listeners are lazy and deferred events stay in their execution')]
public function dispatches(): void
{
$listener = new RecordingListener();
$container = new RecordingContainer([RecordingListener::class => $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);
}
}
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Execution;
use KTXC\Application\Execution\ExecutionContext;
use KTXC\Application\Execution\ExecutionDescriptor;
use KTXC\Application\Execution\ExecutionScope;
use KTXC\Context\IdentityContext;
use KTXC\Context\TenantContext;
use KTXC\Models\Identity\User;
use KTXC\Models\Tenant\TenantObject;
use KTXC\Service\TenantService;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class ExecutionScopeTest extends TestCase
{
#[Test]
#[TestDox('Disposal clears tenant and identity state')]
public function clears(): 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();
$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();
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Module;
use KTXC\Console\Event\EventsDebugCommand;
use KTXC\Module\Module;
use KTXC\Service\FirewallService;
use KTXF\Event\DeliveryMode;
use KTXF\Event\EventListenerRegistry;
use KTXF\Event\SecurityEvent;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
final class CoreModuleTest extends TestCase
{
#[Test]
#[TestDox('Core boot registers owned listeners without resolving services')]
public function registersListeners(): void
{
$registry = new EventListenerRegistry();
$module = new Module($registry);
$module->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());
}
}
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Runtime;
use KTXC\Application\Execution\ExecutionContext;
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\TenantContext;
use KTXC\KernelInterface;
use KTXC\Module\ModuleCollection;
use KTXC\Module\ModuleManager;
use KTXC\Runtime\Console\ConsoleRuntime;
use KTXC\Service\TenantService;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
final class ConsoleRuntimeTest extends TestCase
{
#[Test]
#[TestDox('Successful commands preserve their exit code and terminate')]
public function succeeds(): void
{
$modules = $this->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]);
}
}
+252
View File
@@ -0,0 +1,252 @@
<?php
declare(strict_types=1);
namespace KTXT\Unit\Runtime;
use KTXC\Application\Execution\ExecutionContext;
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\TenantContext;
use KTXC\Http\Middleware\AuthenticationMiddleware;
use KTXC\Http\Middleware\FirewallMiddleware;
use KTXC\Http\Middleware\MiddlewareInterface;
use KTXC\Http\Middleware\RequestHandlerInterface;
use KTXC\Http\Middleware\RouterMiddleware;
use KTXC\Http\Middleware\TenantMiddleware;
use KTXC\Http\Request\Request;
use KTXC\Http\Response\Response;
use KTXC\Http\Response\StreamedResponse;
use KTXC\KernelInterface;
use KTXC\Runtime\Http\HttpRuntime;
use KTXC\Service\TenantService;
use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\Attributes\TestDox;
use Psr\Container\ContainerInterface;
final class HttpRuntimeTest extends TestCase
{
#[Test]
#[TestDox('Stream callbacks run before execution termination')]
public function streams(): void
{
$kernel = null;
$kernel = new RuntimeKernel(
new RuntimeContainer([
TenantMiddleware::class => 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.');
}
}