feat: extend module platform with UI slots, runtime components, CLI tooling and {{userId}} search support

Completes the generic module platform that enables modules to contribute
UI elements, runtime JS components, and search resources without any
core hardcoding.

New generic UI slot types:
- topbar.right_item: module-contributed topbar buttons
- layout.body_end_template: module-contributed dialog/overlay templates
- layout.head_style: module-contributed global CSS
- runtime.component: declarative JS component registration with phase ordering

New infrastructure:
- ModuleAutoloader: PSR-4 autoloading for module-local PHP classes
- ModuleRuntimePageBuilder: symlinks module pages into runtime directory
- ModuleRuntimeAssetPublisher: publishes module CSS/JS to web/modules/
- ModulePermissionSynchronizer: syncs module permissions to DB
- CLI scripts: module-runtime-sync, module-build, module-migrate,
  module-permissions-sync, module-assets-sync
- {{userId}} placeholder in SearchDataService for user-scoped search queries
- Component runtime with phased initialization (early/default/late)
- AppContainer.protectExistingBindings() to prevent module→core overwrites
- Architecture tests: ModuleStructureContractTest, CoreTemplateIsolationTest,
  FrontendComponentRuntimeContractTest, AppContainerIsolationContractTest

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-18 22:19:56 +01:00
parent c364e2b46d
commit c7b8fd516a
139 changed files with 7591 additions and 2535 deletions

View File

@@ -14,8 +14,13 @@ final class AppContainer
/** @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;
}
@@ -38,4 +43,12 @@ final class AppContainer
$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;
}
}

View File

@@ -18,8 +18,6 @@ use MintyPHP\Repository\Stats\AdminStatsRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\AddressBook\AddressBookService;
use MintyPHP\Service\AddressBook\AddressBookServicesFactory;
use MintyPHP\Service\Audit\ApiAuditService;
use MintyPHP\Service\Audit\AuditMetadataEnricher;
use MintyPHP\Service\Audit\AuditServicesFactory;
@@ -76,7 +74,6 @@ final class AppServicesRegistrar implements ContainerRegistrar
$container->set(ImportAuditService::class, static fn (AppContainer $c): ImportAuditService => $c->get(ImportServicesFactory::class)->createImportAuditService());
$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(AddressBookService::class, static fn (AppContainer $c): AddressBookService => $c->get(AddressBookServicesFactory::class)->createAddressBookService());
$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());
$container->set(AuditMetadataEnricher::class, static fn (AppContainer $c): AuditMetadataEnricher => new AuditMetadataEnricher(

View File

@@ -13,8 +13,8 @@ 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\AddressBook\AddressBookServicesFactory;
use MintyPHP\Service\Audit\AuditRepositoryFactory;
use MintyPHP\Service\Audit\AuditServicesFactory;
use MintyPHP\Service\Auth\AuthGatewayFactory;
@@ -38,8 +38,6 @@ use MintyPHP\Service\Settings\SettingServicesFactory;
use MintyPHP\Service\Tenant\TenantRepositoryFactory;
use MintyPHP\Service\Tenant\TenantScopeService;
use MintyPHP\Service\Tenant\TenantServicesFactory;
use MintyPHP\Service\Access\AssignableRoleService;
use MintyPHP\Service\Bookmark\BookmarkServicesFactory;
use MintyPHP\Service\User\UserGatewayFactory;
use MintyPHP\Service\User\UserRepositoryFactory;
use MintyPHP\Service\User\UserServicesFactory;
@@ -88,13 +86,8 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
$c->get(UserServicesFactory::class),
$c->get(AuditServicesFactory::class),
$c->get(DatabaseSessionRepository::class),
$c->get(SchedulerRepositoryFactory::class)
));
$container->set(AddressBookServicesFactory::class, static fn (AppContainer $c): AddressBookServicesFactory => new AddressBookServicesFactory(
$c->get(UserServicesFactory::class),
$c->get(DirectoryServicesFactory::class),
$c->get(CustomFieldServicesFactory::class),
$c->get(TenantScopeService::class)
$c->get(SchedulerRepositoryFactory::class),
$c
));
$container->set(DirectoryServicesFactory::class, static fn (AppContainer $c): DirectoryServicesFactory => new DirectoryServicesFactory(
$c->get(UserServicesFactory::class),
@@ -143,7 +136,6 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
$c->get(SettingServicesFactory::class),
$c->get(TenantScopeService::class)
));
$container->set(BookmarkServicesFactory::class, static fn (): BookmarkServicesFactory => new BookmarkServicesFactory());
$container->set(AuthServicesFactory::class, static fn (AppContainer $c): AuthServicesFactory => new AuthServicesFactory(
$c->get(UserServicesFactory::class),
$c->get(AuditServicesFactory::class),
@@ -154,7 +146,7 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
$c->get(SessionStoreInterface::class),
$c->get(CookieStoreInterface::class),
$c->get(RequestRuntimeInterface::class),
$c->get(BookmarkServicesFactory::class)
$c
));
}
}

View File

@@ -23,8 +23,6 @@ use MintyPHP\Service\User\UserLifecycleService;
use MintyPHP\Service\User\UserPasswordPolicyService;
use MintyPHP\Service\User\UserPasswordService;
use MintyPHP\Service\User\UserRepositoryFactory;
use MintyPHP\Service\Bookmark\BookmarkService;
use MintyPHP\Service\Bookmark\BookmarkServicesFactory;
use MintyPHP\Service\User\UserServicesFactory;
use MintyPHP\Service\User\UserTenantContextService;
@@ -46,7 +44,6 @@ final class UserRegistrar implements ContainerRegistrar
$c->get(UserAccessTemplateService::class),
$c->get(BrandingLogoService::class)
));
$container->set(BookmarkService::class, static fn (AppContainer $c): BookmarkService => $c->get(BookmarkServicesFactory::class)->createBookmarkService());
$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());

View File

@@ -9,6 +9,11 @@ use MintyPHP\App\AppContainer;
*
* 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
{

View File

@@ -13,6 +13,13 @@ 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,

View File

@@ -0,0 +1,56 @@
<?php
namespace MintyPHP\App\Module;
/**
* Runtime autoloader for module-local PHP classes.
*
* Modules can place PHP code under modules/<id>/lib and use the same
* MintyPHP namespace root as core classes.
*/
final class ModuleAutoloader
{
/** @var list<string> */
private static array $moduleLibDirs = [];
private static bool $registered = false;
/**
* @param list<ModuleManifest> $manifests
*/
public static function register(array $manifests): void
{
$dirs = self::$moduleLibDirs;
foreach ($manifests as $manifest) {
$libDir = rtrim($manifest->basePath, '/') . '/lib';
if (is_dir($libDir)) {
$dirs[] = $libDir;
}
}
self::$moduleLibDirs = array_values(array_unique($dirs));
if (self::$registered) {
return;
}
spl_autoload_register([self::class, 'autoload'], true, true);
self::$registered = true;
}
private static function autoload(string $class): void
{
$prefix = 'MintyPHP\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relativePath = str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
foreach (self::$moduleLibDirs as $libDir) {
$candidate = $libDir . '/' . $relativePath;
if (is_file($candidate)) {
require_once $candidate;
return;
}
}
}
}

View File

@@ -35,7 +35,22 @@ final class ModuleManifest
/** @var array<string, list<string>> Asset group name → file list */
public readonly array $assetGroups;
/** @var list<array{name: string, handler: string, schedule?: string}> */
/**
* @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 */
@@ -44,9 +59,22 @@ final class ModuleManifest
/** @var list<class-string> SessionProvider FQCNs */
public readonly array $sessionProviders;
/** @var list<string> Permission keys (e.g. 'address_book.view') */
/**
* @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 array<string, string> UI-capability-key → ability-string for layout authorization */
public readonly array $layoutCapabilities;
public readonly ?string $migrationsPath;
public readonly string $basePath;
@@ -76,10 +104,12 @@ final class ModuleManifest
$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::arrayOf($raw, 'scheduler_jobs');
$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::listOf($raw, 'permissions');
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
$this->authorizationPolicies = self::listOf($raw, 'authorization_policies');
$this->layoutCapabilities = is_array($raw['layout_capabilities'] ?? null) ? $raw['layout_capabilities'] : [];
$migrationsPath = $raw['migrations_path'] ?? null;
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
@@ -112,4 +142,224 @@ final class ModuleManifest
$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;
}
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));
}
}

View File

@@ -0,0 +1,91 @@
<?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
) {
}
/**
* @return array{created: int, updated: int, unchanged: 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++;
}
return [
'created' => $created,
'updated' => $updated,
'unchanged' => $unchanged,
'total' => $total,
];
}
/**
* @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);
}
}

View File

@@ -15,10 +15,21 @@ use RuntimeException;
*/
final class ModuleRegistry
{
/** @var array<string, list<string>> */
private const UI_SLOT_REQUIRED_KEYS = [
'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'],
'search.resource_item' => ['key', 'label', 'base_url', 'permission'],
'user.edit.aside_action' => ['key', 'type', 'label', 'permission'],
'topbar.right_item' => ['key', 'template'],
'layout.body_end_template' => ['key', 'template'],
'layout.head_style' => ['key', 'path'],
'runtime.component' => ['key', 'script'],
];
/** @var array<string, ModuleManifest> keyed by module id */
private array $modules = [];
/** @var array<int, array{path: string, target: string, public?: bool}> merged routes */
/** @var array<int, array{path: string, target: string, module_id: string, public?: bool}> merged routes */
private array $mergedRoutes = [];
/** @var list<string> merged public paths */
@@ -42,12 +53,53 @@ final class ModuleRegistry
/** @var list<class-string> merged session provider FQCNs */
private array $mergedSessionProviders = [];
/** @var list<string> merged permissions */
/**
* @var list<array{
* key: string,
* description: string,
* active: int,
* is_system: int
* }> merged permissions
*/
private array $mergedPermissions = [];
/** @var list<array{name: string, handler: string, schedule?: string}> */
/** @var list<class-string> merged authorization policy FQCNs */
private array $mergedAuthorizationPolicies = [];
/** @var array<string, string> merged layout capabilities: UI-key → ability */
private array $mergedLayoutCapabilities = [];
/**
* @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 = [];
/**
* Boot the registry from a modules directory and activation config.
*
@@ -102,11 +154,16 @@ final class ModuleRegistry
return $a->loadOrder <=> $b->loadOrder ?: strcmp($a->id, $b->id);
});
// Allow module-local classes (modules/<id>/lib/...) to autoload.
ModuleAutoloader::register($manifests);
// Register and merge
foreach ($manifests as $manifest) {
$registry->registerModule($manifest);
}
$registry->finalizeMergedContributions();
return $registry;
}
@@ -119,10 +176,17 @@ final class ModuleRegistry
public static function resolveEnabledModules(array $config): array
{
$fromConfig = $config['enabled_modules'] ?? [];
$fromEnv = trim((string) getenv('APP_ENABLED_MODULES'));
$fromEnvRaw = getenv('APP_ENABLED_MODULES');
if ($fromEnv !== '') {
$fromConfig = array_map('trim', explode(',', $fromEnv));
// 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) !== '')));
@@ -139,15 +203,28 @@ final class ModuleRegistry
$this->modules[$manifest->id] = $manifest;
// — Merge routes (fail-fast on collision) ——————————————
$existingTargets = array_column($this->mergedRoutes, 'target');
foreach ($manifest->routes as $route) {
foreach ($manifest->routes as $rawRoute) {
$route = $this->normalizeRoute($rawRoute, $manifest->id);
$path = $route['path'];
$target = $route['target'];
if (in_array($target, $existingTargets, true)) {
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 an existing route."
"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 ———————————————————————————
@@ -163,23 +240,27 @@ final class ModuleRegistry
}
$contributionList = is_array($contributions) ? $contributions : [$contributions];
foreach ($contributionList as $contribution) {
$key = is_array($contribution) ? ($contribution['key'] ?? null) : null;
if ($key !== null) {
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."
);
}
$normalizedContribution = $this->normalizeUiSlotContribution($slotName, $contribution, $manifest->id);
$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][] = $contribution;
$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."
@@ -200,22 +281,283 @@ final class ModuleRegistry
// — Merge permissions (fail-fast on duplicate) ———————
foreach ($manifest->permissions as $permission) {
if (in_array($permission, $this->mergedPermissions, true)) {
$permissionKey = trim((string) $permission['key']);
if ($permissionKey === '') {
throw new RuntimeException(
"Permission conflict: '{$permission}' from module '{$manifest->id}' is already registered."
"Permission in module '{$manifest->id}' must define non-empty 'key'."
);
}
$this->mergedPermissions[] = $permission;
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 ————————————————
array_push($this->mergedLayoutContextProviders, ...$manifest->layoutContextProviders);
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 ——————————————————————
array_push($this->mergedSessionProviders, ...$manifest->sessionProviders);
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 layout capabilities (fail-fast on duplicate key) ———
foreach ($manifest->layoutCapabilities as $uiKey => $ability) {
$uiKey = trim((string) $uiKey);
$ability = trim((string) $ability);
if ($uiKey === '' || $ability === '') {
throw new RuntimeException(
"Layout capability in module '{$manifest->id}' must have non-empty key and ability."
);
}
if (isset($this->mergedLayoutCapabilities[$uiKey])) {
throw new RuntimeException(
"Layout capability conflict: key '{$uiKey}' from module '{$manifest->id}' is already registered."
);
}
$this->mergedLayoutCapabilities[$uiKey] = $ability;
}
// — Merge scheduler jobs —————————————————————————
array_push($this->mergedSchedulerJobs, ...$manifest->schedulerJobs);
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 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): 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'."
);
}
foreach (self::UI_SLOT_REQUIRED_KEYS[$slotName] ?? [] as $requiredKey) {
$requiredValue = trim((string) ($rawContribution[$requiredKey] ?? ''));
if ($requiredValue === '') {
throw new RuntimeException(
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' is missing required key '{$requiredKey}'."
);
}
}
$this->validateUiSlotContributionByType($slotName, $rawContribution, $moduleId, $key);
$normalized = $rawContribution;
$normalized['key'] = $key;
$normalized['order'] = (int) ($rawContribution['order'] ?? 100);
$normalized['__module_id'] = $moduleId;
$normalized['module_id'] = $moduleId;
return $normalized;
}
/**
* @param array<string, mixed> $rawContribution
*/
private function validateUiSlotContributionByType(
string $slotName,
array $rawContribution,
string $moduleId,
string $slotKey
): void {
if (in_array($slotName, ['aside.tab_panel', 'topbar.right_item', 'layout.body_end_template'], true)) {
$templatePath = trim((string) ($rawContribution['panel_template'] ?? $rawContribution['template'] ?? ''));
if ($templatePath === '' || !is_file($templatePath)) {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot '{$slotName}' in module '{$moduleId}' references missing template '{$templatePath}'."
);
}
}
if ($slotName === 'layout.head_style') {
$path = trim((string) ($rawContribution['path'] ?? ''));
if ($path === '' || str_contains($path, '..')) {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'layout.head_style' in module '{$moduleId}' has invalid path '{$path}'."
);
}
}
if ($slotName === 'user.edit.aside_action') {
$type = strtolower(trim((string) ($rawContribution['type'] ?? 'link')));
if ($type !== 'link' && $type !== 'form') {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' has invalid type '{$type}'."
);
}
if ($type === 'form') {
$action = trim((string) ($rawContribution['action'] ?? ''));
if ($action === '') {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' requires non-empty 'action' for type=form."
);
}
} else {
$href = trim((string) ($rawContribution['href'] ?? ''));
if ($href === '') {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' requires non-empty 'href' for type=link."
);
}
}
}
if ($slotName === 'runtime.component') {
$script = trim((string) ($rawContribution['script'] ?? ''));
if ($script === '' || str_contains($script, '..')) {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid script '{$script}'."
);
}
$phase = strtolower(trim((string) ($rawContribution['phase'] ?? 'late')));
if (!in_array($phase, ['early', 'default', 'late'], true)) {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid phase '{$phase}'."
);
}
$configPath = trim((string) ($rawContribution['config_path'] ?? ''));
if ($configPath !== '' && !preg_match('/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z0-9_]+)+$/', $configPath)) {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid config_path '{$configPath}'."
);
}
}
}
// ─── Getters ────────────────────────────────────────────────────────
@@ -239,7 +581,7 @@ final class ModuleRegistry
return $this->modules[$id];
}
/** @return array<int, array{path: string, target: string, public?: bool}> */
/** @return array<int, array{path: string, target: string, module_id: string, public?: bool}> */
public function getRoutes(): array
{
return $this->mergedRoutes;
@@ -295,13 +637,52 @@ final class ModuleRegistry
return $this->mergedSessionProviders;
}
/** @return list<string> */
/**
* @return list<array{key: string, description: string, active: int, is_system: int}>
*/
public function getPermissions(): array
{
return $this->mergedPermissions;
}
/** @return list<array{name: string, handler: string, schedule?: string}> */
/** @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;
}
/** @return array<string, string> UI-capability-key → ability-string */
public function getLayoutCapabilities(): array
{
return $this->mergedLayoutCapabilities;
}
/**
* @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;

View 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}'");
}
}
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace MintyPHP\App\Module;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
/**
* Builds a runtime PageRoot by symlinking core and enabled module pages.
*/
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];
}
if (is_link($runtimeDir)) {
unlink($runtimeDir);
}
if (!is_dir($runtimeDir) && !mkdir($runtimeDir, 0775, true) && !is_dir($runtimeDir)) {
throw new RuntimeException('Cannot create runtime pages directory: ' . $runtimeDir);
}
$this->removeDirectoryContents($runtimeDir);
$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 = $runtimeDir . '/' . $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 = $runtimeDir . '/' . $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,
];
}
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}");
}
}
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);
}
}
}

View File

@@ -44,8 +44,9 @@ $moduleRegistry = ModuleRegistry::boot($modulesDir, $enabledModules);
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry);
$coreKeys = []; // snapshot keys before module registrars
// We use reflection-free approach: track what the container had before modules
// 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}");

View File

@@ -66,10 +66,15 @@ class AccessPolicyFactory
}
// Assembles all policies into a single AuthorizationService.
// Policy order doesn't matter — each ability maps to exactly one policy via supports().
// Core policies are registered first; module-contributed policies are appended.
// Each ability maps to exactly one policy via supports() — first match wins.
public function createAuthorizationService(): AuthorizationService
{
return $this->authorizationService ??= new AuthorizationService([
if ($this->authorizationService !== null) {
return $this->authorizationService;
}
$policies = [
$this->createUserAuthorizationPolicy(),
$this->createRoleAuthorizationPolicy(),
$this->createPermissionAuthorizationPolicy(),
@@ -77,6 +82,50 @@ class AccessPolicyFactory
$this->createSettingsAuthorizationPolicy(),
$this->createTenantAuthorizationPolicy(),
$this->createDepartmentAuthorizationPolicy(),
]);
];
// Append module-contributed authorization policies.
try {
/** @var \MintyPHP\App\Module\ModuleRegistry $registry */
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
foreach ($registry->getAuthorizationPolicies() as $policyClass) {
if (!is_string($policyClass) || trim($policyClass) === '') {
continue;
}
$policy = $this->instantiateModulePolicy($policyClass);
if ($policy instanceof AuthorizationPolicyInterface) {
$policies[] = $policy;
}
}
} catch (\Throwable) {
// fail-open: no module registry available in this context
}
return $this->authorizationService = new AuthorizationService($policies);
}
private function instantiateModulePolicy(string $policyClass): ?AuthorizationPolicyInterface
{
if (!class_exists($policyClass)) {
return null;
}
try {
$reflection = new \ReflectionClass($policyClass);
$constructor = $reflection->getConstructor();
if ($constructor === null || $constructor->getNumberOfRequiredParameters() === 0) {
$instance = $reflection->newInstance();
return $instance instanceof AuthorizationPolicyInterface ? $instance : null;
}
if ($constructor->getNumberOfRequiredParameters() === 1) {
$instance = $reflection->newInstance($this->permissionService);
return $instance instanceof AuthorizationPolicyInterface ? $instance : null;
}
} catch (\Throwable) {
return null;
}
return null;
}
}

View File

@@ -4,7 +4,6 @@ namespace MintyPHP\Service\Access;
final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterface
{
public const ABILITY_ADDRESS_BOOK_VIEW = 'ops.address_book.view';
public const ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW = 'ops.admin.user_lifecycle_audit.view';
public const ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE = 'ops.admin.users.lifecycle_restore';
public const ABILITY_ADMIN_API_AUDIT_VIEW = 'ops.admin.api_audit.view';
@@ -49,7 +48,6 @@ final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterfac
public function supports(string $ability): bool
{
return in_array($ability, [
self::ABILITY_ADDRESS_BOOK_VIEW,
self::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW,
self::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE,
self::ABILITY_ADMIN_API_AUDIT_VIEW,
@@ -98,7 +96,6 @@ final class OperationsAuthorizationPolicy implements AuthorizationPolicyInterfac
return match ($ability) {
self::ABILITY_ADMIN_USERS_CREATE_EDIT_CUSTOM_FIELDS => $this->authorizeUsersCreateCustomFields($actorUserId),
self::ABILITY_API_TOKENS_SELF_MANAGE => $this->authorizeApiTokensSelfManage($actorUserId),
self::ABILITY_ADDRESS_BOOK_VIEW => $this->allowIfHas($actorUserId, PermissionService::ADDRESS_BOOK_VIEW),
self::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::USER_LIFECYCLE_AUDIT_VIEW),
self::ABILITY_ADMIN_USERS_LIFECYCLE_RESTORE => $this->allowIfHas($actorUserId, PermissionService::USERS_LIFECYCLE_RESTORE),
self::ABILITY_ADMIN_API_AUDIT_VIEW => $this->allowIfHas($actorUserId, PermissionService::API_AUDIT_VIEW),

View File

@@ -80,11 +80,28 @@ final class UiAccessService
}
/**
* Resolve layout capabilities from Core + module-contributed capabilities.
*
* @return array<string, bool>
*/
public function layoutCapabilities(int $actorUserId): array
{
return $this->resolveMap($actorUserId, UiCapabilityMap::LAYOUT);
$map = UiCapabilityMap::LAYOUT;
// Merge module-contributed layout capabilities dynamically.
try {
/** @var \MintyPHP\App\Module\ModuleRegistry $registry */
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
if ($registry instanceof \MintyPHP\App\Module\ModuleRegistry) {
foreach ($registry->getLayoutCapabilities() as $key => $ability) {
$map[$key] = $ability;
}
}
} catch (\Throwable) {
// fail-open: no module registry available in this context
}
return $this->resolveMap($actorUserId, $map);
}
/**

View File

@@ -5,6 +5,11 @@ namespace MintyPHP\Service\Access;
final class UiCapabilityMap
{
/**
* Core layout capabilities — admin panel visibility checks.
*
* Module-contributed capabilities are registered via the module manifest's
* 'layout_capabilities' field and merged dynamically in UiAccessService.
*
* @var array<string, string>
*/
public const LAYOUT = [
@@ -24,7 +29,6 @@ final class UiCapabilityMap
'can_view_imports_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_IMPORTS_AUDIT_VIEW,
'can_view_user_lifecycle_audit' => OperationsAuthorizationPolicy::ABILITY_ADMIN_USER_LIFECYCLE_AUDIT_VIEW,
'can_view_stats' => OperationsAuthorizationPolicy::ABILITY_ADMIN_STATS_VIEW,
'can_view_address_book' => OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW,
];
/**

View File

@@ -82,7 +82,7 @@ class AuthService
$sessionUser = $this->sessionUser();
if (!($sessionUser['active'] ?? 1)) {
Auth::logout();
$this->logoutWithModuleCleanup();
$this->recordAuthEvent('auth.login.failed', 'failed', [
'error_code' => 'account_inactive',
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
@@ -96,7 +96,7 @@ class AuthService
$userId = (int) ($sessionUser['id'] ?? 0);
if ($userId > 0 && !$this->isLocalPasswordLoginAllowedForUser($userId)) {
Auth::logout();
$this->logoutWithModuleCleanup();
$this->recordAuthEvent('auth.login.failed', 'failed', [
'error_code' => 'password_login_disabled',
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
@@ -111,7 +111,7 @@ class AuthService
$this->permissionService->getUserPermissions($userId, true);
$this->loadTenantDataIntoSession($userId);
if ($this->noActiveTenant()) {
Auth::logout();
$this->logoutWithModuleCleanup();
$this->recordAuthEvent('auth.login.failed', 'failed', [
'error_code' => 'no_active_tenant',
'metadata' => ['email_hash' => \MintyPHP\Http\RequestContext::hashValue(strtolower($email))],
@@ -203,7 +203,7 @@ class AuthService
$this->permissionService->getUserPermissions($userId, true);
$this->loadTenantDataIntoSession($userId);
if ($this->noActiveTenant()) {
Auth::logout();
$this->logoutWithModuleCleanup();
$this->recordAuthEvent('auth.login.failed', 'failed', [
'error_code' => 'login_no_active_tenant',
'actor_user_id' => $userId,
@@ -277,7 +277,7 @@ class AuthService
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
$actorTenantId = $this->currentTenantIdFromSession();
$this->rememberMeService->forgetCurrentUser();
Auth::logout();
$this->logoutWithModuleCleanup();
$this->recordAuthEvent('auth.logout', 'success', [
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null,
@@ -290,7 +290,7 @@ class AuthService
$actorTenantId = $this->currentTenantIdFromSession();
$this->sessionStore->remove('session_started_at');
$this->sessionStore->remove('session_last_activity');
Auth::logout();
$this->logoutWithModuleCleanup();
$this->recordAuthEvent('auth.session.timeout', 'success', [
'actor_user_id' => $actorUserId > 0 ? $actorUserId : null,
'actor_tenant_id' => $actorTenantId > 0 ? $actorTenantId : null,
@@ -413,4 +413,10 @@ class AuthService
{
return (bool) $this->sessionStore->get('no_active_tenant', false);
}
private function logoutWithModuleCleanup(): void
{
$this->authSessionTenantContextService->clearModuleSessionData();
Auth::logout();
}
}

View File

@@ -2,12 +2,12 @@
namespace MintyPHP\Service\Auth;
use MintyPHP\App\AppContainer;
use MintyPHP\Http\CookieStoreInterface;
use MintyPHP\Http\RequestRuntimeInterface;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Repository\Support\DatabaseSessionRepository;
use MintyPHP\Service\Audit\AuditServicesFactory;
use MintyPHP\Service\Bookmark\BookmarkServicesFactory;
use MintyPHP\Service\Mail\MailServicesFactory;
use MintyPHP\Service\User\UserServicesFactory;
@@ -34,7 +34,7 @@ class AuthServicesFactory
private readonly SessionStoreInterface $sessionStore,
private readonly CookieStoreInterface $cookieStore,
private readonly RequestRuntimeInterface $requestRuntime,
private readonly BookmarkServicesFactory $bookmarkServicesFactory
private readonly AppContainer $appContainer
) {
}
@@ -152,8 +152,8 @@ class AuthServicesFactory
{
return $this->authSessionTenantContextService ??= new AuthSessionTenantContextService(
$this->userServicesFactory->createUserTenantContextService(),
$this->bookmarkServicesFactory->createBookmarkService(),
$this->sessionStore
$this->sessionStore,
$this->appContainer
);
}
}

View File

@@ -2,16 +2,18 @@
namespace MintyPHP\Service\Auth;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\SessionProvider;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Http\SessionStoreInterface;
use MintyPHP\Service\Bookmark\BookmarkService;
use MintyPHP\Service\User\UserTenantContextService;
class AuthSessionTenantContextService
{
public function __construct(
private readonly UserTenantContextService $userTenantContextService,
private readonly BookmarkService $bookmarkService,
private readonly SessionStoreInterface $sessionStore
private readonly SessionStoreInterface $sessionStore,
private readonly AppContainer $container
) {
}
@@ -23,8 +25,6 @@ class AuthSessionTenantContextService
$availableTenants = $this->userTenantContextService->getAvailableTenants($userId);
$this->sessionStore->set('available_tenants', $availableTenants);
$this->sessionStore->set('available_departments_by_tenant', $this->userTenantContextService->getAvailableDepartmentsByTenant($userId));
$this->sessionStore->set('user_bookmarks', $this->bookmarkService->listGroupedForUser($userId));
if (!$availableTenants) {
$this->sessionStore->set('no_active_tenant', true);
@@ -43,5 +43,45 @@ class AuthSessionTenantContextService
if ($currentTenant) {
$this->sessionStore->set('current_tenant', $currentTenant);
}
$user = $this->sessionStore->get('user', []);
$userRecord = is_array($user) ? $user : [];
foreach ($this->moduleSessionProviders() as $provider) {
$provider->populate($userRecord, $this->container);
}
}
public function clearModuleSessionData(): void
{
foreach ($this->moduleSessionProviders() as $provider) {
$provider->clear();
}
}
/**
* @return list<SessionProvider>
*/
private function moduleSessionProviders(): array
{
$providers = [];
try {
$registry = $this->container->get(ModuleRegistry::class);
if (!$registry instanceof ModuleRegistry) {
return [];
}
foreach ($registry->getSessionProviders() as $providerClass) {
if (!class_exists($providerClass)) {
continue;
}
$provider = new $providerClass();
if ($provider instanceof SessionProvider) {
$providers[] = $provider;
}
}
} catch (\Throwable) {
return [];
}
return $providers;
}
}

View File

@@ -118,6 +118,7 @@ class RememberMeService
if ((bool) $this->sessionStore->get('no_active_tenant', false)) {
// Enforce the same tenant policy as interactive login.
$this->forgetCurrentUser();
$this->authSessionTenantContextService->clearModuleSessionData();
Auth::logout();
return false;
}

View File

@@ -410,7 +410,16 @@ class UserCustomFieldValueService
return ['prepared' => $prepared, 'errors' => $errors];
}
public function extractAddressBookFilterSpec(array $query, int $currentUserId): array
/**
* Extract custom field filter specification from a URL query.
*
* Parses cf_*, cfm_*, and cfd_* query parameters against the user's
* visible custom field definitions and returns validated filter data.
*
* @param array<string, mixed> $query URL query parameters
* @return array{definitions: list<array<string, mixed>>, filters: array<string, array<int, mixed>>, query: array<string, string>}
*/
public function extractCustomFieldFilterSpec(array $query, int $currentUserId): array
{
$tenantIds = $this->userScopeGateway()->getUserTenantIds($currentUserId);
$definitions = TenantCustomFieldDefinitionRepository::listFilterableByTenantIds($tenantIds);

View File

@@ -2,39 +2,51 @@
namespace MintyPHP\Service\Scheduler;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Service\Audit\SystemAuditService;
use MintyPHP\Service\Scheduler\Handler\ScheduledJobHandlerInterface;
use MintyPHP\Service\Scheduler\Handler\SystemAuditPurgeJobHandler;
use MintyPHP\Service\Scheduler\Handler\UserLifecycleJobHandler;
use MintyPHP\Service\User\UserLifecycleService;
use RuntimeException;
class ScheduledJobRegistry
{
public const USER_LIFECYCLE_RUN = 'user_lifecycle_run';
public const SYSTEM_AUDIT_PURGE = 'system_audit_purge';
/**
* Maps job_key => handler instance (must implement ScheduledJobHandlerInterface).
*
* To register a new job:
* 1. Create lib/Service/Scheduler/Handler/YourJobHandler.php implementing the interface.
* 2. Add a public const for the job key above.
* 3. Add one line in __construct(): self::YOUR_JOB_KEY => new YourJobHandler(...),
*
* No other file needs to be changed.
*
* @var array<string, ScheduledJobHandlerInterface>
*/
/** @var array<string, ScheduledJobHandlerInterface> */
private array $handlers;
/**
* @var array<string, array{
* 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 array $moduleJobs = [];
public function __construct(
UserLifecycleService $userLifecycleService,
SystemAuditService $systemAuditService
SystemAuditService $systemAuditService,
private readonly AppContainer $container
) {
$this->handlers = [
self::USER_LIFECYCLE_RUN => new UserLifecycleJobHandler($userLifecycleService),
self::SYSTEM_AUDIT_PURGE => new SystemAuditPurgeJobHandler($systemAuditService),
];
$this->loadModuleJobs();
}
/**
@@ -47,6 +59,9 @@ class ScheduledJobRegistry
foreach ($this->handlers as $jobKey => $handler) {
$result[$jobKey] = $handler->definition();
}
foreach ($this->moduleJobs as $jobKey => $definition) {
$result[$jobKey] = $this->stripHandlerFromDefinition($definition);
}
return $result;
}
@@ -59,7 +74,13 @@ class ScheduledJobRegistry
if ($jobKey === '') {
return null;
}
return isset($this->handlers[$jobKey]) ? $this->handlers[$jobKey]->definition() : null;
if (isset($this->handlers[$jobKey])) {
return $this->handlers[$jobKey]->definition();
}
if (isset($this->moduleJobs[$jobKey])) {
return $this->stripHandlerFromDefinition($this->moduleJobs[$jobKey]);
}
return null;
}
/**
@@ -73,7 +94,12 @@ class ScheduledJobRegistry
*/
public function execute(string $jobKey, ?int $actorUserId): array
{
if (!isset($this->handlers[$jobKey])) {
if (isset($this->handlers[$jobKey])) {
return $this->handlers[$jobKey]->execute($actorUserId);
}
$moduleDefinition = $this->moduleJobs[$jobKey] ?? null;
if (!is_array($moduleDefinition)) {
return [
'status' => 'failed',
'error_code' => 'job_not_supported',
@@ -81,6 +107,128 @@ class ScheduledJobRegistry
'result' => [],
];
}
return $this->handlers[$jobKey]->execute($actorUserId);
$handlerClass = trim((string) $moduleDefinition['handler']);
if ($handlerClass === '') {
return [
'status' => 'failed',
'error_code' => 'job_handler_missing',
'error_message' => 'Scheduler job handler is not configured.',
'result' => [],
];
}
try {
$handler = $this->resolveHandler($handlerClass);
return $handler->execute($actorUserId);
} catch (\Throwable $exception) {
return [
'status' => 'failed',
'error_code' => 'job_handler_resolution_failed',
'error_message' => $exception->getMessage(),
'result' => [],
];
}
}
private function loadModuleJobs(): void
{
$registry = $this->resolveModuleRegistry();
if (!$registry instanceof ModuleRegistry) {
return;
}
foreach ($registry->getSchedulerJobs() as $jobDefinition) {
$jobKey = trim((string) $jobDefinition['job_key']);
if ($jobKey === '') {
continue;
}
if (isset($this->handlers[$jobKey])) {
throw new RuntimeException(
"Module scheduler job '{$jobKey}' collides with core scheduler job key."
);
}
if (isset($this->moduleJobs[$jobKey])) {
throw new RuntimeException(
"Duplicate module scheduler job key '{$jobKey}'."
);
}
$this->moduleJobs[$jobKey] = [
'handler' => trim((string) $jobDefinition['handler']),
'label' => trim((string) $jobDefinition['label']),
'description' => trim((string) $jobDefinition['description']),
'default_enabled' => (int) $jobDefinition['default_enabled'],
'default_timezone' => trim((string) $jobDefinition['default_timezone']),
'default_schedule_type' => trim((string) $jobDefinition['default_schedule_type']),
'default_schedule_interval' => (int) $jobDefinition['default_schedule_interval'],
'default_schedule_time' => $jobDefinition['default_schedule_time'],
'default_schedule_weekdays_csv' => $jobDefinition['default_schedule_weekdays_csv'],
'default_catchup_once' => (int) $jobDefinition['default_catchup_once'],
'allowed_schedule_types' => array_values($jobDefinition['allowed_schedule_types']),
];
}
}
/**
* @param array{
* 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>
* } $definition
* @return array{
* 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 function stripHandlerFromDefinition(array $definition): array
{
unset($definition['handler']);
return $definition;
}
private function resolveModuleRegistry(): ?ModuleRegistry
{
try {
$registry = $this->container->get(ModuleRegistry::class);
} catch (\Throwable) {
return null;
}
return $registry instanceof ModuleRegistry ? $registry : null;
}
private function resolveHandler(string $handlerClass): ScheduledJobHandlerInterface
{
$handler = null;
if ($this->container->has($handlerClass)) {
$handler = $this->container->get($handlerClass);
} elseif (class_exists($handlerClass)) {
$handler = new $handlerClass();
}
if (!$handler instanceof ScheduledJobHandlerInterface) {
throw new RuntimeException(
"Scheduler job handler '{$handlerClass}' must implement " . ScheduledJobHandlerInterface::class . '.'
);
}
return $handler;
}
}

View File

@@ -2,6 +2,7 @@
namespace MintyPHP\Service\Scheduler;
use MintyPHP\App\AppContainer;
use MintyPHP\Repository\Scheduler\ScheduledJobRepositoryInterface;
use MintyPHP\Repository\Scheduler\ScheduledJobRunRepositoryInterface;
use MintyPHP\Repository\Scheduler\SchedulerRuntimeRepositoryInterface;
@@ -22,7 +23,8 @@ class SchedulerServicesFactory
private readonly UserServicesFactory $userServicesFactory,
private readonly AuditServicesFactory $auditServicesFactory,
private readonly DatabaseSessionRepository $databaseSessionRepository,
private readonly SchedulerRepositoryFactory $schedulerRepositoryFactory
private readonly SchedulerRepositoryFactory $schedulerRepositoryFactory,
private readonly AppContainer $appContainer
) {
}
@@ -84,7 +86,8 @@ class SchedulerServicesFactory
{
return $this->scheduledJobRegistry ??= new ScheduledJobRegistry(
$this->getUserLifecycleService(),
$this->getSystemAuditService()
$this->getSystemAuditService(),
$this->appContainer
);
}

View File

@@ -28,12 +28,14 @@ class SearchDataService
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
$resources = SearchConfig::resources($query, $locale);
// Merge module-contributed search resources into the pipeline
// Merge module-contributed search resources into the pipeline.
// Replace {{userId}} before counting ? placeholders so it is not treated as a bind param.
$like = \MintyPHP\Support\Search\SearchQueryNormalizer::normalizeLikeQuery($query);
$userIdStr = (string) $userId;
foreach (SearchConfig::moduleResources() as $key => $moduleDef) {
$countSql = $moduleDef['count_sql'];
$previewSql = $moduleDef['preview_sql'];
$resultSql = $moduleDef['result_sql'];
$countSql = str_replace('{{userId}}', $userIdStr, $moduleDef['count_sql']);
$previewSql = str_replace('{{userId}}', $userIdStr, $moduleDef['preview_sql']);
$resultSql = str_replace('{{userId}}', $userIdStr, $moduleDef['result_sql']);
$resources[] = [
'key' => $key,
'label' => $moduleDef['label'],
@@ -150,12 +152,14 @@ class SearchDataService
$tenantScopeFilters = SearchConfig::tenantScopeFilters();
$resources = SearchConfig::resources($query, $locale);
// Merge module-contributed search resources
// Merge module-contributed search resources.
// Replace {{userId}} before counting ? placeholders so it is not treated as a bind param.
$like = \MintyPHP\Support\Search\SearchQueryNormalizer::normalizeLikeQuery($query);
$userIdStr = (string) $userId;
foreach (SearchConfig::moduleResources() as $key => $moduleDef) {
$countSql = $moduleDef['count_sql'];
$previewSql = $moduleDef['preview_sql'];
$resultSql = $moduleDef['result_sql'];
$countSql = str_replace('{{userId}}', $userIdStr, $moduleDef['count_sql']);
$previewSql = str_replace('{{userId}}', $userIdStr, $moduleDef['preview_sql']);
$resultSql = str_replace('{{userId}}', $userIdStr, $moduleDef['result_sql']);
$resources[] = [
'key' => $key,
'label' => $moduleDef['label'],

View File

@@ -498,9 +498,27 @@ function sortByDescription(array &$items): void
*/
function layoutHasAdminPanel(array $layoutAuth): bool
{
// Collect capability keys contributed by modules — these are non-admin
// capabilities that should not trigger the admin panel visibility.
$moduleCapabilities = [];
try {
/** @var \MintyPHP\App\Module\ModuleRegistry $registry */
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
foreach ($registry->getUiSlots() as $contributions) {
foreach ($contributions as $contribution) {
$perm = is_array($contribution) ? trim((string) ($contribution['permission'] ?? '')) : '';
if ($perm !== '') {
$moduleCapabilities[$perm] = true;
}
}
}
} catch (\Throwable) {
// fail-open: if module registry is not available, skip module capabilities
}
$capabilityKeys = array_keys(\MintyPHP\Service\Access\UiCapabilityMap::LAYOUT);
foreach ($capabilityKeys as $key) {
if ($key === 'can_view_address_book') {
if (isset($moduleCapabilities[$key])) {
continue;
}
if (!empty($layoutAuth[$key])) {
@@ -540,43 +558,63 @@ function appNormalizePositiveIntList(mixed $value): array
}
/**
* Normalize address-book custom field query keys/values.
* Reserved top-level keys in layout navigation context that modules must not override.
*
* @param array<string, mixed> $rawQuery
* @return array<string, string|list<int>>
* @return list<string>
*/
function appNormalizeAddressBookCustomFilterQuery(array $rawQuery): array
function appLayoutNavReservedKeys(): array
{
$normalized = [];
foreach ($rawQuery as $rawKey => $rawValue) {
$key = strtolower(trim((string) $rawKey));
return [
'hasAdminPanel',
'moduleSlots',
'currentTenant',
'availableTenants',
'tenantQueryParam',
'tenantAvatar',
'csrfKey',
'csrfToken',
];
}
/**
* Merge one module layout-provider payload into layout navigation context.
*
* Provider keys are contract-validated and must be namespaced (`module.key`).
*
* @param array<string, mixed> $layoutNav
* @param array<string, mixed> $providerData
* @return array<string, mixed>
*/
function appMergeLayoutNavProviderData(array $layoutNav, array $providerData, string $providerClass): array
{
$reserved = array_fill_keys(appLayoutNavReservedKeys(), true);
foreach ($providerData as $rawKey => $value) {
$key = trim((string) $rawKey);
if ($key === '') {
continue;
throw new \RuntimeException(
"Layout context provider '{$providerClass}' returned an empty top-level key."
);
}
if (preg_match('/^cf_[a-f0-9-]{36}$/', $key)) {
$value = trim((string) $rawValue);
if ($value !== '') {
$normalized[$key] = $value;
}
continue;
if (isset($reserved[$key])) {
throw new \RuntimeException(
"Layout context provider '{$providerClass}' must not override reserved layout key '{$key}'."
);
}
if (preg_match('/^cfm_[a-f0-9-]{36}$/', $key)) {
$ids = appNormalizePositiveIntList($rawValue);
if ($ids) {
$normalized[$key] = $ids;
}
continue;
if (array_key_exists($key, $layoutNav)) {
throw new \RuntimeException(
"Layout context provider '{$providerClass}' collides on existing layout key '{$key}'."
);
}
if (preg_match('/^cfd_[a-f0-9-]{36}_(from|to)$/', $key)) {
$value = trim((string) $rawValue);
$dt = \DateTimeImmutable::createFromFormat('Y-m-d', $value);
if ($dt && $dt->format('Y-m-d') === $value) {
$normalized[$key] = $value;
}
if (!preg_match('/^[a-z0-9]+[a-z0-9._-]*$/', $key) || !str_contains($key, '.')) {
throw new \RuntimeException(
"Layout context provider '{$providerClass}' key '{$key}' must be namespaced (e.g. '<module_id>.data')."
);
}
$layoutNav[$key] = $value;
}
ksort($normalized, SORT_STRING);
return $normalized;
return $layoutNav;
}
/**
@@ -628,9 +666,6 @@ function appBuildLayoutNavContext(array $layoutAuth, array $session, array $quer
'name' => $tenantName,
'hasAvatar' => $tenantHasAvatar,
],
'bookmarks' => is_array($session['user_bookmarks'] ?? null)
? $session['user_bookmarks']
: ['groups' => [], 'ungrouped' => []],
'csrfKey' => $csrfKey,
'csrfToken' => $csrfToken,
];
@@ -641,19 +676,30 @@ function appBuildLayoutNavContext(array $layoutAuth, array $session, array $quer
try {
/** @var \MintyPHP\App\Module\ModuleRegistry $moduleRegistry */
$moduleRegistry = app(\MintyPHP\App\Module\ModuleRegistry::class);
/** @var \MintyPHP\App\AppContainer $container */
$container = $GLOBALS['minty_app_container'];
foreach ($moduleRegistry->getLayoutContextProviders() as $providerClass) {
if (!class_exists($providerClass)) {
continue;
}
$provider = new $providerClass();
if ($provider instanceof \MintyPHP\App\Module\Contracts\LayoutContextProvider) {
$layoutNav = array_merge($layoutNav, $provider->provide($session, $container));
}
}
} catch (\Throwable) {
// fail-open: if module registry is not available, skip module providers
return $layoutNav;
}
$container = $GLOBALS['minty_app_container'] ?? null;
if (!$container instanceof \MintyPHP\App\AppContainer) {
return $layoutNav;
}
foreach ($moduleRegistry->getLayoutContextProviders() as $providerClass) {
if (!class_exists($providerClass)) {
continue;
}
$provider = new $providerClass();
if ($provider instanceof \MintyPHP\App\Module\Contracts\LayoutContextProvider) {
$providerData = $provider->provide($session, $container);
if (!is_array($providerData)) {
throw new \RuntimeException(
"Layout context provider '{$providerClass}' must return an array."
);
}
$layoutNav = appMergeLayoutNavProviderData($layoutNav, $providerData, $providerClass);
}
}
return $layoutNav;

View File

@@ -482,6 +482,24 @@ function appStylePathsForGroups(array $groupNames): array
return [];
}
// Merge module-contributed style groups (same shape as config/assets.php groups).
try {
$registry = app(\MintyPHP\App\Module\ModuleRegistry::class);
if ($registry instanceof \MintyPHP\App\Module\ModuleRegistry) {
foreach ($registry->getAssetGroups() as $groupName => $paths) {
if (!is_string($groupName) || $groupName === '') {
continue;
}
if (!is_array($paths) || isset($groups[$groupName])) {
continue;
}
$groups[$groupName] = $paths;
}
}
} catch (\Throwable) {
// fail-open: no module registry in this runtime context.
}
$resolved = [];
// Used as a fast set to keep insertion order while removing duplicates.
$seen = [];