int)`. * The Phase 6c route registration layer (the slice that adds * {@see \Affilun\Presentation\Rest\Routing\EditorServiceProvider}) * 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 — the * registration layer is the sole consumer. * * Permission resolution flow (mirrors * {@see \Affilun\Presentation\Rest\Controller\ProductsController}): * * 1. Each method names a stable route token (one of * {@see self::ROUTE_TOKEN_PREVIEW} / * {@see self::ROUTE_TOKEN_TEMPLATES} / * {@see self::ROUTE_TOKEN_PROVIDERS}). * 2. {@see CapabilityPolicy::requiredForRead()} (or * `requiredForWrite()`) yields the OR-list of required caps. * 3. 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()}. * 4. A failed authorization check short-circuits to a 403 * `affilun_forbidden` error envelope; the §10.x success path * runs otherwise. * * §10.1 logged-in-user requirement: * * - The §10.1 cache-key grammar is * `affilun:render:preview:{userId}:{...}:v1`. A null user id * would render the cache key namespace meaningless and let * anonymous traffic (which should already have been blocked * by the capability check) reach the renderer. The preview * method therefore short-circuits to a 401 * `affilun_unauthorized` envelope when the resolved user id is * null, even when the capability provider returned true for * a falsy reason. * * Exception mapping order in {@see self::mapException()} (mirrors * {@see \Affilun\Presentation\Rest\Controller\ProductsController}): * * 1. {@see ProductNotFoundException} (Application layer) → 404 * `affilun_product_not_found`. * 2. {@see ProviderException} (any §10.2 subclass) → routed * through {@see ProviderExceptionMapper::map()} which yields * the §15.2 envelope and HTTP status pair. * 3. {@see \InvalidArgumentException} → 422 * `affilun_validation_failed` carrying the exception message in * `details.exception`. The §10.1 use case raises this for * `kind === 'grid'` (Phase-7+ deferral) and for any * VO-construction failure the controller-level validator * did not catch. * 4. 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 §10.1 "no internal detail leakage" posture is * preserved. * * Phase-6 deferrals (documented in code, never silently dropped): * * - §10.1 `wp_kses_post` HTML hardening: the controller * forwards the rendered HTML produced by * {@see PreviewProductCardUseCase} verbatim. The Phase-5 * templates already escape every interpolated value via * `esc_html` / `esc_attr` / `esc_url`; the explicit * `wp_kses_post()` wrap is deferred to a later Phase-6e / * Phase-11 hardening slice (see the use case's class-level * docblock for the timing). * * - §10.1 `kind === 'grid'`: the controller passes `kind` * through to the use case, which itself rejects `'grid'` * with an {@see InvalidArgumentException} per the Phase-7+ * deferral. The 422 envelope's `details.exception` carries * the deferral message verbatim so the editor surface can * render an admin-facing "grid preview not yet available" * hint. * * - §10.1 preview cache: not wired in Phase-6 minimum; * `cache.hit` reads `false` unconditionally per * {@see PreviewResult}'s contract. * * @package Affilun\Presentation\Rest\Controller */ declare(strict_types=1); namespace Affilun\Presentation\Rest\Controller; use Affilun\Application\Editor\UseCase\ListAvailableProvidersUseCase; use Affilun\Application\Editor\UseCase\ListAvailableTemplatesUseCase; use Affilun\Application\Editor\UseCase\PreviewProductCardUseCase; use Affilun\Application\Product\Exception\ProductNotFoundException; use Affilun\Domain\Provider\Exception\ProviderException; use Affilun\Presentation\Rest\BlockPreviewRenderer; 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 §10.1-§10.3 editor-helper endpoints. * * See the file-level docblock for the synthetic * {@see self::META_HTTP_STATUS} envelope key, the permission * resolution flow, the §10.1 logged-in-user requirement, the * exception mapping order, and the Phase-6 deferrals. * * ### Nonce verification (§3.1) * * Per spec §3.1, all admin / block-editor REST calls authenticate via * the WordPress logged-in cookie plus the canonical `wp_rest` nonce * header (`X-WP-Nonce`), validated automatically by the WordPress REST * framework. The REST `permission_callback` checks capabilities at * route level, and this controller repeats the same authorization * (§3.2 / §3.3) via the injected `CapabilityPolicy` + capability provider; nonce * remains the WP framework's responsibility. */ final class EditorController { /** * Stable route token for §10.1 `POST /affilun/v1/editor/preview`. */ public const ROUTE_TOKEN_PREVIEW = 'editor.preview'; /** * Stable route token for §10.2 `GET /affilun/v1/editor/templates`. */ public const ROUTE_TOKEN_TEMPLATES = 'editor.templates'; /** * Stable route token for §10.3 `GET /affilun/v1/editor/providers`. */ public const ROUTE_TOKEN_PROVIDERS = 'editor.providers'; /** * Synthetic envelope key carrying the desired HTTP status. * * The Phase 6c route registration layer extracts and strips * this key when stamping the `WP_REST_Response` status; the * key is never serialized onto the wire response. */ public const META_HTTP_STATUS = '_http_status'; /** * Closed-set tokens for the §10.1 `kind` body field. * * The use case rejects `'grid'` itself with an * {@see InvalidArgumentException} (Phase-7+ deferral); the * controller validates membership in the closed set so a * mistyped `'sigle'` produces a §4.4 envelope rather than the * deferral message. * * @var list */ private const PREVIEW_KIND_TOKENS = array( 'single' ); /** * §10.1 preview use case. */ private PreviewProductCardUseCase $previewUseCase; /** * §10.2 templates enumeration use case. */ private ListAvailableTemplatesUseCase $templatesUseCase; /** * §10.3 providers enumeration use case. */ private ListAvailableProvidersUseCase $providersUseCase; /** * Optional preview adapter that delegates HTML generation to the same * block renderers used by WordPress dynamic block rendering. */ private ?BlockPreviewRenderer $blockPreviewRenderer; /** * §15.2 provider exception → error envelope mapper. */ 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 the current WordPress user id. * * Defaults to a static closure that calls * {@see \get_current_user_id()} when available and returns null * otherwise (so unit tests run without booting WordPress). * * @var callable(): ?int */ private $userIdProvider; /** * 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 optional `$userIdProvider` / `$capabilityProvider` * callables follow the established * {@see \Affilun\Presentation\Rest\Controller\ProductsController} * pattern: when null they fall back to WordPress-aware static * closures that gracefully degrade to "anonymous / no caps" * when the WordPress runtime is not loaded (the unit-test * harness). * * @param PreviewProductCardUseCase $previewUseCase §10.1 preview use case. * @param ListAvailableTemplatesUseCase $templatesUseCase §10.2 templates use case. * @param ListAvailableProvidersUseCase $providersUseCase §10.3 providers 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 $userIdProvider `(): ?int` resolver, or null to use the WP default. * @param callable|null $capabilityProvider `(string): bool` resolver, or null for WP default. * @param BlockPreviewRenderer|null $blockPreviewRenderer Canonical block-renderer preview adapter. */ public function __construct( PreviewProductCardUseCase $previewUseCase, ListAvailableTemplatesUseCase $templatesUseCase, ListAvailableProvidersUseCase $providersUseCase, ProviderExceptionMapper $exceptionMapper, CapabilityPolicy $capabilityPolicy, RequestValidator $requestValidator, RestRequestAdapter $requestAdapter, ?callable $userIdProvider = null, ?callable $capabilityProvider = null, ?BlockPreviewRenderer $blockPreviewRenderer = null ) { $this->previewUseCase = $previewUseCase; $this->templatesUseCase = $templatesUseCase; $this->providersUseCase = $providersUseCase; $this->blockPreviewRenderer = $blockPreviewRenderer; $this->exceptionMapper = $exceptionMapper; $this->capabilityPolicy = $capabilityPolicy; $this->requestValidator = $requestValidator; $this->requestAdapter = $requestAdapter; $this->userIdProvider = $userIdProvider ?? static function (): ?int { if ( ! function_exists( 'get_current_user_id' ) ) { return null; } $id = (int) get_current_user_id(); return $id !== 0 ? $id : null; }; $this->capabilityProvider = $capabilityProvider ?? static function ( string $cap ): bool { if ( ! function_exists( 'current_user_can' ) ) { return false; } return (bool) current_user_can( $cap ); }; } /** * §10.1 `POST /affilun/v1/editor/preview` dispatch. * * Validates `kind` / `productIds` / `template` / `attrs`, * resolves the current user id (preview requires a logged-in * user per §10.1 cache-key composition), drives the canonical * block-preview renderer when available (falling back to * {@see PreviewProductCardUseCase}), and renders the §10.1 * success envelope. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §10.1 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function preview( mixed $request ): array { $required = $this->capabilityPolicy::requiredForWrite( self::ROUTE_TOKEN_PREVIEW ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } $body = $this->requestAdapter->jsonBody( $request ); $kind = array_key_exists( 'kind', $body ) ? $body['kind'] : null; $productIds = array_key_exists( 'productIds', $body ) ? $body['productIds'] : null; $template = array_key_exists( 'template', $body ) ? $body['template'] : null; $attrsInput = array_key_exists( 'attrs', $body ) ? $body['attrs'] : null; $attrsIsMap = $attrsInput === null || ( is_array( $attrsInput ) && ( $attrsInput === array() || ! array_is_list( $attrsInput ) ) ); $result = ValidationResult::valid(); $result = $this->requestValidator->requireOneOf( $result, 'kind', $kind, self::PREVIEW_KIND_TOKENS ); $result = $this->requestValidator->requireArray( $result, 'product_ids', $productIds, 24 ); if ( is_array( $productIds ) && array_is_list( $productIds ) ) { if ( $productIds === array() ) { $result = $result->withField( 'product_ids', 'Must contain at least one entry.' ); } else { foreach ( $productIds as $entry ) { if ( ! is_string( $entry ) || trim( $entry ) === '' ) { $result = $result->withField( 'product_ids', 'Every entry must be a non-empty string.' ); break; } } } } $result = $this->requestValidator->requireString( $result, 'template', $template, 1, 128 ); if ( ! $attrsIsMap ) { $result = $result->withField( 'attrs', 'Must be an object.' ); } if ( ! $result->isValid() ) { return $this->validationErrorResponse( $result, $ctx ); } $userId = $this->currentUserId(); if ( $userId === null ) { return $this->errorResponse( ErrorEnvelope::CODE_UNAUTHORIZED, 'Authentication required.', ErrorEnvelope::STATUS_UNAUTHORIZED, $ctx ); } // Validation guarantees these shapes; refine for static analysis. /** @var list $productIdsValue */ $productIdsValue = is_array( $productIds ) ? array_values( $productIds ) : array(); $kindValue = (string) $kind; $templateValue = (string) $template; /** @var array $attrsValue */ $attrsValue = is_array( $attrsInput ) ? $attrsInput : array(); try { $previewResult = $this->blockPreviewRenderer !== null ? $this->blockPreviewRenderer->execute( $kindValue, $productIdsValue, $templateValue, $attrsValue, $userId ) : $this->previewUseCase->execute( $kindValue, $productIdsValue, $templateValue, $attrsValue, $userId ); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $envelope = SuccessEnvelope::fromContext( $previewResult->toArray(), $ctx ); return $this->successResponse( $envelope, 200 ); } /** * §10.2 `GET /affilun/v1/editor/templates` dispatch. * * No request body, no validation. Drives * {@see ListAvailableTemplatesUseCase} and renders the §10.2 * `data.items` envelope. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §10.2 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function templates( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_TEMPLATES ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } try { $items = $this->templatesUseCase->execute(); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $envelope = SuccessEnvelope::fromContext( array( 'items' => $items ), $ctx ); return $this->successResponse( $envelope, 200 ); } /** * §10.3 `GET /affilun/v1/editor/providers` dispatch. * * No request body, no validation. Drives * {@see ListAvailableProvidersUseCase} and renders the §10.3 * `data.items` envelope. * * @param mixed $request WP_REST_Request, or array shim. * * @return array §10.3 envelope + synthetic * {@see self::META_HTTP_STATUS}. */ public function providers( mixed $request ): array { $required = $this->capabilityPolicy::requiredForRead( self::ROUTE_TOKEN_PROVIDERS ); $ctx = $this->buildContext( $request, $required ); if ( ! $this->isAuthorized( $ctx, $required ) ) { return $this->errorResponse( ErrorEnvelope::CODE_FORBIDDEN, 'Insufficient capabilities.', ErrorEnvelope::STATUS_FORBIDDEN, $ctx ); } try { $items = $this->providersUseCase->execute(); } catch ( Throwable $e ) { return $this->mapException( $e, $ctx ); } $envelope = SuccessEnvelope::fromContext( array( 'items' => $items ), $ctx ); return $this->successResponse( $envelope, 200 ); } /** * Build the per-request {@see RequestContext} carrying the * resolved user id and the currently-held subset of the * supplied required-capability list. * * The caps map is populated by probing each candidate cap via * the injected `$capabilityProvider`; only caps the provider * confirms are entered into the map. {@see CapabilityPolicy::isAuthorized()} * therefore behaves correctly against the resulting context. * * @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 { $userId = $this->currentUserId(); $capResolver = $this->capabilityProvider; $caps = array(); foreach ( $candidateCaps as $cap ) { if ( $capResolver( $cap ) === true ) { $caps[ $cap ] = 'true'; } } return $this->requestAdapter->buildRequestContext( $request, $userId, $caps ); } /** * 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 ); } /** * 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 ); } /** * Translate an arbitrary {@see Throwable} into a §4.3 envelope * + synthetic {@see self::META_HTTP_STATUS}. * * Mapping order (mirrors * {@see \Affilun\Presentation\Rest\Controller\ProductsController}): * * 1. {@see ProductNotFoundException} (Application layer) → 404. * 2. {@see ProviderException} → routed through * {@see ProviderExceptionMapper}. * 3. {@see InvalidArgumentException} → 422 with * `details.exception = $e->getMessage()`. * 4. Other {@see Throwable} → 500 `affilun_internal_error` with * the literal message `'Internal server error.'`. * * @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 ProductNotFoundException ) { return $this->errorResponse( ErrorEnvelope::CODE_PRODUCT_NOT_FOUND, $e->getMessage() !== '' ? $e->getMessage() : 'Product not found.', ErrorEnvelope::STATUS_NOT_FOUND, $ctx ); } 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 ); } /** * Resolve the current user id via the injected * `$userIdProvider`, defensively coercing non-int returns to * null so downstream code can rely on the `?int` shape. */ private function currentUserId(): ?int { $resolver = $this->userIdProvider; $userId = $resolver(); if ( $userId === null ) { return null; } if ( ! is_int( $userId ) ) { return null; } return $userId; } /** * 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 ); } }