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

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

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

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

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

View File

@@ -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"
}
]
}

View File

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

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env php
<?php
/**
* Publishes enabled module web assets into web/modules/.
*
* Usage: php bin/module-assets-sync.php
*
* APP_MODULE_ASSET_MODE:
* - symlink (default): create web/modules/<module-id> symlinks to modules/<id>/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());
}

View File

@@ -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 {
function moduleBuildRun(): int
{
$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);
if (!is_dir($runtimeParent) && !mkdir($runtimeParent, 0775, true) && !is_dir($runtimeParent)) {
throw new \RuntimeException("Failed to create runtime directory: {$runtimeParent}");
}
// 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);
return 0;
}
$registry = app(ModuleRegistry::class);
$modules = $registry->getModules();
$builder = new ModuleRuntimePageBuilder();
$result = $builder->build($runtimeDir, __DIR__ . '/../pages', $modules);
$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++;
}
}
} else {
fwrite(STDOUT, sprintf(
"module-build: runtime pages built — %d core entries + %d module entries.\n",
count($coreEntries) - 2, // minus . and ..
$modulePageCount
(int) ($result['core_entries'] ?? 0),
(int) ($result['module_entries'] ?? 0)
));
} catch (Throwable $exception) {
}
} catch (Throwable $exception) {
fwrite(STDERR, sprintf("module-build: FAILED: %s\n", $exception->getMessage()));
$exitCode = 1;
} finally {
} 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;
}
exit($exitCode);
/**
* Remove all files and symlinks in a directory (non-recursive, top-level only).
*/
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);
} 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);
} else {
unlink($path);
}
}
if (PHP_SAPI === 'cli' && realpath((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === __FILE__) {
exit(moduleBuildRun());
}

View File

@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
/**
* Shared CLI bootstrap and error policy for module runtime scripts.
*
* Policy:
* - first-party warnings/notices/deprecations => 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;
}

View File

@@ -17,31 +17,29 @@
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 {
function moduleMigrateRun(): int
{
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
moduleCliBootstrapApp();
$exitCode = 0;
$totalApplied = 0;
try {
$registry = app(ModuleRegistry::class);
$modules = $registry->getModules();
$hasModules = count($modules) > 0;
if (count($modules) === 0) {
if (!$hasModules) {
fwrite(STDOUT, "module-migrate: no modules enabled, nothing to do.\n");
exit(0);
}
} else {
// Ensure tracking table exists (safe if already created by db/updates/ script)
DB::execute(
DB::query(
'CREATE TABLE IF NOT EXISTS `module_migrations` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`module_id` VARCHAR(64) NOT NULL,
@@ -51,6 +49,7 @@ try {
UNIQUE KEY `uq_module_migration` (`module_id`, `filename`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci'
);
}
foreach ($modules as $manifest) {
$migrationsPath = $manifest->migrationsPath;
@@ -86,33 +85,52 @@ try {
fwrite(STDOUT, sprintf("module-migrate: applying %s/%s ... ", $manifest->id, $filename));
// Execute migration (may contain multiple statements)
// 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::execute($statement);
DB::query($statement);
}
// Record as applied
DB::execute(
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 ($totalApplied === 0) {
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) {
} 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());
}

View File

@@ -0,0 +1,55 @@
#!/usr/bin/env php
<?php
/**
* Synchronizes module-declared permissions into the permissions table.
*
* Usage: php bin/module-permissions-sync.php
*
* Exit codes:
* 0 — sync successful
* 1 — error during sync
*/
declare(strict_types=1);
use MintyPHP\App\Module\ModulePermissionSynchronizer;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Repository\Access\PermissionRepository;
require_once __DIR__ . '/module-cli-bootstrap.php';
function modulePermissionsSyncRun(): int
{
$warningsBefore = moduleCliVendorWarningsIgnoredTotal();
moduleCliBootstrapApp();
$exitCode = 0;
try {
$synchronizer = new ModulePermissionSynchronizer(
app(ModuleRegistry::class),
app(PermissionRepository::class)
);
$result = $synchronizer->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());
}

67
bin/module-runtime-sync.php Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require_once __DIR__ . '/module-cli-bootstrap.php';
require_once __DIR__ . '/module-migrate.php';
require_once __DIR__ . '/module-permissions-sync.php';
require_once __DIR__ . '/module-build.php';
require_once __DIR__ . '/module-assets-sync.php';
function moduleRuntimeSyncRun(): int
{
moduleCliBootstrapApp();
$warningsBeforeAll = moduleCliVendorWarningsIgnoredTotal();
$steps = [
'migrate' => 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());
}

View File

@@ -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',
],

View File

@@ -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'],
];

View File

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

View File

@@ -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`

View File

@@ -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 `<script type="module">`-Blöcke in `pages/**`/`templates/**`; Seitenlogik liegt in `web/js/pages/*`
- [ ] Für Seitenmodule ist Config über `<script type="application/json" id="page-config-...">` bereitgestellt
- [ ] JS-`src` in `pages/**`/`templates/**` nur über `assetVersion('...js...')`
- [ ] Runtime-Komponenten folgen Contract `init(root, config) -> { destroy() }`
- [ ] Runtime-Komponenten enthalten keinen eigenen `DOMContentLoaded`-/Sideeffect-Auto-Init
- [ ] Listener/Timer/Observer aus Runtime-Komponenten werden in `destroy()` sauber abgebaut
- [ ] Runtime-Konfiguration liegt in `page-config-app-components` (default) bzw. `page-config-login-components` (login) und wird ueber `readPageConfig(...)` gelesen
- [ ] Host-gebundene Runtime-Komponenten haben explizite `data-app-component`-Hosts im Markup
- [ ] Global-Utilities ohne natuerliches Host-Element sind explizit runtime-global registriert (`scope: 'global'`)
- [ ] Service-Auflösung in `pages/**`/`templates/**`/`lib/Support/**` nur via `app(Foo::class)`
- [ ] Keine `new ...Factory()` in `lib/Service/**` außerhalb `*Factory.php`
- [ ] Keine direkten `new ...Service()`, `new ...Gateway()`, `new ...Repository()` in `lib/Service/**` außerhalb `*Factory.php`
@@ -62,9 +68,13 @@ Kurze Definition of Done vor Merge.
## Qualitätschecks
- [ ] `docker compose exec php vendor/bin/phpunit`
- [ ] `docker compose exec php vendor/bin/phpunit` zweimal hintereinander in identischer Umgebung (beide Runs gruen)
- [ ] `docker compose exec php vendor/bin/phpstan analyse -c phpstan.neon --no-progress`
- [ ] `docker compose exec php vendor/bin/phpunit tests/Architecture/CoreStarterkitContractTest.php`
- [ ] bei Modul-/Manifest-/Routen-Aenderungen: `APP_ENABLED_MODULES='' php bin/module-runtime-sync.php`
- [ ] bei Modul-/Manifest-/Routen-Aenderungen: `APP_ENABLED_MODULES='addressbook,bookmarks' php bin/module-runtime-sync.php`
- [ ] Runtime-Sync erzeugt Step-Summary (`migrate`, `permissions-sync`, `build`, `assets-sync`) und `vendor_warnings_ignored=<n>`
- [ ] Keine First-party CLI-Warnings/Notices/Deprecations im Runtime-Sync (Vendor-Warnings sind nur temporaer toleriert und muessen gezaehlt sichtbar sein)
- [ ] bei Docs-/Skills-Aenderungen: `docker compose exec php vendor/bin/phpunit tests/Architecture/CodexSkillsContractTest.php`
- [ ] bei Docs-/Skills-Aenderungen: `bin/docs-link-check.sh` (scannt standardmaessig ohne `agent-system/runs/**`)
- [ ] bei Docs-/Skills-Aenderungen: `bin/codex-skills-sync.sh --check`
@@ -82,6 +92,7 @@ Kurze Definition of Done vor Merge.
- [ ] betroffene Edit-Seite + Save
- [ ] relevante Tenant-Sicht
- [ ] ggf. Global Search / API-Flow
- [ ] bei optionalen Modulen: Smoke-Test `APP_ENABLED_MODULES` on/off (keine toten Links, keine Notices/Warnungen)
## Abschluss

View File

@@ -1,41 +1,57 @@
# Extension-Readiness (ohne Architektur-Festlegung)
# Extension-Readiness (Module-System V1.1)
Letzte Aktualisierung: 2026-03-09
Letzte Aktualisierung: 2026-03-18
## Ziel
Verbindliche Vorbedingungen fuer spaetere eigenstaendige Module festhalten, ohne jetzt eine konkrete Extension-Mechanik (Hooks/Lifecycle/Loader) zu standardisieren.
Verbindliche Vorbedingungen fuer weitere eigenstaendige Module auf dem bestehenden Modul-System festhalten.
## Readiness-Checkliste vor neuem Modul
## Kanonische Quelle
Normativer Manifest- und Runtime-Contract: `/docs/reference-module-manifest-contract.md`.
Diese Readiness-Doku ist bewusst kurz und verweist fuer Felddefinitionen/Guards immer auf den Manifest-Contract.
## Readiness-Checkliste vor Modul 2+
1. **Modulgrenzen klarziehen**
- Betroffene Schichten benoennen (`pages -> Service -> Repository`).
- Keine SQL-/Business-Logik in Templates oder `pages/*.phtml`.
2. **Contracts definieren**
- Oeffentliche API-/UI-Contracts dokumentieren (Inputs, Outputs, Fehlerfaelle).
- Bei API-Aenderungen `docs/openapi.yaml` im selben Merge pflegen.
- Keine Modul-spezifischen Hardcodings im Core-Layout/Core-Routing.
2. **Manifest-Contract einhalten**
- `module.php` validiert gegen den kanonischen Contract (`permissions`, `scheduler_jobs`, `ui_slots`, `routes`).
- Route-/Slot-/Layout-Guards laufen fail-fast.
3. **Security + Scope absichern**
- AuthZ/RBAC serverseitig pruefen.
- Tenant-Scope serverseitig pruefen.
- POST-Mutationen mit CSRF absichern.
4. **Testbarkeit und Gates**
4. **Runtime-Hygiene absichern**
- `php bin/module-runtime-sync.php` laeuft reproduzierbar mit standardisierter Step-Summary.
- Keine First-party CLI-Warnings/Notices/Deprecations; Vendor-Rauschen wird nur gezaehlt/reportet.
5. **Testbarkeit und Gates**
- Architektur- und Domain-Tests fuer neue Logik ergaenzen.
- Relevante Quality Gates (`QG-*`) im Merge reporten.
5. **Daten- und Betriebsfaehigkeit**
- `phpunit` und `phpstan` als Pflicht-Gates gruen.
6. **Daten- und Betriebsfaehigkeit**
- Schema-/Datenaenderungen fuer Bestandsinstallationen idempotent in `db/updates/*.sql`.
- Logs/Audit ohne unredacted PII/Secrets.
## Bewusst Out of Scope in diesem Stand
## Derzeit Out of Scope
- Kein standardisierter Extension-Loader.
- Kein Hook-/Plugin-Lifecycle.
- Keine verbindlichen Modul-Ablageorte ausserhalb bestehender Schichten.
- Eigener DB-Server oder Netzwerk-Microservice je Modul.
- Externe Drittanbieter-Module mit Legacy-Kompatibilitaet.
## Entscheidungslog fuer spaetere Standardisierung
## Verfuegbare Core-Services fuer Module
Wenn die Extension-Mechanik spaeter konkretisiert wird, muss die Entscheidung mindestens diese Punkte mitliefern:
JS-seitig:
1. Aktivierung/Deaktivierung und Reihenfolge von Modulen.
2. Versionierung und Migrationsstrategie pro Modul.
3. Sicherheits- und Tenant-Isolation pro Erweiterung.
4. Test- und Rollback-Strategie fuer Modul-Deployments.
- Confirm-Dialog (`confirmDialog`)
- Async-Flash (`showAsyncFlash`)
- DOM-Utilities (`app-dom.js`)
- CSRF-Metadaten über `data-csrf-key` / `data-csrf-token`
PHP-seitig:
- Guard (`MintyPHP\\Support\\Guard`)
- SessionStore (`MintyPHP\\Http\\SessionStoreInterface`)
- Flash (`MintyPHP\\Support\\Flash`)
- DI-Container (`MintyPHP\\App\\AppContainer`)
- Modul-Contracts (`LayoutContextProvider`, `SessionProvider`, `SearchResourceProvider`)

View File

@@ -1,6 +1,6 @@
# Frontend JavaScript
Letzte Aktualisierung: 2026-03-06
Letzte Aktualisierung: 2026-03-18
## Ordnerstruktur
@@ -18,7 +18,7 @@ Letzte Aktualisierung: 2026-03-06
- setzt früh UI-States (no-js/js, Sidebar-/Contrast-LocalStorage)
- `/web/js/app-init.js`
- Modul-Entrypoint für Standardseiten
- importiert Komponenten zentral
- initialisiert Runtime + globale Komponenten zentral
- `/web/js/app-login-init.js`
- reduzierter Entrypoint für Loginseiten
@@ -30,6 +30,34 @@ Empfohlenes Muster:
2. Früher Guard auf fehlende Elemente
3. Keine stillen Fehler verschlucken
4. Nur DOM/UI-Verhalten, keine Fachlogik
5. Lifecycle-Contract für Runtime-Komponenten: `init(root, config)` gibt API mit `destroy()` zurück
6. Runtime-Komponenten dürfen keinen eigenen Auto-Init via `DOMContentLoaded`/Sideeffect enthalten
## Component Runtime (Restmigration)
- Zentrale Runtime: `web/js/core/app-component-runtime.js`.
- Runtime-Page-Configs:
- Standardseiten: `<script type="application/json" id="page-config-app-components">...</script>`
- Loginseiten: `<script type="application/json" id="page-config-login-components">...</script>`
- 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:

View File

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

View File

@@ -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 (`<module_id>.<key>`).
## 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=<n>`.
First-party Warnings/Notices/Deprecations gelten als Fehler (fail-fast).

View File

@@ -14,8 +14,13 @@ final class AppContainer
/** @var array<string, mixed> */
private array $instances = [];
private bool $protectExistingBindings = false;
public function set(string $id, callable $factory): void
{
if ($this->protectExistingBindings && $this->has($id)) {
throw new RuntimeException('Refusing to overwrite existing service binding: ' . $id);
}
$this->bindings[$id] = $factory;
}
@@ -38,4 +43,12 @@ final class AppContainer
$this->instances[$id] = ($this->bindings[$id])($this);
return $this->instances[$id];
}
/**
* Freeze existing bindings/instances: any later overwrite attempt throws.
*/
public function protectExistingBindings(): void
{
$this->protectExistingBindings = true;
}
}

View File

@@ -18,8 +18,6 @@ use MintyPHP\Repository\Stats\AdminStatsRepository;
use MintyPHP\Repository\Tenant\UserTenantRepository;
use MintyPHP\Repository\User\UserReadRepository;
use MintyPHP\Service\Access\PermissionService;
use MintyPHP\Service\AddressBook\AddressBookService;
use MintyPHP\Service\AddressBook\AddressBookServicesFactory;
use MintyPHP\Service\Audit\ApiAuditService;
use MintyPHP\Service\Audit\AuditMetadataEnricher;
use MintyPHP\Service\Audit\AuditServicesFactory;
@@ -76,7 +74,6 @@ final class AppServicesRegistrar implements ContainerRegistrar
$container->set(ImportAuditService::class, static fn (AppContainer $c): ImportAuditService => $c->get(ImportServicesFactory::class)->createImportAuditService());
$container->set(ScheduledJobService::class, static fn (AppContainer $c): ScheduledJobService => $c->get(SchedulerServicesFactory::class)->createScheduledJobService());
$container->set(SchedulerRunService::class, static fn (AppContainer $c): SchedulerRunService => $c->get(SchedulerServicesFactory::class)->createSchedulerRunService());
$container->set(AddressBookService::class, static fn (AppContainer $c): AddressBookService => $c->get(AddressBookServicesFactory::class)->createAddressBookService());
$container->set(TenantCustomFieldService::class, static fn (AppContainer $c): TenantCustomFieldService => $c->get(CustomFieldServicesFactory::class)->createTenantCustomFieldService());
$container->set(UserCustomFieldValueService::class, static fn (AppContainer $c): UserCustomFieldValueService => $c->get(CustomFieldServicesFactory::class)->createUserCustomFieldValueService());
$container->set(AuditMetadataEnricher::class, static fn (AppContainer $c): AuditMetadataEnricher => new AuditMetadataEnricher(

View File

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

View File

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

View File

@@ -9,6 +9,11 @@ use MintyPHP\App\AppContainer;
*
* Called from appBuildLayoutNavContext() after Core data assembly.
* Return value is merged into the $layoutNav array passed to templates.
*
* IMPORTANT: Only called in web request context. CLI/scheduler callers
* must not invoke layout context providers. Implementations should use
* try/catch around request-dependent calls (e.g. requestInput()) as a
* defensive measure.
*/
interface LayoutContextProvider
{

View File

@@ -13,6 +13,13 @@ interface SearchResourceProvider
/**
* Return search resource definitions.
*
* Supported SQL placeholders (replaced before parameter binding):
* - `{{tenantFilter}}` — replaced with a tenant-scope WHERE clause (or empty string)
* - `{{userId}}` — replaced with the current user's ID (integer, safe for inline SQL)
*
* All remaining `?` placeholders are bound to the LIKE search term.
* The last `?` in preview_sql is reserved for the preview limit.
*
* @return array<string, array{
* label: string,
* permission: string,

View File

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

View File

@@ -35,7 +35,22 @@ final class ModuleManifest
/** @var array<string, list<string>> Asset group name → file list */
public readonly array $assetGroups;
/** @var list<array{name: string, handler: string, schedule?: string}> */
/**
* @var list<array{
* job_key: string,
* handler: string,
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>
* }>
*/
public readonly array $schedulerJobs;
/** @var list<class-string> LayoutContextProvider FQCNs */
@@ -44,9 +59,22 @@ final class ModuleManifest
/** @var list<class-string> SessionProvider FQCNs */
public readonly array $sessionProviders;
/** @var list<string> Permission keys (e.g. 'address_book.view') */
/**
* @var list<array{
* key: string,
* description: string,
* active: int,
* is_system: int
* }>
*/
public readonly array $permissions;
/** @var list<class-string> AuthorizationPolicyInterface FQCNs */
public readonly array $authorizationPolicies;
/** @var array<string, string> UI-capability-key → ability-string for layout authorization */
public readonly array $layoutCapabilities;
public readonly ?string $migrationsPath;
public readonly string $basePath;
@@ -76,10 +104,12 @@ final class ModuleManifest
$this->uiSlots = is_array($raw['ui_slots'] ?? null) ? $raw['ui_slots'] : [];
$this->searchResources = self::listOf($raw, 'search_resources');
$this->assetGroups = is_array($raw['asset_groups'] ?? null) ? $raw['asset_groups'] : [];
$this->schedulerJobs = self::arrayOf($raw, 'scheduler_jobs');
$this->schedulerJobs = self::normalizeSchedulerJobs($raw['scheduler_jobs'] ?? [], $this->id);
$this->layoutContextProviders = self::listOf($raw, 'layout_context_providers');
$this->sessionProviders = self::listOf($raw, 'session_providers');
$this->permissions = self::listOf($raw, 'permissions');
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
$this->authorizationPolicies = self::listOf($raw, 'authorization_policies');
$this->layoutCapabilities = is_array($raw['layout_capabilities'] ?? null) ? $raw['layout_capabilities'] : [];
$migrationsPath = $raw['migrations_path'] ?? null;
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
@@ -112,4 +142,224 @@ final class ModuleManifest
$value = $raw[$key] ?? [];
return is_array($value) ? $value : [];
}
/**
* @return list<array{key: string, description: string, active: int, is_system: int}>
*/
private static function normalizePermissions(mixed $value, string $moduleId): array
{
if (!is_array($value)) {
throw new InvalidArgumentException(
sprintf("Module '%s' manifest key 'permissions' must be an array.", $moduleId)
);
}
$permissions = [];
foreach (array_values($value) as $index => $permission) {
if (!is_array($permission)) {
throw new InvalidArgumentException(
sprintf("Module '%s' permissions[%d] must be an object-like array.", $moduleId, $index)
);
}
$key = trim((string) ($permission['key'] ?? ''));
$description = trim((string) ($permission['description'] ?? ''));
if ($key === '' || $description === '') {
throw new InvalidArgumentException(
sprintf("Module '%s' permissions[%d] must define non-empty 'key' and 'description'.", $moduleId, $index)
);
}
$permissions[] = [
'key' => $key,
'description' => $description,
'active' => (int) ((bool) ($permission['active'] ?? true)),
'is_system' => (int) ((bool) ($permission['is_system'] ?? true)),
];
}
return $permissions;
}
/**
* @return list<array{
* job_key: string,
* handler: string,
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>
* }>
*/
private static function normalizeSchedulerJobs(mixed $value, string $moduleId): array
{
if (!is_array($value)) {
throw new InvalidArgumentException(
sprintf("Module '%s' manifest key 'scheduler_jobs' must be an array.", $moduleId)
);
}
$jobs = [];
foreach (array_values($value) as $index => $job) {
if (!is_array($job)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] must be an object-like array.", $moduleId, $index)
);
}
$requiredKeys = [
'job_key',
'handler',
'label',
'description',
'default_enabled',
'default_timezone',
'default_schedule_type',
'default_schedule_interval',
'default_schedule_time',
'default_schedule_weekdays_csv',
'default_catchup_once',
'allowed_schedule_types',
];
foreach ($requiredKeys as $requiredKey) {
if (!array_key_exists($requiredKey, $job)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] is missing required key '%s'.", $moduleId, $index, $requiredKey)
);
}
}
$jobKey = trim((string) ($job['job_key'] ?? ''));
$handler = trim((string) ($job['handler'] ?? ''));
$label = trim((string) ($job['label'] ?? ''));
$description = trim((string) ($job['description'] ?? ''));
$defaultTimezone = trim((string) ($job['default_timezone'] ?? ''));
$defaultType = strtolower(trim((string) ($job['default_schedule_type'] ?? '')));
if ($jobKey === '' || $handler === '' || $label === '' || $description === '' || $defaultTimezone === '') {
throw new InvalidArgumentException(
sprintf(
"Module '%s' scheduler_jobs[%d] requires non-empty job_key, handler, label, description and default_timezone.",
$moduleId,
$index
)
);
}
if (!in_array($defaultType, ['hourly', 'daily', 'weekly'], true)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] has invalid default_schedule_type '%s'.", $moduleId, $index, $defaultType)
);
}
$allowedTypes = self::normalizeAllowedScheduleTypes($job['allowed_schedule_types'], $moduleId, $index);
if (!in_array($defaultType, $allowedTypes, true)) {
throw new InvalidArgumentException(
sprintf(
"Module '%s' scheduler_jobs[%d] default_schedule_type '%s' must be listed in allowed_schedule_types.",
$moduleId,
$index,
$defaultType
)
);
}
$defaultInterval = (int) $job['default_schedule_interval'];
$defaultTimeRaw = trim((string) ($job['default_schedule_time'] ?? ''));
$defaultTime = $defaultTimeRaw !== '' ? $defaultTimeRaw : null;
$defaultWeekdays = self::normalizeWeekdaysCsv(
$job['default_schedule_weekdays_csv'],
$moduleId,
$index
);
if (in_array($defaultType, ['daily', 'weekly'], true) && $defaultTime === null) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_time for %s jobs.", $moduleId, $index, $defaultType)
);
}
if ($defaultType === 'weekly' && $defaultWeekdays === null) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] requires default_schedule_weekdays_csv for weekly jobs.", $moduleId, $index)
);
}
$jobs[] = [
'job_key' => $jobKey,
'handler' => $handler,
'label' => $label,
'description' => $description,
'default_enabled' => (int) ((bool) $job['default_enabled']),
'default_timezone' => $defaultTimezone,
'default_schedule_type' => $defaultType,
'default_schedule_interval' => $defaultInterval,
'default_schedule_time' => $defaultTime,
'default_schedule_weekdays_csv' => $defaultWeekdays,
'default_catchup_once' => (int) ((bool) $job['default_catchup_once']),
'allowed_schedule_types' => $allowedTypes,
];
}
return $jobs;
}
/**
* @return list<string>
*/
private static function normalizeAllowedScheduleTypes(mixed $value, string $moduleId, int $index): array
{
if (!is_array($value) || $value === []) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types must be a non-empty array.", $moduleId, $index)
);
}
$types = [];
foreach ($value as $rawType) {
$type = strtolower(trim((string) $rawType));
if (!in_array($type, ['hourly', 'daily', 'weekly'], true)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] allowed_schedule_types contains invalid type '%s'.", $moduleId, $index, $type)
);
}
$types[] = $type;
}
$types = array_values(array_unique($types));
sort($types, SORT_STRING);
return $types;
}
private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string
{
$raw = trim((string) $value);
if ($raw === '') {
return null;
}
$days = array_filter(array_map('trim', explode(',', $raw)), static fn (string $part): bool => $part !== '');
if ($days === []) {
return null;
}
$normalizedDays = [];
foreach ($days as $day) {
if (!preg_match('/^[1-7]$/', $day)) {
throw new InvalidArgumentException(
sprintf("Module '%s' scheduler_jobs[%d] has invalid weekday '%s' in default_schedule_weekdays_csv.", $moduleId, $index, $day)
);
}
$normalizedDays[] = (int) $day;
}
$normalizedDays = array_values(array_unique($normalizedDays));
sort($normalizedDays, SORT_NUMERIC);
return implode(',', array_map(static fn (int $day): string => (string) $day, $normalizedDays));
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace MintyPHP\App\Module;
use MintyPHP\Repository\Access\PermissionRepositoryInterface;
/**
* Synchronizes module-defined permissions into the permissions table.
*/
final class ModulePermissionSynchronizer
{
public function __construct(
private readonly ModuleRegistry $moduleRegistry,
private readonly PermissionRepositoryInterface $permissionRepository
) {
}
/**
* @return array{created: int, updated: int, unchanged: int, total: int}
*/
public function sync(): array
{
$created = 0;
$updated = 0;
$unchanged = 0;
$total = 0;
foreach ($this->moduleRegistry->getPermissions() as $permissionDefinition) {
$total++;
$key = trim((string) $permissionDefinition['key']);
if ($key === '') {
throw new \RuntimeException('Module permission definition has empty key.');
}
$desired = [
'key' => $key,
'description' => trim((string) $permissionDefinition['description']),
'active' => (int) $permissionDefinition['active'],
'is_system' => (int) $permissionDefinition['is_system'],
];
$existing = $this->permissionRepository->findByKey($key);
if (!is_array($existing)) {
$createdId = $this->permissionRepository->create($desired);
if ($createdId === null) {
throw new \RuntimeException("Failed to create module permission '{$key}'.");
}
$created++;
continue;
}
$existingId = (int) ($existing['id'] ?? 0);
if ($existingId <= 0) {
throw new \RuntimeException("Permission '{$key}' exists without a valid id.");
}
if ($this->isPermissionEqual($existing, $desired)) {
$unchanged++;
continue;
}
$wasUpdated = $this->permissionRepository->update($existingId, $desired);
if (!$wasUpdated) {
throw new \RuntimeException("Failed to update module permission '{$key}' (id {$existingId}).");
}
$updated++;
}
return [
'created' => $created,
'updated' => $updated,
'unchanged' => $unchanged,
'total' => $total,
];
}
/**
* @param array<string, mixed> $existing
* @param array<string, mixed> $desired
*/
private function isPermissionEqual(array $existing, array $desired): bool
{
$existingDescription = trim((string) ($existing['description'] ?? ''));
$existingActive = (int) ($existing['active'] ?? 0);
$existingSystem = (int) ($existing['is_system'] ?? 0);
return $existingDescription === (string) ($desired['description'] ?? '')
&& $existingActive === (int) ($desired['active'] ?? 0)
&& $existingSystem === (int) ($desired['is_system'] ?? 0);
}
}

View File

@@ -15,10 +15,21 @@ use RuntimeException;
*/
final class ModuleRegistry
{
/** @var array<string, list<string>> */
private const UI_SLOT_REQUIRED_KEYS = [
'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'],
'search.resource_item' => ['key', 'label', 'base_url', 'permission'],
'user.edit.aside_action' => ['key', 'type', 'label', 'permission'],
'topbar.right_item' => ['key', 'template'],
'layout.body_end_template' => ['key', 'template'],
'layout.head_style' => ['key', 'path'],
'runtime.component' => ['key', 'script'],
];
/** @var array<string, ModuleManifest> keyed by module id */
private array $modules = [];
/** @var array<int, array{path: string, target: string, public?: bool}> merged routes */
/** @var array<int, array{path: string, target: string, module_id: string, public?: bool}> merged routes */
private array $mergedRoutes = [];
/** @var list<string> merged public paths */
@@ -42,12 +53,53 @@ final class ModuleRegistry
/** @var list<class-string> merged session provider FQCNs */
private array $mergedSessionProviders = [];
/** @var list<string> merged permissions */
/**
* @var list<array{
* key: string,
* description: string,
* active: int,
* is_system: int
* }> merged permissions
*/
private array $mergedPermissions = [];
/** @var list<array{name: string, handler: string, schedule?: string}> */
/** @var list<class-string> merged authorization policy FQCNs */
private array $mergedAuthorizationPolicies = [];
/** @var array<string, string> merged layout capabilities: UI-key → ability */
private array $mergedLayoutCapabilities = [];
/**
* @var list<array{
* job_key: string,
* handler: string,
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>,
* module_id: string
* }>
*/
private array $mergedSchedulerJobs = [];
/** @var array<string, string> route target => owning module id */
private array $routeTargetOwners = [];
/** @var array<string, string> route path => owning module id */
private array $routePathOwners = [];
/** @var array<string, string> permission key => owning module id */
private array $permissionOwners = [];
/** @var array<string, string> scheduler job key => owning module id */
private array $schedulerJobOwners = [];
/**
* Boot the registry from a modules directory and activation config.
*
@@ -102,11 +154,16 @@ final class ModuleRegistry
return $a->loadOrder <=> $b->loadOrder ?: strcmp($a->id, $b->id);
});
// Allow module-local classes (modules/<id>/lib/...) to autoload.
ModuleAutoloader::register($manifests);
// Register and merge
foreach ($manifests as $manifest) {
$registry->registerModule($manifest);
}
$registry->finalizeMergedContributions();
return $registry;
}
@@ -119,10 +176,17 @@ final class ModuleRegistry
public static function resolveEnabledModules(array $config): array
{
$fromConfig = $config['enabled_modules'] ?? [];
$fromEnv = trim((string) getenv('APP_ENABLED_MODULES'));
$fromEnvRaw = getenv('APP_ENABLED_MODULES');
if ($fromEnv !== '') {
$fromConfig = array_map('trim', explode(',', $fromEnv));
// Semantics:
// - not set => use config/modules.php
// - set '' => explicit "no modules"
// - set list => exact list from env
if ($fromEnvRaw !== false) {
$fromEnv = trim((string) $fromEnvRaw);
$fromConfig = $fromEnv === ''
? []
: array_map('trim', explode(',', $fromEnv));
}
return array_values(array_unique(array_filter($fromConfig, static fn (string $id): bool => trim($id) !== '')));
@@ -139,15 +203,28 @@ final class ModuleRegistry
$this->modules[$manifest->id] = $manifest;
// — Merge routes (fail-fast on collision) ——————————————
$existingTargets = array_column($this->mergedRoutes, 'target');
foreach ($manifest->routes as $route) {
foreach ($manifest->routes as $rawRoute) {
$route = $this->normalizeRoute($rawRoute, $manifest->id);
$path = $route['path'];
$target = $route['target'];
if (in_array($target, $existingTargets, true)) {
if (isset($this->routeTargetOwners[$target]) && $this->routeTargetOwners[$target] !== $manifest->id) {
$owner = $this->routeTargetOwners[$target];
throw new RuntimeException(
"Route target conflict: '{$target}' from module '{$manifest->id}' collides with an existing route."
"Route target conflict: '{$target}' from module '{$manifest->id}' collides with module '{$owner}'."
);
}
if (isset($this->routePathOwners[$path])) {
$owner = $this->routePathOwners[$path];
throw new RuntimeException(
"Route path conflict: '{$path}' from module '{$manifest->id}' collides with module '{$owner}'."
);
}
$this->routeTargetOwners[$target] = $manifest->id;
$this->routePathOwners[$path] = $manifest->id;
$this->mergedRoutes[] = $route;
if (!empty($route['public'])) {
$this->mergedPublicPaths[] = $path;
}
}
// — Merge public paths ———————————————————————————
@@ -163,8 +240,8 @@ final class ModuleRegistry
}
$contributionList = is_array($contributions) ? $contributions : [$contributions];
foreach ($contributionList as $contribution) {
$key = is_array($contribution) ? ($contribution['key'] ?? null) : null;
if ($key !== null) {
$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) {
@@ -173,13 +250,17 @@ final class ModuleRegistry
);
}
}
}
$this->mergedUiSlots[$slotName][] = $contribution;
$this->mergedUiSlots[$slotName][] = $normalizedContribution;
}
}
// — Merge search resources (fail-fast on duplicate provider) ——
foreach ($manifest->searchResources as $provider) {
if (!is_string($provider) || trim($provider) === '') {
throw new RuntimeException(
"Search resource provider in module '{$manifest->id}' must be a non-empty class-string."
);
}
if (in_array($provider, $this->mergedSearchResources, true)) {
throw new RuntimeException(
"Search resource conflict: provider '{$provider}' from module '{$manifest->id}' is already registered."
@@ -200,22 +281,283 @@ final class ModuleRegistry
// — Merge permissions (fail-fast on duplicate) ———————
foreach ($manifest->permissions as $permission) {
if (in_array($permission, $this->mergedPermissions, true)) {
$permissionKey = trim((string) $permission['key']);
if ($permissionKey === '') {
throw new RuntimeException(
"Permission conflict: '{$permission}' from module '{$manifest->id}' is already registered."
"Permission in module '{$manifest->id}' must define non-empty 'key'."
);
}
$this->mergedPermissions[] = $permission;
if (isset($this->permissionOwners[$permissionKey])) {
$owner = $this->permissionOwners[$permissionKey];
throw new RuntimeException(
"Permission conflict: '{$permissionKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
);
}
$this->permissionOwners[$permissionKey] = $manifest->id;
$this->mergedPermissions[] = [
'key' => $permissionKey,
'description' => trim((string) $permission['description']),
'active' => (int) $permission['active'],
'is_system' => (int) $permission['is_system'],
];
}
// — Merge layout context providers ————————————————
array_push($this->mergedLayoutContextProviders, ...$manifest->layoutContextProviders);
foreach ($manifest->layoutContextProviders as $providerClass) {
if (!is_string($providerClass) || trim($providerClass) === '') {
throw new RuntimeException(
"Layout context provider in module '{$manifest->id}' must be a non-empty class-string."
);
}
$this->mergedLayoutContextProviders[] = $providerClass;
}
// — Merge session providers ——————————————————————
array_push($this->mergedSessionProviders, ...$manifest->sessionProviders);
foreach ($manifest->sessionProviders as $providerClass) {
if (!is_string($providerClass) || trim($providerClass) === '') {
throw new RuntimeException(
"Session provider in module '{$manifest->id}' must be a non-empty class-string."
);
}
$this->mergedSessionProviders[] = $providerClass;
}
// — Merge authorization policies (fail-fast on duplicate) ———
foreach ($manifest->authorizationPolicies as $policyClass) {
if (!is_string($policyClass) || trim($policyClass) === '') {
throw new RuntimeException(
"Authorization policy in module '{$manifest->id}' must be a non-empty class-string."
);
}
if (in_array($policyClass, $this->mergedAuthorizationPolicies, true)) {
throw new RuntimeException(
"Authorization policy conflict: '{$policyClass}' from module '{$manifest->id}' is already registered."
);
}
$this->mergedAuthorizationPolicies[] = $policyClass;
}
// — Merge layout capabilities (fail-fast on duplicate key) ———
foreach ($manifest->layoutCapabilities as $uiKey => $ability) {
$uiKey = trim((string) $uiKey);
$ability = trim((string) $ability);
if ($uiKey === '' || $ability === '') {
throw new RuntimeException(
"Layout capability in module '{$manifest->id}' must have non-empty key and ability."
);
}
if (isset($this->mergedLayoutCapabilities[$uiKey])) {
throw new RuntimeException(
"Layout capability conflict: key '{$uiKey}' from module '{$manifest->id}' is already registered."
);
}
$this->mergedLayoutCapabilities[$uiKey] = $ability;
}
// — Merge scheduler jobs —————————————————————————
array_push($this->mergedSchedulerJobs, ...$manifest->schedulerJobs);
foreach ($manifest->schedulerJobs as $schedulerJob) {
$jobKey = trim((string) $schedulerJob['job_key']);
if ($jobKey === '') {
throw new RuntimeException(
"Scheduler job in module '{$manifest->id}' must define non-empty 'job_key'."
);
}
if (isset($this->schedulerJobOwners[$jobKey])) {
$owner = $this->schedulerJobOwners[$jobKey];
throw new RuntimeException(
"Scheduler job conflict: '{$jobKey}' from module '{$manifest->id}' is already registered by module '{$owner}'."
);
}
$this->schedulerJobOwners[$jobKey] = $manifest->id;
$this->mergedSchedulerJobs[] = [
'job_key' => $jobKey,
'handler' => trim((string) $schedulerJob['handler']),
'label' => trim((string) $schedulerJob['label']),
'description' => trim((string) $schedulerJob['description']),
'default_enabled' => (int) $schedulerJob['default_enabled'],
'default_timezone' => trim((string) $schedulerJob['default_timezone']),
'default_schedule_type' => trim((string) $schedulerJob['default_schedule_type']),
'default_schedule_interval' => (int) $schedulerJob['default_schedule_interval'],
'default_schedule_time' => $schedulerJob['default_schedule_time'],
'default_schedule_weekdays_csv' => $schedulerJob['default_schedule_weekdays_csv'],
'default_catchup_once' => (int) $schedulerJob['default_catchup_once'],
'allowed_schedule_types' => array_values($schedulerJob['allowed_schedule_types']),
'module_id' => $manifest->id,
];
}
}
private function finalizeMergedContributions(): void
{
$this->mergedPublicPaths = array_values(array_unique(array_filter(
$this->mergedPublicPaths,
static fn (mixed $path): bool => trim((string) $path) !== ''
)));
foreach ($this->mergedUiSlots as $slotName => $items) {
usort($items, static function (array $a, array $b): int {
$orderCmp = ((int) ($a['order'] ?? 100)) <=> ((int) ($b['order'] ?? 100));
if ($orderCmp !== 0) {
return $orderCmp;
}
$moduleCmp = strcmp((string) ($a['__module_id'] ?? ''), (string) ($b['__module_id'] ?? ''));
if ($moduleCmp !== 0) {
return $moduleCmp;
}
return strcmp((string) ($a['key'] ?? ''), (string) ($b['key'] ?? ''));
});
foreach ($items as &$item) {
unset($item['__module_id']);
}
unset($item);
$this->mergedUiSlots[$slotName] = $items;
}
}
/**
* @return array{path: string, target: string, module_id: string, public?: bool}
*/
private function normalizeRoute(mixed $rawRoute, string $moduleId): array
{
if (!is_array($rawRoute)) {
throw new RuntimeException("Route entries in module '{$moduleId}' must be arrays.");
}
$path = trim((string) ($rawRoute['path'] ?? ''));
$target = trim((string) ($rawRoute['target'] ?? ''));
if ($path === '' || $target === '') {
throw new RuntimeException("Routes in module '{$moduleId}' must define non-empty 'path' and 'target'.");
}
$normalized = [
'path' => $path,
'target' => $target,
'module_id' => $moduleId,
];
if (array_key_exists('public', $rawRoute)) {
$normalized['public'] = (bool) $rawRoute['public'];
}
return $normalized;
}
/**
* @return array<string, mixed>
*/
private function normalizeUiSlotContribution(string $slotName, mixed $rawContribution, string $moduleId): array
{
if (!is_array($rawContribution)) {
throw new RuntimeException(
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must be an array."
);
}
$key = trim((string) ($rawContribution['key'] ?? ''));
if ($key === '') {
throw new RuntimeException(
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' must define a non-empty 'key'."
);
}
foreach (self::UI_SLOT_REQUIRED_KEYS[$slotName] ?? [] as $requiredKey) {
$requiredValue = trim((string) ($rawContribution[$requiredKey] ?? ''));
if ($requiredValue === '') {
throw new RuntimeException(
"UI slot contribution for slot '{$slotName}' in module '{$moduleId}' is missing required key '{$requiredKey}'."
);
}
}
$this->validateUiSlotContributionByType($slotName, $rawContribution, $moduleId, $key);
$normalized = $rawContribution;
$normalized['key'] = $key;
$normalized['order'] = (int) ($rawContribution['order'] ?? 100);
$normalized['__module_id'] = $moduleId;
$normalized['module_id'] = $moduleId;
return $normalized;
}
/**
* @param array<string, mixed> $rawContribution
*/
private function validateUiSlotContributionByType(
string $slotName,
array $rawContribution,
string $moduleId,
string $slotKey
): void {
if (in_array($slotName, ['aside.tab_panel', 'topbar.right_item', 'layout.body_end_template'], true)) {
$templatePath = trim((string) ($rawContribution['panel_template'] ?? $rawContribution['template'] ?? ''));
if ($templatePath === '' || !is_file($templatePath)) {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot '{$slotName}' in module '{$moduleId}' references missing template '{$templatePath}'."
);
}
}
if ($slotName === 'layout.head_style') {
$path = trim((string) ($rawContribution['path'] ?? ''));
if ($path === '' || str_contains($path, '..')) {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'layout.head_style' in module '{$moduleId}' has invalid path '{$path}'."
);
}
}
if ($slotName === 'user.edit.aside_action') {
$type = strtolower(trim((string) ($rawContribution['type'] ?? 'link')));
if ($type !== 'link' && $type !== 'form') {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' has invalid type '{$type}'."
);
}
if ($type === 'form') {
$action = trim((string) ($rawContribution['action'] ?? ''));
if ($action === '') {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' requires non-empty 'action' for type=form."
);
}
} else {
$href = trim((string) ($rawContribution['href'] ?? ''));
if ($href === '') {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'user.edit.aside_action' in module '{$moduleId}' requires non-empty 'href' for type=link."
);
}
}
}
if ($slotName === 'runtime.component') {
$script = trim((string) ($rawContribution['script'] ?? ''));
if ($script === '' || str_contains($script, '..')) {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid script '{$script}'."
);
}
$phase = strtolower(trim((string) ($rawContribution['phase'] ?? 'late')));
if (!in_array($phase, ['early', 'default', 'late'], true)) {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid phase '{$phase}'."
);
}
$configPath = trim((string) ($rawContribution['config_path'] ?? ''));
if ($configPath !== '' && !preg_match('/^[a-zA-Z][a-zA-Z0-9_]*(\.[a-zA-Z0-9_]+)+$/', $configPath)) {
throw new RuntimeException(
"UI slot contribution '{$slotKey}' for slot 'runtime.component' in module '{$moduleId}' has invalid config_path '{$configPath}'."
);
}
}
}
// ─── Getters ────────────────────────────────────────────────────────
@@ -239,7 +581,7 @@ final class ModuleRegistry
return $this->modules[$id];
}
/** @return array<int, array{path: string, target: string, public?: bool}> */
/** @return array<int, array{path: string, target: string, module_id: string, public?: bool}> */
public function getRoutes(): array
{
return $this->mergedRoutes;
@@ -295,13 +637,52 @@ final class ModuleRegistry
return $this->mergedSessionProviders;
}
/** @return list<string> */
/**
* @return list<array{key: string, description: string, active: int, is_system: int}>
*/
public function getPermissions(): array
{
return $this->mergedPermissions;
}
/** @return list<array{name: string, handler: string, schedule?: string}> */
/** @return list<string> */
public function getPermissionKeys(): array
{
return array_values(array_map(
static fn (array $permission): string => (string) $permission['key'],
$this->mergedPermissions
));
}
/** @return list<class-string> */
public function getAuthorizationPolicies(): array
{
return $this->mergedAuthorizationPolicies;
}
/** @return array<string, string> UI-capability-key → ability-string */
public function getLayoutCapabilities(): array
{
return $this->mergedLayoutCapabilities;
}
/**
* @return list<array{
* job_key: string,
* handler: string,
* label: string,
* description: string,
* default_enabled: int,
* default_timezone: string,
* default_schedule_type: string,
* default_schedule_interval: int,
* default_schedule_time: ?string,
* default_schedule_weekdays_csv: ?string,
* default_catchup_once: int,
* allowed_schedule_types: list<string>,
* module_id: string
* }>
*/
public function getSchedulerJobs(): array
{
return $this->mergedSchedulerJobs;

View File

@@ -0,0 +1,178 @@
<?php
namespace MintyPHP\App\Module;
use RuntimeException;
/**
* Publishes module web assets to a runtime web/modules directory.
*/
final class ModuleRuntimeAssetPublisher
{
/**
* @param array<string, ModuleManifest> $modules
* @return array{created: int, updated: int, removed: int, unchanged: int}
*/
public function publish(string $runtimeModulesDir, array $modules, string $mode = 'symlink'): array
{
$normalizedMode = strtolower(trim($mode));
if ($normalizedMode === '') {
$normalizedMode = 'symlink';
}
if (!in_array($normalizedMode, ['symlink', 'copy'], true)) {
throw new RuntimeException("Unsupported module asset publish mode '{$mode}'. Use 'symlink' or 'copy'.");
}
if (is_link($runtimeModulesDir) && !unlink($runtimeModulesDir)) {
throw new RuntimeException("Cannot remove existing runtime modules symlink: {$runtimeModulesDir}");
}
if (!is_dir($runtimeModulesDir) && !mkdir($runtimeModulesDir, 0775, true) && !is_dir($runtimeModulesDir)) {
throw new RuntimeException("Cannot create runtime modules directory: {$runtimeModulesDir}");
}
$result = ['created' => 0, 'updated' => 0, 'removed' => 0, 'unchanged' => 0];
$desired = [];
foreach ($modules as $manifest) {
if (!$manifest instanceof ModuleManifest) {
continue;
}
$moduleWebDir = rtrim($manifest->basePath, '/') . '/web';
if (!is_dir($moduleWebDir)) {
continue;
}
$desired[$manifest->id] = $moduleWebDir;
}
$entries = scandir($runtimeModulesDir);
if ($entries === false) {
throw new RuntimeException("Cannot scan runtime modules directory: {$runtimeModulesDir}");
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
if (isset($desired[$entry])) {
continue;
}
$path = $runtimeModulesDir . '/' . $entry;
$this->removePath($path);
$result['removed']++;
}
foreach ($desired as $moduleId => $moduleWebDir) {
$targetPath = $runtimeModulesDir . '/' . $moduleId;
$targetExists = file_exists($targetPath) || is_link($targetPath);
if ($targetExists && $normalizedMode === 'symlink' && is_link($targetPath)) {
$targetReal = realpath($targetPath);
$sourceReal = realpath($moduleWebDir);
if ($targetReal !== false && $sourceReal !== false && $targetReal === $sourceReal) {
$result['unchanged']++;
continue;
}
}
if ($targetExists) {
$this->removePath($targetPath);
}
if ($normalizedMode === 'symlink') {
if (!@symlink($moduleWebDir, $targetPath)) {
throw new RuntimeException(
"Failed to symlink module assets for '{$moduleId}' ({$moduleWebDir} -> {$targetPath}). " .
"Set APP_MODULE_ASSET_MODE=copy if symlinks are unavailable."
);
}
} else {
$this->copyDirectory($moduleWebDir, $targetPath);
}
if ($targetExists) {
$result['updated']++;
} else {
$result['created']++;
}
}
return $result;
}
private function removePath(string $path): void
{
if (is_link($path)) {
if (!unlink($path)) {
throw new RuntimeException("Cannot remove symlink: {$path}");
}
return;
}
if (is_file($path)) {
if (!unlink($path)) {
throw new RuntimeException("Cannot remove file: {$path}");
}
return;
}
if (!is_dir($path)) {
return;
}
$entries = scandir($path);
if ($entries === false) {
throw new RuntimeException("Cannot scan directory: {$path}");
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$this->removePath($path . '/' . $entry);
}
if (!rmdir($path)) {
throw new RuntimeException("Cannot remove directory: {$path}");
}
}
private function copyDirectory(string $sourceDir, string $targetDir): void
{
if (!is_dir($sourceDir)) {
throw new RuntimeException("Source module asset directory not found: {$sourceDir}");
}
if (!is_dir($targetDir) && !mkdir($targetDir, 0775, true) && !is_dir($targetDir)) {
throw new RuntimeException("Cannot create target module asset directory: {$targetDir}");
}
$entries = scandir($sourceDir);
if ($entries === false) {
throw new RuntimeException("Cannot scan source module asset directory: {$sourceDir}");
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$source = $sourceDir . '/' . $entry;
$target = $targetDir . '/' . $entry;
if (is_link($source)) {
$linkTarget = readlink($source);
if ($linkTarget === false || !symlink($linkTarget, $target)) {
throw new RuntimeException("Cannot copy symlink '{$source}' to '{$target}'");
}
continue;
}
if (is_dir($source)) {
$this->copyDirectory($source, $target);
continue;
}
if (!is_file($source)) {
continue;
}
if (!copy($source, $target)) {
throw new RuntimeException("Cannot copy file '{$source}' to '{$target}'");
}
}
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace MintyPHP\App\Module;
use FilesystemIterator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use RuntimeException;
/**
* Builds a runtime PageRoot by symlinking core and enabled module pages.
*/
final class ModuleRuntimePageBuilder
{
/**
* @param array<string, ModuleManifest> $modules
* @return array{core_entries: int, module_entries: int}
*/
public function build(string $runtimeDir, string $corePagesDir, array $modules): array
{
$corePages = realpath($corePagesDir);
if ($corePages === false) {
throw new RuntimeException('Core pages/ directory not found.');
}
if (count($modules) === 0) {
$this->pointRuntimeToCore($runtimeDir, $corePages);
return ['core_entries' => 0, 'module_entries' => 0];
}
if (is_link($runtimeDir)) {
unlink($runtimeDir);
}
if (!is_dir($runtimeDir) && !mkdir($runtimeDir, 0775, true) && !is_dir($runtimeDir)) {
throw new RuntimeException('Cannot create runtime pages directory: ' . $runtimeDir);
}
$this->removeDirectoryContents($runtimeDir);
$coreEntries = scandir($corePages);
if ($coreEntries === false) {
throw new RuntimeException('Cannot scan core pages directory.');
}
$coreCount = 0;
foreach ($coreEntries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$source = $corePages . '/' . $entry;
$target = $runtimeDir . '/' . $entry;
if (!@symlink($source, $target)) {
throw new RuntimeException("Failed to create symlink: {$target}{$source}");
}
$coreCount++;
}
$modulePageCount = 0;
foreach ($modules as $manifest) {
$modulePagesDir = $manifest->basePath . '/pages';
if (!is_dir($modulePagesDir)) {
continue;
}
$entries = scandir($modulePagesDir);
if ($entries === false) {
continue;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$source = $modulePagesDir . '/' . $entry;
$target = $runtimeDir . '/' . $entry;
if (file_exists($target) || is_link($target)) {
throw new RuntimeException(
"Page path collision: '{$entry}' from module '{$manifest->id}' conflicts with existing page."
);
}
if (!@symlink($source, $target)) {
throw new RuntimeException("Failed to create symlink: {$target}{$source}");
}
$modulePageCount++;
}
}
return [
'core_entries' => $coreCount,
'module_entries' => $modulePageCount,
];
}
public function pointRuntimeToCore(string $runtimeDir, string $corePages): void
{
if (is_dir($runtimeDir) && !is_link($runtimeDir)) {
$this->removeDirectoryContents($runtimeDir);
rmdir($runtimeDir);
}
if (is_link($runtimeDir)) {
unlink($runtimeDir);
}
if (!@symlink($corePages, $runtimeDir)) {
throw new RuntimeException("Failed to create symlink: {$runtimeDir}{$corePages}");
}
}
private function removeDirectoryContents(string $dir): void
{
$entries = scandir($dir);
if ($entries === false) {
return;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$path = $dir . '/' . $entry;
if (is_link($path)) {
unlink($path);
continue;
}
if (is_dir($path)) {
$items = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($path, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
$item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname());
}
rmdir($path);
continue;
}
unlink($path);
}
}
}

View File

@@ -44,8 +44,9 @@ $moduleRegistry = ModuleRegistry::boot($modulesDir, $enabledModules);
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry);
$coreKeys = []; // snapshot keys before module registrars
// We use reflection-free approach: track what the container had before modules
// Disallow overriding existing core bindings from module registrars.
$container->protectExistingBindings();
foreach ($moduleRegistry->getContainerRegistrars() as $registrarClass) {
if (!class_exists($registrarClass)) {
throw new RuntimeException("Module container registrar class not found: {$registrarClass}");

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ $disabledAttr = $isReadOnly ? 'disabled' : '';
?>
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-department-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="admin-department-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
<div class="app-tabs-nav">
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
<button type="button" data-tab="visibility"><?php e(t('Visibility')); ?></button>

View File

@@ -32,7 +32,7 @@ $keyReadonlyAttr = ($isReadOnly || !empty($values['is_system'])) ? 'readonly' :
?>
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-permission-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="admin-permission-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
<div class="app-tabs-nav">
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
<?php if ($roles): ?>

View File

@@ -31,7 +31,7 @@ $selectedAssignableRoleIds = is_array($selectedAssignableRoleIds ?? null) ? $sel
?>
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-role-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="admin-role-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
<div class="app-tabs-nav">
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
<button type="button" data-tab="permissions"><?php e(t('Permissions')); ?></button>

View File

@@ -128,7 +128,7 @@ $disabledAttr = $isReadOnly ? 'disabled' : '';
<form id="settings-form" method="post" data-details-storage="settings-main-details-v1" data-standard-detail-form="1">
<input type="hidden" name="settings_submit" value="1">
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-settings-tabs-v1">
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="admin-settings-tabs-v1">
<div class="app-tabs-nav">
<button type="button" data-tab="master-data" data-tab-default><?php e(t('Master data')); ?></button>
<button type="button" data-tab="appearance"><?php e(t('Appearance')); ?></button>

View File

@@ -123,7 +123,7 @@ require templatePath('partials/app-breadcrumb.phtml');
</div>
<hr>
<div class="app-dashboard">
<div class="app-tabs" data-tabs id="stats-tabs">
<div class="app-tabs" data-tabs data-app-component="tabs" id="stats-tabs">
<!-- Tab Navigation -->
<div class="app-tabs-nav">
<button data-tab="users" data-tab-default class="transparent tab-button">

View File

@@ -144,7 +144,7 @@ $openOverrideCard = $detailsOpenAll;
?>
<form id="<?php e($formId); ?>" method="post" data-standard-detail-form="1">
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-tenant-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="admin-tenant-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
<div class="app-tabs-nav">
<button type="button" data-tab="basic" data-tab-default><?php e(t('Master data')); ?></button>
<?php if ($showCustomFieldsTab): ?>

View File

@@ -122,7 +122,7 @@ foreach ($tenants as $tenant) {
?>
<form id="<?php e($formId); ?>" method="post" data-details-storage="admin-user-form-details-v1" data-standard-detail-form="1">
<div class="app-tabs" data-tabs data-tabs-param="tab" data-tabs-storage-key="admin-user-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
<div class="app-tabs" data-tabs data-app-component="tabs" data-tabs-param="tab" data-tabs-storage-key="admin-user-form"<?php if (!empty($ignoreTabStorage)) { echo ' data-tabs-ignore-storage'; } ?>>
<div class="app-tabs-nav">
<button type="button" data-tab="profile" data-tab-default><?php e(t('Master data')); ?></button>
<?php if ($showOrganizationTab): ?>

View File

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

View File

@@ -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 !== '') {
<?php endif; ?>
<?php
$asideActions = [];
if ($canViewAddressBook) {
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
$moduleAsideActionSlots = is_array($moduleSlots['user.edit.aside_action'] ?? null) ? $moduleSlots['user.edit.aside_action'] : [];
$slotContextMap = [
'{user_id}' => (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) {

View File

@@ -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([

View File

@@ -40,7 +40,14 @@
<?php if (file_exists('web/vendor/editorjs/tools/marker.umd.js')): ?>
<script src="<?php e(assetVersion('vendor/editorjs/tools/marker.umd.js')); ?>"></script>
<?php endif; ?>
<script type="module" src="<?php e(assetVersion('js/components/app-page-editor.js')); ?>"></script>
<script type="application/json" id="page-config-page-editor"><?php gridJsonForJs([
'selector' => '[data-page-editor]',
'holderSelector' => '[data-editor-holder]',
'contentFieldSelector' => '#page-content',
'toggleSelector' => '[data-editor-toggle]',
'saveSelector' => '[data-editor-save]',
]); ?></script>
<script type="module" src="<?php e(assetVersion('js/pages/app-page-editor-entry.js')); ?>"></script>
<?php endif; ?>
</section>
<?php if ($canEdit): ?>

View File

@@ -40,7 +40,14 @@
<?php if (file_exists('web/vendor/editorjs/tools/marker.umd.js')): ?>
<script src="<?php e(assetVersion('vendor/editorjs/tools/marker.umd.js')); ?>"></script>
<?php endif; ?>
<script type="module" src="<?php e(assetVersion('js/components/app-page-editor.js')); ?>"></script>
<script type="application/json" id="page-config-page-editor"><?php gridJsonForJs([
'selector' => '[data-page-editor]',
'holderSelector' => '[data-editor-holder]',
'contentFieldSelector' => '#page-content',
'toggleSelector' => '[data-editor-toggle]',
'saveSelector' => '[data-editor-save]',
]); ?></script>
<script type="module" src="<?php e(assetVersion('js/pages/app-page-editor-entry.js')); ?>"></script>
<?php endif; ?>
</section>
<?php if ($canEdit): ?>

View File

@@ -6,6 +6,7 @@ parameters:
paths:
- config
- lib
- modules
- pages
- tests
fileExtensions:

View File

@@ -6,6 +6,7 @@
<testsuites>
<testsuite name="CoreCore">
<directory>tests</directory>
<directory>modules/*/tests</directory>
</testsuite>
</testsuites>
</phpunit>

View File

@@ -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<mixed> $value
*/
$isAssocArray = static function (array $value): bool {
if ($value === []) {
return false;
}
return array_keys($value) !== range(0, count($value) - 1);
};
/**
* @param array<string, mixed> $target
* @param array<string, mixed> $source
* @return array<string, mixed>
*/
$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<string, mixed> $root
* @param array<string, mixed> $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;
?>
<!DOCTYPE html>
@@ -43,8 +297,8 @@ if ($bufferTitle !== '') {
data-csrf-token="<?php e($csrfToken); ?>"
<?php if ($isLoggedIn): ?>
data-session-idle-seconds="<?php e((string) $sessionIdleSeconds); ?>"
data-session-ping-url="<?php e(lurl('admin/session-ping/data')); ?>"
data-session-logout-url="<?php e(lurl('logout')); ?>"
data-session-ping-url="<?php e($sessionPingUrl); ?>"
data-session-logout-url="<?php e($sessionLogoutUrl); ?>"
<?php endif; ?>
class="no-js"
<?php if ($primaryVars !== ''): ?>style="<?php e($primaryVars); ?>" <?php endif; ?>
@@ -62,6 +316,7 @@ if ($bufferTitle !== '') {
<link rel="apple-touch-icon" href="<?php e(appFaviconUrl('apple-touch-icon.png')); ?>">
<link rel="manifest" href="<?php e(asset('favicon/site.webmanifest')); ?>">
<?php renderTemplateStyles('default'); ?>
<?php renderStylePaths($moduleHeadStylePaths); ?>
<?php renderPageStyles(); ?>
</head>
@@ -86,10 +341,24 @@ if ($bufferTitle !== '') {
<?php require __DIR__ . '/partials/app-confirm-dialog.phtml'; ?>
<?php if ($isLoggedIn): ?>
<?php require __DIR__ . '/partials/app-session-warning-dialog.phtml'; ?>
<?php require __DIR__ . '/partials/app-bookmark-dialog.phtml'; ?>
<script type="module" src="<?php e(assetVersion('js/components/app-session-warning.js')); ?>"></script>
<script type="module" src="<?php e(assetVersion('js/components/app-bookmark-init.js')); ?>"></script>
<?php endif; ?>
<?php foreach ($moduleBodyEndTemplateSlots as $slot): ?>
<?php
if (!is_array($slot)) {
continue;
}
$slotPermission = trim((string) ($slot['permission'] ?? ''));
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
continue;
}
$slotTemplate = trim((string) ($slot['template'] ?? ''));
if ($slotTemplate === '' || !is_file($slotTemplate)) {
continue;
}
include $slotTemplate;
?>
<?php endforeach; ?>
<script type="application/json" id="page-config-app-components"><?php gridJsonForJs($componentPageConfig); ?></script>
<script type="module" src="<?php e(assetVersion('js/app-init.js')); ?>"></script>
</body>

View File

@@ -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"]',
],
],
];
?>
<!DOCTYPE html>
@@ -48,6 +67,7 @@ if ($bufferTitle !== '') {
</main>
<?php require __DIR__ . '/partials/app-footer.phtml'; ?>
<div id="gradient"></div>
<script type="application/json" id="page-config-login-components"><?php gridJsonForJs($loginComponentConfig); ?></script>
<script type="module" src="<?php e(assetVersion('js/app-login-init.js')); ?>"></script>
</body>

View File

@@ -18,7 +18,7 @@ $timeouts = [
];
?>
<div class="flash-stack" role="status" aria-live="polite">
<div class="flash-stack" data-app-component="flash-auto-dismiss" role="status" aria-live="polite">
<?php foreach ($messages as $message) : ?>
<?php $type = $message['type'] ?? 'info'; ?>
<?php $timeout = $timeouts[$type] ?? 5000; ?>

View File

@@ -51,14 +51,6 @@ $hasAdminPanel = layoutHasAdminPanel($layoutAuth);
</button>
</li>
<?php endforeach; ?>
<li>
<button type="button" id="aside-tab-bookmarks" data-aside-target="bookmarks" role="tab"
aria-selected="false" aria-controls="aside-panel-bookmarks"
aria-label="<?php e(t('Bookmarks')); ?>"
data-tooltip="<?php e(t('Bookmarks')); ?>" data-tooltip-pos="right">
<i class="bi bi-bookmark-star"></i>
</button>
</li>
<?php if ($hasAdminPanel): ?>
<li>
<button type="button" id="aside-tab-admin" data-aside-target="admin" role="tab" aria-selected="false"

View File

@@ -1,7 +1,6 @@
<?php
use MintyPHP\Service\Docs\DocsCatalogService;
use MintyPHP\Support\BookmarkUrlNormalizer;
$layoutAuth = is_array($viewAuth['layout'] ?? null) ? $viewAuth['layout'] : [];
$canViewTenants = (bool) ($layoutAuth['can_view_tenants'] ?? false);
@@ -255,6 +254,9 @@ $tenantName = trim((string) ($tenantAvatar['name'] ?? ''));
$hasTenantAvatar = !empty($tenantAvatar['hasAvatar']);
$csrfKey = trim((string) ($layoutNav['csrfKey'] ?? \MintyPHP\Session::$csrfSessionKey));
$csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
$modulePanelSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleSlots['aside.tab_panel'] : [];
$moduleSearchSlots = is_array($moduleSlots['search.resource_item'] ?? null) ? $moduleSlots['search.resource_item'] : [];
?>
<aside class="app-sidebar" id="app-sidebar" data-app-component="sidebar-toggle">
<?php if ($currentTenant): ?>
@@ -302,8 +304,6 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
<?php
// ── Module-contributed aside panels (aside.tab_panel slot) ──
$moduleSlots = is_array($layoutNav['moduleSlots'] ?? null) ? $layoutNav['moduleSlots'] : [];
$modulePanelSlots = is_array($moduleSlots['aside.tab_panel'] ?? null) ? $moduleSlots['aside.tab_panel'] : [];
foreach ($modulePanelSlots as $panelSlot):
if (!is_array($panelSlot)) { continue; }
$panelKey = $panelSlot['key'] ?? '';
@@ -357,17 +357,6 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endif; ?>
<?php if ($canViewAddressBook): ?>
<?php $addressBookSearch = navActive('address-book', true); ?>
<li data-search-key="address-book" data-search-base="<?php e(lurl('address-book')); ?>">
<a href="<?php e(lurl('address-book')); ?>" class="<?php e($addressBookSearch['class'] ?? ''); ?>"
<?php echo $addressBookSearch['aria'] ?? ''; ?>>
<span><?php e(t('Address book')); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endif; ?>
<?php if ($canViewTenants): ?>
<?php $tenantsSearch = navActive('admin/tenants', true); ?>
<li data-search-key="tenants" data-search-base="<?php e(lurl('admin/tenants')); ?>">
@@ -482,246 +471,49 @@ $csrfToken = (string) ($layoutNav['csrfToken'] ?? '');
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endif; ?>
</ul>
</nav>
</div>
<?php
$bookmarkData = is_array($layoutNav['bookmarks'] ?? null) ? $layoutNav['bookmarks'] : ['groups' => [], '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());
?>
<nav id="aside-panel-bookmarks" class="app-sidebar-panel" data-app-component="bookmark-panel" data-aside-panel="bookmarks"
data-aside-title="<?php e(t('Bookmarks')); ?>"
data-aside-details-storage="aside-bookmarks-groups-v1"
data-bookmark-reorder-url="<?php e(lurl('bookmarks/reorder-data')); ?>"
data-bookmark-group-delete-url="<?php e(lurl('bookmarks/group-delete-data')); ?>"
data-bookmark-delete-url="<?php e(lurl('bookmarks/delete-data')); ?>"
data-bookmark-msg-group-delete-confirm="<?php e(t('Delete group and keep bookmarks?')); ?>"
data-bookmark-msg-group-deleted="<?php e(t('Group deleted')); ?>"
data-bookmark-msg-group-updated="<?php e(t('Group updated')); ?>"
data-bookmark-msg-bookmark-delete-confirm="<?php e(t('Delete this bookmark?')); ?>"
data-bookmark-msg-bookmark-deleted="<?php e(t('Bookmark deleted')); ?>"
data-bookmark-msg-group-action-failed="<?php e(t('Group action failed')); ?>"
data-bookmark-msg-bookmark-action-failed="<?php e(t('Bookmark action failed')); ?>"
data-bookmark-msg-group-delete-failed="<?php e(t('Group delete failed')); ?>"
data-bookmark-msg-bookmark-delete-failed="<?php e(t('Bookmark action failed')); ?>"
data-bookmark-msg-reorder-saved="<?php e(t('Reorder saved')); ?>"
data-bookmark-msg-reorder-failed="<?php e(t('Reorder failed')); ?>"
data-bookmark-label-delete="<?php e(t('Delete')); ?>"
role="tabpanel" aria-labelledby="aside-tab-bookmarks" hidden>
<?php if (!$bookmarkGroups && !$bookmarkUngrouped): ?>
<?php
$emptyState = [
'message' => t('No bookmarks yet'),
'size' => 'compact',
'align' => 'center',
$coreSearchKeys = [
'users' => 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,
];
require templatePath('partials/app-empty-state.phtml');
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);
}
?>
<?php else: ?>
<?php
$bookmarkRootItems = [];
foreach ($bookmarkUngrouped as $bm) {
$bmId = (int) ($bm['id'] ?? 0);
if ($bmId <= 0) {
continue;
}
$bookmarkRootItems[] = [
'type' => 'bookmark',
'id' => $bmId,
'sort_order' => (int) ($bm['sort_order'] ?? 0),
'bookmark' => $bm,
];
}
foreach ($bookmarkGroups as $group) {
$gId = (int) ($group['id'] ?? 0);
if ($gId <= 0) {
continue;
}
$bookmarkRootItems[] = [
'type' => 'group',
'id' => $gId,
'sort_order' => (int) ($group['sort_order'] ?? 0),
'group' => $group,
];
}
usort($bookmarkRootItems, static function (array $a, array $b): int {
$sortCmp = ((int) ($a['sort_order'] ?? 0)) <=> ((int) ($b['sort_order'] ?? 0));
if ($sortCmp !== 0) {
return $sortCmp;
}
$typeCmp = strcmp((string) ($a['type'] ?? ''), (string) ($b['type'] ?? ''));
if ($typeCmp !== 0) {
return $typeCmp;
}
return ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0));
});
$rootCount = count($bookmarkRootItems);
?>
<ul data-bookmark-root-list>
<?php foreach ($bookmarkRootItems as $rootIndex => $rootItem): ?>
<?php
$rootType = (string) ($rootItem['type'] ?? '');
$rootFirst = $rootIndex === 0;
$rootLast = $rootIndex === ($rootCount - 1);
?>
<?php if ($rootType === 'bookmark'): ?>
<?php
$bm = is_array($rootItem['bookmark'] ?? null) ? $rootItem['bookmark'] : [];
$bmId = (int) ($bm['id'] ?? 0);
$bmName = trim((string) ($bm['name'] ?? ''));
$bmUrl = trim((string) ($bm['url'] ?? ''));
$bmCanonicalUrl = BookmarkUrlNormalizer::canonicalizeRelative($bmUrl);
$bmActive = $bmCanonicalUrl !== '' && $currentPath === $bmCanonicalUrl;
?>
<li class="app-sidebar-bookmark-root-item app-sidebar-bookmark-item"
data-bookmark-root-kind="bookmark"
data-bookmark-root-id="<?php e($bmId); ?>"
data-bookmark-id="<?php e($bmId); ?>"
data-bookmark-name="<?php e($bmName); ?>"
data-bookmark-group-id="">
<a href="<?php e(lurl($bmUrl)); ?>"
class="<?php e($bmActive ? 'active' : ''); ?>"
<?php echo $bmActive ? 'aria-current="page"' : ''; ?>>
<?php e($bmName); ?>
<li data-search-key="<?php e($slotKey); ?>" data-search-base="<?php e($slotHref); ?>">
<a href="<?php e($slotHref); ?>" class="<?php e($slotActive['class'] ?? ''); ?>"
<?php echo $slotActive['aria'] ?? ''; ?>>
<span><?php e(t($slotLabel)); ?></span>
<span class="badge" data-search-count>0</span>
</a>
<details class="app-sidebar-bookmark-action-menu" data-bookmark-action-menu>
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
<i class="bi bi-three-dots"></i>
</summary>
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Bookmark actions')); ?>">
<li>
<button type="button" role="menuitem" data-bookmark-item-action="edit">
<?php e(t('Edit')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" data-bookmark-item-action="move-up" data-bookmark-item-action-scope="root" <?php if ($rootFirst): ?>disabled<?php endif; ?>>
<?php e(t('Move bookmark up')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" data-bookmark-item-action="move-down" data-bookmark-item-action-scope="root" <?php if ($rootLast): ?>disabled<?php endif; ?>>
<?php e(t('Move bookmark down')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-item-action="delete">
<?php e(t('Delete bookmark')); ?>
</button>
</li>
</ul>
</details>
</li>
<?php else: ?>
<?php
$group = is_array($rootItem['group'] ?? null) ? $rootItem['group'] : [];
$gId = (int) ($group['id'] ?? 0);
$gName = trim((string) ($group['name'] ?? ''));
$gIcon = trim((string) ($group['icon'] ?? 'bi-folder'));
$gBookmarks = is_array($group['bookmarks'] ?? null) ? $group['bookmarks'] : [];
?>
<li class="app-sidebar-group app-sidebar-bookmark-group app-sidebar-bookmark-root-item"
data-bookmark-root-kind="group"
data-bookmark-root-id="<?php e($gId); ?>"
data-bookmark-group-id="<?php e($gId); ?>"
data-bookmark-group-name="<?php e($gName); ?>"
data-bookmark-group-icon="<?php e($gIcon); ?>">
<div class="app-sidebar-bookmark-group-shell">
<div class="app-sidebar-bookmark-group-header">
<small class="app-sidebar-bookmark-group-label">
<i class="bi <?php e($gIcon); ?>" aria-hidden="true"></i>
<span><?php e($gName); ?></span>
</small>
<details class="app-sidebar-bookmark-action-menu app-sidebar-bookmark-group-menu" data-bookmark-action-menu>
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
<i class="bi bi-three-dots"></i>
</summary>
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Group actions')); ?>">
<li>
<button type="button" role="menuitem" data-bookmark-group-action="edit">
<?php e(t('Edit')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" data-bookmark-group-action="move-up" <?php if ($rootFirst): ?>disabled<?php endif; ?>>
<?php e(t('Move group up')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" data-bookmark-group-action="move-down" <?php if ($rootLast): ?>disabled<?php endif; ?>>
<?php e(t('Move group down')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-group-action="delete">
<?php e(t('Delete group')); ?>
</button>
</li>
</ul>
</details>
</div>
<?php if ($gBookmarks): ?>
<ul data-bookmark-group-bookmark-list>
<?php $groupBookmarkCount = count($gBookmarks); ?>
<?php foreach ($gBookmarks as $bookmarkIndex => $bm): ?>
<?php
$bmId = (int) ($bm['id'] ?? 0);
$bmName = trim((string) ($bm['name'] ?? ''));
$bmUrl = trim((string) ($bm['url'] ?? ''));
$bmCanonicalUrl = BookmarkUrlNormalizer::canonicalizeRelative($bmUrl);
$bmActive = $bmCanonicalUrl !== '' && $currentPath === $bmCanonicalUrl;
$bookmarkFirst = $bookmarkIndex === 0;
$bookmarkLast = $bookmarkIndex === ($groupBookmarkCount - 1);
?>
<li class="app-sidebar-bookmark-item"
data-bookmark-id="<?php e($bmId); ?>"
data-bookmark-name="<?php e($bmName); ?>"
data-bookmark-group-id="<?php e($gId); ?>">
<a href="<?php e(lurl($bmUrl)); ?>"
class="<?php e($bmActive ? 'active' : ''); ?>"
<?php echo $bmActive ? 'aria-current="page"' : ''; ?>>
<?php e($bmName); ?>
</a>
<details class="app-sidebar-bookmark-action-menu" data-bookmark-action-menu>
<summary class="icon-button" title="<?php e(t('Actions')); ?>" aria-label="<?php e(t('Actions')); ?>">
<i class="bi bi-three-dots"></i>
</summary>
<ul class="app-sidebar-bookmark-action-menu-list" role="menu" aria-label="<?php e(t('Bookmark actions')); ?>">
<li>
<button type="button" role="menuitem" data-bookmark-item-action="edit">
<?php e(t('Edit')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" data-bookmark-item-action="move-up" <?php if ($bookmarkFirst): ?>disabled<?php endif; ?>>
<?php e(t('Move bookmark up')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" data-bookmark-item-action="move-down" <?php if ($bookmarkLast): ?>disabled<?php endif; ?>>
<?php e(t('Move bookmark down')); ?>
</button>
</li>
<li>
<button type="button" role="menuitem" class="app-sidebar-bookmark-action-danger" data-bookmark-item-action="delete">
<?php e(t('Delete bookmark')); ?>
</button>
</li>
</ul>
</details>
<ul class="app-search-preview" data-search-preview></ul>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</div>
</li>
<?php endif; ?>
<?php endforeach; ?>
</ul>
<?php endif; ?>
</nav>
</div>
</div>
</aside>

View File

@@ -1,5 +1,6 @@
<dialog
data-app-session-warning-dialog
data-app-component="session-warning"
aria-labelledby="app-session-warning-title"
aria-describedby="app-session-warning-message"
aria-live="assertive"

View File

@@ -1,7 +1,6 @@
<?php
use MintyPHP\Session;
use MintyPHP\Support\BookmarkUrlNormalizer;
$user = $_SESSION['user'] ?? [];
$accountUrl = accountUrl();
@@ -22,26 +21,13 @@ if ($tenantLabel === '') {
}
$canSwitchTenant = is_array($currentTenant) && count($availableTenants) > 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'] : [];
?>
<header class="app-header">
<header class="app-header" data-app-component="nav-history">
<nav class="app-topbar">
<ul class="app-topbar-left">
<li>
@@ -55,7 +41,7 @@ if ($bmExisting === null) {
<div class="app-topbar-center" aria-hidden="true"></div>
<ul class="app-topbar-right">
<?php if ($canSwitchTenant): ?>
<li class="app-topbar-tenant-slot" data-tenant-switcher data-tooltip="<?php e(t('Tenant')); ?>" data-tooltip-pos="bottom">
<li class="app-topbar-tenant-slot" data-app-component="tenant-switcher" data-tenant-switcher data-tooltip="<?php e(t('Tenant')); ?>" data-tooltip-pos="bottom">
<details class="dropdown app-topbar-tenant-menu">
<summary class="app-topbar-tenant-chip" aria-label="<?php e(t('Switch tenant')); ?>" title="<?php e($tenantLabel); ?>">
<span class="app-topbar-tenant-name"><?php e($tenantLabel); ?></span>
@@ -93,20 +79,24 @@ if ($bmExisting === null) {
</details>
</li>
<?php endif; ?>
<?php foreach ($moduleTopbarSlots as $slot): ?>
<?php
if (!is_array($slot)) {
continue;
}
$slotPermission = trim((string) ($slot['permission'] ?? ''));
if ($slotPermission !== '' && empty($layoutAuth[$slotPermission])) {
continue;
}
$slotTemplate = trim((string) ($slot['template'] ?? ''));
if ($slotTemplate === '' || !is_file($slotTemplate)) {
continue;
}
include $slotTemplate;
?>
<?php endforeach; ?>
<li>
<button type="button" data-bookmark-open-dialog
aria-label="<?php e($bmExisting ? t('Edit bookmark') : t('Bookmarks')); ?>"
data-tooltip="<?php e($bmExisting ? t('Edit bookmark') : t('Bookmarks')); ?>" data-tooltip-pos="bottom"
<?php if ($bmExisting): ?>
data-bookmark-existing-id="<?php e((int) ($bmExisting['id'] ?? 0)); ?>"
data-bookmark-existing-name="<?php e(trim((string) ($bmExisting['name'] ?? ''))); ?>"
data-bookmark-existing-group="<?php e($bmExisting['group_id'] ?? ''); ?>"
<?php endif; ?>>
<i class="bi <?php e($bmExisting ? 'bi-bookmark-check-fill' : 'bi-bookmark-plus'); ?>"></i>
</button>
</li>
<li>
<details class="dropdown app-topbar-user-menu"
<details class="dropdown app-topbar-user-menu" data-app-component="theme-controls"
<?php if ($allowUserTheme): ?>
data-theme-menu
data-theme-url="admin/users/theme"
@@ -125,7 +115,7 @@ if ($bmExisting === null) {
</button>
</li>
<li class="app-topbar-menu-item-detail-sidebar">
<button type="button" id="toggle-main-content-aside" aria-label="<?php e(t('Toggle Detail Sidebar')); ?>">
<button type="button" id="toggle-main-content-aside" data-app-component="details-aside-toggle" aria-label="<?php e(t('Toggle Detail Sidebar')); ?>">
<?php e(t('Toggle Detail Sidebar')); ?>
</button>
</li>
@@ -147,7 +137,7 @@ if ($bmExisting === null) {
<?php endforeach; ?>
<?php endif; ?>
<li>
<button type="button" data-contrast-toggle aria-label="<?php e(t('Toggle contrast')); ?>" aria-pressed="false">
<button type="button" data-app-component="contrast-toggle" data-contrast-toggle aria-label="<?php e(t('Toggle contrast')); ?>" aria-pressed="false">
<?php e(t('High contrast')); ?>
</button>
</li>

View File

@@ -0,0 +1,40 @@
<?php
namespace MintyPHP\Tests\App;
use MintyPHP\App\AppContainer;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class AppContainerTest extends TestCase
{
public function testOverwritingBindingIsAllowedBeforeProtection(): void
{
$container = new AppContainer();
$container->set('service.test', static fn (): string => 'first');
$container->set('service.test', static fn (): string => 'second');
self::assertSame('second', $container->get('service.test'));
}
public function testOverwritingExistingBindingIsBlockedAfterProtection(): void
{
$container = new AppContainer();
$container->set('service.test', static fn (): string => 'first');
$container->protectExistingBindings();
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Refusing to overwrite existing service binding: service.test');
$container->set('service.test', static fn (): string => 'second');
}
public function testAddingNewBindingStillWorksAfterProtection(): void
{
$container = new AppContainer();
$container->set('service.first', static fn (): string => 'first');
$container->protectExistingBindings();
$container->set('service.second', static fn (): string => 'second');
self::assertSame('second', $container->get('service.second'));
}
}

View File

@@ -35,10 +35,32 @@ final class ModuleManifestTest extends TestCase
'ui_slots' => ['aside.tab_panel' => [['key' => 'people', 'label' => 'People']]],
'search_resources' => ['App\\SearchProvider'],
'asset_groups' => ['address-book' => ['css/view.css']],
'scheduler_jobs' => [],
'scheduler_jobs' => [
[
'job_key' => 'addressbook.sync',
'handler' => 'App\\Scheduler\\AddressBookSyncHandler',
'label' => 'Address book sync',
'description' => 'Syncs address data',
'default_enabled' => true,
'default_timezone' => 'Europe/Berlin',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '02:00',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => true,
'allowed_schedule_types' => ['daily', 'weekly'],
],
],
'layout_context_providers' => ['App\\LayoutProvider'],
'session_providers' => ['App\\SessionProvider'],
'permissions' => ['address_book.view'],
'permissions' => [
[
'key' => 'address_book.view',
'description' => 'Can view address book',
'active' => true,
'is_system' => true,
],
],
'migrations_path' => 'db/updates',
];
@@ -49,7 +71,13 @@ final class ModuleManifestTest extends TestCase
self::assertTrue($manifest->enabledByDefault);
self::assertSame(10, $manifest->loadOrder);
self::assertCount(1, $manifest->routes);
self::assertSame(['address_book.view'], $manifest->permissions);
self::assertSame([[
'key' => 'address_book.view',
'description' => 'Can view address book',
'active' => 1,
'is_system' => 1,
]], $manifest->permissions);
self::assertSame('addressbook.sync', $manifest->schedulerJobs[0]['job_key']);
self::assertSame('/srv/modules/addressbook/db/updates', $manifest->migrationsPath);
self::assertSame(['App\\LayoutProvider'], $manifest->layoutContextProviders);
self::assertSame(['App\\SessionProvider'], $manifest->sessionProviders);
@@ -69,4 +97,38 @@ final class ModuleManifestTest extends TestCase
ModuleManifest::fromArray(['id' => ' '], '/tmp/modules/broken');
}
public function testInvalidPermissionContractThrows(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("must define non-empty 'key' and 'description'");
ModuleManifest::fromArray([
'id' => 'mod-invalid-perm',
'permissions' => [['key' => 'mod.permission']],
], '/tmp/modules/mod-invalid-perm');
}
public function testInvalidSchedulerJobContractThrows(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage("missing required key 'allowed_schedule_types'");
ModuleManifest::fromArray([
'id' => 'mod-invalid-job',
'scheduler_jobs' => [[
'job_key' => 'mod.job',
'handler' => 'App\\Handler',
'label' => 'Label',
'description' => 'Desc',
'default_enabled' => true,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '01:00',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => true,
]],
], '/tmp/modules/mod-invalid-job');
}
}

View File

@@ -0,0 +1,181 @@
<?php
namespace MintyPHP\Tests\App\Module;
use MintyPHP\App\Module\ModulePermissionSynchronizer;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Repository\Access\PermissionRepositoryInterface;
use PHPUnit\Framework\TestCase;
final class ModulePermissionSynchronizerTest extends TestCase
{
private string $fixturesDir = '';
protected function setUp(): void
{
$this->fixturesDir = sys_get_temp_dir() . '/module-permission-sync-' . uniqid();
mkdir($this->fixturesDir, 0777, true);
}
protected function tearDown(): void
{
$this->removeDir($this->fixturesDir);
}
public function testSyncCreatesPermissionAndIsIdempotent(): void
{
$registry = $this->createRegistryWithPermissions([[
'key' => 'address_book.view',
'description' => 'Can view address book',
'active' => 1,
'is_system' => 1,
]]);
$repository = new InMemoryPermissionRepository();
$synchronizer = new ModulePermissionSynchronizer($registry, $repository);
$first = $synchronizer->sync();
self::assertSame(1, $first['created']);
self::assertSame(0, $first['updated']);
self::assertSame(0, $first['unchanged']);
$second = $synchronizer->sync();
self::assertSame(0, $second['created']);
self::assertSame(0, $second['updated']);
self::assertSame(1, $second['unchanged']);
}
public function testSyncUpdatesExistingPermissionWhenDefinitionChanged(): void
{
$registry = $this->createRegistryWithPermissions([[
'key' => 'address_book.view',
'description' => 'Can view address book',
'active' => 1,
'is_system' => 1,
]]);
$repository = new InMemoryPermissionRepository();
$repository->create([
'key' => 'address_book.view',
'description' => 'Old description',
'active' => 0,
'is_system' => 0,
]);
$synchronizer = new ModulePermissionSynchronizer($registry, $repository);
$result = $synchronizer->sync();
self::assertSame(0, $result['created']);
self::assertSame(1, $result['updated']);
self::assertSame(0, $result['unchanged']);
$permission = $repository->findByKey('address_book.view');
self::assertSame('Can view address book', $permission['description'] ?? null);
self::assertSame(1, (int) ($permission['active'] ?? 0));
self::assertSame(1, (int) ($permission['is_system'] ?? 0));
}
/**
* @param list<array{key: string, description: string, active: int, is_system: int}> $permissions
*/
private function createRegistryWithPermissions(array $permissions): ModuleRegistry
{
mkdir($this->fixturesDir . '/mod-perm', 0777, true);
file_put_contents(
$this->fixturesDir . '/mod-perm/module.php',
'<?php return ' . var_export([
'id' => 'mod-perm',
'permissions' => $permissions,
], true) . ';'
);
return ModuleRegistry::boot($this->fixturesDir, ['mod-perm']);
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
if ($item->isDir()) {
rmdir($item->getPathname());
} else {
unlink($item->getPathname());
}
}
rmdir($dir);
}
}
final class InMemoryPermissionRepository implements PermissionRepositoryInterface
{
/** @var array<int, array<string, mixed>> */
private array $rows = [];
private int $nextId = 1;
public function list(): array
{
return array_values($this->rows);
}
public function listActive(): array
{
return array_values(array_filter($this->rows, static fn (array $row): bool => (int) ($row['active'] ?? 0) === 1));
}
public function listPaged(array $options): array
{
return ['rows' => $this->list(), 'total' => count($this->rows)];
}
public function find(int $id): ?array
{
return $this->rows[$id] ?? null;
}
public function findByKey(string $key): ?array
{
foreach ($this->rows as $row) {
if ((string) ($row['key'] ?? '') === $key) {
return $row;
}
}
return null;
}
public function create(array $data): ?int
{
$id = $this->nextId++;
$this->rows[$id] = [
'id' => $id,
'key' => (string) ($data['key'] ?? ''),
'description' => (string) ($data['description'] ?? ''),
'active' => (int) ($data['active'] ?? 1),
'is_system' => (int) ($data['is_system'] ?? 1),
];
return $id;
}
public function update(int $id, array $data): bool
{
if (!isset($this->rows[$id])) {
return false;
}
$this->rows[$id]['key'] = (string) ($data['key'] ?? $this->rows[$id]['key']);
$this->rows[$id]['description'] = (string) ($data['description'] ?? $this->rows[$id]['description']);
$this->rows[$id]['active'] = (int) ($data['active'] ?? $this->rows[$id]['active']);
$this->rows[$id]['is_system'] = (int) ($data['is_system'] ?? $this->rows[$id]['is_system']);
return true;
}
public function delete(int $id): bool
{
unset($this->rows[$id]);
return true;
}
}

View File

@@ -43,7 +43,10 @@ final class ModuleRegistryTest extends TestCase
'id' => 'testmod',
'version' => '1.0.0',
'load_order' => 10,
'permissions' => ['test.view', 'test.edit'],
'permissions' => [
['key' => 'test.view', 'description' => 'Test view'],
['key' => 'test.edit', 'description' => 'Test edit'],
],
'search_resources' => [],
]);
@@ -51,7 +54,7 @@ final class ModuleRegistryTest extends TestCase
self::assertTrue($registry->hasModule('testmod'));
self::assertSame('1.0.0', $registry->getModule('testmod')->version);
self::assertSame(['test.view', 'test.edit'], $registry->getPermissions());
self::assertSame(['test.view', 'test.edit'], $registry->getPermissionKeys());
}
public function testDeterministicOrdering(): void
@@ -80,8 +83,16 @@ final class ModuleRegistryTest extends TestCase
public function testPermissionConflictThrows(): void
{
$this->createModuleManifest('mod-a', ['id' => 'mod-a', 'load_order' => 1, 'permissions' => ['shared.perm']]);
$this->createModuleManifest('mod-b', ['id' => 'mod-b', 'load_order' => 2, 'permissions' => ['shared.perm']]);
$this->createModuleManifest('mod-a', [
'id' => 'mod-a',
'load_order' => 1,
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared permission']],
]);
$this->createModuleManifest('mod-b', [
'id' => 'mod-b',
'load_order' => 2,
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared permission duplicate']],
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("Permission conflict: 'shared.perm'");
@@ -108,6 +119,137 @@ final class ModuleRegistryTest extends TestCase
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
}
public function testRoutePathConflictThrows(): void
{
$this->createModuleManifest('mod-a', [
'id' => 'mod-a',
'load_order' => 1,
'routes' => [['path' => '/shared-path', 'target' => 'a/index']],
]);
$this->createModuleManifest('mod-b', [
'id' => 'mod-b',
'load_order' => 2,
'routes' => [['path' => '/shared-path', 'target' => 'b/index']],
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage('Route path conflict');
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
}
public function testUiSlotConflictThrows(): void
{
$panelTemplateA = $this->createFixtureFile('mod-a-panel.phtml', '<ul></ul>');
$panelTemplateB = $this->createFixtureFile('mod-b-panel.phtml', '<ul></ul>');
$this->createModuleManifest('mod-a', [
'id' => 'mod-a',
'load_order' => 1,
'ui_slots' => [
'aside.tab_panel' => [
[
'key' => 'people',
'label' => 'People',
'icon' => 'bi-people',
'permission' => 'can_view_people',
'panel_template' => $panelTemplateA,
],
],
],
]);
$this->createModuleManifest('mod-b', [
'id' => 'mod-b',
'load_order' => 2,
'ui_slots' => [
'aside.tab_panel' => [
[
'key' => 'people',
'label' => 'People B',
'icon' => 'bi-people',
'permission' => 'can_view_people',
'panel_template' => $panelTemplateB,
],
],
],
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("UI slot conflict: slot 'aside.tab_panel' key 'people'");
ModuleRegistry::boot($this->fixturesDir, ['mod-a', 'mod-b']);
}
public function testInvalidRouteContractThrows(): void
{
$this->createModuleManifest('mod-a', [
'id' => 'mod-a',
'routes' => [['path' => '', 'target' => 'address-book/index']],
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("must define non-empty 'path' and 'target'");
ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
}
public function testInvalidUiSlotContractThrows(): void
{
$this->createModuleManifest('mod-a', [
'id' => 'mod-a',
'ui_slots' => [
'aside.tab_panel' => [
['label' => 'Missing Key'],
],
],
]);
$this->expectException(RuntimeException::class);
$this->expectExceptionMessage("must define a non-empty 'key'");
ModuleRegistry::boot($this->fixturesDir, ['mod-a']);
}
public function testUiSlotsAreSortedDeterministicallyByOrderModuleAndKey(): void
{
$this->createModuleManifest('mod-z', [
'id' => 'mod-z',
'load_order' => 1,
'ui_slots' => [
'search.resource_item' => [
['key' => 'z-second', 'label' => 'Z Second', 'base_url' => 'z-second', 'permission' => 'can.z', 'order' => 20],
['key' => 'z-first', 'label' => 'Z First', 'base_url' => 'z-first', 'permission' => 'can.z', 'order' => 20],
],
],
]);
$this->createModuleManifest('mod-a', [
'id' => 'mod-a',
'load_order' => 1,
'ui_slots' => [
'search.resource_item' => [
['key' => 'a-mid', 'label' => 'A Mid', 'base_url' => 'a-mid', 'permission' => 'can.a', 'order' => 20],
],
],
]);
$this->createModuleManifest('mod-b', [
'id' => 'mod-b',
'load_order' => 1,
'ui_slots' => [
'search.resource_item' => [
['key' => 'b-low', 'label' => 'B Low', 'base_url' => 'b-low', 'permission' => 'can.b', 'order' => 10],
],
],
]);
$registry = ModuleRegistry::boot($this->fixturesDir, ['mod-z', 'mod-b', 'mod-a']);
$slotItems = $registry->getSlotContributions('search.resource_item');
self::assertSame(['b-low', 'a-mid', 'z-first', 'z-second'], array_column($slotItems, 'key'));
foreach ($slotItems as $slotItem) {
self::assertArrayNotHasKey('__module_id', $slotItem);
}
}
public function testMissingManifestFileThrows(): void
{
$this->expectException(RuntimeException::class);
@@ -126,16 +268,21 @@ final class ModuleRegistryTest extends TestCase
ModuleRegistry::boot($this->fixturesDir, ['wrongdir']);
}
public function testResolveEnabledModulesFromConfig(): void
public function testResolveEnabledModulesFromConfigWhenEnvUnset(): void
{
putenv('APP_ENABLED_MODULES');
try {
$result = ModuleRegistry::resolveEnabledModules([
'enabled_modules' => ['mod-a', 'mod-b'],
]);
self::assertSame(['mod-a', 'mod-b'], $result);
} finally {
putenv('APP_ENABLED_MODULES');
}
}
public function testResolveEnabledModulesFromEnv(): void
public function testResolveEnabledModulesFromEnvList(): void
{
putenv('APP_ENABLED_MODULES=env-a,env-b');
@@ -149,13 +296,32 @@ final class ModuleRegistryTest extends TestCase
}
}
public function testResolveEnabledModulesFromEmptyEnvReturnsNoModules(): void
{
putenv('APP_ENABLED_MODULES=');
try {
$result = ModuleRegistry::resolveEnabledModules([
'enabled_modules' => ['config-a', 'config-b'],
]);
self::assertSame([], $result);
} finally {
putenv('APP_ENABLED_MODULES');
}
}
public function testResolveEnabledModulesDeduplicates(): void
{
putenv('APP_ENABLED_MODULES');
try {
$result = ModuleRegistry::resolveEnabledModules([
'enabled_modules' => ['mod-a', 'mod-a', 'mod-b'],
]);
self::assertSame(['mod-a', 'mod-b'], $result);
} finally {
putenv('APP_ENABLED_MODULES');
}
}
public function testGetNonExistentModuleThrows(): void
@@ -212,6 +378,17 @@ final class ModuleRegistryTest extends TestCase
);
}
private function createFixtureFile(string $relativePath, string $content): string
{
$path = $this->fixturesDir . '/' . ltrim($relativePath, '/');
$dir = dirname($path);
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
file_put_contents($path, $content);
return $path;
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {

View File

@@ -0,0 +1,142 @@
<?php
namespace MintyPHP\Tests\App\Module;
use MintyPHP\App\Module\ModuleManifest;
use MintyPHP\App\Module\ModuleRuntimeAssetPublisher;
use PHPUnit\Framework\TestCase;
final class ModuleRuntimeAssetPublisherTest extends TestCase
{
private string $tmpDir;
protected function setUp(): void
{
$this->tmpDir = sys_get_temp_dir() . '/corecore-module-assets-test-' . uniqid();
mkdir($this->tmpDir, 0777, true);
}
protected function tearDown(): void
{
$this->removePath($this->tmpDir);
}
public function testPublishSymlinkCreatesManagedModuleLinksAndRemovesStaleEntries(): void
{
if (!function_exists('symlink')) {
self::markTestSkipped('Symlink support is required for this test.');
}
$moduleA = $this->createModuleWithWebAssets('module-a', ['js/a.js' => 'console.log("a");']);
$moduleB = $this->createModuleWithoutWebAssets('module-b');
$runtimeModulesDir = $this->tmpDir . '/web/modules';
mkdir($runtimeModulesDir, 0777, true);
file_put_contents($runtimeModulesDir . '/stale.txt', 'old');
$publisher = new ModuleRuntimeAssetPublisher();
$result = $publisher->publish($runtimeModulesDir, [
'module-a' => $moduleA,
'module-b' => $moduleB,
], 'symlink');
self::assertTrue(is_link($runtimeModulesDir . '/module-a'));
self::assertSame(
realpath($this->tmpDir . '/modules/module-a/web'),
realpath($runtimeModulesDir . '/module-a')
);
self::assertFileDoesNotExist($runtimeModulesDir . '/module-b');
self::assertFileDoesNotExist($runtimeModulesDir . '/stale.txt');
self::assertSame(1, $result['created']);
self::assertSame(1, $result['removed']);
}
public function testPublishSymlinkUpdatesWrongExistingLink(): void
{
if (!function_exists('symlink')) {
self::markTestSkipped('Symlink support is required for this test.');
}
$moduleA = $this->createModuleWithWebAssets('module-a', ['css/a.css' => '.a{}']);
$runtimeModulesDir = $this->tmpDir . '/web/modules';
mkdir($runtimeModulesDir, 0777, true);
$wrongTarget = $this->tmpDir . '/wrong-target';
mkdir($wrongTarget, 0777, true);
symlink($wrongTarget, $runtimeModulesDir . '/module-a');
$publisher = new ModuleRuntimeAssetPublisher();
$result = $publisher->publish($runtimeModulesDir, ['module-a' => $moduleA], 'symlink');
self::assertTrue(is_link($runtimeModulesDir . '/module-a'));
self::assertSame(
realpath($this->tmpDir . '/modules/module-a/web'),
realpath($runtimeModulesDir . '/module-a')
);
self::assertSame(1, $result['updated']);
}
public function testPublishCopyModeCopiesModuleAssets(): void
{
$moduleA = $this->createModuleWithWebAssets('module-a', [
'js/app.js' => 'console.log("copy-mode");',
'css/app.css' => '.copy-mode{}',
]);
$runtimeModulesDir = $this->tmpDir . '/web/modules';
$publisher = new ModuleRuntimeAssetPublisher();
$result = $publisher->publish($runtimeModulesDir, ['module-a' => $moduleA], 'copy');
self::assertDirectoryExists($runtimeModulesDir . '/module-a');
self::assertFalse(is_link($runtimeModulesDir . '/module-a'));
self::assertFileExists($runtimeModulesDir . '/module-a/js/app.js');
self::assertFileExists($runtimeModulesDir . '/module-a/css/app.css');
self::assertSame(1, $result['created']);
}
private function createModuleWithWebAssets(string $id, array $files): ModuleManifest
{
$moduleDir = $this->tmpDir . '/modules/' . $id;
mkdir($moduleDir . '/web', 0777, true);
foreach ($files as $relativePath => $content) {
$target = $moduleDir . '/web/' . ltrim($relativePath, '/');
$targetDir = dirname($target);
if (!is_dir($targetDir)) {
mkdir($targetDir, 0777, true);
}
file_put_contents($target, $content);
}
return ModuleManifest::fromArray(['id' => $id], $moduleDir);
}
private function createModuleWithoutWebAssets(string $id): ModuleManifest
{
$moduleDir = $this->tmpDir . '/modules/' . $id;
mkdir($moduleDir, 0777, true);
return ModuleManifest::fromArray(['id' => $id], $moduleDir);
}
private function removePath(string $path): void
{
if (!file_exists($path) && !is_link($path)) {
return;
}
if (is_link($path) || is_file($path)) {
@unlink($path);
return;
}
if (!is_dir($path)) {
return;
}
$entries = scandir($path);
if ($entries !== false) {
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
$this->removePath($path . '/' . $entry);
}
}
@rmdir($path);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace MintyPHP\Tests\App\Module;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
use PHPUnit\Framework\TestCase;
final class RouterConfigCollisionTest extends TestCase
{
use AppContainerIsolationTrait;
/**
* @runInSeparateProcess
*/
public function testModuleRoutePathCollidingWithCoreRouteThrows(): void
{
$fixturesDir = sys_get_temp_dir() . '/router-config-collision-' . uniqid();
mkdir($fixturesDir . '/mod-a', 0777, true);
file_put_contents(
$fixturesDir . '/mod-a/module.php',
'<?php return ' . var_export([
'id' => 'mod-a',
'routes' => [
['path' => 'login', 'target' => 'mod-a/login'],
],
], true) . ';'
);
try {
$registry = ModuleRegistry::boot($fixturesDir, ['mod-a']);
$container = new AppContainer();
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $registry);
$this->pushAppContainer($container);
if (!defined('APP_ROUTE_DEFINITIONS')) {
define('APP_ROUTE_DEFINITIONS', [
['path' => 'login', 'target' => 'auth/login'],
]);
}
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Module route path conflict');
require dirname(__DIR__, 3) . '/config/router.php';
} finally {
$this->restoreAppContainer();
@unlink($fixturesDir . '/mod-a/module.php');
@rmdir($fixturesDir . '/mod-a');
@rmdir($fixturesDir);
}
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace MintyPHP\Tests\Architecture;
use PHPUnit\Framework\TestCase;
/**
* Contract: Direct writes to $GLOBALS['minty_app_container'] are only allowed
* inside the dedicated AppContainerIsolationTrait.
*/
final class AppContainerIsolationContractTest extends TestCase
{
use ProjectFileAssertionSupport;
public function testDirectAppContainerGlobalWritesAreRestrictedToIsolationTrait(): void
{
$violations = array_merge(
$this->findPatternMatchesInPhpFiles('tests', '/\\$GLOBALS\\[\'minty_app_container\'\\]\\s*=/'),
$this->findPatternMatchesInPhpFiles('tests', '/unset\\(\\$GLOBALS\\[\'minty_app_container\'\\]\\)/')
);
$allowed = [
'tests/Support/AppContainerIsolationTrait.php',
];
$violations = array_values(array_filter(
array_unique($violations),
static fn (string $path): bool => !in_array($path, $allowed, true)
));
sort($violations);
self::assertSame([], $violations, "Direct global app-container writes found outside isolation trait:\n" . implode("\n", $violations));
}
}

View File

@@ -16,7 +16,7 @@ class ContainerFactoryContractTest extends TestCase
{
return [
[\MintyPHP\Service\Access\AccessServicesFactory::class],
[\MintyPHP\Service\AddressBook\AddressBookServicesFactory::class],
[\MintyPHP\Module\AddressBook\Service\AddressBookServicesFactory::class],
[\MintyPHP\Service\Audit\AuditServicesFactory::class],
[\MintyPHP\Service\Auth\AuthServicesFactory::class],
[\MintyPHP\Service\Branding\BrandingServicesFactory::class],

View File

@@ -0,0 +1,58 @@
<?php
namespace MintyPHP\Tests\Architecture;
use FilesystemIterator;
use PHPUnit\Framework\TestCase;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
final class CoreTemplateIsolationTest extends TestCase
{
public function testCoreTemplatesDoNotReferenceModuleSpecificLayoutNavKeys(): void
{
$projectRoot = dirname(__DIR__, 2);
$templatesDir = $projectRoot . '/templates';
$modulesDir = $projectRoot . '/modules';
if (!is_dir($templatesDir) || !is_dir($modulesDir)) {
self::markTestSkipped('templates/ or modules/ directory missing.');
}
$moduleIds = [];
foreach (scandir($modulesDir) ?: [] as $entry) {
if ($entry === '.' || $entry === '..') {
continue;
}
if (!is_dir($modulesDir . '/' . $entry)) {
continue;
}
$moduleIds[] = $entry;
}
$violations = [];
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($templatesDir, FilesystemIterator::SKIP_DOTS),
RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'phtml') {
continue;
}
$path = $file->getPathname();
$content = file_get_contents($path);
if (!is_string($content)) {
continue;
}
foreach ($moduleIds as $moduleId) {
if (preg_match("/layoutNav\[['\"]" . preg_quote($moduleId, '/') . "(\\.|['\"])" . "/", $content) === 1) {
$violations[] = str_replace($projectRoot . '/', '', $path) . ' references layoutNav module key for ' . $moduleId;
}
}
}
self::assertSame([], $violations, "Core templates reference module layout keys:\n" . implode("\n", $violations));
}
}

View File

@@ -35,7 +35,8 @@ class DetailPageContractTest extends TestCase
public function testAppInitIncludesStandardDetailPageAutoInitComponent(): void
{
$content = $this->readProjectFile('web/js/app-init.js');
$this->assertStringContainsString("import './components/app-standard-detail-page.js';", $content);
$this->assertStringContainsString("import { initStandardDetailPages } from './components/app-standard-detail-page.js';", $content);
$this->assertStringContainsString("runtime.register('standard-detail-page', initStandardDetailPages", $content);
}
public function testDetailsTitlebarExposesUnsavedMessageAndPrimarySaveMarkers(): void

View File

@@ -11,10 +11,14 @@ class FilterDrawerContractTest extends TestCase
private function readTemplatePageModule(string $templateFile): string
{
$template = $this->readProjectFile($templateFile);
$matched = preg_match("/assetVersion\\('js\\/pages\\/([^']+\\.js)'\\)/", $template, $captures);
$matched = preg_match(
"/assetVersion\\('((?:modules\\/[^\\/]+\\/)?js\\/pages\\/[^']+\\.js)'\\)/",
$template,
$captures
);
$this->assertSame(1, $matched, $templateFile . ' must reference a page module via assetVersion().');
return $this->readProjectFile('web/js/pages/' . $captures[1]);
return $this->readProjectFile('web/' . ltrim($captures[1], '/'));
}
private function usesSharedListFiltersPartial(string $content): bool
@@ -28,7 +32,7 @@ class FilterDrawerContractTest extends TestCase
private function drawerTemplateFiles(): array
{
return [
'pages/address-book/index(default).phtml',
'modules/addressbook/pages/address-book/index(default).phtml',
'pages/admin/users/index(default).phtml',
'pages/admin/tenants/index(default).phtml',
'pages/admin/departments/index(default).phtml',
@@ -64,8 +68,8 @@ class FilterDrawerContractTest extends TestCase
public function testAddressBookFilterChipsContract(): void
{
$content = $this->readProjectFile('pages/address-book/index(default).phtml');
$moduleContent = $this->readProjectFile('web/js/pages/address-book-index.js');
$content = $this->readProjectFile('modules/addressbook/pages/address-book/index(default).phtml');
$moduleContent = $this->readProjectFile('modules/addressbook/web/js/pages/address-book-index.js');
$this->assertStringNotContainsString('const state = collectFilterState();', $moduleContent);
$this->assertStringContainsString('data-filter-chips-clear-all-label', $content);

View File

@@ -11,10 +11,14 @@ class ListFilterContractTest extends TestCase
private function readTemplatePageModule(string $templateFile): string
{
$template = $this->readProjectFile($templateFile);
$matched = preg_match("/assetVersion\\('js\\/pages\\/([^']+\\.js)'\\)/", $template, $captures);
$matched = preg_match(
"/assetVersion\\('((?:modules\\/[^\\/]+\\/)?js\\/pages\\/[^']+\\.js)'\\)/",
$template,
$captures
);
$this->assertSame(1, $matched, $templateFile . ' must reference a page module via assetVersion().');
return $this->readProjectFile('web/js/pages/' . $captures[1]);
return $this->readProjectFile('web/' . ltrim($captures[1], '/'));
}
private function usesSharedListFiltersPartial(string $content): bool
@@ -28,7 +32,7 @@ class ListFilterContractTest extends TestCase
private function hardCutGridEndpointFiles(): array
{
return [
'pages/address-book/data().php',
'modules/addressbook/pages/address-book/data().php',
'pages/search/data().php',
'pages/admin/users/data().php',
'pages/admin/tenants/data().php',
@@ -51,7 +55,7 @@ class ListFilterContractTest extends TestCase
private function hardCutGridEndpointSchemaFiles(): array
{
return [
'pages/address-book/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'modules/addressbook/pages/address-book/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/search/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/users/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
'pages/admin/tenants/data().php' => "gridParseFiltersFromSchemaFile(__DIR__ . '/filter-schema.php'",
@@ -74,7 +78,7 @@ class ListFilterContractTest extends TestCase
private function listIndexTemplates(): array
{
return [
'pages/address-book/index(default).phtml',
'modules/addressbook/pages/address-book/index(default).phtml',
'pages/search/index(default).phtml',
'pages/admin/users/index(default).phtml',
'pages/admin/tenants/index(default).phtml',
@@ -96,7 +100,7 @@ class ListFilterContractTest extends TestCase
private function listIndexActions(): array
{
return [
'pages/address-book/index().php',
'modules/addressbook/pages/address-book/index().php',
'pages/search/index().php',
'pages/admin/users/index().php',
'pages/admin/tenants/index().php',
@@ -118,7 +122,7 @@ class ListFilterContractTest extends TestCase
private function drawerListTemplates(): array
{
return [
'pages/address-book/index(default).phtml',
'modules/addressbook/pages/address-book/index(default).phtml',
'pages/admin/users/index(default).phtml',
'pages/admin/tenants/index(default).phtml',
'pages/admin/departments/index(default).phtml',

View File

@@ -25,7 +25,7 @@ class ListTitlebarContractTest extends TestCase
'pages/admin/import-audit/index(default).phtml',
'pages/admin/user-lifecycle-audit/index(default).phtml',
'pages/admin/system-audit/index(default).phtml',
'pages/address-book/index(default).phtml',
'modules/addressbook/pages/address-book/index(default).phtml',
'pages/search/index(default).phtml',
'pages/help/hotkeys(default).phtml',
];

View File

@@ -68,6 +68,7 @@ class ListUiSharedPartialsContractTest extends TestCase
public function testAppInitIncludesGlobalConfirmActionComponent(): void
{
$content = $this->readProjectFile('web/js/app-init.js');
$this->assertStringContainsString("import './components/app-confirm-actions.js';", $content);
$this->assertStringContainsString("import { initConfirmActions } from './components/app-confirm-actions.js';", $content);
$this->assertStringContainsString("runtime.register('confirm-actions', initConfirmActions", $content);
}
}

View File

@@ -19,9 +19,9 @@ final class ModuleRegistryContractTest extends TestCase
public function testAddressBookModuleLoadsWhenEnabled(): void
{
$registry = app(ModuleRegistry::class);
$modulesDir = dirname(__DIR__, 2) . '/modules';
$registry = ModuleRegistry::boot($modulesDir, ['addressbook']);
// config/modules.php has addressbook enabled by default
self::assertTrue($registry->hasModule('addressbook'));
self::assertNotEmpty($registry->getSearchResources());
self::assertNotEmpty($registry->getLayoutContextProviders());
@@ -32,8 +32,16 @@ final class ModuleRegistryContractTest extends TestCase
{
$modulesDir = dirname(__DIR__, 2) . '/modules';
// Boot a fresh registry with no modules enabled (simulates APP_ENABLED_MODULES='')
$registry = ModuleRegistry::boot($modulesDir, []);
putenv('APP_ENABLED_MODULES=');
try {
$enabledModules = ModuleRegistry::resolveEnabledModules([
'enabled_modules' => ['addressbook', 'bookmarks'],
]);
self::assertSame([], $enabledModules, 'APP_ENABLED_MODULES empty string must disable all modules.');
$registry = ModuleRegistry::boot($modulesDir, $enabledModules);
} finally {
putenv('APP_ENABLED_MODULES');
}
self::assertSame([], $registry->getModules());
self::assertSame([], $registry->getRoutes());
@@ -54,14 +62,53 @@ final class ModuleRegistryContractTest extends TestCase
$raw = require $manifestFile;
self::assertIsArray($raw);
self::assertSame('addressbook', $raw['id']);
// Permissions owned by Core PermissionService, not module
self::assertSame([], $raw['permissions']);
// Asset groups owned by config/assets.php, not module
self::assertSame([], $raw['asset_groups']);
self::assertSame([[
'key' => 'address_book.view',
'description' => 'Can view address book',
'active' => true,
'is_system' => true,
]], $raw['permissions']);
self::assertArrayHasKey('address-book', $raw['asset_groups']);
self::assertSame(
['modules/addressbook/css/pages/address-book-view.css'],
$raw['asset_groups']['address-book']
);
self::assertNotEmpty($raw['search_resources']);
self::assertNotEmpty($raw['layout_context_providers']);
self::assertNotEmpty($raw['session_providers']);
self::assertArrayHasKey('aside.tab_panel', $raw['ui_slots']);
self::assertArrayHasKey('search.resource_item', $raw['ui_slots']);
self::assertArrayHasKey('user.edit.aside_action', $raw['ui_slots']);
$routes = is_array($raw['routes'] ?? null) ? $raw['routes'] : [];
$routeByPath = [];
foreach ($routes as $route) {
if (!is_array($route)) {
continue;
}
$routeByPath[(string) ($route['path'] ?? '')] = (string) ($route['target'] ?? '');
}
self::assertSame('address-book', $routeByPath['address-book'] ?? null);
self::assertSame('address-book/data', $routeByPath['address-book/data'] ?? null);
self::assertSame('address-book', $routeByPath['addressbook'] ?? null);
self::assertSame('address-book', $routeByPath['adressbook'] ?? null);
}
public function testBookmarksModuleManifestIsValid(): void
{
$modulesDir = dirname(__DIR__, 2) . '/modules';
$manifestFile = $modulesDir . '/bookmarks/module.php';
self::assertFileExists($manifestFile, 'Bookmarks module manifest must exist');
$raw = require $manifestFile;
self::assertIsArray($raw);
self::assertSame('bookmarks', $raw['id']);
self::assertArrayHasKey('aside.tab_panel', $raw['ui_slots']);
self::assertArrayHasKey('topbar.right_item', $raw['ui_slots']);
self::assertArrayHasKey('layout.body_end_template', $raw['ui_slots']);
self::assertArrayHasKey('layout.head_style', $raw['ui_slots']);
self::assertArrayHasKey('runtime.component', $raw['ui_slots']);
}
public function testModuleProviderClassesExist(): void
@@ -104,11 +151,11 @@ final class ModuleRegistryContractTest extends TestCase
file_put_contents($fixturesDir . '/mod-a/module.php', '<?php return ' . var_export([
'id' => 'mod-a',
'permissions' => ['shared.perm'],
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared']],
], true) . ';');
file_put_contents($fixturesDir . '/mod-b/module.php', '<?php return ' . var_export([
'id' => 'mod-b',
'permissions' => ['shared.perm'],
'permissions' => [['key' => 'shared.perm', 'description' => 'Shared duplicate']],
], true) . ';');
$this->expectException(\RuntimeException::class);

View File

@@ -0,0 +1,576 @@
<?php
namespace MintyPHP\Tests\Architecture;
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
use MintyPHP\App\Module\Contracts\SessionProvider;
use MintyPHP\App\Module\ModuleManifest;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
use PHPUnit\Framework\TestCase;
/**
* Architecture test: enforces structural conventions for ALL modules under modules/.
*
* Every module directory must pass these checks. This ensures consistency
* when new modules are added and prevents structural drift.
*/
final class ModuleStructureContractTest extends TestCase
{
use ProjectFileAssertionSupport;
/** @var list<array{id: string, path: string, manifest: ModuleManifest, raw: array<string, mixed>}> */
private static array $modules = [];
public static function setUpBeforeClass(): void
{
$modulesDir = realpath(__DIR__ . '/../../modules');
if ($modulesDir === false || !is_dir($modulesDir)) {
return;
}
$entries = scandir($modulesDir);
if ($entries === false) {
return;
}
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
continue;
}
$manifestFile = $modulesDir . '/' . $entry . '/module.php';
if (!is_file($manifestFile)) {
continue;
}
$raw = require $manifestFile;
if (!is_array($raw)) {
continue;
}
$manifest = ModuleManifest::fromArray($raw, $modulesDir . '/' . $entry);
self::$modules[] = [
'id' => $entry,
'path' => $modulesDir . '/' . $entry,
'manifest' => $manifest,
'raw' => $raw,
];
}
}
// ─── Manifest structure ──────────────────────────────────────────
public function testAtLeastOneModuleExists(): void
{
self::assertNotEmpty(self::$modules, 'Expected at least one module under modules/');
}
public function testEveryModuleHasManifest(): void
{
$modulesDir = $this->projectRootPath() . '/modules';
if (!is_dir($modulesDir)) {
self::markTestSkipped('No modules/ directory');
}
$entries = scandir($modulesDir) ?: [];
foreach ($entries as $entry) {
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
continue;
}
self::assertFileExists(
$modulesDir . '/' . $entry . '/module.php',
"Module directory '{$entry}' is missing module.php manifest"
);
}
}
public function testManifestIdMatchesDirectoryName(): void
{
foreach (self::$modules as $module) {
self::assertSame(
$module['id'],
$module['manifest']->id,
"Module directory '{$module['id']}' has mismatched manifest id '{$module['manifest']->id}'"
);
}
}
public function testManifestHasVersion(): void
{
foreach (self::$modules as $module) {
self::assertMatchesRegularExpression(
'/^\d+\.\d+\.\d+$/',
$module['manifest']->version,
"Module '{$module['id']}' must have a semver version (got '{$module['manifest']->version}')"
);
}
}
public function testManifestHasAllRequiredKeys(): void
{
$requiredKeys = [
'id', 'version', 'enabled_by_default', 'load_order',
'routes', 'public_paths', 'container_registrars',
'ui_slots', 'search_resources', 'asset_groups',
'scheduler_jobs', 'layout_context_providers',
'session_providers', 'permissions',
];
foreach (self::$modules as $module) {
foreach ($requiredKeys as $key) {
self::assertArrayHasKey(
$key,
$module['raw'],
"Module '{$module['id']}' manifest is missing required key '{$key}'"
);
}
}
}
// ─── File structure ──────────────────────────────────────────────
public function testModuleHasLibDirectory(): void
{
foreach (self::$modules as $module) {
self::assertDirectoryExists(
$module['path'] . '/lib',
"Module '{$module['id']}' must have a lib/ directory for PHP classes"
);
}
}
// ─── Container registrars ────────────────────────────────────────
public function testContainerRegistrarClassesExist(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->containerRegistrars as $registrar) {
self::assertTrue(
class_exists($registrar),
"Module '{$module['id']}' declares container_registrar '{$registrar}' but class does not exist"
);
}
}
}
// ─── Provider contracts ──────────────────────────────────────────
public function testSearchProviderClassesImplementContract(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->searchResources as $providerClass) {
self::assertTrue(
class_exists($providerClass),
"Module '{$module['id']}' search provider '{$providerClass}' class not found"
);
$provider = new $providerClass();
self::assertInstanceOf(
SearchResourceProvider::class,
$provider,
"Module '{$module['id']}' search provider must implement SearchResourceProvider"
);
}
}
}
public function testLayoutContextProviderClassesImplementContract(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->layoutContextProviders as $providerClass) {
self::assertTrue(
class_exists($providerClass),
"Module '{$module['id']}' layout context provider '{$providerClass}' class not found"
);
$provider = new $providerClass();
self::assertInstanceOf(
LayoutContextProvider::class,
$provider,
"Module '{$module['id']}' layout context provider must implement LayoutContextProvider"
);
}
}
}
public function testSessionProviderClassesImplementContract(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->sessionProviders as $providerClass) {
self::assertTrue(
class_exists($providerClass),
"Module '{$module['id']}' session provider '{$providerClass}' class not found"
);
$provider = new $providerClass();
self::assertInstanceOf(
SessionProvider::class,
$provider,
"Module '{$module['id']}' session provider must implement SessionProvider"
);
}
}
}
// ─── Authorization policies ──────────────────────────────────────
public function testAuthorizationPolicyClassesImplementContract(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->authorizationPolicies as $policyClass) {
self::assertTrue(
class_exists($policyClass),
"Module '{$module['id']}' authorization policy '{$policyClass}' class not found"
);
self::assertTrue(
is_subclass_of($policyClass, AuthorizationPolicyInterface::class)
|| in_array(AuthorizationPolicyInterface::class, class_implements($policyClass) ?: [], true),
"Module '{$module['id']}' authorization policy must implement AuthorizationPolicyInterface"
);
}
}
}
// ─── Layout capabilities ─────────────────────────────────────────
public function testLayoutCapabilitiesHaveNonEmptyKeysAndAbilities(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->layoutCapabilities as $uiKey => $ability) {
self::assertNotEmpty(
trim((string) $uiKey),
"Module '{$module['id']}' has empty layout_capabilities key"
);
self::assertNotEmpty(
trim((string) $ability),
"Module '{$module['id']}' layout_capabilities key '{$uiKey}' has empty ability"
);
}
}
}
public function testLayoutCapabilityAbilitiesMatchPolicySupports(): void
{
foreach (self::$modules as $module) {
if (empty($module['manifest']->layoutCapabilities)) {
continue;
}
// Collect all abilities supported by this module's policies
$supportedAbilities = [];
foreach ($module['manifest']->authorizationPolicies as $policyClass) {
if (!class_exists($policyClass)) {
continue;
}
// Check each layout capability ability against this policy
$reflection = new \ReflectionClass($policyClass);
foreach ($reflection->getConstants() as $constValue) {
if (is_string($constValue)) {
$supportedAbilities[] = $constValue;
}
}
}
foreach ($module['manifest']->layoutCapabilities as $uiKey => $ability) {
self::assertContains(
$ability,
$supportedAbilities,
"Module '{$module['id']}' layout_capabilities['{$uiKey}'] references ability '{$ability}' "
. "but no module authorization policy declares it as a constant"
);
}
}
}
// ─── Permissions contract ───────────────────────────────────────
public function testPermissionsUseObjectContract(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->permissions as $index => $permission) {
self::assertArrayHasKey('key', $permission, "Module '{$module['id']}' permissions[{$index}] missing key");
self::assertArrayHasKey('description', $permission, "Module '{$module['id']}' permissions[{$index}] missing description");
self::assertNotEmpty(trim((string) $permission['key']), "Module '{$module['id']}' permissions[{$index}] key must be non-empty");
self::assertNotEmpty(trim((string) $permission['description']), "Module '{$module['id']}' permissions[{$index}] description must be non-empty");
self::assertContains((int) $permission['active'], [0, 1], "Module '{$module['id']}' permissions[{$index}] active must be 0|1");
self::assertContains((int) $permission['is_system'], [0, 1], "Module '{$module['id']}' permissions[{$index}] is_system must be 0|1");
}
}
}
// ─── Scheduler jobs contract ────────────────────────────────────
public function testSchedulerJobsUseFullContractSchema(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->schedulerJobs as $index => $job) {
$required = [
'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 ($required as $requiredKey) {
self::assertArrayHasKey(
$requiredKey,
$job,
"Module '{$module['id']}' scheduler_jobs[{$index}] missing '{$requiredKey}'"
);
}
self::assertNotEmpty(trim((string) $job['job_key']), "Module '{$module['id']}' scheduler_jobs[{$index}] job_key must be non-empty");
self::assertNotEmpty(trim((string) $job['handler']), "Module '{$module['id']}' scheduler_jobs[{$index}] handler must be non-empty");
self::assertNotEmpty(trim((string) $job['label']), "Module '{$module['id']}' scheduler_jobs[{$index}] label must be non-empty");
self::assertContains(
(string) $job['default_schedule_type'],
['hourly', 'daily', 'weekly'],
"Module '{$module['id']}' scheduler_jobs[{$index}] default_schedule_type invalid"
);
$allowedTypes = array_values($job['allowed_schedule_types']);
self::assertNotEmpty($allowedTypes, "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types must be non-empty");
foreach ($allowedTypes as $allowedType) {
self::assertContains(
(string) $allowedType,
['hourly', 'daily', 'weekly'],
"Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types entry invalid"
);
}
}
}
self::addToAssertionCount(1);
}
// ─── UI slots ────────────────────────────────────────────────────
public function testUiSlotContributionsHaveRequiredKeys(): void
{
$slotKeyRequirements = [
'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'],
];
foreach (self::$modules as $module) {
foreach ($module['manifest']->uiSlots as $slotName => $contributions) {
if (!isset($slotKeyRequirements[$slotName])) {
continue;
}
$required = $slotKeyRequirements[$slotName];
$contributionList = is_array($contributions) ? $contributions : [$contributions];
foreach ($contributionList as $idx => $contribution) {
if (!is_array($contribution)) {
continue;
}
foreach ($required as $requiredKey) {
self::assertArrayHasKey(
$requiredKey,
$contribution,
"Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] is missing required key '{$requiredKey}'"
);
}
}
}
}
}
public function testPanelTemplatesExist(): void
{
foreach (self::$modules as $module) {
$templateSlots = [
'aside.tab_panel' => 'panel_template',
'topbar.right_item' => 'template',
'layout.body_end_template' => 'template',
];
foreach ($templateSlots as $slotName => $templateKey) {
$contributions = $module['manifest']->uiSlots[$slotName] ?? [];
if (!is_array($contributions)) {
continue;
}
foreach ($contributions as $contribution) {
if (!is_array($contribution)) {
continue;
}
$template = $contribution[$templateKey] ?? null;
if ($template === null) {
continue;
}
self::assertFileExists(
(string) $template,
"Module '{$module['id']}' {$slotName} {$templateKey} does not exist: {$template}"
);
}
}
}
}
public function testRuntimeComponentPhasesAreValid(): void
{
foreach (self::$modules as $module) {
$runtimeComponents = $module['manifest']->uiSlots['runtime.component'] ?? [];
if (!is_array($runtimeComponents)) {
continue;
}
foreach ($runtimeComponents as $index => $contribution) {
if (!is_array($contribution)) {
continue;
}
$phase = strtolower(trim((string) ($contribution['phase'] ?? 'late')));
self::assertContains(
$phase,
['early', 'default', 'late'],
"Module '{$module['id']}' runtime.component[{$index}] has invalid phase '{$phase}'"
);
}
}
}
// ─── Routes ──────────────────────────────────────────────────────
public function testRoutesHavePathAndTarget(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->routes as $idx => $route) {
self::assertArrayHasKey(
'path',
$route,
"Module '{$module['id']}' route[{$idx}] is missing 'path'"
);
self::assertArrayHasKey(
'target',
$route,
"Module '{$module['id']}' route[{$idx}] is missing 'target'"
);
self::assertNotEmpty(
trim((string) $route['path']),
"Module '{$module['id']}' route[{$idx}] has empty 'path'"
);
self::assertNotEmpty(
trim((string) $route['target']),
"Module '{$module['id']}' route[{$idx}] has empty 'target'"
);
}
}
}
// ─── Pages directory ─────────────────────────────────────────────
public function testModulesWithRoutesHavePagesDirectory(): void
{
foreach (self::$modules as $module) {
if (empty($module['manifest']->routes)) {
continue;
}
self::assertDirectoryExists(
$module['path'] . '/pages',
"Module '{$module['id']}' declares routes but has no pages/ directory"
);
}
}
// ─── Namespace ownership ───────────────────────────────────────
/**
* All PHP classes under modules/<id>/lib/ must use a MintyPHP\Module\<Name>\
* namespace — never a Core namespace like MintyPHP\Service\ or MintyPHP\Repository\.
*
* This prevents module code from masquerading as core code and ensures
* `grep MintyPHP\Module\` reliably finds all module-owned classes.
*/
public function testModuleClassesUseModuleNamespace(): void
{
$coreNamespacePrefixes = [
'MintyPHP\\Service\\',
'MintyPHP\\Repository\\',
'MintyPHP\\Support\\',
'MintyPHP\\Http\\',
'MintyPHP\\App\\Container\\',
];
foreach (self::$modules as $module) {
$libDir = $module['path'] . '/lib';
if (!is_dir($libDir)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($libDir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::LEAVES_ONLY
);
foreach ($iterator as $file) {
if ($file->getExtension() !== 'php') {
continue;
}
$content = file_get_contents($file->getPathname());
if ($content === false) {
continue;
}
if (preg_match('/^namespace\s+(.+?);/m', $content, $match)) {
$namespace = $match[1];
foreach ($coreNamespacePrefixes as $corePrefix) {
self::assertFalse(
str_starts_with($namespace . '\\', $corePrefix),
"Module '{$module['id']}' class in {$file->getFilename()} uses core namespace '{$namespace}'. "
. "Module classes must use MintyPHP\\Module\\* namespace."
);
}
}
}
}
}
// ─── No hardcoded Core ability imports in module pages ───────────
public function testModulePagesDoNotImportCoreAbilityConstants(): void
{
foreach (self::$modules as $module) {
$pagesDir = $module['path'] . '/pages';
if (!is_dir($pagesDir)) {
continue;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($pagesDir)
);
foreach ($iterator as $file) {
if (!$file->isFile() || $file->getExtension() !== 'php') {
continue;
}
$content = file_get_contents($file->getPathname());
if ($content === false) {
continue;
}
// Module pages should use their own policy constants, not Core's OperationsAuthorizationPolicy
self::assertStringNotContainsString(
'OperationsAuthorizationPolicy::ABILITY_',
$content,
"Module '{$module['id']}' page '{$file->getFilename()}' imports Core OperationsAuthorizationPolicy ability. "
. "Use the module's own AuthorizationPolicy instead."
);
}
}
}
}

View File

@@ -16,7 +16,6 @@ class OperationsAuthorizationPolicyTest extends TestCase
{
$policy = new OperationsAuthorizationPolicy($this->createMock(PermissionService::class));
$this->assertTrue($policy->supports(OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW));
$this->assertTrue($policy->supports(OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW));
$this->assertTrue($policy->supports(OperationsAuthorizationPolicy::ABILITY_API_TENANTS_DELETE));
$this->assertTrue($policy->supports(OperationsAuthorizationPolicy::ABILITY_API_TOKENS_SELF_MANAGE));
@@ -40,7 +39,7 @@ class OperationsAuthorizationPolicyTest extends TestCase
$permissionService->expects($this->never())->method('userHas');
$policy = new OperationsAuthorizationPolicy($permissionService);
$decision = $policy->authorize(OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW, [
$decision = $policy->authorize(OperationsAuthorizationPolicy::ABILITY_ADMIN_JOBS_VIEW, [
'actor_user_id' => 0,
]);
@@ -83,31 +82,7 @@ class OperationsAuthorizationPolicyTest extends TestCase
// --- authorize: allowIfHas (simple permission check) ---
public function testAddressBookViewAllowedWithPermission(): void
{
$permissionService = $this->permissionGatewayAllowing([
5 => [PermissionService::ADDRESS_BOOK_VIEW],
]);
$policy = new OperationsAuthorizationPolicy($permissionService);
$decision = $policy->authorize(OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW, [
'actor_user_id' => 5,
]);
$this->assertTrue($decision->isAllowed());
}
public function testAddressBookViewDeniedWithoutPermission(): void
{
$permissionService = $this->permissionGatewayAllowing([5 => []]);
$policy = new OperationsAuthorizationPolicy($permissionService);
$decision = $policy->authorize(OperationsAuthorizationPolicy::ABILITY_ADDRESS_BOOK_VIEW, [
'actor_user_id' => 5,
]);
$this->assertForbiddenDecision($decision);
}
// Address book ability tests moved to AddressBookAuthorizationPolicyTest (module-owned).
public function testJobsViewAllowedWithPermission(): void
{

View File

@@ -2,9 +2,11 @@
namespace MintyPHP\Tests\Service\Auth;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\SessionProvider;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Http\SessionStore;
use MintyPHP\Service\Auth\AuthSessionTenantContextService;
use MintyPHP\Service\Bookmark\BookmarkService;
use MintyPHP\Service\User\UserTenantContextService;
use PHPUnit\Framework\TestCase;
@@ -13,6 +15,8 @@ class AuthSessionTenantContextServiceTest extends TestCase
protected function setUp(): void
{
$_SESSION = [];
DummyAuthSessionProvider::$populateCalls = 0;
DummyAuthSessionProvider::$clearCalls = 0;
}
public function testHydrateMarksNoActiveTenantWhenUserHasNoTenants(): void
@@ -21,25 +25,16 @@ class AuthSessionTenantContextServiceTest extends TestCase
$userTenantContextService = $this->createMock(UserTenantContextService::class);
$userTenantContextService->method('getAvailableTenants')->willReturn([]);
$userTenantContextService->method('getAvailableDepartmentsByTenant')->willReturn([]);
$userTenantContextService->expects($this->never())->method('getCurrentTenant');
$bookmarkService = $this->createMock(BookmarkService::class);
$bookmarkService->method('listGroupedForUser')->willReturn(['groups' => [], 'ungrouped' => []]);
$service = new AuthSessionTenantContextService(
$userTenantContextService,
$bookmarkService,
new SessionStore()
);
$service = $this->createService($userTenantContextService);
$service->hydrateForUser(5);
$this->assertSame([], $_SESSION['available_tenants']);
$this->assertSame([], $_SESSION['available_departments_by_tenant']);
$this->assertSame(['groups' => [], 'ungrouped' => []], $_SESSION['user_bookmarks']);
$this->assertTrue((bool) $_SESSION['no_active_tenant']);
$this->assertArrayNotHasKey('current_tenant', $_SESSION);
$this->assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
}
public function testHydrateUsesFirstAvailableTenantAsFallback(): void
@@ -48,30 +43,18 @@ class AuthSessionTenantContextServiceTest extends TestCase
['id' => 7, 'uuid' => 'tenant-7'],
['id' => 8, 'uuid' => 'tenant-8'],
];
$availableDepartments = ['7' => [['id' => 11]]];
$bookmarks = ['groups' => [['id' => 1, 'name' => 'Dev', 'bookmarks' => []]], 'ungrouped' => []];
$userTenantContextService = $this->createMock(UserTenantContextService::class);
$userTenantContextService->method('getAvailableTenants')->willReturn($availableTenants);
$userTenantContextService->method('getAvailableDepartmentsByTenant')->willReturn($availableDepartments);
$userTenantContextService->method('getCurrentTenant')->willReturn(null);
$bookmarkService = $this->createMock(BookmarkService::class);
$bookmarkService->method('listGroupedForUser')->willReturn($bookmarks);
$service = new AuthSessionTenantContextService(
$userTenantContextService,
$bookmarkService,
new SessionStore()
);
$service = $this->createService($userTenantContextService);
$service->hydrateForUser(5);
$this->assertSame($availableTenants, $_SESSION['available_tenants']);
$this->assertSame($availableDepartments, $_SESSION['available_departments_by_tenant']);
$this->assertSame($bookmarks, $_SESSION['user_bookmarks']);
$this->assertFalse((bool) $_SESSION['no_active_tenant']);
$this->assertSame(7, (int) ($_SESSION['current_tenant']['id'] ?? 0));
$this->assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
}
public function testHydratePreservesThemeAndPolicyFieldsInSession(): void
@@ -87,17 +70,9 @@ class AuthSessionTenantContextServiceTest extends TestCase
$userTenantContextService = $this->createMock(UserTenantContextService::class);
$userTenantContextService->method('getAvailableTenants')->willReturn($availableTenants);
$userTenantContextService->method('getAvailableDepartmentsByTenant')->willReturn([]);
$userTenantContextService->method('getCurrentTenant')->willReturn($availableTenants[0]);
$bookmarkService = $this->createMock(BookmarkService::class);
$bookmarkService->method('listGroupedForUser')->willReturn(['groups' => [], 'ungrouped' => []]);
$service = new AuthSessionTenantContextService(
$userTenantContextService,
$bookmarkService,
new SessionStore()
);
$service = $this->createService($userTenantContextService);
$service->hydrateForUser(10);
@@ -105,4 +80,73 @@ class AuthSessionTenantContextServiceTest extends TestCase
$this->assertSame('dark', $tenant['default_theme'] ?? null);
$this->assertSame('0', $tenant['allow_user_theme'] ?? null);
}
public function testHydrateAndClearInvokeConfiguredModuleSessionProviders(): void
{
$fixturesDir = sys_get_temp_dir() . '/auth-session-provider-test-' . uniqid();
mkdir($fixturesDir . '/mod-session', 0777, true);
file_put_contents(
$fixturesDir . '/mod-session/module.php',
'<?php return ' . var_export([
'id' => 'mod-session',
'session_providers' => [DummyAuthSessionProvider::class],
], true) . ';'
);
try {
$registry = ModuleRegistry::boot($fixturesDir, ['mod-session']);
$userTenantContextService = $this->createMock(UserTenantContextService::class);
$userTenantContextService->method('getAvailableTenants')->willReturn([['id' => 7, 'uuid' => 'tenant-7']]);
$userTenantContextService->method('getCurrentTenant')->willReturn(['id' => 7, 'uuid' => 'tenant-7']);
$service = $this->createService($userTenantContextService, $registry);
$_SESSION['user'] = ['id' => 42];
$service->hydrateForUser(42);
$this->assertSame(1, DummyAuthSessionProvider::$populateCalls);
$this->assertSame(42, (int) ($_SESSION['dummy_provider_user_id'] ?? 0));
$service->clearModuleSessionData();
$this->assertSame(1, DummyAuthSessionProvider::$clearCalls);
$this->assertArrayNotHasKey('dummy_provider_user_id', $_SESSION);
} finally {
@unlink($fixturesDir . '/mod-session/module.php');
@rmdir($fixturesDir . '/mod-session');
@rmdir($fixturesDir);
}
}
private function createService(
UserTenantContextService $userTenantContextService,
?ModuleRegistry $registry = null
): AuthSessionTenantContextService {
$registry ??= ModuleRegistry::boot(sys_get_temp_dir() . '/module-registry-empty-' . uniqid(), []);
$container = new AppContainer();
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $registry);
return new AuthSessionTenantContextService(
$userTenantContextService,
new SessionStore(),
$container
);
}
}
final class DummyAuthSessionProvider implements SessionProvider
{
public static int $populateCalls = 0;
public static int $clearCalls = 0;
public function populate(array $user, AppContainer $container): void
{
self::$populateCalls++;
$_SESSION['dummy_provider_user_id'] = (int) ($user['id'] ?? 0);
}
public function clear(): void
{
self::$clearCalls++;
unset($_SESSION['dummy_provider_user_id']);
}
}

View File

@@ -0,0 +1,130 @@
<?php
namespace MintyPHP\Tests\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\ScheduledJobRegistry;
use MintyPHP\Service\User\UserLifecycleService;
use PHPUnit\Framework\TestCase;
final class ScheduledJobRegistryTest extends TestCase
{
private string $fixturesDir = '';
protected function setUp(): void
{
$this->fixturesDir = sys_get_temp_dir() . '/scheduler-module-registry-' . uniqid();
mkdir($this->fixturesDir, 0777, true);
}
protected function tearDown(): void
{
$this->removeDir($this->fixturesDir);
}
public function testModuleJobAppearsInDefinitionsAndIsExecutable(): void
{
$moduleRegistry = $this->createRegistryWithModuleJob();
$container = new AppContainer();
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry);
$container->set(
DummyModuleScheduledJobHandler::class,
static fn (): DummyModuleScheduledJobHandler => new DummyModuleScheduledJobHandler()
);
$registry = new ScheduledJobRegistry(
$this->createMock(UserLifecycleService::class),
$this->createMock(SystemAuditService::class),
$container
);
$definitions = $registry->definitions();
self::assertArrayHasKey('addressbook.sync', $definitions);
self::assertSame('Address book sync', $definitions['addressbook.sync']['label'] ?? null);
$execution = $registry->execute('addressbook.sync', 123);
self::assertSame('success', $execution['status']);
self::assertSame(['actor' => 123], $execution['result']);
}
private function createRegistryWithModuleJob(): ModuleRegistry
{
mkdir($this->fixturesDir . '/mod-job', 0777, true);
file_put_contents(
$this->fixturesDir . '/mod-job/module.php',
'<?php return ' . var_export([
'id' => 'mod-job',
'scheduler_jobs' => [[
'job_key' => 'addressbook.sync',
'handler' => DummyModuleScheduledJobHandler::class,
'label' => 'Address book sync',
'description' => 'Sync job',
'default_enabled' => true,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:00',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => true,
'allowed_schedule_types' => ['daily', 'weekly'],
]],
], true) . ';'
);
return ModuleRegistry::boot($this->fixturesDir, ['mod-job']);
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
if ($item->isDir()) {
rmdir($item->getPathname());
} else {
unlink($item->getPathname());
}
}
rmdir($dir);
}
}
final class DummyModuleScheduledJobHandler implements ScheduledJobHandlerInterface
{
public function definition(): array
{
return [
'label' => 'dummy',
'description' => 'dummy',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '00:00',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['daily'],
];
}
public function execute(?int $actorUserId): array
{
return [
'status' => 'success',
'error_code' => null,
'error_message' => null,
'result' => ['actor' => $actorUserId],
];
}
}

View File

@@ -11,6 +11,97 @@ use PHPUnit\Framework\TestCase;
class ScheduledJobServiceTest extends TestCase
{
public function testEnsureSystemJobsCreatesModuleJobDefinition(): void
{
$jobRepository = $this->createMock(ScheduledJobRepository::class);
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
$registry = $this->createMock(ScheduledJobRegistry::class);
$calculator = new ScheduleCalculator();
$registry->expects($this->once())->method('definitions')->willReturn([
'addressbook.sync' => [
'label' => 'Addressbook Sync',
'description' => 'Syncs address book data',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:00',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
],
]);
$jobRepository->expects($this->once())
->method('findByKey')
->with('addressbook.sync')
->willReturn(null);
$jobRepository->expects($this->once())
->method('create')
->with($this->callback(static function (array $job): bool {
return ($job['job_key'] ?? null) === 'addressbook.sync'
&& ($job['label'] ?? null) === 'Addressbook Sync'
&& ($job['timezone'] ?? null) === 'UTC'
&& ($job['schedule_type'] ?? null) === 'daily';
}))
->willReturn(99);
$jobRepository->expects($this->never())->method('updateJobMeta');
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
$service->ensureSystemJobs();
}
public function testEnsureSystemJobsUpdatesExistingModuleJobMetadata(): void
{
$jobRepository = $this->createMock(ScheduledJobRepository::class);
$runRepository = $this->createMock(ScheduledJobRunRepository::class);
$registry = $this->createMock(ScheduledJobRegistry::class);
$calculator = new ScheduleCalculator();
$registry->expects($this->once())->method('definitions')->willReturn([
'addressbook.sync' => [
'label' => 'Addressbook Sync',
'description' => 'Syncs address book data',
'default_enabled' => 1,
'default_timezone' => 'UTC',
'default_schedule_type' => 'daily',
'default_schedule_interval' => 1,
'default_schedule_time' => '03:00',
'default_schedule_weekdays_csv' => null,
'default_catchup_once' => 1,
'allowed_schedule_types' => ['hourly', 'daily', 'weekly'],
],
]);
$jobRepository->expects($this->once())
->method('findByKey')
->with('addressbook.sync')
->willReturn([
'id' => 12,
'job_key' => 'addressbook.sync',
'label' => 'Old Label',
'description' => 'Old description',
'enabled' => 1,
'timezone' => 'UTC',
'schedule_type' => 'daily',
'schedule_interval' => 1,
'schedule_time' => '03:00',
'schedule_weekdays_csv' => null,
'catchup_once' => 1,
'next_run_at' => '2026-01-01 03:00:00',
]);
$jobRepository->expects($this->once())
->method('updateJobMeta')
->with(12, $this->callback(static function (array $job): bool {
return ($job['label'] ?? null) === 'Addressbook Sync'
&& ($job['description'] ?? null) === 'Syncs address book data';
}))
->willReturn(true);
$jobRepository->expects($this->never())->method('create');
$service = new ScheduledJobService($jobRepository, $runRepository, $registry, $calculator);
$service->ensureSystemJobs();
}
public function testUpdateFromAdminReturnsValidationErrorForWeeklyWithoutWeekdays(): void
{
$jobRepository = $this->createMock(ScheduledJobRepository::class);

View File

@@ -0,0 +1,37 @@
<?php
namespace MintyPHP\Tests\Support;
use MintyPHP\App\AppContainer;
trait AppContainerIsolationTrait
{
private mixed $appContainerIsolationPrevious = null;
private bool $appContainerIsolationActive = false;
protected function pushAppContainer(AppContainer $container): void
{
if (!$this->appContainerIsolationActive) {
$this->appContainerIsolationPrevious = $GLOBALS['minty_app_container'] ?? null;
$this->appContainerIsolationActive = true;
}
$GLOBALS['minty_app_container'] = $container;
}
protected function restoreAppContainer(): void
{
if (!$this->appContainerIsolationActive) {
return;
}
if ($this->appContainerIsolationPrevious instanceof AppContainer) {
$GLOBALS['minty_app_container'] = $this->appContainerIsolationPrevious;
} else {
unset($GLOBALS['minty_app_container']);
}
$this->appContainerIsolationPrevious = null;
$this->appContainerIsolationActive = false;
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace MintyPHP\Tests\Support;
use MintyPHP\App\AppContainer;
use PHPUnit\Framework\TestCase;
final class AppContainerIsolationTraitTest extends TestCase
{
public function testPushAndRestoreRoundTripKeepsOriginalGlobalContainer(): void
{
$original = $GLOBALS['minty_app_container'] ?? null;
self::assertInstanceOf(AppContainer::class, $original);
$harness = new class () {
use AppContainerIsolationTrait;
public function push(AppContainer $container): void
{
$this->pushAppContainer($container);
}
public function restore(): void
{
$this->restoreAppContainer();
}
};
$replacement = new AppContainer();
$harness->push($replacement);
self::assertSame($replacement, $GLOBALS['minty_app_container'] ?? null);
$harness->restore();
self::assertSame($original, $GLOBALS['minty_app_container'] ?? null);
}
public function testMultiplePushesRestoreToInitialContainer(): void
{
$original = $GLOBALS['minty_app_container'] ?? null;
self::assertInstanceOf(AppContainer::class, $original);
$harness = new class () {
use AppContainerIsolationTrait;
public function push(AppContainer $container): void
{
$this->pushAppContainer($container);
}
public function restore(): void
{
$this->restoreAppContainer();
}
};
$first = new AppContainer();
$second = new AppContainer();
$harness->push($first);
$harness->push($second);
self::assertSame($second, $GLOBALS['minty_app_container'] ?? null);
$harness->restore();
self::assertSame($original, $GLOBALS['minty_app_container'] ?? null);
}
}

View File

@@ -5,10 +5,13 @@ namespace MintyPHP\Tests\Support;
use MintyPHP\App\AppContainer;
use MintyPHP\Service\Settings\SettingCacheService;
use MintyPHP\Service\Settings\ThemeConfigGateway;
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
use PHPUnit\Framework\TestCase;
class ThemeResolutionTest extends TestCase
{
use AppContainerIsolationTrait;
private AppContainer $container;
protected function setUp(): void
@@ -23,11 +26,12 @@ class ThemeResolutionTest extends TestCase
$this->setAppSettings([]);
$GLOBALS['minty_app_container'] = $this->container;
$this->pushAppContainer($this->container);
}
protected function tearDown(): void
{
$this->restoreAppContainer();
$_SESSION = [];
}

View File

@@ -4,24 +4,34 @@ namespace MintyPHP\Tests\Unit\Module;
use MintyPHP\App\AppContainer;
use MintyPHP\Module\AddressBook\Providers\AddressBookLayoutProvider;
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
use PHPUnit\Framework\TestCase;
/**
* Verifies that the AddressBookLayoutProvider correctly contributes
* the 'addressBook' key to the layout context.
* the 'addressbook.nav' key to the layout context.
*/
final class LayoutContextProviderTest extends TestCase
{
use AppContainerIsolationTrait;
protected function setUp(): void
{
// Guard against other tests overwriting the global container
if (!($GLOBALS['minty_app_container'] ?? null) instanceof AppContainer
|| !($GLOBALS['minty_app_container'])->has(\MintyPHP\App\Module\ModuleRegistry::class)) {
|| !($GLOBALS['minty_app_container'])->has(\MintyPHP\App\Module\ModuleRegistry::class)
|| !($GLOBALS['minty_app_container'])->has(\MintyPHP\Service\Access\UiAccessService::class)) {
$container = require dirname(__DIR__, 3) . '/lib/App/registerContainer.php';
$this->pushAppContainer($container);
setAppContainer($container);
}
}
protected function tearDown(): void
{
$this->restoreAppContainer();
}
public function testProviderReturnsAddressBookKey(): void
{
$provider = new AddressBookLayoutProvider();
@@ -38,8 +48,8 @@ final class LayoutContextProviderTest extends TestCase
$result = $provider->provide($session, $container);
self::assertArrayHasKey('addressBook', $result);
$ab = $result['addressBook'];
self::assertArrayHasKey('addressbook.nav', $result);
$ab = $result['addressbook.nav'];
self::assertArrayHasKey('url', $ab);
self::assertArrayHasKey('activeSearch', $ab);
self::assertArrayHasKey('activeTenants', $ab);
@@ -57,18 +67,17 @@ final class LayoutContextProviderTest extends TestCase
$result = $provider->provide([], $container);
self::assertArrayHasKey('addressBook', $result);
self::assertSame([], $result['addressBook']['peopleGroups']);
self::assertArrayHasKey('addressbook.nav', $result);
self::assertSame([], $result['addressbook.nav']['peopleGroups']);
}
public function testProviderIsIncludedInLayoutNavWhenModuleActive(): void
{
// When the module is active, appBuildLayoutNavContext() should include
// addressBook via the provider loop
$layoutAuth = app(\MintyPHP\Service\Access\UiAccessService::class)->layoutCapabilities(0);
$layoutNav = appBuildLayoutNavContext($layoutAuth, [], []);
// addressbook.nav via the provider loop
$layoutNav = appBuildLayoutNavContext([], [], []);
self::assertArrayHasKey('addressBook', $layoutNav,
'addressBook key must be present in layoutNav when module is active');
self::assertArrayHasKey('addressbook.nav', $layoutNav,
'addressbook.nav key must be present in layoutNav when module is active');
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace MintyPHP\Tests\Unit\Module;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
use PHPUnit\Framework\TestCase;
final class LayoutNavProviderGuardTest extends TestCase
{
use AppContainerIsolationTrait;
private string $fixturesDir = '';
protected function setUp(): void
{
$this->fixturesDir = sys_get_temp_dir() . '/layout-provider-guard-' . uniqid();
mkdir($this->fixturesDir, 0777, true);
}
protected function tearDown(): void
{
$this->removeDir($this->fixturesDir);
$this->restoreAppContainer();
}
public function testReservedLayoutKeyIsRejected(): void
{
$registry = $this->createRegistryWithProvider(DummyReservedLayoutProvider::class);
$container = new AppContainer();
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $registry);
$this->pushAppContainer($container);
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage("must not override reserved layout key 'moduleSlots'");
appBuildLayoutNavContext([], [], []);
}
public function testNamespacedLayoutProviderKeyIsAccepted(): void
{
$registry = $this->createRegistryWithProvider(DummyNamespacedLayoutProvider::class);
$container = new AppContainer();
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $registry);
$this->pushAppContainer($container);
$layoutNav = appBuildLayoutNavContext([], [], []);
self::assertSame('ok', $layoutNav['dummy.value'] ?? null);
}
private function createRegistryWithProvider(string $providerClass): ModuleRegistry
{
$moduleDir = $this->fixturesDir . '/dummy-module';
mkdir($moduleDir, 0777, true);
file_put_contents(
$moduleDir . '/module.php',
'<?php return ' . var_export([
'id' => 'dummy-module',
'layout_context_providers' => [$providerClass],
], true) . ';'
);
return ModuleRegistry::boot($this->fixturesDir, ['dummy-module']);
}
private function removeDir(string $dir): void
{
if (!is_dir($dir)) {
return;
}
$items = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($items as $item) {
if ($item->isDir()) {
rmdir($item->getPathname());
} else {
unlink($item->getPathname());
}
}
rmdir($dir);
}
}
final class DummyReservedLayoutProvider implements LayoutContextProvider
{
public function provide(array $session, AppContainer $container): array
{
return [
'moduleSlots' => ['bad' => true],
];
}
}
final class DummyNamespacedLayoutProvider implements LayoutContextProvider
{
public function provide(array $session, AppContainer $container): array
{
return [
'dummy.value' => 'ok',
];
}
}

View File

@@ -5,6 +5,7 @@ namespace MintyPHP\Tests\Unit\Module;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\Module\AddressBook\Providers\AddressBookSearchProvider;
use MintyPHP\Support\SearchConfig;
use MintyPHP\Tests\Support\AppContainerIsolationTrait;
use PHPUnit\Framework\TestCase;
/**
@@ -12,18 +13,15 @@ use PHPUnit\Framework\TestCase;
*/
final class SearchProviderCollectionTest extends TestCase
{
private static ?\MintyPHP\App\AppContainer $originalContainer = null;
use AppContainerIsolationTrait;
protected function setUp(): void
{
// Guard against other tests overwriting the global container (e.g. ThemeResolutionTest)
if (self::$originalContainer === null) {
self::$originalContainer = $GLOBALS['minty_app_container'] ?? null;
}
if (!($GLOBALS['minty_app_container'] ?? null) instanceof \MintyPHP\App\AppContainer
|| !($GLOBALS['minty_app_container'])->has(ModuleRegistry::class)) {
// Restore the bootstrap container that has the module registry
$container = require dirname(__DIR__, 3) . '/lib/App/registerContainer.php';
$this->pushAppContainer($container);
setAppContainer($container);
}
SearchConfig::resetModuleProviders();
@@ -31,6 +29,7 @@ final class SearchProviderCollectionTest extends TestCase
protected function tearDown(): void
{
$this->restoreAppContainer();
SearchConfig::resetModuleProviders();
}

View File

@@ -0,0 +1,95 @@
<?php
namespace MintyPHP\Tests\Unit\Module;
use MintyPHP\App\AppContainer;
use MintyPHP\App\Module\Contracts\SessionProvider;
use MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider;
use PHPUnit\Framework\TestCase;
/**
* @covers \MintyPHP\Module\AddressBook\Providers\AddressBookSessionProvider
*/
final class SessionProviderTest extends TestCase
{
protected function setUp(): void
{
$_SESSION = [];
}
protected function tearDown(): void
{
$_SESSION = [];
}
/**
* Create a real AppContainer that returns null for any get() call.
* AppContainer is final and cannot be mocked.
*/
private function emptyContainer(): AppContainer
{
return new AppContainer();
}
public function testImplementsSessionProviderContract(): void
{
$provider = new AddressBookSessionProvider();
self::assertInstanceOf(SessionProvider::class, $provider);
}
public function testPopulateWithInvalidUserClearsSessionKey(): void
{
$_SESSION['available_departments_by_tenant'] = [['tenant' => 'old']];
$provider = new AddressBookSessionProvider();
$provider->populate(['id' => 0], $this->emptyContainer());
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
}
public function testPopulateWithMissingUserIdClearsSessionKey(): void
{
$_SESSION['available_departments_by_tenant'] = [['tenant' => 'old']];
$provider = new AddressBookSessionProvider();
$provider->populate([], $this->emptyContainer());
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
}
public function testPopulateReturnsEarlyWhenContainerMissesDependencies(): void
{
$provider = new AddressBookSessionProvider();
// Container has no bindings → get() will throw, but provider guards with instanceof check
// The provider should handle this gracefully (no session key set)
try {
$provider->populate(['id' => 42], $this->emptyContainer());
} catch (\Throwable) {
// Provider may throw if container has no binding — that's acceptable;
// in production, bindings are always registered before providers run.
}
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
}
public function testClearRemovesSessionKey(): void
{
$_SESSION['available_departments_by_tenant'] = [
['tenant' => ['uuid' => 'abc'], 'departments' => []],
];
$provider = new AddressBookSessionProvider();
$provider->clear();
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
}
public function testClearIsIdempotent(): void
{
// Key doesn't exist — clear() should not throw
$provider = new AddressBookSessionProvider();
$provider->clear();
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
}
}

View File

@@ -31,6 +31,15 @@
3. MUST alle JS-`src` in `pages/**` und `templates/**` ueber `assetVersion('...js...')` laden.
4. MUST NOT inline `<script type="module">...</script>` in `pages/**` oder `templates/**` verwenden.
## Component Lifecycle Runtime
1. MUST migrierte Komponenten ueber `init(root, config)` + `destroy()` standardisieren.
2. MUST NOT migrierte Komponenten per `DOMContentLoaded`/Module-Sideeffect selbst auto-initialisieren.
3. MUST Listener, Timer und Observer, die in `init(...)` entstehen, in `destroy()` wieder abbauen.
4. MUST Runtime-Konfiguration ueber Page-Config (`readPageConfig(...)`) beziehen, keine verteilten Ad-hoc-Globals.
5. MUST host-gebundene UI-Komponenten ueber explizite `data-app-component`-Hosts im Markup binden.
6. Global-Utilities ohne natuerliches Host-Element MAY via Runtime `scope: 'global'` registriert werden, MUESSEN aber denselben Lifecycle-Contract inkl. `destroy()` einhalten.
## Shared Partials
1. MUST Listen-Titlebar ueber `templates/partials/app-list-titlebar.phtml` rendern.

View File

@@ -32,16 +32,9 @@
}
dialog[data-app-confirm-dialog] footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 10px;
}
dialog[data-app-confirm-dialog] footer button {
margin: 0;
}
dialog[data-app-confirm-dialog][data-confirm-variant="danger"] > article {
border-top: 3px solid var(--app-del-color, #b42318);
}
@@ -49,11 +42,4 @@
dialog[data-app-confirm-dialog][data-confirm-variant="warning"] > article {
border-top: 3px solid var(--app-warning-color, #b54708);
}
@media (max-width: 576px) {
dialog[data-app-confirm-dialog] footer {
flex-direction: column-reverse;
align-items: stretch;
}
}
}

View File

@@ -0,0 +1,75 @@
/**
* app-dialog.css
* Base dialog styles shared by all dialog variants (confirm, bookmark, session-warning).
* Handles footer layout, button sizing, header close button, and mobile fallback.
* Variant-specific styles live in their own component files (app-confirm-dialog.css, etc.).
*/
@layer components {
/* ── Footer layout ───────────────────────────────────────────────────── */
dialog > article > footer,
dialog > article form > footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
/* ── Footer buttons: auto-width, no margin ───────────────────────────── */
dialog > article > footer [role="button"],
dialog > article > footer button,
dialog > article form > footer [role="button"],
dialog > article form > footer button {
width: auto;
margin: 0;
}
/* ── Header: title flush, close button positioned ────────────────────── */
dialog > article > header > * {
margin-bottom: 0;
}
dialog > article > header .close,
dialog > article > header :is(a, button)[rel="prev"] {
margin: 0;
margin-left: var(--app-spacing);
padding: 0;
float: right;
}
/* ── Close icon ──────────────────────────────────────────────────────── */
dialog > article .close,
dialog > article :is(a, button)[rel="prev"] {
display: block;
width: 1rem;
height: 1rem;
margin-top: calc(var(--app-spacing) * -1);
margin-bottom: var(--app-spacing);
margin-left: auto;
border: none;
background-image: var(--app-icon-close);
background-position: center;
background-size: auto 1rem;
background-repeat: no-repeat;
background-color: transparent;
opacity: 0.5;
transition: opacity var(--app-transition);
}
dialog > article .close:is([aria-current]:not([aria-current="false"]), :hover, :active, :focus),
dialog > article :is(a, button)[rel="prev"]:is([aria-current]:not([aria-current="false"]), :hover, :active, :focus) {
opacity: 1;
}
/* ── Mobile: stack buttons vertically ────────────────────────────────── */
@media (max-width: 576px) {
dialog > article > footer,
dialog > article form > footer {
flex-direction: column-reverse;
align-items: stretch;
}
dialog > article > footer button,
dialog > article form > footer button {
width: 100%;
}
}
}

View File

@@ -1,34 +1,36 @@
.page-layout {
@layer layout {
.page-layout {
display: flex;
flex-direction: column;
min-height: 100dvh;
}
}
.page-header {
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--border);
}
border-bottom: 1px solid var(--app-border);
}
.page-header .app-brand img {
.page-header .app-brand img {
height: 2.5rem;
width: auto;
}
}
.page-header nav ul {
.page-header nav ul {
display: flex;
gap: 1.5rem;
list-style: none;
padding: 0;
margin: 0;
}
}
.page-content {
.page-content {
flex: 1;
max-width: 60rem;
margin: 0 auto;
padding: 2rem 1.5rem;
width: 100%;
}
}

View File

@@ -2769,10 +2769,6 @@
overflow: auto;
}
dialog > article > footer button {
margin-bottom: 0;
}
.bi::before,
[class^="bi-"]::before,
[class*=" bi-"]::before {
@@ -2791,68 +2787,7 @@
}
}
dialog > article > header > * {
margin-bottom: 0;
}
dialog > article > header .close,
dialog > article > header :is(a, button)[rel="prev"] {
margin: 0;
margin-left: var(--app-spacing);
padding: 0;
float: right;
}
dialog > article > footer {
text-align: right;
}
dialog > article > footer [role="button"],
dialog > article > footer button {
margin-bottom: 0;
}
dialog > article > footer [role="button"]:not(:first-of-type),
dialog > article > footer button:not(:first-of-type) {
margin-left: calc(var(--app-spacing) * 0.5);
}
dialog > article .close,
dialog > article :is(a, button)[rel="prev"] {
display: block;
width: 1rem;
height: 1rem;
margin-top: calc(var(--app-spacing) * -1);
margin-bottom: var(--app-spacing);
margin-left: auto;
border: none;
background-image: var(--app-icon-close);
background-position: center;
background-size: auto 1rem;
background-repeat: no-repeat;
background-color: transparent;
opacity: 0.5;
transition: opacity var(--app-transition);
}
dialog
> article
.close:is(
[aria-current]:not([aria-current="false"]),
:hover,
:active,
:focus
),
dialog
> article
:is(a, button)[rel="prev"]:is(
[aria-current]:not([aria-current="false"]),
:hover,
:active,
:focus
) {
opacity: 1;
}
/* Dialog header/footer/close styles → web/css/components/app-dialog.css */
dialog:not([open]),
dialog[open="false"] {

View File

@@ -287,263 +287,4 @@
margin-bottom: var(--app-spacing);
}
.app-sidebar .app-sidebar-bookmark-item > a > i {
flex-shrink: 0;
width: 16px;
text-align: center;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-item {
display: flex;
align-items: center;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-shell {
position: relative;
display: flex;
flex-direction: column;
gap: 0.2rem;
width: 100%;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-left: var(--app-spacing);
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex: 1;
min-width: 0;
margin: 0;
color: var(--app-muted-color);
font-size: 0.72rem;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label > span {
flex: 1;
min-width: 0;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-group-label > i {
flex-shrink: 0;
width: 16px;
text-align: center;
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu {
margin: 0;
margin-right: 5px;
position: relative;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
summary:after {
display: none;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary {
list-style: none;
border: 0;
background: transparent;
color: var(--app-muted-color);
padding: 0.25rem;
min-width: 1.5rem;
min-height: 1.5rem;
line-height: 1;
margin: 0;
border-radius: 3px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color 120ms ease,
background 120ms ease,
opacity 120ms ease;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary {
opacity: 0;
pointer-events: none;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-item:hover
.app-sidebar-bookmark-action-menu
> summary,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-item:focus-within
.app-sidebar-bookmark-action-menu
> summary,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-group-header:hover
.app-sidebar-bookmark-action-menu
> summary,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-group-header:focus-within
.app-sidebar-bookmark-action-menu
> summary,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu[open]
> summary,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary:focus-visible {
opacity: 1;
pointer-events: auto;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary::-webkit-details-marker {
display: none;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary
i {
font-size: 0.95rem;
pointer-events: none;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu[open]
> summary,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary:hover,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary:focus-visible {
color: var(--app-contrast);
background: color-mix(in srgb, var(--app-contrast) 10%, transparent);
}
@media (hover: none), (pointer: coarse) {
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu
> summary {
opacity: 1;
pointer-events: auto;
}
}
.app-sidebar #aside-panel-bookmarks .app-sidebar-bookmark-action-menu-list {
position: absolute;
inset-inline-end: 0;
inset-block-start: calc(100% + 4px);
min-width: 10rem;
max-width: calc(220px - 2 * var(--app-spacing));
border: 1px solid var(--app-border);
border-radius: var(--app-border-radius);
background: var(--app-card-bg, var(--app-background-color));
box-shadow:
0 4px 16px rgba(0, 0, 0, 0.14),
0 1px 3px rgba(0, 0, 0, 0.08);
padding: 0.25rem;
z-index: 10;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
> li {
margin: 0;
padding: 0;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
button {
width: 100%;
border: 0;
background: transparent;
color: inherit;
text-align: start;
padding: 0.375rem 0.5rem;
margin: 0;
border-radius: 3px;
cursor: pointer;
white-space: nowrap;
font-size: 0.8125rem;
line-height: 1.4;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
button:hover,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
button:focus-visible {
background: color-mix(in srgb, var(--app-contrast) 8%, transparent);
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
button[disabled] {
opacity: 0.45;
cursor: not-allowed;
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
li:has(> .app-sidebar-bookmark-action-danger) {
margin-top: 0.2rem;
padding-top: 0.2rem;
border-top: 1px solid var(--app-border);
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
.app-sidebar-bookmark-action-danger {
color: var(--app-del-color, #c62828);
}
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
.app-sidebar-bookmark-action-danger:hover,
.app-sidebar
#aside-panel-bookmarks
.app-sidebar-bookmark-action-menu-list
.app-sidebar-bookmark-action-danger:focus-visible {
background: rgba(198, 40, 40, 0.12);
}
}

View File

@@ -13,6 +13,8 @@ use MintyPHP\Http\RequestContext;
use MintyPHP\Http\Request;
use MintyPHP\I18n;
use MintyPHP\Router;
use MintyPHP\App\Module\ModuleRegistry;
use MintyPHP\App\Module\ModuleRuntimePageBuilder;
use MintyPHP\Service\Access\UiAccessService;
use MintyPHP\Service\Auth\AuthServicesFactory;
use MintyPHP\Session;
@@ -24,12 +26,27 @@ chdir(__DIR__ . '/..');
require 'vendor/autoload.php';
// Load the config parameters
require 'config/config.php';
// Load the routes
require 'config/router.php';
// Register shortcut functions
require 'lib/Support/helpers.php';
$container = require 'lib/App/registerContainer.php';
setAppContainer($container);
// Resolve runtime page root before routes are initialized.
$moduleRegistry = app(ModuleRegistry::class);
if (count($moduleRegistry->getModules()) > 0) {
$runtimePageRoot = __DIR__ . '/../storage/runtime/pages';
if (!is_dir($runtimePageRoot) && !is_link($runtimePageRoot)) {
$builder = new ModuleRuntimePageBuilder();
$builder->build($runtimePageRoot, __DIR__ . '/../pages', $moduleRegistry->getModules());
}
if (is_dir($runtimePageRoot) || is_link($runtimePageRoot)) {
Router::$pageRoot = 'storage/runtime/pages/';
}
}
// Load routes after container/module registry are available.
require 'config/router.php';
RequestContext::start();
header('X-Request-Id: ' . RequestContext::requestHeaderValue());
@@ -200,8 +217,8 @@ if ($requestedLocale === '' && !$isAssetRequest && !$isApiRequest) {
// Guard: restrict non-public pages to logged-in users
// Merge core public paths with module-contributed public paths.
$modulePublicPaths = [];
if (app(\MintyPHP\App\Module\ModuleRegistry::class) instanceof \MintyPHP\App\Module\ModuleRegistry) {
$modulePublicPaths = app(\MintyPHP\App\Module\ModuleRegistry::class)->getPublicPaths();
if (app(ModuleRegistry::class) instanceof ModuleRegistry) {
$modulePublicPaths = app(ModuleRegistry::class)->getPublicPaths();
}
$allPublicPaths = array_values(array_unique(array_merge(
defined('APP_PUBLIC_PATHS') ? APP_PUBLIC_PATHS : [],

View File

@@ -12,19 +12,36 @@
root.classList.add('js');
root.classList.add('aside-pending');
var assetBase = root.dataset.assetBase || '';
if (assetBase) {
window.APP_ASSET_BASE = assetBase;
var normalizeToken = function (value) {
return String(value == null ? '' : value)
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/^-+|-+$/g, '');
};
var buildStorageKey = function (scope, key) {
var namespace = normalizeToken('app-ui') || 'app-ui';
var version = normalizeToken('v1') || 'v1';
var normalizedScope = normalizeToken(scope || '') || 'global';
var normalizedKey = normalizeToken(key || '');
if (!normalizedKey) {
return '';
}
return namespace + ':' + version + ':' + normalizedScope + ':state:' + normalizedKey;
};
try {
if (window.localStorage.getItem('app-sidebar-collapsed') === '1') {
var sidebarCollapsedKey = buildStorageKey('sidebar', 'sidebar-collapsed');
var sidebarHiddenKey = buildStorageKey('sidebar', 'sidebar-hidden');
var contrastKey = buildStorageKey('contrast', 'contrast-mode');
if (sidebarCollapsedKey && window.localStorage.getItem(sidebarCollapsedKey) === '1') {
root.classList.add('sidebar-collapsed');
}
if (window.localStorage.getItem('app-sidebar-hidden') === '1') {
if (sidebarHiddenKey && window.localStorage.getItem(sidebarHiddenKey) === '1') {
root.classList.add('sidebar-hidden');
}
var contrastValue = window.localStorage.getItem('app-contrast');
var contrastValue = contrastKey ? window.localStorage.getItem(contrastKey) : null;
if (contrastValue === 'high') {
root.dataset.contrast = 'high';
} else {
@@ -33,16 +50,4 @@
} catch (e) {
// ignore storage errors
}
if (typeof window.requestFsLightboxRefresh !== 'function') {
window.requestFsLightboxRefresh = function () {
if (typeof window.refreshFsLightbox === 'function') {
window.refreshFsLightbox();
window.__fsLightboxRefreshPending = false;
return true;
}
window.__fsLightboxRefreshPending = true;
return false;
};
}
})();

Some files were not shown because too many files have changed in this diff Show More