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

@@ -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;
}
}