Files
breadcrumb-the-shire/lib/Service/Access/UiAccessService.php
fs c7b8fd516a 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>
2026-03-18 22:19:56 +01:00

138 lines
4.3 KiB
PHP

<?php
namespace MintyPHP\Service\Access;
final class UiAccessService
{
/** @var array<string, bool> */
private array $decisionCache = [];
public function __construct(
private readonly AuthorizationService $authorizationService
) {
}
public function allow(int $actorUserId, string $ability, array $context = []): bool
{
$ability = trim($ability);
if ($actorUserId <= 0 || $ability === '') {
return false;
}
// Cache key hashes a sorted context so order-independent inputs produce the same key.
$normalizedContext = $this->normalizeContext($context);
$cacheKey = $actorUserId . '|' . $ability . '|' . sha1(json_encode($normalizedContext, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '');
if (array_key_exists($cacheKey, $this->decisionCache)) {
return $this->decisionCache[$cacheKey];
}
$decision = $this->authorizationService->authorize($ability, [
'actor_user_id' => $actorUserId,
...$normalizedContext,
]);
return $this->decisionCache[$cacheKey] = $decision->isAllowed();
}
/**
* @param array<string, string|array{ability: string, context?: array<string, mixed>}> $map
* @param array<string, mixed> $baseContext
* @return array<string, bool>
*/
public function resolveMap(int $actorUserId, array $map, array $baseContext = []): array
{
$resolved = [];
$normalizedBaseContext = $this->normalizeContext($baseContext);
foreach ($map as $key => $definition) {
if (is_string($definition)) {
$resolved[$key] = $this->allow($actorUserId, $definition, $normalizedBaseContext);
continue;
}
if (!is_array($definition)) {
$resolved[$key] = false;
continue;
}
$ability = $definition['ability'];
$context = [];
if (array_key_exists('context', $definition) && is_array($definition['context'])) {
$context = $this->normalizeContext($definition['context']);
}
$resolved[$key] = $this->allow($actorUserId, $ability, [
...$normalizedBaseContext,
...$context,
]);
}
return $resolved;
}
/**
* @param array<string, string|array{ability: string, context?: array<string, mixed>}> $map
* @param array<string, mixed> $baseContext
* @return array<string, bool>
*/
public function pageCapabilities(int $actorUserId, array $map, array $baseContext = []): array
{
return $this->resolveMap($actorUserId, $map, $baseContext);
}
/**
* Resolve layout capabilities from Core + module-contributed capabilities.
*
* @return array<string, bool>
*/
public function layoutCapabilities(int $actorUserId): array
{
$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);
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private function normalizeContext(array $context): array
{
$normalized = $this->normalizeValue($context);
return is_array($normalized) ? $normalized : [];
}
private function normalizeValue(mixed $value): mixed
{
if (!is_array($value)) {
return $value;
}
$isList = array_is_list($value);
if ($isList) {
foreach ($value as $idx => $entry) {
$value[$idx] = $this->normalizeValue($entry);
}
return $value;
}
ksort($value);
foreach ($value as $key => $entry) {
$value[$key] = $this->normalizeValue($entry);
}
return $value;
}
}