diff --git a/core/lib/Controllers/InitController.php b/core/lib/Controllers/InitController.php index bff27b0..e86b00f 100644 --- a/core/lib/Controllers/InitController.php +++ b/core/lib/Controllers/InitController.php @@ -6,6 +6,7 @@ use KTXC\Http\Request\Request; use KTXC\Http\Response\JsonResponse; use KTXC\L10N\LocaleResolver; use KTXC\Module\ModuleManager; +use KTXC\Module\Configuration\BrowserModuleContext; use KTXC\Security\Authorization\PermissionChecker; use KTXC\Service\UserAccountsService; use KTXC\Context\IdentityContextInterface; @@ -30,7 +31,11 @@ class InitController extends ControllerAbstract $configuration = []; // modules - filter by permissions - $configuration['modules'] = []; + $browserContext = new BrowserModuleContext( + $this->tenantContext->requireIdentifier(), + $this->identityContext->requireIdentifier(), + ); + foreach ($this->moduleManager->list(true, true) as $module) { // Check if user has permission to view this module // Allow access if user has: {module_handle}, {module_handle}.*, or * permission @@ -39,12 +44,12 @@ class InitController extends ControllerAbstract continue; } - $integrations = $module->registerBI(); - if ($integrations !== null) { - $configuration['modules'][$handle] = $integrations; - } + $module->configure($browserContext); } + $configuration['modules'] = $browserContext->modules(); + $configuration = array_merge($configuration, $browserContext->configuration()); + // localization $configuration['l10n'] = [ 'locale' => $this->localeResolver->resolve($request), diff --git a/core/lib/Module/Configuration/BrowserModuleContext.php b/core/lib/Module/Configuration/BrowserModuleContext.php new file mode 100644 index 0000000..d356c5c --- /dev/null +++ b/core/lib/Module/Configuration/BrowserModuleContext.php @@ -0,0 +1,93 @@ +> */ + private array $modules = []; + + /** @var array */ + private array $configuration = []; + + public function __construct( + private readonly string $tenantIdentifier, + private readonly string $identityIdentifier, + ) { + } + + public function type(): ModuleContextType + { + return ModuleContextType::Browser; + } + + public function registerModule( + ModuleInstanceInterface $module, + string $namespace, + ?string $boot = null, + ): void { + if (trim($namespace) === '' || ($boot !== null && trim($boot) === '')) { + throw new InvalidArgumentException('Browser namespace must not be empty and boot path must be null or non-empty'); + } + $handle = $module->handle(); + if (isset($this->modules[$handle])) { + throw new LogicException("Browser module '{$handle}' is already registered"); + } + + $entry = [ + 'handle' => $handle, + 'namespace' => $namespace, + 'version' => $module->version(), + 'label' => $module->label(), + 'author' => $module->author(), + 'description' => $module->description(), + ]; + if ($boot !== null) { + $entry['boot'] = $boot; + } + + $this->modules[$handle] = $entry; + } + + public function set(string $key, mixed $value): void + { + if (trim($key) === '') { + throw new InvalidArgumentException('Browser configuration key must not be empty'); + } + if (in_array($key, self::RESERVED_KEYS, true) || array_key_exists($key, $this->configuration)) { + throw new LogicException("Browser configuration '{$key}' is already reserved or registered"); + } + + $this->configuration[$key] = $value; + } + + public function tenantIdentifier(): string + { + return $this->tenantIdentifier; + } + + public function identityIdentifier(): string + { + return $this->identityIdentifier; + } + + public function modules(): array + { + return $this->modules; + } + + public function configuration(): array + { + return $this->configuration; + } +} diff --git a/core/lib/Module/Configuration/ConsoleModuleContext.php b/core/lib/Module/Configuration/ConsoleModuleContext.php new file mode 100644 index 0000000..7900e2f --- /dev/null +++ b/core/lib/Module/Configuration/ConsoleModuleContext.php @@ -0,0 +1,34 @@ + */ + private array $commands = []; + + public function type(): ModuleContextType + { + return ModuleContextType::Console; + } + + public function registerCommand(string $command): void + { + if (trim($command) === '') { + throw new InvalidArgumentException('Console command class must not be empty'); + } + + $this->commands[$command] = true; + } + + public function commands(): array + { + return array_keys($this->commands); + } +} diff --git a/core/lib/Module/Module.php b/core/lib/Module/Module.php index 6dd41fe..274f5fe 100644 --- a/core/lib/Module/Module.php +++ b/core/lib/Module/Module.php @@ -4,6 +4,7 @@ namespace KTXC\Module; use KTXC\Console\Firewall\FirewallMaintenanceCommand; use KTXC\Console\Firewall\FirewallSetupCommand; +use KTXC\Preview\PreviewManager; use KTXC\Service\FirewallService; use KTXC\Service\SystemFirewallLogService; use KTXC\Service\SystemFirewallRuleService; @@ -25,8 +26,9 @@ use KTXC\Security\Event\RateLimitExceededEvent; use KTXC\Security\Event\SuspiciousActivityEvent; use KTXF\Event\DeliveryMode; use KTXF\Event\EventListenerRegistrarInterface; -use KTXF\Module\ModuleBrowserInterface; -use KTXF\Module\ModuleConsoleInterface; +use KTXF\Module\Configuration\BrowserModuleContextInterface; +use KTXF\Module\Configuration\ConsoleModuleContextInterface; +use KTXF\Module\Configuration\ModuleContextInterface; use KTXF\Module\ModuleInstanceAbstract; /** @@ -34,10 +36,11 @@ use KTXF\Module\ModuleInstanceAbstract; * * Provides core system functionality and permissions */ -class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, ModuleBrowserInterface +class Module extends ModuleInstanceAbstract { public function __construct( private readonly EventListenerRegistrarInterface $events, + private readonly PreviewManager $previews, ) { } @@ -213,9 +216,20 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M ]; } - public function registerCI(): array + public function configure(ModuleContextInterface $context): void { - return [ + if ($context instanceof BrowserModuleContextInterface) { + $context->set( + 'preview', + $this->previews->availability($context->tenantIdentifier()), + ); + } + + if (!$context instanceof ConsoleModuleContextInterface) { + return; + } + + foreach ([ FirewallSetupCommand::class, FirewallMaintenanceCommand::class, \KTXC\Console\Event\EventsDebugCommand::class, @@ -240,11 +254,8 @@ class Module extends ModuleInstanceAbstract implements ModuleConsoleInterface, M \KTXC\Console\Role\RoleDeleteCommand::class, \KTXC\Console\Role\RoleAssignCommand::class, \KTXC\Console\Role\RoleRevokeCommand::class, - ]; - } - - public function registerBI(): array - { - return []; + ] as $command) { + $context->registerCommand($command); + } } } diff --git a/core/lib/Module/ModuleObject.php b/core/lib/Module/ModuleObject.php index f3c265e..e3bb10e 100644 --- a/core/lib/Module/ModuleObject.php +++ b/core/lib/Module/ModuleObject.php @@ -4,8 +4,7 @@ namespace KTXC\Module; use JsonSerializable; use KTXC\Module\Store\ModuleEntry; -use KTXF\Module\ModuleBrowserInterface; -use KTXF\Module\ModuleConsoleInterface; +use KTXF\Module\Configuration\ModuleContextInterface; use KTXF\Module\ModuleInstanceInterface; /** @@ -175,20 +174,9 @@ class ModuleObject implements JsonSerializable $this->instance?->upgrade(); } - public function registerBI(): array | null + public function configure(ModuleContextInterface $context): void { - if ($this->instance instanceof ModuleBrowserInterface) { - return $this->instance->registerBI(); - } - return null; - } - - public function registerCI(): array | null - { - if ($this->instance instanceof ModuleConsoleInterface) { - return $this->instance->registerCI(); - } - return null; + $this->instance?->configure($context); } } diff --git a/core/lib/Runtime/Console/ConsoleRuntime.php b/core/lib/Runtime/Console/ConsoleRuntime.php index 45cb6f2..cd61a7c 100644 --- a/core/lib/Runtime/Console/ConsoleRuntime.php +++ b/core/lib/Runtime/Console/ConsoleRuntime.php @@ -7,8 +7,8 @@ namespace KTXC\Runtime\Console; use KTXC\Application\Execution\ExecutionDescriptor; use KTXC\Kernel; use KTXC\KernelInterface; +use KTXC\Module\Configuration\ConsoleModuleContext; 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; @@ -34,15 +34,13 @@ final class ConsoleRuntime /** @var ModuleManager $moduleManager */ $moduleManager = $container->get(ModuleManager::class); + $moduleContext = new ConsoleModuleContext(); foreach ($moduleManager->list() as $module) { - $instance = $module->instance(); - if (!$instance instanceof ModuleConsoleInterface) { - continue; - } + $module->configure($moduleContext); + } - foreach ($instance->registerCI() as $commandClass) { - $this->registerCommand($console, $container, $commandClass); - } + foreach ($moduleContext->commands() as $commandClass) { + $this->registerCommand($console, $container, $commandClass); } return $console->run($input, $output); diff --git a/shared/lib/Module/Configuration/BrowserModuleContextInterface.php b/shared/lib/Module/Configuration/BrowserModuleContextInterface.php new file mode 100644 index 0000000..2fc19bb --- /dev/null +++ b/shared/lib/Module/Configuration/BrowserModuleContextInterface.php @@ -0,0 +1,28 @@ +> */ + public function modules(): array; + + /** @return array */ + public function configuration(): array; +} diff --git a/shared/lib/Module/Configuration/ConsoleModuleContextInterface.php b/shared/lib/Module/Configuration/ConsoleModuleContextInterface.php new file mode 100644 index 0000000..9463dae --- /dev/null +++ b/shared/lib/Module/Configuration/ConsoleModuleContextInterface.php @@ -0,0 +1,14 @@ + */ + public function commands(): array; +} diff --git a/shared/lib/Module/Configuration/ModuleContextInterface.php b/shared/lib/Module/Configuration/ModuleContextInterface.php new file mode 100644 index 0000000..3c3a287 --- /dev/null +++ b/shared/lib/Module/Configuration/ModuleContextInterface.php @@ -0,0 +1,10 @@ +module($registry); $module->boot(); $definitions = $registry->definitions(); @@ -83,18 +91,21 @@ final class CoreModuleTest extends TestCase #[TestDox('Core exposes the event registry debug command')] public function exposesDebugCommand(): void { - $module = new Module(new EventListenerRegistry()); + $module = $this->module(); + $context = new ConsoleModuleContext(); + $module->configure($context); + $commands = $context->commands(); - self::assertContains(EventsDebugCommand::class, $module->registerCI()); - self::assertContains(FirewallSetupCommand::class, $module->registerCI()); - self::assertContains(FirewallMaintenanceCommand::class, $module->registerCI()); + self::assertContains(EventsDebugCommand::class, $commands); + self::assertContains(FirewallSetupCommand::class, $commands); + self::assertContains(FirewallMaintenanceCommand::class, $commands); } #[Test] #[TestDox('Core registers dedicated system firewall permissions')] public function registersSystemFirewallPermissions(): void { - $permissions = (new Module(new EventListenerRegistry()))->permissions(); + $permissions = $this->module()->permissions(); self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_READ, $permissions); self::assertArrayHasKey(SystemFirewallRuleService::PERMISSION_MANAGE, $permissions); @@ -107,4 +118,35 @@ final class CoreModuleTest extends TestCase self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_MAINTENANCE_READ, $permissions); self::assertArrayHasKey(SystemFirewallStatusService::PERMISSION_SETTINGS_MANAGE, $permissions); } + + #[Test] + #[TestDox('Core resolves preview browser configuration')] + public function resolvesPreviewBrowserConfiguration(): void + { + $module = $this->module(); + $context = new BrowserModuleContext('tenant-id', 'identity-id'); + $module->configure($context); + + self::assertSame([ + 'preview' => [ + 'enabled' => false, + 'storage' => false, + 'generation' => false, + ], + ], $context->configuration()); + } + + private function module(?EventListenerRegistry $registry = null): Module + { + $tenants = $this->createStub(TenantService::class); + $providers = new ProviderManager($this->createStub(ContainerInterface::class)); + $previews = new PreviewManager( + $tenants, + $providers, + new SystemStoreManager($tenants, $providers), + $this->createStub(LoggerInterface::class), + ); + + return new Module($registry ?? new EventListenerRegistry(), $previews); + } } diff --git a/tests/php/Unit/Module/ModuleContextTest.php b/tests/php/Unit/Module/ModuleContextTest.php new file mode 100644 index 0000000..5a6dd67 --- /dev/null +++ b/tests/php/Unit/Module/ModuleContextTest.php @@ -0,0 +1,85 @@ +configure($browser); + $module->configure($console); + + self::assertSame( + [ + 'test_context' => [ + 'handle' => 'test_context', + 'namespace' => 'TestContext', + 'version' => '0.0.1', + 'label' => 'Test Context', + 'author' => 'Ktrix', + 'description' => 'Tests module contexts', + 'boot' => 'static/module.mjs', + ], + ], + $browser->modules(), + ); + self::assertSame( + ['test' => ['tenant' => 'tenant-id', 'identity' => 'identity-id']], + $browser->configuration(), + ); + self::assertSame(['ExampleCommand'], $console->commands()); + self::assertSame(ModuleContextType::Browser, $browser->type()); + self::assertSame(ModuleContextType::Console, $console->type()); + } + + #[Test] + public function rejectsDuplicateBrowserConfigurationKeys(): void + { + $context = new BrowserModuleContext('tenant-id', 'identity-id'); + $context->set('test', true); + + $this->expectException(LogicException::class); + $context->set('test', false); + } +} + +final class TestContextModule extends ModuleInstanceAbstract +{ + public function handle(): string { return 'test_context'; } + public function version(): string { return '0.0.1'; } + public function label(): string { return 'Test Context'; } + public function description(): string { return 'Tests module contexts'; } + public function author(): string { return 'Ktrix'; } + + public function configure(ModuleContextInterface $context): void + { + if ($context instanceof BrowserModuleContextInterface) { + $context->registerModule($this, 'TestContext', 'static/module.mjs'); + $context->set('test', [ + 'tenant' => $context->tenantIdentifier(), + 'identity' => $context->identityIdentifier(), + ]); + } + if ($context instanceof ConsoleModuleContextInterface) { + $context->registerCommand('ExampleCommand'); + $context->registerCommand('ExampleCommand'); + } + } +}