diff --git a/agent-system/checks/guard-catalog.json b/agent-system/checks/guard-catalog.json index 24d99d3..af3a40a 100644 --- a/agent-system/checks/guard-catalog.json +++ b/agent-system/checks/guard-catalog.json @@ -267,6 +267,13 @@ "title": "CSS reuse-first: prefer existing classes over new rules", "requirement": "MUST check existing CSS layers (base, components, layout, pages) for suitable classes before writing new rules. MUST use existing app-* component classes as the basis and only add additive overrides. MUST NOT duplicate declarations already defined in shared component or layout files.", "source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md" + }, + { + "id": "GR-UI-017", + "area": "ui", + "title": "Component lifecycle contract for migrated UI modules", + "requirement": "Components migrated to the lifecycle runtime MUST expose an init(root, config) entrypoint that returns an API with destroy(). Migrated component modules MUST NOT auto-initialize themselves via DOMContentLoaded/module side effects and MUST NOT retain legacy wrapper entrypoints or init alias exports. Event listeners, timers, and observers created during init MUST be cleaned up in destroy(). Runtime configuration MUST come from page-config payloads (readPageConfig) instead of scattered ad-hoc globals. Page-level initialization MUST be loaded via web/js/pages/* entry modules, not js/components/* script loads. Host-bound UI components (including tabs) MUST provide explicit data-app-component hosts in markup and remain root-strict (no document.querySelector fallback paths). Global utilities without a natural host MAY be runtime-registered with scope=global and MUST still satisfy the same lifecycle/destroy contract. Project-specific custom window.* globals are forbidden; cross-component communication MUST use core channel modules. Migrated UI storage MUST use the shared namespaced storage contract without legacy key fallback/sync paths.", + "source": "tools/codex-skills/core-guardrails/references/boundaries-ui.md" } ] } diff --git a/agent-system/checks/guard-checklist.md b/agent-system/checks/guard-checklist.md index 54e2a65..5379f84 100644 --- a/agent-system/checks/guard-checklist.md +++ b/agent-system/checks/guard-checklist.md @@ -41,6 +41,7 @@ Canonical guard IDs live in `agent-system/checks/guard-catalog.json`. - `GR-UI-015` new t() keys present in all language files (de + en) in the same merge; no key missing in any language file - `GR-UI-011` JS reuse-first: existing exports in web/js/core/ and web/js/components/ checked before adding a new function; no logic duplication from core modules - `GR-UI-012` CSS reuse-first: existing app-* classes and layer rules checked before writing new declarations; only additive overrides on top of shared components +- `GR-UI-017` lifecycle runtime contract for migrated components is enforced (init(root, config) + destroy(), no component-level auto-init side effects, no legacy wrappers/init alias exports, cleanup of listeners/timers/observers, runtime config via page-config, page-level JS only via `web/js/pages/*` modules, host-bound components incl. tabs with `data-app-component` and root-strict selectors, global utilities only via explicit runtime-global registration, no project-specific custom `window.*` globals, namespaced UI storage without legacy fallback/sync paths) ## Testing - `GR-TEST-001` new or changed Service/Gateway logic has accompanying PHPUnit tests (happy path + at least one failure/edge case) in the same merge diff --git a/bin/module-assets-sync.php b/bin/module-assets-sync.php new file mode 100644 index 0000000..f5a0015 --- /dev/null +++ b/bin/module-assets-sync.php @@ -0,0 +1,80 @@ +#!/usr/bin/env php + symlinks to modules//web + * - copy: copy module web directories (for environments without symlink support) + * + * Exit codes: + * 0 — sync successful + * 1 — error during sync + */ + +declare(strict_types=1); + +use MintyPHP\App\Module\ModuleRegistry; +use MintyPHP\App\Module\ModuleRuntimeAssetPublisher; + +require_once __DIR__ . '/module-cli-bootstrap.php'; + +function moduleAssetsSyncRun(): int +{ + $warningsBefore = moduleCliVendorWarningsIgnoredTotal(); + moduleCliBootstrapApp(); + + $exitCode = 0; + $runtimeModulesDir = __DIR__ . '/../web/modules'; + $lockFile = __DIR__ . '/../storage/runtime/.module-assets-sync.lock'; + $mode = (string) getenv('APP_MODULE_ASSET_MODE'); + if (trim($mode) === '') { + $mode = 'symlink'; + } + + try { + $lockDir = dirname($lockFile); + if (!is_dir($lockDir) && !mkdir($lockDir, 0775, true) && !is_dir($lockDir)) { + throw new RuntimeException("Failed to create lock directory: {$lockDir}"); + } + + $lock = fopen($lockFile, 'c'); + if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) { + fwrite(STDERR, "module-assets-sync: another sync is in progress, skipping.\n"); + return 0; + } + + $registry = app(ModuleRegistry::class); + $publisher = new ModuleRuntimeAssetPublisher(); + $result = $publisher->publish($runtimeModulesDir, $registry->getModules(), $mode); + + fwrite(STDOUT, sprintf( + "module-assets-sync: mode=%s created=%d updated=%d removed=%d unchanged=%d\n", + $mode, + $result['created'], + $result['updated'], + $result['removed'], + $result['unchanged'] + )); + } catch (Throwable $exception) { + fwrite(STDERR, sprintf("module-assets-sync: FAILED: %s\n", $exception->getMessage())); + $exitCode = 1; + } finally { + if (isset($lock) && is_resource($lock)) { + flock($lock, LOCK_UN); + fclose($lock); + } + } + + $vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore); + fwrite(STDOUT, sprintf("module-assets-sync: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored)); + + return $exitCode; +} + +if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { + exit(moduleAssetsSyncRun()); +} diff --git a/bin/module-build.php b/bin/module-build.php index cd49b78..9cd9c74 100644 --- a/bin/module-build.php +++ b/bin/module-build.php @@ -18,159 +18,64 @@ declare(strict_types=1); -chdir(__DIR__ . '/..'); - -require 'vendor/autoload.php'; -require 'config/config.php'; -require 'lib/Support/helpers.php'; -$container = require 'lib/App/registerContainer.php'; -setAppContainer($container); - use MintyPHP\App\Module\ModuleRegistry; +use MintyPHP\App\Module\ModuleRuntimePageBuilder; -$exitCode = 0; -$runtimeDir = __DIR__ . '/../storage/runtime/pages'; -$lockFile = __DIR__ . '/../storage/runtime/.module-build.lock'; +require_once __DIR__ . '/module-cli-bootstrap.php'; -try { - // Ensure runtime directory exists - $runtimeParent = dirname($runtimeDir); - if (!is_dir($runtimeParent)) { - mkdir($runtimeParent, 0775, true); - } - - // Lock to prevent concurrent builds - $lock = fopen($lockFile, 'c'); - if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) { - fwrite(STDERR, "module-build: another build is in progress, skipping.\n"); - exit(0); - } - - $registry = app(ModuleRegistry::class); - $modules = $registry->getModules(); - - $corePages = realpath(__DIR__ . '/../pages'); - if ($corePages === false) { - throw new RuntimeException('Core pages/ directory not found.'); - } - - // If no modules, ensure runtime dir points to core pages - if (count($modules) === 0) { - // Remove existing runtime dir if it's a real directory (not a symlink) - if (is_dir($runtimeDir) && !is_link($runtimeDir)) { - removeDirectoryContents($runtimeDir); - rmdir($runtimeDir); - } - // Symlink runtime → core pages - if (is_link($runtimeDir)) { - unlink($runtimeDir); - } - symlink($corePages, $runtimeDir); - fwrite(STDOUT, "module-build: no modules, runtime → core pages (symlink).\n"); - exit(0); - } - - // With modules: build merged directory - if (is_link($runtimeDir)) { - unlink($runtimeDir); - } - if (!is_dir($runtimeDir)) { - mkdir($runtimeDir, 0775, true); - } - - // Clean existing symlinks in runtime dir - removeDirectoryContents($runtimeDir); - - // Symlink all core page directories/files - $coreEntries = scandir($corePages); - if ($coreEntries === false) { - throw new RuntimeException('Cannot scan core pages/ directory.'); - } - foreach ($coreEntries as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - $source = $corePages . '/' . $entry; - $target = $runtimeDir . '/' . $entry; - symlink($source, $target); - } - - // Add module page symlinks (fail-fast on collision) - $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." - ); - } - - symlink($source, $target); - $modulePageCount++; - } - } - - fwrite(STDOUT, sprintf( - "module-build: runtime pages built — %d core entries + %d module entries.\n", - count($coreEntries) - 2, // minus . and .. - $modulePageCount - )); -} catch (Throwable $exception) { - fwrite(STDERR, sprintf("module-build: FAILED: %s\n", $exception->getMessage())); - $exitCode = 1; -} finally { - if (isset($lock) && is_resource($lock)) { - flock($lock, LOCK_UN); - fclose($lock); - } -} - -exit($exitCode); - -/** - * Remove all files and symlinks in a directory (non-recursive, top-level only). - */ -function removeDirectoryContents(string $dir): void +function moduleBuildRun(): int { - $entries = scandir($dir); - if ($entries === false) { - return; - } - foreach ($entries as $entry) { - if ($entry === '.' || $entry === '..') { - continue; + $warningsBefore = moduleCliVendorWarningsIgnoredTotal(); + moduleCliBootstrapApp(); + + $exitCode = 0; + $runtimeDir = __DIR__ . '/../storage/runtime/pages'; + $lockFile = __DIR__ . '/../storage/runtime/.module-build.lock'; + + try { + // Ensure runtime directory exists + $runtimeParent = dirname($runtimeDir); + if (!is_dir($runtimeParent) && !mkdir($runtimeParent, 0775, true) && !is_dir($runtimeParent)) { + throw new \RuntimeException("Failed to create runtime directory: {$runtimeParent}"); } - $path = $dir . '/' . $entry; - if (is_link($path)) { - unlink($path); - } elseif (is_dir($path)) { - // Recursively remove directories that were created (shouldn't happen in normal flow) - $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); + + // Lock to prevent concurrent builds + $lock = fopen($lockFile, 'c'); + if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) { + fwrite(STDERR, "module-build: another build is in progress, skipping.\n"); + return 0; + } + + $registry = app(ModuleRegistry::class); + $modules = $registry->getModules(); + $builder = new ModuleRuntimePageBuilder(); + $result = $builder->build($runtimeDir, __DIR__ . '/../pages', $modules); + + if (count($modules) === 0) { + fwrite(STDOUT, "module-build: no modules, runtime → core pages (symlink).\n"); } else { - unlink($path); + fwrite(STDOUT, sprintf( + "module-build: runtime pages built — %d core entries + %d module entries.\n", + (int) ($result['core_entries'] ?? 0), + (int) ($result['module_entries'] ?? 0) + )); + } + } catch (Throwable $exception) { + fwrite(STDERR, sprintf("module-build: FAILED: %s\n", $exception->getMessage())); + $exitCode = 1; + } finally { + if (isset($lock) && is_resource($lock)) { + flock($lock, LOCK_UN); + fclose($lock); } } + + $vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore); + fwrite(STDOUT, sprintf("module-build: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored)); + + return $exitCode; +} + +if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { + exit(moduleBuildRun()); } diff --git a/bin/module-cli-bootstrap.php b/bin/module-cli-bootstrap.php new file mode 100644 index 0000000..eb9185a --- /dev/null +++ b/bin/module-cli-bootstrap.php @@ -0,0 +1,116 @@ + fail-fast (RuntimeException) + * - vendor warnings/notices/deprecations => ignored and counted + */ + +function moduleCliProjectRoot(): string +{ + return dirname(__DIR__); +} + +function moduleCliVendorWarningsIgnoredTotal(): int +{ + $count = $GLOBALS['module_cli_vendor_warnings_ignored'] ?? 0; + return is_int($count) ? $count : 0; +} + +function moduleCliVendorWarningsIgnoredSince(int $before): int +{ + return max(0, moduleCliVendorWarningsIgnoredTotal() - $before); +} + +function moduleCliIncrementVendorWarningsIgnored(): void +{ + $current = moduleCliVendorWarningsIgnoredTotal(); + $GLOBALS['module_cli_vendor_warnings_ignored'] = $current + 1; +} + +function moduleCliSeverityName(int $severity): string +{ + return match ($severity) { + E_WARNING => 'E_WARNING', + E_NOTICE => 'E_NOTICE', + E_USER_WARNING => 'E_USER_WARNING', + E_USER_NOTICE => 'E_USER_NOTICE', + E_DEPRECATED => 'E_DEPRECATED', + E_USER_DEPRECATED => 'E_USER_DEPRECATED', + default => 'E_' . $severity, + }; +} + +function moduleCliInstallErrorPolicy(): void +{ + static $installed = false; + if ($installed) { + return; + } + $installed = true; + + error_reporting(E_ALL); + ini_set('display_errors', '0'); + + set_error_handler(static function ( + int $severity, + string $message, + string $file = '', + int $line = 0 + ): bool { + $handledSeverities = [ + E_WARNING, + E_NOTICE, + E_USER_WARNING, + E_USER_NOTICE, + E_DEPRECATED, + E_USER_DEPRECATED, + ]; + if (!in_array($severity, $handledSeverities, true)) { + return false; + } + + if ((error_reporting() & $severity) === 0) { + return true; + } + + $normalizedFile = str_replace('\\', '/', $file); + if (str_contains($normalizedFile, '/vendor/')) { + moduleCliIncrementVendorWarningsIgnored(); + return true; + } + + throw new RuntimeException(sprintf( + "First-party runtime warning (%s) in %s:%d: %s", + moduleCliSeverityName($severity), + $file, + $line, + $message + )); + }); +} + +function moduleCliBootstrapApp(): void +{ + static $booted = false; + if ($booted) { + return; + } + + moduleCliInstallErrorPolicy(); + + $projectRoot = moduleCliProjectRoot(); + chdir($projectRoot); + + require_once $projectRoot . '/vendor/autoload.php'; + require_once $projectRoot . '/config/config.php'; + require_once $projectRoot . '/lib/Support/helpers.php'; + $container = require $projectRoot . '/lib/App/registerContainer.php'; + setAppContainer($container); + + $booted = true; +} diff --git a/bin/module-migrate.php b/bin/module-migrate.php index 63bd3b6..c434716 100644 --- a/bin/module-migrate.php +++ b/bin/module-migrate.php @@ -17,102 +17,120 @@ declare(strict_types=1); -chdir(__DIR__ . '/..'); - -require 'vendor/autoload.php'; -require 'config/config.php'; -require 'lib/Support/helpers.php'; -$container = require 'lib/App/registerContainer.php'; -setAppContainer($container); - use MintyPHP\App\Module\ModuleRegistry; use MintyPHP\DB; -$exitCode = 0; -$totalApplied = 0; +require_once __DIR__ . '/module-cli-bootstrap.php'; -try { - $registry = app(ModuleRegistry::class); - $modules = $registry->getModules(); +function moduleMigrateRun(): int +{ + $warningsBefore = moduleCliVendorWarningsIgnoredTotal(); + moduleCliBootstrapApp(); - if (count($modules) === 0) { - fwrite(STDOUT, "module-migrate: no modules enabled, nothing to do.\n"); - exit(0); - } + $exitCode = 0; + $totalApplied = 0; - // Ensure tracking table exists (safe if already created by db/updates/ script) - DB::execute( - 'CREATE TABLE IF NOT EXISTS `module_migrations` ( - `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, - `module_id` VARCHAR(64) NOT NULL, - `filename` VARCHAR(255) NOT NULL, - `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - PRIMARY KEY (`id`), - UNIQUE KEY `uq_module_migration` (`module_id`, `filename`) - ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' - ); + try { + $registry = app(ModuleRegistry::class); + $modules = $registry->getModules(); + $hasModules = count($modules) > 0; - foreach ($modules as $manifest) { - $migrationsPath = $manifest->migrationsPath; - if ($migrationsPath === null || !is_dir($migrationsPath)) { - continue; - } - - // Discover SQL files - $files = glob($migrationsPath . '/*.sql'); - if ($files === false || count($files) === 0) { - continue; - } - sort($files); // alphabetical order - - // Get already-applied filenames - $applied = []; - $rows = DB::select('SELECT filename FROM module_migrations WHERE module_id = ?', [$manifest->id]); - foreach ($rows as $row) { - $applied[$row['filename']] = true; - } - - foreach ($files as $file) { - $filename = basename($file); - if (isset($applied[$filename])) { - continue; - } - - $sql = file_get_contents($file); - if ($sql === false || trim($sql) === '') { - fwrite(STDERR, sprintf("module-migrate: WARNING skipping empty file %s/%s\n", $manifest->id, $filename)); - continue; - } - - fwrite(STDOUT, sprintf("module-migrate: applying %s/%s ... ", $manifest->id, $filename)); - - // Execute migration (may contain multiple statements) - $statements = array_filter(array_map('trim', explode(';', $sql)), static fn (string $s): bool => $s !== ''); - foreach ($statements as $statement) { - DB::execute($statement); - } - - // Record as applied - DB::execute( - 'INSERT INTO module_migrations (module_id, filename) VALUES (?, ?)', - [$manifest->id, $filename] + if (!$hasModules) { + fwrite(STDOUT, "module-migrate: no modules enabled, nothing to do.\n"); + } else { + // Ensure tracking table exists (safe if already created by db/updates/ script) + DB::query( + 'CREATE TABLE IF NOT EXISTS `module_migrations` ( + `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `module_id` VARCHAR(64) NOT NULL, + `filename` VARCHAR(255) NOT NULL, + `applied_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uq_module_migration` (`module_id`, `filename`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' ); - - fwrite(STDOUT, "OK\n"); - $totalApplied++; } + + foreach ($modules as $manifest) { + $migrationsPath = $manifest->migrationsPath; + if ($migrationsPath === null || !is_dir($migrationsPath)) { + continue; + } + + // Discover SQL files + $files = glob($migrationsPath . '/*.sql'); + if ($files === false || count($files) === 0) { + continue; + } + sort($files); // alphabetical order + + // Get already-applied filenames + $applied = []; + $rows = DB::select('SELECT filename FROM module_migrations WHERE module_id = ?', [$manifest->id]); + foreach ($rows as $row) { + $applied[$row['filename']] = true; + } + + foreach ($files as $file) { + $filename = basename($file); + if (isset($applied[$filename])) { + continue; + } + + $sql = file_get_contents($file); + if ($sql === false || trim($sql) === '') { + fwrite(STDERR, sprintf("module-migrate: WARNING skipping empty file %s/%s\n", $manifest->id, $filename)); + continue; + } + + fwrite(STDOUT, sprintf("module-migrate: applying %s/%s ... ", $manifest->id, $filename)); + + // Execute migration inside a transaction so partial failures don't leave the DB inconsistent + DB::query('START TRANSACTION'); + try { + $statements = array_filter(array_map('trim', explode(';', $sql)), static fn (string $s): bool => $s !== ''); + foreach ($statements as $statement) { + DB::query($statement); + } + + // Record as applied + DB::query( + 'INSERT INTO module_migrations (module_id, filename) VALUES (?, ?)', + [$manifest->id, $filename] + ); + DB::query('COMMIT'); + } catch (\Throwable $migrationError) { + DB::query('ROLLBACK'); + throw new \RuntimeException( + sprintf("Migration %s/%s failed: %s", $manifest->id, $filename, $migrationError->getMessage()), + 0, + $migrationError + ); + } + + fwrite(STDOUT, "OK\n"); + $totalApplied++; + } + } + + if (!$hasModules) { + // Already reported above. + } elseif ($totalApplied === 0) { + fwrite(STDOUT, "module-migrate: all modules up to date, 0 new migrations.\n"); + } else { + fwrite(STDOUT, sprintf("module-migrate: applied %d migration(s).\n", $totalApplied)); + } + } catch (Throwable $exception) { + fwrite(STDERR, sprintf("module-migrate: FAILED: %s\n", $exception->getMessage())); + $exitCode = 1; } - if ($totalApplied === 0) { - fwrite(STDOUT, "module-migrate: all modules up to date, 0 new migrations.\n"); - } else { - fwrite(STDOUT, sprintf("module-migrate: applied %d migration(s).\n", $totalApplied)); - } -} catch (Throwable $exception) { - fwrite(STDERR, sprintf("module-migrate: FAILED: %s\n", $exception->getMessage())); - $exitCode = 1; -} finally { - DB::close(); + $vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore); + fwrite(STDOUT, sprintf("module-migrate: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored)); + + return $exitCode; } -exit($exitCode); +if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { + exit(moduleMigrateRun()); +} diff --git a/bin/module-permissions-sync.php b/bin/module-permissions-sync.php new file mode 100644 index 0000000..8ae03b8 --- /dev/null +++ b/bin/module-permissions-sync.php @@ -0,0 +1,55 @@ +#!/usr/bin/env php +sync(); + + fwrite(STDOUT, sprintf( + "module-permissions-sync: created=%d updated=%d unchanged=%d total=%d\n", + $result['created'], + $result['updated'], + $result['unchanged'], + $result['total'] + )); + } catch (Throwable $exception) { + fwrite(STDERR, sprintf("module-permissions-sync: FAILED: %s\n", $exception->getMessage())); + $exitCode = 1; + } + + $vendorWarningsIgnored = moduleCliVendorWarningsIgnoredSince($warningsBefore); + fwrite(STDOUT, sprintf("module-permissions-sync: vendor_warnings_ignored=%d\n", $vendorWarningsIgnored)); + + return $exitCode; +} + +if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { + exit(modulePermissionsSyncRun()); +} diff --git a/bin/module-runtime-sync.php b/bin/module-runtime-sync.php new file mode 100755 index 0000000..446e3f7 --- /dev/null +++ b/bin/module-runtime-sync.php @@ -0,0 +1,67 @@ +#!/usr/bin/env php + static fn (): int => moduleMigrateRun(), + 'permissions-sync' => static fn (): int => modulePermissionsSyncRun(), + 'build' => static fn (): int => moduleBuildRun(), + 'assets-sync' => static fn (): int => moduleAssetsSyncRun(), + ]; + + $statusByStep = []; + foreach (array_keys($steps) as $stepName) { + $statusByStep[$stepName] = 'pending'; + } + + $finalExitCode = 0; + foreach ($steps as $stepName => $runner) { + $stepWarningsBefore = moduleCliVendorWarningsIgnoredTotal(); + $stepExitCode = $runner(); + $stepWarnings = moduleCliVendorWarningsIgnoredSince($stepWarningsBefore); + $stepStatus = $stepExitCode === 0 ? 'ok' : 'failed'; + $statusByStep[$stepName] = $stepStatus; + + fwrite(STDOUT, sprintf( + "module-runtime-sync: step=%s status=%s exit_code=%d vendor_warnings_ignored=%d\n", + $stepName, + $stepStatus, + $stepExitCode, + $stepWarnings + )); + + if ($stepExitCode !== 0) { + $finalExitCode = $stepExitCode; + break; + } + } + + $totalVendorWarnings = moduleCliVendorWarningsIgnoredSince($warningsBeforeAll); + fwrite(STDOUT, sprintf( + "module-runtime-sync: summary migrate=%s permissions-sync=%s build=%s assets-sync=%s vendor_warnings_ignored=%d\n", + $statusByStep['migrate'], + $statusByStep['permissions-sync'], + $statusByStep['build'], + $statusByStep['assets-sync'], + $totalVendorWarnings + )); + + return $finalExitCode; +} + +if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) { + exit(moduleRuntimeSyncRun()); +} diff --git a/config/assets.php b/config/assets.php index 5d542b6..4b21a78 100644 --- a/config/assets.php +++ b/config/assets.php @@ -12,6 +12,7 @@ return [ 'shared' => [ 'css/components/app-badges.css', 'css/layout/app-shell.css', + 'css/components/app-dialog.css', 'css/components/app-blockquote.css', 'css/components/app-forms.css', 'css/components/app-flash.css', @@ -46,14 +47,10 @@ return [ 'css/vendor-overrides/editorjs.css', 'css/components/app-page-copy.css', 'css/layout/app-aside-icon-bar.css', - 'css/components/app-bookmark-form.css', ], 'login' => [ 'css/pages/app-login.css', ], - 'address-book' => [ - 'css/pages/address-book-view.css', - ], 'error' => [ 'css/pages/app-error.css', ], diff --git a/config/modules.php b/config/modules.php index 84bcff9..49816d7 100644 --- a/config/modules.php +++ b/config/modules.php @@ -4,10 +4,13 @@ * Module activation configuration. * * Modules listed here are loaded at boot time. The APP_ENABLED_MODULES env - * variable (comma-separated) overrides this list when set. + * variable controls overrides with deterministic semantics: + * - APP_ENABLED_MODULES not set: use this config list + * - APP_ENABLED_MODULES='': explicit no-modules mode + * - APP_ENABLED_MODULES='a,b': exact module list from env * * Each entry must match a directory name under modules/ containing a module.php manifest. */ return [ - 'enabled_modules' => ['addressbook'], + 'enabled_modules' => ['addressbook', 'bookmarks'], ]; diff --git a/config/router.php b/config/router.php index 5394858..161335e 100644 --- a/config/router.php +++ b/config/router.php @@ -9,26 +9,41 @@ if (!is_array($routeDefinitions) || !$routeDefinitions) { $routeDefinitions = is_file($routesFile) ? include $routesFile : []; } +$coreRoutePaths = []; if (is_array($routeDefinitions)) { foreach ($routeDefinitions as $route) { $path = trim((string) ($route['path'] ?? '')); $target = trim((string) ($route['target'] ?? '')); - if ($path !== '' && $target !== '') { - Router::addRoute($path, $target); + if ($path === '' || $target === '') { + continue; } + $coreRoutePaths[$path] = $target; + Router::addRoute($path, $target); } } // ── Module routes ──────────────────────────────────────────────────── -// Module routes are merged after core routes. The ModuleRegistry is -// available at this point because registerContainer.php runs before -// config/router.php is included. -if (function_exists('app') && app(ModuleRegistry::class) instanceof ModuleRegistry) { +// Module routes are merged after core routes. web/index.php includes this file +// only after container bootstrap, so ModuleRegistry is available here. +if (app(ModuleRegistry::class) instanceof ModuleRegistry) { $moduleRegistry = app(ModuleRegistry::class); + $modulePaths = []; foreach ($moduleRegistry->getRoutes() as $route) { $path = trim((string) $route['path']); $target = trim((string) $route['target']); + $moduleId = trim((string) $route['module_id']); if ($path !== '' && $target !== '') { + if (isset($coreRoutePaths[$path])) { + throw new RuntimeException( + "Module route path conflict: '{$path}' from module '{$moduleId}' collides with core route target '{$coreRoutePaths[$path]}'." + ); + } + if (isset($modulePaths[$path])) { + throw new RuntimeException( + "Module route path conflict: '{$path}' from module '{$moduleId}' collides with module '{$modulePaths[$path]}'." + ); + } + $modulePaths[$path] = $moduleId; Router::addRoute($path, $target); } } diff --git a/docs/index.md b/docs/index.md index b427f3b..ccd51e8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,6 @@ # Dokumentation -Letzte Aktualisierung: 2026-03-09 +Letzte Aktualisierung: 2026-03-18 Diese Dokumentation ist nach dem Diataxis-Modell in vier Quadranten gegliedert: Tutorials zum Lernen, How-to Guides zum Nachschlagen von Aufgaben, Explanation zum Verstehen und Reference zum Nachschlagen von Fakten. @@ -96,7 +96,9 @@ Diese Dokumentation ist nach dem Diataxis-Modell in vier Quadranten gegliedert: - `/docs/reference-codex-prompts.md` - Standard-Prompts fuer Skill-basierte Planung und Implementierung. - `/docs/reference-extension-readiness.md` - - Verbindliche Vorbedingungen fuer spaetere Module ohne Hook-/Lifecycle-Festlegung. + - Freigabe-Checkliste fuer weitere Module auf Basis des bestehenden Modul-Contracts. +- `/docs/reference-module-manifest-contract.md` + - Verbindlicher Runtime- und Manifest-Contract fuer Module (V1.1). - `/docs/reference-agents-overview.md` - Einstieg in das Rollenmodell und die Artefakte. - `/docs/reference-agents-roles.md` diff --git a/docs/reference-entwickler-checkliste.md b/docs/reference-entwickler-checkliste.md index c6be529..5b19435 100644 --- a/docs/reference-entwickler-checkliste.md +++ b/docs/reference-entwickler-checkliste.md @@ -1,6 +1,6 @@ # Entwickler-Checkliste -Letzte Aktualisierung: 2026-03-09 +Letzte Aktualisierung: 2026-03-18 ## Ziel @@ -41,6 +41,12 @@ Kurze Definition of Done vor Merge. - [ ] Keine inline `` + - Loginseiten: `` +- Runtime liest Config über `readPageConfig(...)` und mountet registrierte Komponenten pro Entrypoint: + - `web/js/app-init.js` (default) + - `web/js/app-login-init.js` (login) +- Komponenteninterner Contract: + - Init-Signatur: `initX(root, config)` + - Rueckgabe: `{ destroy() }` + - `destroy()` muss alle Listener/Timer/Observer abbauen. +- Hybrid Host-Policy: + - Host-gebundene UI-Komponenten nutzen explizite `data-app-component`-Hosts im Markup. + - Tabs sind host-gebunden und laufen ausschliesslich ueber `data-app-component="tabs"`. + - Globale Utilities ohne natuerliches Host-Element werden runtime-global (`scope: 'global'`) registriert, aber mit demselben Lifecycle-Contract. + - Root-Strict gilt fuer host-gebundene Komponenten: keine `document.querySelector`-Fallback-Pfade. + - Projektseitige Custom-Globals auf `window.*` sind verboten; Komponenten kommunizieren ueber Core-Channels. + +## UI Storage Contract + +- Persistente UI-States laufen fuer Runtime-Komponenten ueber `web/js/core/app-ui-storage.js`. +- Storage-Keys sind namespaced (`namespace:version:scope:*`) statt ungeordnet verteilt. +- Hard-Cut-Policy: keine Legacy-Key-Fallbacks und kein Legacy-Key-Sync in migrierten Runtime-Komponenten/Core-Modulen. Template für neue Komponenten: @@ -257,7 +285,7 @@ Für Create/Edit-Detailseiten gilt: - Hauptformular über `data-standard-detail-form="1"` explizit opt-in markieren. - Initialisierung läuft zentral über `initStandardDetailPage(...)` aus `web/js/core/app-detail-page-factory.js`. -- Auto-Init erfolgt über `web/js/components/app-standard-detail-page.js` (in `app-init.js` eingebunden). +- Runtime-Init erfolgt über `web/js/app-init.js` (Komponente `standard-detail-page`). Standardverhalten: diff --git a/docs/reference-konventionen.md b/docs/reference-konventionen.md index 1426271..f710e26 100644 --- a/docs/reference-konventionen.md +++ b/docs/reference-konventionen.md @@ -1,6 +1,6 @@ # Konventionen (kurz und verbindlich) -Letzte Aktualisierung: 2026-03-09 +Letzte Aktualisierung: 2026-03-17 ## 1) Code @@ -45,6 +45,8 @@ Letzte Aktualisierung: 2026-03-09 - Templates nur für Rendering. - UI-Texte über `t('...')`. - Strukturregeln in `/docs/reference-frontend-css.md` und `/docs/reference-frontend-javascript.md`. +- Runtime-Komponenten nur über Lifecycle-Contract (`init(root, config)` + `destroy()`), ohne komponenteneigenen Auto-Init. +- Host-gebundene Runtime-Komponenten über `data-app-component` markieren; nur echte Global-Utilities ohne Host als runtime-global registrieren. ## 6) Mindestchecks vor Merge diff --git a/docs/reference-module-manifest-contract.md b/docs/reference-module-manifest-contract.md new file mode 100644 index 0000000..2fb3f70 --- /dev/null +++ b/docs/reference-module-manifest-contract.md @@ -0,0 +1,99 @@ +# Module Manifest Contract (V1.1) + +Letzte Aktualisierung: 2026-03-18 + +## Ziel + +Verbindlicher Contract fuer `modules/*/module.php`, damit Module zur Runtime deterministisch geladen und validiert werden. + +## Status + +Dieses Dokument ist die kanonische (normative) Quelle fuer Manifest-, Runtime- und Guard-Regeln des Modul-Systems. +`/docs/reference-extension-readiness.md` referenziert diesen Contract und dupliziert keine Felddefinitionen. + +## Aktivierung (`APP_ENABLED_MODULES`) + +- nicht gesetzt: `config/modules.php` gilt. +- gesetzt und leer (`APP_ENABLED_MODULES=''`): keine Module. +- gesetzt mit Liste (`APP_ENABLED_MODULES='a,b'`): exakt diese Module. + +## `permissions` Contract + +`permissions` ist eine Objektliste: + +```php +'permissions' => [ + [ + 'key' => 'module.permission', + 'description' => 'Human readable text', + 'active' => true, // optional, default true + 'is_system' => true, // optional, default true + ], +], +``` + +- `key` und `description` sind Pflichtfelder. +- Runtime-Sync erfolgt ueber `php bin/module-permissions-sync.php`. +- `module-runtime-sync` fuehrt diesen Sync automatisch aus. + +## `scheduler_jobs` Contract + +`scheduler_jobs` ist eine Objektliste mit vollem Default-Contract: + +```php +'scheduler_jobs' => [ + [ + 'job_key' => 'module.job', + 'handler' => App\\Scheduler\\MyJobHandler::class, + 'label' => 'Job label', + 'description' => 'Job description', + 'default_enabled' => true, + 'default_timezone' => 'Europe/Berlin', + 'default_schedule_type' => 'daily', // hourly|daily|weekly + 'default_schedule_interval' => 1, + 'default_schedule_time' => '02:00', // required for daily/weekly + 'default_schedule_weekdays_csv' => null, // weekly: e.g. '1,3,5' + 'default_catchup_once' => true, + 'allowed_schedule_types' => ['daily', 'weekly'], + ], +], +``` + +- Manifest ist Source of Truth fuer Job-Metadaten und Defaults. +- Handler werden zur Laufzeit container-resolvable fuer `execute(...)` geladen. + +## `ui_slots` Contract (V1.2) + +Neue generische Slot-Typen: + +- `topbar.right_item`: `{ key, template, permission?, order }` +- `layout.body_end_template`: `{ key, template, permission?, order }` +- `layout.head_style`: `{ key, path, permission?, order }` +- `runtime.component`: `{ key, script, export?, selector?, scope?, config_path?, default_config?, phase?, permission?, order }` + +`runtime.component.phase` ist `early|default|late` (Default: `late`). + +Abgrenzung CSS: + +- `layout.head_style` = global auf allen Seiten. +- `asset_groups` = seiten-/kontextbezogen ueber `style_groups`. + +## Guard-Regeln + +- Route-Kollisionen sind fail-fast: + - Modul vs Modul auf `target` und `path`. + - Modul vs Core auf `path` beim Router-Boot. +- Layout-Provider duerfen reservierte Core-Keys nicht ueberschreiben (`moduleSlots`, `csrfKey`, `currentTenant`, ...). +- Layout-Provider-Top-Level-Keys muessen namespaced sein (`.`). + +## Standard-Runbook + +`php bin/module-runtime-sync.php` fuehrt in fester Reihenfolge aus: + +1. `module-migrate` +2. `module-permissions-sync` +3. `module-build` +4. `module-assets-sync` + +Der Output enthaelt eine deterministische Step-Summary (`migrate`, `permissions-sync`, `build`, `assets-sync`) inklusive `vendor_warnings_ignored=`. +First-party Warnings/Notices/Deprecations gelten als Fehler (fail-fast). diff --git a/lib/App/AppContainer.php b/lib/App/AppContainer.php index b62c11c..e21b517 100644 --- a/lib/App/AppContainer.php +++ b/lib/App/AppContainer.php @@ -14,8 +14,13 @@ final class AppContainer /** @var array */ 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; + } } diff --git a/lib/App/Container/Registrars/AppServicesRegistrar.php b/lib/App/Container/Registrars/AppServicesRegistrar.php index 88dbcb3..5a8066e 100644 --- a/lib/App/Container/Registrars/AppServicesRegistrar.php +++ b/lib/App/Container/Registrars/AppServicesRegistrar.php @@ -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( diff --git a/lib/App/Container/Registrars/ServiceFactoryRegistrar.php b/lib/App/Container/Registrars/ServiceFactoryRegistrar.php index 3d952d4..486c670 100644 --- a/lib/App/Container/Registrars/ServiceFactoryRegistrar.php +++ b/lib/App/Container/Registrars/ServiceFactoryRegistrar.php @@ -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 )); } } diff --git a/lib/App/Container/Registrars/UserRegistrar.php b/lib/App/Container/Registrars/UserRegistrar.php index a67e5b0..02b9b9b 100644 --- a/lib/App/Container/Registrars/UserRegistrar.php +++ b/lib/App/Container/Registrars/UserRegistrar.php @@ -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()); diff --git a/lib/App/Module/Contracts/LayoutContextProvider.php b/lib/App/Module/Contracts/LayoutContextProvider.php index 83d55a0..0b1bdc0 100644 --- a/lib/App/Module/Contracts/LayoutContextProvider.php +++ b/lib/App/Module/Contracts/LayoutContextProvider.php @@ -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 { diff --git a/lib/App/Module/Contracts/SearchResourceProvider.php b/lib/App/Module/Contracts/SearchResourceProvider.php index b65a89c..12ae3f6 100644 --- a/lib/App/Module/Contracts/SearchResourceProvider.php +++ b/lib/App/Module/Contracts/SearchResourceProvider.php @@ -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/lib and use the same + * MintyPHP namespace root as core classes. + */ +final class ModuleAutoloader +{ + /** @var list */ + private static array $moduleLibDirs = []; + + private static bool $registered = false; + + /** + * @param list $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; + } + } + } +} diff --git a/lib/App/Module/ModuleManifest.php b/lib/App/Module/ModuleManifest.php index 1a314bd..e8ddb6b 100644 --- a/lib/App/Module/ModuleManifest.php +++ b/lib/App/Module/ModuleManifest.php @@ -35,7 +35,22 @@ final class ModuleManifest /** @var array> Asset group name → file list */ public readonly array $assetGroups; - /** @var list */ + /** + * @var list + * }> + */ public readonly array $schedulerJobs; /** @var list LayoutContextProvider FQCNs */ @@ -44,9 +59,22 @@ final class ModuleManifest /** @var list SessionProvider FQCNs */ public readonly array $sessionProviders; - /** @var list Permission keys (e.g. 'address_book.view') */ + /** + * @var list + */ public readonly array $permissions; + /** @var list AuthorizationPolicyInterface FQCNs */ + public readonly array $authorizationPolicies; + + /** @var array 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 + */ + 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 + * }> + */ + 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 + */ + 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)); + } } diff --git a/lib/App/Module/ModulePermissionSynchronizer.php b/lib/App/Module/ModulePermissionSynchronizer.php new file mode 100644 index 0000000..97cf003 --- /dev/null +++ b/lib/App/Module/ModulePermissionSynchronizer.php @@ -0,0 +1,91 @@ +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 $existing + * @param array $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); + } +} diff --git a/lib/App/Module/ModuleRegistry.php b/lib/App/Module/ModuleRegistry.php index 2e37b09..1a90b4f 100644 --- a/lib/App/Module/ModuleRegistry.php +++ b/lib/App/Module/ModuleRegistry.php @@ -15,10 +15,21 @@ use RuntimeException; */ final class ModuleRegistry { + /** @var array> */ + 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 keyed by module id */ private array $modules = []; - /** @var array merged routes */ + /** @var array merged routes */ private array $mergedRoutes = []; /** @var list merged public paths */ @@ -42,12 +53,53 @@ final class ModuleRegistry /** @var list merged session provider FQCNs */ private array $mergedSessionProviders = []; - /** @var list merged permissions */ + /** + * @var list merged permissions + */ private array $mergedPermissions = []; - /** @var list */ + /** @var list merged authorization policy FQCNs */ + private array $mergedAuthorizationPolicies = []; + + /** @var array merged layout capabilities: UI-key → ability */ + private array $mergedLayoutCapabilities = []; + + /** + * @var list, + * module_id: string + * }> + */ private array $mergedSchedulerJobs = []; + /** @var array route target => owning module id */ + private array $routeTargetOwners = []; + + /** @var array route path => owning module id */ + private array $routePathOwners = []; + + /** @var array permission key => owning module id */ + private array $permissionOwners = []; + + /** @var array 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//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 + */ + 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 $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 */ + /** @return array */ public function getRoutes(): array { return $this->mergedRoutes; @@ -295,13 +637,52 @@ final class ModuleRegistry return $this->mergedSessionProviders; } - /** @return list */ + /** + * @return list + */ public function getPermissions(): array { return $this->mergedPermissions; } - /** @return list */ + /** @return list */ + public function getPermissionKeys(): array + { + return array_values(array_map( + static fn (array $permission): string => (string) $permission['key'], + $this->mergedPermissions + )); + } + + /** @return list */ + public function getAuthorizationPolicies(): array + { + return $this->mergedAuthorizationPolicies; + } + + /** @return array UI-capability-key → ability-string */ + public function getLayoutCapabilities(): array + { + return $this->mergedLayoutCapabilities; + } + + /** + * @return list, + * module_id: string + * }> + */ public function getSchedulerJobs(): array { return $this->mergedSchedulerJobs; diff --git a/lib/App/Module/ModuleRuntimeAssetPublisher.php b/lib/App/Module/ModuleRuntimeAssetPublisher.php new file mode 100644 index 0000000..6a426d2 --- /dev/null +++ b/lib/App/Module/ModuleRuntimeAssetPublisher.php @@ -0,0 +1,178 @@ + $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}'"); + } + } + } +} diff --git a/lib/App/Module/ModuleRuntimePageBuilder.php b/lib/App/Module/ModuleRuntimePageBuilder.php new file mode 100644 index 0000000..2a23618 --- /dev/null +++ b/lib/App/Module/ModuleRuntimePageBuilder.php @@ -0,0 +1,135 @@ + $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); + } + } +} diff --git a/lib/App/registerContainer.php b/lib/App/registerContainer.php index b4121bb..86ba7ca 100644 --- a/lib/App/registerContainer.php +++ b/lib/App/registerContainer.php @@ -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}"); diff --git a/lib/Service/Access/AccessPolicyFactory.php b/lib/Service/Access/AccessPolicyFactory.php index 99bcddf..494501a 100644 --- a/lib/Service/Access/AccessPolicyFactory.php +++ b/lib/Service/Access/AccessPolicyFactory.php @@ -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; } } diff --git a/lib/Service/Access/OperationsAuthorizationPolicy.php b/lib/Service/Access/OperationsAuthorizationPolicy.php index e62c8fc..54706bc 100644 --- a/lib/Service/Access/OperationsAuthorizationPolicy.php +++ b/lib/Service/Access/OperationsAuthorizationPolicy.php @@ -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), diff --git a/lib/Service/Access/UiAccessService.php b/lib/Service/Access/UiAccessService.php index 479f881..d970f23 100644 --- a/lib/Service/Access/UiAccessService.php +++ b/lib/Service/Access/UiAccessService.php @@ -80,11 +80,28 @@ final class UiAccessService } /** + * Resolve layout capabilities from Core + module-contributed capabilities. + * * @return array */ 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); } /** diff --git a/lib/Service/Access/UiCapabilityMap.php b/lib/Service/Access/UiCapabilityMap.php index cd80574..92f80b6 100644 --- a/lib/Service/Access/UiCapabilityMap.php +++ b/lib/Service/Access/UiCapabilityMap.php @@ -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 */ 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, ]; /** diff --git a/lib/Service/Auth/AuthService.php b/lib/Service/Auth/AuthService.php index 94c9023..a14c0ee 100644 --- a/lib/Service/Auth/AuthService.php +++ b/lib/Service/Auth/AuthService.php @@ -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(); + } } diff --git a/lib/Service/Auth/AuthServicesFactory.php b/lib/Service/Auth/AuthServicesFactory.php index 7637a5f..a6ac440 100644 --- a/lib/Service/Auth/AuthServicesFactory.php +++ b/lib/Service/Auth/AuthServicesFactory.php @@ -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 ); } } diff --git a/lib/Service/Auth/AuthSessionTenantContextService.php b/lib/Service/Auth/AuthSessionTenantContextService.php index 9d7cd65..f87afe0 100644 --- a/lib/Service/Auth/AuthSessionTenantContextService.php +++ b/lib/Service/Auth/AuthSessionTenantContextService.php @@ -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 + */ + 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; } } diff --git a/lib/Service/Auth/RememberMeService.php b/lib/Service/Auth/RememberMeService.php index 54f3a3f..1709d5c 100644 --- a/lib/Service/Auth/RememberMeService.php +++ b/lib/Service/Auth/RememberMeService.php @@ -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; } diff --git a/lib/Service/CustomField/UserCustomFieldValueService.php b/lib/Service/CustomField/UserCustomFieldValueService.php index e69a177..412f6d5 100644 --- a/lib/Service/CustomField/UserCustomFieldValueService.php +++ b/lib/Service/CustomField/UserCustomFieldValueService.php @@ -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 $query URL query parameters + * @return array{definitions: list>, filters: array>, query: array} + */ + public function extractCustomFieldFilterSpec(array $query, int $currentUserId): array { $tenantIds = $this->userScopeGateway()->getUserTenantIds($currentUserId); $definitions = TenantCustomFieldDefinitionRepository::listFilterableByTenantIds($tenantIds); diff --git a/lib/Service/Scheduler/ScheduledJobRegistry.php b/lib/Service/Scheduler/ScheduledJobRegistry.php index 9a29a69..6448cac 100644 --- a/lib/Service/Scheduler/ScheduledJobRegistry.php +++ b/lib/Service/Scheduler/ScheduledJobRegistry.php @@ -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 - */ + /** @var array */ private array $handlers; + /** + * @var array + * }> + */ + 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 + * } $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 + * } + */ + 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; } } diff --git a/lib/Service/Scheduler/SchedulerServicesFactory.php b/lib/Service/Scheduler/SchedulerServicesFactory.php index 6935615..4378ae2 100644 --- a/lib/Service/Scheduler/SchedulerServicesFactory.php +++ b/lib/Service/Scheduler/SchedulerServicesFactory.php @@ -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 ); } diff --git a/lib/Service/Search/SearchDataService.php b/lib/Service/Search/SearchDataService.php index 5dc2f91..02da5fc 100644 --- a/lib/Service/Search/SearchDataService.php +++ b/lib/Service/Search/SearchDataService.php @@ -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'], diff --git a/lib/Support/helpers/app.php b/lib/Support/helpers/app.php index 374be60..ff73d66 100644 --- a/lib/Support/helpers/app.php +++ b/lib/Support/helpers/app.php @@ -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 $rawQuery - * @return array> + * @return list */ -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 $layoutNav + * @param array $providerData + * @return array + */ +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. '.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; diff --git a/lib/Support/helpers/ui.php b/lib/Support/helpers/ui.php index 3e5505d..ee757b0 100644 --- a/lib/Support/helpers/ui.php +++ b/lib/Support/helpers/ui.php @@ -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 = []; diff --git a/pages/admin/departments/_form.phtml b/pages/admin/departments/_form.phtml index 0efa95a..ec7e9b5 100644 --- a/pages/admin/departments/_form.phtml +++ b/pages/admin/departments/_form.phtml @@ -30,7 +30,7 @@ $disabledAttr = $isReadOnly ? 'disabled' : ''; ?>
-
> +
>
diff --git a/pages/admin/permissions/_form.phtml b/pages/admin/permissions/_form.phtml index 6e9202f..7dbd3f5 100644 --- a/pages/admin/permissions/_form.phtml +++ b/pages/admin/permissions/_form.phtml @@ -32,7 +32,7 @@ $keyReadonlyAttr = ($isReadOnly || !empty($values['is_system'])) ? 'readonly' : ?> -
> +
>
diff --git a/pages/admin/roles/_form.phtml b/pages/admin/roles/_form.phtml index ebdcca5..5742d18 100644 --- a/pages/admin/roles/_form.phtml +++ b/pages/admin/roles/_form.phtml @@ -31,7 +31,7 @@ $selectedAssignableRoleIds = is_array($selectedAssignableRoleIds ?? null) ? $sel ?> -
> +
>
diff --git a/pages/admin/settings/index(default).phtml b/pages/admin/settings/index(default).phtml index dd31f64..d3ae71b 100644 --- a/pages/admin/settings/index(default).phtml +++ b/pages/admin/settings/index(default).phtml @@ -128,7 +128,7 @@ $disabledAttr = $isReadOnly ? 'disabled' : ''; -
+
diff --git a/pages/admin/stats/index(default).phtml b/pages/admin/stats/index(default).phtml index 9471a6f..aa1a471 100644 --- a/pages/admin/stats/index(default).phtml +++ b/pages/admin/stats/index(default).phtml @@ -123,7 +123,7 @@ require templatePath('partials/app-breadcrumb.phtml');

-
+
diff --git a/pages/admin/users/_form.phtml b/pages/admin/users/_form.phtml index d6db5e9..ee4293d 100644 --- a/pages/admin/users/_form.phtml +++ b/pages/admin/users/_form.phtml @@ -122,7 +122,7 @@ foreach ($tenants as $tenant) { ?> -
> +
>
diff --git a/pages/admin/users/edit($id).php b/pages/admin/users/edit($id).php index 1b47b37..95486c1 100644 --- a/pages/admin/users/edit($id).php +++ b/pages/admin/users/edit($id).php @@ -68,7 +68,6 @@ $canEditAssignments = (bool) ($capabilities['can_edit_assignments'] ?? false); $canManageTenants = (bool) ($capabilities['can_manage_tenants'] ?? false); $canEditCustomFieldValues = (bool) ($capabilities['can_edit_custom_field_values'] ?? false); $canManageApiTokens = (bool) ($capabilities['can_manage_api_tokens'] ?? false); -$canViewAddressBook = (bool) ($capabilities['can_view_address_book'] ?? false); $canViewUserMeta = (bool) ($capabilities['can_view_user_meta'] ?? false); $canViewUserAudit = (bool) ($capabilities['can_view_user_audit'] ?? false); $canAccessPdf = (bool) ($capabilities['can_access_pdf'] ?? false); diff --git a/pages/admin/users/edit(default).phtml b/pages/admin/users/edit(default).phtml index a767d3a..e770c3b 100644 --- a/pages/admin/users/edit(default).phtml +++ b/pages/admin/users/edit(default).phtml @@ -16,7 +16,6 @@ $currentUserId = (int) ($_SESSION['user']['id'] ?? 0); $isOwnAccount = $currentUserId > 0 && $currentUserId === (int) ($values['id'] ?? 0); $titleText = $isOwnAccount ? t('My account') : t('Edit user'); $canViewUsers = !empty($canViewUsers); -$canViewAddressBook = !empty($canViewAddressBook); $canViewUserMeta = !empty($canViewUserMeta); $canViewUserAudit = !empty($canViewUserAudit); $canAccessPdf = !empty($canAccessPdf); @@ -58,11 +57,56 @@ if ($lastLoginAt !== '') { (string) ($values['id'] ?? ''), + '{user_uuid}' => (string) ($values['uuid'] ?? ''), + ]; + foreach ($moduleAsideActionSlots as $slotAction) { + if (!is_array($slotAction)) { + continue; + } + $slotPermission = trim((string) ($slotAction['permission'] ?? '')); + if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) { + continue; + } + + $slotLabel = trim((string) ($slotAction['label'] ?? '')); + if ($slotLabel === '') { + continue; + } + $slotType = trim((string) ($slotAction['type'] ?? 'link')); + if ($slotType === 'form') { + $slotFormAction = trim((string) ($slotAction['action'] ?? '')); + if ($slotFormAction === '') { + continue; + } + $slotFormAction = strtr($slotFormAction, $slotContextMap); + if (!str_starts_with($slotFormAction, '/') && !str_starts_with($slotFormAction, 'http://') && !str_starts_with($slotFormAction, 'https://')) { + $slotFormAction = lurl($slotFormAction); + } + $asideActions[] = [ + 'type' => 'form', + 'method' => strtoupper(trim((string) ($slotAction['method'] ?? 'POST'))), + 'action' => $slotFormAction, + 'label' => t($slotLabel), + ]; + continue; + } + + $slotHref = trim((string) ($slotAction['href'] ?? '')); + if ($slotHref === '') { + continue; + } + $slotHref = strtr($slotHref, $slotContextMap); + if (!str_starts_with($slotHref, '/') && !str_starts_with($slotHref, 'http://') && !str_starts_with($slotHref, 'https://')) { + $slotHref = lurl($slotHref); + } $asideActions[] = [ 'type' => 'link', - 'href' => lurl('address-book/view/' . ($values['uuid'] ?? '')), - 'label' => t('View in address book'), + 'href' => $slotHref, + 'label' => t($slotLabel), ]; } if ($canEditUser) { diff --git a/pages/admin/users/switch-tenant().php b/pages/admin/users/switch-tenant().php index c8160df..332b22c 100644 --- a/pages/admin/users/switch-tenant().php +++ b/pages/admin/users/switch-tenant().php @@ -77,12 +77,8 @@ if (isset($session['user']) && is_array($session['user'])) { $sessionStore->set('user', $userSession); } -// Update session with full tenant data -$sessionStore->set('current_tenant', $result['tenant'] ?? null); - -// Reload available tenants and departments -$sessionStore->set('available_tenants', $userTenantContextService->getAvailableTenants($userId)); -$sessionStore->set('available_departments_by_tenant', $userTenantContextService->getAvailableDepartmentsByTenant($userId)); +// Reload full tenant + module session context in one place. +app(\MintyPHP\Service\Auth\AuthService::class)->loadTenantDataIntoSession($userId); if (Request::wantsJson()) { Router::json([ diff --git a/pages/page/index(default).phtml b/pages/page/index(default).phtml index 0ed1f0d..8f1d8a8 100644 --- a/pages/page/index(default).phtml +++ b/pages/page/index(default).phtml @@ -40,7 +40,14 @@ - + + diff --git a/pages/public-page/index(page).phtml b/pages/public-page/index(page).phtml index d56f382..0c519e7 100644 --- a/pages/public-page/index(page).phtml +++ b/pages/public-page/index(page).phtml @@ -40,7 +40,14 @@ - + + diff --git a/phpstan.neon b/phpstan.neon index 6c44bc4..b55ce8f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -6,6 +6,7 @@ parameters: paths: - config - lib + - modules - pages - tests fileExtensions: diff --git a/phpunit.xml b/phpunit.xml index 963e73e..4c36339 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -6,6 +6,7 @@ tests + modules/*/tests diff --git a/templates/default.phtml b/templates/default.phtml index dafb17e..0889bb3 100644 --- a/templates/default.phtml +++ b/templates/default.phtml @@ -13,6 +13,8 @@ $csrfToken = (string) ($_SESSION[$csrfKey] ?? ''); $isLoggedIn = !empty($user['id']); $sessionIdleMinutes = (int) (appSetting('session_idle_timeout_minutes') ?? 30); $sessionIdleSeconds = max($sessionIdleMinutes, 5) * 60; +$sessionPingUrl = lurl('admin/session-ping/data'); +$sessionLogoutUrl = lurl('logout'); $telemetryEnabled = frontendTelemetryEnabled(); $telemetrySampleRate = frontendTelemetrySampleRate(); $telemetryAllowedEvents = implode(',', frontendTelemetryAllowedEvents()); @@ -20,6 +22,118 @@ $telemetryUrl = lurl('admin/frontend-telemetry/ingest'); $pageRequestId = RequestContext::id(); $viewAuth = is_array($viewAuth ?? null) ? $viewAuth : []; $viewAuth['layout'] = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : []; +$layoutAuth = $viewAuth['layout']; +$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : []; +$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : []; +$moduleHeadStyleSlots = is_array($moduleSlots['layout.head_style'] ?? null) ? $moduleSlots['layout.head_style'] : []; +$moduleBodyEndTemplateSlots = is_array($moduleSlots['layout.body_end_template'] ?? null) ? $moduleSlots['layout.body_end_template'] : []; +$moduleRuntimeComponentSlots = is_array($moduleSlots['runtime.component'] ?? null) ? $moduleSlots['runtime.component'] : []; + +/** + * @param array $value + */ +$isAssocArray = static function (array $value): bool { + if ($value === []) { + return false; + } + return array_keys($value) !== range(0, count($value) - 1); +}; + +/** + * @param array $target + * @param array $source + * @return array + */ +$strictMergeConfig = static function (array $target, array $source, string $context, callable $isAssocArray) use (&$strictMergeConfig): array { + foreach ($source as $key => $sourceValue) { + if (!is_string($key) || trim($key) === '') { + throw new RuntimeException("Invalid default_config key in {$context}."); + } + if (!array_key_exists($key, $target)) { + $target[$key] = $sourceValue; + continue; + } + + $targetValue = $target[$key]; + if (is_array($targetValue) && is_array($sourceValue) && $isAssocArray($targetValue) && $isAssocArray($sourceValue)) { + $target[$key] = $strictMergeConfig($targetValue, $sourceValue, $context . '.' . $key, $isAssocArray); + continue; + } + + if ($targetValue !== $sourceValue) { + throw new RuntimeException("default_config collision at {$context}.{$key}."); + } + } + + return $target; +}; + +/** + * @param array $root + * @param array $value + */ +$mergeConfigAtPath = static function (array &$root, string $path, array $value, string $context, callable $strictMergeConfig): void { + $segments = array_values(array_filter(array_map('trim', explode('.', $path)), static fn (string $item): bool => $item !== '')); + if ($segments === []) { + throw new RuntimeException("Invalid config_path in {$context}: '{$path}'"); + } + + $cursor = &$root; + $fullPath = ''; + foreach ($segments as $segment) { + if (!preg_match('/^[a-zA-Z_][a-zA-Z0-9_]*$/', $segment)) { + throw new RuntimeException("Invalid config_path segment '{$segment}' in {$context}."); + } + $fullPath = $fullPath === '' ? $segment : $fullPath . '.' . $segment; + if (!array_key_exists($segment, $cursor)) { + $cursor[$segment] = []; + } + if (!is_array($cursor[$segment])) { + throw new RuntimeException("Config path '{$fullPath}' in {$context} is not an object."); + } + $cursor = &$cursor[$segment]; + } + + $cursor = $strictMergeConfig( + is_array($cursor) ? $cursor : [], + $value, + $context, + static fn (array $raw): bool => $raw !== [] && array_keys($raw) !== range(0, count($raw) - 1) + ); +}; + +$normalizeConfigPathSegment = static function (string $raw): string { + $segment = preg_replace('/[^a-zA-Z0-9_]+/', '_', strtolower(trim($raw))) ?? ''; + $segment = trim($segment, '_'); + if ($segment === '') { + return 'item'; + } + if (preg_match('/^[0-9]/', $segment)) { + return 'n_' . $segment; + } + return $segment; +}; + +$moduleHeadStylePaths = []; +$moduleHeadSeen = []; +foreach ($moduleHeadStyleSlots as $slot) { + if (!is_array($slot)) { + continue; + } + $slotPermission = trim((string) ($slot['permission'] ?? '')); + if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) { + continue; + } + $path = ltrim(trim((string) ($slot['path'] ?? '')), '/'); + if ($path === '') { + continue; + } + if (isset($moduleHeadSeen[$path])) { + continue; + } + $moduleHeadSeen[$path] = true; + $moduleHeadStylePaths[] = $path; +} $pageTitle = $defaultTitle; ob_start(); Buffer::get('title'); @@ -27,6 +141,146 @@ $bufferTitle = trim((string) ob_get_clean()); if ($bufferTitle !== '') { $pageTitle = $bufferTitle . ' - ' . $defaultTitle; } +$componentPageConfig = [ + 'components' => [ + 'confirmActions' => [], + 'flashAutoDismiss' => [ + 'selector' => '.notice[data-flash-timeout]', + ], + 'passwordHints' => [], + 'copyBadge' => [], + 'multiSelect' => [ + 'selector' => '[data-multi-select]', + ], + 'navHistory' => [], + 'detailsState' => [ + 'selector' => '[data-details-storage]', + 'storageNamespace' => 'app-ui', + 'storageVersion' => 'v1', + 'storageScope' => 'details-open', + ], + 'settingsTelemetry' => [], + 'autoSubmit' => [ + 'selector' => '[data-auto-submit]', + ], + 'sidebarToggle' => [ + 'buttonSelector' => '[data-sidebar-toggle]', + 'asideSelector' => '#app-sidebar', + 'containerSelector' => '.app-container', + 'storageNamespace' => 'app-ui', + 'storageVersion' => 'v1', + 'storageScope' => 'sidebar', + 'collapsedStorageKey' => 'sidebar-collapsed', + 'hiddenStorageKey' => 'sidebar-hidden', + ], + 'asidePanels' => [ + 'barSelector' => '.aside-icon-bar', + 'panelSelector' => '.app-sidebar-panel', + 'titleSelector' => '.app-sidebar-title', + 'toolsSelector' => '[data-aside-tools]', + 'storageNamespace' => 'app-ui', + 'storageVersion' => 'v1', + 'storageScope' => 'aside-panels', + 'detailsStorageScope' => 'aside-details', + ], + 'detailsAsideToggle' => [ + 'buttonSelector' => '#toggle-main-content-aside', + 'asideSelector' => '#app-details-aside-section', + 'containerSelector' => '.app-details-container', + 'storageNamespace' => 'app-ui', + 'storageVersion' => 'v1', + 'storageScope' => 'details-aside', + ], + 'themeControls' => [ + 'menuSelector' => '[data-theme-menu]', + 'toggleSelector' => '[data-theme-toggle]', + ], + 'contrastToggle' => [ + 'selector' => '[data-contrast-toggle]', + 'storageNamespace' => 'app-ui', + 'storageVersion' => 'v1', + 'storageScope' => 'contrast', + 'storageKey' => 'contrast-mode', + ], + 'tenantSwitcher' => [ + 'rootSelector' => '[data-tenant-switcher]', + 'linkSelector' => '[data-switch-tenant]', + ], + 'colorDefaultToggle' => [], + 'customFieldOptionsToggle' => [], + 'tenantSsoToggle' => [], + 'standardDetailPage' => [], + 'globalSearch' => [ + 'inputSelector' => '#side-search', + 'resultsSelector' => '[data-global-search-results]', + 'appBase' => localeBase(), + ], + 'tabs' => [ + 'selector' => '[data-app-component="tabs"]', + 'storageNamespace' => 'app-ui', + 'storageVersion' => 'v1', + 'storageScope' => 'tabs', + ], + 'sessionWarning' => [ + 'enabled' => $isLoggedIn, + 'selector' => '[data-app-session-warning-dialog]', + 'idleSeconds' => $sessionIdleSeconds, + 'pingUrl' => $sessionPingUrl, + 'logoutUrl' => $sessionLogoutUrl, + 'csrfKey' => $csrfKey, + 'csrfToken' => $csrfToken, + ], + 'fslightboxRefresh' => [], + ], +]; +$moduleRuntimeComponents = []; +foreach ($moduleRuntimeComponentSlots as $slot) { + if (!is_array($slot)) { + continue; + } + + $slotPermission = trim((string) ($slot['permission'] ?? '')); + if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) { + continue; + } + + $slotKey = trim((string) ($slot['key'] ?? '')); + $moduleId = trim((string) ($slot['module_id'] ?? '')); + $scriptPath = ltrim(trim((string) ($slot['script'] ?? '')), '/'); + if ($slotKey === '' || $moduleId === '' || $scriptPath === '') { + continue; + } + + $phase = strtolower(trim((string) ($slot['phase'] ?? 'late'))); + if ($phase !== 'early' && $phase !== 'default' && $phase !== 'late') { + throw new RuntimeException("Invalid runtime.component phase '{$phase}' for slot '{$slotKey}'."); + } + + $configPath = trim((string) ($slot['config_path'] ?? '')); + if ($configPath === '') { + $configPath = 'components.modules.' . $normalizeConfigPathSegment($moduleId) . '.' . $normalizeConfigPathSegment($slotKey); + } + + $defaultConfig = is_array($slot['default_config'] ?? null) ? $slot['default_config'] : []; + if ($defaultConfig !== []) { + $mergeConfigAtPath($componentPageConfig, $configPath, $defaultConfig, "runtime.component '{$slotKey}'", $strictMergeConfig); + } + + $moduleRuntimeComponents[] = [ + 'key' => $slotKey, + 'moduleId' => $moduleId, + 'componentName' => $moduleId . '.' . $slotKey, + 'script' => assetVersion($scriptPath), + 'export' => trim((string) ($slot['export'] ?? '')), + 'selector' => trim((string) ($slot['selector'] ?? '')), + 'scope' => trim((string) ($slot['scope'] ?? '')), + 'configPath' => $configPath, + 'phase' => $phase, + 'order' => (int) ($slot['order'] ?? 100), + ]; +} + +$componentPageConfig['moduleRuntimeComponents'] = $moduleRuntimeComponents; ?> @@ -43,8 +297,8 @@ if ($bufferTitle !== '') { data-csrf-token="" data-session-idle-seconds="" - data-session-ping-url="" - data-session-logout-url="" + data-session-ping-url="" + data-session-logout-url="" class="no-js" style="" @@ -62,6 +316,7 @@ if ($bufferTitle !== '') { + @@ -86,10 +341,24 @@ if ($bufferTitle !== '') { - - - + + + + diff --git a/templates/login.phtml b/templates/login.phtml index 550fa40..8d460f5 100644 --- a/templates/login.phtml +++ b/templates/login.phtml @@ -12,6 +12,25 @@ $bufferTitle = trim((string) ob_get_clean()); if ($bufferTitle !== '') { $pageTitle = $bufferTitle . ' - ' . $defaultTitle; } +$loginComponentConfig = [ + 'components' => [ + 'flashAutoDismiss' => [ + 'selector' => '.notice[data-flash-timeout]', + ], + 'passwordHints' => [ + 'selector' => '[data-password-hints]', + ], + 'passwordToggle' => [ + 'selector' => 'input[type="password"]', + ], + 'loginErrorFocus' => [ + 'selector' => '.notice[data-variant="error"][role="alert"][tabindex="-1"]', + ], + 'loginSubmitLock' => [ + 'selector' => 'form[data-login-submit-lock="1"]', + ], + ], +]; ?> @@ -48,6 +67,7 @@ if ($bufferTitle !== '') {
+ diff --git a/templates/partials/app-flash.phtml b/templates/partials/app-flash.phtml index 7550f47..91bef73 100644 --- a/templates/partials/app-flash.phtml +++ b/templates/partials/app-flash.phtml @@ -18,7 +18,7 @@ $timeouts = [ ]; ?> -
+
diff --git a/templates/partials/app-main-aside-icon-bar.phtml b/templates/partials/app-main-aside-icon-bar.phtml index e4a510e..b068a61 100644 --- a/templates/partials/app-main-aside-icon-bar.phtml +++ b/templates/partials/app-main-aside-icon-bar.phtml @@ -51,14 +51,6 @@ $hasAdminPanel = layoutHasAdminPanel($layoutAuth); -
  • - -
  • - - -
  • - > - - 0 - -
      -
    • -
    • @@ -482,246 +471,49 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
      • + true, + 'tenants' => true, + 'departments' => true, + 'roles' => true, + 'permissions' => true, + 'scheduled-jobs' => true, + 'pages' => true, + 'docs' => true, + 'hotkeys' => true, + 'mail-log' => true, + 'api-audit' => true, + 'system-audit' => true, + ]; + foreach ($moduleSearchSlots as $searchSlot): + if (!is_array($searchSlot)) { continue; } + $slotKey = trim((string) ($searchSlot['key'] ?? '')); + if ($slotKey === '' || isset($coreSearchKeys[$slotKey])) { continue; } + $slotPermission = trim((string) ($searchSlot['permission'] ?? '')); + if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) { continue; } + $slotLabel = trim((string) ($searchSlot['label'] ?? '')); + if ($slotLabel === '') { continue; } + $slotBase = trim((string) ($searchSlot['base_url'] ?? '')); + if ($slotBase === '') { continue; } + $slotHref = $slotBase; + $slotActive = ['class' => '', 'aria' => '']; + if (!str_starts_with($slotBase, '/') && !str_starts_with($slotBase, 'http://') && !str_starts_with($slotBase, 'https://')) { + $slotHref = lurl($slotBase); + $slotActive = navActive($slotBase, true); + } + ?> +
      • + > + + 0 + +
          +
        • +
          - - [], 'ungrouped' => []]; - $bookmarkGroups = is_array($bookmarkData['groups'] ?? null) ? $bookmarkData['groups'] : []; - $bookmarkUngrouped = is_array($bookmarkData['ungrouped'] ?? null) ? $bookmarkData['ungrouped'] : []; - $currentPath = BookmarkUrlNormalizer::canonicalizeRequestUri((string) ($_SERVER['REQUEST_URI'] ?? ''), localeBase()); - ?> -
          diff --git a/templates/partials/app-session-warning-dialog.phtml b/templates/partials/app-session-warning-dialog.phtml index 8b37f85..fff247c 100644 --- a/templates/partials/app-session-warning-dialog.phtml +++ b/templates/partials/app-session-warning-dialog.phtml @@ -1,5 +1,6 @@ 1; $allowUserTheme = allowUserTheme(); - -// Bookmark toggle: check if current page is already bookmarked -$bmData = is_array($_SESSION['user_bookmarks'] ?? null) ? $_SESSION['user_bookmarks'] : ['groups' => [], 'ungrouped' => []]; -$bmCurrentUrl = BookmarkUrlNormalizer::canonicalizeRequestUri((string) ($_SERVER['REQUEST_URI'] ?? ''), localeBase()); -$bmExisting = null; -foreach (($bmData['ungrouped'] ?? []) as $bm) { - $bmUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($bm['url'] ?? '')); - if ($bmUrl !== '' && $bmUrl === $bmCurrentUrl) { $bmExisting = $bm; break; } -} -if ($bmExisting === null) { - foreach (($bmData['groups'] ?? []) as $grp) { - foreach (($grp['bookmarks'] ?? []) as $bm) { - $bmUrl = BookmarkUrlNormalizer::canonicalizeRelative((string) ($bm['url'] ?? '')); - if ($bmUrl !== '' && $bmUrl === $bmCurrentUrl) { $bmExisting = $bm; break 2; } - } - } -} +$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : []; +$layoutNav = is_array($layoutNav ?? null) ? $layoutNav : []; +$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : []; +$moduleTopbarSlots = is_array($moduleSlots['topbar.right_item'] ?? null) ? $moduleSlots['topbar.right_item'] : []; ?> -
          +