refactor: rename lib/ to core/ for clearer core-module separation
Rename the top-level lib/ directory to core/ so the project root immediately communicates which code is core platform and which lives in modules/. PHP namespaces (MintyPHP\*) are unchanged — only the Composer PSR-4 path mapping moves from lib/ to core/. Module-internal lib/ directories (modules/*/lib/) are untouched. Updated across the full stack: - composer.json PSR-4 mapping - bootstrap entry points (web/index.php, bin/*, tests/bootstrap.php) - tooling configs (phpstan.neon, phpunit.xml, php-cs-fixer) - 26 architecture contract tests - enforcement-policy, guard-catalog, quality-gates - all documentation (CLAUDE.md, README, 25 docs/, .agents/skills/) - bin/qa-extended.sh search paths - .claude/settings.local.json permission paths Workflow: .agents/runs/CORE-LIB-RENAME-001/ (Analyst → Planner → Executor → Code Review (4 findings fixed) → Security Review → Acceptance Test → Finalizer) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
54
core/App/AppContainer.php
Normal file
54
core/App/AppContainer.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
// Minimal DI container: factories are registered with set() and resolved lazily on first get().
|
||||
// Each service is instantiated at most once — get() returns the same instance on every call.
|
||||
final class AppContainer
|
||||
{
|
||||
/** @var array<string, callable(self): mixed> */
|
||||
private array $bindings = [];
|
||||
|
||||
/** @var array<string, mixed> */
|
||||
private array $instances = [];
|
||||
|
||||
private bool $protectExistingBindings = false;
|
||||
|
||||
public function set(string $id, callable $factory): void
|
||||
{
|
||||
if ($this->protectExistingBindings && $this->has($id)) {
|
||||
throw new RuntimeException('Refusing to overwrite existing service binding: ' . $id);
|
||||
}
|
||||
$this->bindings[$id] = $factory;
|
||||
}
|
||||
|
||||
public function has(string $id): bool
|
||||
{
|
||||
return array_key_exists($id, $this->instances) || array_key_exists($id, $this->bindings);
|
||||
}
|
||||
|
||||
public function get(string $id): mixed
|
||||
{
|
||||
if (array_key_exists($id, $this->instances)) {
|
||||
return $this->instances[$id];
|
||||
}
|
||||
|
||||
if (!array_key_exists($id, $this->bindings)) {
|
||||
throw new RuntimeException('Service not bound: ' . $id);
|
||||
}
|
||||
|
||||
// Resolve, cache, and return — factory receives the container for its own dependencies.
|
||||
$this->instances[$id] = ($this->bindings[$id])($this);
|
||||
return $this->instances[$id];
|
||||
}
|
||||
|
||||
/**
|
||||
* Freeze existing bindings/instances: any later overwrite attempt throws.
|
||||
*/
|
||||
public function protectExistingBindings(): void
|
||||
{
|
||||
$this->protectExistingBindings = true;
|
||||
}
|
||||
}
|
||||
341
core/App/Bootstrap/EnvValidator.php
Normal file
341
core/App/Bootstrap/EnvValidator.php
Normal file
@@ -0,0 +1,341 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Bootstrap;
|
||||
|
||||
final class EnvValidator
|
||||
{
|
||||
/** @var list<string> */
|
||||
private const REQUIRED_KEYS = [
|
||||
'APP_NAME',
|
||||
'APP_ENV',
|
||||
'APP_DEBUG',
|
||||
'APP_TIMEZONE',
|
||||
'APP_STORAGE_PATH',
|
||||
'APP_LOCALE',
|
||||
'APP_LOCALES',
|
||||
'APP_I18N_DOMAIN',
|
||||
'SESSION_NAME',
|
||||
'TENANT_SCOPE_STRICT',
|
||||
'DB_HOST',
|
||||
'DB_PORT',
|
||||
'DB_NAME',
|
||||
'DB_USER',
|
||||
'DB_PASS',
|
||||
'CACHE_SERVERS',
|
||||
'FIREWALL_CONCURRENCY',
|
||||
'FIREWALL_SPINLOCK_SECONDS',
|
||||
'FIREWALL_INTERVAL_SECONDS',
|
||||
'FIREWALL_CACHE_PREFIX',
|
||||
'FIREWALL_REVERSE_PROXY',
|
||||
];
|
||||
|
||||
public static function validate(): void
|
||||
{
|
||||
if (!self::isValidationEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$env = [];
|
||||
foreach (array_merge(self::REQUIRED_KEYS, ['APP_URL', 'APP_CRYPTO_KEY', 'APP_VENDOR_PHP_ISSUES_MODE']) as $key) {
|
||||
$env[$key] = getenv($key);
|
||||
}
|
||||
|
||||
$errors = self::validateArray($env);
|
||||
if ($errors === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new \RuntimeException(
|
||||
"Configuration validation failed:\n - "
|
||||
. implode("\n - ", $errors)
|
||||
. "\nPlease verify your .env based on .env.example"
|
||||
);
|
||||
}
|
||||
|
||||
// Validation is on by default; set APP_CONFIG_VALIDATE=false to skip (e.g. in CI without full env).
|
||||
public static function isValidationEnabled(): bool
|
||||
{
|
||||
$raw = getenv('APP_CONFIG_VALIDATE');
|
||||
if ($raw === false || trim((string) $raw) === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return self::parseBool((string) $raw) ?? true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $env
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function validateArray(array $env): array
|
||||
{
|
||||
$errors = [];
|
||||
|
||||
foreach (self::REQUIRED_KEYS as $key) {
|
||||
if (!self::hasEnvKey($env, $key)) {
|
||||
$errors[] = "Missing required env key '{$key}'";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (self::value($env, $key) === '') {
|
||||
$errors[] = "Env key '{$key}' must not be empty";
|
||||
}
|
||||
}
|
||||
|
||||
$appEnv = strtolower(self::value($env, 'APP_ENV'));
|
||||
if ($appEnv !== '' && !in_array($appEnv, ['dev', 'prod', 'test'], true)) {
|
||||
$errors[] = "APP_ENV must be one of: dev, prod, test";
|
||||
}
|
||||
|
||||
$appDebug = self::value($env, 'APP_DEBUG');
|
||||
if ($appDebug !== '' && self::parseBool($appDebug) === null) {
|
||||
$errors[] = "APP_DEBUG must be a boolean value (true/false/1/0)";
|
||||
}
|
||||
|
||||
$tenantScopeStrict = self::value($env, 'TENANT_SCOPE_STRICT');
|
||||
if ($tenantScopeStrict !== '' && self::parseBool($tenantScopeStrict) === null) {
|
||||
$errors[] = "TENANT_SCOPE_STRICT must be a boolean value (true/false/1/0)";
|
||||
}
|
||||
|
||||
$vendorPhpIssuesMode = strtolower(self::value($env, 'APP_VENDOR_PHP_ISSUES_MODE'));
|
||||
if (
|
||||
$vendorPhpIssuesMode !== ''
|
||||
&& !in_array($vendorPhpIssuesMode, ['strict', 'suppress_deprecations', 'suppress_deprecations_warnings'], true)
|
||||
) {
|
||||
$errors[] = 'APP_VENDOR_PHP_ISSUES_MODE must be one of: strict, suppress_deprecations, suppress_deprecations_warnings';
|
||||
}
|
||||
|
||||
$reverseProxy = self::value($env, 'FIREWALL_REVERSE_PROXY');
|
||||
if ($reverseProxy !== '' && self::parseBool($reverseProxy) === null) {
|
||||
$errors[] = "FIREWALL_REVERSE_PROXY must be a boolean value (true/false/1/0)";
|
||||
}
|
||||
|
||||
$timezone = self::value($env, 'APP_TIMEZONE');
|
||||
if ($timezone !== '' && !in_array($timezone, \DateTimeZone::listIdentifiers(), true)) {
|
||||
$errors[] = "APP_TIMEZONE is invalid: '{$timezone}'";
|
||||
}
|
||||
|
||||
$locales = self::parseCsv(self::value($env, 'APP_LOCALES'));
|
||||
if ($locales === []) {
|
||||
$errors[] = 'APP_LOCALES must contain at least one locale';
|
||||
}
|
||||
|
||||
$locale = self::value($env, 'APP_LOCALE');
|
||||
if ($locale !== '' && $locales !== [] && !in_array($locale, $locales, true)) {
|
||||
$errors[] = "APP_LOCALE '{$locale}' is not part of APP_LOCALES";
|
||||
}
|
||||
|
||||
$dbPort = self::parseInt(self::value($env, 'DB_PORT'));
|
||||
if ($dbPort === null || $dbPort < 1 || $dbPort > 65535) {
|
||||
$errors[] = 'DB_PORT must be an integer between 1 and 65535';
|
||||
}
|
||||
|
||||
$firewallConcurrency = self::parseInt(self::value($env, 'FIREWALL_CONCURRENCY'));
|
||||
if ($firewallConcurrency === null || $firewallConcurrency < 1) {
|
||||
$errors[] = 'FIREWALL_CONCURRENCY must be an integer >= 1';
|
||||
}
|
||||
|
||||
$firewallInterval = self::parseInt(self::value($env, 'FIREWALL_INTERVAL_SECONDS'));
|
||||
if ($firewallInterval === null || $firewallInterval < 1) {
|
||||
$errors[] = 'FIREWALL_INTERVAL_SECONDS must be an integer >= 1';
|
||||
}
|
||||
|
||||
$firewallSpinLock = self::parseFloat(self::value($env, 'FIREWALL_SPINLOCK_SECONDS'));
|
||||
if ($firewallSpinLock === null || $firewallSpinLock <= 0) {
|
||||
$errors[] = 'FIREWALL_SPINLOCK_SECONDS must be a float > 0';
|
||||
}
|
||||
|
||||
$cacheServers = self::parseCsv(self::value($env, 'CACHE_SERVERS'));
|
||||
if ($cacheServers === []) {
|
||||
$errors[] = 'CACHE_SERVERS must contain at least one host[:port] entry';
|
||||
} else {
|
||||
foreach ($cacheServers as $server) {
|
||||
if (!preg_match('/^[a-z0-9.-]+(?::\d{1,5})?$/i', $server)) {
|
||||
$errors[] = "CACHE_SERVERS contains invalid entry '{$server}'";
|
||||
continue;
|
||||
}
|
||||
|
||||
$parts = explode(':', $server, 2);
|
||||
if (count($parts) !== 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$port = self::parseInt($parts[1]);
|
||||
if ($port === null || $port < 1 || $port > 65535) {
|
||||
$errors[] = "CACHE_SERVERS entry '{$server}' has invalid port";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$storagePath = self::value($env, 'APP_STORAGE_PATH');
|
||||
if ($storagePath !== '') {
|
||||
$absoluteStoragePath = self::absolutePath($storagePath);
|
||||
if (!is_dir($absoluteStoragePath)) {
|
||||
$errors[] = "APP_STORAGE_PATH does not exist: '{$storagePath}'";
|
||||
} elseif (!is_writable($absoluteStoragePath)) {
|
||||
$errors[] = "APP_STORAGE_PATH is not writable: '{$storagePath}'";
|
||||
}
|
||||
}
|
||||
|
||||
$cryptoKey = self::value($env, 'APP_CRYPTO_KEY');
|
||||
if ($cryptoKey !== '' && !self::isValidCryptoKey($cryptoKey)) {
|
||||
$errors[] = 'APP_CRYPTO_KEY must be 64-char hex or base64 encoded 32-byte key';
|
||||
}
|
||||
|
||||
$appUrl = self::value($env, 'APP_URL');
|
||||
$appUrlIsValid = true;
|
||||
if ($appUrl !== '' && !self::isValidHttpUrl($appUrl)) {
|
||||
$errors[] = "APP_URL is invalid: '{$appUrl}'";
|
||||
$appUrlIsValid = false;
|
||||
}
|
||||
|
||||
if (in_array($appEnv, ['dev', 'prod'], true) && $appUrl === '') {
|
||||
if ($appEnv === 'prod') {
|
||||
$errors[] = 'APP_URL must be set in production';
|
||||
} else {
|
||||
$errors[] = 'APP_URL must be set in development';
|
||||
}
|
||||
}
|
||||
|
||||
// Production-only hard requirements: HTTPS, real hostname, crypto key, debug off, strict tenant scope.
|
||||
if ($appEnv === 'prod') {
|
||||
if ($appUrl !== '' && $appUrlIsValid) {
|
||||
$appUrlScheme = strtolower((string) parse_url($appUrl, PHP_URL_SCHEME));
|
||||
if ($appUrlScheme !== 'https') {
|
||||
$errors[] = 'APP_URL must use https in production';
|
||||
}
|
||||
|
||||
$appUrlHost = strtolower((string) parse_url($appUrl, PHP_URL_HOST));
|
||||
if (self::isLocalOrIpHost($appUrlHost)) {
|
||||
$errors[] = 'APP_URL must not use localhost or an IP host in production';
|
||||
}
|
||||
}
|
||||
if ($cryptoKey === '') {
|
||||
$errors[] = 'APP_CRYPTO_KEY must be set in production';
|
||||
}
|
||||
if (self::parseBool($appDebug) !== false) {
|
||||
$errors[] = 'APP_DEBUG must be false in production';
|
||||
}
|
||||
if (self::parseBool($tenantScopeStrict) !== true) {
|
||||
$errors[] = 'TENANT_SCOPE_STRICT must be true in production';
|
||||
}
|
||||
}
|
||||
|
||||
return $errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $env
|
||||
*/
|
||||
private static function hasEnvKey(array $env, string $key): bool
|
||||
{
|
||||
return array_key_exists($key, $env) && $env[$key] !== false && $env[$key] !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $env
|
||||
*/
|
||||
private static function value(array $env, string $key): string
|
||||
{
|
||||
if (!self::hasEnvKey($env, $key)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return trim((string) $env[$key]);
|
||||
}
|
||||
|
||||
private static function parseBool(string $value): ?bool
|
||||
{
|
||||
$parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
|
||||
return $parsed;
|
||||
}
|
||||
|
||||
private static function parseInt(string $value): ?int
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$parsed = filter_var($value, FILTER_VALIDATE_INT);
|
||||
return $parsed !== false ? (int) $parsed : null;
|
||||
}
|
||||
|
||||
private static function parseFloat(string $value): ?float
|
||||
{
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
$parsed = filter_var($value, FILTER_VALIDATE_FLOAT);
|
||||
return $parsed !== false ? (float) $parsed : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function parseCsv(string $value): array
|
||||
{
|
||||
if ($value === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map('trim', explode(',', $value))));
|
||||
}
|
||||
|
||||
// Accepts either 64-char hex (32 bytes) or base64-encoded 32 bytes — both produce a 256-bit key.
|
||||
private static function isValidCryptoKey(string $value): bool
|
||||
{
|
||||
if (preg_match('/^[a-f0-9]{64}$/i', $value) === 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$decoded = base64_decode($value, true);
|
||||
return is_string($decoded) && strlen($decoded) === 32;
|
||||
}
|
||||
|
||||
private static function isValidHttpUrl(string $value): bool
|
||||
{
|
||||
if (filter_var($value, FILTER_VALIDATE_URL) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$scheme = strtolower((string) parse_url($value, PHP_URL_SCHEME));
|
||||
return in_array($scheme, ['http', 'https'], true);
|
||||
}
|
||||
|
||||
private static function isLocalOrIpHost(string $host): bool
|
||||
{
|
||||
$host = strtolower(trim($host));
|
||||
if ($host === '') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($host === 'localhost' || str_ends_with($host, '.localhost')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$host = trim($host, '[]');
|
||||
return filter_var($host, FILTER_VALIDATE_IP) !== false;
|
||||
}
|
||||
|
||||
private static function absolutePath(string $path): string
|
||||
{
|
||||
if (self::isAbsolutePath($path)) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
$projectRoot = dirname(__DIR__, 3);
|
||||
return $projectRoot . '/' . ltrim($path, '/');
|
||||
}
|
||||
|
||||
private static function isAbsolutePath(string $path): bool
|
||||
{
|
||||
if ($path === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($path[0] === '/' || $path[0] === '\\') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return preg_match('/^[A-Za-z]:[\\\\\/]/', $path) === 1;
|
||||
}
|
||||
}
|
||||
10
core/App/Container/ContainerRegistrar.php
Normal file
10
core/App/Container/ContainerRegistrar.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
|
||||
interface ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void;
|
||||
}
|
||||
46
core/App/Container/Registrars/AccessRegistrar.php
Normal file
46
core/App/Container/Registrars/AccessRegistrar.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Repository\Access\PermissionRepository;
|
||||
use MintyPHP\Repository\Access\RoleAssignableRoleRepository;
|
||||
use MintyPHP\Repository\Access\RolePermissionRepository;
|
||||
use MintyPHP\Repository\Access\RoleRepository;
|
||||
use MintyPHP\Service\Access\AccessServicesFactory;
|
||||
use MintyPHP\Service\Access\AssignableRoleService;
|
||||
use MintyPHP\Service\Access\AuthorizationService;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Access\RoleService;
|
||||
use MintyPHP\Service\Access\UiAccessService;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Directory\DirectoryServicesFactory;
|
||||
|
||||
final class AccessRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(AuthorizationService::class, static fn (AppContainer $c): AuthorizationService => $c->get(AccessServicesFactory::class)->createAuthorizationService());
|
||||
$container->set(UiAccessService::class, static fn (AppContainer $c): UiAccessService => new UiAccessService(
|
||||
$c->get(AuthorizationService::class),
|
||||
$c->has(ModuleRegistry::class) ? $c->get(ModuleRegistry::class) : null
|
||||
));
|
||||
$container->set(PermissionService::class, static fn (AppContainer $c): PermissionService => $c->get(AccessServicesFactory::class)->createPermissionService());
|
||||
// RoleService lives in DirectoryServicesFactory (not AccessServicesFactory) because
|
||||
// role management involves directory-level concerns (departments, tenant structure).
|
||||
$container->set(RoleService::class, static fn (AppContainer $c): RoleService => $c->get(DirectoryServicesFactory::class)->createRoleService());
|
||||
$container->set(PermissionRepository::class, static fn (AppContainer $c): PermissionRepository => $c->get(AccessServicesFactory::class)->createPermissionRepository());
|
||||
$container->set(RolePermissionRepository::class, static fn (AppContainer $c): RolePermissionRepository => $c->get(AccessServicesFactory::class)->createRolePermissionRepository());
|
||||
$container->set(RoleAssignableRoleRepository::class, static fn (AppContainer $c): RoleAssignableRoleRepository => $c->get(AccessServicesFactory::class)->createRoleAssignableRoleRepository());
|
||||
$container->set(RoleRepository::class, static fn (AppContainer $c): RoleRepository => $c->get(DirectoryServicesFactory::class)->createRoleRepository());
|
||||
$container->set(AssignableRoleService::class, static fn (AppContainer $c): AssignableRoleService => new AssignableRoleService(
|
||||
$c->get(RoleAssignableRoleRepository::class),
|
||||
$c->get(AccessServicesFactory::class)->createUserRoleRepository(),
|
||||
$c->get(PermissionService::class),
|
||||
$c->get(RoleRepository::class),
|
||||
$c->get(AuditRecorderInterface::class)
|
||||
));
|
||||
}
|
||||
}
|
||||
101
core/App/Container/Registrars/AppServicesRegistrar.php
Normal file
101
core/App/Container/Registrars/AppServicesRegistrar.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\App\Module\ModulePermissionSynchronizer;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\App\Module\ModuleRuntimeAssetPublisher;
|
||||
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
|
||||
use MintyPHP\Http\CookieStore;
|
||||
use MintyPHP\Http\CookieStoreInterface;
|
||||
use MintyPHP\Http\Input\RequestInputFactory;
|
||||
use MintyPHP\Http\IntendedUrlService;
|
||||
use MintyPHP\Http\RequestRuntime;
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStore;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Access\PermissionRepository;
|
||||
use MintyPHP\Repository\Module\ModuleMigrationRepository;
|
||||
use MintyPHP\Repository\Search\SearchQueryRepository;
|
||||
use MintyPHP\Repository\Stats\AdminStatsRepository;
|
||||
use MintyPHP\Repository\System\SystemHealthRepository;
|
||||
use MintyPHP\Repository\Tenant\UserTenantRepository;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\CustomField\CustomFieldServicesFactory;
|
||||
use MintyPHP\Service\CustomField\TenantCustomFieldService;
|
||||
use MintyPHP\Service\CustomField\UserCustomFieldValueService;
|
||||
use MintyPHP\Service\Data\GridUserCountEnricher;
|
||||
use MintyPHP\Service\Import\ImportService;
|
||||
use MintyPHP\Service\Import\ImportServicesFactory;
|
||||
use MintyPHP\Service\Mail\MailLogService;
|
||||
use MintyPHP\Service\Mail\MailService;
|
||||
use MintyPHP\Service\Mail\MailServicesFactory;
|
||||
use MintyPHP\Service\Module\ModuleMigrationService;
|
||||
use MintyPHP\Service\Scheduler\ScheduledJobService;
|
||||
use MintyPHP\Service\Scheduler\SchedulerRunService;
|
||||
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
|
||||
use MintyPHP\Service\Search\SearchDataService;
|
||||
use MintyPHP\Service\Security\RateLimiterService;
|
||||
use MintyPHP\Service\Security\SecurityServicesFactory;
|
||||
use MintyPHP\Service\Stats\AdminStatsViewDataService;
|
||||
use MintyPHP\Service\System\SystemHealthService;
|
||||
use MintyPHP\Service\System\SystemInfoService;
|
||||
|
||||
final class AppServicesRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$projectRoot = dirname(__DIR__, 4);
|
||||
|
||||
$container->set(AdminStatsViewDataService::class, static fn (AppContainer $c): AdminStatsViewDataService => new AdminStatsViewDataService(
|
||||
$c->get(AdminStatsRepository::class)
|
||||
));
|
||||
$container->set(SystemHealthRepository::class, static fn (): SystemHealthRepository => new SystemHealthRepository());
|
||||
$container->set(SystemHealthService::class, static fn (AppContainer $c): SystemHealthService => new SystemHealthService(
|
||||
$c->get(SystemHealthRepository::class)
|
||||
));
|
||||
$container->set(SystemInfoService::class, static fn (AppContainer $c): SystemInfoService => new SystemInfoService(
|
||||
$c->get(SystemHealthService::class),
|
||||
$c->get(SystemHealthRepository::class),
|
||||
$c->has(ModuleRegistry::class) ? $c->get(ModuleRegistry::class) : null
|
||||
));
|
||||
$container->set(SearchDataService::class, static fn (AppContainer $c): SearchDataService => new SearchDataService(
|
||||
$c->get(PermissionService::class),
|
||||
$c->get(UserTenantRepository::class),
|
||||
$c->get(SearchQueryRepository::class)
|
||||
));
|
||||
$container->set(RateLimiterService::class, static fn (AppContainer $c): RateLimiterService => $c->get(SecurityServicesFactory::class)->createRateLimiterService());
|
||||
$container->set(MailService::class, static fn (AppContainer $c): MailService => $c->get(MailServicesFactory::class)->createMailService());
|
||||
$container->set(MailLogService::class, static fn (AppContainer $c): MailLogService => $c->get(MailServicesFactory::class)->createMailLogService());
|
||||
// Audit services are registered by the audit module's AuditContainerRegistrar.
|
||||
$container->set(ImportService::class, static fn (AppContainer $c): ImportService => $c->get(ImportServicesFactory::class)->createImportService());
|
||||
$container->set(ScheduledJobService::class, static fn (AppContainer $c): ScheduledJobService => $c->get(SchedulerServicesFactory::class)->createScheduledJobService());
|
||||
$container->set(SchedulerRunService::class, static fn (AppContainer $c): SchedulerRunService => $c->get(SchedulerServicesFactory::class)->createSchedulerRunService());
|
||||
$container->set(TenantCustomFieldService::class, static fn (AppContainer $c): TenantCustomFieldService => $c->get(CustomFieldServicesFactory::class)->createTenantCustomFieldService());
|
||||
$container->set(UserCustomFieldValueService::class, static fn (AppContainer $c): UserCustomFieldValueService => $c->get(CustomFieldServicesFactory::class)->createUserCustomFieldValueService());
|
||||
// AuditMetadataEnricher is registered by the audit module's AuditContainerRegistrar.
|
||||
$container->set(GridUserCountEnricher::class, static fn (): GridUserCountEnricher => new GridUserCountEnricher());
|
||||
$container->set(IntendedUrlService::class, static fn (): IntendedUrlService => new IntendedUrlService());
|
||||
$container->set(RequestInputFactory::class, static fn (): RequestInputFactory => new RequestInputFactory());
|
||||
$container->set(SessionStore::class, static fn (): SessionStore => new SessionStore());
|
||||
$container->set(SessionStoreInterface::class, static fn (AppContainer $c): SessionStoreInterface => $c->get(SessionStore::class));
|
||||
$container->set(CookieStore::class, static fn (): CookieStore => new CookieStore());
|
||||
$container->set(CookieStoreInterface::class, static fn (AppContainer $c): CookieStoreInterface => $c->get(CookieStore::class));
|
||||
$container->set(RequestRuntime::class, static fn (): RequestRuntime => new RequestRuntime());
|
||||
$container->set(RequestRuntimeInterface::class, static fn (AppContainer $c): RequestRuntimeInterface => $c->get(RequestRuntime::class));
|
||||
$container->set(ModuleMigrationRepository::class, static fn (): ModuleMigrationRepository => new ModuleMigrationRepository());
|
||||
$container->set(ModuleMigrationService::class, static fn (AppContainer $c): ModuleMigrationService => new ModuleMigrationService(
|
||||
$c->get(ModuleRegistry::class),
|
||||
$c->get(ModuleMigrationRepository::class)
|
||||
));
|
||||
$container->set(ModulePermissionSynchronizer::class, fn (AppContainer $c): ModulePermissionSynchronizer => new ModulePermissionSynchronizer(
|
||||
$c->get(ModuleRegistry::class),
|
||||
$c->get(PermissionRepository::class),
|
||||
$projectRoot . '/modules'
|
||||
));
|
||||
$container->set(ModuleRuntimePageBuilder::class, static fn (): ModuleRuntimePageBuilder => new ModuleRuntimePageBuilder());
|
||||
$container->set(ModuleRuntimeAssetPublisher::class, static fn (): ModuleRuntimeAssetPublisher => new ModuleRuntimeAssetPublisher());
|
||||
}
|
||||
}
|
||||
50
core/App/Container/Registrars/AuthRegistrar.php
Normal file
50
core/App/Container/Registrars/AuthRegistrar.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
||||
use MintyPHP\Repository\Auth\PasswordResetRepository;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantLdapAuthRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantMicrosoftAuthRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Auth\ApiTokenEndpointService;
|
||||
use MintyPHP\Service\Auth\ApiTokenService;
|
||||
use MintyPHP\Service\Auth\AuthRepositoryFactory;
|
||||
use MintyPHP\Service\Auth\AuthService;
|
||||
use MintyPHP\Service\Auth\AuthServicesFactory;
|
||||
use MintyPHP\Service\Auth\EmailVerificationService;
|
||||
use MintyPHP\Service\Auth\LdapAuthService;
|
||||
use MintyPHP\Service\Auth\MicrosoftOidcService;
|
||||
use MintyPHP\Service\Auth\PasswordResetService;
|
||||
use MintyPHP\Service\Auth\RememberMeService;
|
||||
use MintyPHP\Service\Auth\SsoUserLinkService;
|
||||
use MintyPHP\Service\Auth\TenantSsoService;
|
||||
|
||||
final class AuthRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(AuthService::class, static fn (AppContainer $c): AuthService => $c->get(AuthServicesFactory::class)->createAuthService());
|
||||
$container->set(RememberMeService::class, static fn (AppContainer $c): RememberMeService => $c->get(AuthServicesFactory::class)->createRememberMeService());
|
||||
$container->set(ApiTokenService::class, static fn (AppContainer $c): ApiTokenService => $c->get(AuthServicesFactory::class)->createApiTokenService());
|
||||
$container->set(ApiTokenEndpointService::class, static fn (AppContainer $c): ApiTokenEndpointService => new ApiTokenEndpointService(
|
||||
$c->get(AuthRepositoryFactory::class)->createApiTokenRepository(),
|
||||
$c->get(TenantRepository::class),
|
||||
$c->get(\MintyPHP\Service\Tenant\TenantScopeService::class)
|
||||
));
|
||||
$container->set(PasswordResetService::class, static fn (AppContainer $c): PasswordResetService => $c->get(AuthServicesFactory::class)->createPasswordResetService());
|
||||
$container->set(EmailVerificationService::class, static fn (AppContainer $c): EmailVerificationService => $c->get(AuthServicesFactory::class)->createEmailVerificationService());
|
||||
$container->set(TenantSsoService::class, static fn (AppContainer $c): TenantSsoService => $c->get(AuthServicesFactory::class)->createTenantSsoService());
|
||||
$container->set(MicrosoftOidcService::class, static fn (AppContainer $c): MicrosoftOidcService => $c->get(AuthServicesFactory::class)->createMicrosoftOidcService());
|
||||
$container->set(SsoUserLinkService::class, static fn (AppContainer $c): SsoUserLinkService => $c->get(AuthServicesFactory::class)->createSsoUserLinkService());
|
||||
$container->set(LdapAuthService::class, static fn (AppContainer $c): LdapAuthService => $c->get(AuthServicesFactory::class)->createLdapAuthService());
|
||||
$container->set(TenantMicrosoftAuthRepository::class, static fn (AppContainer $c): TenantMicrosoftAuthRepository => $c->get(AuthRepositoryFactory::class)->createTenantMicrosoftAuthRepository());
|
||||
$container->set(TenantLdapAuthRepository::class, static fn (AppContainer $c): TenantLdapAuthRepository => $c->get(AuthRepositoryFactory::class)->createTenantLdapAuthRepository());
|
||||
$container->set(ApiTokenRepository::class, static fn (AppContainer $c): ApiTokenRepository => $c->get(AuthRepositoryFactory::class)->createApiTokenRepository());
|
||||
$container->set(RememberTokenRepository::class, static fn (AppContainer $c): RememberTokenRepository => $c->get(AuthRepositoryFactory::class)->createRememberTokenRepository());
|
||||
$container->set(PasswordResetRepository::class, static fn (AppContainer $c): PasswordResetRepository => $c->get(AuthRepositoryFactory::class)->createPasswordResetRepository());
|
||||
}
|
||||
}
|
||||
31
core/App/Container/Registrars/DirectoryRegistrar.php
Normal file
31
core/App/Container/Registrars/DirectoryRegistrar.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Repository\Org\DepartmentRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Directory\DirectoryServicesFactory;
|
||||
use MintyPHP\Service\Directory\DirectorySettingsGateway;
|
||||
use MintyPHP\Service\Org\DepartmentService;
|
||||
use MintyPHP\Service\Tenant\TenantAvatarService;
|
||||
use MintyPHP\Service\Tenant\TenantFaviconService;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
use MintyPHP\Service\Tenant\TenantService;
|
||||
use MintyPHP\Service\Tenant\TenantServicesFactory;
|
||||
|
||||
final class DirectoryRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(TenantService::class, static fn (AppContainer $c): TenantService => $c->get(DirectoryServicesFactory::class)->createTenantService());
|
||||
$container->set(TenantScopeService::class, static fn (AppContainer $c): TenantScopeService => $c->get(TenantServicesFactory::class)->createTenantScopeService());
|
||||
$container->set(DepartmentService::class, static fn (AppContainer $c): DepartmentService => $c->get(DirectoryServicesFactory::class)->createDepartmentService());
|
||||
$container->set(DirectorySettingsGateway::class, static fn (AppContainer $c): DirectorySettingsGateway => $c->get(DirectoryServicesFactory::class)->createDirectorySettingsGateway());
|
||||
$container->set(TenantAvatarService::class, static fn (AppContainer $c): TenantAvatarService => $c->get(TenantServicesFactory::class)->createTenantAvatarService());
|
||||
$container->set(TenantFaviconService::class, static fn (AppContainer $c): TenantFaviconService => $c->get(TenantServicesFactory::class)->createTenantFaviconService());
|
||||
$container->set(TenantRepository::class, static fn (AppContainer $c): TenantRepository => $c->get(TenantServicesFactory::class)->createTenantRepository());
|
||||
$container->set(DepartmentRepository::class, static fn (AppContainer $c): DepartmentRepository => $c->get(DirectoryServicesFactory::class)->createDepartmentRepository());
|
||||
}
|
||||
}
|
||||
40
core/App/Container/Registrars/RepositoryFactoryRegistrar.php
Normal file
40
core/App/Container/Registrars/RepositoryFactoryRegistrar.php
Normal file
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Repository\Search\SearchQueryRepository;
|
||||
use MintyPHP\Repository\Stats\AdminStatsRepository;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Service\Access\AccessRepositoryFactory;
|
||||
use MintyPHP\Service\Auth\AuthRepositoryFactory;
|
||||
use MintyPHP\Service\Directory\DirectoryRepositoryFactory;
|
||||
use MintyPHP\Service\Import\ImportRepositoryFactory;
|
||||
use MintyPHP\Service\Mail\MailRepositoryFactory;
|
||||
use MintyPHP\Service\Scheduler\SchedulerRepositoryFactory;
|
||||
use MintyPHP\Service\Security\SecurityRepositoryFactory;
|
||||
use MintyPHP\Service\Settings\SettingRepositoryFactory;
|
||||
use MintyPHP\Service\Tenant\TenantRepositoryFactory;
|
||||
use MintyPHP\Service\User\UserRepositoryFactory;
|
||||
|
||||
final class RepositoryFactoryRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(SettingRepositoryFactory::class, static fn (): SettingRepositoryFactory => new SettingRepositoryFactory());
|
||||
$container->set(SecurityRepositoryFactory::class, static fn (): SecurityRepositoryFactory => new SecurityRepositoryFactory());
|
||||
$container->set(MailRepositoryFactory::class, static fn (): MailRepositoryFactory => new MailRepositoryFactory());
|
||||
$container->set(ImportRepositoryFactory::class, static fn (): ImportRepositoryFactory => new ImportRepositoryFactory());
|
||||
$container->set(SchedulerRepositoryFactory::class, static fn (): SchedulerRepositoryFactory => new SchedulerRepositoryFactory());
|
||||
$container->set(TenantRepositoryFactory::class, static fn (): TenantRepositoryFactory => new TenantRepositoryFactory());
|
||||
$container->set(DirectoryRepositoryFactory::class, static fn (): DirectoryRepositoryFactory => new DirectoryRepositoryFactory());
|
||||
$container->set(AccessRepositoryFactory::class, static fn (): AccessRepositoryFactory => new AccessRepositoryFactory());
|
||||
$container->set(UserRepositoryFactory::class, static fn (): UserRepositoryFactory => new UserRepositoryFactory());
|
||||
$container->set(AuthRepositoryFactory::class, static fn (): AuthRepositoryFactory => new AuthRepositoryFactory());
|
||||
// These repositories have no factory because they're standalone — no shared config or gateway needed.
|
||||
$container->set(AdminStatsRepository::class, static fn (): AdminStatsRepository => new AdminStatsRepository());
|
||||
$container->set(SearchQueryRepository::class, static fn (): SearchQueryRepository => new SearchQueryRepository());
|
||||
$container->set(DatabaseSessionRepository::class, static fn (): DatabaseSessionRepository => new DatabaseSessionRepository());
|
||||
}
|
||||
}
|
||||
156
core/App/Container/Registrars/ServiceFactoryRegistrar.php
Normal file
156
core/App/Container/Registrars/ServiceFactoryRegistrar.php
Normal file
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\App\Module\ModuleClassResolver;
|
||||
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Http\CookieStoreInterface;
|
||||
use MintyPHP\Http\RequestRuntimeInterface;
|
||||
use MintyPHP\Http\SessionStoreInterface;
|
||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||
use MintyPHP\Repository\Tenant\TenantRepository;
|
||||
use MintyPHP\Service\Access\AccessGatewayFactory;
|
||||
use MintyPHP\Service\Access\AccessPolicyFactory;
|
||||
use MintyPHP\Service\Access\AccessRepositoryFactory;
|
||||
use MintyPHP\Service\Access\AccessServicesFactory;
|
||||
use MintyPHP\Service\Access\AssignableRoleService;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Audit\ImportAuditInterface;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
|
||||
use MintyPHP\Service\Auth\AuthGatewayFactory;
|
||||
use MintyPHP\Service\Auth\AuthRepositoryFactory;
|
||||
use MintyPHP\Service\Auth\AuthServicesFactory;
|
||||
use MintyPHP\Service\Branding\BrandingServicesFactory;
|
||||
use MintyPHP\Service\CustomField\CustomFieldServicesFactory;
|
||||
use MintyPHP\Service\Directory\DirectoryGatewayFactory;
|
||||
use MintyPHP\Service\Directory\DirectoryRepositoryFactory;
|
||||
use MintyPHP\Service\Directory\DirectoryServicesFactory;
|
||||
use MintyPHP\Service\Import\ImportServicesFactory;
|
||||
use MintyPHP\Service\Mail\MailRepositoryFactory;
|
||||
use MintyPHP\Service\Mail\MailServicesFactory;
|
||||
use MintyPHP\Service\Scheduler\SchedulerRepositoryFactory;
|
||||
use MintyPHP\Service\Scheduler\SchedulerServicesFactory;
|
||||
use MintyPHP\Service\Security\SecurityRepositoryFactory;
|
||||
use MintyPHP\Service\Security\SecurityServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingRepositoryFactory;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Tenant\TenantRepositoryFactory;
|
||||
use MintyPHP\Service\Tenant\TenantScopeService;
|
||||
use MintyPHP\Service\Tenant\TenantServicesFactory;
|
||||
use MintyPHP\Service\User\UserGatewayFactory;
|
||||
use MintyPHP\Service\User\UserRepositoryFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
|
||||
final class ServiceFactoryRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(SettingServicesFactory::class, static fn (AppContainer $c): SettingServicesFactory => new SettingServicesFactory(
|
||||
$c->get(SettingRepositoryFactory::class)
|
||||
));
|
||||
// Audit interfaces are NOT registered here — they are set AFTER module registrars
|
||||
// in registerContainer.php so the audit module can provide real implementations.
|
||||
// If the audit module is disabled, null implementations are used as fallback.
|
||||
$container->set(SecurityServicesFactory::class, static fn (AppContainer $c): SecurityServicesFactory => new SecurityServicesFactory(
|
||||
$c->get(SecurityRepositoryFactory::class)
|
||||
));
|
||||
$container->set(MailServicesFactory::class, static fn (AppContainer $c): MailServicesFactory => new MailServicesFactory(
|
||||
$c->get(MailRepositoryFactory::class),
|
||||
$c->get(SettingServicesFactory::class)->createSettingsSmtpGateway()
|
||||
));
|
||||
$container->set(BrandingServicesFactory::class, static fn (AppContainer $c): BrandingServicesFactory => new BrandingServicesFactory(
|
||||
$c->get(SettingServicesFactory::class)->createSettingsAppGateway()
|
||||
));
|
||||
$container->set(CustomFieldServicesFactory::class, static fn (AppContainer $c): CustomFieldServicesFactory => new CustomFieldServicesFactory(
|
||||
$c->get(TenantRepository::class),
|
||||
$c->get(TenantScopeService::class)
|
||||
));
|
||||
$container->set(TenantServicesFactory::class, static fn (AppContainer $c): TenantServicesFactory => new TenantServicesFactory(
|
||||
$c->get(PermissionService::class),
|
||||
$c->get(TenantRepositoryFactory::class)
|
||||
));
|
||||
$container->set(ImportServicesFactory::class, static fn (AppContainer $c): ImportServicesFactory => new ImportServicesFactory(
|
||||
$c->get(UserServicesFactory::class),
|
||||
$c->get(AccessServicesFactory::class),
|
||||
$c->get(SettingServicesFactory::class),
|
||||
$c->get(DirectoryServicesFactory::class),
|
||||
$c->get(ImportAuditInterface::class),
|
||||
$c->get(TenantScopeService::class),
|
||||
$c->get(SessionStoreInterface::class)
|
||||
));
|
||||
$container->set(SchedulerServicesFactory::class, static fn (AppContainer $c): SchedulerServicesFactory => new SchedulerServicesFactory(
|
||||
$c->get(UserServicesFactory::class),
|
||||
$c->get(AuditRecorderInterface::class),
|
||||
$c->get(DatabaseSessionRepository::class),
|
||||
$c->get(SchedulerRepositoryFactory::class),
|
||||
$c
|
||||
));
|
||||
$container->set(DirectoryServicesFactory::class, static fn (AppContainer $c): DirectoryServicesFactory => new DirectoryServicesFactory(
|
||||
$c->get(UserServicesFactory::class),
|
||||
$c->get(AuditRecorderInterface::class),
|
||||
$c->get(DirectoryRepositoryFactory::class),
|
||||
$c->get(DirectoryGatewayFactory::class)
|
||||
));
|
||||
$container->set(DirectoryGatewayFactory::class, static fn (AppContainer $c): DirectoryGatewayFactory => new DirectoryGatewayFactory(
|
||||
$c->get(SettingServicesFactory::class),
|
||||
$c->get(TenantScopeService::class)
|
||||
));
|
||||
$container->set(AccessServicesFactory::class, static fn (AppContainer $c): AccessServicesFactory => new AccessServicesFactory(
|
||||
$c->get(AccessRepositoryFactory::class),
|
||||
$c->get(AccessGatewayFactory::class),
|
||||
// Wrapped in a second closure to break the circular dependency:
|
||||
// AccessServicesFactory <-> AccessPolicyFactory via PermissionService.
|
||||
static fn (): AccessPolicyFactory => $c->get(AccessPolicyFactory::class)
|
||||
));
|
||||
$container->set(AccessGatewayFactory::class, static fn (AppContainer $c): AccessGatewayFactory => new AccessGatewayFactory(
|
||||
$c->get(AccessRepositoryFactory::class),
|
||||
$c->get(AuditRecorderInterface::class),
|
||||
$c->get(SessionStoreInterface::class)
|
||||
));
|
||||
$container->set(AccessPolicyFactory::class, static fn (AppContainer $c): AccessPolicyFactory => new AccessPolicyFactory(
|
||||
$c->get(PermissionService::class),
|
||||
$c->get(TenantScopeService::class),
|
||||
$c->has(ModuleRegistry::class) ? $c->get(ModuleRegistry::class) : null,
|
||||
static fn (string $class): ?object => ModuleClassResolver::resolve($c, $class)
|
||||
));
|
||||
$container->set(UserGatewayFactory::class, static fn (AppContainer $c): UserGatewayFactory => new UserGatewayFactory(
|
||||
$c->get(AccessServicesFactory::class),
|
||||
$c->get(SettingServicesFactory::class),
|
||||
$c->get(TenantScopeService::class),
|
||||
$c->get(DirectoryRepositoryFactory::class),
|
||||
));
|
||||
$container->set(UserServicesFactory::class, static fn (AppContainer $c): UserServicesFactory => new UserServicesFactory(
|
||||
$c->get(AuditRecorderInterface::class),
|
||||
$c->get(UserLifecycleAuditInterface::class),
|
||||
$c->get(UserRepositoryFactory::class),
|
||||
$c->get(UserGatewayFactory::class),
|
||||
$c->get(DatabaseSessionRepository::class),
|
||||
// Wrapped in a closure to break circular dependency:
|
||||
// UserServicesFactory -> AssignableRoleService -> RoleRepository -> DirectoryServicesFactory -> UserServicesFactory
|
||||
static fn (): AssignableRoleService => $c->get(AssignableRoleService::class),
|
||||
$c->has(ModuleEventDispatcher::class) ? $c->get(ModuleEventDispatcher::class) : null
|
||||
));
|
||||
$container->set(AuthGatewayFactory::class, static fn (AppContainer $c): AuthGatewayFactory => new AuthGatewayFactory(
|
||||
$c->get(AuthRepositoryFactory::class),
|
||||
$c->get(AccessServicesFactory::class),
|
||||
$c->get(SettingServicesFactory::class),
|
||||
$c->get(TenantScopeService::class)
|
||||
));
|
||||
$container->set(AuthServicesFactory::class, static fn (AppContainer $c): AuthServicesFactory => new AuthServicesFactory(
|
||||
$c->get(UserServicesFactory::class),
|
||||
$c->get(AuditRecorderInterface::class),
|
||||
$c->get(MailServicesFactory::class),
|
||||
$c->get(AuthRepositoryFactory::class),
|
||||
$c->get(AuthGatewayFactory::class),
|
||||
$c->get(DatabaseSessionRepository::class),
|
||||
$c->get(SessionStoreInterface::class),
|
||||
$c->get(CookieStoreInterface::class),
|
||||
$c->get(RequestRuntimeInterface::class),
|
||||
$c
|
||||
));
|
||||
}
|
||||
}
|
||||
74
core/App/Container/Registrars/SettingsRegistrar.php
Normal file
74
core/App/Container/Registrars/SettingsRegistrar.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Repository\Auth\ApiTokenRepository;
|
||||
use MintyPHP\Repository\Auth\RememberTokenRepository;
|
||||
use MintyPHP\Service\Access\RoleService;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Branding\BrandingFaviconService;
|
||||
use MintyPHP\Service\Branding\BrandingLogoService;
|
||||
use MintyPHP\Service\Branding\BrandingServicesFactory;
|
||||
use MintyPHP\Service\Org\DepartmentService;
|
||||
use MintyPHP\Service\Settings\AdminSettingsService;
|
||||
use MintyPHP\Service\Settings\SettingCacheService;
|
||||
use MintyPHP\Service\Settings\SettingsApiPolicyGateway;
|
||||
use MintyPHP\Service\Settings\SettingsAppGateway;
|
||||
use MintyPHP\Service\Settings\SettingsDefaultsGateway;
|
||||
use MintyPHP\Service\Settings\SettingServicesFactory;
|
||||
use MintyPHP\Service\Settings\SettingsFrontendTelemetryGateway;
|
||||
use MintyPHP\Service\Settings\SettingsLoginPersistenceGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMetadataGateway;
|
||||
use MintyPHP\Service\Settings\SettingsMicrosoftGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSessionGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSmtpGateway;
|
||||
use MintyPHP\Service\Settings\SettingsSystemAuditGateway;
|
||||
use MintyPHP\Service\Settings\SettingsUserLifecycleGateway;
|
||||
use MintyPHP\Service\Settings\ThemeConfigGateway;
|
||||
use MintyPHP\Service\Tenant\TenantService;
|
||||
|
||||
final class SettingsRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(SettingsMetadataGateway::class, static fn (AppContainer $c): SettingsMetadataGateway => $c->get(SettingServicesFactory::class)->createSettingsMetadataGateway());
|
||||
$container->set(SettingsDefaultsGateway::class, static fn (AppContainer $c): SettingsDefaultsGateway => $c->get(SettingServicesFactory::class)->createSettingsDefaultsGateway());
|
||||
$container->set(SettingsAppGateway::class, static fn (AppContainer $c): SettingsAppGateway => $c->get(SettingServicesFactory::class)->createSettingsAppGateway());
|
||||
$container->set(SettingsApiPolicyGateway::class, static fn (AppContainer $c): SettingsApiPolicyGateway => $c->get(SettingServicesFactory::class)->createSettingsApiPolicyGateway());
|
||||
$container->set(SettingsUserLifecycleGateway::class, static fn (AppContainer $c): SettingsUserLifecycleGateway => $c->get(SettingServicesFactory::class)->createSettingsUserLifecycleGateway());
|
||||
$container->set(SettingsSessionGateway::class, static fn (AppContainer $c): SettingsSessionGateway => $c->get(SettingServicesFactory::class)->createSettingsSessionGateway());
|
||||
$container->set(SettingsSystemAuditGateway::class, static fn (AppContainer $c): SettingsSystemAuditGateway => $c->get(SettingServicesFactory::class)->createSettingsSystemAuditGateway());
|
||||
$container->set(SettingsFrontendTelemetryGateway::class, static fn (AppContainer $c): SettingsFrontendTelemetryGateway => $c->get(SettingServicesFactory::class)->createSettingsFrontendTelemetryGateway());
|
||||
$container->set(SettingsMicrosoftGateway::class, static fn (AppContainer $c): SettingsMicrosoftGateway => $c->get(SettingServicesFactory::class)->createSettingsMicrosoftGateway());
|
||||
$container->set(SettingsSmtpGateway::class, static fn (AppContainer $c): SettingsSmtpGateway => $c->get(SettingServicesFactory::class)->createSettingsSmtpGateway());
|
||||
$container->set(SettingsLoginPersistenceGateway::class, static fn (AppContainer $c): SettingsLoginPersistenceGateway => $c->get(SettingServicesFactory::class)->createSettingsLoginPersistenceGateway());
|
||||
$container->set(SettingCacheService::class, static fn (AppContainer $c): SettingCacheService => $c->get(SettingServicesFactory::class)->createSettingCacheService());
|
||||
$container->set(AdminSettingsService::class, static fn (AppContainer $c): AdminSettingsService => new AdminSettingsService(
|
||||
$c->get(SettingsDefaultsGateway::class),
|
||||
$c->get(SettingsAppGateway::class),
|
||||
$c->get(SettingsApiPolicyGateway::class),
|
||||
$c->get(SettingsUserLifecycleGateway::class),
|
||||
$c->get(SettingsSessionGateway::class),
|
||||
$c->get(SettingsSystemAuditGateway::class),
|
||||
$c->get(SettingsFrontendTelemetryGateway::class),
|
||||
$c->get(SettingsMicrosoftGateway::class),
|
||||
$c->get(SettingsSmtpGateway::class),
|
||||
$c->get(SettingsLoginPersistenceGateway::class),
|
||||
$c->get(SettingsMetadataGateway::class),
|
||||
$c->get(SettingCacheService::class),
|
||||
$c->get(TenantService::class),
|
||||
$c->get(RoleService::class),
|
||||
$c->get(DepartmentService::class),
|
||||
$c->get(RememberTokenRepository::class),
|
||||
$c->get(ApiTokenRepository::class),
|
||||
$c->get(AuditRecorderInterface::class)
|
||||
));
|
||||
$container->set(ThemeConfigGateway::class, static fn (AppContainer $c): ThemeConfigGateway => $c->get(SettingServicesFactory::class)->createThemeConfigGateway());
|
||||
// Branding services are registered here because they depend on settings gateways
|
||||
// and are consumed alongside settings (e.g. in UserAccessPdfService and the admin UI).
|
||||
$container->set(BrandingLogoService::class, static fn (AppContainer $c): BrandingLogoService => $c->get(BrandingServicesFactory::class)->createBrandingLogoService());
|
||||
$container->set(BrandingFaviconService::class, static fn (AppContainer $c): BrandingFaviconService => $c->get(BrandingServicesFactory::class)->createBrandingFaviconService());
|
||||
}
|
||||
}
|
||||
62
core/App/Container/Registrars/UserRegistrar.php
Normal file
62
core/App/Container/Registrars/UserRegistrar.php
Normal file
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Container\Registrars;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Repository\Access\UserRoleRepository;
|
||||
use MintyPHP\Repository\Org\UserDepartmentRepository;
|
||||
use MintyPHP\Repository\Tenant\UserTenantRepository;
|
||||
use MintyPHP\Repository\User\UserReadRepository;
|
||||
use MintyPHP\Repository\User\UserWriteRepository;
|
||||
use MintyPHP\Service\Auth\TenantSsoService;
|
||||
use MintyPHP\Service\Branding\BrandingLogoService;
|
||||
use MintyPHP\Service\User\UserAccessPdfService;
|
||||
use MintyPHP\Service\User\UserAccessTemplateService;
|
||||
use MintyPHP\Service\User\UserAccountService;
|
||||
use MintyPHP\Service\User\UserApiWriteInputMapper;
|
||||
use MintyPHP\Service\User\UserAssignmentService;
|
||||
use MintyPHP\Service\User\UserAvatarService;
|
||||
use MintyPHP\Service\User\UserDirectoryGateway;
|
||||
use MintyPHP\Service\User\UserLifecycleRestoreService;
|
||||
use MintyPHP\Service\User\UserLifecycleService;
|
||||
use MintyPHP\Service\User\UserPasswordPolicyService;
|
||||
use MintyPHP\Service\User\UserPasswordService;
|
||||
use MintyPHP\Service\User\UserRepositoryFactory;
|
||||
use MintyPHP\Service\User\UserServicesFactory;
|
||||
use MintyPHP\Service\User\UserTenantContextService;
|
||||
|
||||
final class UserRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(UserAccountService::class, static fn (AppContainer $c): UserAccountService => $c->get(UserServicesFactory::class)->createUserAccountService());
|
||||
$container->set(UserAssignmentService::class, static fn (AppContainer $c): UserAssignmentService => $c->get(UserServicesFactory::class)->createUserAssignmentService());
|
||||
$container->set(UserPasswordService::class, static fn (AppContainer $c): UserPasswordService => $c->get(UserServicesFactory::class)->createUserPasswordService());
|
||||
$container->set(UserAvatarService::class, static fn (AppContainer $c): UserAvatarService => $c->get(UserServicesFactory::class)->createUserAvatarService());
|
||||
$container->set(UserDirectoryGateway::class, static fn (AppContainer $c): UserDirectoryGateway => $c->get(UserServicesFactory::class)->createUserDirectoryGateway());
|
||||
$container->set(UserAccessTemplateService::class, static fn (AppContainer $c): UserAccessTemplateService => new UserAccessTemplateService(
|
||||
$c->get(TenantSsoService::class),
|
||||
$c->get(UserDirectoryGateway::class),
|
||||
$c->get(UserTenantRepository::class)
|
||||
));
|
||||
$container->set(UserAccessPdfService::class, static fn (AppContainer $c): UserAccessPdfService => new UserAccessPdfService(
|
||||
$c->get(UserAccessTemplateService::class),
|
||||
$c->get(BrandingLogoService::class)
|
||||
));
|
||||
$container->set(UserTenantContextService::class, static fn (AppContainer $c): UserTenantContextService => $c->get(UserServicesFactory::class)->createUserTenantContextService());
|
||||
$container->set(UserLifecycleService::class, static fn (AppContainer $c): UserLifecycleService => $c->get(UserServicesFactory::class)->createUserLifecycleService());
|
||||
$container->set(UserPasswordPolicyService::class, static fn (AppContainer $c): UserPasswordPolicyService => $c->get(UserServicesFactory::class)->createUserPasswordPolicyService());
|
||||
$container->set(UserLifecycleRestoreService::class, static fn (AppContainer $c): UserLifecycleRestoreService => $c->get(UserServicesFactory::class)->createUserLifecycleRestoreService());
|
||||
$container->set(UserApiWriteInputMapper::class, static fn (AppContainer $c): UserApiWriteInputMapper => new UserApiWriteInputMapper(
|
||||
$c->get(\MintyPHP\Service\Tenant\TenantService::class),
|
||||
$c->get(\MintyPHP\Service\Access\RoleService::class),
|
||||
$c->get(\MintyPHP\Service\Org\DepartmentService::class)
|
||||
));
|
||||
$container->set(UserReadRepository::class, static fn (AppContainer $c): UserReadRepository => $c->get(UserRepositoryFactory::class)->createUserReadRepository());
|
||||
$container->set(UserWriteRepository::class, static fn (AppContainer $c): UserWriteRepository => $c->get(UserRepositoryFactory::class)->createUserWriteRepository());
|
||||
$container->set(UserTenantRepository::class, static fn (AppContainer $c): UserTenantRepository => $c->get(UserRepositoryFactory::class)->createUserTenantRepository());
|
||||
$container->set(UserRoleRepository::class, static fn (AppContainer $c): UserRoleRepository => $c->get(UserRepositoryFactory::class)->createUserRoleRepository());
|
||||
$container->set(UserDepartmentRepository::class, static fn (AppContainer $c): UserDepartmentRepository => $c->get(UserRepositoryFactory::class)->createUserDepartmentRepository());
|
||||
}
|
||||
}
|
||||
18
core/App/Module/Contracts/EventListener.php
Normal file
18
core/App/Module/Contracts/EventListener.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module\Contracts;
|
||||
|
||||
/**
|
||||
* Interface for module event listeners.
|
||||
*
|
||||
* Modules declare listeners in their manifest under 'event_listeners'.
|
||||
* The dispatcher resolves listeners from the container and calls handle().
|
||||
*/
|
||||
interface EventListener
|
||||
{
|
||||
/**
|
||||
* @param string $event Event name (e.g. 'user.deleted')
|
||||
* @param array<string, mixed> $payload Event-specific data
|
||||
*/
|
||||
public function handle(string $event, array $payload): void;
|
||||
}
|
||||
25
core/App/Module/Contracts/LayoutContextProvider.php
Normal file
25
core/App/Module/Contracts/LayoutContextProvider.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module\Contracts;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
|
||||
/**
|
||||
* Provides module-specific data for the layout navigation context.
|
||||
*
|
||||
* Called from appBuildLayoutNavContext() after Core data assembly.
|
||||
* Return value is merged into the $layoutNav array passed to templates.
|
||||
*
|
||||
* IMPORTANT: Only called in web request context. CLI/scheduler callers
|
||||
* must not invoke layout context providers. Implementations should use
|
||||
* try/catch around request-dependent calls (e.g. requestInput()) as a
|
||||
* defensive measure.
|
||||
*/
|
||||
interface LayoutContextProvider
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $session Current session data
|
||||
* @return array<string, mixed> Key-value pairs to merge into $layoutNav
|
||||
*/
|
||||
public function provide(array $session, AppContainer $container): array;
|
||||
}
|
||||
17
core/App/Module/Contracts/ModuleDeactivationHandler.php
Normal file
17
core/App/Module/Contracts/ModuleDeactivationHandler.php
Normal file
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module\Contracts;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
|
||||
/**
|
||||
* Handles cleanup when a module is deactivated.
|
||||
*
|
||||
* Invoked by `php bin/console module:deactivate` after a module has been removed
|
||||
* from the enabled list. The handler receives the full app container and
|
||||
* can perform cleanup tasks like removing orphaned data or revoking permissions.
|
||||
*/
|
||||
interface ModuleDeactivationHandler
|
||||
{
|
||||
public function deactivate(AppContainer $container): void;
|
||||
}
|
||||
46
core/App/Module/Contracts/SearchResourceProvider.php
Normal file
46
core/App/Module/Contracts/SearchResourceProvider.php
Normal file
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module\Contracts;
|
||||
|
||||
/**
|
||||
* Provides search resources contributed by a module.
|
||||
*
|
||||
* Each provider returns a list of search resources (with SQL for count/preview/result),
|
||||
* maps result rows to display items, and provides list URLs for deep-linking.
|
||||
*/
|
||||
interface SearchResourceProvider
|
||||
{
|
||||
/**
|
||||
* Return search resource definitions.
|
||||
*
|
||||
* Supported SQL placeholders (replaced before parameter binding):
|
||||
* - `{{tenantFilter}}` — replaced with a tenant-scope WHERE clause (or empty string)
|
||||
* - `{{userId}}` — replaced with the current user's ID (integer, safe for inline SQL)
|
||||
*
|
||||
* All remaining `?` placeholders are bound to the LIKE search term.
|
||||
* The last `?` in preview_sql is reserved for the preview limit.
|
||||
*
|
||||
* @return array<string, array{
|
||||
* label: string,
|
||||
* permission: string,
|
||||
* count_sql: string,
|
||||
* preview_sql: string,
|
||||
* result_sql: string,
|
||||
* tenant_filter?: string
|
||||
* }>
|
||||
*/
|
||||
public function resources(): array;
|
||||
|
||||
/**
|
||||
* Map a raw result row to a display-ready item.
|
||||
*
|
||||
* @param array<string, mixed> $row
|
||||
* @return array{title: string, subtitle?: string, url: string, icon?: string}|null
|
||||
*/
|
||||
public function mapResultItem(string $resourceKey, array $row): ?array;
|
||||
|
||||
/**
|
||||
* Return the list URL for the given resource key and encoded search term.
|
||||
*/
|
||||
public function listUrl(string $resourceKey, string $encodedSearch): string;
|
||||
}
|
||||
26
core/App/Module/Contracts/SessionProvider.php
Normal file
26
core/App/Module/Contracts/SessionProvider.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module\Contracts;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
|
||||
/**
|
||||
* Populates module-specific session data after login and cleans up on logout.
|
||||
*
|
||||
* Called from the login flow after successful authentication. When a module is
|
||||
* deactivated, its provider is not called — session keys are simply not set.
|
||||
*/
|
||||
interface SessionProvider
|
||||
{
|
||||
/**
|
||||
* Populate session keys with module-specific data after successful login.
|
||||
*
|
||||
* @param array<string, mixed> $user The authenticated user record
|
||||
*/
|
||||
public function populate(array $user, AppContainer $container): void;
|
||||
|
||||
/**
|
||||
* Remove session keys owned by this module (called on logout).
|
||||
*/
|
||||
public function clear(AppContainer $container): void;
|
||||
}
|
||||
50
core/App/Module/ModuleClassResolver.php
Normal file
50
core/App/Module/ModuleClassResolver.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
|
||||
/**
|
||||
* Resolve module-declared classes from DI first and only fall back to direct
|
||||
* instantiation when the class is not container-bound and has no required
|
||||
* constructor arguments.
|
||||
*/
|
||||
final class ModuleClassResolver
|
||||
{
|
||||
public static function resolve(AppContainer $container, string $class): ?object
|
||||
{
|
||||
$class = trim($class);
|
||||
if ($class === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($container->has($class)) {
|
||||
try {
|
||||
$resolved = $container->get($class);
|
||||
return is_object($resolved) ? $resolved : null;
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!class_exists($class)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$reflection = new \ReflectionClass($class);
|
||||
if (!$reflection->isInstantiable()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$constructor = $reflection->getConstructor();
|
||||
if ($constructor !== null && $constructor->getNumberOfRequiredParameters() > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $reflection->newInstance();
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
75
core/App/Module/ModuleEventDispatcher.php
Normal file
75
core/App/Module/ModuleEventDispatcher.php
Normal file
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Module\Contracts\EventListener;
|
||||
use MintyPHP\Http\RequestContext;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
|
||||
/**
|
||||
* Fire-and-forget event dispatcher for module lifecycle events.
|
||||
*
|
||||
* Listeners are declared in module manifests and lazy-resolved from the container.
|
||||
* A failing listener does not break the dispatch chain — the exception is swallowed
|
||||
* so that one misbehaving module cannot disrupt core flows.
|
||||
*/
|
||||
final class ModuleEventDispatcher
|
||||
{
|
||||
/**
|
||||
* @param array<string, list<array{class: class-string, method: string}>> $listenerMap
|
||||
*/
|
||||
public function __construct(
|
||||
private readonly array $listenerMap,
|
||||
private readonly AppContainer $container,
|
||||
private readonly ?AuditRecorderInterface $systemAuditService = null
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function dispatch(string $event, array $payload): void
|
||||
{
|
||||
$listeners = $this->listenerMap[$event] ?? [];
|
||||
|
||||
foreach ($listeners as $descriptor) {
|
||||
$class = '';
|
||||
$method = '';
|
||||
try {
|
||||
$class = $descriptor['class'];
|
||||
$method = $descriptor['method'];
|
||||
|
||||
$listener = ModuleClassResolver::resolve($this->container, $class);
|
||||
if (!$listener instanceof EventListener) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$listener->{$method}($event, $payload);
|
||||
} catch (\Throwable $exception) {
|
||||
$this->recordListenerFailure($event, $class, $method, $exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function recordListenerFailure(
|
||||
string $event,
|
||||
string $listenerClass,
|
||||
string $listenerMethod,
|
||||
\Throwable $exception
|
||||
): void {
|
||||
if ($this->systemAuditService === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->systemAuditService->record('module.listener.failed', 'failed', [
|
||||
'metadata' => [
|
||||
'module_event' => $event,
|
||||
'listener_class' => $listenerClass,
|
||||
'listener_method' => $listenerMethod,
|
||||
'exception_class' => $exception::class,
|
||||
'request_id' => RequestContext::id(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
55
core/App/Module/ModuleI18nLoader.php
Normal file
55
core/App/Module/ModuleI18nLoader.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use MintyPHP\I18n;
|
||||
use ReflectionProperty;
|
||||
|
||||
/**
|
||||
* Preloads core + module translations into I18n::$strings at boot time.
|
||||
*
|
||||
* Since I18n::$strings is private static and lives in a vendor class,
|
||||
* Reflection is used once per locale to inject the merged translation map.
|
||||
*/
|
||||
final class ModuleI18nLoader
|
||||
{
|
||||
/**
|
||||
* @param list<ModuleManifest> $manifests Active module manifests (already sorted)
|
||||
* @param string $coreI18nDir Absolute path to the core i18n/ directory
|
||||
*/
|
||||
public static function preload(array $manifests, string $coreI18nDir): void
|
||||
{
|
||||
$locales = ['de', 'en'];
|
||||
$domain = I18n::$domain ?: 'default';
|
||||
|
||||
$prop = new ReflectionProperty(I18n::class, 'strings');
|
||||
|
||||
foreach ($locales as $locale) {
|
||||
// Load core file
|
||||
$coreFile = $coreI18nDir . '/' . $domain . '_' . $locale . '.json';
|
||||
$merged = is_file($coreFile)
|
||||
? (json_decode(file_get_contents($coreFile), true) ?: [])
|
||||
: [];
|
||||
|
||||
// Merge each module's translations on top
|
||||
foreach ($manifests as $manifest) {
|
||||
if ($manifest->i18nPath === null) {
|
||||
continue;
|
||||
}
|
||||
$moduleFile = $manifest->i18nPath . '/' . $domain . '_' . $locale . '.json';
|
||||
if (!is_file($moduleFile)) {
|
||||
continue;
|
||||
}
|
||||
$moduleStrings = json_decode(file_get_contents($moduleFile), true);
|
||||
if (is_array($moduleStrings)) {
|
||||
$merged = array_merge($merged, $moduleStrings);
|
||||
}
|
||||
}
|
||||
|
||||
// Inject via Reflection into I18n::$strings
|
||||
$strings = $prop->getValue();
|
||||
$strings[$domain][$locale] = $merged;
|
||||
$prop->setValue(null, $strings);
|
||||
}
|
||||
}
|
||||
}
|
||||
462
core/App/Module/ModuleManifest.php
Normal file
462
core/App/Module/ModuleManifest.php
Normal file
@@ -0,0 +1,462 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Immutable value object describing a single module's contributions.
|
||||
*
|
||||
* Each module ships a `module.php` that returns an associative array.
|
||||
* ModuleManifest validates and normalises that array into a typed contract.
|
||||
*/
|
||||
final class ModuleManifest
|
||||
{
|
||||
public readonly string $id;
|
||||
public readonly string $version;
|
||||
public readonly bool $enabledByDefault;
|
||||
public readonly int $loadOrder;
|
||||
|
||||
/** @var array<int, array{path: string, target: string, public?: bool}> */
|
||||
public readonly array $routes;
|
||||
|
||||
/** @var list<string> */
|
||||
public readonly array $publicPaths;
|
||||
|
||||
/** @var list<class-string> Container registrar FQCNs */
|
||||
public readonly array $containerRegistrars;
|
||||
|
||||
/** @var array<string, mixed> Keyed by slot name (e.g. 'aside.tab_panel') */
|
||||
public readonly array $uiSlots;
|
||||
|
||||
/** @var list<class-string> SearchResourceProvider FQCNs */
|
||||
public readonly array $searchResources;
|
||||
|
||||
/** @var array<string, list<string>> Asset group name → file list */
|
||||
public readonly array $assetGroups;
|
||||
|
||||
/**
|
||||
* @var list<array{
|
||||
* job_key: string,
|
||||
* handler: string,
|
||||
* label: string,
|
||||
* description: string,
|
||||
* default_enabled: int,
|
||||
* default_timezone: string,
|
||||
* default_schedule_type: string,
|
||||
* default_schedule_interval: int,
|
||||
* default_schedule_time: ?string,
|
||||
* default_schedule_weekdays_csv: ?string,
|
||||
* default_catchup_once: int,
|
||||
* allowed_schedule_types: list<string>
|
||||
* }>
|
||||
*/
|
||||
public readonly array $schedulerJobs;
|
||||
|
||||
/** @var list<class-string> LayoutContextProvider FQCNs */
|
||||
public readonly array $layoutContextProviders;
|
||||
|
||||
/** @var list<class-string> SessionProvider FQCNs */
|
||||
public readonly array $sessionProviders;
|
||||
|
||||
/**
|
||||
* @var list<array{
|
||||
* key: string,
|
||||
* description: string,
|
||||
* active: int,
|
||||
* is_system: int
|
||||
* }>
|
||||
*/
|
||||
public readonly array $permissions;
|
||||
|
||||
/** @var list<class-string> AuthorizationPolicyInterface FQCNs */
|
||||
public readonly array $authorizationPolicies;
|
||||
|
||||
/** @var list<string> Module IDs required by this module */
|
||||
public readonly array $requires;
|
||||
|
||||
/** @var array<string, list<array{class: class-string, method: string}>> Event listeners keyed by event name */
|
||||
public readonly array $eventListeners;
|
||||
|
||||
/** @var class-string|null ModuleDeactivationHandler FQCN */
|
||||
public readonly ?string $deactivationHandler;
|
||||
|
||||
public readonly ?string $migrationsPath;
|
||||
|
||||
public readonly ?string $i18nPath;
|
||||
|
||||
public readonly string $basePath;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $raw The manifest array returned by module.php
|
||||
* @param string $basePath Absolute path to the module directory
|
||||
*/
|
||||
private function __construct(array $raw, string $basePath)
|
||||
{
|
||||
$this->basePath = $basePath;
|
||||
|
||||
// — required fields ————————————————————————————
|
||||
if (!isset($raw['id']) || !is_string($raw['id']) || trim($raw['id']) === '') {
|
||||
throw new InvalidArgumentException('Module manifest must have a non-empty string "id".');
|
||||
}
|
||||
$this->id = trim($raw['id']);
|
||||
|
||||
$this->version = isset($raw['version']) && is_string($raw['version']) ? trim($raw['version']) : '0.0.0';
|
||||
$this->enabledByDefault = (bool) ($raw['enabled_by_default'] ?? false);
|
||||
$this->loadOrder = (int) ($raw['load_order'] ?? 100);
|
||||
|
||||
// — optional contribution arrays ———————————————
|
||||
$this->routes = self::arrayOf($raw, 'routes');
|
||||
$this->publicPaths = self::listOf($raw, 'public_paths');
|
||||
$this->containerRegistrars = self::listOf($raw, 'container_registrars');
|
||||
$this->uiSlots = is_array($raw['ui_slots'] ?? null) ? $raw['ui_slots'] : [];
|
||||
$this->searchResources = self::listOf($raw, 'search_resources');
|
||||
$this->assetGroups = is_array($raw['asset_groups'] ?? null) ? $raw['asset_groups'] : [];
|
||||
$this->schedulerJobs = self::normalizeSchedulerJobs($raw['scheduler_jobs'] ?? [], $this->id);
|
||||
$this->layoutContextProviders = self::listOf($raw, 'layout_context_providers');
|
||||
$this->sessionProviders = self::listOf($raw, 'session_providers');
|
||||
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
|
||||
$this->authorizationPolicies = self::listOf($raw, 'authorization_policies');
|
||||
|
||||
$this->requires = self::normalizeRequires($raw['requires'] ?? [], $this->id);
|
||||
$this->eventListeners = self::normalizeEventListeners($raw['event_listeners'] ?? [], $this->id);
|
||||
|
||||
$deactivationHandler = $raw['deactivation_handler'] ?? null;
|
||||
$this->deactivationHandler = is_string($deactivationHandler) && trim($deactivationHandler) !== ''
|
||||
? trim($deactivationHandler)
|
||||
: null;
|
||||
|
||||
$migrationsPath = $raw['migrations_path'] ?? null;
|
||||
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
|
||||
? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/')
|
||||
: null;
|
||||
|
||||
$i18nPath = $raw['i18n_path'] ?? null;
|
||||
$this->i18nPath = is_string($i18nPath) && trim($i18nPath) !== ''
|
||||
? rtrim($basePath, '/') . '/' . ltrim(trim($i18nPath), '/')
|
||||
: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $raw
|
||||
*/
|
||||
public static function fromArray(array $raw, string $basePath): self
|
||||
{
|
||||
return new self($raw, $basePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<mixed>
|
||||
*/
|
||||
private static function listOf(array $raw, string $key): array
|
||||
{
|
||||
$value = $raw[$key] ?? [];
|
||||
return is_array($value) ? array_values($value) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int|string, mixed>
|
||||
*/
|
||||
private static function arrayOf(array $raw, string $key): array
|
||||
{
|
||||
$value = $raw[$key] ?? [];
|
||||
return is_array($value) ? $value : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, description: string, active: int, is_system: int}>
|
||||
*/
|
||||
private static function normalizePermissions(mixed $value, string $moduleId): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' manifest key 'permissions' must be an array.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
$permissions = [];
|
||||
foreach (array_values($value) as $index => $permission) {
|
||||
if (!is_array($permission)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' permissions[%d] must be an object-like array.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$key = trim((string) ($permission['key'] ?? ''));
|
||||
$description = trim((string) ($permission['description'] ?? ''));
|
||||
if ($key === '' || $description === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' permissions[%d] must define non-empty 'key' and 'description'.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$permissions[] = [
|
||||
'key' => $key,
|
||||
'description' => $description,
|
||||
'active' => (int) ((bool) ($permission['active'] ?? true)),
|
||||
'is_system' => (int) ((bool) ($permission['is_system'] ?? true)),
|
||||
];
|
||||
}
|
||||
|
||||
return $permissions;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* job_key: string,
|
||||
* handler: string,
|
||||
* label: string,
|
||||
* description: string,
|
||||
* default_enabled: int,
|
||||
* default_timezone: string,
|
||||
* default_schedule_type: string,
|
||||
* default_schedule_interval: int,
|
||||
* default_schedule_time: ?string,
|
||||
* default_schedule_weekdays_csv: ?string,
|
||||
* default_catchup_once: int,
|
||||
* allowed_schedule_types: list<string>
|
||||
* }>
|
||||
*/
|
||||
private static function normalizeSchedulerJobs(mixed $value, string $moduleId): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' manifest key 'scheduler_jobs' must be an array.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
$jobs = [];
|
||||
foreach (array_values($value) as $index => $job) {
|
||||
if (!is_array($job)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] must be an object-like array.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$requiredKeys = [
|
||||
'job_key',
|
||||
'handler',
|
||||
'label',
|
||||
'description',
|
||||
'default_enabled',
|
||||
'default_timezone',
|
||||
'default_schedule_type',
|
||||
'default_schedule_interval',
|
||||
'default_schedule_time',
|
||||
'default_schedule_weekdays_csv',
|
||||
'default_catchup_once',
|
||||
'allowed_schedule_types',
|
||||
];
|
||||
foreach ($requiredKeys as $requiredKey) {
|
||||
if (!array_key_exists($requiredKey, $job)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] is missing required key '%s'.", $moduleId, $index, $requiredKey)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
$jobKey = trim((string) ($job['job_key'] ?? ''));
|
||||
$handler = trim((string) ($job['handler'] ?? ''));
|
||||
$label = trim((string) ($job['label'] ?? ''));
|
||||
$description = trim((string) ($job['description'] ?? ''));
|
||||
$defaultTimezone = trim((string) ($job['default_timezone'] ?? ''));
|
||||
$defaultType = strtolower(trim((string) ($job['default_schedule_type'] ?? '')));
|
||||
|
||||
if ($jobKey === '' || $handler === '' || $label === '' || $description === '' || $defaultTimezone === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
"Module '%s' scheduler_jobs[%d] requires non-empty job_key, handler, label, description and default_timezone.",
|
||||
$moduleId,
|
||||
$index
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
if (!in_array($defaultType, ['hourly', 'daily', 'weekly'], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] has invalid default_schedule_type '%s'.", $moduleId, $index, $defaultType)
|
||||
);
|
||||
}
|
||||
|
||||
$allowedTypes = self::normalizeAllowedScheduleTypes($job['allowed_schedule_types'], $moduleId, $index);
|
||||
if (!in_array($defaultType, $allowedTypes, true)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf(
|
||||
"Module '%s' scheduler_jobs[%d] default_schedule_type '%s' must be listed in allowed_schedule_types.",
|
||||
$moduleId,
|
||||
$index,
|
||||
$defaultType
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$defaultInterval = (int) $job['default_schedule_interval'];
|
||||
$defaultTimeRaw = trim((string) ($job['default_schedule_time'] ?? ''));
|
||||
$defaultTime = $defaultTimeRaw !== '' ? $defaultTimeRaw : null;
|
||||
$defaultWeekdays = self::normalizeWeekdaysCsv(
|
||||
$job['default_schedule_weekdays_csv'],
|
||||
$moduleId,
|
||||
$index
|
||||
);
|
||||
|
||||
if (in_array($defaultType, ['daily', 'weekly'], true) && $defaultTime === null) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_time for %s jobs.", $moduleId, $index, $defaultType)
|
||||
);
|
||||
}
|
||||
if ($defaultType === 'weekly' && $defaultWeekdays === null) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_weekdays_csv for weekly jobs.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$jobs[] = [
|
||||
'job_key' => $jobKey,
|
||||
'handler' => $handler,
|
||||
'label' => $label,
|
||||
'description' => $description,
|
||||
'default_enabled' => (int) ((bool) $job['default_enabled']),
|
||||
'default_timezone' => $defaultTimezone,
|
||||
'default_schedule_type' => $defaultType,
|
||||
'default_schedule_interval' => $defaultInterval,
|
||||
'default_schedule_time' => $defaultTime,
|
||||
'default_schedule_weekdays_csv' => $defaultWeekdays,
|
||||
'default_catchup_once' => (int) ((bool) $job['default_catchup_once']),
|
||||
'allowed_schedule_types' => $allowedTypes,
|
||||
];
|
||||
}
|
||||
|
||||
return $jobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalizeAllowedScheduleTypes(mixed $value, string $moduleId, int $index): array
|
||||
{
|
||||
if (!is_array($value) || $value === []) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types must be a non-empty array.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$types = [];
|
||||
foreach ($value as $rawType) {
|
||||
$type = strtolower(trim((string) $rawType));
|
||||
if (!in_array($type, ['hourly', 'daily', 'weekly'], true)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types contains invalid type '%s'.", $moduleId, $index, $type)
|
||||
);
|
||||
}
|
||||
$types[] = $type;
|
||||
}
|
||||
|
||||
$types = array_values(array_unique($types));
|
||||
sort($types, SORT_STRING);
|
||||
return $types;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, list<array{class: class-string, method: string}>>
|
||||
*/
|
||||
private static function normalizeEventListeners(mixed $value, string $moduleId): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' manifest key 'event_listeners' must be an array.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
$listeners = [];
|
||||
foreach ($value as $eventName => $handlers) {
|
||||
$eventName = trim((string) $eventName);
|
||||
if ($eventName === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' event_listeners has an empty event name key.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
if (!is_array($handlers)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' event_listeners['%s'] must be an array of handler descriptors.", $moduleId, $eventName)
|
||||
);
|
||||
}
|
||||
|
||||
$normalizedHandlers = [];
|
||||
foreach (array_values($handlers) as $index => $handler) {
|
||||
if (!is_array($handler)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' event_listeners['%s'][%d] must be an array with 'class' and 'method'.", $moduleId, $eventName, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$class = trim((string) ($handler['class'] ?? ''));
|
||||
$method = trim((string) ($handler['method'] ?? ''));
|
||||
if ($class === '' || $method === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' event_listeners['%s'][%d] must define non-empty 'class' and 'method'.", $moduleId, $eventName, $index)
|
||||
);
|
||||
}
|
||||
|
||||
$normalizedHandlers[] = ['class' => $class, 'method' => $method];
|
||||
}
|
||||
|
||||
if ($normalizedHandlers !== []) {
|
||||
$listeners[$eventName] = $normalizedHandlers;
|
||||
}
|
||||
}
|
||||
|
||||
return $listeners;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function normalizeRequires(mixed $value, string $moduleId): array
|
||||
{
|
||||
if (!is_array($value)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' manifest key 'requires' must be an array.", $moduleId)
|
||||
);
|
||||
}
|
||||
|
||||
$requires = [];
|
||||
foreach (array_values($value) as $index => $item) {
|
||||
if (!is_string($item) || trim($item) === '') {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' requires[%d] must be a non-empty string.", $moduleId, $index)
|
||||
);
|
||||
}
|
||||
$requires[] = trim($item);
|
||||
}
|
||||
|
||||
return array_values(array_unique($requires));
|
||||
}
|
||||
|
||||
private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string
|
||||
{
|
||||
$raw = trim((string) $value);
|
||||
if ($raw === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$days = array_filter(array_map('trim', explode(',', $raw)), static fn (string $part): bool => $part !== '');
|
||||
if ($days === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$normalizedDays = [];
|
||||
foreach ($days as $day) {
|
||||
if (!preg_match('/^[1-7]$/', $day)) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf("Module '%s' scheduler_jobs[%d] has invalid weekday '%s' in default_schedule_weekdays_csv.", $moduleId, $index, $day)
|
||||
);
|
||||
}
|
||||
$normalizedDays[] = (int) $day;
|
||||
}
|
||||
|
||||
$normalizedDays = array_values(array_unique($normalizedDays));
|
||||
sort($normalizedDays, SORT_NUMERIC);
|
||||
return implode(',', array_map(static fn (int $day): string => (string) $day, $normalizedDays));
|
||||
}
|
||||
}
|
||||
44
core/App/Module/ModuleManifestLoader.php
Normal file
44
core/App/Module/ModuleManifestLoader.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Loads a single module manifest from disk without requiring the module to be enabled.
|
||||
*
|
||||
* Unlike ModuleRegistry (which only loads enabled modules), this loader can resolve
|
||||
* any module by its directory — used by validation and deactivation tooling.
|
||||
*/
|
||||
final class ModuleManifestLoader
|
||||
{
|
||||
/**
|
||||
* Load and validate a module manifest from its on-disk location.
|
||||
*
|
||||
* @param string $modulesDir Absolute path to the modules/ directory
|
||||
* @param string $moduleId Directory name / module ID
|
||||
*
|
||||
* @throws RuntimeException If the manifest file is missing or invalid
|
||||
*/
|
||||
public static function loadFromDisk(string $modulesDir, string $moduleId): ModuleManifest
|
||||
{
|
||||
$manifestFile = rtrim($modulesDir, '/') . '/' . $moduleId . '/module.php';
|
||||
|
||||
if (!is_file($manifestFile)) {
|
||||
throw new RuntimeException(
|
||||
"Module manifest not found at: {$manifestFile}"
|
||||
);
|
||||
}
|
||||
|
||||
$raw = require $manifestFile;
|
||||
if (!is_array($raw)) {
|
||||
throw new RuntimeException(
|
||||
"Module manifest at '{$manifestFile}' must return an array."
|
||||
);
|
||||
}
|
||||
|
||||
ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile);
|
||||
|
||||
return ModuleManifest::fromArray($raw, dirname($manifestFile));
|
||||
}
|
||||
}
|
||||
108
core/App/Module/ModuleManifestSchemaValidator.php
Normal file
108
core/App/Module/ModuleManifestSchemaValidator.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use Opis\JsonSchema\Errors\ErrorFormatter;
|
||||
use Opis\JsonSchema\Errors\ValidationError;
|
||||
use Opis\JsonSchema\JsonPointer;
|
||||
use Opis\JsonSchema\Validator;
|
||||
use RuntimeException;
|
||||
|
||||
final class ModuleManifestSchemaValidator
|
||||
{
|
||||
private const DEFAULT_SCHEMA_PATH = '.agents/contracts/module-manifest.schema.json';
|
||||
|
||||
/** @var array<string, object> */
|
||||
private static array $schemaCache = [];
|
||||
|
||||
public static function assertValid(
|
||||
array $rawManifest,
|
||||
string $moduleId,
|
||||
?string $manifestFile = null,
|
||||
?string $schemaFile = null
|
||||
): void {
|
||||
$schemaPath = self::resolveSchemaPath($schemaFile);
|
||||
$schema = self::loadSchema($schemaPath);
|
||||
|
||||
$payload = json_decode((string) json_encode($rawManifest), false);
|
||||
if (!is_object($payload)) {
|
||||
throw new RuntimeException("Manifest schema validation failed for module '{$moduleId}': manifest payload is not serializable.");
|
||||
}
|
||||
|
||||
$validator = new Validator();
|
||||
$result = $validator->validate($payload, $schema);
|
||||
if ($result->isValid()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$location = $manifestFile ?? ("modules/{$moduleId}/module.php");
|
||||
$message = self::formatValidationErrors($result->error());
|
||||
throw new RuntimeException(
|
||||
"Manifest schema validation failed for module '{$moduleId}' ({$location}): {$message}"
|
||||
);
|
||||
}
|
||||
|
||||
private static function resolveSchemaPath(?string $schemaFile): string
|
||||
{
|
||||
if ($schemaFile !== null && trim($schemaFile) !== '') {
|
||||
return $schemaFile;
|
||||
}
|
||||
|
||||
return dirname(__DIR__, 3) . '/' . self::DEFAULT_SCHEMA_PATH;
|
||||
}
|
||||
|
||||
private static function loadSchema(string $schemaPath): object
|
||||
{
|
||||
$normalizedPath = str_replace('\\', '/', $schemaPath);
|
||||
if (isset(self::$schemaCache[$normalizedPath])) {
|
||||
return self::$schemaCache[$normalizedPath];
|
||||
}
|
||||
|
||||
if (!is_file($schemaPath)) {
|
||||
throw new RuntimeException("Module manifest schema not found: {$schemaPath}");
|
||||
}
|
||||
|
||||
$rawSchema = file_get_contents($schemaPath);
|
||||
if ($rawSchema === false) {
|
||||
throw new RuntimeException("Unable to read module manifest schema: {$schemaPath}");
|
||||
}
|
||||
|
||||
$decodedSchema = json_decode($rawSchema);
|
||||
if (!is_object($decodedSchema)) {
|
||||
throw new RuntimeException("Invalid JSON schema in {$schemaPath}");
|
||||
}
|
||||
|
||||
self::$schemaCache[$normalizedPath] = $decodedSchema;
|
||||
return $decodedSchema;
|
||||
}
|
||||
|
||||
private static function formatValidationErrors(?ValidationError $error): string
|
||||
{
|
||||
if ($error === null) {
|
||||
return 'unknown schema validation error';
|
||||
}
|
||||
|
||||
$formatter = new ErrorFormatter();
|
||||
$messages = $formatter->formatFlat(
|
||||
$error,
|
||||
static function (ValidationError $validationError) use ($formatter): string {
|
||||
$path = JsonPointer::pathToString($validationError->data()->fullPath());
|
||||
$normalizedPath = $path === '' ? '/' : $path;
|
||||
$message = $formatter->formatErrorMessage($validationError);
|
||||
|
||||
return "{$normalizedPath}: {$message}";
|
||||
}
|
||||
);
|
||||
|
||||
$messages = array_values(array_unique(array_filter(
|
||||
$messages,
|
||||
static fn ($message): bool => is_string($message) && trim($message) !== ''
|
||||
)));
|
||||
|
||||
if ($messages === []) {
|
||||
return 'unknown schema validation error';
|
||||
}
|
||||
|
||||
return implode('; ', array_slice($messages, 0, 3));
|
||||
}
|
||||
}
|
||||
209
core/App/Module/ModulePermissionSynchronizer.php
Normal file
209
core/App/Module/ModulePermissionSynchronizer.php
Normal file
@@ -0,0 +1,209 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use MintyPHP\Repository\Access\PermissionRepositoryInterface;
|
||||
|
||||
/**
|
||||
* Synchronizes module-defined permissions into the permissions table.
|
||||
*/
|
||||
final class ModulePermissionSynchronizer
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ModuleRegistry $moduleRegistry,
|
||||
private readonly PermissionRepositoryInterface $permissionRepository,
|
||||
private readonly string $modulesDir = ''
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{created: int, updated: int, unchanged: int, deactivated: int, total: int}
|
||||
*/
|
||||
public function sync(): array
|
||||
{
|
||||
$created = 0;
|
||||
$updated = 0;
|
||||
$unchanged = 0;
|
||||
$total = 0;
|
||||
|
||||
foreach ($this->moduleRegistry->getPermissions() as $permissionDefinition) {
|
||||
$total++;
|
||||
$key = trim((string) $permissionDefinition['key']);
|
||||
if ($key === '') {
|
||||
throw new \RuntimeException('Module permission definition has empty key.');
|
||||
}
|
||||
|
||||
$desired = [
|
||||
'key' => $key,
|
||||
'description' => trim((string) $permissionDefinition['description']),
|
||||
'active' => (int) $permissionDefinition['active'],
|
||||
'is_system' => (int) $permissionDefinition['is_system'],
|
||||
];
|
||||
|
||||
$existing = $this->permissionRepository->findByKey($key);
|
||||
if (!is_array($existing)) {
|
||||
$createdId = $this->permissionRepository->create($desired);
|
||||
if ($createdId === null) {
|
||||
throw new \RuntimeException("Failed to create module permission '{$key}'.");
|
||||
}
|
||||
$created++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$existingId = (int) ($existing['id'] ?? 0);
|
||||
if ($existingId <= 0) {
|
||||
throw new \RuntimeException("Permission '{$key}' exists without a valid id.");
|
||||
}
|
||||
|
||||
if ($this->isPermissionEqual($existing, $desired)) {
|
||||
$unchanged++;
|
||||
continue;
|
||||
}
|
||||
|
||||
$wasUpdated = $this->permissionRepository->update($existingId, $desired);
|
||||
if (!$wasUpdated) {
|
||||
throw new \RuntimeException("Failed to update module permission '{$key}' (id {$existingId}).");
|
||||
}
|
||||
$updated++;
|
||||
}
|
||||
|
||||
$deactivated = $this->deactivateOrphaned();
|
||||
|
||||
return [
|
||||
'created' => $created,
|
||||
'updated' => $updated,
|
||||
'unchanged' => $unchanged,
|
||||
'deactivated' => $deactivated,
|
||||
'total' => $total,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate module-owned permissions that no active module claims.
|
||||
*
|
||||
* Only touches permissions whose key is declared in at least one module
|
||||
* manifest on disk (enabled or not). Core seed permissions are never touched.
|
||||
* Setting active=0 is reversible — re-enabling the module and running sync
|
||||
* restores active=1.
|
||||
*/
|
||||
private function deactivateOrphaned(): int
|
||||
{
|
||||
$activeKeys = $this->moduleRegistry->getPermissionKeys();
|
||||
$allModuleKeys = $this->collectAllModulePermissionKeys();
|
||||
|
||||
// Nothing to deactivate if we can't determine module-owned keys
|
||||
if ($allModuleKeys === []) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
$systemPermissions = $this->permissionRepository->listSystem();
|
||||
$deactivated = 0;
|
||||
|
||||
foreach ($systemPermissions as $permission) {
|
||||
$key = (string) $permission['key'];
|
||||
$id = (int) $permission['id'];
|
||||
$isActive = (int) $permission['active'];
|
||||
|
||||
if ($key === '' || $id <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Already inactive — nothing to do
|
||||
if ($isActive === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Still claimed by an active module — keep it
|
||||
if (in_array($key, $activeKeys, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Not a module-owned permission — never touch core seed permissions
|
||||
if (!in_array($key, $allModuleKeys, true)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Orphaned module permission: declared by a module on disk but not by any active module
|
||||
$this->permissionRepository->update($id, [
|
||||
'key' => $key,
|
||||
'description' => (string) $permission['description'],
|
||||
'active' => 0,
|
||||
'is_system' => 1,
|
||||
]);
|
||||
$deactivated++;
|
||||
}
|
||||
|
||||
return $deactivated;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all module manifests on disk (not just enabled) and collect their permission keys.
|
||||
* This determines which permissions are module-owned vs core seed data.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function collectAllModulePermissionKeys(): array
|
||||
{
|
||||
if ($this->modulesDir === '' || !is_dir($this->modulesDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$keys = [];
|
||||
$entries = scandir($this->modulesDir);
|
||||
if ($entries === false) {
|
||||
return [];
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$manifestFile = $this->modulesDir . '/' . $entry . '/module.php';
|
||||
if (!is_file($manifestFile)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$raw = require $manifestFile;
|
||||
if (!is_array($raw)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$permissions = $raw['permissions'] ?? [];
|
||||
if (!is_array($permissions)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($permissions as $permission) {
|
||||
if (is_array($permission)) {
|
||||
$key = trim((string) ($permission['key'] ?? ''));
|
||||
if ($key !== '') {
|
||||
$keys[] = $key;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Skip broken manifests — don't break the sync
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique($keys));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $existing
|
||||
* @param array<string, mixed> $desired
|
||||
*/
|
||||
private function isPermissionEqual(array $existing, array $desired): bool
|
||||
{
|
||||
$existingDescription = trim((string) ($existing['description'] ?? ''));
|
||||
$existingActive = (int) ($existing['active'] ?? 0);
|
||||
$existingSystem = (int) ($existing['is_system'] ?? 0);
|
||||
|
||||
return $existingDescription === (string) ($desired['description'] ?? '')
|
||||
&& $existingActive === (int) ($desired['active'] ?? 0)
|
||||
&& $existingSystem === (int) ($desired['is_system'] ?? 0);
|
||||
}
|
||||
}
|
||||
701
core/App/Module/ModuleRegistry.php
Normal file
701
core/App/Module/ModuleRegistry.php
Normal file
@@ -0,0 +1,701 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use Composer\Autoload\ClassLoader;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Loads, validates and merges module manifests.
|
||||
*
|
||||
* Modules are discovered from a configurable directory and activated via an
|
||||
* allow-list (config/modules.php + APP_ENABLED_MODULES env variable).
|
||||
*
|
||||
* The registry is immutable after construction — all contributions are merged
|
||||
* at boot time and available through typed getters.
|
||||
*/
|
||||
final class ModuleRegistry
|
||||
{
|
||||
/** @var array<string, ModuleManifest> keyed by module id */
|
||||
private array $modules = [];
|
||||
|
||||
/** @var array<int, array{path: string, target: string, module_id: string, public?: bool}> merged routes */
|
||||
private array $mergedRoutes = [];
|
||||
|
||||
/** @var list<string> merged public paths */
|
||||
private array $mergedPublicPaths = [];
|
||||
|
||||
/** @var list<class-string> merged container registrar FQCNs */
|
||||
private array $mergedContainerRegistrars = [];
|
||||
|
||||
/** @var array<string, list<mixed>> merged UI slots keyed by slot name */
|
||||
private array $mergedUiSlots = [];
|
||||
|
||||
/** @var list<class-string> merged search resource provider FQCNs */
|
||||
private array $mergedSearchResources = [];
|
||||
|
||||
/** @var array<string, list<string>> merged asset groups */
|
||||
private array $mergedAssetGroups = [];
|
||||
|
||||
/** @var list<class-string> merged layout context provider FQCNs */
|
||||
private array $mergedLayoutContextProviders = [];
|
||||
|
||||
/** @var list<class-string> merged session provider FQCNs */
|
||||
private array $mergedSessionProviders = [];
|
||||
|
||||
/**
|
||||
* @var list<array{
|
||||
* key: string,
|
||||
* description: string,
|
||||
* active: int,
|
||||
* is_system: int
|
||||
* }> merged permissions
|
||||
*/
|
||||
private array $mergedPermissions = [];
|
||||
|
||||
/** @var list<class-string> merged authorization policy FQCNs */
|
||||
private array $mergedAuthorizationPolicies = [];
|
||||
|
||||
/**
|
||||
* @var list<array{
|
||||
* job_key: string,
|
||||
* handler: string,
|
||||
* label: string,
|
||||
* description: string,
|
||||
* default_enabled: int,
|
||||
* default_timezone: string,
|
||||
* default_schedule_type: string,
|
||||
* default_schedule_interval: int,
|
||||
* default_schedule_time: ?string,
|
||||
* default_schedule_weekdays_csv: ?string,
|
||||
* default_catchup_once: int,
|
||||
* allowed_schedule_types: list<string>,
|
||||
* module_id: string
|
||||
* }>
|
||||
*/
|
||||
private array $mergedSchedulerJobs = [];
|
||||
|
||||
/** @var array<string, string> route target => owning module id */
|
||||
private array $routeTargetOwners = [];
|
||||
|
||||
/** @var array<string, string> route path => owning module id */
|
||||
private array $routePathOwners = [];
|
||||
|
||||
/** @var array<string, string> permission key => owning module id */
|
||||
private array $permissionOwners = [];
|
||||
|
||||
/** @var array<string, string> scheduler job key => owning module id */
|
||||
private array $schedulerJobOwners = [];
|
||||
|
||||
/** @var array<string, list<array{class: class-string, method: string}>> merged event listeners keyed by event name */
|
||||
private array $mergedEventListeners = [];
|
||||
|
||||
/**
|
||||
* Boot the registry from a modules directory and activation config.
|
||||
*
|
||||
* @param string $modulesDir Absolute path to the modules/ directory
|
||||
* @param list<string> $enabledModuleIds Module IDs to activate (from config + env)
|
||||
*/
|
||||
public static function boot(string $modulesDir, array $enabledModuleIds): self
|
||||
{
|
||||
$registry = new self();
|
||||
|
||||
if (!is_dir($modulesDir)) {
|
||||
return $registry;
|
||||
}
|
||||
|
||||
// Discover and load manifests for enabled modules
|
||||
$manifests = [];
|
||||
foreach ($enabledModuleIds as $moduleId) {
|
||||
$moduleId = trim($moduleId);
|
||||
if ($moduleId === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$modulePath = rtrim($modulesDir, '/') . '/' . $moduleId;
|
||||
$manifestFile = $modulePath . '/module.php';
|
||||
|
||||
if (!is_file($manifestFile)) {
|
||||
throw new RuntimeException(
|
||||
"Module '{$moduleId}' is enabled but manifest not found at: {$manifestFile}"
|
||||
);
|
||||
}
|
||||
|
||||
$raw = require $manifestFile;
|
||||
if (!is_array($raw)) {
|
||||
throw new RuntimeException(
|
||||
"Module manifest at '{$manifestFile}' must return an array."
|
||||
);
|
||||
}
|
||||
|
||||
ModuleManifestSchemaValidator::assertValid($raw, $moduleId, $manifestFile);
|
||||
|
||||
$manifest = ModuleManifest::fromArray($raw, $modulePath);
|
||||
|
||||
if ($manifest->id !== $moduleId) {
|
||||
throw new RuntimeException(
|
||||
"Module directory '{$moduleId}' does not match manifest id '{$manifest->id}'."
|
||||
);
|
||||
}
|
||||
|
||||
$manifests[] = $manifest;
|
||||
}
|
||||
|
||||
// Sort deterministically: load_order ASC, then id ASC
|
||||
usort($manifests, static function (ModuleManifest $a, ModuleManifest $b): int {
|
||||
return $a->loadOrder <=> $b->loadOrder ?: strcmp($a->id, $b->id);
|
||||
});
|
||||
|
||||
// Register module lib dirs with Composer's PSR-4 autoloader so that
|
||||
// module classes (MintyPHP\Module\<Name>\*) resolve without a custom loader.
|
||||
self::registerModuleAutoloading($manifests);
|
||||
|
||||
// Preload core + module translations into I18n before any t() call.
|
||||
$coreI18nDir = rtrim($modulesDir, '/') . '/../i18n';
|
||||
if (is_dir($coreI18nDir)) {
|
||||
ModuleI18nLoader::preload($manifests, $coreI18nDir);
|
||||
}
|
||||
|
||||
// Register and merge
|
||||
foreach ($manifests as $manifest) {
|
||||
$registry->registerModule($manifest);
|
||||
}
|
||||
|
||||
$registry->validateDependencies();
|
||||
$registry->finalizeMergedContributions();
|
||||
|
||||
return $registry;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve enabled module IDs from config array + ENV override.
|
||||
*
|
||||
* @param array{enabled_modules?: list<string>} $config From config/modules.php
|
||||
* @return list<string>
|
||||
*/
|
||||
public static function resolveEnabledModules(array $config): array
|
||||
{
|
||||
$fromConfig = $config['enabled_modules'] ?? [];
|
||||
$fromEnvRaw = getenv('APP_ENABLED_MODULES');
|
||||
|
||||
// Semantics:
|
||||
// - not set => use config/modules.php
|
||||
// - set '' => explicit "no modules"
|
||||
// - set list => exact list from env
|
||||
if ($fromEnvRaw !== false) {
|
||||
$fromEnv = trim((string) $fromEnvRaw);
|
||||
$fromConfig = $fromEnv === ''
|
||||
? []
|
||||
: array_map('trim', explode(',', $fromEnv));
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($fromConfig, static fn (string $id): bool => trim($id) !== '')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Register module lib/ directories with Composer's ClassLoader.
|
||||
*
|
||||
* @param list<ModuleManifest> $manifests
|
||||
*/
|
||||
private static function registerModuleAutoloading(array $manifests): void
|
||||
{
|
||||
$composerLoader = null;
|
||||
foreach (spl_autoload_functions() as $autoloader) {
|
||||
if (is_array($autoloader) && $autoloader[0] instanceof ClassLoader) {
|
||||
$composerLoader = $autoloader[0];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($composerLoader === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($manifests as $manifest) {
|
||||
$libDir = rtrim($manifest->basePath, '/') . '/lib';
|
||||
if (is_dir($libDir)) {
|
||||
$composerLoader->addPsr4('MintyPHP\\', [$libDir]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function registerModule(ModuleManifest $manifest): void
|
||||
{
|
||||
if (isset($this->modules[$manifest->id])) {
|
||||
throw new RuntimeException(
|
||||
"Duplicate module id: '{$manifest->id}' is already registered."
|
||||
);
|
||||
}
|
||||
|
||||
$this->modules[$manifest->id] = $manifest;
|
||||
|
||||
// — Merge routes (fail-fast on collision) ——————————————
|
||||
foreach ($manifest->routes as $rawRoute) {
|
||||
$route = $this->normalizeRoute($rawRoute, $manifest->id);
|
||||
$path = $route['path'];
|
||||
$target = $route['target'];
|
||||
if (isset($this->routeTargetOwners[$target]) && $this->routeTargetOwners[$target] !== $manifest->id) {
|
||||
$owner = $this->routeTargetOwners[$target];
|
||||
throw new RuntimeException(
|
||||
"Route target conflict: '{$target}' from module '{$manifest->id}' collides with module '{$owner}'."
|
||||
);
|
||||
}
|
||||
if (isset($this->routePathOwners[$path])) {
|
||||
$owner = $this->routePathOwners[$path];
|
||||
throw new RuntimeException(
|
||||
"Route path conflict: '{$path}' from module '{$manifest->id}' collides with module '{$owner}'."
|
||||
);
|
||||
}
|
||||
$this->routeTargetOwners[$target] = $manifest->id;
|
||||
$this->routePathOwners[$path] = $manifest->id;
|
||||
$this->mergedRoutes[] = $route;
|
||||
if (!empty($route['public'])) {
|
||||
$this->mergedPublicPaths[] = $path;
|
||||
}
|
||||
}
|
||||
|
||||
// — Merge public paths ———————————————————————————
|
||||
array_push($this->mergedPublicPaths, ...$manifest->publicPaths);
|
||||
|
||||
// — Merge container registrars ————————————————————
|
||||
array_push($this->mergedContainerRegistrars, ...$manifest->containerRegistrars);
|
||||
|
||||
// — Merge UI slots (fail-fast on duplicate keys within same slot) ——
|
||||
foreach ($manifest->uiSlots as $slotName => $contributions) {
|
||||
if (!isset($this->mergedUiSlots[$slotName])) {
|
||||
$this->mergedUiSlots[$slotName] = [];
|
||||
}
|
||||
$contributionList = is_array($contributions) ? $contributions : [$contributions];
|
||||
foreach ($contributionList as $contribution) {
|
||||
$normalizedContribution = $this->normalizeUiSlotContribution($slotName, $contribution, $manifest->id, $manifest->basePath);
|
||||
$key = (string) $normalizedContribution['key'];
|
||||
foreach ($this->mergedUiSlots[$slotName] as $existing) {
|
||||
$existingKey = is_array($existing) ? ($existing['key'] ?? null) : null;
|
||||
if ($existingKey === $key) {
|
||||
throw new RuntimeException(
|
||||
"UI slot conflict: slot '{$slotName}' key '{$key}' from module '{$manifest->id}' already exists."
|
||||
);
|
||||
}
|
||||
}
|
||||
$this->mergedUiSlots[$slotName][] = $normalizedContribution;
|
||||
}
|
||||
}
|
||||
|
||||
// — Merge search resources (fail-fast on duplicate provider) ——
|
||||
foreach ($manifest->searchResources as $provider) {
|
||||
if (!is_string($provider) || trim($provider) === '') {
|
||||
throw new RuntimeException(
|
||||
"Search resource provider in module '{$manifest->id}' must be a non-empty class-string."
|
||||
);
|
||||
}
|
||||
if (in_array($provider, $this->mergedSearchResources, true)) {
|
||||
throw new RuntimeException(
|
||||
"Search resource conflict: provider '{$provider}' from module '{$manifest->id}' is already registered."
|
||||
);
|
||||
}
|
||||
$this->mergedSearchResources[] = $provider;
|
||||
}
|
||||
|
||||
// — Merge asset groups ————————————————————————————
|
||||
foreach ($manifest->assetGroups as $groupName => $files) {
|
||||
if (isset($this->mergedAssetGroups[$groupName])) {
|
||||
throw new RuntimeException(
|
||||
"Asset group conflict: group '{$groupName}' from module '{$manifest->id}' already exists."
|
||||
);
|
||||
}
|
||||
$this->mergedAssetGroups[$groupName] = $files;
|
||||
}
|
||||
|
||||
// — Merge permissions (fail-fast on duplicate) ———————
|
||||
foreach ($manifest->permissions as $permission) {
|
||||
$permissionKey = trim((string) $permission['key']);
|
||||
if ($permissionKey === '') {
|
||||
throw new RuntimeException(
|
||||
"Permission in module '{$manifest->id}' must define non-empty 'key'."
|
||||
);
|
||||
}
|
||||
|
||||
if (isset($this->permissionOwners[$permissionKey])) {
|
||||
$owner = $this->permissionOwners[$permissionKey];
|
||||
throw new RuntimeException(
|
||||
"Permission conflict: '{$permissionKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
|
||||
);
|
||||
}
|
||||
|
||||
$this->permissionOwners[$permissionKey] = $manifest->id;
|
||||
$this->mergedPermissions[] = [
|
||||
'key' => $permissionKey,
|
||||
'description' => trim((string) $permission['description']),
|
||||
'active' => (int) $permission['active'],
|
||||
'is_system' => (int) $permission['is_system'],
|
||||
];
|
||||
}
|
||||
|
||||
// — Merge layout context providers ————————————————
|
||||
foreach ($manifest->layoutContextProviders as $providerClass) {
|
||||
if (!is_string($providerClass) || trim($providerClass) === '') {
|
||||
throw new RuntimeException(
|
||||
"Layout context provider in module '{$manifest->id}' must be a non-empty class-string."
|
||||
);
|
||||
}
|
||||
$this->mergedLayoutContextProviders[] = $providerClass;
|
||||
}
|
||||
|
||||
// — Merge session providers ——————————————————————
|
||||
foreach ($manifest->sessionProviders as $providerClass) {
|
||||
if (!is_string($providerClass) || trim($providerClass) === '') {
|
||||
throw new RuntimeException(
|
||||
"Session provider in module '{$manifest->id}' must be a non-empty class-string."
|
||||
);
|
||||
}
|
||||
$this->mergedSessionProviders[] = $providerClass;
|
||||
}
|
||||
|
||||
// — Merge authorization policies (fail-fast on duplicate) ———
|
||||
foreach ($manifest->authorizationPolicies as $policyClass) {
|
||||
if (!is_string($policyClass) || trim($policyClass) === '') {
|
||||
throw new RuntimeException(
|
||||
"Authorization policy in module '{$manifest->id}' must be a non-empty class-string."
|
||||
);
|
||||
}
|
||||
if (in_array($policyClass, $this->mergedAuthorizationPolicies, true)) {
|
||||
throw new RuntimeException(
|
||||
"Authorization policy conflict: '{$policyClass}' from module '{$manifest->id}' is already registered."
|
||||
);
|
||||
}
|
||||
$this->mergedAuthorizationPolicies[] = $policyClass;
|
||||
}
|
||||
|
||||
// — Merge event listeners ——————————————————————————
|
||||
foreach ($manifest->eventListeners as $eventName => $handlers) {
|
||||
if (!isset($this->mergedEventListeners[$eventName])) {
|
||||
$this->mergedEventListeners[$eventName] = [];
|
||||
}
|
||||
array_push($this->mergedEventListeners[$eventName], ...$handlers);
|
||||
}
|
||||
|
||||
// — Merge scheduler jobs —————————————————————————
|
||||
foreach ($manifest->schedulerJobs as $schedulerJob) {
|
||||
$jobKey = trim((string) $schedulerJob['job_key']);
|
||||
if ($jobKey === '') {
|
||||
throw new RuntimeException(
|
||||
"Scheduler job in module '{$manifest->id}' must define non-empty 'job_key'."
|
||||
);
|
||||
}
|
||||
if (isset($this->schedulerJobOwners[$jobKey])) {
|
||||
$owner = $this->schedulerJobOwners[$jobKey];
|
||||
throw new RuntimeException(
|
||||
"Scheduler job conflict: '{$jobKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
|
||||
);
|
||||
}
|
||||
|
||||
$this->schedulerJobOwners[$jobKey] = $manifest->id;
|
||||
$this->mergedSchedulerJobs[] = [
|
||||
'job_key' => $jobKey,
|
||||
'handler' => trim((string) $schedulerJob['handler']),
|
||||
'label' => trim((string) $schedulerJob['label']),
|
||||
'description' => trim((string) $schedulerJob['description']),
|
||||
'default_enabled' => (int) $schedulerJob['default_enabled'],
|
||||
'default_timezone' => trim((string) $schedulerJob['default_timezone']),
|
||||
'default_schedule_type' => trim((string) $schedulerJob['default_schedule_type']),
|
||||
'default_schedule_interval' => (int) $schedulerJob['default_schedule_interval'],
|
||||
'default_schedule_time' => $schedulerJob['default_schedule_time'],
|
||||
'default_schedule_weekdays_csv' => $schedulerJob['default_schedule_weekdays_csv'],
|
||||
'default_catchup_once' => (int) $schedulerJob['default_catchup_once'],
|
||||
'allowed_schedule_types' => array_values($schedulerJob['allowed_schedule_types']),
|
||||
'module_id' => $manifest->id,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
private function validateDependencies(): void
|
||||
{
|
||||
$enabledIds = array_keys($this->modules);
|
||||
|
||||
// Check all required modules are enabled
|
||||
foreach ($this->modules as $manifest) {
|
||||
foreach ($manifest->requires as $requiredId) {
|
||||
if (!isset($this->modules[$requiredId])) {
|
||||
throw new RuntimeException(
|
||||
"Module '{$manifest->id}' requires module '{$requiredId}' which is not enabled."
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for circular dependencies (DFS cycle detection)
|
||||
$visited = [];
|
||||
$inStack = [];
|
||||
|
||||
$visit = function (string $moduleId) use (&$visit, &$visited, &$inStack): void {
|
||||
if (isset($inStack[$moduleId])) {
|
||||
throw new RuntimeException(
|
||||
"Circular module dependency detected involving module '{$moduleId}'."
|
||||
);
|
||||
}
|
||||
if (isset($visited[$moduleId])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$inStack[$moduleId] = true;
|
||||
|
||||
if (isset($this->modules[$moduleId])) {
|
||||
foreach ($this->modules[$moduleId]->requires as $requiredId) {
|
||||
$visit($requiredId);
|
||||
}
|
||||
}
|
||||
|
||||
unset($inStack[$moduleId]);
|
||||
$visited[$moduleId] = true;
|
||||
};
|
||||
|
||||
foreach ($enabledIds as $moduleId) {
|
||||
$visit($moduleId);
|
||||
}
|
||||
}
|
||||
|
||||
private function finalizeMergedContributions(): void
|
||||
{
|
||||
$this->mergedPublicPaths = array_values(array_unique(array_filter(
|
||||
$this->mergedPublicPaths,
|
||||
static fn (mixed $path): bool => trim((string) $path) !== ''
|
||||
)));
|
||||
|
||||
foreach ($this->mergedUiSlots as $slotName => $items) {
|
||||
usort($items, static function (array $a, array $b): int {
|
||||
$orderCmp = ((int) ($a['order'] ?? 100)) <=> ((int) ($b['order'] ?? 100));
|
||||
if ($orderCmp !== 0) {
|
||||
return $orderCmp;
|
||||
}
|
||||
$moduleCmp = strcmp((string) ($a['__module_id'] ?? ''), (string) ($b['__module_id'] ?? ''));
|
||||
if ($moduleCmp !== 0) {
|
||||
return $moduleCmp;
|
||||
}
|
||||
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
|
||||
});
|
||||
|
||||
foreach ($items as &$item) {
|
||||
unset($item['__module_id']);
|
||||
}
|
||||
unset($item);
|
||||
|
||||
$this->mergedUiSlots[$slotName] = $items;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{path: string, target: string, module_id: string, public?: bool}
|
||||
*/
|
||||
private function normalizeRoute(mixed $rawRoute, string $moduleId): array
|
||||
{
|
||||
if (!is_array($rawRoute)) {
|
||||
throw new RuntimeException("Route entries in module '{$moduleId}' must be arrays.");
|
||||
}
|
||||
|
||||
$path = trim((string) ($rawRoute['path'] ?? ''));
|
||||
$target = trim((string) ($rawRoute['target'] ?? ''));
|
||||
if ($path === '' || $target === '') {
|
||||
throw new RuntimeException("Routes in module '{$moduleId}' must define non-empty 'path' and 'target'.");
|
||||
}
|
||||
|
||||
$normalized = [
|
||||
'path' => $path,
|
||||
'target' => $target,
|
||||
'module_id' => $moduleId,
|
||||
];
|
||||
|
||||
if (array_key_exists('public', $rawRoute)) {
|
||||
$normalized['public'] = (bool) $rawRoute['public'];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function normalizeUiSlotContribution(string $slotName, mixed $rawContribution, string $moduleId, string $basePath): array
|
||||
{
|
||||
if (!is_array($rawContribution)) {
|
||||
throw new RuntimeException(
|
||||
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must be an array."
|
||||
);
|
||||
}
|
||||
|
||||
$key = trim((string) ($rawContribution['key'] ?? ''));
|
||||
if ($key === '') {
|
||||
throw new RuntimeException(
|
||||
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must define a non-empty 'key'."
|
||||
);
|
||||
}
|
||||
|
||||
// Resolve relative template paths to absolute using the module's basePath.
|
||||
foreach (['panel_template', 'template'] as $templateKey) {
|
||||
if (!isset($rawContribution[$templateKey])) {
|
||||
continue;
|
||||
}
|
||||
$templatePath = trim((string) $rawContribution[$templateKey]);
|
||||
if ($templatePath !== '' && !str_starts_with($templatePath, '/')) {
|
||||
$rawContribution[$templateKey] = rtrim($basePath, '/') . '/' . $templatePath;
|
||||
}
|
||||
}
|
||||
|
||||
$normalized = $rawContribution;
|
||||
$normalized['key'] = $key;
|
||||
$normalized['order'] = (int) ($rawContribution['order'] ?? 100);
|
||||
$normalized['__module_id'] = $moduleId;
|
||||
$normalized['module_id'] = $moduleId;
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
// ─── Getters ────────────────────────────────────────────────────────
|
||||
|
||||
/** @return array<string, ModuleManifest> */
|
||||
public function getModules(): array
|
||||
{
|
||||
return $this->modules;
|
||||
}
|
||||
|
||||
public function hasModule(string $id): bool
|
||||
{
|
||||
return isset($this->modules[$id]);
|
||||
}
|
||||
|
||||
public function getModule(string $id): ModuleManifest
|
||||
{
|
||||
if (!isset($this->modules[$id])) {
|
||||
throw new RuntimeException("Module '{$id}' is not registered.");
|
||||
}
|
||||
return $this->modules[$id];
|
||||
}
|
||||
|
||||
/** @return array<int, array{path: string, target: string, module_id: string, public?: bool}> */
|
||||
public function getRoutes(): array
|
||||
{
|
||||
return $this->mergedRoutes;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function getPublicPaths(): array
|
||||
{
|
||||
return $this->mergedPublicPaths;
|
||||
}
|
||||
|
||||
/** @return list<class-string> */
|
||||
public function getContainerRegistrars(): array
|
||||
{
|
||||
return $this->mergedContainerRegistrars;
|
||||
}
|
||||
|
||||
/** @return array<string, list<mixed>> */
|
||||
public function getUiSlots(): array
|
||||
{
|
||||
return $this->mergedUiSlots;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<mixed> Contributions for a specific slot
|
||||
*/
|
||||
public function getSlotContributions(string $slotName): array
|
||||
{
|
||||
return $this->mergedUiSlots[$slotName] ?? [];
|
||||
}
|
||||
|
||||
/** @return list<class-string> */
|
||||
public function getSearchResources(): array
|
||||
{
|
||||
return $this->mergedSearchResources;
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public function getAssetGroups(): array
|
||||
{
|
||||
return $this->mergedAssetGroups;
|
||||
}
|
||||
|
||||
/** @return list<class-string> */
|
||||
public function getLayoutContextProviders(): array
|
||||
{
|
||||
return $this->mergedLayoutContextProviders;
|
||||
}
|
||||
|
||||
/** @return list<class-string> */
|
||||
public function getSessionProviders(): array
|
||||
{
|
||||
return $this->mergedSessionProviders;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{key: string, description: string, active: int, is_system: int}>
|
||||
*/
|
||||
public function getPermissions(): array
|
||||
{
|
||||
return $this->mergedPermissions;
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
public function getPermissionKeys(): array
|
||||
{
|
||||
return array_values(array_map(
|
||||
static fn (array $permission): string => (string) $permission['key'],
|
||||
$this->mergedPermissions
|
||||
));
|
||||
}
|
||||
|
||||
/** @return list<class-string> */
|
||||
public function getAuthorizationPolicies(): array
|
||||
{
|
||||
return $this->mergedAuthorizationPolicies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect unique ability strings from all module UI slot contributions.
|
||||
*
|
||||
* Used by UiAccessService to resolve module abilities into the layout auth map.
|
||||
* Each ability maps to itself (key = ability, value = ability).
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getModuleUiAbilities(): array
|
||||
{
|
||||
$abilities = [];
|
||||
foreach ($this->mergedUiSlots as $items) {
|
||||
foreach ($items as $item) {
|
||||
$ability = trim((string) ($item['permission'] ?? ''));
|
||||
if ($ability !== '') {
|
||||
$abilities[$ability] = $ability;
|
||||
}
|
||||
}
|
||||
}
|
||||
return $abilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<array{
|
||||
* job_key: string,
|
||||
* handler: string,
|
||||
* label: string,
|
||||
* description: string,
|
||||
* default_enabled: int,
|
||||
* default_timezone: string,
|
||||
* default_schedule_type: string,
|
||||
* default_schedule_interval: int,
|
||||
* default_schedule_time: ?string,
|
||||
* default_schedule_weekdays_csv: ?string,
|
||||
* default_catchup_once: int,
|
||||
* allowed_schedule_types: list<string>,
|
||||
* module_id: string
|
||||
* }>
|
||||
*/
|
||||
public function getSchedulerJobs(): array
|
||||
{
|
||||
return $this->mergedSchedulerJobs;
|
||||
}
|
||||
|
||||
/** @return array<string, list<array{class: class-string, method: string}>> */
|
||||
public function getEventListeners(): array
|
||||
{
|
||||
return $this->mergedEventListeners;
|
||||
}
|
||||
}
|
||||
178
core/App/Module/ModuleRuntimeAssetPublisher.php
Normal file
178
core/App/Module/ModuleRuntimeAssetPublisher.php
Normal file
@@ -0,0 +1,178 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Publishes module web assets to a runtime web/modules directory.
|
||||
*/
|
||||
final class ModuleRuntimeAssetPublisher
|
||||
{
|
||||
/**
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
* @return array{created: int, updated: int, removed: int, unchanged: int}
|
||||
*/
|
||||
public function publish(string $runtimeModulesDir, array $modules, string $mode = 'symlink'): array
|
||||
{
|
||||
$normalizedMode = strtolower(trim($mode));
|
||||
if ($normalizedMode === '') {
|
||||
$normalizedMode = 'symlink';
|
||||
}
|
||||
if (!in_array($normalizedMode, ['symlink', 'copy'], true)) {
|
||||
throw new RuntimeException("Unsupported module asset publish mode '{$mode}'. Use 'symlink' or 'copy'.");
|
||||
}
|
||||
|
||||
if (is_link($runtimeModulesDir) && !unlink($runtimeModulesDir)) {
|
||||
throw new RuntimeException("Cannot remove existing runtime modules symlink: {$runtimeModulesDir}");
|
||||
}
|
||||
if (!is_dir($runtimeModulesDir) && !mkdir($runtimeModulesDir, 0775, true) && !is_dir($runtimeModulesDir)) {
|
||||
throw new RuntimeException("Cannot create runtime modules directory: {$runtimeModulesDir}");
|
||||
}
|
||||
|
||||
$result = ['created' => 0, 'updated' => 0, 'removed' => 0, 'unchanged' => 0];
|
||||
|
||||
$desired = [];
|
||||
foreach ($modules as $manifest) {
|
||||
if (!$manifest instanceof ModuleManifest) {
|
||||
continue;
|
||||
}
|
||||
$moduleWebDir = rtrim($manifest->basePath, '/') . '/web';
|
||||
if (!is_dir($moduleWebDir)) {
|
||||
continue;
|
||||
}
|
||||
$desired[$manifest->id] = $moduleWebDir;
|
||||
}
|
||||
|
||||
$entries = scandir($runtimeModulesDir);
|
||||
if ($entries === false) {
|
||||
throw new RuntimeException("Cannot scan runtime modules directory: {$runtimeModulesDir}");
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
if (isset($desired[$entry])) {
|
||||
continue;
|
||||
}
|
||||
$path = $runtimeModulesDir . '/' . $entry;
|
||||
$this->removePath($path);
|
||||
$result['removed']++;
|
||||
}
|
||||
|
||||
foreach ($desired as $moduleId => $moduleWebDir) {
|
||||
$targetPath = $runtimeModulesDir . '/' . $moduleId;
|
||||
$targetExists = file_exists($targetPath) || is_link($targetPath);
|
||||
|
||||
if ($targetExists && $normalizedMode === 'symlink' && is_link($targetPath)) {
|
||||
$targetReal = realpath($targetPath);
|
||||
$sourceReal = realpath($moduleWebDir);
|
||||
if ($targetReal !== false && $sourceReal !== false && $targetReal === $sourceReal) {
|
||||
$result['unchanged']++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if ($targetExists) {
|
||||
$this->removePath($targetPath);
|
||||
}
|
||||
|
||||
if ($normalizedMode === 'symlink') {
|
||||
if (!@symlink($moduleWebDir, $targetPath)) {
|
||||
throw new RuntimeException(
|
||||
"Failed to symlink module assets for '{$moduleId}' ({$moduleWebDir} -> {$targetPath}). " .
|
||||
"Set APP_MODULE_ASSET_MODE=copy if symlinks are unavailable."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
$this->copyDirectory($moduleWebDir, $targetPath);
|
||||
}
|
||||
|
||||
if ($targetExists) {
|
||||
$result['updated']++;
|
||||
} else {
|
||||
$result['created']++;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function removePath(string $path): void
|
||||
{
|
||||
if (is_link($path)) {
|
||||
if (!unlink($path)) {
|
||||
throw new RuntimeException("Cannot remove symlink: {$path}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (is_file($path)) {
|
||||
if (!unlink($path)) {
|
||||
throw new RuntimeException("Cannot remove file: {$path}");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_dir($path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entries = scandir($path);
|
||||
if ($entries === false) {
|
||||
throw new RuntimeException("Cannot scan directory: {$path}");
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$this->removePath($path . '/' . $entry);
|
||||
}
|
||||
|
||||
if (!rmdir($path)) {
|
||||
throw new RuntimeException("Cannot remove directory: {$path}");
|
||||
}
|
||||
}
|
||||
|
||||
private function copyDirectory(string $sourceDir, string $targetDir): void
|
||||
{
|
||||
if (!is_dir($sourceDir)) {
|
||||
throw new RuntimeException("Source module asset directory not found: {$sourceDir}");
|
||||
}
|
||||
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
|
||||
throw new RuntimeException("Cannot create target module asset directory: {$targetDir}");
|
||||
}
|
||||
|
||||
$entries = scandir($sourceDir);
|
||||
if ($entries === false) {
|
||||
throw new RuntimeException("Cannot scan source module asset directory: {$sourceDir}");
|
||||
}
|
||||
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = $sourceDir . '/' . $entry;
|
||||
$target = $targetDir . '/' . $entry;
|
||||
|
||||
if (is_link($source)) {
|
||||
$linkTarget = readlink($source);
|
||||
if ($linkTarget === false || !symlink($linkTarget, $target)) {
|
||||
throw new RuntimeException("Cannot copy symlink '{$source}' to '{$target}'");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (is_dir($source)) {
|
||||
$this->copyDirectory($source, $target);
|
||||
continue;
|
||||
}
|
||||
if (!is_file($source)) {
|
||||
continue;
|
||||
}
|
||||
if (!copy($source, $target)) {
|
||||
throw new RuntimeException("Cannot copy file '{$source}' to '{$target}'");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
302
core/App/Module/ModuleRuntimePageBuilder.php
Normal file
302
core/App/Module/ModuleRuntimePageBuilder.php
Normal file
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\App\Module;
|
||||
|
||||
use FilesystemIterator;
|
||||
use RecursiveDirectoryIterator;
|
||||
use RecursiveIteratorIterator;
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Builds a runtime PageRoot by symlinking core and enabled module pages.
|
||||
*
|
||||
* Build uses atomic staging+swap: output is constructed in a temporary staging
|
||||
* directory alongside the live target and only swapped into place after
|
||||
* successful validation. Partial failures cannot leave inconsistent live state.
|
||||
*/
|
||||
final class ModuleRuntimePageBuilder
|
||||
{
|
||||
/**
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
* @return array{core_entries: int, module_entries: int}
|
||||
*/
|
||||
public function build(string $runtimeDir, string $corePagesDir, array $modules): array
|
||||
{
|
||||
$corePages = realpath($corePagesDir);
|
||||
if ($corePages === false) {
|
||||
throw new RuntimeException('Core pages/ directory not found.');
|
||||
}
|
||||
|
||||
if (count($modules) === 0) {
|
||||
$this->pointRuntimeToCore($runtimeDir, $corePages);
|
||||
return ['core_entries' => 0, 'module_entries' => 0];
|
||||
}
|
||||
|
||||
// ── Staging: build in a sibling directory, swap on success ────
|
||||
$stagingDir = dirname($runtimeDir) . '/.pages-staging-' . getmypid();
|
||||
$this->ensureCleanDirectory($stagingDir);
|
||||
|
||||
try {
|
||||
$result = $this->populateDirectory($stagingDir, $corePages, $modules);
|
||||
} catch (\Throwable $e) {
|
||||
$this->removeDirectory($stagingDir);
|
||||
throw $e;
|
||||
}
|
||||
|
||||
// ── Atomic swap ──────────────────────────────────────────────
|
||||
$this->swapIntoLive($stagingDir, $runtimeDir);
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function pointRuntimeToCore(string $runtimeDir, string $corePages): void
|
||||
{
|
||||
if (is_dir($runtimeDir) && !is_link($runtimeDir)) {
|
||||
$this->removeDirectoryContents($runtimeDir);
|
||||
rmdir($runtimeDir);
|
||||
}
|
||||
if (is_link($runtimeDir)) {
|
||||
unlink($runtimeDir);
|
||||
}
|
||||
if (!@symlink($corePages, $runtimeDir)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$runtimeDir} → {$corePages}");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Fingerprint: detect stale runtime state ─────────────────────
|
||||
|
||||
/**
|
||||
* Compute the expected fingerprint for a set of modules.
|
||||
*
|
||||
* The digest includes module IDs, each manifest file content hash,
|
||||
* and top-level page-entry signatures so that manifest-only or
|
||||
* page-structure changes are detected even when the module ID list
|
||||
* is unchanged.
|
||||
*
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
*/
|
||||
public static function expectedFingerprint(array $modules): string
|
||||
{
|
||||
$parts = [];
|
||||
|
||||
$manifests = $modules;
|
||||
uasort($manifests, static fn (ModuleManifest $a, ModuleManifest $b): int => $a->id <=> $b->id);
|
||||
|
||||
foreach ($manifests as $manifest) {
|
||||
$manifestFile = $manifest->basePath . '/module.php';
|
||||
$manifestHash = is_file($manifestFile)
|
||||
? md5_file($manifestFile)
|
||||
: 'missing';
|
||||
|
||||
$pageEntries = self::topLevelPageEntries($manifest->basePath . '/pages');
|
||||
|
||||
$parts[] = $manifest->id . ':' . $manifestHash . ':' . implode(',', $pageEntries);
|
||||
}
|
||||
|
||||
if ($parts === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return hash('sha256', implode('|', $parts));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted fingerprint from disk.
|
||||
*/
|
||||
public static function readFingerprint(string $runtimeDir): string
|
||||
{
|
||||
$file = dirname($runtimeDir) . '/.module-fingerprint';
|
||||
|
||||
return is_file($file) ? trim((string) file_get_contents($file)) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a fingerprint after a successful build so web/index.php can
|
||||
* detect stale runtime state without rebuilding on every request.
|
||||
*
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
*/
|
||||
public static function writeFingerprint(string $runtimeDir, array $modules): void
|
||||
{
|
||||
$file = dirname($runtimeDir) . '/.module-fingerprint';
|
||||
|
||||
file_put_contents($file, self::expectedFingerprint($modules));
|
||||
}
|
||||
|
||||
// ── Internal helpers ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Populate a directory with core + module page symlinks.
|
||||
*
|
||||
* @param array<string, ModuleManifest> $modules
|
||||
* @return array{core_entries: int, module_entries: int}
|
||||
*/
|
||||
private function populateDirectory(string $targetDir, string $corePages, array $modules): array
|
||||
{
|
||||
$coreEntries = scandir($corePages);
|
||||
if ($coreEntries === false) {
|
||||
throw new RuntimeException('Cannot scan core pages directory.');
|
||||
}
|
||||
|
||||
$coreCount = 0;
|
||||
foreach ($coreEntries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$source = $corePages . '/' . $entry;
|
||||
$target = $targetDir . '/' . $entry;
|
||||
if (!@symlink($source, $target)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
||||
}
|
||||
$coreCount++;
|
||||
}
|
||||
|
||||
$modulePageCount = 0;
|
||||
foreach ($modules as $manifest) {
|
||||
$modulePagesDir = $manifest->basePath . '/pages';
|
||||
if (!is_dir($modulePagesDir)) {
|
||||
continue;
|
||||
}
|
||||
$entries = scandir($modulePagesDir);
|
||||
if ($entries === false) {
|
||||
continue;
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$source = $modulePagesDir . '/' . $entry;
|
||||
$target = $targetDir . '/' . $entry;
|
||||
if (file_exists($target) || is_link($target)) {
|
||||
throw new RuntimeException(
|
||||
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
|
||||
);
|
||||
}
|
||||
if (!@symlink($source, $target)) {
|
||||
throw new RuntimeException("Failed to create symlink: {$target} → {$source}");
|
||||
}
|
||||
$modulePageCount++;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'core_entries' => $coreCount,
|
||||
'module_entries' => $modulePageCount,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap staging directory into the live runtime path.
|
||||
*
|
||||
* Uses rename() for an atomic filesystem operation (same filesystem).
|
||||
*/
|
||||
private function swapIntoLive(string $stagingDir, string $runtimeDir): void
|
||||
{
|
||||
// Remove existing live target
|
||||
$oldDir = null;
|
||||
if (is_link($runtimeDir)) {
|
||||
unlink($runtimeDir);
|
||||
} elseif (is_dir($runtimeDir)) {
|
||||
// Move old live dir aside, then remove after swap
|
||||
$oldDir = $runtimeDir . '.old-' . getmypid();
|
||||
if (!@rename($runtimeDir, $oldDir)) {
|
||||
$this->removeDirectoryContents($runtimeDir);
|
||||
rmdir($runtimeDir);
|
||||
$oldDir = null;
|
||||
}
|
||||
} else {
|
||||
$oldDir = null;
|
||||
}
|
||||
|
||||
// Atomic rename of staging → live
|
||||
if (!@rename($stagingDir, $runtimeDir)) {
|
||||
throw new RuntimeException(
|
||||
"Failed to swap staging directory into live runtime path: {$stagingDir} → {$runtimeDir}"
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up old directory if we moved it aside
|
||||
if ($oldDir !== null && (is_dir($oldDir) || is_link($oldDir))) {
|
||||
$this->removeDirectory($oldDir);
|
||||
}
|
||||
}
|
||||
|
||||
private function ensureCleanDirectory(string $dir): void
|
||||
{
|
||||
if (is_dir($dir)) {
|
||||
$this->removeDirectoryContents($dir);
|
||||
rmdir($dir);
|
||||
}
|
||||
if (is_link($dir)) {
|
||||
unlink($dir);
|
||||
}
|
||||
if (!mkdir($dir, 0775, true) && !is_dir($dir)) {
|
||||
throw new RuntimeException('Cannot create staging directory: ' . $dir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return sorted top-level entry names from a module pages directory.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function topLevelPageEntries(string $pagesDir): array
|
||||
{
|
||||
if (!is_dir($pagesDir)) {
|
||||
return [];
|
||||
}
|
||||
$entries = scandir($pagesDir);
|
||||
if ($entries === false) {
|
||||
return [];
|
||||
}
|
||||
$entries = array_values(array_filter(
|
||||
$entries,
|
||||
static fn (string $e): bool => $e !== '.' && $e !== '..',
|
||||
));
|
||||
sort($entries);
|
||||
return $entries;
|
||||
}
|
||||
|
||||
private function removeDirectory(string $dir): void
|
||||
{
|
||||
if (is_link($dir)) {
|
||||
unlink($dir);
|
||||
return;
|
||||
}
|
||||
if (!is_dir($dir)) {
|
||||
return;
|
||||
}
|
||||
$this->removeDirectoryContents($dir);
|
||||
rmdir($dir);
|
||||
}
|
||||
|
||||
private function removeDirectoryContents(string $dir): void
|
||||
{
|
||||
$entries = scandir($dir);
|
||||
if ($entries === false) {
|
||||
return;
|
||||
}
|
||||
foreach ($entries as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$path = $dir . '/' . $entry;
|
||||
if (is_link($path)) {
|
||||
unlink($path);
|
||||
continue;
|
||||
}
|
||||
if (is_dir($path)) {
|
||||
$items = new RecursiveIteratorIterator(
|
||||
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
|
||||
RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
foreach ($items as $item) {
|
||||
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
|
||||
}
|
||||
rmdir($path);
|
||||
continue;
|
||||
}
|
||||
unlink($path);
|
||||
}
|
||||
}
|
||||
}
|
||||
101
core/App/registerContainer.php
Normal file
101
core/App/registerContainer.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\AccessRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\AppServicesRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\AuthRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\DirectoryRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\RepositoryFactoryRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\ServiceFactoryRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\SettingsRegistrar;
|
||||
use MintyPHP\App\Container\Registrars\UserRegistrar;
|
||||
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||
use MintyPHP\App\Module\ModuleRegistry;
|
||||
use MintyPHP\Service\Audit\ApiAuditServiceInterface;
|
||||
use MintyPHP\Service\Audit\ApiSystemAuditReporterInterface;
|
||||
use MintyPHP\Service\Audit\AuditMetadataEnricherInterface;
|
||||
use MintyPHP\Service\Audit\AuditRecorderInterface;
|
||||
use MintyPHP\Service\Audit\ImportAuditInterface;
|
||||
use MintyPHP\Service\Audit\NullApiAuditService;
|
||||
use MintyPHP\Service\Audit\NullApiSystemAuditReporter;
|
||||
use MintyPHP\Service\Audit\NullAuditMetadataEnricher;
|
||||
use MintyPHP\Service\Audit\NullAuditRecorder;
|
||||
use MintyPHP\Service\Audit\NullImportAudit;
|
||||
use MintyPHP\Service\Audit\NullUserLifecycleAudit;
|
||||
use MintyPHP\Service\Audit\UserLifecycleAuditInterface;
|
||||
|
||||
$container = new AppContainer();
|
||||
|
||||
// Order matters: RepositoryFactoryRegistrar and ServiceFactoryRegistrar must run first
|
||||
// because later registrars pull concrete services from those factories.
|
||||
$registrars = [
|
||||
new RepositoryFactoryRegistrar(), // raw repository factories (no deps)
|
||||
new ServiceFactoryRegistrar(), // service factories wired with their repo/gateway deps
|
||||
new AccessRegistrar(), // RBAC: permissions, roles, authorization
|
||||
new AuthRegistrar(), // authentication, SSO, tokens
|
||||
new DirectoryRegistrar(), // tenants, departments, scope
|
||||
new UserRegistrar(), // user services and repositories
|
||||
new SettingsRegistrar(), // settings, branding
|
||||
new AppServicesRegistrar(), // leaf services (stats, search, mail, scheduler, ...)
|
||||
];
|
||||
|
||||
foreach ($registrars as $registrar) {
|
||||
$registrar->register($container);
|
||||
}
|
||||
|
||||
// ── Module registrars (run after all Core registrars) ────────────────
|
||||
// Module registrars MUST NOT overwrite existing container keys.
|
||||
// The ModuleRegistry is stored in the container for downstream access.
|
||||
|
||||
$modulesConfig = is_file(__DIR__ . '/../../config/modules.php')
|
||||
? (array) require __DIR__ . '/../../config/modules.php'
|
||||
: [];
|
||||
$enabledModules = ModuleRegistry::resolveEnabledModules($modulesConfig);
|
||||
$modulesDir = dirname(__DIR__, 2) . '/modules';
|
||||
$moduleRegistry = ModuleRegistry::boot($modulesDir, $enabledModules);
|
||||
|
||||
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry);
|
||||
$container->set(ModuleEventDispatcher::class, static fn () => new ModuleEventDispatcher(
|
||||
$moduleRegistry->getEventListeners(),
|
||||
$container,
|
||||
$container->has(AuditRecorderInterface::class) ? $container->get(AuditRecorderInterface::class) : null
|
||||
));
|
||||
|
||||
// Disallow overriding existing core bindings from module registrars.
|
||||
$container->protectExistingBindings();
|
||||
|
||||
foreach ($moduleRegistry->getContainerRegistrars() as $registrarClass) {
|
||||
if (!class_exists($registrarClass)) {
|
||||
throw new RuntimeException("Module container registrar class not found: {$registrarClass}");
|
||||
}
|
||||
$registrarInstance = new $registrarClass();
|
||||
if (!$registrarInstance instanceof ContainerRegistrar) {
|
||||
throw new RuntimeException(
|
||||
"Module container registrar '{$registrarClass}' must implement " . ContainerRegistrar::class
|
||||
);
|
||||
}
|
||||
$registrarInstance->register($container);
|
||||
}
|
||||
|
||||
// ── Audit interface fallbacks (null implementations when audit module is disabled) ──
|
||||
if (!$container->has(AuditRecorderInterface::class)) {
|
||||
$container->set(AuditRecorderInterface::class, static fn (): AuditRecorderInterface => new NullAuditRecorder());
|
||||
}
|
||||
if (!$container->has(UserLifecycleAuditInterface::class)) {
|
||||
$container->set(UserLifecycleAuditInterface::class, static fn (): UserLifecycleAuditInterface => new NullUserLifecycleAudit());
|
||||
}
|
||||
if (!$container->has(ImportAuditInterface::class)) {
|
||||
$container->set(ImportAuditInterface::class, static fn (): ImportAuditInterface => new NullImportAudit());
|
||||
}
|
||||
if (!$container->has(AuditMetadataEnricherInterface::class)) {
|
||||
$container->set(AuditMetadataEnricherInterface::class, static fn (): AuditMetadataEnricherInterface => new NullAuditMetadataEnricher());
|
||||
}
|
||||
if (!$container->has(ApiAuditServiceInterface::class)) {
|
||||
$container->set(ApiAuditServiceInterface::class, static fn (): ApiAuditServiceInterface => new NullApiAuditService());
|
||||
}
|
||||
if (!$container->has(ApiSystemAuditReporterInterface::class)) {
|
||||
$container->set(ApiSystemAuditReporterInterface::class, static fn (): ApiSystemAuditReporterInterface => new NullApiSystemAuditReporter());
|
||||
}
|
||||
|
||||
return $container;
|
||||
Reference in New Issue
Block a user