int)`. The * {@see \Affilun\Presentation\Rest\AuditServiceProvider} * route-callback wrapper extracts and strips the * {@see self::META_HTTP_STATUS} key when stamping the * `WP_REST_Response` HTTP status; the key is never serialized * onto the wire response. * * Permission resolution flow (mirrors the canonical * {@see \Affilun\Presentation\Rest\Controller\LogsController} * pattern): * * 1. The endpoint uses the §3.2 `'audit.read'` route token, * resolved by {@see CapabilityPolicy::requiredForRead()} to * the OR-list `affilun_view_diagnostics` / `manage_options`. * `affilun_view_diagnostics` is reused here rather than minting a * dedicated `affilun_view_audit` capability so the §12.4 surface * stays inside the existing diagnostics-tier authorization * boundary; a future slice that needs to split the two * audiences can introduce a dedicated capability without * changing this controller. * 2. The controller probes each required cap via the injected * `$capabilityProvider` callable, populates a * {@see RequestContext} caps map with the held tokens, and * delegates to {@see CapabilityPolicy::isAuthorized()}. * 3. A failed authorization check short-circuits to a 403 * `affilun_forbidden` error envelope; the §12.4 success path * runs otherwise. * * Exception mapping order in {@see self::mapException()}: * * 1. {@see \InvalidArgumentException} → 422 `affilun_validation_failed` * carrying the exception message in `details.exception`. The * §12.4 query-param parsing path raises this (via * {@see AuditEventListQuery}) when `audit_kind` is * off-whitelist or when the pagination axes are out of range. * 2. Catch-all {@see \Throwable} → 500 `affilun_internal_error` with * the literal message `'Internal server error.'`. The * original exception's class and message are NOT forwarded so * the "no internal detail leakage" posture is preserved. * * @package Affilun\Presentation\Rest\Controller */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Controller; use Affilun\Application\Audit\UseCase\GetAuditEventsSummaryUseCase; use Affilun\Application\Audit\UseCase\ListAuditEventsUseCase; use Affilun\Domain\Provider\Exception\ProviderException; use Affilun\Infrastructure\Persistence\AuditEventListQuery; use Affilun\Presentation\Rest\Permission\CapabilityPolicy; use Affilun\Presentation\Rest\RequestContext; use Affilun\Presentation\Rest\Response\ErrorEnvelope; use Affilun\Presentation\Rest\Response\ProviderExceptionMapper; use Affilun\Presentation\Rest\Response\SuccessEnvelope; use Affilun\Presentation\Rest\Validation\RequestValidator; use Affilun\Presentation\Rest\Validation\ValidationResult; use InvalidArgumentException; use Throwable; /** * REST controller for the §12.4 audit-events endpoint. * * See the file-level docblock for the synthetic * {@see self::META_HTTP_STATUS} envelope key, the permission * resolution flow, and the exception mapping order. */ final class AuditController { /** * Stable route token for §12.4 `GET /affilun/v1/audit/events`. * Mapped by {@see CapabilityPolicy} to the §3.2 `audit.read` * read-side OR-list (`affilun_view_diagnostics` / * `manage_options`). */ public const ROUTE_TOKEN_LIST = 'audit.read'; /** * Stable route token for §12.5 `GET /affilun/v1/audit/events/summary`. * Mapped by {@see CapabilityPolicy} to the same `audit.summary` * read-side OR-list (`affilun_view_diagnostics` / * `manage_options`) the §12.4 list surface uses. */ public const ROUTE_TOKEN_SUMMARY = 'audit.summary'; /** * Synthetic envelope key carrying the desired HTTP status. */ public const META_HTTP_STATUS = '_http_status'; /** * §12.4 list use case. */ private ListAuditEventsUseCase $listUseCase; /** * §12.5 summary use case. */ private ?GetAuditEventsSummaryUseCase $summaryUseCase; /** * §15.2 provider exception → error envelope mapper. Held even * though §12.4 does not raise provider exceptions itself, so * the constructor matches the canonical * {@see \Affilun\Presentation\Rest\Controller\LogsController} * shape. */ private ProviderExceptionMapper $exceptionMapper; /** * §3.2 / §3.3 capability policy. */ private CapabilityPolicy $capabilityPolicy; /** * §17.1 request input validator. */ private RequestValidator $requestValidator; /** * §3 request adapter (header / param / body extraction). */ private RestRequestAdapter $requestAdapter; /** * Resolver for whether the current user holds a given capability. * * Defaults to a static closure that calls * {@see \current_user_can()} when available and returns false * otherwise (so unit tests run without booting WordPress). * * @var callable(string): bool */ private $capabilityProvider; /** * Construct the controller with explicit dependency injection. * * The §12.5 summary use case is optional so callers that only * exercise the §12.4 list surface (the historical * pre-summary-endpoint test suite) keep working without * modification. When null, {@see self::summary()} returns * a §4.3 `affilun_not_found` envelope so a misconfigured * deployment that registered the summary route without * binding the use case fails closed rather than silently * returning an empty payload. * * @param ListAuditEventsUseCase $listUseCase §12.4 use case. * @param ProviderExceptionMapper $exceptionMapper §15.2 mapper. * @param CapabilityPolicy $capabilityPolicy §3.2 / §3.3 policy. * @param RequestValidator $requestValidator §17.1 validator. * @param RestRequestAdapter $requestAdapter §3 request adapter. * @param callable|null $capabilityProvider `(string): bool` resolver, or null for WP default. * @param GetAuditEventsSummaryUseCase|null $summaryUseCase Optional §12.5 use case. */ public function __construct( ListAuditEventsUseCase $listUseCase, ProviderExceptionMapper $exceptionMapper, CapabilityPolicy $capabilityPolicy, RequestValidator $requestValidator, RestRequestAdapter $requestAdapter, ?callable $capabilityProvider = null, ?GetAuditEventsSummaryUseCase $summaryUseCase = null ) { $this->listUseCase = $listUseCase; $this->exceptionMapper = $exceptionMapper; $this->capabilityPolicy = $capabilityPolicy; $this->requestValidator = $requestValidator; $this->requestAdapter = $requestAdapter; $this->summaryUseCase = $summaryUseCase; $this->capabilityProvider = $capabilityProvider ?? static function ( string $cap ): bool { if ( ! function_exists( 'current_user_can' ) ) { return false; } return (bool) current_user_can( $cap ); }; } /** * §12.5 `GET /affilun/v1/audit/events/summary` dispatch. * * Reads the optional `recent_limit` query parameter * (defaulting to {@see GetAuditEventsSummaryUseCase::DEFAULT_RECENT_LIMIT}), * validates the capability OR-list and the bound, drives the * §12.5 use case, and renders the success envelope with * HTTP 200. * * Failure modes: * * - Capability denial → §4.3 `affilun_forbidden` envelope, 403. * - `recent_limit` outside `[1, MAX_RECENT_LIMIT]` → * §4.4 validation envelope, 422. * - Summary use case not bound (misconfigured deployment) → * §4.3 `affilun_not_found` envelope, 404. Fail-closed posture * so a half-wired bootstrap surfaces loudly. * - Other {@see Throwable} → routed through * {@see self::mapException()} for symmetry with the §12.4 * list path. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §12.5 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function summary( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_SUMMARY ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } if ( $this->summaryUseCase === null ) { return $this->errorResponse( 'affilun_endpoint_not_available', 'Audit summary surface is not available in this deployment.', ErrorEnvelope::STATUS_NOT_FOUND, $ctx ); } $recentLimit = $this->requestAdapter->paramInt( $request, 'recent_limit', GetAuditEventsSummaryUseCase::DEFAULT_RECENT_LIMIT ); $result = ValidationResult::valid(); $result = $this->requestValidator->requireInt( $result, 'recent_limit', $recentLimit, 1, GetAuditEventsSummaryUseCase::MAX_RECENT_LIMIT ); if ( ! $result->isValid() ) { return $this->validationErrorResponse( $result, $ctx ); } try { $summaryResult = $this->summaryUseCase->execute( (int) $recentLimit ); } catch ( InvalidArgumentException $e ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'Validation failed.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx, array( 'exception' => $e->getMessage() ) ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $envelope = SuccessEnvelope::fromContext( $summaryResult->toArray(), $ctx ); return $this->successResponse( $envelope, 200 ); } /** * §12.4 `GET /affilun/v1/audit/events` dispatch. * * Reads the column-level filters (`audit_kind`, `actor_id`, * `target_type`, `target_id`) plus the pagination axes * (`page`, `per_page`), validates them, builds an * {@see AuditEventListQuery}, and drives * {@see ListAuditEventsUseCase}. Renders the §12.4 success * envelope with HTTP 200. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §12.4 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function list( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_LIST ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } $auditKind = $this->requestAdapter->paramString( $request, 'audit_kind' ); $actorId = $this->requestAdapter->paramString( $request, 'actor_id' ); $targetType = $this->requestAdapter->paramString( $request, 'target_type' ); $targetId = $this->requestAdapter->paramString( $request, 'target_id' ); $requestId = $this->requestAdapter->paramString( $request, 'request_id' ); $correlationId = $this->requestAdapter->paramString( $request, 'correlation_id' ); $jobId = $this->requestAdapter->paramString( $request, 'job_id' ); $page = $this->requestAdapter->paramInt( $request, 'page', 1 ); $perPage = $this->requestAdapter->paramInt( $request, 'per_page', AuditEventListQuery::DEFAULT_PER_PAGE ); $result = ValidationResult::valid(); if ( $auditKind !== null && ! in_array( $auditKind, AuditEventListQuery::VALID_AUDIT_KINDS, true ) ) { $result = $result->withField( 'audit_kind', sprintf( 'Must be one of: %s.', implode( ', ', AuditEventListQuery::VALID_AUDIT_KINDS ) ) ); } $result = $this->requestValidator->requireInt( $result, 'page', $page, 1, PHP_INT_MAX ); $result = $this->requestValidator->requireInt( $result, 'per_page', $perPage, 1, AuditEventListQuery::MAX_PER_PAGE ); if ( ! $result->isValid() ) { return $this->validationErrorResponse( $result, $ctx ); } try { $query = new AuditEventListQuery( $auditKind, $actorId, $targetType, $targetId, (int) $page, (int) $perPage, $requestId, $correlationId, $jobId ); } catch ( InvalidArgumentException $e ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'Validation failed.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx, array( 'exception' => $e->getMessage() ) ); } try { $listResult = $this->listUseCase->execute( $query ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $envelope = SuccessEnvelope::fromContext( $listResult->toArray(), $ctx ); return $this->successResponse( $envelope, 200 ); } /** * Build the per-request {@see RequestContext} carrying the * currently-held subset of the supplied required-capability * list. * * @param mixed $request WP_REST_Request, or array shim. * @param list $candidateCaps Caps to probe and populate. */ private function buildContext( mixed $request, array $candidateCaps ): RequestContext { $capResolver = $this->capabilityProvider; $caps = array(); foreach ( $candidateCaps as $cap ) { if ( $capResolver( $cap ) === true ) { $caps[ $cap ] = 'true'; } } return $this->requestAdapter->buildRequestContext( $request, null, $caps ); } /** * Wrap a {@see SuccessEnvelope} into the response array shape * the route registration layer consumes. * * @param SuccessEnvelope $envelope Source envelope. * @param int $status §14.1 HTTP status to stamp. * * @return array */ private function successResponse( SuccessEnvelope $envelope, int $status ): array { return $envelope->toArray() + array( self::META_HTTP_STATUS => $status ); } /** * Build a §4.3 error envelope + synthetic * {@see self::META_HTTP_STATUS}. * * @param string $code §15.1 code. * @param string $message Human-readable summary. * @param int $status §14.1 HTTP status. * @param RequestContext $ctx Per-request meta. * @param array $details Structured diagnostic context. * @param bool $retryable §10.2 retryability hint. * * @return array */ private function errorResponse( string $code, string $message, int $status, RequestContext $ctx, array $details = array(), bool $retryable = false ): array { $envelope = ErrorEnvelope::fromContext( $code, $message, $status, $ctx, $details, $retryable ); return $envelope->toArray() + array( self::META_HTTP_STATUS => $status ); } /** * Build a §4.4 validation error envelope + synthetic * {@see self::META_HTTP_STATUS} pinned to 422. * * @param ValidationResult $result Accumulator carrying the * per-field errors. * @param RequestContext $ctx Per-request meta. * * @return array */ private function validationErrorResponse( ValidationResult $result, RequestContext $ctx ): array { $envelope = $result->toEnvelope( $ctx ); return $envelope->toArray() + array( self::META_HTTP_STATUS => ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY ); } /** * Translate an arbitrary {@see Throwable} into a §4.3 envelope * + synthetic {@see self::META_HTTP_STATUS}. * * Mapping order: * * 1. {@see ProviderException} → routed through * {@see ProviderExceptionMapper::map()} for symmetry with * the canonical * {@see \Affilun\Presentation\Rest\Controller\LogsController} * shape. * 2. {@see InvalidArgumentException} → 422 with * `details.exception = $e->getMessage()`. * 3. Other {@see Throwable} → 500 `affilun_internal_error` with * the literal message `'Internal server error.'`. * * The {@see ProviderExceptionMapper} dependency is held for * symmetry with the canonical * {@see \Affilun\Presentation\Rest\Controller\LogsController} * shape; §12.4 itself is unlikely to raise provider exceptions * but future slices may. * * @param Throwable $e Exception to translate. * @param RequestContext $ctx Per-request meta. * * @return array */ private function mapException( Throwable $e, RequestContext $ctx ): array { if ( $e instanceof ProviderException ) { $envelope = $this->exceptionMapper->map( $e, $ctx ); return $envelope->toArray() + array( self::META_HTTP_STATUS => $envelope->status() ); } if ( $e instanceof InvalidArgumentException ) { return $this->errorResponse( ErrorEnvelope::CODE_VALIDATION_FAILED, 'Validation failed.', ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY, $ctx, array( 'exception' => $e->getMessage() ) ); } return $this->errorResponse( ErrorEnvelope::CODE_INTERNAL_ERROR, 'Internal server error.', ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR, $ctx ); } /** * Report whether the supplied {@see RequestContext} satisfies * the given OR-list of required capabilities, delegating to * {@see CapabilityPolicy::isAuthorized()}. * * @param RequestContext $ctx Per-request meta. * @param list $required OR-list of capability tokens. */ private function isAuthorized( RequestContext $ctx, array $required ): bool { return $this->capabilityPolicy::isAuthorized( $ctx, $required ); } }