feat: module architecture improvements — session keys, dependency graph, event dispatcher, deactivation hooks
Six targeted improvements to the modular monolith platform: 1. Fix AddressBook session key prefix to follow module.<id>.* convention 2. Move ADDRESS_BOOK_VIEW permission constant from core PermissionService into module 3. Add declarative JSON schema for module manifests (.agents/contracts/) 4. Add `requires` field with missing-dependency and circular-dependency detection 5. Add lightweight fire-and-forget event dispatcher (user.created/deleted/login/logout) 6. Add module deactivation hook interface and CLI script (bin/module-deactivate.php) Includes 15 new architecture/unit tests covering all new functionality. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
42
.agents/README.md
Normal file
42
.agents/README.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# .agents/
|
||||||
|
|
||||||
|
Centralized home for the 7-role agent workflow system.
|
||||||
|
|
||||||
|
## Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
workflow.md ← Roles, state machine, handover rules (start here)
|
||||||
|
contracts/ ← JSON schemas for role outputs (7 roles)
|
||||||
|
templates/ ← Starter JSON payloads per role (7 templates)
|
||||||
|
prompts/ ← Role-specific prompt baselines (7 prompts)
|
||||||
|
checks/ ← Guard catalog (22 guards), quality gates (9), checklists
|
||||||
|
skills/ ← Reusable skill packages (guardrails, planner, grid, PHP CI)
|
||||||
|
runs/ ← Runtime artifacts per workflow run (gitignored)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Roles
|
||||||
|
|
||||||
|
| # | Role | Artifact | Guards |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Analyst | `analysis.json` | — |
|
||||||
|
| 2 | Planner | `plan.json` | selects guards |
|
||||||
|
| 3 | Executor | `execution-report.json` | provides evidence |
|
||||||
|
| 4 | Code Reviewer | `review-code.json` | 13 guards (GR-CORE/TEST/UI) |
|
||||||
|
| 5 | Security Reviewer | `review-security.json` | 9 guards (GR-SEC) |
|
||||||
|
| 6 | Acceptance Tester | `review-acceptance.json` | SC-* criteria |
|
||||||
|
| 7 | Finalizer | `finalize.json` | all reviews + CI |
|
||||||
|
|
||||||
|
## Quick Links
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|---|---|
|
||||||
|
| Workflow & roles | `workflow.md` |
|
||||||
|
| Guard catalog (22 guards) | `checks/guard-catalog.json` |
|
||||||
|
| Quality gates (9 gates) | `checks/quality-gates.json` |
|
||||||
|
| Code review checklist | `checks/guard-checklist.md` |
|
||||||
|
| Acceptance checklist | `checks/acceptance-checklist.md` |
|
||||||
|
| Module manifest schema | `contracts/module-manifest.schema.json` |
|
||||||
|
|
||||||
|
## Project Context
|
||||||
|
|
||||||
|
See `CLAUDE.md` (repo root) for tech stack, architecture rules, and conventions.
|
||||||
209
.agents/contracts/module-manifest.schema.json
Normal file
209
.agents/contracts/module-manifest.schema.json
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "module-manifest.schema.json",
|
||||||
|
"title": "Module Manifest Schema",
|
||||||
|
"description": "Declarative schema for module.php manifest files under modules/<id>/.",
|
||||||
|
"type": "object",
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1,
|
||||||
|
"pattern": "^[a-z][a-z0-9_-]*$",
|
||||||
|
"description": "Module identifier — must match the directory name under modules/."
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^\\d+\\.\\d+\\.\\d+$",
|
||||||
|
"description": "Semantic version (e.g. 1.0.0)."
|
||||||
|
},
|
||||||
|
"enabled_by_default": {
|
||||||
|
"type": "boolean",
|
||||||
|
"default": false
|
||||||
|
},
|
||||||
|
"load_order": {
|
||||||
|
"type": "integer",
|
||||||
|
"default": 100,
|
||||||
|
"description": "Lower values load first. Deterministic tie-break by id ASC."
|
||||||
|
},
|
||||||
|
"requires": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"uniqueItems": true,
|
||||||
|
"default": [],
|
||||||
|
"description": "Module IDs that must be enabled for this module to function."
|
||||||
|
},
|
||||||
|
"routes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["path", "target"],
|
||||||
|
"properties": {
|
||||||
|
"path": { "type": "string", "minLength": 1 },
|
||||||
|
"target": { "type": "string", "minLength": 1 },
|
||||||
|
"public": { "type": "boolean", "default": false }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"public_paths": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" },
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"container_registrars": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1, "description": "FQCN of a ContainerRegistrar." },
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"ui_slots": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Keyed by slot name (e.g. 'aside.tab_panel'). Values are arrays of contribution objects.",
|
||||||
|
"patternProperties": {
|
||||||
|
"^.+$": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key"],
|
||||||
|
"properties": {
|
||||||
|
"key": { "type": "string", "minLength": 1 },
|
||||||
|
"label": { "type": "string" },
|
||||||
|
"icon": { "type": "string" },
|
||||||
|
"href": { "type": "string" },
|
||||||
|
"permission": { "type": "string" },
|
||||||
|
"panel_template": { "type": "string" },
|
||||||
|
"template": { "type": "string" },
|
||||||
|
"path": { "type": "string" },
|
||||||
|
"script": { "type": "string" },
|
||||||
|
"export": { "type": "string" },
|
||||||
|
"selector": { "type": "string" },
|
||||||
|
"scope": { "type": "string" },
|
||||||
|
"config_path": { "type": "string" },
|
||||||
|
"default_config": { "type": "object" },
|
||||||
|
"phase": { "type": "string", "enum": ["early", "default", "late"] },
|
||||||
|
"type": { "type": "string" },
|
||||||
|
"order": { "type": "integer", "default": 100 },
|
||||||
|
"details_storage": { "type": "string" },
|
||||||
|
"details_open_active": { "type": "boolean" },
|
||||||
|
"base_url": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"search_resources": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1, "description": "FQCN of a SearchResourceProvider." },
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"asset_groups": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Asset group name → file list.",
|
||||||
|
"patternProperties": {
|
||||||
|
"^.+$": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"layout_context_providers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1, "description": "FQCN of a LayoutContextProvider." },
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"session_providers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1, "description": "FQCN of a SessionProvider." },
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"authorization_policies": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "minLength": 1, "description": "FQCN of an AuthorizationPolicyInterface." },
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"permissions": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["key", "description"],
|
||||||
|
"properties": {
|
||||||
|
"key": { "type": "string", "minLength": 1 },
|
||||||
|
"description": { "type": "string", "minLength": 1 },
|
||||||
|
"active": { "type": "boolean", "default": true },
|
||||||
|
"is_system": { "type": "boolean", "default": true }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"scheduler_jobs": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"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"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"job_key": { "type": "string", "minLength": 1 },
|
||||||
|
"handler": { "type": "string", "minLength": 1 },
|
||||||
|
"label": { "type": "string", "minLength": 1 },
|
||||||
|
"description": { "type": "string", "minLength": 1 },
|
||||||
|
"default_enabled": { "type": ["boolean", "integer"] },
|
||||||
|
"default_timezone": { "type": "string", "minLength": 1 },
|
||||||
|
"default_schedule_type": { "type": "string", "enum": ["hourly", "daily", "weekly"] },
|
||||||
|
"default_schedule_interval": { "type": "integer" },
|
||||||
|
"default_schedule_time": { "type": ["string", "null"] },
|
||||||
|
"default_schedule_weekdays_csv": { "type": ["string", "null"] },
|
||||||
|
"default_catchup_once": { "type": ["boolean", "integer"] },
|
||||||
|
"allowed_schedule_types": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "type": "string", "enum": ["hourly", "daily", "weekly"] },
|
||||||
|
"minItems": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
|
"default": []
|
||||||
|
},
|
||||||
|
"event_listeners": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "Event name → list of listener descriptors.",
|
||||||
|
"patternProperties": {
|
||||||
|
"^.+$": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"required": ["class", "method"],
|
||||||
|
"properties": {
|
||||||
|
"class": { "type": "string", "minLength": 1, "description": "FQCN implementing EventListener." },
|
||||||
|
"method": { "type": "string", "minLength": 1 }
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"default": {}
|
||||||
|
},
|
||||||
|
"deactivation_handler": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"description": "FQCN of a ModuleDeactivationHandler. Null if no cleanup needed.",
|
||||||
|
"default": null
|
||||||
|
},
|
||||||
|
"migrations_path": {
|
||||||
|
"type": ["string", "null"],
|
||||||
|
"description": "Relative path to SQL migration scripts. Resolved against module basePath.",
|
||||||
|
"default": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"additionalProperties": false
|
||||||
|
}
|
||||||
84
bin/module-deactivate.php
Normal file
84
bin/module-deactivate.php
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CLI script to run a module's deactivation handler.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* php bin/module-deactivate.php <module-id> --confirm
|
||||||
|
* php bin/module-deactivate.php <module-id> --dry-run
|
||||||
|
*
|
||||||
|
* The module does NOT need to be in the enabled list — the manifest is loaded
|
||||||
|
* directly from modules/<id>/module.php.
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/module-cli-bootstrap.php';
|
||||||
|
|
||||||
|
$exitCode = moduleCliRunStep('module-deactivate', static function (): int {
|
||||||
|
global $argv;
|
||||||
|
|
||||||
|
$moduleId = $argv[1] ?? '';
|
||||||
|
$flags = array_slice($argv, 2);
|
||||||
|
|
||||||
|
if ($moduleId === '' || $moduleId === '--help') {
|
||||||
|
fwrite(STDOUT, "Usage: php bin/module-deactivate.php <module-id> --confirm|--dry-run\n");
|
||||||
|
return $moduleId === '--help' ? 0 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$confirm = in_array('--confirm', $flags, true);
|
||||||
|
$dryRun = in_array('--dry-run', $flags, true);
|
||||||
|
|
||||||
|
if (!$confirm && !$dryRun) {
|
||||||
|
fwrite(STDERR, "Error: must pass --confirm to execute or --dry-run to validate.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$projectRoot = moduleCliProjectRoot();
|
||||||
|
$manifestFile = $projectRoot . '/modules/' . $moduleId . '/module.php';
|
||||||
|
|
||||||
|
if (!is_file($manifestFile)) {
|
||||||
|
fwrite(STDERR, "Error: module manifest not found at {$manifestFile}\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$raw = require $manifestFile;
|
||||||
|
if (!is_array($raw)) {
|
||||||
|
fwrite(STDERR, "Error: manifest must return an array.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$manifest = \MintyPHP\App\Module\ModuleManifest::fromArray($raw, dirname($manifestFile));
|
||||||
|
|
||||||
|
$handlerClass = $manifest->deactivationHandler;
|
||||||
|
if ($handlerClass === null) {
|
||||||
|
fwrite(STDOUT, "Module '{$moduleId}' has no deactivation_handler declared — nothing to do.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!class_exists($handlerClass)) {
|
||||||
|
fwrite(STDERR, "Error: deactivation handler class '{$handlerClass}' not found.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$handler = new $handlerClass();
|
||||||
|
if (!$handler instanceof \MintyPHP\App\Module\Contracts\ModuleDeactivationHandler) {
|
||||||
|
fwrite(STDERR, "Error: '{$handlerClass}' does not implement ModuleDeactivationHandler.\n");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($dryRun) {
|
||||||
|
fwrite(STDOUT, "Dry-run: handler '{$handlerClass}' found and valid for module '{$moduleId}'.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fwrite(STDOUT, "Running deactivation handler for module '{$moduleId}'...\n");
|
||||||
|
|
||||||
|
$container = app();
|
||||||
|
$handler->deactivate($container);
|
||||||
|
|
||||||
|
fwrite(STDOUT, "Deactivation handler for module '{$moduleId}' completed.\n");
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
exit($exitCode);
|
||||||
@@ -4,6 +4,7 @@ namespace MintyPHP\App\Container\Registrars;
|
|||||||
|
|
||||||
use MintyPHP\App\AppContainer;
|
use MintyPHP\App\AppContainer;
|
||||||
use MintyPHP\App\Container\ContainerRegistrar;
|
use MintyPHP\App\Container\ContainerRegistrar;
|
||||||
|
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||||
use MintyPHP\App\Module\ModuleRegistry;
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
use MintyPHP\Http\CookieStoreInterface;
|
use MintyPHP\Http\CookieStoreInterface;
|
||||||
use MintyPHP\Http\RequestRuntimeInterface;
|
use MintyPHP\Http\RequestRuntimeInterface;
|
||||||
@@ -130,7 +131,8 @@ final class ServiceFactoryRegistrar implements ContainerRegistrar
|
|||||||
$c->get(DatabaseSessionRepository::class),
|
$c->get(DatabaseSessionRepository::class),
|
||||||
// Wrapped in a closure to break circular dependency:
|
// Wrapped in a closure to break circular dependency:
|
||||||
// UserServicesFactory -> AssignableRoleService -> RoleRepository -> DirectoryServicesFactory -> UserServicesFactory
|
// UserServicesFactory -> AssignableRoleService -> RoleRepository -> DirectoryServicesFactory -> UserServicesFactory
|
||||||
static fn (): AssignableRoleService => $c->get(AssignableRoleService::class)
|
static fn (): AssignableRoleService => $c->get(AssignableRoleService::class),
|
||||||
|
$c->has(ModuleEventDispatcher::class) ? $c->get(ModuleEventDispatcher::class) : null
|
||||||
));
|
));
|
||||||
$container->set(AuthGatewayFactory::class, static fn (AppContainer $c): AuthGatewayFactory => new AuthGatewayFactory(
|
$container->set(AuthGatewayFactory::class, static fn (AppContainer $c): AuthGatewayFactory => new AuthGatewayFactory(
|
||||||
$c->get(AuthRepositoryFactory::class),
|
$c->get(AuthRepositoryFactory::class),
|
||||||
|
|||||||
18
lib/App/Module/Contracts/EventListener.php
Normal file
18
lib/App/Module/Contracts/EventListener.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\App\Module\Contracts;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for module event listeners.
|
||||||
|
*
|
||||||
|
* Modules declare listeners in their manifest under 'event_listeners'.
|
||||||
|
* The dispatcher resolves listeners from the container and calls handle().
|
||||||
|
*/
|
||||||
|
interface EventListener
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $event Event name (e.g. 'user.deleted')
|
||||||
|
* @param array<string, mixed> $payload Event-specific data
|
||||||
|
*/
|
||||||
|
public function handle(string $event, array $payload): void;
|
||||||
|
}
|
||||||
17
lib/App/Module/Contracts/ModuleDeactivationHandler.php
Normal file
17
lib/App/Module/Contracts/ModuleDeactivationHandler.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\App\Module\Contracts;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles cleanup when a module is deactivated.
|
||||||
|
*
|
||||||
|
* Invoked by `bin/module-deactivate.php` after a module has been removed
|
||||||
|
* from the enabled list. The handler receives the full app container and
|
||||||
|
* can perform cleanup tasks like removing orphaned data or revoking permissions.
|
||||||
|
*/
|
||||||
|
interface ModuleDeactivationHandler
|
||||||
|
{
|
||||||
|
public function deactivate(AppContainer $container): void;
|
||||||
|
}
|
||||||
49
lib/App/Module/ModuleEventDispatcher.php
Normal file
49
lib/App/Module/ModuleEventDispatcher.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\App\Module;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Module\Contracts\EventListener;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget event dispatcher for module lifecycle events.
|
||||||
|
*
|
||||||
|
* Listeners are declared in module manifests and lazy-resolved from the container.
|
||||||
|
* A failing listener does not break the dispatch chain — the exception is swallowed
|
||||||
|
* so that one misbehaving module cannot disrupt core flows.
|
||||||
|
*/
|
||||||
|
final class ModuleEventDispatcher
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param array<string, list<array{class: class-string, method: string}>> $listenerMap
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly array $listenerMap,
|
||||||
|
private readonly AppContainer $container
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $payload
|
||||||
|
*/
|
||||||
|
public function dispatch(string $event, array $payload): void
|
||||||
|
{
|
||||||
|
$listeners = $this->listenerMap[$event] ?? [];
|
||||||
|
|
||||||
|
foreach ($listeners as $descriptor) {
|
||||||
|
try {
|
||||||
|
$class = $descriptor['class'];
|
||||||
|
$method = $descriptor['method'];
|
||||||
|
|
||||||
|
$listener = $this->container->get($class);
|
||||||
|
if (!$listener instanceof EventListener) {
|
||||||
|
$listener = new $class();
|
||||||
|
}
|
||||||
|
|
||||||
|
$listener->{$method}($event, $payload);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// Swallow — one failing listener must not break the chain.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,15 @@ final class ModuleManifest
|
|||||||
/** @var list<class-string> AuthorizationPolicyInterface FQCNs */
|
/** @var list<class-string> AuthorizationPolicyInterface FQCNs */
|
||||||
public readonly array $authorizationPolicies;
|
public readonly array $authorizationPolicies;
|
||||||
|
|
||||||
|
/** @var list<string> Module IDs required by this module */
|
||||||
|
public readonly array $requires;
|
||||||
|
|
||||||
|
/** @var array<string, list<array{class: class-string, method: string}>> Event listeners keyed by event name */
|
||||||
|
public readonly array $eventListeners;
|
||||||
|
|
||||||
|
/** @var class-string|null ModuleDeactivationHandler FQCN */
|
||||||
|
public readonly ?string $deactivationHandler;
|
||||||
|
|
||||||
public readonly ?string $migrationsPath;
|
public readonly ?string $migrationsPath;
|
||||||
|
|
||||||
public readonly string $basePath;
|
public readonly string $basePath;
|
||||||
@@ -107,6 +116,14 @@ final class ModuleManifest
|
|||||||
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
|
$this->permissions = self::normalizePermissions($raw['permissions'] ?? [], $this->id);
|
||||||
$this->authorizationPolicies = self::listOf($raw, 'authorization_policies');
|
$this->authorizationPolicies = self::listOf($raw, 'authorization_policies');
|
||||||
|
|
||||||
|
$this->requires = self::normalizeRequires($raw['requires'] ?? [], $this->id);
|
||||||
|
$this->eventListeners = self::normalizeEventListeners($raw['event_listeners'] ?? [], $this->id);
|
||||||
|
|
||||||
|
$deactivationHandler = $raw['deactivation_handler'] ?? null;
|
||||||
|
$this->deactivationHandler = is_string($deactivationHandler) && trim($deactivationHandler) !== ''
|
||||||
|
? trim($deactivationHandler)
|
||||||
|
: null;
|
||||||
|
|
||||||
$migrationsPath = $raw['migrations_path'] ?? null;
|
$migrationsPath = $raw['migrations_path'] ?? null;
|
||||||
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
|
$this->migrationsPath = is_string($migrationsPath) && trim($migrationsPath) !== ''
|
||||||
? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/')
|
? rtrim($basePath, '/') . '/' . ltrim(trim($migrationsPath), '/')
|
||||||
@@ -332,6 +349,83 @@ final class ModuleManifest
|
|||||||
return $types;
|
return $types;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, list<array{class: class-string, method: string}>>
|
||||||
|
*/
|
||||||
|
private static function normalizeEventListeners(mixed $value, string $moduleId): array
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' manifest key 'event_listeners' must be an array.", $moduleId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$listeners = [];
|
||||||
|
foreach ($value as $eventName => $handlers) {
|
||||||
|
$eventName = trim((string) $eventName);
|
||||||
|
if ($eventName === '') {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' event_listeners has an empty event name key.", $moduleId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!is_array($handlers)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' event_listeners['%s'] must be an array of handler descriptors.", $moduleId, $eventName)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedHandlers = [];
|
||||||
|
foreach (array_values($handlers) as $index => $handler) {
|
||||||
|
if (!is_array($handler)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' event_listeners['%s'][%d] must be an array with 'class' and 'method'.", $moduleId, $eventName, $index)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$class = trim((string) ($handler['class'] ?? ''));
|
||||||
|
$method = trim((string) ($handler['method'] ?? ''));
|
||||||
|
if ($class === '' || $method === '') {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' event_listeners['%s'][%d] must define non-empty 'class' and 'method'.", $moduleId, $eventName, $index)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedHandlers[] = ['class' => $class, 'method' => $method];
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($normalizedHandlers !== []) {
|
||||||
|
$listeners[$eventName] = $normalizedHandlers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $listeners;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private static function normalizeRequires(mixed $value, string $moduleId): array
|
||||||
|
{
|
||||||
|
if (!is_array($value)) {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' manifest key 'requires' must be an array.", $moduleId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$requires = [];
|
||||||
|
foreach (array_values($value) as $index => $item) {
|
||||||
|
if (!is_string($item) || trim($item) === '') {
|
||||||
|
throw new InvalidArgumentException(
|
||||||
|
sprintf("Module '%s' requires[%d] must be a non-empty string.", $moduleId, $index)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$requires[] = trim($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_unique($requires));
|
||||||
|
}
|
||||||
|
|
||||||
private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string
|
private static function normalizeWeekdaysCsv(mixed $value, string $moduleId, int $index): ?string
|
||||||
{
|
{
|
||||||
$raw = trim((string) $value);
|
$raw = trim((string) $value);
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ final class ModuleRegistry
|
|||||||
/** @var array<string, string> scheduler job key => owning module id */
|
/** @var array<string, string> scheduler job key => owning module id */
|
||||||
private array $schedulerJobOwners = [];
|
private array $schedulerJobOwners = [];
|
||||||
|
|
||||||
|
/** @var array<string, list<array{class: class-string, method: string}>> merged event listeners keyed by event name */
|
||||||
|
private array $mergedEventListeners = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Boot the registry from a modules directory and activation config.
|
* Boot the registry from a modules directory and activation config.
|
||||||
*
|
*
|
||||||
@@ -150,6 +153,7 @@ final class ModuleRegistry
|
|||||||
$registry->registerModule($manifest);
|
$registry->registerModule($manifest);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$registry->validateDependencies();
|
||||||
$registry->finalizeMergedContributions();
|
$registry->finalizeMergedContributions();
|
||||||
|
|
||||||
return $registry;
|
return $registry;
|
||||||
@@ -354,6 +358,14 @@ final class ModuleRegistry
|
|||||||
$this->mergedAuthorizationPolicies[] = $policyClass;
|
$this->mergedAuthorizationPolicies[] = $policyClass;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// — Merge event listeners ——————————————————————————
|
||||||
|
foreach ($manifest->eventListeners as $eventName => $handlers) {
|
||||||
|
if (!isset($this->mergedEventListeners[$eventName])) {
|
||||||
|
$this->mergedEventListeners[$eventName] = [];
|
||||||
|
}
|
||||||
|
array_push($this->mergedEventListeners[$eventName], ...$handlers);
|
||||||
|
}
|
||||||
|
|
||||||
// — Merge scheduler jobs —————————————————————————
|
// — Merge scheduler jobs —————————————————————————
|
||||||
foreach ($manifest->schedulerJobs as $schedulerJob) {
|
foreach ($manifest->schedulerJobs as $schedulerJob) {
|
||||||
$jobKey = trim((string) $schedulerJob['job_key']);
|
$jobKey = trim((string) $schedulerJob['job_key']);
|
||||||
@@ -388,6 +400,52 @@ final class ModuleRegistry
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function validateDependencies(): void
|
||||||
|
{
|
||||||
|
$enabledIds = array_keys($this->modules);
|
||||||
|
|
||||||
|
// Check all required modules are enabled
|
||||||
|
foreach ($this->modules as $manifest) {
|
||||||
|
foreach ($manifest->requires as $requiredId) {
|
||||||
|
if (!isset($this->modules[$requiredId])) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Module '{$manifest->id}' requires module '{$requiredId}' which is not enabled."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for circular dependencies (DFS cycle detection)
|
||||||
|
$visited = [];
|
||||||
|
$inStack = [];
|
||||||
|
|
||||||
|
$visit = function (string $moduleId) use (&$visit, &$visited, &$inStack): void {
|
||||||
|
if (isset($inStack[$moduleId])) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
"Circular module dependency detected involving module '{$moduleId}'."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (isset($visited[$moduleId])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inStack[$moduleId] = true;
|
||||||
|
|
||||||
|
if (isset($this->modules[$moduleId])) {
|
||||||
|
foreach ($this->modules[$moduleId]->requires as $requiredId) {
|
||||||
|
$visit($requiredId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
unset($inStack[$moduleId]);
|
||||||
|
$visited[$moduleId] = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach ($enabledIds as $moduleId) {
|
||||||
|
$visit($moduleId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function finalizeMergedContributions(): void
|
private function finalizeMergedContributions(): void
|
||||||
{
|
{
|
||||||
$this->mergedPublicPaths = array_values(array_unique(array_filter(
|
$this->mergedPublicPaths = array_values(array_unique(array_filter(
|
||||||
@@ -626,4 +684,10 @@ final class ModuleRegistry
|
|||||||
{
|
{
|
||||||
return $this->mergedSchedulerJobs;
|
return $this->mergedSchedulerJobs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<string, list<array{class: class-string, method: string}>> */
|
||||||
|
public function getEventListeners(): array
|
||||||
|
{
|
||||||
|
return $this->mergedEventListeners;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ use MintyPHP\App\Container\Registrars\RepositoryFactoryRegistrar;
|
|||||||
use MintyPHP\App\Container\Registrars\ServiceFactoryRegistrar;
|
use MintyPHP\App\Container\Registrars\ServiceFactoryRegistrar;
|
||||||
use MintyPHP\App\Container\Registrars\SettingsRegistrar;
|
use MintyPHP\App\Container\Registrars\SettingsRegistrar;
|
||||||
use MintyPHP\App\Container\Registrars\UserRegistrar;
|
use MintyPHP\App\Container\Registrars\UserRegistrar;
|
||||||
|
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||||
use MintyPHP\App\Module\ModuleRegistry;
|
use MintyPHP\App\Module\ModuleRegistry;
|
||||||
|
|
||||||
$container = new AppContainer();
|
$container = new AppContainer();
|
||||||
@@ -43,6 +44,10 @@ $modulesDir = dirname(__DIR__, 2) . '/modules';
|
|||||||
$moduleRegistry = ModuleRegistry::boot($modulesDir, $enabledModules);
|
$moduleRegistry = ModuleRegistry::boot($modulesDir, $enabledModules);
|
||||||
|
|
||||||
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry);
|
$container->set(ModuleRegistry::class, static fn (): ModuleRegistry => $moduleRegistry);
|
||||||
|
$container->set(ModuleEventDispatcher::class, static fn () => new ModuleEventDispatcher(
|
||||||
|
$moduleRegistry->getEventListeners(),
|
||||||
|
$container
|
||||||
|
));
|
||||||
|
|
||||||
// Disallow overriding existing core bindings from module registrars.
|
// Disallow overriding existing core bindings from module registrars.
|
||||||
$container->protectExistingBindings();
|
$container->protectExistingBindings();
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class PermissionService
|
|||||||
public const USERS_ACCESS_PDF = 'users.access_pdf';
|
public const USERS_ACCESS_PDF = 'users.access_pdf';
|
||||||
public const USERS_SELF_UPDATE = 'users.self_update';
|
public const USERS_SELF_UPDATE = 'users.self_update';
|
||||||
public const USERS_UPDATE_ASSIGNMENTS = 'users.update_assignments';
|
public const USERS_UPDATE_ASSIGNMENTS = 'users.update_assignments';
|
||||||
public const ADDRESS_BOOK_VIEW = 'address_book.view';
|
|
||||||
public const TENANTS_VIEW = 'tenants.view';
|
public const TENANTS_VIEW = 'tenants.view';
|
||||||
public const TENANTS_CREATE = 'tenants.create';
|
public const TENANTS_CREATE = 'tenants.create';
|
||||||
public const TENANTS_UPDATE = 'tenants.update';
|
public const TENANTS_UPDATE = 'tenants.update';
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ class UserAuthorizationPolicy implements AuthorizationPolicyInterface
|
|||||||
|
|
||||||
$canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId);
|
$canEditUser = $this->canEditUserForTarget($actorUserId, $targetUserId);
|
||||||
if (!$this->hasPermission($actorUserId, PermissionService::USERS_VIEW)
|
if (!$this->hasPermission($actorUserId, PermissionService::USERS_VIEW)
|
||||||
&& !$this->hasPermission($actorUserId, PermissionService::ADDRESS_BOOK_VIEW)
|
&& !$this->hasPermission($actorUserId, 'address_book.view') // Registered by addressbook module when active
|
||||||
&& !$canEditUser) {
|
&& !$canEditUser) {
|
||||||
return AuthorizationDecision::deny(403, 'forbidden');
|
return AuthorizationDecision::deny(403, 'forbidden');
|
||||||
}
|
}
|
||||||
@@ -409,7 +409,7 @@ class UserAuthorizationPolicy implements AuthorizationPolicyInterface
|
|||||||
|| ($isOwnAccount && $this->hasPermission($actorUserId, PermissionService::USERS_SELF_UPDATE))
|
|| ($isOwnAccount && $this->hasPermission($actorUserId, PermissionService::USERS_SELF_UPDATE))
|
||||||
),
|
),
|
||||||
'can_manage_api_tokens' => $canEditUser && $this->hasPermission($actorUserId, PermissionService::API_TOKENS_MANAGE),
|
'can_manage_api_tokens' => $canEditUser && $this->hasPermission($actorUserId, PermissionService::API_TOKENS_MANAGE),
|
||||||
'can_view_address_book' => $this->hasPermission($actorUserId, PermissionService::ADDRESS_BOOK_VIEW),
|
'can_view_address_book' => $this->hasPermission($actorUserId, 'address_book.view'), // Registered by addressbook module when active
|
||||||
'can_view_user_meta' => $this->hasPermission($actorUserId, PermissionService::USERS_VIEW_META),
|
'can_view_user_meta' => $this->hasPermission($actorUserId, PermissionService::USERS_VIEW_META),
|
||||||
'can_view_user_audit' => $canViewUserAudit,
|
'can_view_user_audit' => $canViewUserAudit,
|
||||||
'can_access_pdf' => $this->hasPermission($actorUserId, PermissionService::USERS_ACCESS_PDF),
|
'can_access_pdf' => $this->hasPermission($actorUserId, PermissionService::USERS_ACCESS_PDF),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Service\Auth;
|
namespace MintyPHP\Service\Auth;
|
||||||
|
|
||||||
|
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||||
use MintyPHP\Auth;
|
use MintyPHP\Auth;
|
||||||
use MintyPHP\Http\SessionStoreInterface;
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
use MintyPHP\Repository\User\UserReadRepositoryInterface;
|
||||||
@@ -42,7 +43,8 @@ class AuthService
|
|||||||
private readonly TenantSsoService $tenantSsoService,
|
private readonly TenantSsoService $tenantSsoService,
|
||||||
private readonly AuthSessionTenantContextService $authSessionTenantContextService,
|
private readonly AuthSessionTenantContextService $authSessionTenantContextService,
|
||||||
private readonly SystemAuditService $systemAuditService,
|
private readonly SystemAuditService $systemAuditService,
|
||||||
private readonly SessionStoreInterface $sessionStore
|
private readonly SessionStoreInterface $sessionStore,
|
||||||
|
private readonly ?ModuleEventDispatcher $eventDispatcher = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +132,11 @@ class AuthService
|
|||||||
'actor_tenant_id' => $this->currentTenantIdFromSession(),
|
'actor_tenant_id' => $this->currentTenantIdFromSession(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->eventDispatcher?->dispatch('user.login', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'provider' => 'local',
|
||||||
|
]);
|
||||||
|
|
||||||
return ['ok' => true];
|
return ['ok' => true];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -218,6 +225,12 @@ class AuthService
|
|||||||
'actor_tenant_id' => $this->currentTenantIdFromSession(),
|
'actor_tenant_id' => $this->currentTenantIdFromSession(),
|
||||||
'metadata' => ['provider' => $loginProvider],
|
'metadata' => ['provider' => $loginProvider],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->eventDispatcher?->dispatch('user.login', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'provider' => $loginProvider,
|
||||||
|
]);
|
||||||
|
|
||||||
return ['ok' => true, 'user' => $user];
|
return ['ok' => true, 'user' => $user];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +289,11 @@ class AuthService
|
|||||||
{
|
{
|
||||||
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
|
$actorUserId = (int) ($this->sessionUser()['id'] ?? 0);
|
||||||
$actorTenantId = $this->currentTenantIdFromSession();
|
$actorTenantId = $this->currentTenantIdFromSession();
|
||||||
|
|
||||||
|
$this->eventDispatcher?->dispatch('user.logout', [
|
||||||
|
'user_id' => $actorUserId,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->rememberMeService->forgetCurrentUser();
|
$this->rememberMeService->forgetCurrentUser();
|
||||||
$this->logoutWithModuleCleanup();
|
$this->logoutWithModuleCleanup();
|
||||||
$this->recordAuthEvent('auth.logout', 'success', [
|
$this->recordAuthEvent('auth.logout', 'success', [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace MintyPHP\Service\Auth;
|
namespace MintyPHP\Service\Auth;
|
||||||
|
|
||||||
use MintyPHP\App\AppContainer;
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||||
use MintyPHP\Http\CookieStoreInterface;
|
use MintyPHP\Http\CookieStoreInterface;
|
||||||
use MintyPHP\Http\RequestRuntimeInterface;
|
use MintyPHP\Http\RequestRuntimeInterface;
|
||||||
use MintyPHP\Http\SessionStoreInterface;
|
use MintyPHP\Http\SessionStoreInterface;
|
||||||
@@ -100,7 +101,8 @@ class AuthServicesFactory
|
|||||||
$this->createTenantSsoService(),
|
$this->createTenantSsoService(),
|
||||||
$this->createAuthSessionTenantContextService(),
|
$this->createAuthSessionTenantContextService(),
|
||||||
$this->auditServicesFactory->createSystemAuditService(),
|
$this->auditServicesFactory->createSystemAuditService(),
|
||||||
$this->sessionStore
|
$this->sessionStore,
|
||||||
|
$this->resolveEventDispatcher()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,6 +150,15 @@ class AuthServicesFactory
|
|||||||
return new OidcHttpGateway();
|
return new OidcHttpGateway();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function resolveEventDispatcher(): ?ModuleEventDispatcher
|
||||||
|
{
|
||||||
|
if ($this->appContainer->has(ModuleEventDispatcher::class)) {
|
||||||
|
$dispatcher = $this->appContainer->get(ModuleEventDispatcher::class);
|
||||||
|
return $dispatcher instanceof ModuleEventDispatcher ? $dispatcher : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private function createAuthSessionTenantContextService(): AuthSessionTenantContextService
|
private function createAuthSessionTenantContextService(): AuthSessionTenantContextService
|
||||||
{
|
{
|
||||||
return $this->authSessionTenantContextService ??= new AuthSessionTenantContextService(
|
return $this->authSessionTenantContextService ??= new AuthSessionTenantContextService(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Service\User;
|
namespace MintyPHP\Service\User;
|
||||||
|
|
||||||
|
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||||
use MintyPHP\I18n;
|
use MintyPHP\I18n;
|
||||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||||
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
|
use MintyPHP\Repository\User\UserListQueryRepositoryInterface;
|
||||||
@@ -36,7 +37,8 @@ class UserAccountService
|
|||||||
private readonly TenantScopeService $scopeGateway,
|
private readonly TenantScopeService $scopeGateway,
|
||||||
private readonly UserDirectoryGateway $directoryGateway,
|
private readonly UserDirectoryGateway $directoryGateway,
|
||||||
private readonly SystemAuditService $systemAuditService,
|
private readonly SystemAuditService $systemAuditService,
|
||||||
private readonly DatabaseSessionRepository $databaseSessionRepository
|
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
||||||
|
private readonly ?ModuleEventDispatcher $eventDispatcher = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,6 +114,12 @@ class UserAccountService
|
|||||||
'target_uuid' => (string) ($user['uuid'] ?? ''),
|
'target_uuid' => (string) ($user['uuid'] ?? ''),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->eventDispatcher?->dispatch('user.deleted', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'uuid' => (string) ($user['uuid'] ?? ''),
|
||||||
|
'actor_user_id' => $currentUserId,
|
||||||
|
]);
|
||||||
|
|
||||||
return ['ok' => true, 'user' => $user];
|
return ['ok' => true, 'user' => $user];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -291,6 +299,12 @@ class UserAccountService
|
|||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->eventDispatcher?->dispatch('user.created', [
|
||||||
|
'user_id' => $userId,
|
||||||
|
'uuid' => is_string($uuid) ? $uuid : '',
|
||||||
|
'actor_user_id' => $currentUserId,
|
||||||
|
]);
|
||||||
|
|
||||||
return ['ok' => true, 'form' => $form, 'uuid' => $uuid];
|
return ['ok' => true, 'form' => $form, 'uuid' => $uuid];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Service\User;
|
namespace MintyPHP\Service\User;
|
||||||
|
|
||||||
|
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||||
use MintyPHP\Repository\Access\UserRoleRepositoryInterface;
|
use MintyPHP\Repository\Access\UserRoleRepositoryInterface;
|
||||||
use MintyPHP\Repository\Org\UserDepartmentRepositoryInterface;
|
use MintyPHP\Repository\Org\UserDepartmentRepositoryInterface;
|
||||||
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
use MintyPHP\Repository\Support\DatabaseSessionRepository;
|
||||||
@@ -33,7 +34,8 @@ class UserServicesFactory
|
|||||||
private readonly UserRepositoryFactory $userRepositoryFactory,
|
private readonly UserRepositoryFactory $userRepositoryFactory,
|
||||||
private readonly UserGatewayFactory $userGatewayFactory,
|
private readonly UserGatewayFactory $userGatewayFactory,
|
||||||
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
private readonly DatabaseSessionRepository $databaseSessionRepository,
|
||||||
private readonly \Closure $assignableRoleServiceFactory
|
private readonly \Closure $assignableRoleServiceFactory,
|
||||||
|
private readonly ?ModuleEventDispatcher $eventDispatcher = null
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,7 +51,8 @@ class UserServicesFactory
|
|||||||
$this->userGatewayFactory->getTenantScopeService(),
|
$this->userGatewayFactory->getTenantScopeService(),
|
||||||
$this->createUserDirectoryGateway(),
|
$this->createUserDirectoryGateway(),
|
||||||
$this->auditServicesFactory->createSystemAuditService(),
|
$this->auditServicesFactory->createSystemAuditService(),
|
||||||
$this->databaseSessionRepository
|
$this->databaseSessionRepository,
|
||||||
|
$this->eventDispatcher
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use MintyPHP\Service\Access\PermissionService;
|
|||||||
final class AddressBookAuthorizationPolicy implements AuthorizationPolicyInterface
|
final class AddressBookAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||||
{
|
{
|
||||||
public const ABILITY_VIEW = 'addressbook.view';
|
public const ABILITY_VIEW = 'addressbook.view';
|
||||||
|
public const PERMISSION_KEY = 'address_book.view';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly PermissionService $permissionService
|
private readonly PermissionService $permissionService
|
||||||
@@ -33,7 +34,7 @@ final class AddressBookAuthorizationPolicy implements AuthorizationPolicyInterfa
|
|||||||
return AuthorizationDecision::deny(403, 'forbidden');
|
return AuthorizationDecision::deny(403, 'forbidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!$this->permissionService->userHas($actorUserId, PermissionService::ADDRESS_BOOK_VIEW)) {
|
if (!$this->permissionService->userHas($actorUserId, self::PERMISSION_KEY)) {
|
||||||
return AuthorizationDecision::deny(403, 'forbidden');
|
return AuthorizationDecision::deny(403, 'forbidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ final class AddressBookLayoutProvider implements LayoutContextProvider
|
|||||||
'activeDepartments' => appNormalizePositiveIntList($query['departments'] ?? ''),
|
'activeDepartments' => appNormalizePositiveIntList($query['departments'] ?? ''),
|
||||||
'activeRoles' => appNormalizePositiveIntList($query['roles'] ?? ''),
|
'activeRoles' => appNormalizePositiveIntList($query['roles'] ?? ''),
|
||||||
'activeCustomFilters' => self::normalizeCustomFilterQuery($query),
|
'activeCustomFilters' => self::normalizeCustomFilterQuery($query),
|
||||||
'peopleGroups' => is_array($session['available_departments_by_tenant'] ?? null)
|
'peopleGroups' => is_array($session['module.addressbook.departments_by_tenant'] ?? null)
|
||||||
? $session['available_departments_by_tenant']
|
? $session['module.addressbook.departments_by_tenant']
|
||||||
: [],
|
: [],
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ final class AddressBookSessionProvider implements SessionProvider
|
|||||||
{
|
{
|
||||||
$userId = (int) ($user['id'] ?? 0);
|
$userId = (int) ($user['id'] ?? 0);
|
||||||
if ($userId <= 0) {
|
if ($userId <= 0) {
|
||||||
unset($_SESSION['available_departments_by_tenant']);
|
unset($_SESSION['module.addressbook.departments_by_tenant']);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,13 +28,13 @@ final class AddressBookSessionProvider implements SessionProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
$sessionStore->set(
|
$sessionStore->set(
|
||||||
'available_departments_by_tenant',
|
'module.addressbook.departments_by_tenant',
|
||||||
$tenantContext->getAvailableDepartmentsByTenant($userId)
|
$tenantContext->getAvailableDepartmentsByTenant($userId)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function clear(): void
|
public function clear(): void
|
||||||
{
|
{
|
||||||
unset($_SESSION['available_departments_by_tenant']);
|
unset($_SESSION['module.addressbook.departments_by_tenant']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ return [
|
|||||||
'version' => '1.0.0',
|
'version' => '1.0.0',
|
||||||
'enabled_by_default' => false,
|
'enabled_by_default' => false,
|
||||||
'load_order' => 10,
|
'load_order' => 10,
|
||||||
|
'requires' => [],
|
||||||
|
|
||||||
'routes' => [
|
'routes' => [
|
||||||
// Keep canonical route stable without forcing a /index target to avoid Router redirect loops.
|
// Keep canonical route stable without forcing a /index target to avoid Router redirect loops.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ return [
|
|||||||
'version' => '1.0.0',
|
'version' => '1.0.0',
|
||||||
'enabled_by_default' => true,
|
'enabled_by_default' => true,
|
||||||
'load_order' => 20,
|
'load_order' => 20,
|
||||||
|
'requires' => [],
|
||||||
|
|
||||||
'routes' => [
|
'routes' => [
|
||||||
['path' => 'bookmarks/save-data', 'target' => 'bookmarks/save-data'],
|
['path' => 'bookmarks/save-data', 'target' => 'bookmarks/save-data'],
|
||||||
|
|||||||
@@ -11,18 +11,23 @@ class AgentSystemContractTest extends TestCase
|
|||||||
public function testAgentSystemJsonFilesAreValidJson(): void
|
public function testAgentSystemJsonFilesAreValidJson(): void
|
||||||
{
|
{
|
||||||
$jsonFiles = [
|
$jsonFiles = [
|
||||||
'agent-system/checks/guard-catalog.json',
|
'.agents/checks/guard-catalog.json',
|
||||||
'agent-system/checks/quality-gates.json',
|
'.agents/checks/quality-gates.json',
|
||||||
'agent-system/contracts/planner.schema.json',
|
'.agents/contracts/analyst.schema.json',
|
||||||
'agent-system/contracts/executor.schema.json',
|
'.agents/contracts/planner.schema.json',
|
||||||
'agent-system/contracts/reviewer-guards.schema.json',
|
'.agents/contracts/executor.schema.json',
|
||||||
'agent-system/contracts/reviewer-acceptance.schema.json',
|
'.agents/contracts/reviewer-code.schema.json',
|
||||||
'agent-system/contracts/finalizer.schema.json',
|
'.agents/contracts/reviewer-security.schema.json',
|
||||||
'agent-system/templates/plan.template.json',
|
'.agents/contracts/reviewer-acceptance.schema.json',
|
||||||
'agent-system/templates/execution-report.template.json',
|
'.agents/contracts/finalizer.schema.json',
|
||||||
'agent-system/templates/review-guards.template.json',
|
'.agents/templates/analysis.template.json',
|
||||||
'agent-system/templates/review-acceptance.template.json',
|
'.agents/templates/plan.template.json',
|
||||||
'agent-system/templates/finalize.template.json',
|
'.agents/templates/execution-report.template.json',
|
||||||
|
'.agents/templates/review-code.template.json',
|
||||||
|
'.agents/templates/review-security.template.json',
|
||||||
|
'.agents/templates/review-acceptance.template.json',
|
||||||
|
'.agents/templates/finalize.template.json',
|
||||||
|
'.agents/contracts/module-manifest.schema.json',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($jsonFiles as $file) {
|
foreach ($jsonFiles as $file) {
|
||||||
@@ -33,27 +38,42 @@ class AgentSystemContractTest extends TestCase
|
|||||||
|
|
||||||
public function testGuardCatalogIdsAreUniqueAndWellFormed(): void
|
public function testGuardCatalogIdsAreUniqueAndWellFormed(): void
|
||||||
{
|
{
|
||||||
$catalog = $this->decodeJson('agent-system/checks/guard-catalog.json');
|
$catalog = $this->decodeJson('.agents/checks/guard-catalog.json');
|
||||||
$guards = is_array($catalog['guards'] ?? null) ? $catalog['guards'] : [];
|
$guards = is_array($catalog['guards'] ?? null) ? $catalog['guards'] : [];
|
||||||
$this->assertNotSame([], $guards, 'Guard catalog must not be empty.');
|
$this->assertNotSame([], $guards, 'Guard catalog must not be empty.');
|
||||||
|
|
||||||
$ids = [];
|
$ids = [];
|
||||||
foreach ($guards as $guard) {
|
foreach ($guards as $guard) {
|
||||||
$id = (string) ($guard['id'] ?? '');
|
$id = (string) ($guard['id'] ?? '');
|
||||||
$source = (string) ($guard['source'] ?? '');
|
$reviewer = (string) ($guard['reviewer'] ?? '');
|
||||||
$this->assertMatchesRegularExpression('/^GR-[A-Z]+-[0-9]{3}$/', $id, 'Invalid guard id: ' . $id);
|
$this->assertMatchesRegularExpression('/^GR-(CORE|TEST|UI|SEC)-[A-Z0-9]{3,}$/', $id, 'Invalid guard id: ' . $id);
|
||||||
$this->assertNotSame('', $source, 'Missing guard source for ' . $id);
|
$this->assertContains($reviewer, ['code', 'security'], 'Invalid reviewer for ' . $id);
|
||||||
$sourcePath = explode('#', $source)[0];
|
|
||||||
$this->readProjectFile($sourcePath);
|
|
||||||
$ids[] = $id;
|
$ids[] = $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->assertSame($ids, array_values(array_unique($ids)), 'Duplicate guard IDs found.');
|
$this->assertSame($ids, array_values(array_unique($ids)), 'Duplicate guard IDs found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testGuardCatalogReviewerAssignment(): void
|
||||||
|
{
|
||||||
|
$catalog = $this->decodeJson('.agents/checks/guard-catalog.json');
|
||||||
|
$guards = is_array($catalog['guards'] ?? null) ? $catalog['guards'] : [];
|
||||||
|
|
||||||
|
foreach ($guards as $guard) {
|
||||||
|
$id = (string) ($guard['id'] ?? '');
|
||||||
|
$reviewer = (string) ($guard['reviewer'] ?? '');
|
||||||
|
|
||||||
|
if (str_starts_with($id, 'GR-SEC-')) {
|
||||||
|
$this->assertSame('security', $reviewer, $id . ' must be assigned to security reviewer');
|
||||||
|
} else {
|
||||||
|
$this->assertSame('code', $reviewer, $id . ' must be assigned to code reviewer');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function testQualityGateIdsAreUniqueAndWellFormed(): void
|
public function testQualityGateIdsAreUniqueAndWellFormed(): void
|
||||||
{
|
{
|
||||||
$gates = $this->decodeJson('agent-system/checks/quality-gates.json');
|
$gates = $this->decodeJson('.agents/checks/quality-gates.json');
|
||||||
$items = is_array($gates['gates'] ?? null) ? $gates['gates'] : [];
|
$items = is_array($gates['gates'] ?? null) ? $gates['gates'] : [];
|
||||||
$this->assertNotSame([], $items, 'Quality gates must not be empty.');
|
$this->assertNotSame([], $items, 'Quality gates must not be empty.');
|
||||||
|
|
||||||
@@ -71,8 +91,8 @@ class AgentSystemContractTest extends TestCase
|
|||||||
|
|
||||||
public function testTemplatesReferenceKnownGuardAndGateIds(): void
|
public function testTemplatesReferenceKnownGuardAndGateIds(): void
|
||||||
{
|
{
|
||||||
$catalog = $this->decodeJson('agent-system/checks/guard-catalog.json');
|
$catalog = $this->decodeJson('.agents/checks/guard-catalog.json');
|
||||||
$gates = $this->decodeJson('agent-system/checks/quality-gates.json');
|
$gates = $this->decodeJson('.agents/checks/quality-gates.json');
|
||||||
$guardIds = array_map(
|
$guardIds = array_map(
|
||||||
static fn (array $guard): string => (string) ($guard['id'] ?? ''),
|
static fn (array $guard): string => (string) ($guard['id'] ?? ''),
|
||||||
is_array($catalog['guards'] ?? null) ? $catalog['guards'] : []
|
is_array($catalog['guards'] ?? null) ? $catalog['guards'] : []
|
||||||
@@ -82,7 +102,7 @@ class AgentSystemContractTest extends TestCase
|
|||||||
is_array($gates['gates'] ?? null) ? $gates['gates'] : []
|
is_array($gates['gates'] ?? null) ? $gates['gates'] : []
|
||||||
);
|
);
|
||||||
|
|
||||||
$planTemplate = $this->decodeJson('agent-system/templates/plan.template.json');
|
$planTemplate = $this->decodeJson('.agents/templates/plan.template.json');
|
||||||
$planGuardIds = is_array($planTemplate['guardrails']['required_guard_ids'] ?? null)
|
$planGuardIds = is_array($planTemplate['guardrails']['required_guard_ids'] ?? null)
|
||||||
? $planTemplate['guardrails']['required_guard_ids']
|
? $planTemplate['guardrails']['required_guard_ids']
|
||||||
: [];
|
: [];
|
||||||
@@ -96,39 +116,74 @@ class AgentSystemContractTest extends TestCase
|
|||||||
$this->assertContains($id, $gateIds, 'Unknown gate id in plan template: ' . (string) $id);
|
$this->assertContains($id, $gateIds, 'Unknown gate id in plan template: ' . (string) $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
$reviewGuardsTemplate = $this->decodeJson('agent-system/templates/review-guards.template.json');
|
$reviewCodeTemplate = $this->decodeJson('.agents/templates/review-code.template.json');
|
||||||
$reviewGuardIds = is_array($reviewGuardsTemplate['checked_guard_ids'] ?? null)
|
$reviewGuardIds = is_array($reviewCodeTemplate['checked_guard_ids'] ?? null)
|
||||||
? $reviewGuardsTemplate['checked_guard_ids']
|
? $reviewCodeTemplate['checked_guard_ids']
|
||||||
: [];
|
: [];
|
||||||
$reviewGateIds = is_array($reviewGuardsTemplate['checked_quality_gate_ids'] ?? null)
|
$reviewGateIds = is_array($reviewCodeTemplate['checked_quality_gate_ids'] ?? null)
|
||||||
? $reviewGuardsTemplate['checked_quality_gate_ids']
|
? $reviewCodeTemplate['checked_quality_gate_ids']
|
||||||
: [];
|
: [];
|
||||||
foreach ($reviewGuardIds as $id) {
|
foreach ($reviewGuardIds as $id) {
|
||||||
$this->assertContains($id, $guardIds, 'Unknown guard id in review template: ' . (string) $id);
|
$this->assertContains($id, $guardIds, 'Unknown guard id in review-code template: ' . (string) $id);
|
||||||
}
|
}
|
||||||
foreach ($reviewGateIds as $id) {
|
foreach ($reviewGateIds as $id) {
|
||||||
$this->assertContains($id, $gateIds, 'Unknown gate id in review template: ' . (string) $id);
|
$this->assertContains($id, $gateIds, 'Unknown gate id in review-code template: ' . (string) $id);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reviewSecurityTemplate = $this->decodeJson('.agents/templates/review-security.template.json');
|
||||||
|
$secGuardIds = is_array($reviewSecurityTemplate['checked_guard_ids'] ?? null)
|
||||||
|
? $reviewSecurityTemplate['checked_guard_ids']
|
||||||
|
: [];
|
||||||
|
foreach ($secGuardIds as $id) {
|
||||||
|
$this->assertContains($id, $guardIds, 'Unknown guard id in review-security template: ' . (string) $id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testRolePromptsReferenceGuardCatalogAndQualityGates(): void
|
public function testRolePromptsReferenceGuardCatalog(): void
|
||||||
{
|
{
|
||||||
$plannerPrompt = $this->readProjectFile('agent-system/prompts/planner.md');
|
$plannerPrompt = $this->readProjectFile('.agents/prompts/planner.md');
|
||||||
$executorPrompt = $this->readProjectFile('agent-system/prompts/executor.md');
|
$executorPrompt = $this->readProjectFile('.agents/prompts/executor.md');
|
||||||
$reviewerPrompt = $this->readProjectFile('agent-system/prompts/reviewer-guards.md');
|
$codeReviewerPrompt = $this->readProjectFile('.agents/prompts/reviewer-code.md');
|
||||||
|
$securityReviewerPrompt = $this->readProjectFile('.agents/prompts/reviewer-security.md');
|
||||||
|
|
||||||
$this->assertStringContainsString('agent-system/checks/guard-catalog.json', $plannerPrompt);
|
$this->assertStringContainsString('.agents/checks/guard-catalog.json', $plannerPrompt);
|
||||||
$this->assertStringContainsString('agent-system/checks/quality-gates.json', $plannerPrompt);
|
$this->assertStringContainsString('.agents/checks/quality-gates.json', $plannerPrompt);
|
||||||
$this->assertStringContainsString('DOCS-SKILLS-AUDIT-xxx', $plannerPrompt);
|
$this->assertStringContainsString('.agents/checks/guard-catalog.json', $executorPrompt);
|
||||||
$this->assertStringContainsString('QG-008', $plannerPrompt);
|
$this->assertStringContainsString('.agents/checks/quality-gates.json', $executorPrompt);
|
||||||
$this->assertStringContainsString('QG-009', $plannerPrompt);
|
$this->assertStringContainsString('.agents/checks/guard-catalog.json', $codeReviewerPrompt);
|
||||||
$this->assertStringContainsString('agent-system/checks/guard-catalog.json', $executorPrompt);
|
$this->assertStringContainsString('.agents/checks/guard-catalog.json', $securityReviewerPrompt);
|
||||||
$this->assertStringContainsString('agent-system/checks/quality-gates.json', $executorPrompt);
|
}
|
||||||
$this->assertStringContainsString('QG-008', $executorPrompt);
|
|
||||||
$this->assertStringContainsString('QG-009', $executorPrompt);
|
public function testAllSevenRolePromptsExist(): void
|
||||||
$this->assertStringContainsString('agent-system/checks/guard-catalog.json', $reviewerPrompt);
|
{
|
||||||
$this->assertStringContainsString('agent-system/checks/quality-gates.json', $reviewerPrompt);
|
$roles = ['analyst', 'planner', 'executor', 'reviewer-code', 'reviewer-security', 'reviewer-acceptance', 'finalizer'];
|
||||||
$this->assertStringContainsString('agent-system/checks/docs-skills-audit-policy.md', $reviewerPrompt);
|
|
||||||
|
foreach ($roles as $role) {
|
||||||
|
$this->readProjectFile('.agents/prompts/' . $role . '.md');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllSevenContractsExist(): void
|
||||||
|
{
|
||||||
|
$contracts = ['analyst', 'planner', 'executor', 'reviewer-code', 'reviewer-security', 'reviewer-acceptance', 'finalizer'];
|
||||||
|
|
||||||
|
foreach ($contracts as $contract) {
|
||||||
|
$decoded = json_decode($this->readProjectFile('.agents/contracts/' . $contract . '.schema.json'), true);
|
||||||
|
$this->assertIsArray($decoded, 'Invalid JSON schema: ' . $contract);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAllSevenTemplatesExist(): void
|
||||||
|
{
|
||||||
|
$templates = [
|
||||||
|
'analysis', 'plan', 'execution-report',
|
||||||
|
'review-code', 'review-security', 'review-acceptance', 'finalize',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($templates as $template) {
|
||||||
|
$decoded = json_decode($this->readProjectFile('.agents/templates/' . $template . '.template.json'), true);
|
||||||
|
$this->assertIsArray($decoded, 'Invalid JSON template: ' . $template);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -143,6 +143,57 @@ final class ModuleRegistryContractTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testMissingDependencyThrowsException(): void
|
||||||
|
{
|
||||||
|
$fixturesDir = sys_get_temp_dir() . '/module-dep-test-' . uniqid();
|
||||||
|
mkdir($fixturesDir . '/mod-dep', 0777, true);
|
||||||
|
|
||||||
|
file_put_contents($fixturesDir . '/mod-dep/module.php', '<?php return ' . var_export([
|
||||||
|
'id' => 'mod-dep',
|
||||||
|
'requires' => ['nonexistent-module'],
|
||||||
|
], true) . ';');
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage("requires module 'nonexistent-module' which is not enabled");
|
||||||
|
|
||||||
|
try {
|
||||||
|
ModuleRegistry::boot($fixturesDir, ['mod-dep']);
|
||||||
|
} finally {
|
||||||
|
@unlink($fixturesDir . '/mod-dep/module.php');
|
||||||
|
@rmdir($fixturesDir . '/mod-dep');
|
||||||
|
@rmdir($fixturesDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCircularDependencyThrowsException(): void
|
||||||
|
{
|
||||||
|
$fixturesDir = sys_get_temp_dir() . '/module-circular-test-' . uniqid();
|
||||||
|
mkdir($fixturesDir . '/mod-x', 0777, true);
|
||||||
|
mkdir($fixturesDir . '/mod-y', 0777, true);
|
||||||
|
|
||||||
|
file_put_contents($fixturesDir . '/mod-x/module.php', '<?php return ' . var_export([
|
||||||
|
'id' => 'mod-x',
|
||||||
|
'requires' => ['mod-y'],
|
||||||
|
], true) . ';');
|
||||||
|
file_put_contents($fixturesDir . '/mod-y/module.php', '<?php return ' . var_export([
|
||||||
|
'id' => 'mod-y',
|
||||||
|
'requires' => ['mod-x'],
|
||||||
|
], true) . ';');
|
||||||
|
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('Circular module dependency');
|
||||||
|
|
||||||
|
try {
|
||||||
|
ModuleRegistry::boot($fixturesDir, ['mod-x', 'mod-y']);
|
||||||
|
} finally {
|
||||||
|
@unlink($fixturesDir . '/mod-x/module.php');
|
||||||
|
@unlink($fixturesDir . '/mod-y/module.php');
|
||||||
|
@rmdir($fixturesDir . '/mod-x');
|
||||||
|
@rmdir($fixturesDir . '/mod-y');
|
||||||
|
@rmdir($fixturesDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function testConflictDetectionIsStrict(): void
|
public function testConflictDetectionIsStrict(): void
|
||||||
{
|
{
|
||||||
$fixturesDir = sys_get_temp_dir() . '/module-arch-test-' . uniqid();
|
$fixturesDir = sys_get_temp_dir() . '/module-arch-test-' . uniqid();
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace MintyPHP\Tests\Architecture;
|
namespace MintyPHP\Tests\Architecture;
|
||||||
|
|
||||||
|
use MintyPHP\App\Module\Contracts\EventListener;
|
||||||
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
||||||
|
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
|
||||||
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
|
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
|
||||||
use MintyPHP\App\Module\Contracts\SessionProvider;
|
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||||
use MintyPHP\App\Module\ModuleManifest;
|
use MintyPHP\App\Module\ModuleManifest;
|
||||||
@@ -128,6 +130,26 @@ final class ModuleStructureContractTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRequiresFieldContainsOnlyValidModuleIds(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->requires as $index => $requiredId) {
|
||||||
|
self::assertIsString($requiredId);
|
||||||
|
self::assertNotEmpty(
|
||||||
|
trim($requiredId),
|
||||||
|
"Module '{$module['id']}' requires[{$index}] must be a non-empty string"
|
||||||
|
);
|
||||||
|
self::assertMatchesRegularExpression(
|
||||||
|
'/^[a-z][a-z0-9_-]*$/',
|
||||||
|
$requiredId,
|
||||||
|
"Module '{$module['id']}' requires[{$index}] has invalid module id format: '{$requiredId}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── File structure ──────────────────────────────────────────────
|
// ─── File structure ──────────────────────────────────────────────
|
||||||
|
|
||||||
public function testModuleHasLibDirectory(): void
|
public function testModuleHasLibDirectory(): void
|
||||||
@@ -542,6 +564,113 @@ final class ModuleStructureContractTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Event listener contract ────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testEventListenerClassesImplementContract(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->eventListeners as $eventName => $handlers) {
|
||||||
|
foreach ($handlers as $index => $handler) {
|
||||||
|
$class = $handler['class'];
|
||||||
|
self::assertTrue(
|
||||||
|
class_exists($class),
|
||||||
|
"Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' not found"
|
||||||
|
);
|
||||||
|
self::assertTrue(
|
||||||
|
is_subclass_of($class, EventListener::class)
|
||||||
|
|| in_array(EventListener::class, class_implements($class) ?: [], true),
|
||||||
|
"Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' "
|
||||||
|
. "must implement EventListener interface"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Deactivation handler contract ──────────────────────────────
|
||||||
|
|
||||||
|
public function testDeactivationHandlerClassImplementsContract(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
$handler = $module['manifest']->deactivationHandler;
|
||||||
|
if ($handler === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
self::assertTrue(
|
||||||
|
class_exists($handler),
|
||||||
|
"Module '{$module['id']}' deactivation_handler class '{$handler}' not found"
|
||||||
|
);
|
||||||
|
self::assertTrue(
|
||||||
|
is_subclass_of($handler, ModuleDeactivationHandler::class)
|
||||||
|
|| in_array(ModuleDeactivationHandler::class, class_implements($handler) ?: [], true),
|
||||||
|
"Module '{$module['id']}' deactivation_handler class '{$handler}' "
|
||||||
|
. "must implement ModuleDeactivationHandler interface"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Session key convention ────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module session providers must use the 'module.<id>.' prefix for all session keys.
|
||||||
|
*
|
||||||
|
* Scans session provider classes for $_SESSION[ and ->set( patterns and verifies
|
||||||
|
* that the key argument starts with 'module.<module_id>.'.
|
||||||
|
*/
|
||||||
|
public function testSessionProvidersUseModulePrefixedKeys(): void
|
||||||
|
{
|
||||||
|
foreach (self::$modules as $module) {
|
||||||
|
foreach ($module['manifest']->sessionProviders as $providerClass) {
|
||||||
|
if (!class_exists($providerClass)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reflection = new \ReflectionClass($providerClass);
|
||||||
|
$file = $reflection->getFileName();
|
||||||
|
if ($file === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$content = file_get_contents($file);
|
||||||
|
if ($content === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expectedPrefix = 'module.' . $module['id'] . '.';
|
||||||
|
|
||||||
|
// Check $_SESSION['...'] writes and unset($_SESSION['...'])
|
||||||
|
if (preg_match_all('/\$_SESSION\[\'([^\']+)\'\]/', $content, $matches)) {
|
||||||
|
foreach ($matches[1] as $key) {
|
||||||
|
self::assertStringStartsWith(
|
||||||
|
$expectedPrefix,
|
||||||
|
$key,
|
||||||
|
"Module '{$module['id']}' session provider '{$providerClass}' uses session key "
|
||||||
|
. "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check ->set('...', ...) calls on session store
|
||||||
|
if (preg_match_all('/->set\(\s*\'([^\']+)\'/', $content, $matches)) {
|
||||||
|
foreach ($matches[1] as $key) {
|
||||||
|
self::assertStringStartsWith(
|
||||||
|
$expectedPrefix,
|
||||||
|
$key,
|
||||||
|
"Module '{$module['id']}' session provider '{$providerClass}' uses session store key "
|
||||||
|
. "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── No hardcoded Core ability imports in module pages ───────────
|
// ─── No hardcoded Core ability imports in module pages ───────────
|
||||||
|
|
||||||
public function testModulePagesDoNotImportCoreAbilityConstants(): void
|
public function testModulePagesDoNotImportCoreAbilityConstants(): void
|
||||||
|
|||||||
@@ -12,8 +12,7 @@ use FilesystemIterator;
|
|||||||
* in the search infrastructure, layout context builder, or aside templates.
|
* in the search infrastructure, layout context builder, or aside templates.
|
||||||
*
|
*
|
||||||
* Intentionally allowed in Core (not flagged):
|
* Intentionally allowed in Core (not flagged):
|
||||||
* - PermissionService::ADDRESS_BOOK_VIEW (general people-visibility gate)
|
* - OperationsAuthorizationPolicy / UiCapabilityMap / UserAuthorizationPolicy (authorization — uses inline 'address_book.view' string)
|
||||||
* - OperationsAuthorizationPolicy / UiCapabilityMap / UserAuthorizationPolicy (authorization)
|
|
||||||
* - modules/addressbook/* (module-owned runtime, pages/assets/providers)
|
* - modules/addressbook/* (module-owned runtime, pages/assets/providers)
|
||||||
* - i18n translation files
|
* - i18n translation files
|
||||||
* - Module directory (lib/Module/, modules/)
|
* - Module directory (lib/Module/, modules/)
|
||||||
|
|||||||
@@ -364,7 +364,7 @@ class UserAuthorizationPolicyTest extends TestCase
|
|||||||
public function testAvatarViewAllowsInScopeAddressBookViewer(): void
|
public function testAvatarViewAllowsInScopeAddressBookViewer(): void
|
||||||
{
|
{
|
||||||
$permissionService = $this->permissionGatewayAllowing([
|
$permissionService = $this->permissionGatewayAllowing([
|
||||||
5 => [PermissionService::ADDRESS_BOOK_VIEW],
|
5 => ['address_book.view'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$scopeGateway = $this->createMock(TenantScopeService::class);
|
$scopeGateway = $this->createMock(TenantScopeService::class);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class AuthSessionTenantContextServiceTest extends TestCase
|
|||||||
$this->assertSame([], $_SESSION['available_tenants']);
|
$this->assertSame([], $_SESSION['available_tenants']);
|
||||||
$this->assertTrue((bool) $_SESSION['no_active_tenant']);
|
$this->assertTrue((bool) $_SESSION['no_active_tenant']);
|
||||||
$this->assertArrayNotHasKey('current_tenant', $_SESSION);
|
$this->assertArrayNotHasKey('current_tenant', $_SESSION);
|
||||||
$this->assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
$this->assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testHydrateUsesFirstAvailableTenantAsFallback(): void
|
public function testHydrateUsesFirstAvailableTenantAsFallback(): void
|
||||||
@@ -54,7 +54,7 @@ class AuthSessionTenantContextServiceTest extends TestCase
|
|||||||
$this->assertSame($availableTenants, $_SESSION['available_tenants']);
|
$this->assertSame($availableTenants, $_SESSION['available_tenants']);
|
||||||
$this->assertFalse((bool) $_SESSION['no_active_tenant']);
|
$this->assertFalse((bool) $_SESSION['no_active_tenant']);
|
||||||
$this->assertSame(7, (int) ($_SESSION['current_tenant']['id'] ?? 0));
|
$this->assertSame(7, (int) ($_SESSION['current_tenant']['id'] ?? 0));
|
||||||
$this->assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
$this->assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testHydratePreservesThemeAndPolicyFieldsInSession(): void
|
public function testHydratePreservesThemeAndPolicyFieldsInSession(): void
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ final class AddressBookAuthorizationPolicyTest extends TestCase
|
|||||||
public function testAllowedWithPermission(): void
|
public function testAllowedWithPermission(): void
|
||||||
{
|
{
|
||||||
$permissionService = $this->permissionGatewayAllowing([
|
$permissionService = $this->permissionGatewayAllowing([
|
||||||
5 => [PermissionService::ADDRESS_BOOK_VIEW],
|
5 => [AddressBookAuthorizationPolicy::PERMISSION_KEY],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$policy = new AddressBookAuthorizationPolicy($permissionService);
|
$policy = new AddressBookAuthorizationPolicy($permissionService);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ final class LayoutContextProviderTest extends TestCase
|
|||||||
$container = new AppContainer();
|
$container = new AppContainer();
|
||||||
|
|
||||||
$session = [
|
$session = [
|
||||||
'available_departments_by_tenant' => [
|
'module.addressbook.departments_by_tenant' => [
|
||||||
[
|
[
|
||||||
'tenant' => ['uuid' => 'abc', 'description' => 'Tenant A'],
|
'tenant' => ['uuid' => 'abc', 'description' => 'Tenant A'],
|
||||||
'departments' => [['id' => 1, 'description' => 'Dept 1']],
|
'departments' => [['id' => 1, 'description' => 'Dept 1']],
|
||||||
|
|||||||
100
tests/Unit/Module/ModuleEventDispatcherTest.php
Normal file
100
tests/Unit/Module/ModuleEventDispatcherTest.php
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Tests\Unit\Module;
|
||||||
|
|
||||||
|
use MintyPHP\App\AppContainer;
|
||||||
|
use MintyPHP\App\Module\Contracts\EventListener;
|
||||||
|
use MintyPHP\App\Module\ModuleEventDispatcher;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ModuleEventDispatcherTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDispatchCallsRegisteredListeners(): void
|
||||||
|
{
|
||||||
|
$called = false;
|
||||||
|
$capturedEvent = '';
|
||||||
|
$capturedPayload = [];
|
||||||
|
|
||||||
|
$listener = new class ($called, $capturedEvent, $capturedPayload) implements EventListener {
|
||||||
|
public function __construct(
|
||||||
|
private bool &$called,
|
||||||
|
private string &$capturedEvent,
|
||||||
|
private array &$capturedPayload
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(string $event, array $payload): void
|
||||||
|
{
|
||||||
|
$this->called = true;
|
||||||
|
$this->capturedEvent = $event;
|
||||||
|
$this->capturedPayload = $payload;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$container = new AppContainer();
|
||||||
|
$container->set($listener::class, static fn () => $listener);
|
||||||
|
|
||||||
|
$dispatcher = new ModuleEventDispatcher(
|
||||||
|
['user.deleted' => [['class' => $listener::class, 'method' => 'handle']]],
|
||||||
|
$container
|
||||||
|
);
|
||||||
|
|
||||||
|
$dispatcher->dispatch('user.deleted', ['user_id' => 42]);
|
||||||
|
|
||||||
|
self::assertTrue($called);
|
||||||
|
self::assertSame('user.deleted', $capturedEvent);
|
||||||
|
self::assertSame(['user_id' => 42], $capturedPayload);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDispatchIsNoOpForUnknownEvent(): void
|
||||||
|
{
|
||||||
|
$container = new AppContainer();
|
||||||
|
$dispatcher = new ModuleEventDispatcher([], $container);
|
||||||
|
|
||||||
|
// Should not throw
|
||||||
|
$dispatcher->dispatch('unknown.event', ['data' => 'test']);
|
||||||
|
|
||||||
|
self::addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDispatchContinuesAfterListenerException(): void
|
||||||
|
{
|
||||||
|
$secondCalled = false;
|
||||||
|
|
||||||
|
$failingListener = new class () implements EventListener {
|
||||||
|
public function handle(string $event, array $payload): void
|
||||||
|
{
|
||||||
|
throw new \RuntimeException('Listener failure');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$successListener = new class ($secondCalled) implements EventListener {
|
||||||
|
public function __construct(private bool &$called)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(string $event, array $payload): void
|
||||||
|
{
|
||||||
|
$this->called = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
$container = new AppContainer();
|
||||||
|
$container->set($failingListener::class, static fn () => $failingListener);
|
||||||
|
$container->set($successListener::class, static fn () => $successListener);
|
||||||
|
|
||||||
|
$dispatcher = new ModuleEventDispatcher(
|
||||||
|
[
|
||||||
|
'user.created' => [
|
||||||
|
['class' => $failingListener::class, 'method' => 'handle'],
|
||||||
|
['class' => $successListener::class, 'method' => 'handle'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$container
|
||||||
|
);
|
||||||
|
|
||||||
|
$dispatcher->dispatch('user.created', ['user_id' => 1]);
|
||||||
|
|
||||||
|
self::assertTrue($secondCalled, 'Second listener should be called even after the first one throws');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -39,22 +39,22 @@ final class SessionProviderTest extends TestCase
|
|||||||
|
|
||||||
public function testPopulateWithInvalidUserClearsSessionKey(): void
|
public function testPopulateWithInvalidUserClearsSessionKey(): void
|
||||||
{
|
{
|
||||||
$_SESSION['available_departments_by_tenant'] = [['tenant' => 'old']];
|
$_SESSION['module.addressbook.departments_by_tenant'] = [['tenant' => 'old']];
|
||||||
|
|
||||||
$provider = new AddressBookSessionProvider();
|
$provider = new AddressBookSessionProvider();
|
||||||
$provider->populate(['id' => 0], $this->emptyContainer());
|
$provider->populate(['id' => 0], $this->emptyContainer());
|
||||||
|
|
||||||
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPopulateWithMissingUserIdClearsSessionKey(): void
|
public function testPopulateWithMissingUserIdClearsSessionKey(): void
|
||||||
{
|
{
|
||||||
$_SESSION['available_departments_by_tenant'] = [['tenant' => 'old']];
|
$_SESSION['module.addressbook.departments_by_tenant'] = [['tenant' => 'old']];
|
||||||
|
|
||||||
$provider = new AddressBookSessionProvider();
|
$provider = new AddressBookSessionProvider();
|
||||||
$provider->populate([], $this->emptyContainer());
|
$provider->populate([], $this->emptyContainer());
|
||||||
|
|
||||||
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPopulateReturnsEarlyWhenContainerMissesDependencies(): void
|
public function testPopulateReturnsEarlyWhenContainerMissesDependencies(): void
|
||||||
@@ -69,19 +69,19 @@ final class SessionProviderTest extends TestCase
|
|||||||
// in production, bindings are always registered before providers run.
|
// in production, bindings are always registered before providers run.
|
||||||
}
|
}
|
||||||
|
|
||||||
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testClearRemovesSessionKey(): void
|
public function testClearRemovesSessionKey(): void
|
||||||
{
|
{
|
||||||
$_SESSION['available_departments_by_tenant'] = [
|
$_SESSION['module.addressbook.departments_by_tenant'] = [
|
||||||
['tenant' => ['uuid' => 'abc'], 'departments' => []],
|
['tenant' => ['uuid' => 'abc'], 'departments' => []],
|
||||||
];
|
];
|
||||||
|
|
||||||
$provider = new AddressBookSessionProvider();
|
$provider = new AddressBookSessionProvider();
|
||||||
$provider->clear();
|
$provider->clear();
|
||||||
|
|
||||||
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testClearIsIdempotent(): void
|
public function testClearIsIdempotent(): void
|
||||||
@@ -90,6 +90,6 @@ final class SessionProviderTest extends TestCase
|
|||||||
$provider = new AddressBookSessionProvider();
|
$provider = new AddressBookSessionProvider();
|
||||||
$provider->clear();
|
$provider->clear();
|
||||||
|
|
||||||
self::assertArrayNotHasKey('available_departments_by_tenant', $_SESSION);
|
self::assertArrayNotHasKey('module.addressbook.departments_by_tenant', $_SESSION);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user